Compare commits

..
73 Commits
Author SHA1 Message Date
vii b59c070cc0 docs(changelog): release 0.3.4
Report Nightcap's own version instead of Mocktail's.
2026-10-10 21:27:00 -05:00
vii 4a9acf8c49 feat(site): redesign the landing page and legal pages 2026-10-10 21:07:33 -05:00
vii c342bab7b5 docs(changelog): note the voice chat fix after a Roblox update 2026-10-10 00:33:00 -05:00
vii a3b9c872a5 fix(update): derive the FMOD bridge from the shipped reference when the installed profile lacks it 2026-10-10 00:33:00 -05:00
vii 4f45baf3f7 docs(changelog): note the anti-aliasing and voice chat microphone fixes 2026-10-10 00:09:42 -05:00
vii 0ddb546ba8 chore(merge): merge upstream komaruworld/mocktail main 2026-10-10 00:09:42 -05:00
vii 7ba752ebb7 docs(changelog): bring the unreleased section up to date 2026-10-09 23:57:41 -05:00
vii 227895faa4 feat(window): use Wayland on NVIDIA Wayland sessions
The X11/XWayland fallback guarded against the explicit-sync disconnect
that is fixed, so the rule and its driver detection are removed.
2026-10-09 23:57:41 -05:00
vii dfc3c8d555 docs(changelog): note the Roblox 2.742 update and new interface fixes 2026-10-09 23:48:55 -05:00
vii e490dff97f feat(runtime): show the desktop side panel menu by default 2026-10-09 23:48:55 -05:00
vii b5b9990017 fix(services): let the BrowserTracker bootstrap use Roblox's storage directory
Tighten a group-writable directory this user owns, reject a storage file
this user cannot vouch for, and treat a stored ID of "0" as unset.
2026-10-09 23:48:55 -05:00
vii d278c0fe0a fix(runtime): run nativeAppBridgeAppStart by default
It starts Roblox's StartupController, which fetches the account's
flag-linked experiments and writes ClientSettings/IxpSettings.json.
2026-10-09 23:48:55 -05:00
vii 044f97e476 fix(update): accept payloads that reorder the leading constructors
Roblox 2.742 moves the process-load constructor behind the JNI singleton
initializer and renames registers in the registry initializer. The deriver
finds each leading constructor by its code, and the loader checks that host
bridges run the checkpoint and JNI singleton constructors and skip the two
wiring entries.
2026-10-09 23:48:55 -05:00
vii 321ba5b3a2 test(flatpak): keep a host flatpak-builder out of the missing-builder check 2026-10-09 14:19:01 -05:00
vii fd364a7bf2 docs: wrap the changelog at 110 columns and drop the glyphs roadmap item
Add a pre-commit check for added changelog lines over 110 characters. Roblox has no Nintendo glyph set, so the roadmap item cannot be met.
2026-10-09 13:50:46 -05:00
vii 5253563e3f fix(appimage): restore a distribution package when its reduced build is too old
A reduced gtk4 older than the one libadwaita requires failed the nightly configure step.
2026-10-09 13:49:38 -05:00
komaruworld 551d2a1829 Probably a vc fix 2026-10-09 15:49:35 +03:00
farwhile 37b88103f4 fix(runtime): stop forcing single-sample MSAA (#185)
* fix(runtime): stop forcing single-sample MSAA

* test removed

* Fix race in webview helper launcher test
2026-10-09 08:33:32 +03:00
vii 763faed0e3 fix(graphics): pick the Vulkan driver that matches the bound kernel driver
Skip a GPU whose kernel driver has no installed Vulkan driver and try the next one. Log why when none can run Vulkan.
2026-10-08 18:41:36 -05:00
komaruworld 74eedada0b improve evilbsd support
Netbsd hi
2026-10-09 00:45:05 +03:00
vii 6cb3dd00a9 fix(compat): bound Bionic strerror_r by errno table and gate atoi shim to non-glibc
strerror_r: Bionic's message table covers errno 0..133 except the
unassigned 41 and 58. Numbers outside that set now get "Unknown error N"
without consulting the host, so the text is the same on glibc and musl and
no longer relies on comparing against strerror_r(-1).

atoi: glibc's atoi is (int)strtol() like Bionic's, so the host symbol is
used there as before. The Bionic shim is registered only on libcs where
that is not true (musl). linker_registry_test expects the matching result.
2026-10-08 16:10:03 -05:00
vii fe2f641a9e chore(merge): merge upstream komaruworld/mocktail main
Brings in 3e6f63e (drop libexecinfo), aa31cdf (Chimera/musl portability),
bc10810 (Bionic atoi, canary signal exit codes) and cb2e9dd (CI fixes).

Conflicts:
- tests/native_update_test.cc: keep both <cerrno> and <csignal>.
- tests/webview_helper_launcher_test.cc: keep the existing python3 socket
  read; it already solves the dash multi-digit fd redirection problem that
  upstream fixed by switching the helper to bash.
2026-10-08 16:10:03 -05:00
komaruworld cb2e9dd598 Small ci fix 2026-10-08 21:12:29 +03:00
komaruworld bc108106db mog chimera 2026-10-08 18:14:23 +03:00
komaruworld aa31cdf395 Improve chimera support 2026-10-08 16:59:11 +03:00
komaruworld 3e6f63ea93 remove libexecinfo dependency 2026-10-08 16:13:41 +03:00
vii bfa6811417 docs(changelog): note the crash and hang fixes 2026-10-07 14:22:39 -05:00
vii 84502c0a1a fix(window): show the hidden window from the main thread
The render thread called SDL_ShowWindow outside the surface commit gate. It now sets a flag, and PumpEvents shows the window inside the gate.
2026-10-07 14:20:16 -05:00
vii 46d1d5ffbe fix(graphics): retry swapchain creation with the game's present mode
A driver can reject a present mode the device did not enable. Retry once with the mode the game asked for, except after errors another mode cannot fix.
2026-10-07 14:20:16 -05:00
vii 545fd8344f fix(session-log): stop waiting for the log writer after 2 seconds
A child that kept the log pipes open made Stop wait forever. Poll for the writer, then SIGKILL it after 2 seconds.
2026-10-07 14:20:16 -05:00
vii 4c40fcdc99 fix(jnivm): stub missing JNI functions and ignore repeat releases
Fill the 65 unimplemented JNI table slots with stubs that log once and return zero, so a call no longer jumps to address 0. A release of a handle with no reference count now returns, so a double release cannot queue one slot twice and give two objects the same handle.
2026-10-07 14:20:16 -05:00
vii d4c7a6f741 fix(app-shell): answer LAUNCH_CONVERSATION so chat requests open 2026-10-07 13:04:31 -05:00
vii 491925f59e test(graphics): register texture_override_test with ctest 2026-10-07 12:01:14 -05:00
vii 2917bc5423 chore(merge): merge upstream komaruworld/mocktail main 2026-10-07 12:01:14 -05:00
komaruworld 2451264696 ETC2 texture fixes
Refs #191
2026-10-07 19:02:35 +03:00
komaruworld a9a96c6861 Add zsync file
fixes #182
2026-10-07 17:35:33 +03:00
vii 5bf4eeb41b fix(window): keep main-thread surface commits out of host present
With explicit sync, a wl_surface commit from the SDL thread can land
between the driver's sync points and its buffer attach. The compositor
answers with a no_buffer protocol error and disconnects the client.
2026-10-06 19:24:27 -05:00
vii d937dd7ea6 fix(flatpak): ask for the X11 socket only as a fallback 2026-10-04 21:08:23 -05:00
vii 169e65bf60 docs(changelog): release 0.3.3 2026-10-04 20:44:33 -05:00
vii 69476f0f47 docs(changelog): add theme crash and Flatpak NVIDIA fixes 2026-10-04 20:14:25 -05:00
vii 79d0cdfb40 chore(merge): merge upstream komaruworld/mocktail main
Keep our captured-pointer handling in place of upstream's relative_mode
version. Keep upstream's motion coalescing guard on our relative flag.
2026-10-04 20:14:13 -05:00
komaruworld 273209b9ea Fix angle platform selection 2026-10-03 21:19:26 +03:00
komaruworld bbfd603e17 Fix captured mouse input
Refs; #135
2026-10-03 14:28:12 +03:00
komaruworld 1ba2941e7f Fix theme crash
Fixes #144
2026-10-03 14:03:25 +03:00
komaruworld 68c90ce428 flatpak nvidia xwayland fallback
#186
2026-10-03 13:48:39 +03:00
vii 068f07e125 chore(merge): merge upstream komaruworld/mocktail main 2026-10-02 20:02:32 -05:00
vii a469689bbb fix(webview): use GTK's software renderer when the helper composites in software
GTK's GL renderer aborted in libepoxy while snapshotting a page switch, which closed the web view. GSK_RENDERER set by the user still wins.
2026-10-02 20:02:32 -05:00
vii 51836a980d docs(changelog): add follow-user, Nix desktop entry, text focus and sky fixes 2026-10-02 20:02:32 -05:00
vii ad546a7781 Merge pull request #16 from kimlherme/docs/recovery-marker-cleanup
docs: document leftover recovery marker cleanup
2026-10-02 19:37:49 -05:00
kimlherme 3c45450dfc docs: document recovery marker cleanup 2026-10-02 20:21:34 -03:00
vii 185c11b57f test(update): expect a missing abi to count only on x86_64 hosts 2026-10-02 12:52:16 -05:00
vii 2c3d7b485e test(update): declare the guest ABI in test catalogs so aarch64 matches
Profiles without abi default to x86_64, so every payload store test skipped its catalog on arm64.
2026-10-02 12:44:47 -05:00
komaruworld 6f8344a28b Sky fixes 2026-10-02 16:52:27 +03:00
vii b60c43e8f0 chore(merge): merge upstream komaruworld/mocktail main
Keep both the CallProtocol and AccountProtocol bridges. Keep our pointer
hold and catalog validation. Re-apply our JNI local-frame counting,
singleton handles, class handle cache, handle-table report and thread
detach release on upstream's split jnivm files.
2026-10-01 21:41:43 -05:00
vii b5e4273277 docs(changelog): add updater, audio and platform fixes 2026-10-01 19:26:21 -05:00
vii d2858fedc8 Merge pull request #13 from kimlherme/fix/payload-update-correctness
fix(update): preserve rollback state and validate payload activation
2026-10-01 19:25:58 -05:00
vii 326a0659ea Merge pull request #15 from kimlherme/fix/platform-lifecycle-compatibility
fix(platform): correct Vulkan teardown, pointer capture and DNS flags
2026-10-01 19:25:51 -05:00
vii dd45467056 Merge pull request #14 from kimlherme/fix/audio-jni-ownership
fix(runtime): preserve OpenSL tickets and JNI array references
2026-10-01 19:25:40 -05:00
vii 94b1036296 docs(changelog): add ETC2 decode speedup 2026-10-01 19:14:37 -05:00
vii 4c61daad0c Merge pull request #10 from glook9001/simd-etc2-decoder
perf(graphics): SIMD PSHUFB ETC2/EAC decode with bit-exact scalar verification
2026-10-01 19:14:33 -05:00
kimlherme f5f224875a fix(platform): correct Vulkan teardown, pointer capture and DNS flags 2026-10-01 21:05:31 -03:00
kimlherme 90d4891a4e fix(update): preserve rollback state and validate payload activation 2026-10-01 21:05:30 -03:00
kimlherme 46dd99266c fix(runtime): preserve OpenSL tickets and JNI array references 2026-10-01 21:05:30 -03:00
77fce18a95 po co chodzic, mozna latac (#183)
* po co chodzic, mozna latac

* Fix jni ownership, split helpers

---------

Co-authored-by: Mia <uwie@thinkwise.ro>
Co-authored-by: komaruworld <rtysus228@gmail.com>
2026-10-01 20:31:52 +03:00
komaruworld d6f0f2c755 Fix some vc
Fokora
2026-10-01 18:03:02 +03:00
glook9001 c6afc5af24 perf(graphics): eliminate crop page faults with strided resampling and streaming WC loads 2026-10-01 12:08:54 +03:00
glook9001 74e7455ee1 perf(graphics): SIMD PSHUFB ETC2/EAC decode with bit-exact scalar verification 2026-10-01 12:08:35 +03:00
vii 000d021452 docs(changelog): add payload import fix 2026-09-30 21:34:06 -05:00
vii 2eef2c3821 Merge pull request #11 from kimlherme/fix/payload-import-rollback
fix(payload): preserve the previous payload on failed imports
2026-09-30 21:33:43 -05:00
Guilhermepycharm 4928443f90 fix(payload): preserve previous files across failed imports
Enable rollback before the first backup move, preserve originals during incomplete backups, and retain recovery files if restoration fails. Add offline full-importer regression coverage and register it with CTest.
2026-09-30 22:54:33 -03:00
native b37497db7b fix text overlay again (#179) 2026-09-30 17:55:30 +03:00
Lupey 6bbdc39d7e feat(nix): add desktop application (#177) 2026-09-30 17:48:05 +03:00
komaruworld 1c11336ba1 Fix text issues
bla bla bla
2026-09-30 16:56:48 +03:00
141 changed files with 11464 additions and 2741 deletions

No files matched your search

+13 -2
View File
@@ -104,14 +104,25 @@ jobs:
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
libplacebo-dev libpng-dev zlib1g-dev \
xvfb xauth libgl1-mesa-dri jq patchelf
- 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
-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
cmake --build build-ci --parallel 4
./build-ci/mocktail --help
./build-ci/mocktail_updater status
- name: Run C++ and integration tests
shell: bash
env:
SDL_AUDIODRIVER: dummy
SDL_VIDEODRIVER: x11
LIBGL_ALWAYS_SOFTWARE: '1'
run: |
xvfb-run -a ctest --test-dir build-ci --output-on-failure \
--parallel 4 --timeout 120
+18 -10
View File
@@ -44,6 +44,8 @@ jobs:
if: needs.probe.outputs.build == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
UPINFO: gh-releases-zsync|${{ github.repository_owner }}|${{ github.event.repository.name }}|continuous|Nightcap-nightly-x86_64.AppImage.zsync
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -60,6 +62,7 @@ jobs:
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
--env GH_TOKEN \
--env UPINFO \
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
pacman -Syu --noconfirm --needed \
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
@@ -123,11 +126,12 @@ jobs:
export RUNTIME=/tmp/uruntime
export DWARFS_CMD=/tmp/mkdwarfs
make appimage BUILD_DIR=build-appimage JOBS=4 \
APPIMAGE=dist/Mocktail-x86_64.AppImage \
APPIMAGE=dist/Nightcap-nightly-x86_64.AppImage \
PORTABLE_MODE=standalone APPIMAGE_FORMAT=anylinux \
ANYLINUX_PACKAGER=/tmp/quick-sharun \
ANYLINUX_APPIMAGETOOL=/tmp/appimagetool
test -x dist/Mocktail-x86_64.AppImage
test -x dist/Nightcap-nightly-x86_64.AppImage
test -s dist/Nightcap-nightly-x86_64.AppImage.zsync
'
- name: Smoke-test AppImage without FUSE
@@ -140,14 +144,17 @@ jobs:
export XDG_CACHE_HOME="$smoke_dir/cache"
export XDG_STATE_HOME="$smoke_dir/state"
mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME"
timeout 60s dist/Mocktail-x86_64.AppImage --appimage-extract-and-run --help
timeout 60s dist/Mocktail-x86_64.AppImage --appimage-extract-and-run mocktail_updater status
test "$(dist/Nightcap-nightly-x86_64.AppImage --appimage-updateinformation)" = "$UPINFO"
timeout 60s dist/Nightcap-nightly-x86_64.AppImage --appimage-extract-and-run --help
timeout 60s dist/Nightcap-nightly-x86_64.AppImage --appimage-extract-and-run mocktail_updater status
- name: Upload AppImage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Nightcap-AppImage
path: dist/Mocktail-x86_64.AppImage
path: |
dist/Nightcap-nightly-x86_64.AppImage
dist/Nightcap-nightly-x86_64.AppImage.zsync
if-no-files-found: error
retention-days: 14
@@ -173,13 +180,14 @@ jobs:
name: Nightcap-AppImage
path: release
- name: Name and checksum the nightly build
- name: Checksum the nightly build
shell: bash
run: |
set -Eeuo pipefail
# The Makefile builds under upstream's artifact name; the published
# asset carries this fork's.
mv -- release/Mocktail-x86_64.AppImage release/Nightcap-nightly-x86_64.AppImage
# The .zsync file names the AppImage it updates, so the build already
# carries the published name.
test -s release/Nightcap-nightly-x86_64.AppImage
test -s release/Nightcap-nightly-x86_64.AppImage.zsync
(cd release && sha256sum Nightcap-nightly-x86_64.AppImage \
> Nightcap-nightly-x86_64.AppImage.sha256)
find release -maxdepth 1 -type f -print
@@ -206,7 +214,7 @@ jobs:
# Drop assets left by earlier runs under other names.
while IFS= read -r asset; do
case "$asset" in
Nightcap-nightly-x86_64.AppImage|Nightcap-nightly-x86_64.AppImage.sha256)
Nightcap-nightly-x86_64.AppImage|Nightcap-nightly-x86_64.AppImage.zsync|Nightcap-nightly-x86_64.AppImage.sha256)
;;
*)
gh release delete-asset continuous "$asset" --yes
+210 -175
View File
@@ -1,206 +1,245 @@
# Changelog
All notable changes to Nightcap. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Release notes on
GitHub are taken from the matching section here.
All notable changes to Nightcap. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Release notes on GitHub are taken from the matching section here.
## [Unreleased]
## [0.3.4] - 2026-10-11
### Added
- The AppImage build now makes a `.zsync` file, so tools like `appimageupdatetool` can update the AppImage.
- The desktop side panel menu now shows by default on PC profiles. Set `FIntFlyoutTestVariantRollout12` in
`fflags.json` to change it.
### Changed
- The Flatpak build asks for the X11 socket only when there is no Wayland session, as before 0.3.3.
- Nightcap now uses Wayland on a Wayland session with NVIDIA drivers too, however it is started. It used to
fall back to X11 there when started from a terminal.
- Building Nightcap no longer needs libexecinfo.
### Fixed
- Fixed Roblox 2.742 being rejected as an update, which left the game on the older version. Nightcap now
accepts Roblox versions that change the order of their start-up steps.
- Fixed Nightcap reporting Mocktail's version number, 1.0.4, in session logs and support bundles. It now
reports its own version.
- Fixed the old Roblox home screen showing instead of the new one with the Me tab and the "For you" and
"Charts" tabs. Nightcap now runs the Roblox start-up step that loads the new interface for your account.
- Fixed anti-aliasing being forced off at every graphics quality level. Roblox now picks it, and a
`FIntDebugForceMSAASamples` value in `fflags.json` still applies.
- Fixed the voice chat microphone only working on Roblox 2.738. It no longer depends on one Roblox version.
- Fixed other players not hearing you in voice chat after a Roblox update. The updater now rebuilds the
audio device data when the previous version's profile has none.
- Fixed frame hitches while textures load on graphics cards that need ETC2 textures decoded on the CPU.
- Fixed problems on Chimera Linux and other musl systems, including error messages that differed from the
ones Roblox expects.
- Fixed the nightly AppImage build failing when a bundled GTK 4 was older than the one libadwaita needs.
- Fixed a random crash on Wayland with NVIDIA drivers, where the game window closed after you let go of the
camera or left mouse lock. The log showed `wp_linux_drm_syncobj_surface_v1` error 3.
- Fixed some menu actions being ignored. The Chat button on a profile now opens the Party tab with that
player, and clicking a search suggestion now runs the search instead of closing the search menu.
- Fixed possible crashes when the game calls an Android function Nightcap did not provide, or releases the
same reference twice.
- Fixed a possible hang on close when a web view process stayed open. Nightcap now stops waiting for the log
writer after 2 seconds.
- Fixed a possible missing picture on graphics drivers that reject the low-latency present mode. Nightcap now
retries with the game's own mode.
- Fixed a possible Wayland crash when the window first appears on the OpenGL graphics path.
- Fixed a missing picture on machines where the graphics card runs a different driver than the one Nightcap
picked, such as an NVIDIA card on nouveau or a laptop with an Intel chip and an older second card. Nightcap
now picks the Vulkan driver that matches the running kernel driver, and says so when no card can run Vulkan.
## [0.3.3] - 2026-10-04
### Added
- Joining a friend from a Roblox "follow user" link now works. Nightcap looks up which game they are in and
joins it.
- Nix users get a desktop entry for the app.
### Changed
- Roblox textures now decode about 3 times faster, which shortens the stalls when a place loads and when new
parts of the map stream in. Thanks to @glook9001.
### Fixed
- `make update-roblox` no longer deletes your previous Roblox install when an import fails partway through. If
a step fails, the old files are put back, or kept in a folder whose path is printed. Thanks to @kimlherme.
- A failed Roblox update now restores your previous version's records instead of leaving them half switched. A
damaged version file is rejected instead of being half read, and a temporary failure to start the startup
check no longer marks a working Roblox build as broken for good. Thanks to @kimlherme.
- Fixed a possible crash when Roblox clears its audio queue while sound is still playing, and a memory leak in
lists of objects passed to Roblox's Java code. Thanks to @kimlherme.
- Fixed a possible crash when the text overlay shuts down, a freeze when Roblox releases the mouse lock while
another release is already in progress, and network lookups that misread Roblox's options. Thanks to
@kimlherme.
- Fixed the sky sometimes rendering with the wrong textures in the Vulkan renderer.
- Clicking while a Roblox text box has focus no longer closes it. Only Roblox decides when a click ends
typing.
- Going to another page in the web view, such as Friends from a profile, no longer closes the web view. It
crashed on every page change on Wayland systems whose GL setup GTK could not use.
- Fixed a crash when Roblox's saved dark or light theme setting was missing or damaged.
## [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.
- 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.
- 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.
- 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.
- `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
- Each release carries a DEB, an RPM, and a pacman package beside the
AppImage, with a `.sha256` for every file. They install `/usr/bin/mocktail`
and conflict with each other and with upstream's `mocktail` package.
Nightcap is in no distribution repository or the AUR; the recipes under
`packaging/aur/nightcap{,-bin,-git}` build from this repository locally.
- The AppStream metadata lists the Nightcap releases, so software centres
show the release history instead of upstream's.
- Each release carries a DEB, an RPM, and a pacman package beside the AppImage, with a `.sha256` for every
file. They install `/usr/bin/mocktail` and conflict with each other and with upstream's `mocktail` package.
Nightcap is in no distribution repository or the AUR; the recipes under `packaging/aur/nightcap{,-bin,-git}`
build from this repository locally.
- The AppStream metadata lists the Nightcap releases, so software centres show the release history instead of
upstream's.
### Fixed
- ETC2 texture decoding no longer blocks the thread that submits the frame.
Uploads decode on a background dispatcher and the submit carries a timeline
semaphore wait, so the GPU waits for the texture instead of the render
thread. Reading the application's compressed bytes moves to that dispatcher
too whenever they are already mapped. In Blade Ball the worst
`vkQueueSubmit` fell from 140ms to 1ms, its p99 from 4.9ms to 0.05ms, and
the total time spent in submits from 2534ms to 309ms. Frame interval is
unchanged: the remaining tail is the engine's own main-thread step. A device
without the `timelineSemaphore` feature decodes inline as before, as does a
submit whose `pNext` already sizes arrays by its semaphore counts.
- The mouse pointer jumped when a right-click camera drag ended, and drifted
to the viewport edge during one. The input router inferred pointer capture
from SDL reporting zero absolute coordinates, which holds on X11 but not on
Wayland, where the reported position accumulates the relative deltas and
leaves the window. Motion, button, and wheel events now carry the window's
relative mouse mode. The right-click camera fallback also only captures
inside an experience, so the cursor stays free over the app surface.
- Shutting the window down while an experience was starting or leaving could
free the pointer capture owner underneath the thread using it. Callers off
the SDL thread now hold a reference for the length of their call.
- The TextBox caret did not move when a trailing space was typed in a
single-line field. The final cluster rect SDL_ttf reports stops at the last
inked glyph, so the laid-out width is used there instead. Wrapped layouts
keep the clamp, because their width is the widest line rather than the last.
- Every TextBox keystroke re-ran `TTF_Init`, a fontconfig sort, six
`TTF_OpenFont` calls, and `TTF_Quit` on the render thread. The overlay holds
its opened fonts and reopens them only when the Roblox font or the point
size changes, keeping the current set when a reopen fails. Measured over 100
rasters, 1.59ms to 0.12ms per keystroke.
- Staged ETC2 copies now carry an explicit host-write barrier. A queue
submission only makes host writes from before it visible to the device, and
an asynchronous decode writes after the submit, so without the barrier those
texels were not guaranteed to reach the copy that reads them.
- ETC2 texture decoding no longer blocks the thread that submits the frame. Uploads decode on a background
dispatcher and the submit carries a timeline semaphore wait, so the GPU waits for the texture instead of the
render thread. Reading the application's compressed bytes moves to that dispatcher too whenever they are
already mapped. In Blade Ball the worst `vkQueueSubmit` fell from 140ms to 1ms, its p99 from 4.9ms to
0.05ms, and the total time spent in submits from 2534ms to 309ms. Frame interval is unchanged: the remaining
tail is the engine's own main-thread step. A device without the `timelineSemaphore` feature decodes inline
as before, as does a submit whose `pNext` already sizes arrays by its semaphore counts.
- The mouse pointer jumped when a right-click camera drag ended, and drifted to the viewport edge during one.
The input router inferred pointer capture from SDL reporting zero absolute coordinates, which holds on X11
but not on Wayland, where the reported position accumulates the relative deltas and leaves the window.
Motion, button, and wheel events now carry the window's relative mouse mode. The right-click camera fallback
also only captures inside an experience, so the cursor stays free over the app surface.
- Shutting the window down while an experience was starting or leaving could free the pointer capture owner
underneath the thread using it. Callers off the SDL thread now hold a reference for the length of their
call.
- The TextBox caret did not move when a trailing space was typed in a single-line field. The final cluster
rect SDL_ttf reports stops at the last inked glyph, so the laid-out width is used there instead. Wrapped
layouts keep the clamp, because their width is the widest line rather than the last.
- Every TextBox keystroke re-ran `TTF_Init`, a fontconfig sort, six `TTF_OpenFont` calls, and `TTF_Quit` on
the render thread. The overlay holds its opened fonts and reopens them only when the Roblox font or the
point size changes, keeping the current set when a reopen fails. Measured over 100 rasters, 1.59ms to 0.12ms
per keystroke.
- Staged ETC2 copies now carry an explicit host-write barrier. A queue submission only makes host writes from
before it visible to the device, and an asynchronous decode writes after the submit, so without the barrier
those texels were not guaranteed to reach the copy that reads them.
### Changed
- `docs/PERFORMANCE.md` documents the GameMode settings to run with. A
`desiredgov=powersave` in `~/.config/gamemode.ini` overrides the packaged
`performance` default and clocks the CPU down for the whole session while
still reporting an active performance request.
- `docs/PERFORMANCE.md` documents the GameMode settings to run with. A `desiredgov=powersave` in
`~/.config/gamemode.ini` overrides the packaged `performance` default and clocks the CPU down for the whole
session while still reporting an active performance request.
## [0.3.0] - 2026-09-18
### Added
- Custom Discord presence. `integrations.discord_rpc.text.title` replaces the
name in "Playing Nightcap", and `integrations.discord_rpc.images` sets the
large and small icons and their hover text. Text and image fields accept
`{place_name}`, `{place_icon}`, and `{creator_name}`. A field that expands
an empty placeholder is left out, and `text.state: ""` hides the state line.
- The default Discord card now matches Sober: the title is "Roblox", the
details line is "Playing <experience>", the state is "by <creator>", and
the Roblox badge sits inside the experience icon. The creator comes from the
same Roblox games request that already fetches the experience name. With
`show_place_name: false` the creator is hidden too, so the card shows only
the title and timer.
- `--profile <file>` writes a Chrome-format trace of the Vulkan adapter's
work: presents, GPU waits, submits, ETC2 texture decode, and pipeline and
shader creation. Open it in ui.perfetto.dev. `docs/BENCHMARKING.md`
describes the benchmark runs, and `scripts/summarize_profile_trace.py`
turns traces into a before and after table.
- Custom Discord presence. `integrations.discord_rpc.text.title` replaces the name in "Playing Nightcap", and
`integrations.discord_rpc.images` sets the large and small icons and their hover text. Text and image fields
accept `{place_name}`, `{place_icon}`, and `{creator_name}`. A field that expands an empty placeholder is
left out, and `text.state: ""` hides the state line.
- The default Discord card now matches Sober: the title is "Roblox", the details line is "Playing
<experience>", the state is "by <creator>", and the Roblox badge sits inside the experience icon. The
creator comes from the same Roblox games request that already fetches the experience name. With
`show_place_name: false` the creator is hidden too, so the card shows only the title and timer.
- `--profile <file>` writes a Chrome-format trace of the Vulkan adapter's work: presents, GPU waits, submits,
ETC2 texture decode, and pipeline and shader creation. Open it in ui.perfetto.dev. `docs/BENCHMARKING.md`
describes the benchmark runs, and `scripts/summarize_profile_trace.py` turns traces into a before and after
table.
### Fixed
- Texture uploads stalled the frame they were submitted on. ETC2 batches
decoded on worker threads but were then resampled one upload at a time on
the submitting thread, which at the default 4x upscale is sixteen times the
decoded texels. Resampling now runs on the same worker pool, in two passes so
mip level 0 still resolves before the mips built from it. In a 70k-part
place the worst `vkQueueSubmit` fell from 167ms to 92ms.
- Most ETC2 batches decoded on a single thread. Workers were created and
joined per batch, so the parallel floor had to cover that cost and sat at a
1024x1024 image; batches under it ran on the caller, which was 54% of all
decode time in a 70k-part place. Decode workers are now a persistent pool,
and the floor drops to a 128x128 image. Mean decode time in that place fell
from 8.2ms to 4.7ms, and 11.8ms before both texture fixes.
- On CPUs with a wide core-speed spread, Feral GameMode pinned the process to
the few fastest cores, concentrating Roblox's worker pool onto them instead
of spreading it. With `performance.gamemode: auto`, a request that narrows
the CPU affinity is now released again, which releases the pin. Set
`pin_cores=no` in `~/.config/gamemode.ini` to keep GameMode without the
pinning, or `performance.gamemode: on` to accept it. See
`docs/PERFORMANCE.md`.
- With `show_place_name: false`, Discord no longer shows the experience
thumbnail or its name on hover.
- The host main thread no longer spins on the engine's message pump, which
cost about half a core and 146,000 empty calls a second. It now rests 100 µs
between polls: sleeping when the governor is `performance`, parking the
core with TPAUSE on CPUs that have it, spinning otherwise. With GameMode
setting the governor, process CPU in a 60k-part place fell from 216% to
107% at the same 120 fps. `MOCKTAIL_ENGINE_PUMP_REST` forces a mode; see
`docs/PERFORMANCE.md`.
- ETC2 uploads with a `bufferRowLength` or `bufferImageHeight` larger than
the image read the padded rows as packed data and decoded garbage.
- ETC2 resampling created and joined worker threads on every batch. It now
runs on the persistent decode pool.
- A failed trace write left the profile writer thread waking every 250 ms
for the rest of the session. It now exits.
- The `--profile` trace gains a `pump` slice for the engine's main-thread
step.
- Texture uploads stalled the frame they were submitted on. ETC2 batches decoded on worker threads but were
then resampled one upload at a time on the submitting thread, which at the default 4x upscale is sixteen
times the decoded texels. Resampling now runs on the same worker pool, in two passes so mip level 0 still
resolves before the mips built from it. In a 70k-part place the worst `vkQueueSubmit` fell from 167ms to
92ms.
- Most ETC2 batches decoded on a single thread. Workers were created and joined per batch, so the parallel
floor had to cover that cost and sat at a 1024x1024 image; batches under it ran on the caller, which was 54%
of all decode time in a 70k-part place. Decode workers are now a persistent pool, and the floor drops to a
128x128 image. Mean decode time in that place fell from 8.2ms to 4.7ms, and 11.8ms before both texture
fixes.
- On CPUs with a wide core-speed spread, Feral GameMode pinned the process to the few fastest cores,
concentrating Roblox's worker pool onto them instead of spreading it. With `performance.gamemode: auto`, a
request that narrows the CPU affinity is now released again, which releases the pin. Set `pin_cores=no` in
`~/.config/gamemode.ini` to keep GameMode without the pinning, or `performance.gamemode: on` to accept it.
See `docs/PERFORMANCE.md`.
- With `show_place_name: false`, Discord no longer shows the experience thumbnail or its name on hover.
- The host main thread no longer spins on the engine's message pump, which cost about half a core and 146,000
empty calls a second. It now rests 100 µs between polls: sleeping when the governor is `performance`,
parking the core with TPAUSE on CPUs that have it, spinning otherwise. With GameMode setting the governor,
process CPU in a 60k-part place fell from 216% to 107% at the same 120 fps. `MOCKTAIL_ENGINE_PUMP_REST`
forces a mode; see `docs/PERFORMANCE.md`.
- ETC2 uploads with a `bufferRowLength` or `bufferImageHeight` larger than the image read the padded rows as
packed data and decoded garbage.
- ETC2 resampling created and joined worker threads on every batch. It now runs on the persistent decode pool.
- A failed trace write left the profile writer thread waking every 250 ms for the rest of the session. It now
exits.
- The `--profile` trace gains a `pump` slice for the engine's main-thread step.
## [0.2.0] - 2026-09-16
### Added
- FAQ covering Discord RPC, log locations, and VR, adapted from upstream.
- `input.raw_mouse` in `config.yaml` sends mouse look deltas in the host's
own units. Turn it on when first-person aiming feels too slow or too fast
under fractional display scaling. Off by default.
- Nightly AppImage builds, published as the `continuous` prerelease. They are
untested; use a tagged release for normal play.
- `input.raw_mouse` in `config.yaml` sends mouse look deltas in the host's own units. Turn it on when
first-person aiming feels too slow or too fast under fractional display scaling. Off by default.
- Nightly AppImage builds, published as the `continuous` prerelease. They are untested; use a tagged release
for normal play.
### Changed
- Values in `fflags.json` now win over the performance preset instead of
stopping the launch. Startup prints each key it kept.
- Discord Rich Presence uses Nightcap's own Discord application, so it shows
as "Playing Nightcap". Terms and privacy pages for it live on the
project site.
- Discord Rich Presence is on by default. Set
`integrations.discord_rpc.enabled: false` to turn it off.
- The AppImage build verifies its reduced dependency packages against digests
taken at build time, rather than a digest list kept in the repository.
- Values in `fflags.json` now win over the performance preset instead of stopping the launch. Startup prints
each key it kept.
- Discord Rich Presence uses Nightcap's own Discord application, so it shows as "Playing Nightcap". Terms and
privacy pages for it live on the project site.
- Discord Rich Presence is on by default. Set `integrations.discord_rpc.enabled: false` to turn it off.
- The AppImage build verifies its reduced dependency packages against digests taken at build time, rather than
a digest list kept in the repository.
### Fixed
- Mouse look keeps responding after a long turn. The tracked pointer position
could drift past the edge of the view, which swallowed every reversal until
the mouse travelled all the way back.
- Nightcap no longer refuses to start with `cannot lock appStorage`. It
created its own credential directory using the process umask, then rejected
that directory for being group writable. Systems with a permissive umask,
such as `002`, failed on first run.
- Mouse look keeps responding after a long turn. The tracked pointer position could drift past the edge of the
view, which swallowed every reversal until the mouse travelled all the way back.
- Nightcap no longer refuses to start with `cannot lock appStorage`. It created its own credential directory
using the process umask, then rejected that directory for being group writable. Systems with a permissive
umask, such as `002`, failed on first run.
## [0.1.0] - 2026-09-15
@@ -208,37 +247,33 @@ First Nightcap release, forked from komaruworld/mocktail.
### Added
- ETC2 and EAC texture emulation on Vulkan, so games that use compressed
mobile textures render correctly on desktop GPUs. Decoding runs on worker
threads to avoid stutter.
- PNG texture overrides in `~/.config/mocktail/textures`, keyed by upload
hash, with a dump mode to find the texture you want to replace.
- Small textures are upscaled 4x by default. Set
`MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn it off.
- AppImage and signed Flatpak builds on every release tag. The Flatpak repo
is published at https://coderdayton.github.io/nightcap/.
- ETC2 and EAC texture emulation on Vulkan, so games that use compressed mobile textures render correctly on
desktop GPUs. Decoding runs on worker threads to avoid stutter.
- PNG texture overrides in `~/.config/mocktail/textures`, keyed by upload hash, with a dump mode to find the
texture you want to replace.
- Small textures are upscaled 4x by default. Set `MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn it off.
- AppImage and signed Flatpak builds on every release tag. The Flatpak repo is published at
https://coderdayton.github.io/nightcap/.
- Roadmap in `docs/ROADMAP.md`.
### Changed
- Frames are presented as soon as they are ready instead of waiting in a
queue, which lowers input latency.
- Roblox now sees the real host RAM and display size instead of a low-end
phone profile.
- App ID is `io.github.CoderDayton.nightcap`, so Nightcap installs next to
upstream Mocktail. The binary and config paths keep the `mocktail` name.
- Frames are presented as soon as they are ready instead of waiting in a queue, which lowers input latency.
- Roblox now sees the real host RAM and display size instead of a low-end phone profile.
- App ID is `io.github.CoderDayton.nightcap`, so Nightcap installs next to upstream Mocktail. The binary and
config paths keep the `mocktail` name.
- New name, icon, and colors.
- The desktop entry launches on Wayland and points at the installed binary.
### Fixed
- Servers no longer kick with error 319 shortly after joining.
- The FMOD output-device bridge is optional, so the updater no longer fails
without it.
- The AppImage build no longer fails on stale checksums for the slimmed
Arch packages.
- The FMOD output-device bridge is optional, so the updater no longer fails without it.
- The AppImage build no longer fails on stale checksums for the slimmed Arch packages.
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.2...HEAD
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.4...HEAD
[0.3.4]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.4
[0.3.3]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.3
[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
+8 -47
View File
@@ -9,7 +9,7 @@
cmake_minimum_required(VERSION 3.20)
project(Mocktail
VERSION 1.0.4
VERSION 0.3.4
DESCRIPTION "Android Roblox compatibility runtime for Linux"
LANGUAGES CXX
)
@@ -249,6 +249,9 @@ mocktail_apply_compile_options(mocktail_updater)
add_library(mocktail_legacy_jnivm STATIC
src/jnivm/jnivm.cc
src/jnivm/jni_references.cc
src/jnivm/jni_fields.cc
src/jnivm/jni_strings.cc
)
add_library(Mocktail::LegacyJni ALIAS mocktail_legacy_jnivm)
target_include_directories(mocktail_legacy_jnivm PUBLIC include)
@@ -285,10 +288,13 @@ add_library(mocktail_runtime STATIC
src/runtime/roblox_capability_resolver.cc
src/runtime/roblox_experience_launch_bridge.cc
src/runtime/roblox_launch_uri.cc
src/runtime/roblox_presence_resolver.cc
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/roblox_message_bus_request_bridge.cc
src/runtime/roblox_call_protocol_bridge.cc
src/runtime/payload_update_preflight.cc
src/runtime/process_launch_policy.cc
src/runtime/process_diagnostics.cc
@@ -474,7 +480,7 @@ target_link_libraries(mocktail_window PUBLIC
Mocktail::SdlVulkanWsi
SDL3::SDL3
${CMAKE_DL_LIBS}
PRIVATE Mocktail::Runtime Mocktail::GlesTextOverlay
PRIVATE Mocktail::Runtime Mocktail::GlesTextOverlay Mocktail::GraphicsFoundation
)
add_library(mocktail_roblox_output_device_runtime STATIC
@@ -555,50 +561,6 @@ target_link_libraries(mocktail_bionic_abi_exports PRIVATE
Threads::Threads
)
include(CheckCXXSourceCompiles)
include(CheckIncludeFileCXX)
include(CMakePushCheckState)
check_include_file_cxx(execinfo.h MOCKTAIL_HAVE_EXECINFO_H)
if(NOT MOCKTAIL_HAVE_EXECINFO_H)
message(FATAL_ERROR
"execinfo.h is required for crash diagnostics; on musl install "
"libexecinfo-devel"
)
endif()
set(MOCKTAIL_EXECINFO_LINK_LIBRARY "")
set(_mocktail_execinfo_link_probe [=[
#include <execinfo.h>
int main() {
void* frames[1] = {};
const int count = backtrace(frames, 1);
return backtrace_symbols(frames, count) == nullptr;
}
]=])
check_cxx_source_compiles(
"${_mocktail_execinfo_link_probe}"
MOCKTAIL_EXECINFO_IN_HOST_LIBC
)
if(NOT MOCKTAIL_EXECINFO_IN_HOST_LIBC)
find_library(MOCKTAIL_EXECINFO_LIBRARY NAMES execinfo REQUIRED)
cmake_push_check_state(RESET)
set(CMAKE_REQUIRED_LIBRARIES "${MOCKTAIL_EXECINFO_LIBRARY}")
check_cxx_source_compiles(
"${_mocktail_execinfo_link_probe}"
MOCKTAIL_EXECINFO_IN_EXTERNAL_LIBRARY
)
cmake_pop_check_state()
if(NOT MOCKTAIL_EXECINFO_IN_EXTERNAL_LIBRARY)
message(FATAL_ERROR
"execinfo.h is available, but backtrace and backtrace_symbols cannot "
"be linked"
)
endif()
set(MOCKTAIL_EXECINFO_LINK_LIBRARY "${MOCKTAIL_EXECINFO_LIBRARY}")
endif()
unset(_mocktail_execinfo_link_probe)
add_library(mocktail_legacy_runtime STATIC
src/legacy/legacy_runtime.cc
src/runtime/roblox_text_input_jni_bridge.cc
@@ -623,7 +585,6 @@ target_link_libraries(mocktail_legacy_runtime PRIVATE
libc_shim
mocktail_window
mocktail_graphics_foundation
${MOCKTAIL_EXECINFO_LINK_LIBRARY}
)
target_compile_definitions(mocktail_legacy_runtime PRIVATE
"MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=\"${MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST}\""
+10
View File
@@ -7,6 +7,7 @@
<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>
<a href="https://discord.gg/DbAUG9hBFU"><img alt="Discord" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Fv10%2Finvites%2FDbAUG9hBFU%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&label=Discord&logo=discord&logoColor=white&color=5865F2&suffix=%20members"></a>
</h1>
</div>
@@ -95,6 +96,12 @@ The APK is checked before any native code is loaded. It is downloaded on first
launch and is not bundled with Nightcap. The last working copy is kept in case
an update fails.
A leftover `.previous_good-recovery.json` in the payload store marks pending
recovery after a publication or restoration failure and blocks further payload
publication and garbage collection. Check `current.json` and `previous_good.json`
against the saved recovery state and fix the underlying problem before manually
removing the marker.
<details>
<summary>Screenshots</summary>
@@ -115,6 +122,9 @@ PC profiles use the legacy Charts page by default to avoid the blank screen
caused by `Color3` errors in the newer SDUI page. An explicit
`FFlagLuaAppChartsAppPage` override takes precedence over this default.
PC profiles also set `FIntFlyoutTestVariantRollout12` to `2`, which shows the
desktop side panel menu. An explicit override takes precedence.
```json
{
"FFlagExample": "True",
+1 -1
View File
@@ -32,7 +32,7 @@
"mode": "vendored-patched-snapshot",
"url": "https://github.com/minecraft-linux/mcpelauncher-linker.git",
"upstream_commit": "1ac3ea6c1cf4f73a84d73f0bff510fba94a7d0f2",
"vendor_tree": "fca44d85e3773c135ccfc603b14abdf6aeb8efd1",
"vendor_tree": "0637221e48f382ec8d0bc701385f4b10bcd07a65",
"patch": "third_party/patches/mcpelauncher-linker.patch",
"nested_sources": {
"android_bionic": {
-14
View File
@@ -22,20 +22,6 @@ what you are playing and you can switch it all off in one click.
Done when every option in `config/mocktail.example.yaml` that a player would
touch can be set without a text editor.
## 2. Controller button glyphs
Upstream added Xbox and PlayStation gamepad support through SDL. Controllers
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.
Done when a plugged-in pad shows its own buttons.
## Shipped
- Lower input latency: frames are presented as soon as they are ready.
+11
View File
@@ -49,6 +49,17 @@
gdb
];
desktopItems = [
(pkgs.makeDesktopItem {
name = "Mocktail";
exec = "mocktail";
icon = "mocktail";
desktopName = "Mocktail";
comment = "Experimental Roblox compatibility runtime for Linux and FreeBSD";
categories = [ "Game" ];
})
];
shellHook = ''
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath package.buildInputs}"
+19 -10
View File
@@ -11,19 +11,27 @@
namespace mocktail::compat {
// glibc's atoi is (int)strtol(), exactly like Bionic's, so the host symbol is
// used as-is there. Other libcs (musl) differ on overflow and need the shim.
#ifdef __GLIBC__
inline constexpr bool kHostAtoiMatchesBionic = true;
#else
inline constexpr bool kHostAtoiMatchesBionic = false;
#endif
// Android uses size_t fields here; glibc uses int and musl has no equivalent.
// An all-zero fallback means unknown, not zero usage.
struct BionicMallinfoSnapshot {
size_t arena = 0;
size_t ordblks = 0;
size_t smblks = 0;
size_t hblks = 0;
size_t hblkhd = 0;
size_t usmblks = 0;
size_t fsmblks = 0;
size_t uordblks = 0;
size_t fordblks = 0;
size_t keepcost = 0;
size_t arena;
size_t ordblks;
size_t smblks;
size_t hblks;
size_t hblkhd;
size_t usmblks;
size_t fsmblks;
size_t uordblks;
size_t fordblks;
size_t keepcost;
};
struct BionicLocaleState;
@@ -90,6 +98,7 @@ int mocktail_bionic_cxa_thread_atexit_impl(
mocktail::compat::BionicThreadDestructor destructor, void* argument,
void* dso_handle);
void mocktail_bionic_arc4random_buf(void* buffer, size_t size);
int mocktail_bionic_atoi(const char* text);
mocktail::compat::BionicMallinfoSnapshot mocktail_bionic_mallinfo();
int mocktail_bionic_sysinfo(struct sysinfo* info);
int mocktail_bionic_uname(struct utsname* name);
+5
View File
@@ -36,6 +36,11 @@ struct FmodOutputDeviceBridgeProfile {
? std::array<std::size_t, 4>{9, 10, 12, 18}
: std::array<std::size_t, 4>{8, 9, 10, 16};
}
std::array<std::size_t, 6> capture_vtable_indexes() const {
return vtable_layout_version == 2
? std::array<std::size_t, 6>{4, 13, 14, 15, 16, 17}
: std::array<std::size_t, 6>{4, 11, 12, 13, 14, 15};
}
bool valid_vtable_layout() const {
return vtable_layout_version == 1 || vtable_layout_version == 2;
+9
View File
@@ -109,6 +109,15 @@ struct HostAbiProfile {
size_t NativeMimallocConstructorRangeEndExclusive() const noexcept;
bool ShouldInitializeNativeMimallocThreadAfterConstructor(
size_t index) const noexcept;
// True when both replays have the one verified shape. The native replay is
// one range that runs to the end of .init_array. Its first four
// constructors are the process-load constructor at the thread-initializer
// checkpoint, the JNI singleton initializer, and two wiring entries. The
// host-bridge replay runs the same constructors without the two wiring
// entries. `jni_singleton_constructor` is the .init_array index of the JNI
// singleton initializer.
bool HasVerifiedConstructorPolicy(
size_t jni_singleton_constructor) const noexcept;
bool HasValidNativePreJniBootstrap() const noexcept;
HostAllocatorStrategy ResolveAllocatorStrategy(
bool host_bridges_override_present,
@@ -61,8 +61,7 @@ class RobloxOutputDeviceBridge final {
void SelectDevice(int index, bool input = false);
void ConstructGuestString(void* destination, std::string_view value) const;
std::unique_ptr<NativeInputCapture> capture_;
bool capture_profile_supported_ = false;
std::shared_ptr<NativeInputCapture> capture_;
std::array<std::uintptr_t, 6> original_capture_methods_{};
mutable std::mutex mutex_;
compat::FmodOutputDeviceBridgeProfile profile_;
+3
View File
@@ -16,6 +16,9 @@ struct AngleProbeOptions {
bool allow_software_device = false;
};
// Checks ANGLE entry points and client extensions without initializing a display.
BackendCapability InspectAngleLibraries(const AngleProbeOptions& options);
// Loads a pinned ANGLE pair, verifies the required EGL/GLES symbols, and
// initializes an EGL_PLATFORM_ANGLE Vulkan display. No context is fabricated.
BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options);
@@ -2,6 +2,7 @@
#define MOCKTAIL_GRAPHICS_PRESENT_MODE_POLICY_H_
#include <cstdint>
#include <cstdio>
#include <string_view>
#include <vector>
@@ -29,6 +30,36 @@ FilterPresentModes(PresentModePolicy policy,
const char *PresentModePolicyName(PresentModePolicy policy);
const char *PresentModeKhrName(VkPresentModeKHR mode);
// Creates the swapchain with the mode in `info`. A driver can reject a mode
// that the device did not enable, so a failed create is retried once with the
// mode the game asked for. `create` takes the info and returns a VkResult.
template <typename Create>
VkResult CreateSwapchainWithPresentModeFallback(VkSwapchainCreateInfoKHR &info,
VkPresentModeKHR requested,
Create create) {
const VkResult result = create(info);
if (result == VK_SUCCESS || info.presentMode == requested) {
return result;
}
switch (result) {
case VK_ERROR_OUT_OF_HOST_MEMORY:
case VK_ERROR_OUT_OF_DEVICE_MEMORY:
case VK_ERROR_DEVICE_LOST:
case VK_ERROR_SURFACE_LOST_KHR:
case VK_ERROR_NATIVE_WINDOW_IN_USE_KHR:
return result; // Another present mode cannot fix these.
default:
break;
}
std::fprintf(stderr,
" [vulkan] swapchain presentMode %s failed (%d); retrying "
"with %s\n",
PresentModeKhrName(info.presentMode), static_cast<int>(result),
PresentModeKhrName(requested));
info.presentMode = requested;
return create(info);
}
// Extra images so acquire does not wait on the compositor-held buffer.
std::uint32_t PreferSwapchainMinImageCount(PresentModePolicy policy,
std::uint32_t requested,
@@ -35,6 +35,10 @@ void ResampleRgba(const RgbaImage& source, std::uint32_t width,
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
std::uint32_t source_height, std::uint32_t width,
std::uint32_t height, std::uint8_t* destination);
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
std::uint32_t source_height, std::size_t source_stride_bytes,
std::uint32_t width, std::uint32_t height,
std::uint8_t* destination);
// Replacement textures keyed by the hash of a texture's level-0 compressed
// bytes. An override lives at <override_dir>/<hash>.png. With a dump
+20
View File
@@ -18,6 +18,26 @@ std::string SelectVulkanIcdManifest(
const std::vector<std::filesystem::path>& directories,
std::string_view vendor);
// One GPU as the kernel reports it.
struct HostGpu {
unsigned int vendor = 0; // PCI vendor ID.
std::string kernel_driver; // Bound kernel driver; empty when unknown.
};
struct VulkanIcdSelection {
std::string manifest; // Empty when no GPU has a usable driver.
std::vector<std::string> skipped; // Why each passed-over GPU was skipped.
};
// ICD manifest for the first GPU whose bound kernel driver has an installed
// Vulkan driver. NVIDIA and AMD cards are tried before Intel when
// `prefer_discrete` is set, and after it otherwise. A GPU with an unknown
// kernel driver is matched by vendor alone.
VulkanIcdSelection SelectHostVulkanIcd(
const std::vector<HostGpu>& gpus,
const std::vector<std::filesystem::path>& directories,
bool prefer_discrete);
// Publishes the resolved graphics backend before the managed payload updater
// starts. OpenGL is a strict system EGL/GLES path; it never silently retries
// through ANGLE/Vulkan or accepts a window without a real graphics context.
+2 -2
View File
@@ -47,8 +47,8 @@ struct RobloxThemeCacheResult {
};
// Reads the current account's Roblox theme without changing appStorage. A
// missing value leaves dark_theme empty so the caller can use Roblox's own
// startup default.
// missing or unrecognized value falls back to AuthenticatedTheme, then the
// caller's startup default.
RobloxThemeCacheResult ReadRobloxThemeCache(
const std::filesystem::path& app_storage_file,
std::int64_t authenticated_user_id);
@@ -0,0 +1,26 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_CALL_PROTOCOL_BRIDGE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_CALL_PROTOCOL_BRIDGE_H_
#include "runtime/roblox_message_bus_request_bridge.h"
namespace mocktail {
namespace runtime {
// In-experience CoreScripts query the app's cross-experience call state before
// initializing voice. Mocktail does not start or accept cross-experience calls,
// so that state is Idle. This query neither joins voice nor opens a microphone.
class RobloxCallProtocolBridge final {
public:
RobloxCallProtocolBridge(JniEnvironmentProvider environment,
RobloxMessageBusSymbols symbols,
RobloxMessageBusObjects objects);
Status Initialize();
Status Shutdown();
private:
RobloxMessageBusRequestBridge bridge_;
};
} // namespace runtime
} // namespace mocktail
#endif
@@ -15,6 +15,7 @@
#include "mocktail/status.h"
#include "runtime/owned_pthread.h"
#include "runtime/roblox_browser_service_bridge.h"
#include "runtime/roblox_call_protocol_bridge.h"
#include "runtime/roblox_experience_launch_bridge.h"
#include "runtime/roblox_experience_presence.h"
#include "runtime/roblox_fresh_game_launch_controller.h"
@@ -97,7 +98,9 @@ struct RobloxExperienceSurfaceProvider {
void* context = nullptr;
GameSurface (*snapshot)(void* context) = nullptr;
bool valid() const { return snapshot != nullptr; }
bool valid() const {
return snapshot != nullptr;
}
};
// Production composition for the APK platform protocols and ExperienceProtocol
@@ -256,6 +259,7 @@ class RobloxExperienceComposition final {
std::unique_ptr<RobloxWebViewBridge> web_view_bridge_;
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge_;
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge_;
std::unique_ptr<RobloxCallProtocolBridge> call_protocol_bridge_;
// Optional: a payload without the AccountProtocol entrypoints still starts.
std::unique_ptr<RobloxAccountBridge> account_bridge_;
std::shared_ptr<WebViewHelperProcess> web_surface_process_;
@@ -0,0 +1,87 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_MESSAGE_BUS_REQUEST_BRIDGE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_MESSAGE_BUS_REQUEST_BRIDGE_H_
#include <jni.h>
#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include "mocktail/status.h"
#include "runtime/roblox_game_session_native_adapter.h"
namespace mocktail {
namespace runtime {
struct RobloxMessageBusSymbols {
jobject (*subscribe_request)(JNIEnv*, jobject, jstring, jstring, jobject,
jboolean) = nullptr;
void (*delete_connection)(JNIEnv*, jobject, jlong) = nullptr;
void (*publish_response)(JNIEnv*, jobject, jstring, jstring, jstring, jint,
jstring) = nullptr;
void (*set_async_handler)(JNIEnv*, jobject, jstring, jstring,
jobject) = nullptr;
void (*clear_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
void (*call_response_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
bool complete() const {
return subscribe_request && delete_connection && publish_response &&
set_async_handler && clear_handler && call_response_handler;
}
};
struct RobloxMessageBusObjects {
jobject message_bus = nullptr;
void* context = nullptr;
jobject (*create_raw_callback)(void*, std::shared_ptr<void>,
void (*)(void*, JNIEnv*, jstring)) = nullptr;
void (*clear_raw_callback)(void*, jobject) = nullptr;
jobject (*create_async_handler)(void*, std::shared_ptr<void>,
void (*)(void*, JNIEnv*, jstring,
jstring)) = nullptr;
void (*clear_async_handler)(void*, jobject) = nullptr;
bool complete() const {
return message_bus && context && create_raw_callback &&
clear_raw_callback && create_async_handler && clear_async_handler;
}
};
struct RobloxMessageBusReply {
std::string json;
jint code = 0;
const char* outcome = "ok";
};
// Owns both legacy subscriptions and correlated async request handlers.
// A policy receives only a request's method index and JNI-local payload.
class RobloxMessageBusRequestBridge final {
public:
using Handler =
std::function<RobloxMessageBusReply(JNIEnv*, jstring, std::size_t)>;
RobloxMessageBusRequestBridge(JniEnvironmentProvider environment,
RobloxMessageBusSymbols symbols,
RobloxMessageBusObjects objects,
std::string protocol,
std::vector<std::string> methods,
Handler handler);
~RobloxMessageBusRequestBridge();
Status Initialize();
Status Shutdown();
private:
struct State;
Status ShutdownLocked();
const JniEnvironmentProvider environment_;
const RobloxMessageBusSymbols symbols_;
const RobloxMessageBusObjects objects_;
const std::string protocol_name_;
const std::vector<std::string> methods_;
const Handler handler_;
std::mutex lifecycle_mutex_;
std::shared_ptr<State> state_;
};
} // namespace runtime
} // namespace mocktail
#endif
+5 -65
View File
@@ -1,58 +1,12 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
#include <jni.h>
#include <memory>
#include <mutex>
#include "mocktail/status.h"
#include "runtime/roblox_game_session_native_adapter.h"
#include "runtime/roblox_message_bus_request_bridge.h"
namespace mocktail {
namespace runtime {
using RobloxPermissionsMessageBusSymbols = RobloxMessageBusSymbols;
using RobloxPermissionsMessageBusObjects = RobloxMessageBusObjects;
// Exact MessageBus JNI entrypoints used by the APK PermissionsProtocol.
struct RobloxPermissionsMessageBusSymbols {
jobject (*subscribe_request)(JNIEnv*, jobject, jstring, jstring, jobject,
jboolean) = nullptr;
void (*delete_connection)(JNIEnv*, jobject, jlong) = nullptr;
void (*publish_response)(JNIEnv*, jobject, jstring, jstring, jstring, jint,
jstring) = nullptr;
void (*set_async_handler)(JNIEnv*, jobject, jstring, jstring,
jobject) = nullptr;
void (*clear_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
void (*call_response_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
bool complete() const {
return subscribe_request && delete_connection && publish_response &&
set_async_handler && clear_handler && call_response_handler;
}
};
struct RobloxPermissionsMessageBusObjects {
jobject message_bus = nullptr;
void* context = nullptr;
jobject (*create_raw_callback)(void*, std::shared_ptr<void>,
void (*)(void*, JNIEnv*, jstring)) = nullptr;
void (*clear_raw_callback)(void*, jobject) = nullptr;
jobject (*create_async_handler)(void*, std::shared_ptr<void>,
void (*)(void*, JNIEnv*, jstring,
jstring)) = nullptr;
void (*clear_async_handler)(void*, jobject) = nullptr;
bool complete() const {
return message_bus && context && create_raw_callback &&
clear_raw_callback && create_async_handler && clear_async_handler;
}
};
// Installs both legacy protocol subscriptions and correlated async handlers
// before native bootstrap. MICROPHONE_ACCESS follows host capture
// configuration; LOCAL_NETWORK needs no extra runtime permission, matching the
// Android APK. No query/request opens an audio device, enables a disabled
// microphone, or changes Roblox account eligibility. Other capabilities remain
// unsupported.
class RobloxPermissionsBridge final {
public:
RobloxPermissionsBridge(JniEnvironmentProvider environment,
@@ -60,26 +14,12 @@ class RobloxPermissionsBridge final {
RobloxPermissionsMessageBusObjects objects,
bool microphone_enabled);
~RobloxPermissionsBridge();
RobloxPermissionsBridge(const RobloxPermissionsBridge&) = delete;
RobloxPermissionsBridge& operator=(const RobloxPermissionsBridge&) = delete;
Status Initialize();
Status Shutdown();
private:
struct State;
Status ShutdownLocked();
const JniEnvironmentProvider environment_;
const RobloxPermissionsMessageBusSymbols symbols_;
const RobloxPermissionsMessageBusObjects objects_;
const bool microphone_enabled_;
// Serializes lifecycle only. Native dispatch never holds this mutex.
std::mutex lifecycle_mutex_;
std::shared_ptr<State> state_;
std::unique_ptr<RobloxMessageBusRequestBridge> bridge_;
};
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
#endif
@@ -0,0 +1,35 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
#define MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
#include <cstdint>
#include <string>
#include "mocktail/status.h"
namespace mocktail {
namespace runtime {
struct ResolvedFollowUserPlace {
int64_t place_id = 0;
std::string game_instance_id; // Roblox's "gameId" for the running session.
};
// Reads the session cookie mocktail already persists at
// ~/.local/share/mocktail/auth/roblox.cookie (or $XDG_DATA_HOME
// equivalent). Returns an empty string if no session is stored.
std::string ReadStoredRoblosecurityCookie();
// Calls Roblox's presence API to find what place/instance a user is
// currently in. `auth_cookie` is the runtime's already-authenticated
// .ROBLOSECURITY (or equivalent bearer token) — this function performs no
// authentication of its own and requires the runtime's validated session.
//
// Returns an error if the user is offline, has presence hidden, or is not
// in a joinable experience (e.g. in Studio, or privacy settings block it).
Status ResolveFollowUserPlace(int64_t user_id, const std::string& auth_cookie,
ResolvedFollowUserPlace* out);
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
+19
View File
@@ -32,6 +32,9 @@ inline constexpr char kRobloxOpenCaptchaViewNotification[] =
"OPEN_CAPTCHA_VIEW";
inline constexpr char kRobloxViewProfileNotification[] = "VIEW_PROFILE";
inline constexpr char kRobloxPurchaseRobuxNotification[] = "PURCHASE_ROBUX";
// Sent with a decimal user id when the app asks for a chat with that user.
inline constexpr char kRobloxLaunchConversationNotification[] =
"LAUNCH_CONVERSATION";
// Owned subset of WebViewProtocol's openWindow payload. The URL is copied
// before the MessageBus RawCallback returns, so no JNI local reference escapes.
@@ -115,6 +118,14 @@ using PublishWebViewRawFn = void (*)(JNIEnv *, jobject, jstring, jstring);
using BroadcastWebViewDataModelFocusFn = void (*)(JNIEnv *, jclass, jstring,
jstring, jstring);
using SignalWebViewJavascriptCallbackFn = void (*)(JNIEnv*, jclass, jstring);
// NativeGLInterface.nativeAppBridgeV2SendAppEventOnGameLoaded(namespace,
// detail, detailType).
using SendRobloxAppEventOnGameLoadedFn = void (*)(JNIEnv*, jclass, jstring,
jstring, jstring);
// NativeGLInterface.nativeAppBridgeV2SendAppEventOnAppReady(namespace, detail,
// detailType, appName). The engine holds the event until appName is ready.
using SendRobloxAppEventOnAppReadyFn = void (*)(JNIEnv*, jclass, jstring,
jstring, jstring, jstring);
using UpdateRobloxCookieSetHandlerFn =
void (*)(JNIEnv*, jobject, jobject);
// Roblox's counted handle to its Subsystem<IWebViewProtocol>. The protocol,
@@ -167,6 +178,9 @@ struct RobloxWebViewMessageBusSymbols {
UpdateRobloxCookieSetHandlerFn update_cookie_set_handler = nullptr;
// Optional: found by code contract, not by symbol name.
AcquireWebViewProtocolFn acquire_web_view_protocol = nullptr;
// Optional: without them LAUNCH_CONVERSATION is reported as unavailable.
SendRobloxAppEventOnGameLoadedFn send_app_event_on_game_loaded = nullptr;
SendRobloxAppEventOnAppReadyFn send_app_event_on_app_ready = nullptr;
bool complete() const {
return get_open_window_id != nullptr &&
@@ -270,11 +284,14 @@ private:
kDataModelUnfocused,
kDataModelFocused,
kMessageBusWindowClose,
kStartConversationWithUser,
};
struct PendingHostWindowEvent {
HostWindowEventType type = HostWindowEventType::kDataModelUnfocused;
uint64_t generation = 0;
// Decimal user id of a kStartConversationWithUser event.
std::string user_id;
};
Status CheckJniException(JNIEnv *env, const char *operation) const;
@@ -283,6 +300,8 @@ private:
Status DispatchOpenRequest(RobloxWebViewOpenRequest request,
OpenSource source);
void QueueHostWindowEvent(HostWindowEventType type, uint64_t generation);
void QueueStartConversation(std::string user_id);
Status SendStartConversationEvents(JNIEnv* env, const std::string& user_id);
Status BeginDispatch();
void EndDispatch();
static void RawMessageCallback(void *context, JNIEnv *env, jstring message);
+4
View File
@@ -11,6 +11,10 @@ struct HostAbiDerivationOptions {
std::filesystem::path reference_library;
std::filesystem::path reference_profile;
std::vector<std::filesystem::path> reference_compatibility_manifests;
// Optional libroblox.so of another build that the compatibility manifests
// describe. It supplies the FMOD output-device anchors when the manifests
// have none for the reference build.
std::filesystem::path runtime_fallback_library;
std::filesystem::path candidate_payload_directory;
std::filesystem::path output_directory;
};
+10 -1
View File
@@ -4,6 +4,7 @@
#include <array>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <string>
#include <vector>
@@ -33,11 +34,18 @@ struct PayloadGarbageResult {
explicit operator bool() const { return error.empty(); }
};
// Optional per-store fault hook for atomic manifest writes. Returning true
// makes the selected operation fail through its normal cleanup path.
enum class ManifestWritePhase { kCreate, kWrite, kFileFsync, kRename };
using ManifestWriteFault =
std::function<bool(const std::filesystem::path&, ManifestWritePhase)>;
class PayloadStore final {
public:
PayloadStore(std::filesystem::path root,
std::filesystem::path compatibility_manifest,
std::filesystem::path runtime_binary = {});
std::filesystem::path runtime_binary = {},
ManifestWriteFault manifest_write_fault = {});
PayloadStoreResult Stage(const std::filesystem::path& prepared_payload);
PayloadStoreResult Promote(std::string_view payload_id);
@@ -60,6 +68,7 @@ class PayloadStore final {
std::filesystem::path root_;
std::filesystem::path compatibility_manifest_;
std::filesystem::path runtime_binary_;
ManifestWriteFault manifest_write_fault_;
};
} // namespace mocktail::update
+13 -2
View File
@@ -1,7 +1,10 @@
#ifndef MOCKTAIL_UPDATE_READINESS_CANARY_H_
#define MOCKTAIL_UPDATE_READINESS_CANARY_H_
#include <spawn.h>
#include <filesystem>
#include <functional>
#include <string>
#include <string_view>
@@ -22,6 +25,12 @@ std::string_view CanaryGraphicsBackendName(CanaryGraphicsBackend backend);
bool ParseCanaryGraphicsBackend(std::string_view name,
CanaryGraphicsBackend* backend);
// Optional per-request replacement for the POSIX spawn operation.
// It follows posix_spawn return/ownership semantics; empty uses posix_spawn.
using CanarySpawn =
std::function<int(pid_t*, const char*, const posix_spawn_file_actions_t*,
const posix_spawnattr_t*, char* const[], char* const[])>;
struct CanaryOptions {
std::filesystem::path runtime_binary;
std::filesystem::path payload_directory;
@@ -29,9 +38,9 @@ struct CanaryOptions {
std::filesystem::path host_abi_profile;
std::filesystem::path cache_root;
std::filesystem::path state_root;
CanaryGraphicsBackend graphics_backend =
CanaryGraphicsBackend::kDirectVulkan;
CanaryGraphicsBackend graphics_backend = CanaryGraphicsBackend::kDirectVulkan;
int timeout_seconds = 150;
CanarySpawn spawn;
bool probation_candidate() const { return !host_abi_profile.empty(); }
};
@@ -39,6 +48,8 @@ struct CanaryOptions {
struct CanaryResult {
std::filesystem::path log_path;
int exit_code = -1;
// Direct posix_spawn return code; zero when spawn was not attempted.
int spawn_error = 0;
std::string error;
explicit operator bool() const {
+3
View File
@@ -26,6 +26,8 @@ struct UpdatePaths {
// build the single file happens to describe.
std::filesystem::path host_abi_reference_profile;
std::filesystem::path runtime_binary;
// Optional packaging wrapper; runtime_binary identifies the actual ELF.
std::filesystem::path runtime_launcher;
};
struct UpdateRequest {
@@ -38,6 +40,7 @@ struct UpdateRequest {
CanaryGraphicsBackend canary_graphics_backend =
CanaryGraphicsBackend::kDirectVulkan;
int progress_fd = -1;
CanarySpawn canary_spawn;
};
struct UpdateResult {
+5
View File
@@ -13,6 +13,11 @@ pre-commit:
desktop-entry:
glob: "*.desktop"
run: desktop-file-validate {staged_files}
changelog-width:
glob: "CHANGELOG.md"
run: >-
git diff --cached -U0 -- CHANGELOG.md |
awk '/^\+[^+]/ && length($0) > 111 { print "CHANGELOG.md: added line over 110 characters: " substr($0, 2); bad = 1 } END { exit bad }'
pre-push:
commands:
+1
View File
@@ -7,6 +7,7 @@ MOCKTAIL_PORTABLE_MODE=standalone
MOCKTAIL_RUNTIME_LIBRARY_DIR=${SHARUN_DIR}/lib/mocktail
MOCKTAIL_BIN=${SHARUN_DIR}/bin/mocktail
MOCKTAIL_UPDATE_CANARY_BIN=${SHARUN_DIR}/bin/mocktail
MOCKTAIL_UPDATE_RUNTIME_BINARY=${SHARUN_DIR}/shared/bin/mocktail
MOCKTAIL_UPDATE_HELPER=${SHARUN_DIR}/bin/mocktail_updater
MOCKTAIL_WEBVIEW_HELPER=${SHARUN_DIR}/bin/mocktail_webview_helper
MOCKTAIL_FAILURE_DIALOG_HELPER=${SHARUN_DIR}/bin/mocktail_failure_dialog
@@ -0,0 +1,602 @@
// cc -O2 -o mocktail_webview_host mocktail_webview_host.c $(pkg-config --cflags --libs gtk4 webkitgtk-6.0)
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <glib-unix.h>
#include <gtk/gtk.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <webkit/webkit.h>
#define PORT 2137
#define MAX_PACKET (64u * 1024u + 12u)
#define VERSION "2.725.1142"
enum {
OP_LOAD_URL = 1, OP_SET_TITLE = 2, OP_SET_VISIBLE = 3, OP_EVAL_JS = 4,
OP_CLOSE = 5, OP_SET_BACK_DISABLED = 6, OP_SHOW_DOMAIN_TITLE = 7,
OP_SET_COOKIE = 8, OP_CLEAR_COOKIE = 9, OP_RETAIN_COOKIE = 10
};
enum { EV_EXECUTE_ROBLOX = 1, EV_ROBLOX_WK_HYBRID = 2, EV_READY = 3, EV_COOKIE = 4 };
static const char kBridge[] =
"(() => { 'use strict';"
" const h = window.webkit && window.webkit.messageHandlers;"
" const handler = h && h.executeRoblox; if (!handler) return;"
" const bridge = {};"
" Object.defineProperty(bridge, 'executeRoblox', {"
" value: (q) => handler.postMessage(JSON.parse(q)),"
" enumerable: true, writable: false, configurable: false });"
" try { Object.defineProperty(window, '__globalRobloxAndroidBridge__', {"
" value: bridge, enumerable: true, writable: false, configurable: false });"
" } catch (_) {} })();";
static GtkApplication *app;
static GtkWindow *window;
static WebKitWebView *view;
static WebKitNetworkSession *session;
static int listener = -1;
static int client = -1;
static guint client_read_source;
static guint client_write_source;
static GByteArray *rx;
static GQueue *tx;
static size_t tx_offset;
static gboolean domain_title;
static gboolean login_page;
static gboolean back_disabled;
static gboolean shutting_down;
static char *last_cookie;
static gboolean flush_tx(gint fd, GIOCondition cond, gpointer data);
static gboolean on_client(gint fd, GIOCondition cond, gpointer data);
static void apply(uint8_t op, const char *payload, size_t size);
static void create_surface(void);
static void destroy_surface(void);
static void drop_client(const char *why) {
if (client < 0) return;
fprintf(stderr, "client dropped: %s\n", why);
if (client_read_source) {
g_source_remove(client_read_source);
client_read_source = 0;
}
if (client_write_source) {
g_source_remove(client_write_source);
client_write_source = 0;
}
close(client);
client = -1;
g_byte_array_set_size(rx, 0);
while (!g_queue_is_empty(tx)) g_bytes_unref(g_queue_pop_head(tx));
tx_offset = 0;
g_free(last_cookie);
last_cookie = NULL;
destroy_surface();
}
static void quit_app(void) {
if (shutting_down) return;
shutting_down = TRUE;
drop_client("shutting down");
if (app) g_application_quit(G_APPLICATION(app));
}
static void send_event(uint8_t type, const char *payload, size_t size) {
if (client < 0) return;
size_t body = 12 + size;
uint8_t *buffer = g_malloc0(4 + body);
buffer[0] = body >> 24; buffer[1] = body >> 16; buffer[2] = body >> 8; buffer[3] = body;
memcpy(buffer + 4, "MWVE", 4);
buffer[8] = 1;
buffer[9] = type;
buffer[12] = size >> 24; buffer[13] = size >> 16; buffer[14] = size >> 8; buffer[15] = size;
if (size) memcpy(buffer + 16, payload, size);
g_queue_push_tail(tx, g_bytes_new_take(buffer, 4 + body));
if (!client_write_source)
client_write_source = g_unix_fd_add(client, G_IO_OUT | G_IO_HUP | G_IO_ERR, flush_tx, NULL);
}
static gboolean flush_tx(gint fd, GIOCondition cond, gpointer data) {
(void)data;
if (cond & (G_IO_HUP | G_IO_ERR)) {
client_write_source = 0;
drop_client("write side closed");
return G_SOURCE_REMOVE;
}
while (!g_queue_is_empty(tx)) {
GBytes *head = g_queue_peek_head(tx);
gsize length;
const uint8_t *bytes = g_bytes_get_data(head, &length);
ssize_t count = send(fd, bytes + tx_offset, length - tx_offset, MSG_DONTWAIT | MSG_NOSIGNAL);
if (count < 0 && errno == EINTR) continue;
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return G_SOURCE_CONTINUE;
if (count <= 0) {
client_write_source = 0;
drop_client("write failed");
return G_SOURCE_REMOVE;
}
tx_offset += (size_t)count;
if (tx_offset == length) {
tx_offset = 0;
g_bytes_unref(g_queue_pop_head(tx));
}
}
client_write_source = 0;
return G_SOURCE_REMOVE;
}
static char *user_agent(void) {
const char *v = getenv("MOCKTAIL_ROBLOX_VERSION");
if (!v || !*v) v = VERSION;
return g_strdup_printf(
"Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) ROBLOX Android App "
"%s Tablet Hybrid() GooglePlayStore RobloxApp/%s (GlobalDist; GooglePlayStore)", v, v);
}
static void on_cookies(GObject *source, GAsyncResult *result, gpointer data) {
(void)data;
GError *error = NULL;
GList *cookies = webkit_cookie_manager_get_cookies_finish(WEBKIT_COOKIE_MANAGER(source), result, &error);
for (GList *i = cookies; i; i = i->next) {
SoupCookie *c = i->data;
const char *name = soup_cookie_get_name(c);
const char *value = soup_cookie_get_value(c);
if (name && value && strcmp(name, ".ROBLOSECURITY") == 0 && *value &&
(!last_cookie || strcmp(last_cookie, value) != 0)) {
g_free(last_cookie);
last_cookie = g_strdup(value);
send_event(EV_COOKIE, value, strlen(value));
break;
}
}
g_list_free_full(cookies, (GDestroyNotify)soup_cookie_free);
g_clear_error(&error);
}
static void request_cookie(void) {
if (!login_page || !session || client < 0) return;
webkit_cookie_manager_get_cookies(webkit_network_session_get_cookie_manager(session),
"https://www.roblox.com/", NULL, on_cookies, NULL);
}
static void on_cookie_changed(WebKitCookieManager *manager, gpointer data) {
(void)manager; (void)data;
request_cookie();
}
static void configure_agent(const char *url) {
WebKitSettings *settings = webkit_web_view_get_settings(view);
GUri *uri = g_uri_parse(url, G_URI_FLAGS_NONE, NULL);
const char *path = uri ? g_uri_get_path(uri) : NULL;
login_page = path && (strcmp(path, "/login") == 0 || strcmp(path, "/login/") == 0);
if (uri) g_uri_unref(uri);
if (login_page) {
webkit_settings_set_user_agent(settings, NULL);
request_cookie();
} else {
char *agent = user_agent();
webkit_settings_set_user_agent(settings, agent);
g_free(agent);
}
}
static void update_title(void) {
if (!domain_title || !window || !view) return;
const char *uri = webkit_web_view_get_uri(view);
if (!uri) return;
GUri *parsed = g_uri_parse(uri, G_URI_FLAGS_NONE, NULL);
if (!parsed) return;
const char *host = g_uri_get_host(parsed);
if (host) gtk_window_set_title(window, host);
g_uri_unref(parsed);
}
static void on_load_changed(WebKitWebView *v, WebKitLoadEvent event, gpointer data) {
(void)v; (void)data;
if (event == WEBKIT_LOAD_COMMITTED || event == WEBKIT_LOAD_FINISHED) {
update_title();
request_cookie();
}
}
static GtkWidget *on_create(WebKitWebView *v, WebKitNavigationAction *action, gpointer data) {
(void)v; (void)data;
WebKitURIRequest *request = webkit_navigation_action_get_request(action);
const char *uri = request ? webkit_uri_request_get_uri(request) : NULL;
if (uri && view) webkit_web_view_load_uri(view, uri);
return NULL;
}
static char *json_string_after(const char *json, const char *key) {
char *needle = g_strdup_printf("\"%s\"", key);
const char *at = strstr(json, needle);
g_free(needle);
if (!at) return NULL;
at = strchr(at + strlen(key) + 2, ':');
if (!at) return NULL;
at++;
while (*at == ' ') at++;
if (*at != '"') return NULL;
at++;
GString *out = g_string_new(NULL);
while (*at && *at != '"') {
if (*at == '\\' && at[1]) at++;
g_string_append_c(out, *at);
at++;
}
return g_string_free(out, FALSE);
}
static void on_callback_done(GObject *source, GAsyncResult *result, gpointer data) {
gboolean close_after = GPOINTER_TO_INT(data);
GError *error = NULL;
JSCValue *value = webkit_web_view_evaluate_javascript_finish(WEBKIT_WEB_VIEW(source), result, &error);
gboolean confirmed = !error && value && jsc_value_is_boolean(value) && jsc_value_to_boolean(value);
if (value) g_object_unref(value);
g_clear_error(&error);
if (confirmed && close_after) destroy_surface();
}
static void acknowledge_page_command(const char *json) {
if (!view || !json) return;
char *feature = json_string_after(json, "feature");
char *callback = json_string_after(json, "callbackID");
if (feature && callback && *callback) {
gboolean success = strcmp(feature, "CaptchaSuccess") == 0;
if (success || strcmp(feature, "CaptchaShown") == 0) {
GString *quoted = g_string_new("\"");
for (const char *p = callback; *p; p++) {
if (*p == '"' || *p == '\\') g_string_append_c(quoted, '\\');
g_string_append_c(quoted, *p);
}
g_string_append_c(quoted, '"');
char *script = g_strdup_printf(
"(() => { try { if (window.Roblox && window.Roblox.Hybrid && "
"window.Roblox.Hybrid.Bridge && typeof window.Roblox.Hybrid.Bridge.nativeCallback === 'function') { "
"window.Roblox.Hybrid.Bridge.nativeCallback(%s, true, {}); return true; } } catch (_) {} return false; })();",
quoted->str);
webkit_web_view_evaluate_javascript(view, script, -1, NULL, NULL, NULL,
on_callback_done, GINT_TO_POINTER(success));
g_free(script);
g_string_free(quoted, TRUE);
}
}
g_free(feature);
g_free(callback);
}
static void forward(uint8_t type, JSCValue *value) {
char *text = NULL;
if (jsc_value_is_string(value)) text = jsc_value_to_string(value);
else if (jsc_value_is_object(value)) text = jsc_value_to_json(value, 0);
if (!text) return;
size_t size = strlen(text);
if (size && size <= 64u * 1024u) {
send_event(type, text, size);
acknowledge_page_command(text);
}
g_free(text);
}
static void on_execute_roblox(WebKitUserContentManager *m, JSCValue *value, gpointer data) {
(void)m; (void)data;
forward(EV_EXECUTE_ROBLOX, value);
}
static void on_hybrid(WebKitUserContentManager *m, JSCValue *value, gpointer data) {
(void)m; (void)data;
if (!jsc_value_is_object(value)) return;
JSCValue *command = jsc_value_object_get_property(value, "command");
if (command) {
forward(EV_ROBLOX_WK_HYBRID, command);
g_object_unref(command);
}
}
static void on_view_close(WebKitWebView *v, gpointer data) {
(void)v; (void)data;
destroy_surface();
}
static gboolean on_close_request(GtkWindow *w, gpointer data) {
(void)w; (void)data;
window = NULL;
view = NULL;
return FALSE;
}
static void on_reload(GtkButton *button, gpointer data) {
(void)button; (void)data;
if (view) webkit_web_view_reload_bypass_cache(view);
}
static void on_eval(GObject *source, GAsyncResult *result, gpointer data) {
(void)data;
GError *error = NULL;
JSCValue *value = webkit_web_view_evaluate_javascript_finish(WEBKIT_WEB_VIEW(source), result, &error);
if (value) g_object_unref(value);
g_clear_error(&error);
}
static void on_cookie_done(GObject *source, GAsyncResult *result, gpointer data) {
(void)data;
GError *error = NULL;
webkit_cookie_manager_add_cookie_finish(WEBKIT_COOKIE_MANAGER(source), result, &error);
g_clear_error(&error);
}
static void on_cookie_deleted(GObject *source, GAsyncResult *result, gpointer data) {
(void)data;
GError *error = NULL;
webkit_cookie_manager_delete_cookie_finish(WEBKIT_COOKIE_MANAGER(source), result, &error);
g_clear_error(&error);
}
static void ensure_session(void) {
if (session) return;
gchar *data_dir = g_build_filename(g_get_user_data_dir(), "mocktail", "webview", NULL);
gchar *cache_dir = g_build_filename(g_get_user_cache_dir(), "mocktail", "webview", NULL);
g_mkdir_with_parents(data_dir, 0700);
g_mkdir_with_parents(cache_dir, 0700);
gchar *cookie_db = g_build_filename(data_dir, "cookies.sqlite", NULL);
session = webkit_network_session_new(data_dir, cache_dir);
g_free(data_dir);
g_free(cache_dir);
WebKitCookieManager *cookies = webkit_network_session_get_cookie_manager(session);
webkit_cookie_manager_set_persistent_storage(cookies, cookie_db, WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE);
webkit_cookie_manager_set_accept_policy(cookies, WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS);
g_signal_connect(cookies, "changed", G_CALLBACK(on_cookie_changed), NULL);
g_free(cookie_db);
webkit_network_session_set_itp_enabled(session, FALSE);
webkit_network_session_set_persistent_credential_storage_enabled(session, TRUE);
}
static void create_surface(void) {
if (window || view) return;
ensure_session();
WebKitUserContentManager *manager = webkit_user_content_manager_new();
WebKitWebView *created = WEBKIT_WEB_VIEW(g_object_new(WEBKIT_TYPE_WEB_VIEW, "network-session", session,
"user-content-manager", manager, NULL));
g_object_unref(manager);
manager = webkit_web_view_get_user_content_manager(created);
g_signal_connect(manager, "script-message-received::executeRoblox", G_CALLBACK(on_execute_roblox), NULL);
g_signal_connect(manager, "script-message-received::RobloxWKHybrid", G_CALLBACK(on_hybrid), NULL);
g_signal_connect(manager, "script-message-received::mocktailRobloxBridge", G_CALLBACK(on_execute_roblox), NULL);
webkit_user_content_manager_register_script_message_handler(manager, "executeRoblox", NULL);
webkit_user_content_manager_register_script_message_handler(manager, "RobloxWKHybrid", NULL);
webkit_user_content_manager_register_script_message_handler(manager, "mocktailRobloxBridge", NULL);
WebKitUserScript *script = webkit_user_script_new(kBridge, WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, NULL, NULL);
webkit_user_content_manager_add_script(manager, script);
webkit_user_script_unref(script);
WebKitSettings *settings = webkit_web_view_get_settings(created);
webkit_settings_set_enable_javascript(settings, TRUE);
webkit_settings_set_javascript_can_open_windows_automatically(settings, TRUE);
webkit_settings_set_enable_back_forward_navigation_gestures(settings, !back_disabled);
char *agent = user_agent();
webkit_settings_set_user_agent(settings, agent);
g_free(agent);
g_signal_connect(created, "load-changed", G_CALLBACK(on_load_changed), NULL);
g_signal_connect(created, "create", G_CALLBACK(on_create), NULL);
g_signal_connect(created, "close", G_CALLBACK(on_view_close), NULL);
GtkWindow *created_window = GTK_WINDOW(gtk_application_window_new(app));
gtk_window_set_title(created_window, "Roblox");
gtk_window_set_default_size(created_window, 1100, 760);
GtkWidget *header = gtk_header_bar_new();
GtkWidget *reload = gtk_button_new_from_icon_name("view-refresh-symbolic");
g_signal_connect(reload, "clicked", G_CALLBACK(on_reload), NULL);
gtk_header_bar_pack_start(GTK_HEADER_BAR(header), reload);
gtk_window_set_titlebar(created_window, header);
gtk_widget_set_vexpand(GTK_WIDGET(created), TRUE);
gtk_window_set_child(created_window, GTK_WIDGET(created));
g_signal_connect(created_window, "close-request", G_CALLBACK(on_close_request), NULL);
view = created;
window = created_window;
}
static void destroy_surface(void) {
GtkWindow *w = window;
window = NULL;
view = NULL;
login_page = FALSE;
domain_title = FALSE;
if (w) gtk_window_destroy(w);
}
static void apply(uint8_t op, const char *payload, size_t size) {
if (op == OP_CLOSE) {
destroy_surface();
return;
}
if (op == OP_SET_BACK_DISABLED) {
back_disabled = size == 1 && payload[0];
if (view) webkit_settings_set_enable_back_forward_navigation_gestures(
webkit_web_view_get_settings(view), !back_disabled);
return;
}
if (op == OP_SHOW_DOMAIN_TITLE) {
domain_title = size == 1 && payload[0];
update_title();
return;
}
if (op == OP_SET_COOKIE || op == OP_CLEAR_COOKIE) {
ensure_session();
WebKitCookieManager *cookies = webkit_network_session_get_cookie_manager(session);
if (op == OP_SET_COOKIE) {
char *value = g_strndup(payload, size);
SoupCookie *cookie = soup_cookie_new(".ROBLOSECURITY", value, ".roblox.com", "/", -1);
g_free(value);
if (cookie) {
soup_cookie_set_secure(cookie, TRUE);
soup_cookie_set_http_only(cookie, TRUE);
soup_cookie_set_same_site_policy(cookie, SOUP_SAME_SITE_POLICY_LAX);
webkit_cookie_manager_add_cookie(cookies, cookie, NULL, on_cookie_done, NULL);
soup_cookie_free(cookie);
}
} else {
SoupCookie *cookie = soup_cookie_new(".ROBLOSECURITY", "", ".roblox.com", "/", -1);
if (cookie) {
webkit_cookie_manager_delete_cookie(cookies, cookie, NULL, on_cookie_deleted, NULL);
soup_cookie_free(cookie);
}
}
return;
}
if (op == OP_RETAIN_COOKIE) return;
if (op == OP_LOAD_URL) create_surface();
if (!window || !view) return;
switch (op) {
case OP_LOAD_URL: {
char *url = g_strndup(payload, size);
configure_agent(url);
webkit_web_view_load_uri(view, url);
gtk_window_present(window);
g_free(url);
break;
}
case OP_SET_TITLE: {
char *title = g_strndup(payload, size);
gtk_window_set_title(window, title);
g_free(title);
break;
}
case OP_SET_VISIBLE:
if (size == 1 && payload[0]) gtk_window_present(window);
else gtk_widget_set_visible(GTK_WIDGET(window), FALSE);
break;
case OP_EVAL_JS:
webkit_web_view_evaluate_javascript(view, payload, (gssize)size, NULL, NULL, NULL, on_eval, NULL);
break;
default:
break;
}
}
static gboolean process_frames(void) {
while (rx->len >= 4) {
uint32_t length = ((uint32_t)rx->data[0] << 24) | ((uint32_t)rx->data[1] << 16) |
((uint32_t)rx->data[2] << 8) | rx->data[3];
if (length > MAX_PACKET) return FALSE;
if (rx->len < 4u + length) break;
uint8_t *copy = g_memdup2(rx->data + 4, length);
g_byte_array_remove_range(rx, 0, 4 + length);
if (length >= 12 && memcmp(copy, "MWVC", 4) == 0) {
size_t size = ((size_t)copy[8] << 24) | ((size_t)copy[9] << 16) |
((size_t)copy[10] << 8) | copy[11];
if (size == length - 12) apply(copy[5], (const char *)copy + 12, size);
}
g_free(copy);
if (client < 0) return TRUE;
}
return TRUE;
}
static gboolean on_client(gint fd, GIOCondition cond, gpointer data) {
(void)data;
uint8_t buffer[8192];
while (TRUE) {
ssize_t count = read(fd, buffer, sizeof buffer);
if (count < 0 && errno == EINTR) continue;
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) break;
if (count <= 0) {
client_read_source = 0;
drop_client("client closed");
return G_SOURCE_REMOVE;
}
g_byte_array_append(rx, buffer, (guint)count);
if (!process_frames()) {
client_read_source = 0;
drop_client("bad frame");
return G_SOURCE_REMOVE;
}
if (client < 0) return G_SOURCE_REMOVE;
}
if (cond & (G_IO_HUP | G_IO_ERR)) {
client_read_source = 0;
drop_client("client hung up");
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
static gboolean on_listener(gint fd, GIOCondition cond, gpointer data) {
(void)cond; (void)data;
int accepted = accept(fd, NULL, NULL);
if (accepted < 0) return G_SOURCE_CONTINUE;
if (client >= 0) drop_client("replaced by a new connection");
fcntl(accepted, F_SETFD, FD_CLOEXEC);
fcntl(accepted, F_SETFL, fcntl(accepted, F_GETFL) | O_NONBLOCK);
int one = 1;
setsockopt(accepted, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
client = accepted;
fprintf(stderr, "client connected\n");
client_read_source = g_unix_fd_add(client, G_IO_IN | G_IO_HUP | G_IO_ERR, on_client, NULL);
send_event(EV_READY, "", 0);
return G_SOURCE_CONTINUE;
}
static int listen_socket(void) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) return -1;
fcntl(fd, F_SETFD, FD_CLOEXEC);
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
struct sockaddr_in address;
memset(&address, 0, sizeof address);
address.sin_family = AF_INET;
address.sin_port = htons(PORT);
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (bind(fd, (struct sockaddr *)&address, sizeof address) != 0 || listen(fd, 4) != 0) {
close(fd);
return -1;
}
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
return fd;
}
static void activate(GtkApplication *application, gpointer data) {
(void)application; (void)data;
}
static gboolean on_sigterm(gpointer data) {
(void)data;
quit_app();
return G_SOURCE_REMOVE;
}
int main(int argc, char **argv) {
(void)argc;
setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 0);
setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", 0);
rx = g_byte_array_new();
tx = g_queue_new();
listener = listen_socket();
if (listener < 0) {
fprintf(stderr, "cannot listen on 127.0.0.1:%d: %s\n", PORT, strerror(errno));
return 1;
}
g_unix_fd_add(listener, G_IO_IN, on_listener, NULL);
g_unix_signal_add(SIGTERM, on_sigterm, NULL);
g_unix_signal_add(SIGINT, on_sigterm, NULL);
app = gtk_application_new("org.mocktail.WebViewHost", G_APPLICATION_NON_UNIQUE);
g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
g_application_hold(G_APPLICATION(app));
int status = g_application_run(G_APPLICATION(app), 1, argv);
g_object_unref(app);
return status;
}
@@ -57,6 +57,32 @@
<binary>mocktail</binary>
</provides>
<releases>
<release version="0.3.4" date="2026-10-11">
<description>
<ul>
<li>Fixed Roblox 2.742 being rejected as an update.</li>
<li>The new Roblox home screen and its side menu now show.</li>
<li>Fixed the voice chat microphone after Roblox updates.</li>
<li>Anti-aliasing is no longer forced off.</li>
<li>Nightcap now uses Wayland on NVIDIA Wayland sessions.</li>
<li>Fixed a missing picture on machines whose graphics card runs a different driver.</li>
<li>Fewer frame hitches while textures load.</li>
</ul>
</description>
</release>
<release version="0.3.3" date="2026-10-04">
<description>
<ul>
<li>Joining a friend from a follow user link now works.</li>
<li>Roblox textures decode about 3 times faster, so loading stalls are shorter.</li>
<li>A failed Roblox update now restores your previous install instead of leaving it half changed.</li>
<li>Fixed crashes and freezes with audio, the text overlay, the mouse lock and saved themes.</li>
<li>Going to another page in the web view no longer closes it.</li>
<li>Clicking while typing no longer closes the text box.</li>
<li>Fixed the sky showing the wrong textures.</li>
</ul>
</description>
</release>
<release version="0.3.2" date="2026-09-29">
<description>
<ul>
+429 -77
View File
@@ -23,6 +23,7 @@ supported.
from __future__ import annotations
import argparse
import contextlib
from dataclasses import dataclass
import hashlib
import json
@@ -161,6 +162,10 @@ class SignatureSpec:
allow_registry_object_size_change: bool = False
minimum_anchor_bytes: int = 6
registry_size_indices: tuple[int, int] = (8, 12)
# Instructions whose register operands the compiler may have chosen
# differently. Each reference register must stand for one candidate
# register across all of them, and the reverse.
renamed_register_indices: tuple[int, ...] = ()
@dataclass(frozen=True)
@@ -1075,6 +1080,51 @@ def semantic_shape(
)
def register_family(name: str) -> str:
"""Return the 64-bit register that name is all or part of."""
if len(name) >= 2 and name[0] == "r" and name[1].isdigit():
return name.rstrip("bwd")
for family in ("ax", "bx", "cx", "dx", "si", "di", "bp", "sp"):
if name in (family, "e" + family, "r" + family):
return "r" + family
return name
def renamed_shapes_match(
reference_shape: tuple[Any, ...],
candidate_shape: tuple[Any, ...],
forward: dict[str, str],
backward: dict[str, str],
) -> bool:
"""Compare two semantic shapes, letting register operands be renamed.
forward and backward hold the renames accepted so far and gain new ones.
"""
reference_mnemonic, reference_operands = reference_shape
candidate_mnemonic, candidate_operands = candidate_shape
if reference_mnemonic != candidate_mnemonic or len(reference_operands) != len(
candidate_operands
):
return False
for left, right in zip(reference_operands, candidate_operands):
if left[0] != "reg" or right[0] != "reg":
if left != right:
return False
continue
if left[2] != right[2]:
return False
reference_family = register_family(left[1])
candidate_family = register_family(right[1])
if (
forward.setdefault(reference_family, candidate_family)
!= candidate_family
or backward.setdefault(candidate_family, reference_family)
!= reference_family
):
return False
return True
def validate_semantic_match(
reference: Sequence[Any],
candidate: Sequence[Any],
@@ -1082,6 +1132,8 @@ def validate_semantic_match(
) -> None:
if len(reference) != len(candidate):
raise AnalyzerError("normalized signature instruction count changed")
forward: dict[str, str] = {}
backward: dict[str, str] = {}
for index, (reference_instruction, candidate_instruction) in enumerate(
zip(reference, candidate)
):
@@ -1089,8 +1141,14 @@ def validate_semantic_match(
spec.allow_registry_object_size_change
and index in spec.registry_size_indices
)
if semantic_shape(reference_instruction, wildcard_size) != semantic_shape(
candidate_instruction, wildcard_size
reference_shape = semantic_shape(reference_instruction, wildcard_size)
candidate_shape = semantic_shape(candidate_instruction, wildcard_size)
if (
not renamed_shapes_match(
reference_shape, candidate_shape, forward, backward
)
if index in spec.renamed_register_indices
else reference_shape != candidate_shape
):
raise AnalyzerError(
f"normalized instruction semantics changed at signature index {index}"
@@ -1541,6 +1599,7 @@ def derive_runtime_compatibility(
reference: ElfImage,
candidate: ElfImage,
manifests: Sequence[Path],
fallback: ElfImage | None = None,
) -> dict[str, Any]:
source = load_reference_runtime_profile(manifests, reference.build_id)
result: dict[str, Any] = {}
@@ -1563,9 +1622,38 @@ def derive_runtime_compatibility(
result["fmod_output_device_bridge"] = derive_fmod_output_device_bridge(
reference, candidate, fmod
)
elif fallback is not None:
bridge = derive_fallback_fmod_output_device_bridge(
fallback, candidate, manifests
)
if bridge is not None:
result["fmod_output_device_bridge"] = bridge
return result
def derive_fallback_fmod_output_device_bridge(
fallback: ElfImage,
candidate: ElfImage,
manifests: Sequence[Path],
) -> dict[str, Any] | None:
"""Derive the optional FMOD bridge from another build in the manifests.
Any failure leaves the bridge out.
"""
if fallback.build_id == candidate.build_id:
return None
try:
fallback.validate_toolchain()
fmod = load_reference_runtime_profile(manifests, fallback.build_id).get(
"fmod_output_device_bridge"
)
if fmod is None:
return None
return derive_fmod_output_device_bridge(fallback, candidate, fmod)
except AnalyzerError:
return None
def find_registry_function_entry(
candidate: ElfImage,
body_rva: int,
@@ -1613,6 +1701,73 @@ def find_registry_function_entry(
return entries[0]
def find_renamed_registry_body_match(
reference: ElfImage,
candidate: ElfImage,
reference_body: Sequence[Any],
minimum_anchor_bytes: int,
) -> SignatureMatch:
"""Find the registry initializer body under other temporary registers.
The body allocates the registry, clears it, and starts filling it:
0 mov edi, SIZE 6 xor esi, esi
1 call allocate 7 call memset
2 mov rbx, rax 8 mov ONE, 0x3f800000
3 xor ZERO, ZERO 9 mov dword ptr [rbx + 0x20], ONE
4 mov edx, SIZE 10 lea FIELD, [rbx + 0x28]
5 mov rdi, rax
ZERO, ONE and FIELD are the compiler's choice. Instructions 4 to 7 use
registers the calling convention fixes, so they are searched for by bytes
and every hit is then checked against all eleven instructions.
"""
clear_index = 4
clear_matches = find_signature_matches(
reference,
candidate,
SignatureSpec(
"registry-initializer-clear",
reference_body[clear_index].address,
4,
True,
minimum_anchor_bytes,
(0, 0),
),
allow_multiple_candidates=True,
)
body_spec = SignatureSpec(
"registry-initializer-body",
reference_body[0].address,
len(reference_body),
True,
minimum_anchor_bytes,
(0, 4),
(3, 8, 9, 10),
)
# Instructions 0 to 3 keep their lengths as long as ZERO stays one of r8
# to r15, so the body starts this far before the clear sequence.
lead = reference_body[clear_index].address - reference_body[0].address
found = []
for clear in clear_matches:
if clear.rva < lead:
continue
try:
body = disassemble(candidate, clear.rva - lead, len(reference_body))
if body[clear_index].address != clear.rva:
continue
validate_semantic_match(reference_body, body, body_spec)
except AnalyzerError:
continue
found.append(SignatureMatch(clear.rva - lead, tuple(reference_body), body))
if len(found) != 1:
raise AnalyzerError(
f"signature registry-initializer-body matched {len(found)} "
"candidate locations with renamed registers"
)
return found[0]
def find_registry_signature_match(
reference: ElfImage,
candidate: ElfImage,
@@ -1633,7 +1788,18 @@ def find_registry_signature_match(
spec.minimum_anchor_bytes,
(0, 4),
)
body_match = find_signature_match(reference, candidate, body_spec)
try:
body_match = find_signature_match(reference, candidate, body_spec)
except AnalyzerError:
body_match = find_renamed_registry_body_match(
reference,
candidate,
reference_full[
body_instruction_index : body_instruction_index
+ body_instruction_count
],
spec.minimum_anchor_bytes,
)
candidate_entry = find_registry_function_entry(
candidate,
body_match.rva,
@@ -1671,14 +1837,14 @@ def find_constructor_boundary_match(
reference: ElfImage,
candidate: ElfImage,
spec: SignatureSpec,
reference_entry: int,
candidate_entry: int,
candidate_entries: Sequence[int],
) -> SignatureMatch:
if reference_entry != spec.reference_rva:
raise AnalyzerError(
"reference constructor boundary moved outside init-array index 4"
)
reference_wrapper = disassemble(reference, reference_entry, 1)
"""Match a constructor that is one jump to its body.
The body is found by signature, and the candidate constructor is the one
of candidate_entries that jumps to it.
"""
reference_wrapper = disassemble(reference, spec.reference_rva, 1)
reference_target = direct_jump_target(
reference_wrapper[0], "reference constructor boundary"
)
@@ -1686,20 +1852,29 @@ def find_constructor_boundary_match(
reference,
candidate,
SignatureSpec(
"constructor-4-target",
f"{spec.name}-target",
reference_target,
spec.instruction_count,
),
)
candidate_wrapper = disassemble(candidate, candidate_entry, 1)
candidate_target = direct_jump_target(
candidate_wrapper[0], "candidate constructor boundary"
)
if candidate_target != target_match.rva:
found = []
for candidate_entry in candidate_entries:
try:
candidate_wrapper = disassemble(candidate, candidate_entry, 1)
candidate_target = direct_jump_target(
candidate_wrapper[0], "candidate constructor boundary"
)
except AnalyzerError:
continue
if candidate_target == target_match.rva:
found.append(
SignatureMatch(candidate_entry, reference_wrapper, candidate_wrapper)
)
if len(found) != 1:
raise AnalyzerError(
"constructor index 4 no longer targets its verified boundary"
f"{spec.name} no longer targets its verified boundary"
)
return SignatureMatch(candidate_entry, reference_wrapper, candidate_wrapper)
return found[0]
def rip_targets(instruction: Any) -> tuple[int, ...]:
@@ -1713,6 +1888,161 @@ def rip_targets(instruction: Any) -> tuple[int, ...]:
return tuple(targets)
def is_register_operand(operand: Any, register: int) -> bool:
return operand.type == capstone_x86.X86_OP_REG and operand.reg == register
def jni_singleton_publication(image: ElfImage, entry: int) -> tuple[Any, ...]:
"""Return the instructions that publish the JNI singleton.
They open its initializer at entry, after a stack frame if it has one:
lea rax, [rip + OBJECT]
xchg qword ptr [rip + SLOT], rax
mov qword ptr [rip + OBJECT], rax
OBJECT is the eight bytes after SLOT.
"""
for frame in (0, 2):
try:
decoded = disassemble(image, entry, frame + 3)
except AnalyzerError:
continue
if frame:
push, link = decoded[:2]
if (
push.mnemonic != "push"
or len(push.operands) != 1
or not is_register_operand(push.operands[0], capstone_x86.X86_REG_RBP)
or link.mnemonic != "mov"
or len(link.operands) != 2
or not is_register_operand(link.operands[0], capstone_x86.X86_REG_RBP)
or not is_register_operand(link.operands[1], capstone_x86.X86_REG_RSP)
):
continue
load, exchange, store = decoded[frame:]
object_targets = rip_targets(load)
slot_targets = rip_targets(exchange)
stored_targets = rip_targets(store)
if (
load.mnemonic == "lea"
and len(load.operands) == 2
and is_register_operand(load.operands[0], capstone_x86.X86_REG_RAX)
and exchange.mnemonic == "xchg"
and len(exchange.operands) == 2
and exchange.operands[0].size == 8
and is_register_operand(exchange.operands[1], capstone_x86.X86_REG_RAX)
and store.mnemonic == "mov"
and len(store.operands) == 2
and store.operands[0].size == 8
and is_register_operand(store.operands[1], capstone_x86.X86_REG_RAX)
and len(object_targets) == 1
and len(slot_targets) == 1
and len(stored_targets) == 1
and object_targets[0] == stored_targets[0]
and object_targets[0] == slot_targets[0] + 8
):
return decoded[frame:]
raise AnalyzerError(
f"JNI singleton initializer at {format_rva(entry)} "
"does not publish its singleton"
)
def find_jni_singleton_match(
reference: ElfImage,
candidate: ElfImage,
spec: SignatureSpec,
candidate_entries: Sequence[int],
) -> SignatureMatch:
"""Find the JNI singleton initializer among candidate_entries.
The instructions after the publication follow the singleton's layout, so
when a release changes that layout only the publication is matched. The
initializer itself runs from the init-array either way.
"""
try:
return find_signature_match(reference, candidate, spec)
except AnalyzerError:
pass
reference_publication = jni_singleton_publication(reference, spec.reference_rva)
found = find_signature_match(
reference,
candidate,
SignatureSpec(
"jni-singleton-publication",
reference_publication[0].address,
len(reference_publication),
minimum_anchor_bytes=3,
),
)
for entry in candidate_entries:
try:
publication = jni_singleton_publication(candidate, entry)
except AnalyzerError:
continue
if publication[0].address == found.rva:
return SignatureMatch(entry, reference_publication, publication)
raise AnalyzerError("JNI singleton initializer is not a leading constructor")
# The native replay starts at this init-array index on x86_64.
LEADING_CONSTRUCTOR_BEGIN = 2
# The process-load constructor, the JNI singleton initializer and two wiring
# entries. A release may put them in any order.
LEADING_CONSTRUCTOR_COUNT = 4
def match_leading_constructors(
reference: ElfImage,
candidate: ElfImage,
constructor_anchors: dict[str, Any],
jni_spec: SignatureSpec,
reference_init: Sequence[int],
candidate_init: Sequence[int],
) -> tuple[tuple[int, ...], SignatureMatch]:
"""Find each reference leading constructor in the candidate by its code.
Returns, for each reference leading constructor, the candidate init-array
index of the same constructor, both counted from
LEADING_CONSTRUCTOR_BEGIN, and the JNI singleton initializer match.
"""
moved = "constructor moved outside verified init-array boundary"
end = LEADING_CONSTRUCTOR_BEGIN + LEADING_CONSTRUCTOR_COUNT
if len(reference_init) < end or len(candidate_init) < end:
raise AnalyzerError("init-array shape is not derivable")
candidate_entries = tuple(candidate_init[LEADING_CONSTRUCTOR_BEGIN:end])
candidate_offsets = []
jni_match = None
for index in range(LEADING_CONSTRUCTOR_BEGIN, end):
reference_entry = reference_init[index]
name = f"constructor-{index}"
if parse_rva(constructor_anchors.get(str(index)), name) != reference_entry:
raise AnalyzerError(moved)
if reference_entry == jni_spec.reference_rva:
match = find_jni_singleton_match(
reference, candidate, jni_spec, candidate_entries
)
jni_match = match
else:
spec = SignatureSpec(name, reference_entry, 24)
if disassemble(reference, reference_entry, 1)[0].mnemonic == "jmp":
match = find_constructor_boundary_match(
reference, candidate, spec, candidate_entries
)
else:
match = find_signature_match(reference, candidate, spec)
if match.rva not in candidate_entries:
raise AnalyzerError(moved)
candidate_offset = candidate_entries.index(match.rva)
if candidate_offset in candidate_offsets:
raise AnalyzerError(moved)
candidate_offsets.append(candidate_offset)
if jni_match is None:
raise AnalyzerError("JNI singleton initializer is not a leading constructor")
return tuple(candidate_offsets), jni_match
def mapped_rip_target(
match: SignatureMatch, reference_target: int, description: str
) -> int:
@@ -1805,7 +2135,6 @@ def create_signature_specs(
bridges = bridge_map(profile)
data_seeds = profile["data_seeds"]
bootstrap = profile["native_pre_jni_bootstrap"]
constructor_rvas = anchors["constructor_rvas"]
return [
SignatureSpec(
"small-allocate",
@@ -1870,36 +2199,51 @@ def create_signature_specs(
parse_rva(anchors["jni_singleton_initializer_rva"], "JNI singleton anchor"),
11,
),
SignatureSpec(
"constructor-2", parse_rva(constructor_rvas["2"], "constructor 2"), 24
),
SignatureSpec(
"constructor-3", parse_rva(constructor_rvas["3"], "constructor 3"), 24
),
SignatureSpec(
"constructor-4",
parse_rva(constructor_rvas["4"], "constructor 4"),
24,
minimum_anchor_bytes=3,
),
]
def adjusted_ranges(
ranges: list[dict[str, int]], reference_count: int, candidate_count: int
def remapped_constructor_ranges(
ranges: list[dict[str, int]],
candidate_offsets: Sequence[int],
reference_count: int,
candidate_count: int,
) -> list[dict[str, int]]:
result = []
"""Rewrite the reference constructor ranges for the candidate init-array.
Each leading constructor keeps its place in or out of the replay under
its candidate index, and every later constructor runs.
"""
cannot_fit = "reference constructor range cannot fit candidate init-array"
leading_end = LEADING_CONSTRUCTOR_BEGIN + LEADING_CONSTRUCTOR_COUNT
ranges = validated_ranges(ranges, "reference constructor ranges", reference_count)
if (
ranges[0]["begin"] < LEADING_CONSTRUCTOR_BEGIN
or ranges[-1]["begin"] > leading_end
or ranges[-1]["end_exclusive"] != reference_count
or reference_count <= leading_end
or candidate_count <= leading_end
):
raise AnalyzerError(cannot_fit)
runs = [False] * LEADING_CONSTRUCTOR_COUNT
for item in ranges:
end = (
candidate_count
if item["end_exclusive"] == reference_count
else item["end_exclusive"]
)
if end > candidate_count:
raise AnalyzerError(
"reference constructor range cannot fit candidate init-array"
)
result.append({"begin": item["begin"], "end_exclusive": end})
for index in range(item["begin"], min(item["end_exclusive"], leading_end)):
runs[candidate_offsets[index - LEADING_CONSTRUCTOR_BEGIN]] = True
result = []
run_begin = None
for offset, run in enumerate(runs):
index = LEADING_CONSTRUCTOR_BEGIN + offset
if run:
if run_begin is None:
run_begin = index
elif run_begin is not None:
result.append({"begin": run_begin, "end_exclusive": index})
run_begin = None
result.append(
{
"begin": leading_end if run_begin is None else run_begin,
"end_exclusive": candidate_count,
}
)
return validated_ranges(result, "derived constructor ranges", candidate_count)
@@ -1948,13 +2292,14 @@ def derive_profile(
matches[spec.name] = find_registry_signature_match(
reference, candidate, spec
)
elif spec.name == "constructor-4":
matches[spec.name] = find_constructor_boundary_match(
elif spec.name == "jni-singleton-initializer":
leading_offsets, matches[spec.name] = match_leading_constructors(
reference,
candidate,
anchors["constructor_rvas"],
spec,
reference_init[4],
candidate_init[4],
reference_init,
candidate_init,
)
else:
matches[spec.name] = find_signature_match(reference, candidate, spec)
@@ -1990,21 +2335,7 @@ def derive_profile(
"registry allocator call"
)
matches["allocate"] = selected_allocate[0]
for index in (2, 3, 4):
match = matches[f"constructor-{index}"]
if (
reference_init[index] != match.reference_instructions[0].address
or candidate_init[index] != match.rva
):
raise AnalyzerError(
f"constructor {index} moved outside its verified init-array boundary"
)
singleton_match = matches["jni-singleton-initializer"]
if (
reference_init[5] != singleton_match.reference_instructions[0].address
or candidate_init[5] != singleton_match.rva
):
raise AnalyzerError("JNI singleton initializer is not constructor index 5")
candidate_bridge_entries = []
for entry in reference_profile["bridge_entries"]:
@@ -2075,28 +2406,34 @@ def derive_profile(
init_section = candidate.sections[".init_array"]
reference_count = reference_profile["init_array_count"]
candidate_count = len(candidate_init)
constructor_ranges = adjusted_ranges(
validated_ranges(
reference_profile["constructor_run_ranges"],
"reference constructor ranges",
reference_count,
),
constructor_ranges = remapped_constructor_ranges(
reference_profile["constructor_run_ranges"],
leading_offsets,
reference_count,
candidate_count,
)
native_ranges = adjusted_ranges(
validated_ranges(
reference_profile["native_mimalloc_constructor_run_ranges"],
"reference native constructor ranges",
reference_count,
),
native_ranges = remapped_constructor_ranges(
reference_profile["native_mimalloc_constructor_run_ranges"],
leading_offsets,
reference_count,
candidate_count,
)
thread_boundary = reference_profile[
reference_thread_boundary = reference_profile[
"native_mimalloc_thread_initializer_after_constructor"
]
if thread_boundary != 2 or not any(
if not (
LEADING_CONSTRUCTOR_BEGIN
<= reference_thread_boundary
< LEADING_CONSTRUCTOR_BEGIN + LEADING_CONSTRUCTOR_COUNT
):
raise AnalyzerError("native allocator TLS boundary is no longer verified")
# The thread initializer follows the process-load constructor to its
# candidate index.
thread_boundary = (
LEADING_CONSTRUCTOR_BEGIN
+ leading_offsets[reference_thread_boundary - LEADING_CONSTRUCTOR_BEGIN]
)
if not any(
item["begin"] <= thread_boundary < item["end_exclusive"]
for item in native_ranges
):
@@ -2243,13 +2580,22 @@ def analyze(arguments: argparse.Namespace) -> tuple[dict[str, Any], dict[str, An
metadata_path = Path(arguments.payload_metadata)
sidecar_path = Path(arguments.reference_profile)
reference_sidecar = validated_sidecar(sidecar_path)
with ElfImage(reference_path) as reference, ElfImage(candidate_path) as candidate:
with contextlib.ExitStack() as stack:
reference = stack.enter_context(ElfImage(reference_path))
candidate = stack.enter_context(ElfImage(candidate_path))
fallback = None
if arguments.runtime_fallback_lib is not None:
with contextlib.suppress(AnalyzerError):
fallback = stack.enter_context(
ElfImage(Path(arguments.runtime_fallback_lib))
)
metadata = validated_payload_metadata(metadata_path, candidate_path, candidate)
profile, anchors = derive_profile(reference, candidate, reference_sidecar)
runtime_compatibility = derive_runtime_compatibility(
reference,
candidate,
tuple(Path(path) for path in arguments.reference_compatibility),
fallback,
)
return output_documents(
candidate,
@@ -2274,6 +2620,12 @@ def parse_arguments(argv: Sequence[str]) -> argparse.Namespace:
metavar="MANIFEST",
help="compatibility manifest containing exact reference runtime anchors",
)
parser.add_argument(
"--runtime-fallback-lib",
metavar="LIB",
help="libroblox.so of another build in the compatibility manifests; "
"supplies the FMOD output-device anchors when the reference has none",
)
parser.add_argument("--candidate-lib", required=True, metavar="LIB")
parser.add_argument("--payload-metadata", required=True, metavar="META")
parser.add_argument("--output", required=True, metavar="PROFILE")
+51 -1
View File
@@ -107,6 +107,28 @@ AnyLinuxDownloadDependencies() {
AnyLinuxDependencyDie 'dependency downloads failed after 3 snapshots'
}
# Prints the package name and path of each archive that is older than the
# installed package.
AnyLinuxDependencyDowngrades() {
local package name version installed
for package in "$@"; do
read -r name version < <(pacman -Qp -- "${package}")
installed="$(pacman -Q -- "${name}" 2>/dev/null | cut -d ' ' -f 2)" || continue
[[ -n "${installed}" ]] || continue
if (( $(vercmp "${version}" "${installed}") < 0 )); then
printf '%s\t%s\n' "${name}" "${package}"
fi
done
}
# Prints each pkg-config module whose requirements are not met.
AnyLinuxDependencyBrokenModules() {
local module
while read -r module; do
pkg-config --exists "${module}" 2>/dev/null || printf '%s\n' "${module}"
done < <(pkg-config --list-package-names 2>/dev/null)
}
AnyLinuxDependenciesMain() (
AnyLinuxDependencyRequireContainer
(( $# == 0 )) || AnyLinuxDependencyDie 'no arguments are supported'
@@ -126,7 +148,35 @@ AnyLinuxDependenciesMain() (
while IFS=$'\t' read -r digest filename url; do
packages+=("${DOWNLOADS}/${filename}")
done <"${DOWNLOADS}/dependencies.tsv"
pacman -U --noconfirm --needed "${packages[@]}"
local broken name package
local -a current=() older=() older_names=()
local -A is_older=()
while IFS=$'\t' read -r name package; do
older_names+=("${name}")
is_older["${package}"]=1
done < <(AnyLinuxDependencyDowngrades "${packages[@]}")
for package in "${packages[@]}"; do
if [[ -n "${is_older[${package}]:-}" ]]; then
older+=("${package}")
else
current+=("${package}")
fi
done
(( ${#current[@]} == 0 )) || pacman -U --noconfirm --needed "${current[@]}"
(( ${#older[@]} > 0 )) || return 0
# A reduced package can lag the distribution. If the older version leaves
# another package's pkg-config requirements unmet, build with the
# distribution's package for that library. Modules that the reduced
# packages of the current version already leave unmet do not count.
broken="$(AnyLinuxDependencyBrokenModules)"
pacman -U --noconfirm --needed "${older[@]}"
if [[ "$(AnyLinuxDependencyBrokenModules)" != "${broken}" ]]; then
printf '[anylinux-dependencies] reduced packages are too old; restoring %s\n' \
"${older_names[*]}" >&2
pacman -S --noconfirm "${older_names[@]}"
[[ "$(AnyLinuxDependencyBrokenModules)" == "${broken}" ]] ||
AnyLinuxDependencyDie 'reduced packages break pkg-config requirements'
fi
)
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
+6
View File
@@ -207,7 +207,13 @@ EOF
"${ANYLINUX_TOOL}" --make-appimage
local result="${ANYLINUX_WORK}/image/$(basename -- "${ANYLINUX_OUTPUT}")"
[[ -x "${result}" ]] || AnyLinuxDie "quick-sharun did not create the requested AppImage"
if [[ -n "${UPINFO:-}" ]]; then
[[ -s "${result}.zsync" ]] || AnyLinuxDie "update information was set but no .zsync was generated"
fi
mv -f -- "${result}" "${ANYLINUX_OUTPUT}"
if [[ -f "${result}.zsync" ]]; then
mv -fT -- "${result}.zsync" "${ANYLINUX_OUTPUT}.zsync"
fi
printf '[anylinux] AppImage ready: %s\n' "${ANYLINUX_OUTPUT}" >&2
}
+7 -3
View File
@@ -409,9 +409,11 @@ WriteClassicPixbufCache() {
Die "classic GdkPixbuf loader cache is unavailable"
sed -E \
-e 's@^# LoaderDir = .*$@# LoaderDir = lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders@' \
-e 's#^"[^"]*/(libpixbufloader[-_][^/"]+\.so)"$#"lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders/\1"#' \
-e 's#^"[^"]*/([^/"]+\.so)"$#"lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders/\1"#' \
"${source_cache}" >"${destination_cache}"
if grep -Eq '^"/(usr|lib|lib64|opt|home|hdd|tmp)/' \
# Module records contain only a quoted path. Image signatures such as XPM
# can also start with '/' but have additional fields after the string.
if grep -Eq '^"/[^"]*"$' \
"${destination_cache}"; then
Die "GdkPixbuf cache still contains an absolute loader path"
fi
@@ -663,4 +665,6 @@ Main() {
PublishResources
}
Main "$@"
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
Main "$@"
fi
+42 -20
View File
@@ -28,6 +28,7 @@ STORE_ROOT=""
TEMP_DIR=""
ROLLBACK_DIR=""
ROLLBACK_NEEDED=false
BACKUP_COMPLETE=false
usage() {
cat <<'EOF'
@@ -146,34 +147,54 @@ certificate_digests() {
}
restore_payload() {
[[ "${ROLLBACK_NEEDED}" == true && -d "${ROLLBACK_DIR}" ]] || return 0
[[ "${ROLLBACK_NEEDED}" == true ]] || return 0
[[ -d "${ROLLBACK_DIR}" ]] || return 1
log WARN "restoring the previous payload"
mkdir -p "${RBX_BIN_DIR}" "$(dirname -- "${METADATA_PATH}")"
mkdir -p "${RBX_BIN_DIR}" "$(dirname -- "${METADATA_PATH}")" || return 1
local path
for path in libroblox.so sober_apk assets; do
if [[ -e "${RBX_BIN_DIR}/${path}" ]]; then
mv -- "${RBX_BIN_DIR}/${path}" "${TEMP_DIR}/failed-${path}"
local path active_path failed_path restore_status=0
for path in libroblox.so sober_apk assets roblox_payload.json; do
active_path="${RBX_BIN_DIR}/${path}"
failed_path="${TEMP_DIR}/failed-${path}"
if [[ "${path}" == roblox_payload.json ]]; then
active_path="${METADATA_PATH}"
failed_path="${TEMP_DIR}/failed-metadata.json"
fi
if [[ -e "${ROLLBACK_DIR}/${path}" ]]; then
mv -- "${ROLLBACK_DIR}/${path}" "${RBX_BIN_DIR}/${path}"
# Before activation, anything still active belongs to the old payload.
# Only components removed by the backup need restoring in this phase.
if [[ "${BACKUP_COMPLETE}" == false ]]; then
if [[ -e "${active_path}" || -L "${active_path}" ||
! -e "${ROLLBACK_DIR}/${path}" && ! -L "${ROLLBACK_DIR}/${path}" ]]; then
continue
fi
fi
if [[ -e "${active_path}" || -L "${active_path}" ]]; then
# Do not restore onto an occupied destination if removing the new
# component fails: mv could overwrite a file or nest a directory.
if ! mv -- "${active_path}" "${failed_path}"; then
restore_status=1
continue
fi
fi
if [[ -e "${ROLLBACK_DIR}/${path}" || -L "${ROLLBACK_DIR}/${path}" ]]; then
mv -- "${ROLLBACK_DIR}/${path}" "${active_path}" || restore_status=1
fi
done
if [[ -e "${METADATA_PATH}" ]]; then
mv -- "${METADATA_PATH}" "${TEMP_DIR}/failed-metadata.json"
fi
if [[ -e "${ROLLBACK_DIR}/roblox_payload.json" ]]; then
mv -- "${ROLLBACK_DIR}/roblox_payload.json" "${METADATA_PATH}"
fi
return "${restore_status}"
}
cleanup() {
local -r status=$?
local status=$?
trap - EXIT
set +e
restore_payload
if ! restore_payload; then
log ERROR "payload restoration failed; recovery files retained at ${TEMP_DIR}"
(( status != 0 )) || status=1
exit "${status}"
fi
if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then
rm -rf -- "${TEMP_DIR}"
fi
@@ -401,17 +422,18 @@ main() {
mkdir -p "${VERSIONS_DIR}" "$(dirname -- "${METADATA_PATH}")"
ROLLBACK_DIR="${TEMP_DIR}/rollback"
mkdir -p "${ROLLBACK_DIR}"
ROLLBACK_NEEDED=true
local active_path
for active_path in libroblox.so sober_apk assets; do
if [[ -e "${RBX_BIN_DIR}/${active_path}" ]]; then
if [[ -e "${RBX_BIN_DIR}/${active_path}" || -L "${RBX_BIN_DIR}/${active_path}" ]]; then
mv -- "${RBX_BIN_DIR}/${active_path}" "${ROLLBACK_DIR}/${active_path}"
fi
done
if [[ -e "${METADATA_PATH}" ]]; then
if [[ -e "${METADATA_PATH}" || -L "${METADATA_PATH}" ]]; then
mv -- "${METADATA_PATH}" "${ROLLBACK_DIR}/roblox_payload.json"
fi
ROLLBACK_NEEDED=true
BACKUP_COMPLETE=true
mv -- "${stage_dir}/libroblox.so" "${RBX_BIN_DIR}/libroblox.so"
mv -- "${stage_dir}/sober_apk" "${RBX_BIN_DIR}/sober_apk"
+93 -42
View File
@@ -6,51 +6,102 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Nightcap for Linux</title>
<meta name="description" content="Play Roblox on Linux like it was made for your PC. Nightcap is Mocktail, tuned to feel fast and look sharp.">
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
<link rel="stylesheet" href="styles.css">
</head>
<body><main>
<img src="nightcap.svg" alt="Nightcap logo">
<h1>Nightcap</h1>
<p>Play Roblox on Linux like it was made for your PC. Nightcap is Mocktail, tuned to feel fast and look sharp.</p>
<a class="button" href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">Download AppImage</a>
<a class="button secondary" href="nightcap.flatpakref">Install with Flatpak</a>
<div class="commands">
<p>AppImage</p>
<code>chmod +x Nightcap-x86_64.AppImage
./Nightcap-x86_64.AppImage</code>
<p>Flatpak</p>
<code>flatpak install --user https://coderdayton.github.io/nightcap/nightcap.flatpakref
flatpak run io.github.CoderDayton.nightcap</code>
<p>DEB, RPM and pacman</p>
<code>sudo apt install ./nightcap_&lt;version&gt;-1_amd64.deb
<body>
<header class="hero">
<div class="wrap">
<p class="top">
<a href="https://github.com/CoderDayton/nightcap" aria-label="Nightcap on GitHub" title="GitHub"><svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg></a>
<a href="https://discord.gg/DbAUG9hBFU" aria-label="Nightcap on Discord" title="Discord"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg></a>
</p>
<h1><img src="nightcap.svg" alt=""><span>Nightcap</span></h1>
<p class="lede">Play Roblox on Linux like it was made for your PC. Nightcap is Mocktail, tuned to feel fast and look sharp.</p>
<p class="actions">
<a class="button" href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">Download AppImage</a>
<a class="button secondary" href="nightcap.flatpakref">Install with Flatpak</a>
</p>
</div>
</header>
<main class="wrap">
<section class="row stacked">
<h2>Install</h2>
<dl class="body install">
<dt>AppImage</dt>
<dd><pre><code>chmod +x Nightcap-x86_64.AppImage
./Nightcap-x86_64.AppImage</code></pre></dd>
<dt>Flatpak</dt>
<dd><pre><code>flatpak install --user https://coderdayton.github.io/nightcap/nightcap.flatpakref
flatpak run io.github.CoderDayton.nightcap</code></pre></dd>
<dt>DEB, RPM, pacman</dt>
<dd><pre><code>sudo apt install ./nightcap_&lt;version&gt;-1_amd64.deb
sudo dnf install ./nightcap-&lt;version&gt;-1.x86_64.rpm
sudo pacman -U nightcap-&lt;version&gt;-1-x86_64.pkg.tar.zst</code>
<p>Build from source</p>
<code>git clone --recurse-submodules https://github.com/CoderDayton/nightcap.git
sudo pacman -U nightcap-&lt;version&gt;-1-x86_64.pkg.tar.zst</code></pre></dd>
<dt>Source</dt>
<dd><pre><code>git clone --recurse-submodules https://github.com/CoderDayton/nightcap.git
cd nightcap &amp;&amp; make build
./build/mocktail</code>
</div>
<h2>What's different</h2>
<ul>
<li>Lower input lag. Frames are shown as soon as they are ready.</li>
<li>Sharper textures, with your own PNG overrides if you want them.</li>
<li>ETC2 textures render correctly on desktop GPUs.</li>
<li>No more error 319 kicks after joining a server.</li>
<li>Roblox sees your real RAM and screen size.</li>
</ul>
<h2>Direct downloads</h2>
<p>Prebuilt builds for Linux x86_64.</p>
<p class="links">
<a href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">AppImage</a>
<a href="Nightcap-x86_64.flatpak">Flatpak bundle</a>
<a href="https://github.com/CoderDayton/nightcap/releases/latest">DEB, RPM, pacman</a>
<a href="nightcap-flatpak.gpg">Signing key</a>
<a href="terms.html">Terms</a>
<a href="privacy.html">Privacy</a>
</p>
<p><a class="source" href="https://github.com/CoderDayton/nightcap">Source code</a> ·
<a class="source" href="https://github.com/komaruworld/mocktail">Upstream Mocktail</a></p>
<p>Nightcap is an independent community project and is not affiliated with Roblox Corporation.</p>
</main></body>
./build/mocktail</code></pre></dd>
</dl>
</section>
<section class="row">
<h2>What's different</h2>
<dl class="body facts">
<dt>Input lag</dt>
<dd>Lower. Frames are shown as soon as they are ready.</dd>
<dt>Textures</dt>
<dd>Sharper, with your own PNG overrides if you want them.</dd>
<dt>ETC2</dt>
<dd>Textures render correctly on desktop GPUs.</dd>
<dt>Error 319</dt>
<dd>No more kicks after joining a server.</dd>
<dt>RAM and screen</dt>
<dd>Roblox sees your real RAM and screen size.</dd>
</dl>
</section>
<section class="row">
<h2>Direct downloads</h2>
<div class="body">
<p>Prebuilt builds for Linux x86_64.</p>
<ul class="links">
<li><a href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage"><span class="name">AppImage</span><span class="file">Nightcap-x86_64.AppImage</span><span class="go" aria-hidden="true">↓</span></a></li>
<li><a href="Nightcap-x86_64.flatpak"><span class="name">Flatpak bundle</span><span class="file">Nightcap-x86_64.flatpak</span><span class="go" aria-hidden="true">↓</span></a></li>
<li><a href="https://github.com/CoderDayton/nightcap/releases/latest"><span class="name">DEB, RPM, pacman</span><span class="file">GitHub releases page</span><span class="go" aria-hidden="true">↗</span></a></li>
<li><a href="nightcap-flatpak.gpg"><span class="name">Signing key</span><span class="file">nightcap-flatpak.gpg</span><span class="go" aria-hidden="true">↓</span></a></li>
</ul>
</div>
</section>
</main>
<footer class="wrap foot">
<p>
<a href="https://github.com/CoderDayton/nightcap">Source code</a> ·
<a href="https://github.com/komaruworld/mocktail">Upstream Mocktail</a> ·
<a href="terms.html">Terms</a> ·
<a href="privacy.html">Privacy</a>
</p>
<p>Nightcap is an independent community project and is not affiliated with Roblox Corporation.</p>
</footer>
<script>
// Adds a copy button to each command block. The page works without it.
document.querySelectorAll('.install dd').forEach(function (block) {
var button = document.createElement('button');
button.type = 'button';
button.textContent = 'copy';
button.addEventListener('click', function () {
if (!navigator.clipboard) return;
navigator.clipboard.writeText(block.querySelector('code').textContent).then(function () {
button.textContent = 'copied';
setTimeout(function () { button.textContent = 'copy'; }, 1600);
});
});
block.appendChild(button);
});
</script>
</body>
</html>
+36 -26
View File
@@ -8,36 +8,46 @@
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
<link rel="stylesheet" href="styles.css">
</head>
<body><main>
<h1>Privacy Policy</h1>
<p>Nightcap is a Linux runtime for the Roblox Android client. This policy covers Nightcap itself and its Discord application. Roblox and Discord have their own policies for the data they handle.</p>
<body>
<header class="wrap bar">
<a class="brand" href="index.html"><img src="nightcap.svg" alt="" width="30" height="30"><span>Nightcap</span></a>
<p class="top">
<a href="https://github.com/CoderDayton/nightcap" aria-label="Nightcap on GitHub" title="GitHub"><svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg></a>
<a href="https://discord.gg/DbAUG9hBFU" aria-label="Nightcap on Discord" title="Discord"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg></a>
</p>
</header>
<h2>What Nightcap collects</h2>
<p>Nothing. Nightcap has no server. It does not collect, store, or send telemetry, analytics, crash reports, or account data to the Nightcap project.</p>
<main class="wrap prose">
<h1>Privacy Policy</h1>
<p>Nightcap is a Linux runtime for the Roblox Android client. This policy covers Nightcap itself and its Discord application. Roblox and Discord have their own policies for the data they handle.</p>
<h2>What stays on your machine</h2>
<ul>
<li>Configuration in <code>~/.config/mocktail/</code></li>
<li>Session logs and support bundles in <code>~/.local/state/mocktail/</code></li>
<li>Roblox login session under <code>~/.local/share/mocktail/</code></li>
<li>Roblox cache, downloaded payloads, and shader cache under <code>~/.cache/mocktail/</code></li>
</ul>
<p>Flatpak installs keep the same layout under <code>~/.var/app/io.github.CoderDayton.nightcap/</code>.</p>
<h2>What Nightcap collects</h2>
<p>Nothing. Nightcap has no server. It does not collect, store, or send telemetry, analytics, crash reports, or account data to the Nightcap project.</p>
<h2>Discord Rich Presence</h2>
<p>When Rich Presence is on, Nightcap sends the current experience name, its public icon URL, elapsed time, and an optional public server join code to the Discord desktop client on the same machine over a local socket. Discord displays this on your profile under its own privacy settings. Nightcap never signs in to Discord, never reads a Discord token, and never contacts Discord's servers.</p>
<p>Set <code>integrations.discord_rpc.enabled: false</code> in <code>config.yaml</code> to turn it off.</p>
<h2>What stays on your machine</h2>
<ul>
<li>Configuration in <code>~/.config/mocktail/</code></li>
<li>Session logs and support bundles in <code>~/.local/state/mocktail/</code></li>
<li>Roblox login session under <code>~/.local/share/mocktail/</code></li>
<li>Roblox cache, downloaded payloads, and shader cache under <code>~/.cache/mocktail/</code></li>
</ul>
<p>Flatpak installs keep the same layout under <code>~/.var/app/io.github.CoderDayton.nightcap/</code>.</p>
<h2>Roblox</h2>
<p>Nightcap talks to Roblox's services exactly as the Roblox client would. That traffic is covered by the Roblox Privacy Policy.</p>
<h2>Discord Rich Presence</h2>
<p>When Rich Presence is on, Nightcap sends the current experience name, its public icon URL, elapsed time, and an optional public server join code to the Discord desktop client on the same machine over a local socket. Discord displays this on your profile under its own privacy settings. Nightcap never signs in to Discord, never reads a Discord token, and never contacts Discord's servers.</p>
<p>Set <code>integrations.discord_rpc.enabled: false</code> in <code>config.yaml</code> to turn it off.</p>
<h2>Contact</h2>
<p>Open an issue at <a class="source" href="https://github.com/CoderDayton/nightcap/issues">github.com/CoderDayton/nightcap/issues</a>.</p>
<h2>Roblox</h2>
<p>Nightcap talks to Roblox's services exactly as the Roblox client would. That traffic is covered by the Roblox Privacy Policy.</p>
<p class="links">
<a href="index.html">Home</a>
<a href="terms.html">Terms of Service</a>
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
</p>
</main></body>
<h2>Contact</h2>
<p>Open an issue at <a href="https://github.com/CoderDayton/nightcap/issues">github.com/CoderDayton/nightcap/issues</a>.</p>
<p class="legal-nav">
<a href="index.html">Home</a>
<a href="terms.html">Terms of Service</a>
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
</p>
</main>
</body>
</html>
+247 -49
View File
@@ -1,94 +1,292 @@
/* Modified by vii from komaruworld/mocktail. See README "About this fork". */
:root {
font: 17px/1.55 system-ui, sans-serif;
--bg: #14122b;
--ink: #ece8ff;
--muted: #b3aad6;
--accent: #6c4fd1;
--link: #a78bfa;
--line: #3a3470;
--code-bg: #1d1a3d;
font: 17px/1.6 system-ui, sans-serif;
color-scheme: dark;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: #14122b;
color: #ece8ff;
background: var(--bg);
color: var(--ink);
}
main {
max-width: 680px;
margin: 12vh auto;
padding: 32px;
a { color: var(--link); }
a:focus-visible,
button:focus-visible { outline: 2px solid var(--link); outline-offset: 3px; }
.wrap {
position: relative;
max-width: 920px;
margin: 0 auto;
padding: 0 24px;
}
img {
width: 88px;
height: 88px;
.hero { padding-bottom: clamp(48px, 10vh, 110px); }
.top {
margin: 0;
padding-top: 22px;
display: flex;
justify-content: flex-end;
gap: 18px;
}
h1 {
margin: .2rem 0;
font-size: clamp(2.5rem, 8vw, 4.5rem);
.top a {
display: block;
color: var(--muted);
line-height: 0;
}
h2 {
margin: 32px 0 0;
font-size: 1.15rem;
.top a:hover { color: var(--ink); }
.hero h1 {
margin: clamp(48px, 12vh, 130px) 0 0;
font-size: clamp(3.6rem, 15vw, 9.5rem);
font-weight: 800;
line-height: .95;
letter-spacing: -.045em;
display: flex;
align-items: center;
gap: .16em;
}
p,
li {
color: #b3aad6;
.hero h1 img {
height: .78em;
width: auto;
flex: none;
}
ul {
padding-left: 1.2rem;
.lede {
max-width: 30ch;
margin: 22px 0 0;
color: var(--ink);
font-size: 1.25rem;
line-height: 1.45;
}
.actions { margin: 28px 0 0; }
a.button {
display: inline-block;
margin: 16px 8px 16px 0;
padding: 12px 18px;
border-radius: 9px;
background: #6c4fd1;
color: white;
margin: 0 10px 10px 0;
padding: 12px 20px;
border: 2px solid var(--accent);
background: var(--accent);
color: #fff;
font-weight: 700;
text-decoration: none;
}
a.secondary {
background: #2f2a6b;
a.button:hover { background: #7d62dd; border-color: #7d62dd; }
a.button.secondary {
background: var(--bg);
border-color: var(--link);
color: var(--ink);
}
.commands {
margin-top: 10px;
a.button.secondary:hover { background: var(--code-bg); }
/* Every section is a label on the left and its content on the right. */
.row {
display: grid;
grid-template-columns: 200px 1fr;
gap: 8px 32px;
padding: 36px 0;
border-top: 1px solid var(--line);
}
.commands p,
.download-label {
margin: 14px 0 5px;
color: #ece8ff;
font-size: .82rem;
h2 {
margin: 0;
font-size: 1rem;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
}
.row.stacked { grid-template-columns: 1fr; gap: 20px; }
.body { margin: 0; min-width: 0; }
.body p { margin: 0 0 12px; color: var(--muted); }
dl.body {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0 28px;
}
dt {
padding: 9px 0;
color: var(--muted);
}
dd {
position: relative;
margin: 0;
padding: 9px 0;
min-width: 0;
}
.facts dt,
.facts dd { border-bottom: 1px solid var(--line); }
.facts dt:last-of-type,
.facts dd:last-of-type { border-bottom: 0; }
.facts dt { color: var(--ink); font-weight: 700; }
dl.body.install { display: block; }
.install dt {
padding: 18px 0 6px;
font-size: .9rem;
}
.install dt:first-child { padding-top: 0; }
.install dd { padding: 0; }
pre {
margin: 0;
padding: 14px;
overflow-x: auto;
border-left: 3px solid var(--accent);
background: var(--code-bg);
}
code {
display: block;
padding: 14px;
overflow-wrap: anywhere;
border: 1px solid #3a3470;
border-radius: 6px;
background: #1d1a3d;
font: .9rem/1.7 ui-monospace, "DejaVu Sans Mono", "JetBrains Mono", monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
dd button {
position: absolute;
top: -30px;
right: 0;
padding: 2px 0 2px 8px;
border: 0;
background: transparent;
color: var(--muted);
font: .8rem ui-monospace, monospace;
cursor: pointer;
}
dd button:hover { color: var(--ink); text-decoration: underline; }
.links {
margin: 10px 0 14px;
margin: 0;
padding: 0;
list-style: none;
border-top: 1px solid var(--line);
}
.links li { border-bottom: 1px solid var(--line); }
.links a {
display: inline-block;
margin: 0 14px 6px 0;
color: #a78bfa;
display: grid;
grid-template-columns: 200px 1fr 24px;
align-items: baseline;
gap: 4px 16px;
margin: 0 -8px;
padding: 12px 24px 12px 8px;
color: var(--ink);
font-weight: 700;
text-decoration: none;
}
a.source {
color: #a78bfa;
.links a:hover { background: var(--code-bg); }
.links a:hover .name { text-decoration: underline; text-decoration-color: var(--link); text-underline-offset: 4px; }
.links .file {
color: var(--muted);
font: 400 .88rem ui-monospace, "DejaVu Sans Mono", monospace;
overflow-wrap: anywhere;
}
.links .go { color: var(--link); text-align: right; }
.foot {
padding-bottom: 48px;
color: var(--muted);
font-size: .9rem;
}
.foot::before {
content: "";
display: block;
margin-bottom: 24px;
border-top: 1px solid var(--line);
}
.foot p { margin: 0 0 8px; }
/* Terms and privacy pages: a header bar and one readable column. */
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 22px;
}
.bar .top { padding-top: 0; }
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--ink);
font-size: 1.15rem;
font-weight: 800;
letter-spacing: -.02em;
text-decoration: none;
}
.brand img { width: 30px; height: 30px; }
.prose {
padding-top: clamp(32px, 8vh, 80px);
padding-bottom: 48px;
}
.prose > * { max-width: 700px; }
.prose h1 {
margin: 0 0 16px;
font-size: clamp(2rem, 6vw, 3rem);
line-height: 1.1;
letter-spacing: -.03em;
}
.prose h2 {
margin: 36px 0 6px;
padding-top: 20px;
border-top: 1px solid var(--line);
font-size: 1.05rem;
}
.prose p,
.prose li { margin: 0 0 12px; }
.prose ul { margin: 0 0 12px; padding-left: 1.2rem; }
.prose code {
padding: 1px 6px;
background: var(--code-bg);
font-size: .85rem;
white-space: normal;
}
.legal-nav { display: flex; flex-wrap: wrap; gap: 4px 20px; margin-top: 28px; }
@media (max-width: 720px) {
.row { grid-template-columns: 1fr; padding: 28px 0; }
dl.body:not(.install) { grid-template-columns: 1fr; }
.facts dt { padding-bottom: 0; border-bottom: 0; }
.links a { grid-template-columns: 1fr 24px; }
.links .file { grid-column: 1; grid-row: 2; }
.links .go { grid-column: 2; grid-row: 1 / span 2; align-self: center; }
}
+29 -19
View File
@@ -8,29 +8,39 @@
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
<link rel="stylesheet" href="styles.css">
</head>
<body><main>
<h1>Terms of Service</h1>
<p>Nightcap is free and open-source software licensed under the Apache License, Version 2.0. The license text shipped with the source governs your use of the software.</p>
<body>
<header class="wrap bar">
<a class="brand" href="index.html"><img src="nightcap.svg" alt="" width="30" height="30"><span>Nightcap</span></a>
<p class="top">
<a href="https://github.com/CoderDayton/nightcap" aria-label="Nightcap on GitHub" title="GitHub"><svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg></a>
<a href="https://discord.gg/DbAUG9hBFU" aria-label="Nightcap on Discord" title="Discord"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg></a>
</p>
</header>
<h2>No warranty</h2>
<p>Nightcap is provided "as is", without warranty of any kind. The authors are not liable for any loss, damage, or account action that results from using it. See sections 7 and 8 of the Apache License.</p>
<main class="wrap prose">
<h1>Terms of Service</h1>
<p>Nightcap is free and open-source software licensed under the Apache License, Version 2.0. The license text shipped with the source governs your use of the software.</p>
<h2>Not affiliated</h2>
<p>Nightcap is an independent community project. It is not affiliated with, endorsed by, or supported by Roblox Corporation or Discord Inc. Roblox and Discord are trademarks of their respective owners.</p>
<h2>No warranty</h2>
<p>Nightcap is provided "as is", without warranty of any kind. The authors are not liable for any loss, damage, or account action that results from using it. See sections 7 and 8 of the Apache License.</p>
<h2>Your responsibility</h2>
<p>You are responsible for following the Roblox Terms of Use and the Discord Terms of Service while using Nightcap with those services. Nightcap does not modify Roblox game logic, does not bypass Roblox security, and is not a cheat.</p>
<h2>Not affiliated</h2>
<p>Nightcap is an independent community project. It is not affiliated with, endorsed by, or supported by Roblox Corporation or Discord Inc. Roblox and Discord are trademarks of their respective owners.</p>
<h2>Discord application</h2>
<p>The Nightcap Discord application only provides Rich Presence for the Nightcap desktop runtime. It requests no permissions, has no bot, and does not access your Discord account.</p>
<h2>Your responsibility</h2>
<p>You are responsible for following the Roblox Terms of Use and the Discord Terms of Service while using Nightcap with those services. Nightcap does not modify Roblox game logic, does not bypass Roblox security, and is not a cheat.</p>
<h2>Changes</h2>
<p>These terms may change with new releases. The current version always lives at this address.</p>
<h2>Discord application</h2>
<p>The Nightcap Discord application only provides Rich Presence for the Nightcap desktop runtime. It requests no permissions, has no bot, and does not access your Discord account.</p>
<p class="links">
<a href="index.html">Home</a>
<a href="privacy.html">Privacy Policy</a>
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
</p>
</main></body>
<h2>Changes</h2>
<p>These terms may change with new releases. The current version always lives at this address.</p>
<p class="legal-nav">
<a href="index.html">Home</a>
<a href="privacy.html">Privacy Policy</a>
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
</p>
</main>
</body>
</html>
+66 -25
View File
@@ -7,6 +7,7 @@
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include <map>
#include <memory>
#include <mutex>
@@ -31,18 +32,18 @@ struct NativeOptionalFormat {
struct NativeSharedSink { void* object; void* owner; };
static_assert(sizeof(NativeOptionalFormat) == 16);
static_assert(sizeof(NativePcmFormat) == 8);
inline std::atomic<NativeInputCapture*> g_native_capture{nullptr};
inline std::shared_ptr<NativeInputCapture> g_native_capture;
class NativeInputCapture {
public:
using DeliverPcm = std::size_t (*)(void*, const void*, std::size_t,
std::uint64_t, const NativePcmFormat*);
using Destroy = void (*)(NativeSharedSink*);
using OriginalLatency = void (*)(void*, std::uint32_t*, std::uint32_t*);
using ImageRange = bool (*)(std::uintptr_t, std::uintptr_t, std::size_t);
struct SinkAbi {
std::uintptr_t vtable;
DeliverPcm deliver;
Destroy release;
std::uintptr_t image_base;
ImageRange is_image_relro;
ImageRange is_image_code;
OriginalLatency latency;
};
NativeInputCapture(SinkAbi abi, bool enabled)
@@ -50,17 +51,22 @@ class NativeInputCapture {
~NativeInputCapture() { Shutdown(); }
static NativeOptionalFormat Format(void*) {
auto* capture = g_native_capture.load(std::memory_order_acquire);
return {{}, capture && capture->enabled_ ? 1ULL : 0ULL};
auto capture =
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
if (!capture) return {};
std::lock_guard lock(capture->mutex_);
return {{}, capture->enabled_ && !capture->closed_ ? 1ULL : 0ULL};
}
static void Latency(void* self, std::uint32_t* playback,
std::uint32_t* recording) {
// Preserve the native playback latency and report the host capture queue.
auto* capture = g_native_capture.load(std::memory_order_acquire);
auto capture =
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
if (!capture) return;
std::lock_guard lock(capture->mutex_);
if (capture->closed_) return;
capture->abi_.latency(self, playback, recording);
if (recording) {
std::lock_guard lock(capture->mutex_);
*recording = 10;
auto it = capture->sessions_.find(self);
if (it != capture->sessions_.end()) {
@@ -70,33 +76,48 @@ class NativeInputCapture {
}
}
static bool Start(void* self, NativeSharedSink* sink) {
auto* capture = g_native_capture.load(std::memory_order_acquire);
auto capture =
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
return capture && capture->StartSink(self, sink);
}
static void Stop(void* self, NativeSharedSink* sink) {
auto* capture = g_native_capture.load(std::memory_order_acquire);
auto capture =
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
if (capture) capture->StopSink(self, sink);
}
static bool Recording(void* self) {
auto* capture = g_native_capture.load(std::memory_order_acquire);
auto capture =
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
if (!capture) return false;
std::lock_guard lock(capture->mutex_);
return capture->sessions_.find(self) != capture->sessions_.end();
}
static void Poll(void* self) {
auto* capture = g_native_capture.load(std::memory_order_acquire);
auto capture =
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
if (capture) capture->Deliver(self);
}
void Shutdown() {
std::lock_guard lock(mutex_);
closed_ = true;
for (auto& [self, session] : sessions_) Close(*session);
sessions_.clear();
}
private:
struct Sink {
NativeSharedSink shared;
DeliverPcm deliver;
};
struct SharedCount {
const std::uintptr_t* vtable;
long shared_owners;
long weak_owners;
};
using ControlBlockHook = void (*)(void*);
struct Session {
std::unique_ptr<AudioCapture> input;
std::vector<NativeSharedSink> sinks;
std::vector<Sink> sinks;
std::mutex queue_mutex;
// At most 200 ms; a stalled consumer must not accumulate stale speech.
std::array<std::array<std::int16_t, 480>, 20> frames{};
@@ -118,20 +139,33 @@ class NativeInputCapture {
session.input->buffer_data(), bytes);
++session.count;
}
bool IsGuestVtable(const void* object, std::size_t size,
std::initializer_list<std::size_t> methods) const {
const auto base = abi_.image_base;
const auto* table = *static_cast<const std::uintptr_t* const*>(object);
const auto address = reinterpret_cast<std::uintptr_t>(table);
if (address < base ||
!abi_.is_image_relro(base, address - base, size * sizeof(*table)))
return false;
for (const auto slot : methods)
if (table[slot] < base ||
!abi_.is_image_code(base, table[slot] - base, 1))
return false;
return true;
}
bool StartSink(void* self, NativeSharedSink* sink) {
std::lock_guard lock(mutex_);
if (!enabled_ || !self || !sink || !sink->object || !sink->owner)
if (closed_ || !enabled_ || !self || !sink || !sink->object || !sink->owner)
return false;
auto* table = *reinterpret_cast<std::uintptr_t**>(sink->object);
if (reinterpret_cast<std::uintptr_t>(table) != abi_.vtable ||
table[2] != reinterpret_cast<std::uintptr_t>(abi_.deliver)) {
if (!IsGuestVtable(sink->object, 3, {2}) ||
!IsGuestVtable(sink->owner, 5, {2, 4})) {
std::fprintf(stderr, " [audio-input] unsupported native PCM sink ABI\n");
return false;
}
auto it = sessions_.find(self);
if (it != sessions_.end()) {
for (const auto& existing : it->second->sinks)
if (existing.object == sink->object) return true;
if (existing.shared.object == sink->object) return true;
} else {
auto session = std::make_unique<Session>();
SdlAudioCaptureOptions options;
@@ -151,13 +185,20 @@ class NativeInputCapture {
}
// The native parameter is a by-value shared_ptr. Retain its existing
// ownership by moving it, leaving the caller's temporary empty.
it->second->sinks.push_back(*sink);
const auto* table = *static_cast<const std::uintptr_t* const*>(sink->object);
it->second->sinks.push_back({*sink, reinterpret_cast<DeliverPcm>(table[2])});
*sink = {};
return true;
}
void Release(NativeSharedSink& sink) {
abi_.release(&sink);
static void Release(Sink& sink) {
auto* count = static_cast<SharedCount*>(sink.shared.owner);
sink = {};
if (__atomic_fetch_sub(&count->shared_owners, 1, __ATOMIC_ACQ_REL) != 0)
return;
reinterpret_cast<ControlBlockHook>(count->vtable[2])(count);
if (__atomic_load_n(&count->weak_owners, __ATOMIC_ACQUIRE) == 0 ||
__atomic_fetch_sub(&count->weak_owners, 1, __ATOMIC_ACQ_REL) == 0)
reinterpret_cast<ControlBlockHook>(count->vtable[4])(count);
}
void Close(Session& session) {
session.input->Shutdown();
@@ -174,7 +215,7 @@ class NativeInputCapture {
if (it == sessions_.end() || !sink) return;
auto& session = *it->second;
auto existing = std::find_if(session.sinks.begin(), session.sinks.end(),
[&](const auto& entry) { return entry.object == sink->object; });
[&](const auto& entry) { return entry.shared.object == sink->object; });
if (existing == session.sinks.end()) return;
Release(*existing);
session.sinks.erase(existing);
@@ -203,8 +244,7 @@ class NativeInputCapture {
}
const NativePcmFormat format;
for (const auto& sink : session.sinks)
abi_.deliver(
sink.object, pcm.data(), pcm.size(), delay, &format);
sink.deliver(sink.shared.object, pcm.data(), pcm.size(), delay, &format);
if (++session.delivered == 1)
std::fprintf(stderr, " [audio-input] PCM delivered to native voice sink\n");
if (!session.audible && std::any_of(pcm.begin(), pcm.end(),
@@ -216,6 +256,7 @@ class NativeInputCapture {
}
SinkAbi abi_;
bool enabled_;
bool closed_ = false;
std::mutex mutex_;
std::map<void*, std::unique_ptr<Session>> sessions_;
};
+40 -81
View File
@@ -12,9 +12,7 @@
#include <mutex>
#include <new>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
namespace mocktail::audio {
namespace {
@@ -55,10 +53,8 @@ struct OpenSlSimpleBufferQueueAdapter::State
};
struct ReleaseTicket {
std::shared_ptr<State> state;
std::uint64_t id = 0;
std::uint64_t generation = 0;
bool in_use = false;
const std::shared_ptr<State> state;
const std::uint64_t generation;
};
struct CallbackJob {
@@ -71,13 +67,7 @@ struct OpenSlSimpleBufferQueueAdapter::State
: sink(std::move(owned_sink)),
max_buffers(options.max_buffers),
event_callback(options.event_callback),
event_context(options.event_context),
ticket_pool(options.max_buffers) {
free_tickets.reserve(options.max_buffers);
for (std::size_t i = options.max_buffers; i > 0; --i) {
free_tickets.push_back(i - 1);
}
}
event_context(options.event_context) {}
static const AndroidSimpleBufferQueueTable kQueueTable;
@@ -103,7 +93,6 @@ struct OpenSlSimpleBufferQueueAdapter::State
const std::shared_ptr<State> state = raw_state->shared_from_this();
std::lock_guard<std::mutex> operation_lock(state->sink_operation_mutex);
std::uint64_t id = 0;
ReleaseTicket* ticket = nullptr;
{
std::lock_guard<std::mutex> lock(state->mutex);
@@ -111,24 +100,24 @@ struct OpenSlSimpleBufferQueueAdapter::State
return opensl_abi::kResultPreconditionsViolated;
}
if (state->pending_count.load(std::memory_order_relaxed) >=
state->max_buffers ||
state->free_tickets.empty()) {
state->max_buffers) {
return opensl_abi::kResultBufferInsufficient;
}
const std::size_t slot_index = state->free_tickets.back();
state->free_tickets.pop_back();
ticket = &state->ticket_pool[slot_index];
id = state->next_id++;
ticket->state = state;
ticket->id = id;
ticket->generation = state->generation;
ticket->in_use = true;
ticket = new (std::nothrow)
ReleaseTicket{state, state->generation};
if (ticket == nullptr) {
state->last_status = Status::Error(
StatusCode::kUnavailable, "unable to allocate OpenSL release ticket");
return ToOpenSlResult(state->last_status);
}
state->pending_count.fetch_add(1, std::memory_order_relaxed);
++state->submitted_buffers;
}
const PcmBuffer pcm{buffer, static_cast<std::size_t>(size),
&State::ReleaseBuffer, ticket};
// A successful enqueue may release synchronously. Only a failed enqueue
// leaves ownership here; its contract forbids a release callback.
const Status status = state->sink->Enqueue(pcm);
if (status.ok()) {
state->NotifyEvent(OpenSlBufferQueueEvent::kSubmitted,
@@ -136,22 +125,12 @@ struct OpenSlSimpleBufferQueueAdapter::State
return opensl_abi::kResultSuccess;
}
const std::unique_ptr<ReleaseTicket> failed_ticket(ticket);
{
std::lock_guard<std::mutex> lock(state->mutex);
state->last_status = status;
if (ticket->in_use && ticket->id == id) {
ticket->in_use = false;
ticket->state.reset();
const std::size_t slot_index =
static_cast<std::size_t>(ticket - state->ticket_pool.data());
state->free_tickets.push_back(slot_index);
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
}
if (state->submitted_buffers > 0) {
--state->submitted_buffers;
}
}
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
--state->submitted_buffers;
}
return ToOpenSlResult(status);
}
@@ -164,25 +143,14 @@ struct OpenSlSimpleBufferQueueAdapter::State
}
const std::shared_ptr<State> state = raw_state->shared_from_this();
std::lock_guard<std::mutex> operation_lock(state->sink_operation_mutex);
std::size_t discarded_count = 0;
{
std::lock_guard<std::mutex> lock(state->mutex);
if (state->stopping) {
return opensl_abi::kResultPreconditionsViolated;
}
++state->generation;
state->free_tickets.clear();
for (auto& ticket : state->ticket_pool) {
if (ticket.in_use) {
ticket.in_use = false;
ticket.state.reset();
++discarded_count;
}
}
for (std::size_t i = state->max_buffers; i > 0; --i) {
state->free_tickets.push_back(i - 1);
}
state->discarded_buffers += discarded_count;
state->discarded_buffers +=
state->pending_count.load(std::memory_order_relaxed);
state->pending_count.store(0, std::memory_order_relaxed);
}
const Status status = state->sink->Clear();
@@ -226,7 +194,8 @@ struct OpenSlSimpleBufferQueueAdapter::State
static void ReleaseBuffer(void* context, const void* /*data*/,
std::size_t size_bytes) {
auto* ticket = static_cast<ReleaseTicket*>(context);
const std::unique_ptr<ReleaseTicket> ticket(
static_cast<ReleaseTicket*>(context));
if (ticket == nullptr) {
return;
}
@@ -239,29 +208,27 @@ struct OpenSlSimpleBufferQueueAdapter::State
bool discarded = false;
{
std::lock_guard<std::mutex> lock(state->mutex);
if (ticket->in_use) {
ticket->in_use = false;
ticket->state.reset();
const std::size_t slot_index =
static_cast<std::size_t>(ticket - state->ticket_pool.data());
state->free_tickets.push_back(slot_index);
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
}
if (!state->stopping && ticket->generation == state->generation) {
state->processed_buffers.fetch_add(1, std::memory_order_relaxed);
++state->consumed_buffers;
state->consumed_bytes += size_bytes;
consumed = true;
if (state->callback != nullptr) {
state->callback_jobs.push_back(
CallbackJob{state->callback, state->callback_context});
notify_worker = true;
}
} else {
++state->discarded_buffers;
discarded = true;
// Clear already counted older generations as discarded. Their tickets
// remain alive until release without affecting newly enqueued buffers.
if (ticket->generation != state->generation) {
return;
}
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
}
if (!state->stopping) {
state->processed_buffers.fetch_add(1, std::memory_order_relaxed);
++state->consumed_buffers;
state->consumed_bytes += size_bytes;
consumed = true;
if (state->callback != nullptr) {
state->callback_jobs.push_back(
CallbackJob{state->callback, state->callback_context});
notify_worker = true;
}
} else {
++state->discarded_buffers;
discarded = true;
}
}
if (consumed) {
@@ -374,14 +341,11 @@ struct OpenSlSimpleBufferQueueAdapter::State
void* const event_context;
Handle handle;
pthread_t callback_thread{};
std::vector<ReleaseTicket> ticket_pool;
std::vector<std::size_t> free_tickets;
std::atomic<std::size_t> pending_count{0};
std::deque<CallbackJob> callback_jobs;
AndroidSimpleBufferQueueCallback callback = nullptr;
void* callback_context = nullptr;
Status last_status;
std::uint64_t next_id = 1;
std::uint64_t generation = 1;
std::atomic<std::uint64_t> processed_buffers{0};
std::uint64_t submitted_buffers = 0;
@@ -547,7 +511,6 @@ void OpenSlSimpleBufferQueueAdapter::Shutdown() {
}
state->shutdown_started = true;
state->stopping = true;
++state->generation;
state->callback = nullptr;
state->callback_context = nullptr;
state->callback_jobs.clear();
@@ -566,10 +529,6 @@ void OpenSlSimpleBufferQueueAdapter::Shutdown() {
state->last_status = Status::Error(
StatusCode::kPlatformError,
"audio sink did not release all borrowed OpenSL buffers");
for (auto& ticket : state->ticket_pool) {
ticket.in_use = false;
ticket.state.reset();
}
state->pending_count.store(0, std::memory_order_relaxed);
}
state->shutdown_complete = true;
+33 -37
View File
@@ -130,14 +130,6 @@ std::uintptr_t FunctionAddress(Function function) {
} // namespace
// ABI verified for Roblox 2.738.1397. Keep capture separate from the menu
// profile: relocating query methods alone does not establish a recording ABI.
namespace {
constexpr std::array<std::size_t, 6> kCaptureSlots{4, 13, 14, 15, 16, 17};
constexpr std::array<std::uintptr_t, 6> kCaptureRvas{
0x320c630, 0x320bcf6, 0x320d4b4, 0x320dd94, 0x320b844, 0x320e49a};
} // namespace
RobloxOutputDeviceBridge::RobloxOutputDeviceBridge() = default;
namespace internal {
@@ -226,10 +218,6 @@ Status RobloxOutputDeviceBridge::Install(const compat::BuildProfile& profile) {
"another Roblox output-device bridge owns the process");
}
profile_ = *profile.fmod_output_device_bridge;
capture_profile_supported_ =
profile.elf_build_id == "5f0704edd9064f566ee3d6df2bd2fabbcc709f03" &&
profile_.vtable_rva == 0x6cd3ce0 &&
profile_.vtable_layout_version == 2 && profile_.has_input_devices();
if (!linker::SetAndroidLibraryLoadObserver(&ObserveAndroidLibrary, this)) {
g_active_bridge.store(nullptr, std::memory_order_release);
profile_ = {};
@@ -255,7 +243,10 @@ void RobloxOutputDeviceBridge::Shutdown() {
" [audio-menu] failed to restore FmodAudioDevice "
"vtable protection\n");
}
g_native_capture.store(nullptr, std::memory_order_release);
std::atomic_store_explicit(&g_native_capture,
std::shared_ptr<NativeInputCapture>{},
std::memory_order_release);
if (capture_) capture_->Shutdown();
capture_.reset();
active_ = false;
devices_.clear();
@@ -419,6 +410,7 @@ Status RobloxOutputDeviceBridge::Activate(std::uintptr_t image_base) {
Status RobloxOutputDeviceBridge::PatchVtableLocked() {
const std::size_t kCurrentOutputVtableIndex = profile_.current_vtable_index();
const std::size_t kSelectOutputVtableIndex = profile_.select_vtable_index();
const auto capture_indexes = profile_.capture_vtable_indexes();
if (profile_.vtable_rva >
std::numeric_limits<std::uintptr_t>::max() -
(kSelectOutputVtableIndex + 1) * sizeof(std::uintptr_t)) {
@@ -461,26 +453,25 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
"FmodAudioDevice string ABI contract validation failed");
}
if (capture_profile_supported_) {
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) {
if (!IsExecutableImageRange(library_base_, kCaptureRvas[i], 1) ||
vtable_[kCaptureSlots[i]] != library_base_ + kCaptureRvas[i])
if (profile_.has_input_devices()) {
for (std::size_t i = 0; i < capture_indexes.size(); ++i) {
const auto method = vtable_[capture_indexes[i]];
if (method < library_base_ ||
!IsExecutableImageRange(library_base_, method - library_base_, 1) ||
std::find(profile_.input_method_rvas.begin(),
profile_.input_method_rvas.end(), method - library_base_) !=
profile_.input_method_rvas.end() ||
std::find(original_capture_methods_.begin(),
original_capture_methods_.begin() + i, method) !=
original_capture_methods_.begin() + i)
return FailedPrecondition("native input capture vtable ABI mismatch");
original_capture_methods_[i] = vtable_[kCaptureSlots[i]];
original_capture_methods_[i] = method;
}
if (!IsExecutableImageRange(library_base_, 0x3213154, 1) ||
!IsExecutableImageRange(library_base_, 0x1dc4cd0, 1) ||
!IsRelroImageRange(library_base_, 0x6cd4550, 24))
return FailedPrecondition("native input capture sink ABI outside image");
capture_ = std::make_unique<NativeInputCapture>(
capture_ = std::make_shared<NativeInputCapture>(
NativeInputCapture::SinkAbi{
library_base_ + 0x6cd4550,
reinterpret_cast<NativeInputCapture::DeliverPcm>(
library_base_ + 0x3213154),
reinterpret_cast<NativeInputCapture::Destroy>(
library_base_ + 0x1dc4cd0),
library_base_, &IsRelroImageRange, &IsExecutableImageRange,
reinterpret_cast<NativeInputCapture::OriginalLatency>(
library_base_ + kCaptureRvas[1])},
original_capture_methods_[1])},
!input_devices_.empty());
}
@@ -519,7 +510,8 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
__atomic_store_n(&vtable_[indexes[i]], methods[i], __ATOMIC_RELEASE);
}
if (capture_) {
g_native_capture.store(capture_.get(), std::memory_order_release);
std::atomic_store_explicit(&g_native_capture, capture_,
std::memory_order_release);
const std::array<std::uintptr_t, 6> methods{
FunctionAddress(&NativeInputCapture::Format),
FunctionAddress(&NativeInputCapture::Latency),
@@ -527,8 +519,8 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
FunctionAddress(&NativeInputCapture::Stop),
FunctionAddress(&NativeInputCapture::Poll),
FunctionAddress(&NativeInputCapture::Recording)};
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
__atomic_store_n(&vtable_[kCaptureSlots[i]], methods[i], __ATOMIC_RELEASE);
for (std::size_t i = 0; i < capture_indexes.size(); ++i)
__atomic_store_n(&vtable_[capture_indexes[i]], methods[i], __ATOMIC_RELEASE);
}
if (!SetVtableWritable(vtable_, kSelectOutputVtableIndex, false)) {
__atomic_store_n(&vtable_[kOutputCountVtableIndex], original_methods_[0],
@@ -546,10 +538,13 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
__ATOMIC_RELEASE);
}
if (capture_) {
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
__atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i],
for (std::size_t i = 0; i < capture_indexes.size(); ++i)
__atomic_store_n(&vtable_[capture_indexes[i]], original_capture_methods_[i],
__ATOMIC_RELEASE);
g_native_capture.store(nullptr, std::memory_order_release);
std::atomic_store_explicit(&g_native_capture,
std::shared_ptr<NativeInputCapture>{},
std::memory_order_release);
capture_->Shutdown();
capture_.reset();
}
(void)SetVtableWritable(vtable_, kSelectOutputVtableIndex, false);
@@ -581,8 +576,9 @@ bool RobloxOutputDeviceBridge::RestoreVtableLocked() {
__ATOMIC_RELEASE);
}
if (capture_) {
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
__atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i],
const auto capture_indexes = profile_.capture_vtable_indexes();
for (std::size_t i = 0; i < capture_indexes.size(); ++i)
__atomic_store_n(&vtable_[capture_indexes[i]], original_capture_methods_[i],
__ATOMIC_RELEASE);
}
return SetVtableWritable(vtable_, kSelectOutputVtableIndex, false);
+50 -3
View File
@@ -15,6 +15,49 @@ namespace {
constexpr int kBionicAddressInfoFailure = 4;
constexpr int kBionicAddressInfoMemory = 6;
constexpr int kBionicAddressInfoBadFlags = 3;
struct AddressInfoFlag {
int bionic;
int host;
};
// Bundled Android netdb.h AI_MASK includes only these five flags.
constexpr std::array<AddressInfoFlag, 5> kAddressInfoFlags = {{
{0x00000001, AI_PASSIVE},
{0x00000002, AI_CANONNAME},
{0x00000004, AI_NUMERICHOST},
{0x00000008, AI_NUMERICSERV},
{0x00000400, AI_ADDRCONFIG},
}};
constexpr int SupportedBionicAddressInfoFlags() noexcept {
int flags = 0;
for (const AddressInfoFlag& entry : kAddressInfoFlags) {
flags |= entry.bionic;
}
return flags;
}
int HostAddressInfoFlags(int bionic_flags) noexcept {
int flags = 0;
for (const AddressInfoFlag& entry : kAddressInfoFlags) {
if ((bionic_flags & entry.bionic) != 0) {
flags |= entry.host;
}
}
return flags;
}
int BionicAddressInfoFlags(int host_flags) noexcept {
int flags = 0;
for (const AddressInfoFlag& entry : kAddressInfoFlags) {
if ((host_flags & entry.host) != 0) {
flags |= entry.bionic;
}
}
return flags;
}
constexpr std::array<std::string_view, 3> kBlockedUploadSuffixes = {
"crashes.rbxinfra.com",
@@ -63,7 +106,7 @@ int BionicAddressInfoError(int host_error) noexcept {
return 2;
}
if (host_error == EAI_BADFLAGS) {
return 3;
return kBionicAddressInfoBadFlags;
}
if (host_error == EAI_FAIL) {
return kBionicAddressInfoFailure;
@@ -102,7 +145,7 @@ int BionicAddressInfoError(int host_error) noexcept {
addrinfo HostHints(const BionicAddressInfo* hints) noexcept {
addrinfo host_hints{};
if (hints != nullptr) {
host_hints.ai_flags = hints->ai_flags;
host_hints.ai_flags = HostAddressInfoFlags(hints->ai_flags);
host_hints.ai_family = hints->ai_family;
host_hints.ai_socktype = hints->ai_socktype;
host_hints.ai_protocol = hints->ai_protocol;
@@ -122,7 +165,7 @@ bool CopyHostAddressInfo(const addrinfo* host,
BionicFreeAddressInfo(head);
return false;
}
node->ai_flags = current->ai_flags;
node->ai_flags = BionicAddressInfoFlags(current->ai_flags);
node->ai_family = current->ai_family;
node->ai_socktype = current->ai_socktype;
node->ai_protocol = current->ai_protocol;
@@ -187,6 +230,10 @@ int BionicGetAddressInfo(const char* node, const char* service,
if (node != nullptr && IsBlockedCrashReportUploadHost(node)) {
return kBionicAddressInfoNameNotFound;
}
if (hints != nullptr &&
(hints->ai_flags & ~SupportedBionicAddressInfoFlags()) != 0) {
return kBionicAddressInfoBadFlags;
}
const addrinfo host_hints = HostHints(hints);
addrinfo* host_result = nullptr;
+22 -3
View File
@@ -150,6 +150,15 @@ Result ParseInHostCLocale(Parser parser) noexcept {
return result;
}
// Highest errno in Bionic's message table (EHWPOISON). Numbers 41 and 58 are
// unassigned in the generic Linux errno list, so Bionic has no text for them.
constexpr int kBionicMaxErrno = 133;
constexpr bool HasBionicErrnoMessage(int error_number) noexcept {
return error_number >= 0 && error_number <= kBionicMaxErrno &&
error_number != 41 && error_number != 58;
}
template <typename Result>
const char* HostStrErrorResult(Result result, char* buffer) noexcept {
if constexpr (std::is_integral_v<Result>) {
@@ -318,10 +327,16 @@ int BionicStrError(int error_number, char* buffer,
return -1;
}
// Bionic's table ends at EHWPOISON. Unknown numbers get the same text on
// every host instead of whatever the host libc prints (musl has no
// "Unknown error N" form).
std::array<char, 256> host_buffer{};
auto host_result =
::strerror_r(error_number, host_buffer.data(), host_buffer.size());
const char* message = HostStrErrorResult(host_result, host_buffer.data());
const char* message = nullptr;
if (HasBionicErrnoMessage(error_number)) {
auto host_result =
::strerror_r(error_number, host_buffer.data(), host_buffer.size());
message = HostStrErrorResult(host_result, host_buffer.data());
}
std::array<char, 64> unknown_buffer{};
if (message == nullptr) {
@@ -373,6 +388,10 @@ extern "C" void mocktail_bionic_arc4random_buf(void* buffer, size_t size) {
mocktail::compat::BionicArc4RandomBuffer(buffer, size);
}
extern "C" int mocktail_bionic_atoi(const char* text) {
return static_cast<int>(std::strtol(text, nullptr, 10));
}
extern "C" mocktail::compat::BionicMallinfoSnapshot mocktail_bionic_mallinfo() {
return mocktail::compat::BionicMallinfo();
}
+1 -1
View File
@@ -114,7 +114,7 @@ int CopySupportedThreadAttributes(const MocktailBionicPthreadAttr& source,
if (RejectRealtimeScheduling(source.sched_policy)) {
return EPERM;
}
if (source.sched_policy != SCHED_NORMAL) {
if (source.sched_policy != SCHED_OTHER) {
result = pthread_attr_setschedpolicy(destination, source.sched_policy);
if (result != 0) {
return result;
+36
View File
@@ -270,6 +270,42 @@ bool HostAbiProfile::ShouldInitializeNativeMimallocThreadAfterConstructor(
index == native_mimalloc_thread_initializer_after_constructor;
}
bool HostAbiProfile::HasVerifiedConstructorPolicy(
size_t jni_singleton_constructor) const noexcept {
constexpr size_t kLeadingConstructors = 4;
constexpr size_t kWiringConstructors = 2;
if (!HasValidConstructorRanges() ||
!HasValidNativeMimallocConstructorRanges() ||
native_mimalloc_constructor_run_range_count != 1) {
return false;
}
const size_t begin = native_mimalloc_constructor_run_ranges[0].begin;
const size_t checkpoint =
native_mimalloc_thread_initializer_after_constructor;
const ConstructorRange &tail =
constructor_run_ranges[constructor_run_range_count - 1];
if (init_array_count < begin + kLeadingConstructors ||
native_mimalloc_constructor_run_ranges[0].end_exclusive !=
init_array_count ||
checkpoint < begin || checkpoint >= begin + kLeadingConstructors ||
!AllowsConstructor(checkpoint) || jni_singleton_constructor < begin ||
jni_singleton_constructor >= begin + kLeadingConstructors ||
jni_singleton_constructor == checkpoint ||
!AllowsConstructor(jni_singleton_constructor) ||
ConstructorRangeBegin() < begin ||
tail.begin > begin + kLeadingConstructors ||
tail.end_exclusive != init_array_count) {
return false;
}
size_t excluded = 0;
for (size_t index = begin; index < begin + kLeadingConstructors; ++index) {
if (!AllowsConstructor(index)) {
++excluded;
}
}
return excluded == kWiringConstructors;
}
bool HostAbiProfile::HasValidNativePreJniBootstrap() const noexcept {
return native_pre_jni_bootstrap.IsValid();
}
+20 -20
View File
@@ -612,26 +612,26 @@ std::unique_ptr<OwnedHostAbiProfile> ParseProfile(const Json& root,
*error = "external host ABI profile has inconsistent constructor policy";
return nullptr;
}
// 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 != 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 !=
checkpoint ||
owned->profile.native_mimalloc_constructor_run_ranges[0].end_exclusive !=
owned->profile.init_array_count ||
(kGuestElfMachine == EM_X86_64 && checkpoint != 2)) {
// The verified constructor layout is the same on both guest ABIs. Payloads
// differ in the order of the four leading constructors, and the guest ABIs
// differ in the index the native replay starts at.
// derivation_code_rvas holds three initializer anchors, then the anchors of
// the four leading constructors in .init_array order.
constexpr size_t kJniSingletonAnchor = 2;
constexpr size_t kFirstConstructorAnchor = 3;
constexpr size_t kConstructorAnchors = 4;
size_t jni_singleton_constructor = owned->profile.init_array_count;
for (size_t anchor = 0; anchor < kConstructorAnchors; ++anchor) {
if (owned->derivation_code_rvas[kFirstConstructorAnchor + anchor] ==
owned->derivation_code_rvas[kJniSingletonAnchor]) {
jni_singleton_constructor =
owned->profile.NativeMimallocConstructorRangeBegin() + anchor;
break;
}
}
if (!owned->profile.HasVerifiedConstructorPolicy(jni_singleton_constructor) ||
(kGuestElfMachine == EM_X86_64 &&
owned->profile.NativeMimallocConstructorRangeBegin() != 2)) {
*error =
"external host ABI profile changed the verified constructor policy";
return nullptr;
+26 -6
View File
@@ -67,9 +67,8 @@ bool HasSymbols(const SharedLibrary& library,
return true;
}
} // namespace
BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
BackendCapability CheckAngleLibraries(const AngleProbeOptions& options,
bool initialize_display) {
if (options.egl_library_path.empty() || options.gles_library_path.empty()) {
return Unavailable(
"pinned ANGLE EGL and GLES library paths are both required");
@@ -104,6 +103,20 @@ BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
missing);
}
auto query_string =
reinterpret_cast<PFNEGLQUERYSTRINGPROC>(egl.Find("eglQueryString"));
const char* extensions = query_string(EGL_NO_DISPLAY, EGL_EXTENSIONS);
const std::string extension_list =
std::string(" ") + (extensions != nullptr ? extensions : "") + " ";
if (extension_list.find(" EGL_ANGLE_platform_angle ") == std::string::npos) {
return Unavailable("EGL library does not support EGL_ANGLE_platform_angle");
}
if (!initialize_display) {
return {GraphicsBackendKind::kAngleVulkan, CapabilityState::kLoadable,
HardwareAcceleration::kUnknown,
"ANGLE EGL/GLES entry points available"};
}
auto get_proc_address =
reinterpret_cast<PFNEGLGETPROCADDRESSPROC>(egl.Find("eglGetProcAddress"));
auto get_platform_display = reinterpret_cast<PFNEGLGETPLATFORMDISPLAYEXTPROC>(
@@ -121,9 +134,6 @@ BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
reinterpret_cast<PFNEGLINITIALIZEPROC>(egl.Find("eglInitialize"));
auto terminate =
reinterpret_cast<PFNEGLTERMINATEPROC>(egl.Find("eglTerminate"));
auto query_string =
reinterpret_cast<PFNEGLQUERYSTRINGPROC>(egl.Find("eglQueryString"));
const EGLint device_type =
options.allow_software_device
? EGL_PLATFORM_ANGLE_DEVICE_TYPE_SWIFTSHADER_ANGLE
@@ -173,5 +183,15 @@ BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
std::move(detail)};
}
} // namespace
BackendCapability InspectAngleLibraries(const AngleProbeOptions& options) {
return CheckAngleLibraries(options, false);
}
BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
return CheckAngleLibraries(options, true);
}
} // namespace graphics
} // namespace mocktail
+5 -1
View File
@@ -2000,7 +2000,11 @@ VKAPI_ATTR VkResult VKAPI_CALL vkCreateSwapchainKHR(
}
}
const VkResult result =
host_create(device, &host_info, allocator, swapchain);
mocktail::graphics::CreateSwapchainWithPresentModeFallback(
host_info, create_info->presentMode,
[&](const VkSwapchainCreateInfoKHR& attempt) {
return host_create(device, &attempt, allocator, swapchain);
});
if (result != VK_SUCCESS) {
return result;
}
+266 -86
View File
@@ -2,7 +2,11 @@
#include <algorithm>
#include <array>
#include <cstring>
#include <pthread.h>
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
#include <immintrin.h>
#endif
#include <atomic>
#include <condition_variable>
@@ -42,9 +46,46 @@ int Expand5(int value) { return (value << 3) | (value >> 2); }
int Expand6(int value) { return (value << 2) | (value >> 4); }
int Expand7(int value) { return (value << 1) | (value >> 6); }
inline std::uint32_t MakeRgba32(int r, int g, int bl, int a) {
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
__m128i v = _mm_set_epi32(a, bl, g, r);
__m128i p16 = _mm_packs_epi32(v, v);
__m128i p8 = _mm_packus_epi16(p16, p16);
return static_cast<std::uint32_t>(_mm_cvtsi128_si32(p8));
#else
return static_cast<std::uint32_t>(Clamp255(r)) |
(static_cast<std::uint32_t>(Clamp255(g)) << 8) |
(static_cast<std::uint32_t>(Clamp255(bl)) << 16) |
(static_cast<std::uint32_t>(static_cast<std::uint8_t>(a)) << 24);
#endif
}
#if defined(__SSSE3__) || defined(__x86_64__) || defined(_M_X64)
struct ColumnShuffleTable {
alignas(16) std::uint8_t masks[256][16];
constexpr ColumnShuffleTable() : masks{} {
for (int m = 0; m < 16; ++m) {
for (int l = 0; l < 16; ++l) {
const int idx = (m << 4) | l;
for (int p = 0; p < 4; ++p) {
const int color_idx = (((m >> p) & 1) << 1) | ((l >> p) & 1);
for (int b = 0; b < 4; ++b) {
masks[idx][p * 4 + b] = static_cast<std::uint8_t>(color_idx * 4 + b);
}
}
}
}
}
};
inline constexpr ColumnShuffleTable kColShuffleTable{};
#endif
// Decodes one ETC2 RGB block into 16 column-major RGBA texels (x * 4 + y).
#if (defined(__GNUC__) || defined(__clang__)) && (defined(__x86_64__) || defined(_M_X64))
__attribute__((target("ssse3")))
#endif
void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
std::array<std::uint8_t, 64>* out) {
std::uint32_t* out32) {
static constexpr int kDelta[8] = {0, 1, 2, 3, -4, -3, -2, -1};
const bool diff_or_opaque = (b[3] & 2) != 0;
const bool opaque = !punchthrough || diff_or_opaque;
@@ -53,13 +94,6 @@ void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
auto index_of = [msb, lsb](int texel) {
return (((msb >> texel) & 1) << 1) | ((lsb >> texel) & 1);
};
auto write = [out](int texel, int r, int g, int bl, int a) {
const std::size_t o = static_cast<std::size_t>(texel) * 4;
(*out)[o] = Clamp255(r);
(*out)[o + 1] = Clamp255(g);
(*out)[o + 2] = Clamp255(bl);
(*out)[o + 3] = static_cast<std::uint8_t>(a);
};
const int r_sum = (b[0] >> 3) + kDelta[b[0] & 7];
const int g_sum = (b[1] >> 3) + kDelta[b[1] & 7];
@@ -100,13 +134,13 @@ void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
paint[3][c] = c2[c] - d;
}
}
std::uint32_t paint32[4];
for (int i = 0; i < 4; ++i) {
paint32[i] = MakeRgba32(paint[i][0], paint[i][1], paint[i][2], 255);
}
for (int texel = 0; texel < 16; ++texel) {
const int index = index_of(texel);
if (!opaque && index == 2) {
write(texel, 0, 0, 0, 0);
} else {
write(texel, paint[index][0], paint[index][1], paint[index][2], 255);
}
out32[texel] = (!opaque && index == 2) ? 0 : paint32[index];
}
return;
}
@@ -124,9 +158,10 @@ void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
const int bv = Expand6(b[7] & 63);
for (int x = 0; x < 4; ++x) {
for (int y = 0; y < 4; ++y) {
write(x * 4 + y, (x * (rh - ro) + y * (rv - ro) + 4 * ro + 2) >> 2,
(x * (gh - go) + y * (gv - go) + 4 * go + 2) >> 2,
(x * (bh - bo) + y * (bv - bo) + 4 * bo + 2) >> 2, 255);
out32[x * 4 + y] = MakeRgba32(
(x * (rh - ro) + y * (rv - ro) + 4 * ro + 2) >> 2,
(x * (gh - go) + y * (gv - go) + 4 * go + 2) >> 2,
(x * (bh - bo) + y * (bv - bo) + 4 * bo + 2) >> 2, 255);
}
}
return;
@@ -144,39 +179,108 @@ void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
}
const int tables[2] = {b[3] >> 5, (b[3] >> 2) & 7};
const bool flipped = (b[3] & 1) != 0;
for (int x = 0; x < 4; ++x) {
for (int y = 0; y < 4; ++y) {
const int texel = x * 4 + y;
const int index = index_of(texel);
if (!opaque && index == 2) {
write(texel, 0, 0, 0, 0);
continue;
}
const int sub = flipped ? (y >= 2 ? 1 : 0) : (x >= 2 ? 1 : 0);
// Basis Universal palette precalculation: 8 colors per block instead of 48 additions/clamps.
std::uint32_t palette[2][4];
for (int sub = 0; sub < 2; ++sub) {
for (int idx = 0; idx < 4; ++idx) {
const int modifier =
(!opaque && index == 0) ? 0 : kEtc1Modifiers[tables[sub]][index];
write(texel, base[sub][0] + modifier, base[sub][1] + modifier,
base[sub][2] + modifier, 255);
(!opaque && idx == 0) ? 0 : kEtc1Modifiers[tables[sub]][idx];
palette[sub][idx] = MakeRgba32(base[sub][0] + modifier,
base[sub][1] + modifier,
base[sub][2] + modifier, 255);
}
}
if (opaque) {
#if defined(__SSSE3__) || defined(__x86_64__) || defined(_M_X64)
const __m128i pal0 = _mm_loadu_si128(reinterpret_cast<const __m128i*>(palette[0]));
const __m128i pal1 = _mm_loadu_si128(reinterpret_cast<const __m128i*>(palette[1]));
if (!flipped) {
for (int x = 0; x < 2; ++x) {
const int combo = (((msb >> (x * 4)) & 15) << 4) | ((lsb >> (x * 4)) & 15);
const __m128i mask = _mm_load_si128(
reinterpret_cast<const __m128i*>(kColShuffleTable.masks[combo]));
_mm_storeu_si128(reinterpret_cast<__m128i*>(out32 + x * 4),
_mm_shuffle_epi8(pal0, mask));
}
for (int x = 2; x < 4; ++x) {
const int combo = (((msb >> (x * 4)) & 15) << 4) | ((lsb >> (x * 4)) & 15);
const __m128i mask = _mm_load_si128(
reinterpret_cast<const __m128i*>(kColShuffleTable.masks[combo]));
_mm_storeu_si128(reinterpret_cast<__m128i*>(out32 + x * 4),
_mm_shuffle_epi8(pal1, mask));
}
} else {
for (int x = 0; x < 4; ++x) {
const int combo = (((msb >> (x * 4)) & 15) << 4) | ((lsb >> (x * 4)) & 15);
const __m128i mask = _mm_load_si128(
reinterpret_cast<const __m128i*>(kColShuffleTable.masks[combo]));
const __m128i col0 = _mm_shuffle_epi8(pal0, mask);
const __m128i col1 = _mm_shuffle_epi8(pal1, mask);
const __m128i blended =
_mm_unpacklo_epi64(col0, _mm_srli_si128(col1, 8));
_mm_storeu_si128(reinterpret_cast<__m128i*>(out32 + x * 4), blended);
}
}
#else
if (!flipped) {
for (int x = 0; x < 2; ++x) {
const std::uint32_t* pal = palette[0];
out32[x * 4 + 0] = pal[index_of(x * 4 + 0)];
out32[x * 4 + 1] = pal[index_of(x * 4 + 1)];
out32[x * 4 + 2] = pal[index_of(x * 4 + 2)];
out32[x * 4 + 3] = pal[index_of(x * 4 + 3)];
}
for (int x = 2; x < 4; ++x) {
const std::uint32_t* pal = palette[1];
out32[x * 4 + 0] = pal[index_of(x * 4 + 0)];
out32[x * 4 + 1] = pal[index_of(x * 4 + 1)];
out32[x * 4 + 2] = pal[index_of(x * 4 + 2)];
out32[x * 4 + 3] = pal[index_of(x * 4 + 3)];
}
} else {
for (int x = 0; x < 4; ++x) {
out32[x * 4 + 0] = palette[0][index_of(x * 4 + 0)];
out32[x * 4 + 1] = palette[0][index_of(x * 4 + 1)];
out32[x * 4 + 2] = palette[1][index_of(x * 4 + 2)];
out32[x * 4 + 3] = palette[1][index_of(x * 4 + 3)];
}
}
#endif
} else {
for (int x = 0; x < 4; ++x) {
for (int y = 0; y < 4; ++y) {
const int texel = x * 4 + y;
const int index = index_of(texel);
if (index == 2) {
out32[texel] = 0;
} else {
const int sub = flipped ? (y >= 2 ? 1 : 0) : (x >= 2 ? 1 : 0);
out32[texel] = palette[sub][index];
}
}
}
}
}
// EAC 3-bit index for column-major texel (x * 4 + y).
int EacIndex(const std::uint8_t* b, int texel) {
inline std::uint64_t ReadEacBits(const std::uint8_t* b) {
std::uint64_t bits = 0;
for (int i = 2; i < 8; ++i) {
bits = (bits << 8) | b[i];
}
return bits;
}
inline int EacIndexFromBits(std::uint64_t bits, int texel) {
return static_cast<int>((bits >> (45 - texel * 3)) & 7);
}
int EacModifier(const std::uint8_t* b, int texel) {
return kEacModifiers[b[1] & 15][EacIndex(b, texel)];
}
std::uint16_t DecodeR11(const std::uint8_t* b, int texel, bool is_signed) {
const int multiplier = b[1] >> 4;
const int modifier = EacModifier(b, texel);
inline std::uint16_t DecodeR11Modifier(const std::uint8_t* b, int multiplier,
const int* modifier_table,
int modifier_idx, bool is_signed) {
const int modifier = modifier_table[modifier_idx];
const int scaled = multiplier != 0 ? modifier * multiplier * 8 : modifier;
if (!is_signed) {
const int value = std::clamp(b[0] * 8 + 4 + scaled, 0, 2047);
@@ -308,12 +412,18 @@ class DecodePool {
run_ = run;
context_ = context;
unclaimed_ = helpers;
running_ = helpers;
running_.store(helpers, std::memory_order_relaxed);
}
for (std::size_t i = 0; i < helpers; ++i) {
ready_.notify_one();
}
ready_.notify_all();
run(context);
std::unique_lock<std::mutex> lock(mutex_);
done_.wait(lock, [this] { return running_ == 0; });
if (running_.load(std::memory_order_acquire) > 0) {
std::unique_lock<std::mutex> lock(done_mutex_);
done_.wait(lock, [this] {
return running_.load(std::memory_order_acquire) == 0;
});
}
run_ = nullptr;
context_ = nullptr;
return true;
@@ -343,29 +453,33 @@ class DecodePool {
}
void WorkerLoop() {
std::unique_lock<std::mutex> lock(mutex_);
for (;;) {
ready_.wait(lock, [this] { return unclaimed_ > 0; });
--unclaimed_;
void (*run)(void*) = run_;
void* context = context_;
lock.unlock();
void (*run)(void*) = nullptr;
void* context = nullptr;
{
std::unique_lock<std::mutex> lock(mutex_);
ready_.wait(lock, [this] { return unclaimed_ > 0; });
--unclaimed_;
run = run_;
context = context_;
}
run(context);
lock.lock();
if (--running_ == 0) {
done_.notify_all();
if (running_.fetch_sub(1, std::memory_order_acq_rel) == 1) {
std::lock_guard<std::mutex> lock(done_mutex_);
done_.notify_one();
}
}
}
std::mutex batch_mutex_;
std::mutex mutex_;
std::mutex done_mutex_;
std::condition_variable ready_;
std::condition_variable done_;
void (*run_)(void*) = nullptr;
void* context_ = nullptr;
std::size_t unclaimed_ = 0;
std::size_t running_ = 0;
std::atomic<std::size_t> running_{0};
std::size_t threads_ = 0;
};
@@ -378,6 +492,16 @@ void DecodeEtcJobs(EtcDecodeJob* jobs, std::size_t count,
}
DecodeBandQueue queue;
std::vector<DecodeBand>& bands = queue.bands;
std::size_t estimated_bands = 0;
for (std::size_t index = 0; index < count; ++index) {
const EtcDecodeJob& job = jobs[index];
const std::uint32_t blocks_wide = (job.width + 3) / 4;
const std::uint32_t blocks_high = (job.height + 3) / 4;
const std::uint32_t rows_per_band = static_cast<std::uint32_t>(
std::max<std::uint64_t>(1, kBlocksPerBand / (blocks_wide ? blocks_wide : 1)));
estimated_bands += (blocks_high + rows_per_band - 1) / (rows_per_band ? rows_per_band : 1);
}
bands.reserve(estimated_bands);
std::uint64_t total_blocks = 0;
for (std::size_t index = 0; index < count; ++index) {
EtcDecodeJob& job = jobs[index];
@@ -439,48 +563,104 @@ bool DecodeEtcImageBlockRows(EtcFormat format, const std::uint8_t* source,
const std::size_t texel_bytes = EtcDecodedTexelBytes(format);
const std::size_t block_bytes = EtcBlockBytes(format);
std::array<std::uint8_t, 64> rgba{};
alignas(16) std::uint32_t cols[16];
const std::uint64_t end_block_row =
static_cast<std::uint64_t>(first_block_row) + block_row_count;
const bool is_rgba_family = (format == EtcFormat::kEtc2Rgb8 ||
format == EtcFormat::kEtc2Rgb8A1 ||
format == EtcFormat::kEtc2Rgba8);
for (std::uint64_t by = first_block_row; by < end_block_row; ++by) {
for (std::uint64_t bx = 0; bx < blocks_wide; ++bx) {
const std::uint8_t* block =
source + (by * blocks_wide + bx) * block_bytes;
if (format == EtcFormat::kEtc2Rgb8 || format == EtcFormat::kEtc2Rgb8A1) {
DecodeRgbBlock(block, format == EtcFormat::kEtc2Rgb8A1, &rgba);
} else if (format == EtcFormat::kEtc2Rgba8) {
DecodeRgbBlock(block + 8, false, &rgba);
}
for (std::uint32_t y = 0; y < 4 && by * 4 + y < height; ++y) {
for (std::uint32_t x = 0; x < 4 && bx * 4 + x < width; ++x) {
const int texel = static_cast<int>(x * 4 + y);
std::uint8_t* out =
destination + ((by * 4 + y) * width + bx * 4 + x) * texel_bytes;
switch (format) {
case EtcFormat::kEtc2Rgb8:
case EtcFormat::kEtc2Rgb8A1:
case EtcFormat::kEtc2Rgba8:
std::copy_n(rgba.data() + texel * 4, 4, out);
if (format == EtcFormat::kEtc2Rgba8) {
const int alpha =
block[0] + EacModifier(block, texel) * (block[1] >> 4);
out[3] = Clamp255(alpha);
}
break;
case EtcFormat::kEacR11:
case EtcFormat::kEacR11Signed:
case EtcFormat::kEacRg11:
case EtcFormat::kEacRg11Signed: {
const bool is_signed = format == EtcFormat::kEacR11Signed ||
format == EtcFormat::kEacRg11Signed;
const int channels = texel_bytes / 2;
for (int channel = 0; channel < channels; ++channel) {
const std::uint16_t value =
DecodeR11(block + channel * 8, texel, is_signed);
out[channel * 2] = static_cast<std::uint8_t>(value);
out[channel * 2 + 1] = static_cast<std::uint8_t>(value >> 8);
}
break;
const bool interior = (by * 4 + 4 <= height) && (bx * 4 + 4 <= width);
if (is_rgba_family) {
if (format == EtcFormat::kEtc2Rgb8 || format == EtcFormat::kEtc2Rgb8A1) {
DecodeRgbBlock(block, format == EtcFormat::kEtc2Rgb8A1, cols);
} else {
DecodeRgbBlock(block + 8, false, cols);
const std::uint64_t alpha_bits = ReadEacBits(block);
const int alpha_base = block[0];
const int alpha_multiplier = block[1] >> 4;
const auto& alpha_table = kEacModifiers[block[1] & 15];
std::uint8_t alpha_palette[8];
for (int i = 0; i < 8; ++i) {
alpha_palette[i] =
Clamp255(alpha_base + alpha_table[i] * alpha_multiplier);
}
for (int texel = 0; texel < 16; ++texel) {
cols[texel] =
(cols[texel] & 0x00FFFFFF) |
(static_cast<std::uint32_t>(
alpha_palette[EacIndexFromBits(alpha_bits, texel)])
<< 24);
}
}
if (interior) {
std::uint32_t* dst_row = reinterpret_cast<std::uint32_t*>(
destination + (by * 4 * width + bx * 4) * 4);
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
__m128 r0 = _mm_load_ps(reinterpret_cast<const float*>(cols));
__m128 r1 = _mm_load_ps(reinterpret_cast<const float*>(cols + 4));
__m128 r2 = _mm_load_ps(reinterpret_cast<const float*>(cols + 8));
__m128 r3 = _mm_load_ps(reinterpret_cast<const float*>(cols + 12));
_MM_TRANSPOSE4_PS(r0, r1, r2, r3);
_mm_storeu_ps(reinterpret_cast<float*>(dst_row), r0);
dst_row += width;
_mm_storeu_ps(reinterpret_cast<float*>(dst_row), r1);
dst_row += width;
_mm_storeu_ps(reinterpret_cast<float*>(dst_row), r2);
dst_row += width;
_mm_storeu_ps(reinterpret_cast<float*>(dst_row), r3);
#else
for (int y = 0; y < 4; ++y) {
dst_row[0] = cols[y];
dst_row[1] = cols[4 + y];
dst_row[2] = cols[8 + y];
dst_row[3] = cols[12 + y];
dst_row += width;
}
#endif
} else {
for (std::uint32_t y = 0; y < 4 && by * 4 + y < height; ++y) {
for (std::uint32_t x = 0; x < 4 && bx * 4 + x < width; ++x) {
const int texel = static_cast<int>(x * 4 + y);
std::uint32_t* out = reinterpret_cast<std::uint32_t*>(
destination + ((by * 4 + y) * width + bx * 4 + x) * 4);
*out = cols[texel];
}
}
}
} else {
const bool is_signed = format == EtcFormat::kEacR11Signed ||
format == EtcFormat::kEacRg11Signed;
const int channels = static_cast<int>(texel_bytes / 2);
std::uint64_t channel_bits[2] = {0, 0};
std::uint16_t channel_palette[2][8];
for (int c = 0; c < channels; ++c) {
const std::uint8_t* cb = block + c * 8;
channel_bits[c] = ReadEacBits(cb);
const int mult = cb[1] >> 4;
const int* table = kEacModifiers[cb[1] & 15];
for (int i = 0; i < 8; ++i) {
channel_palette[c][i] =
DecodeR11Modifier(cb, mult, table, i, is_signed);
}
}
for (std::uint32_t y = 0; y < 4 && by * 4 + y < height; ++y) {
for (std::uint32_t x = 0; x < 4 && bx * 4 + x < width; ++x) {
const int texel = static_cast<int>(x * 4 + y);
std::uint8_t* out =
destination + ((by * 4 + y) * width + bx * 4 + x) * texel_bytes;
for (int channel = 0; channel < channels; ++channel) {
const std::uint16_t value =
channel_palette[channel][EacIndexFromBits(channel_bits[channel],
texel)];
out[channel * 2] = static_cast<std::uint8_t>(value);
out[channel * 2 + 1] = static_cast<std::uint8_t>(value >> 8);
}
}
}
+33 -12
View File
@@ -72,20 +72,33 @@ void ResampleRgba(const RgbaImage& source, std::uint32_t width,
static_cast<std::size_t>(source.width) * source.height * 4) {
return;
}
ResampleRgba(source.pixels.data(), source.width, source.height, width,
height, destination);
ResampleRgba(source.pixels.data(), source.width, source.height,
static_cast<std::size_t>(source.width) * 4, width, height,
destination);
}
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
std::uint32_t source_height, std::uint32_t width,
std::uint32_t height, std::uint8_t* destination) {
ResampleRgba(source, source_width, source_height,
static_cast<std::size_t>(source_width) * 4, width, height,
destination);
}
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
std::uint32_t source_height, std::size_t source_stride_bytes,
std::uint32_t width, std::uint32_t height,
std::uint8_t* destination) {
if (source == nullptr || destination == nullptr || width == 0 ||
height == 0 || source_width == 0 || source_height == 0) {
height == 0 || source_width == 0 || source_height == 0 ||
source_stride_bytes < static_cast<std::size_t>(source_width) * 4) {
return;
}
// Source box of destination column x is [columns[2x], columns[2x + 1]).
thread_local std::vector<std::uint32_t> columns;
columns.resize(static_cast<std::size_t>(width) * 2);
if (columns.size() < static_cast<std::size_t>(width) * 2) {
columns.resize(static_cast<std::size_t>(width) * 2);
}
bool one_texel_columns = true;
for (std::uint32_t x = 0; x < width; ++x) {
const std::uint32_t x0 = static_cast<std::uint32_t>(
@@ -115,11 +128,11 @@ void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
previous_y0 = y0;
previous_y1 = y1;
if (one_texel_columns && y1 == y0 + 1) {
const std::uint8_t* source_row = source + y0 * source_width * 4;
const std::uint8_t* source_row = source + y0 * source_stride_bytes;
for (std::uint32_t x = 0; x < width; ++x) {
std::memcpy(out_row + static_cast<std::size_t>(x) * 4,
source_row + columns[2 * static_cast<std::size_t>(x)] * 4,
4);
std::uint32_t pixel;
std::memcpy(&pixel, source_row + columns[2 * static_cast<std::size_t>(x)] * 4, 4);
std::memcpy(out_row + static_cast<std::size_t>(x) * 4, &pixel, 4);
}
continue;
}
@@ -128,8 +141,9 @@ void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
const std::uint64_t x1 = columns[2 * static_cast<std::size_t>(x) + 1];
std::uint64_t sum[4] = {0, 0, 0, 0};
for (std::uint64_t sy = y0; sy < y1; ++sy) {
const std::uint8_t* row_ptr = source + sy * source_stride_bytes;
for (std::uint64_t sx = x0; sx < x1; ++sx) {
const std::uint8_t* pixel = source + (sy * source_width + sx) * 4;
const std::uint8_t* pixel = row_ptr + sx * 4;
for (int channel = 0; channel < 4; ++channel) {
sum[channel] += pixel[channel];
}
@@ -137,9 +151,16 @@ void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
}
const std::uint64_t count = (y1 - y0) * (x1 - x0);
std::uint8_t* out = destination + (static_cast<std::size_t>(y) * width + x) * 4;
for (int channel = 0; channel < 4; ++channel) {
out[channel] =
static_cast<std::uint8_t>((sum[channel] + count / 2) / count);
if (count == 1) {
for (int channel = 0; channel < 4; ++channel) {
out[channel] = static_cast<std::uint8_t>(sum[channel]);
}
} else {
const std::uint64_t half = count / 2;
for (int channel = 0; channel < 4; ++channel) {
out[channel] =
static_cast<std::uint8_t>((sum[channel] + half) / count);
}
}
}
}
+55 -23
View File
@@ -18,6 +18,10 @@
#include <utility>
#include <vector>
#if defined(__x86_64__)
#include <immintrin.h>
#endif
namespace mocktail::graphics {
bool LookupEmulatedEtc2Format(VkFormat format, Etc2EmulatedFormat* out) {
@@ -398,6 +402,45 @@ bool CreateStagingBlock(const HostDevice& dev, VkDeviceSize size,
return true;
}
#if defined(__x86_64__)
__attribute__((target("sse4.1")))
inline void CopyHostVisibleMemory(std::uint8_t* dst, const std::uint8_t* src,
std::size_t bytes) {
if ((reinterpret_cast<std::uintptr_t>(src) & 15) != 0 || bytes < 16) {
std::memcpy(dst, src, bytes);
return;
}
std::size_t i = 0;
for (; i + 64 <= bytes; i += 64) {
__m128i c0 = _mm_stream_load_si128(
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i)));
__m128i c1 = _mm_stream_load_si128(
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i + 16)));
__m128i c2 = _mm_stream_load_si128(
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i + 32)));
__m128i c3 = _mm_stream_load_si128(
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i + 48)));
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i), c0);
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i + 16), c1);
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i + 32), c2);
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i + 48), c3);
}
for (; i + 16 <= bytes; i += 16) {
__m128i c = _mm_stream_load_si128(
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i)));
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i), c);
}
if (i < bytes) {
std::memcpy(dst + i, src + i, bytes - i);
}
}
#else
inline void CopyHostVisibleMemory(std::uint8_t* dst, const std::uint8_t* src,
std::size_t bytes) {
std::memcpy(dst, src, bytes);
}
#endif
// 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`.
@@ -411,17 +454,18 @@ void GatherCompressed(const std::vector<PendingUpload>& uploads,
std::uint8_t* target = destination + offset;
const VkDeviceSize compressed_layer = upload.compressed / upload.layers;
if (upload.source_span == upload.compressed) {
std::memcpy(target, source, upload.compressed);
CopyHostVisibleMemory(target, source, upload.compressed);
} else {
const VkDeviceSize block_rows =
(static_cast<VkDeviceSize>(upload.height) + 3) / 4;
const VkDeviceSize packed_row = compressed_layer / block_rows;
for (std::uint32_t layer = 0; layer < upload.layers; ++layer) {
for (VkDeviceSize row = 0; row < block_rows; ++row) {
std::memcpy(target + layer * compressed_layer + row * packed_row,
source + layer * upload.source_layer_stride +
row * upload.source_row_stride,
packed_row);
CopyHostVisibleMemory(
target + layer * compressed_layer + row * packed_row,
source + layer * upload.source_layer_stride +
row * upload.source_row_stride,
packed_row);
}
}
}
@@ -710,21 +754,11 @@ struct VulkanEtc2Emulation::State {
upload.target);
return;
}
const std::uint8_t* source = decoded;
std::vector<std::uint8_t> cropped;
if (upload.source_width != upload.width ||
upload.source_height != upload.height) {
const std::size_t row = static_cast<std::size_t>(upload.source_width) * 4;
cropped.resize(row * upload.source_height);
for (std::uint32_t y = 0; y < upload.source_height; ++y) {
std::memcpy(cropped.data() + y * row,
decoded + static_cast<std::size_t>(y) * upload.width * 4,
row);
}
source = cropped.data();
}
ResampleRgba(source, upload.source_width, upload.source_height,
upload.target_width, upload.target_height, upload.target);
const std::size_t source_stride =
static_cast<std::size_t>(upload.width) * 4;
ResampleRgba(decoded, upload.source_width, upload.source_height,
source_stride, upload.target_width, upload.target_height,
upload.target);
} else {
std::memcpy(upload.target, decoded, upload.decoded);
}
@@ -1700,9 +1734,7 @@ Etc2SubmitWait VulkanEtc2Emulation::PrepareSubmit(
collect(command_buffers[index]);
const auto record = state_->commands.find(command_buffers[index]);
if (record != state_->commands.end()) {
const std::vector<VkCommandBuffer> secondaries =
record->second.secondaries;
for (VkCommandBuffer secondary : secondaries) {
for (VkCommandBuffer secondary : record->second.secondaries) {
collect(secondary);
}
}
@@ -749,8 +749,10 @@ void VulkanTextOverlayCompositor::DestroyDevice(VkDevice device) {
if (iterator == impl_->devices.end()) {
return;
}
std::lock_guard<std::mutex> overlay_lock((*iterator)->overlay_mutex);
impl_->DestroyDeviceResources(iterator->get());
{
std::lock_guard<std::mutex> overlay_lock((*iterator)->overlay_mutex);
impl_->DestroyDeviceResources(iterator->get());
}
impl_->devices.erase(iterator);
}
+137
View File
@@ -0,0 +1,137 @@
#include "jni_fields.h"
#include "jni_references.h"
namespace jnivm::internal {
namespace {
std::unordered_set<std::string> g_field_names;
std::unordered_map<std::string, jobject> g_static_object_fields;
} // namespace
jfieldID StoreFieldId(const char* name) {
if (name == nullptr) return nullptr;
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
const auto stored = g_field_names.emplace(name).first;
// unordered_set preserves references to its elements across rehashes.
return reinterpret_cast<jfieldID>(const_cast<char*>(stored->c_str()));
}
void SetStaticObjectFieldRaw(const char* name, jobject value) {
if (name == nullptr) return;
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
RetainJniReference(value);
auto& field = g_static_object_fields[name];
const jobject previous = field;
field = value;
ReleaseJniReference(previous);
}
jobject StaticObjectFieldValue(const char* name) {
if (name == nullptr) return nullptr;
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
const auto found = g_static_object_fields.find(name);
return found == g_static_object_fields.end()
? nullptr
: NewLocalJniReference(found->second);
}
void SetObjectFieldRaw(jobject obj, const char* field_name, jobject value) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
if (pseudo_object && field_name) {
RetainJniReference(value);
auto it = pseudo_object->object_fields.find(field_name);
jobject prev =
(it != pseudo_object->object_fields.end()) ? it->second : nullptr;
pseudo_object->object_fields[field_name] = value;
if (prev != nullptr) {
ReleaseJniReference(prev);
}
}
}
void SetIntFieldRaw(jobject obj, const char* field_name, jint value) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
if (pseudo_object && field_name) {
pseudo_object->int_fields[field_name] = value;
}
}
void SetLongFieldRaw(jobject obj, const char* field_name, jlong value) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
if (pseudo_object && field_name) {
pseudo_object->long_fields[field_name] = value;
}
}
void SetFloatFieldRaw(jobject obj, const char* field_name, jfloat value) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
if (pseudo_object && field_name) {
pseudo_object->float_fields[field_name] = value;
}
}
void SetBooleanFieldRaw(jobject obj, const char* field_name, jboolean value) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
if (pseudo_object && field_name) {
pseudo_object->boolean_fields[field_name] = value;
}
}
jobject ObjectFieldValue(jobject obj, const char* field_name) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* pseudo_object = PseudoObjectFromRef(obj);
if (!pseudo_object || !field_name) {
return nullptr;
}
auto it = pseudo_object->object_fields.find(field_name);
return it == pseudo_object->object_fields.end()
? nullptr
: NewLocalJniReference(it->second);
}
jint IntFieldValue(jobject obj, const char* field_name) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* pseudo_object = PseudoObjectFromRef(obj);
if (!pseudo_object || !field_name) {
return 0;
}
auto it = pseudo_object->int_fields.find(field_name);
return it == pseudo_object->int_fields.end() ? 0 : it->second;
}
jboolean BooleanFieldValue(jobject obj, const char* field_name) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* pseudo_object = PseudoObjectFromRef(obj);
if (!pseudo_object || !field_name) {
return JNI_FALSE;
}
auto it = pseudo_object->boolean_fields.find(field_name);
return it == pseudo_object->boolean_fields.end() ? JNI_FALSE : it->second;
}
jlong LongFieldValue(jobject obj, const char* field_name) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* pseudo_object = PseudoObjectFromRef(obj);
if (!pseudo_object || !field_name) {
return 0;
}
auto it = pseudo_object->long_fields.find(field_name);
return it == pseudo_object->long_fields.end() ? 0 : it->second;
}
jfloat FloatFieldValue(jobject obj, const char* field_name) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* pseudo_object = PseudoObjectFromRef(obj);
if (!pseudo_object || !field_name) {
return 0.0f;
}
auto it = pseudo_object->float_fields.find(field_name);
return it == pseudo_object->float_fields.end() ? 0.0f : it->second;
}
} // namespace jnivm::internal
+25
View File
@@ -0,0 +1,25 @@
#ifndef MOCKTAIL_JNIVM_JNI_FIELDS_H_
#define MOCKTAIL_JNIVM_JNI_FIELDS_H_
#include <jni.h>
namespace jnivm::internal {
// Field names are interned for the lifetime of the process. Object getters
// return independently owned local references; setters retain their values.
jfieldID StoreFieldId(const char* name);
void SetStaticObjectFieldRaw(const char* name, jobject value);
jobject StaticObjectFieldValue(const char* name);
void SetObjectFieldRaw(jobject obj, const char* name, jobject value);
void SetIntFieldRaw(jobject obj, const char* name, jint value);
void SetLongFieldRaw(jobject obj, const char* name, jlong value);
void SetFloatFieldRaw(jobject obj, const char* name, jfloat value);
void SetBooleanFieldRaw(jobject obj, const char* name, jboolean value);
jobject ObjectFieldValue(jobject obj, const char* name);
jint IntFieldValue(jobject obj, const char* name);
jlong LongFieldValue(jobject obj, const char* name);
jfloat FloatFieldValue(jobject obj, const char* name);
jboolean BooleanFieldValue(jobject obj, const char* name);
} // namespace jnivm::internal
#endif
+451
View File
@@ -0,0 +1,451 @@
#include "jni_references.h"
#include <algorithm>
#include <atomic>
#include <cstdio>
#include <cstdlib>
namespace jnivm {
// Encoded handles are also read by the guest ABI boundary.
void* my_segment[100000] = {};
int g_jni_ref_index = 1;
} // namespace jnivm
namespace jnivm::internal {
enum class SegmentType : uint8_t {
kEmpty = 0,
kObject = 1,
kClass = 2,
};
// Class handles are interned by binary name. Real JNI resolves the same class
// to the same runtime class object, and class handles are never released, so
// every FindClass/GetObjectClass used to consume a fresh permanent segment
// slot and exhaust the table during long sessions.
// Each frame counts how many local references it holds per handle, so
// DeleteLocalRef costs one hash lookup per open frame.
using LocalFrame = std::unordered_map<jobject, uint32_t>;
thread_local std::vector<LocalFrame> g_local_frames;
std::recursive_mutex g_jni_state_mutex;
std::unordered_map<jobject, std::unique_ptr<Object>> g_object_storage;
std::unordered_map<jobject, uint32_t> g_jni_ref_counts;
std::shared_ptr<void> g_segment_owners[kJniSegmentCapacity];
std::unordered_set<jclass> g_known_classes;
// One handle per Class. Class slots are never freed, so the table holds at
// most one slot per distinct class.
std::unordered_map<const Class*, jclass> g_class_handles;
std::unordered_map<std::string, jobject> g_singleton_objects;
std::unordered_set<jobject> g_singleton_handles;
std::unordered_map<std::string, std::shared_ptr<Class>> g_fallback_classes;
std::unordered_set<jstring> g_known_strings;
std::unordered_map<jarray, std::unique_ptr<PseudoArray>> g_array_storage;
static std::atomic<uint8_t> g_segment_types[kJniSegmentCapacity] = {};
std::vector<int> g_free_slots;
static bool g_shutting_down_jnivm = false;
struct JniVmShutdownHook {
JniVmShutdownHook() {
std::atexit([]() { g_shutting_down_jnivm = true; });
}
};
static JniVmShutdownHook g_shutdown_hook;
bool TraceEnabled() {
static const bool enabled = std::getenv("MOCKTAIL_JNI_TRACE") != nullptr;
return enabled;
}
jobject JniHandleFromIndex(uint32_t index) {
return reinterpret_cast<jobject>(static_cast<uintptr_t>(index)
<< kJniHandleShift);
}
uint32_t JniIndexFromHandle(jobject obj) {
return static_cast<uint32_t>(reinterpret_cast<uintptr_t>(obj) >>
kJniHandleShift);
}
void EnsureLocalFrame() {
if (g_local_frames.empty()) {
g_local_frames.emplace_back();
}
}
void RegisterLocalRef(jobject obj) {
if (obj == nullptr) {
return;
}
EnsureLocalFrame();
++g_local_frames.back()[obj];
}
void UnregisterLocalRef(jobject obj) {
if (obj == nullptr) {
return;
}
for (auto frame = g_local_frames.rbegin(); frame != g_local_frames.rend();
++frame) {
auto it = frame->find(obj);
if (it != frame->end()) {
if (--it->second == 0) {
frame->erase(it);
}
return;
}
}
}
// A detached thread can no longer use its local references, so they go the
// way ART drops them on detach.
void ReleaseThreadLocalRefs() {
std::vector<LocalFrame> frames = std::move(g_local_frames);
g_local_frames.clear();
for (const LocalFrame& frame : frames) {
for (const auto& [obj, count] : frame) {
for (uint32_t i = 0; i < count; ++i) {
ReleaseJniReference(obj);
}
}
}
}
void RetainJniReference(jobject obj) {
if (obj == nullptr) {
return;
}
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto it = g_jni_ref_counts.find(obj);
if (it != g_jni_ref_counts.end()) {
++it->second;
} else {
g_jni_ref_counts[obj] = 1;
}
}
void ReportHandleTableFull();
int AllocateSegmentSlot(void* value, std::shared_ptr<void> owner = nullptr,
SegmentType type = SegmentType::kObject) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
int index = 0;
if (!g_free_slots.empty()) {
index = g_free_slots.back();
g_free_slots.pop_back();
} else if (g_jni_ref_index > 0 &&
static_cast<uint32_t>(g_jni_ref_index) < kJniSegmentCapacity) {
index = g_jni_ref_index++;
} else {
ReportHandleTableFull();
return 0;
}
my_segment[index] = value;
g_segment_owners[index] = std::move(owner);
g_segment_types[index].store(static_cast<uint8_t>(type),
std::memory_order_release);
return index;
}
std::shared_ptr<Class> FallbackClassForName(const std::string& class_name) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto it = g_fallback_classes.find(class_name);
if (it != g_fallback_classes.end()) {
return it->second;
}
auto cls = std::make_shared<Class>(class_name);
g_fallback_classes[class_name] = cls;
return cls;
}
std::shared_ptr<Class> ClassFromJClass(jclass clazz) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
if (!clazz) {
return nullptr;
}
const uint32_t index = JniIndexFromHandle(reinterpret_cast<jobject>(clazz));
Class* cls = nullptr;
if (index > 0 && index < kJniSegmentCapacity) {
cls = reinterpret_cast<Class*>(my_segment[index]);
if (g_segment_owners[index]) {
return std::static_pointer_cast<Class>(g_segment_owners[index]);
}
} else {
cls = reinterpret_cast<Class*>(clazz);
}
if (!cls) {
return nullptr;
}
return FallbackClassForName(cls->GetName());
}
jclass StoreClass(std::shared_ptr<Class> cls) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
if (!cls) {
return nullptr;
}
Class* raw_ptr = cls.get();
if (const auto cached = g_class_handles.find(raw_ptr);
cached != g_class_handles.end()) {
return cached->second;
}
int index = AllocateSegmentSlot(raw_ptr, cls, SegmentType::kClass);
if (index <= 0) {
return nullptr;
}
jclass handle = reinterpret_cast<jclass>(
JniHandleFromIndex(static_cast<uint32_t>(index)));
g_known_classes.insert(handle);
g_class_handles.emplace(raw_ptr, handle);
return handle;
}
jobject StoreObject(std::unique_ptr<Object> object) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
Object* raw_ptr = object.get();
int index = AllocateSegmentSlot(raw_ptr, nullptr, SegmentType::kObject);
if (index <= 0) {
return nullptr;
}
jobject handle = JniHandleFromIndex(static_cast<uint32_t>(index));
g_object_storage[handle] = std::move(object);
g_jni_ref_counts[handle] = 1;
RegisterLocalRef(handle);
return handle;
}
void ReleaseJniReference(jobject obj) {
if (obj == nullptr || g_shutting_down_jnivm) {
return;
}
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
if (g_shutting_down_jnivm) {
return;
}
// Every live object and array holds a count, so a handle without one was
// already released. Releasing it again would queue its slot twice.
auto ref_it = g_jni_ref_counts.find(obj);
if (ref_it == g_jni_ref_counts.end()) {
return;
}
if (ref_it->second > 1) {
--ref_it->second;
return;
}
g_jni_ref_counts.erase(ref_it);
auto arr_it = g_array_storage.find(reinterpret_cast<jarray>(obj));
if (arr_it != g_array_storage.end()) {
std::unique_ptr<PseudoArray> dying_array = std::move(arr_it->second);
std::vector<jobject> elements = std::move(dying_array->objects);
g_array_storage.erase(arr_it);
// Release after detaching the array: elements may recursively erase other
// arrays. Keep dying_array alive until those releases have completed.
for (jobject element : elements) {
ReleaseJniReference(element);
}
return;
}
if (g_known_classes.find(reinterpret_cast<jclass>(obj)) !=
g_known_classes.end()) {
return;
}
if (g_singleton_handles.count(obj) != 0) {
return;
}
const uint32_t index = JniIndexFromHandle(obj);
if (index > 0 && index < kJniSegmentCapacity) {
g_segment_types[index].store(static_cast<uint8_t>(SegmentType::kEmpty),
std::memory_order_release);
my_segment[index] = nullptr;
g_segment_owners[index].reset();
g_free_slots.push_back(static_cast<int>(index));
}
g_known_strings.erase(reinterpret_cast<jstring>(obj));
std::unique_ptr<Object> dying_object;
auto obj_it = g_object_storage.find(obj);
if (obj_it != g_object_storage.end()) {
dying_object = std::move(obj_it->second);
g_object_storage.erase(obj_it);
}
}
PseudoJavaObject* PseudoObjectFromRef(jobject obj) {
if (__builtin_expect(obj == nullptr, 0)) {
return nullptr;
}
const uint32_t index = JniIndexFromHandle(obj);
if (__builtin_expect(index > 0 && index < kJniSegmentCapacity, 1)) {
const uint8_t type = g_segment_types[index].load(std::memory_order_acquire);
if (__builtin_expect(type == static_cast<uint8_t>(SegmentType::kObject),
1)) {
void* raw_ptr = my_segment[index];
if (__builtin_expect(raw_ptr != nullptr, 1)) {
return static_cast<PseudoJavaObject*>(
reinterpret_cast<Object*>(raw_ptr));
}
}
}
return nullptr;
}
jobject MakeObjectForClass(const std::string& class_name) {
auto cls = FallbackClassForName(class_name);
return StoreObject(std::make_unique<PseudoJavaObject>(std::move(cls)));
}
jobject MakeObject(jclass clazz) {
auto cls = ClassFromJClass(clazz);
if (!cls) {
cls = FallbackClassForName("java/lang/Object");
}
return StoreObject(std::make_unique<PseudoJavaObject>(std::move(cls)));
}
jobject SingletonObject(const std::string& class_name) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto it = g_singleton_objects.find(class_name);
if (it != g_singleton_objects.end()) {
return it->second;
}
jobject object = MakeObjectForClass(class_name);
g_singleton_objects[class_name] = object;
g_singleton_handles.insert(object);
return object;
}
std::string_view ObjectClassName(jobject obj) {
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
if (!pseudo_object || !pseudo_object->GetClass()) {
return {};
}
return pseudo_object->GetClass()->GetName();
}
void MarkSingletonHandle(jobject obj) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
g_singleton_handles.insert(obj);
}
// Logs once per process which classes hold the live handles, so the leak
// behind a later null FindClass or NewStringUTF can be named.
void ReportHandleTableFull() {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
static bool reported = false;
if (reported) {
return;
}
reported = true;
std::unordered_map<std::string_view, std::size_t> live_by_class;
for (const auto& entry : g_object_storage) {
std::string_view name = ObjectClassName(entry.first);
++live_by_class[name.empty() ? std::string_view("<unknown>") : name];
}
std::vector<std::pair<std::string_view, std::size_t>> ranked(
live_by_class.begin(), live_by_class.end());
const std::size_t shown = std::min<std::size_t>(ranked.size(), 10);
std::partial_sort(ranked.begin(), ranked.begin() + shown, ranked.end(),
[](const auto& a, const auto& b) {
return a.second > b.second;
});
fprintf(stderr,
"[JNI] handle table full (%u slots): %zu objects, %zu classes, "
"%zu arrays live; new objects return null\n",
kJniSegmentCapacity, g_object_storage.size(),
g_class_handles.size(), g_array_storage.size());
for (std::size_t i = 0; i < shown; ++i) {
fprintf(stderr, "[JNI] %zu x %.*s\n", ranked[i].second,
static_cast<int>(ranked[i].first.size()), ranked[i].first.data());
}
}
PseudoArray* ArrayFromRef(jarray array) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto it = g_array_storage.find(array);
return it == g_array_storage.end() ? nullptr : it->second.get();
}
jbyteArray MakeByteArray(jsize len) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto array = std::make_unique<PseudoArray>();
if (len > 0) {
array->bytes.resize(static_cast<std::size_t>(len));
}
jbyteArray ref = reinterpret_cast<jbyteArray>(array.get());
g_array_storage[ref] = std::move(array);
g_jni_ref_counts[reinterpret_cast<jobject>(ref)] = 1;
RegisterLocalRef(reinterpret_cast<jobject>(ref));
return ref;
}
jfloatArray MakeFloatArray(jsize len) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto array = std::make_unique<PseudoArray>();
if (len > 0) {
array->floats.resize(static_cast<std::size_t>(len));
}
jfloatArray ref = reinterpret_cast<jfloatArray>(array.get());
g_array_storage[ref] = std::move(array);
g_jni_ref_counts[reinterpret_cast<jobject>(ref)] = 1;
RegisterLocalRef(reinterpret_cast<jobject>(ref));
return ref;
}
jobjectArray MakeObjectArray(jsize len, jobject init) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto array = std::make_unique<PseudoArray>();
if (len > 0) {
array->objects.resize(static_cast<std::size_t>(len), init);
if (init != nullptr) {
for (jsize index = 0; index < len; ++index) {
RetainJniReference(init);
}
}
}
jobjectArray ref = reinterpret_cast<jobjectArray>(array.get());
g_array_storage[ref] = std::move(array);
g_jni_ref_counts[reinterpret_cast<jobject>(ref)] = 1;
RegisterLocalRef(reinterpret_cast<jobject>(ref));
return ref;
}
PseudoJavaObject::~PseudoJavaObject() {
for (const auto& field : object_fields) ReleaseJniReference(field.second);
}
jobject NewLocalJniReference(jobject obj) {
if (obj != nullptr) {
RetainJniReference(obj);
RegisterLocalRef(obj);
}
return obj;
}
void DeleteLocalJniReference(jobject obj) {
UnregisterLocalRef(obj);
ReleaseJniReference(obj);
}
void PushLocalJniFrame() {
EnsureLocalFrame();
g_local_frames.emplace_back();
}
jobject PopLocalJniFrame(jobject result) {
EnsureLocalFrame();
RetainJniReference(result);
LocalFrame frame = std::move(g_local_frames.back());
if (g_local_frames.size() > 1)
g_local_frames.pop_back();
else
g_local_frames.back().clear();
for (const auto& [obj, count] : frame) {
for (uint32_t i = 0; i < count; ++i) {
ReleaseJniReference(obj);
}
}
RegisterLocalRef(result);
return result;
}
} // namespace jnivm::internal
+65
View File
@@ -0,0 +1,65 @@
#ifndef MOCKTAIL_JNIVM_JNI_REFERENCES_H_
#define MOCKTAIL_JNIVM_JNI_REFERENCES_H_
#include <string_view>
#include <unordered_set>
#include "jnivm/jnivm.h"
namespace jnivm::internal {
// Shared by reference ownership and object/field access. Callers retaining a
// raw PseudoJavaObject pointer must keep its JNI reference alive.
extern std::recursive_mutex g_jni_state_mutex;
extern std::unordered_set<jstring> g_known_strings;
inline constexpr uint32_t kJniSegmentCapacity = 100000;
inline constexpr uint32_t kJniHandleShift = 16;
struct PseudoArray {
std::vector<jbyte> bytes;
std::vector<jfloat> floats;
std::vector<jobject> objects;
};
struct PseudoJavaObject : Object {
explicit PseudoJavaObject(std::shared_ptr<Class> cls)
: Object(std::move(cls)) {}
~PseudoJavaObject() override;
std::unordered_map<std::string, jobject> object_fields;
std::unordered_map<std::string, jint> int_fields;
std::unordered_map<std::string, jlong> long_fields;
std::unordered_map<std::string, jfloat> float_fields;
std::unordered_map<std::string, jboolean> boolean_fields;
};
bool TraceEnabled();
void RegisterLocalRef(jobject obj);
void UnregisterLocalRef(jobject obj);
void ReleaseThreadLocalRefs();
void RetainJniReference(jobject obj);
void ReleaseJniReference(jobject obj);
jobject NewLocalJniReference(jobject obj);
void DeleteLocalJniReference(jobject obj);
void PushLocalJniFrame();
jobject PopLocalJniFrame(jobject result);
std::shared_ptr<Class> FallbackClassForName(const std::string& name);
std::shared_ptr<Class> ClassFromJClass(jclass clazz);
jclass StoreClass(std::shared_ptr<Class> cls);
jobject StoreObject(std::unique_ptr<Object> object);
PseudoJavaObject* PseudoObjectFromRef(jobject obj);
jobject MakeObjectForClass(const std::string& name);
jobject MakeObject(jclass clazz);
jobject SingletonObject(const std::string& name);
// Marks a VM-owned handle so detaching its creating thread does not free it.
void MarkSingletonHandle(jobject obj);
std::string_view ObjectClassName(jobject obj);
PseudoArray* ArrayFromRef(jarray array);
jbyteArray MakeByteArray(jsize len);
jfloatArray MakeFloatArray(jsize len);
jobjectArray MakeObjectArray(jsize len, jobject init);
} // namespace jnivm::internal
#endif
+381
View File
@@ -0,0 +1,381 @@
#include "jni_strings.h"
#include <algorithm>
#include <cstring>
#include <iostream>
#include <limits>
#include "jni_references.h"
namespace jnivm::internal {
namespace {
constexpr jchar kEmptyUtf16[] = {0};
void AppendUtf8CodePoint(std::uint32_t code_point, std::string* output) {
if (code_point <= 0x7f) {
output->push_back(static_cast<char>(code_point));
} else if (code_point <= 0x7ff) {
output->push_back(static_cast<char>(0xc0 | (code_point >> 6)));
output->push_back(static_cast<char>(0x80 | (code_point & 0x3f)));
} else if (code_point <= 0xffff) {
output->push_back(static_cast<char>(0xe0 | (code_point >> 12)));
output->push_back(static_cast<char>(0x80 | ((code_point >> 6) & 0x3f)));
output->push_back(static_cast<char>(0x80 | (code_point & 0x3f)));
} else {
output->push_back(static_cast<char>(0xf0 | (code_point >> 18)));
output->push_back(static_cast<char>(0x80 | ((code_point >> 12) & 0x3f)));
output->push_back(static_cast<char>(0x80 | ((code_point >> 6) & 0x3f)));
output->push_back(static_cast<char>(0x80 | (code_point & 0x3f)));
}
}
std::string Utf16ToUtf8(const std::vector<jchar>& utf16) {
std::string output;
output.reserve(utf16.size());
for (std::size_t index = 0; index < utf16.size(); ++index) {
std::uint32_t code_point = utf16[index];
if (code_point >= 0xd800 && code_point <= 0xdbff &&
index + 1 < utf16.size() && utf16[index + 1] >= 0xdc00 &&
utf16[index + 1] <= 0xdfff) {
code_point =
0x10000 + ((code_point - 0xd800) << 10) + (utf16[++index] - 0xdc00);
} else if (code_point >= 0xd800 && code_point <= 0xdfff) {
code_point = 0xfffd;
}
AppendUtf8CodePoint(code_point, &output);
}
return output;
}
std::string Utf16ToModifiedUtf8(const std::vector<jchar>& utf16,
std::size_t begin = 0,
std::size_t count = std::string::npos) {
std::string output;
if (begin >= utf16.size()) {
return output;
}
const std::size_t end =
std::min(utf16.size(), begin + std::min(count, utf16.size() - begin));
output.reserve((end - begin) * 3);
for (std::size_t index = begin; index < end; ++index) {
const std::uint32_t code_unit = utf16[index];
if (code_unit == 0) {
output.push_back(static_cast<char>(0xc0));
output.push_back(static_cast<char>(0x80));
} else if (code_unit <= 0x7f) {
output.push_back(static_cast<char>(code_unit));
} else if (code_unit <= 0x7ff) {
output.push_back(static_cast<char>(0xc0 | (code_unit >> 6)));
output.push_back(static_cast<char>(0x80 | (code_unit & 0x3f)));
} else {
output.push_back(static_cast<char>(0xe0 | (code_unit >> 12)));
output.push_back(static_cast<char>(0x80 | ((code_unit >> 6) & 0x3f)));
output.push_back(static_cast<char>(0x80 | (code_unit & 0x3f)));
}
}
return output;
}
std::vector<jchar> ModifiedUtf8ToUtf16(const char* input) {
std::vector<jchar> output;
if (input == nullptr) {
return output;
}
const auto* bytes = reinterpret_cast<const std::uint8_t*>(input);
const std::size_t size = std::strlen(input);
for (std::size_t index = 0; index < size;) {
const std::uint8_t first = bytes[index];
std::uint32_t code_point = 0xfffd;
std::size_t consumed = 1;
if (first != 0 && first <= 0x7f) {
code_point = first;
} else if ((first & 0xe0) == 0xc0 && index + 1 < size &&
(bytes[index + 1] & 0xc0) == 0x80) {
code_point = ((first & 0x1f) << 6) | (bytes[index + 1] & 0x3f);
if (code_point == 0 || code_point >= 0x80) {
consumed = 2;
} else {
code_point = 0xfffd;
}
} else if ((first & 0xf0) == 0xe0 && index + 2 < size &&
(bytes[index + 1] & 0xc0) == 0x80 &&
(bytes[index + 2] & 0xc0) == 0x80) {
code_point = ((first & 0x0f) << 12) | ((bytes[index + 1] & 0x3f) << 6) |
(bytes[index + 2] & 0x3f);
if (code_point >= 0x800) {
consumed = 3;
} else {
code_point = 0xfffd;
}
} else if ((first & 0xf8) == 0xf0 && index + 3 < size &&
(bytes[index + 1] & 0xc0) == 0x80 &&
(bytes[index + 2] & 0xc0) == 0x80 &&
(bytes[index + 3] & 0xc0) == 0x80) {
code_point = ((first & 0x07) << 18) | ((bytes[index + 1] & 0x3f) << 12) |
((bytes[index + 2] & 0x3f) << 6) | (bytes[index + 3] & 0x3f);
if (code_point >= 0x10000 && code_point <= 0x10ffff) {
consumed = 4;
} else {
code_point = 0xfffd;
}
}
if (code_point <= 0xffff) {
output.push_back(static_cast<jchar>(code_point));
} else {
code_point -= 0x10000;
output.push_back(static_cast<jchar>(0xd800 + (code_point >> 10)));
output.push_back(static_cast<jchar>(0xdc00 + (code_point & 0x3ff)));
}
index += consumed;
}
return output;
}
static std::vector<jchar> MakeUtf16Vector(const jchar* utf16, jsize length) {
if (utf16 != nullptr && length > 0) {
return std::vector<jchar>(utf16, utf16 + length);
}
return {};
}
struct PseudoStringObject : PseudoJavaObject {
PseudoStringObject(std::shared_ptr<Class> cls, const char* utf)
: PseudoJavaObject(std::move(cls)),
chars(ModifiedUtf8ToUtf16(utf)),
value(Utf16ToUtf8(chars)),
modified_utf8(Utf16ToModifiedUtf8(chars)) {}
PseudoStringObject(std::shared_ptr<Class> cls, const jchar* utf16,
jsize length)
: PseudoJavaObject(std::move(cls)),
chars(MakeUtf16Vector(utf16, length)),
value(Utf16ToUtf8(chars)),
modified_utf8(Utf16ToModifiedUtf8(chars)) {}
std::vector<jchar> chars;
std::string value;
std::string modified_utf8;
};
} // namespace
jstring MakeString(const char* utf) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto cls = FallbackClassForName("java/lang/String");
auto object = std::make_unique<PseudoStringObject>(std::move(cls), utf);
jobject handle = StoreObject(std::move(object));
jstring str = reinterpret_cast<jstring>(handle);
g_known_strings.insert(str);
return str;
}
jstring MakeUtf16String(const jchar* utf16, jsize length) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto cls = FallbackClassForName("java/lang/String");
auto object =
std::make_unique<PseudoStringObject>(std::move(cls), utf16, length);
jobject handle = StoreObject(std::move(object));
jstring str = reinterpret_cast<jstring>(handle);
g_known_strings.insert(str);
return str;
}
std::string_view StringViewFromJString(jstring str) {
if (__builtin_expect(!str, 0)) {
return {};
}
auto* string_object = static_cast<PseudoStringObject*>(
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
if (string_object) {
return string_object->value;
}
const char* utf = reinterpret_cast<const char*>(str);
return utf ? std::string_view(utf) : std::string_view();
}
std::string StringFromJString(jstring str) {
const std::string_view sv = StringViewFromJString(str);
return std::string(sv);
}
bool IsUtf8CharsetName(jstring charset_name) {
std::string normalized = StringFromJString(charset_name);
for (char& character : normalized) {
if (character >= 'A' && character <= 'Z') {
character = static_cast<char>(character - 'A' + 'a');
}
}
return normalized == "utf-8" || normalized == "utf8" ||
normalized == "unicode-1-1-utf-8";
}
std::string JavaStringUtf8Bytes(const std::vector<jchar>& utf16) {
std::string output;
output.reserve(utf16.size());
for (std::size_t index = 0; index < utf16.size(); ++index) {
std::uint32_t code_point = utf16[index];
if (code_point >= 0xd800 && code_point <= 0xdbff &&
index + 1 < utf16.size() && utf16[index + 1] >= 0xdc00 &&
utf16[index + 1] <= 0xdfff) {
code_point =
0x10000 + ((code_point - 0xd800) << 10) + (utf16[++index] - 0xdc00);
} else if (code_point >= 0xd800 && code_point <= 0xdfff) {
// CharsetEncoder replaces each malformed UTF-16 unit with '?'.
output.push_back('?');
continue;
}
AppendUtf8CodePoint(code_point, &output);
}
return output;
}
jbyteArray JavaStringGetUtf8Bytes(jobject obj, jstring charset_name) {
if (!IsUtf8CharsetName(charset_name)) {
if (TraceEnabled()) {
std::cerr << " [JNI] java/lang/String.getBytes rejected unsupported "
"charset\n";
}
return nullptr;
}
std::string bytes;
{
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* string_object =
dynamic_cast<PseudoStringObject*>(PseudoObjectFromRef(obj));
if (string_object == nullptr) {
return nullptr;
}
bytes = JavaStringUtf8Bytes(string_object->chars);
}
if (bytes.size() >
static_cast<std::size_t>(std::numeric_limits<jsize>::max())) {
return nullptr;
}
jbyteArray result = MakeByteArray(static_cast<jsize>(bytes.size()));
PseudoArray* array = ArrayFromRef(result);
if (array == nullptr || array->bytes.size() != bytes.size()) {
return nullptr;
}
if (!bytes.empty()) {
std::memcpy(array->bytes.data(), bytes.data(), bytes.size());
}
return result;
}
static inline bool IsRawStringPointer(jstring str) {
uintptr_t val = reinterpret_cast<uintptr_t>(str);
if (val < 0x10000) {
return false;
}
if ((val & 0xffff) == 0 && (val >> kJniHandleShift) < kJniSegmentCapacity) {
return false;
}
return true;
}
const char* StringChars(jstring str) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
if (!str) {
return nullptr;
}
if (g_known_strings.find(str) != g_known_strings.end()) {
auto* string_object = static_cast<PseudoStringObject*>(
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
return string_object != nullptr ? string_object->modified_utf8.c_str() : "";
}
if (!IsRawStringPointer(str)) {
return "";
}
return reinterpret_cast<const char*>(str);
}
const jchar* StringUtf16Chars(jstring str) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
if (!str) {
return nullptr;
}
if (g_known_strings.find(str) != g_known_strings.end()) {
auto* string_object = static_cast<PseudoStringObject*>(
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
return string_object && !string_object->chars.empty()
? string_object->chars.data()
: kEmptyUtf16;
}
if (!IsRawStringPointer(str)) {
return kEmptyUtf16;
}
return reinterpret_cast<const jchar*>(str);
}
jsize StringUtf16Length(jstring str) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
if (g_known_strings.find(str) != g_known_strings.end()) {
auto* string_object = static_cast<PseudoStringObject*>(
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
return string_object != nullptr
? static_cast<jsize>(string_object->chars.size())
: 0;
}
if (!IsRawStringPointer(str)) {
return 0;
}
const char* bytes = reinterpret_cast<const char*>(str);
return bytes != nullptr ? static_cast<jsize>(std::strlen(bytes)) : 0;
}
jsize StringModifiedUtf8Length(jstring str) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
if (g_known_strings.find(str) != g_known_strings.end()) {
auto* string_object = static_cast<PseudoStringObject*>(
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
return string_object != nullptr
? static_cast<jsize>(string_object->modified_utf8.size())
: 0;
}
if (!IsRawStringPointer(str)) {
return 0;
}
const char* bytes = reinterpret_cast<const char*>(str);
return bytes != nullptr ? static_cast<jsize>(std::strlen(bytes)) : 0;
}
void CopyStringRegion(jstring str, jsize start, jsize length, jchar* output) {
if (str == nullptr || output == nullptr || start < 0 || length <= 0) {
return;
}
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* string_object = static_cast<PseudoStringObject*>(
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
if (string_object == nullptr ||
static_cast<std::size_t>(start) >= string_object->chars.size()) {
return;
}
const std::size_t count =
std::min(static_cast<std::size_t>(length),
string_object->chars.size() - static_cast<std::size_t>(start));
std::copy_n(string_object->chars.data() + start, count, output);
}
void CopyStringModifiedUtf8Region(jstring str, jsize start, jsize length,
char* output) {
if (str == nullptr || output == nullptr || start < 0 || length <= 0) {
return;
}
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* string_object = static_cast<PseudoStringObject*>(
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
if (string_object == nullptr ||
static_cast<std::size_t>(start) >= string_object->chars.size()) {
return;
}
const std::size_t count =
std::min(static_cast<std::size_t>(length),
string_object->chars.size() - static_cast<std::size_t>(start));
const std::string encoded = Utf16ToModifiedUtf8(
string_object->chars, static_cast<std::size_t>(start), count);
std::memcpy(output, encoded.data(), encoded.size());
}
} // namespace jnivm::internal
+25
View File
@@ -0,0 +1,25 @@
#ifndef MOCKTAIL_JNIVM_JNI_STRINGS_H_
#define MOCKTAIL_JNIVM_JNI_STRINGS_H_
#include <jni.h>
#include <string>
#include <string_view>
namespace jnivm::internal {
jstring MakeString(const char* utf);
jstring MakeUtf16String(const jchar* utf16, jsize length);
std::string_view StringViewFromJString(jstring str);
std::string StringFromJString(jstring str);
jbyteArray JavaStringGetUtf8Bytes(jobject obj, jstring charset_name);
const char* StringChars(jstring str);
const jchar* StringUtf16Chars(jstring str);
jsize StringUtf16Length(jstring str);
jsize StringModifiedUtf8Length(jstring str);
void CopyStringRegion(jstring str, jsize start, jsize length, jchar* output);
void CopyStringModifiedUtf8Region(jstring str, jsize start, jsize length,
char* output);
} // namespace jnivm::internal
#endif
+136 -1041
View File
File diff suppressed because it is too large. Load diff
+89 -11
View File
@@ -31,7 +31,6 @@
#include <sys/socket.h>
#include <sys/time.h>
#include <time.h>
#include <execinfo.h>
#include <ucontext.h>
#include <elf.h>
#include <fcntl.h>
@@ -651,10 +650,10 @@ void ApplyRuntimeDefaults() {
SetEnvDefault("MOCKTAIL_INIT_CLIENT_SETTINGS", "0");
SetEnvDefault("MOCKTAIL_POST_CLIENT_SETTINGS", "0");
SetEnvDefault("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
// Current Roblox Android builds drive app startup through NativeGLInterface's
// V2 app bridge path. The legacy NativeAppBridgeInterface entry point stays
// available by opt-in, but running it in parallel can block V2 init.
SetEnvDefault("MOCKTAIL_APP_BRIDGE_APP_START", "0");
// nativeAppBridgeAppStart runs Roblox's StartupController, which builds the
// experimentation protocol that writes ClientSettings/IxpSettings.json. It
// runs inline before V2 init; on its own thread it can block V2 init.
SetEnvDefault("MOCKTAIL_APP_BRIDGE_APP_START", "1");
SetEnvDefault("MOCKTAIL_APP_BRIDGE_APP_START_THREAD", "0");
SetEnvDefault("MOCKTAIL_START_LUA_APP_DM", "1");
SetEnvDefault("MOCKTAIL_START_LUA_APP_DM_INLINE", "0");
@@ -4951,9 +4950,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
mocktail::runtime::ReadRobloxThemeCache(
app_storage_file, dependencies.account_identity().user_id);
if (!saved_theme) {
std::cerr << "[FATAL] Cannot read Roblox theme cache: "
std::cerr << " [theme] Cannot read Roblox theme cache; using default: "
<< saved_theme.error << '\n';
return EXIT_FAILURE;
}
if (saved_theme.dark_theme.has_value()) {
dark_theme = *saved_theme.dark_theme;
@@ -4964,7 +4962,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
if (runtime_config.theme_mode() == "roblox") {
std::cout << " [theme] "
<< (roblox_theme_found ? "Roblox saved theme="
: "Roblox theme missing; default=")
: "Roblox theme unavailable; default=")
<< (dark_theme ? "dark" : "light") << '\n'
<< std::flush;
} else if (app_storage_file != nullptr) {
@@ -4972,9 +4970,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
if (!mocktail::runtime::ApplyRobloxThemeCacheOverride(
app_storage_file, dependencies.account_identity().user_id,
dark_theme, &theme_error)) {
std::cerr << "[FATAL] Roblox theme cache override failed: " << theme_error
<< '\n';
return EXIT_FAILURE;
std::cerr << " [theme] Cannot persist Roblox theme override: "
<< theme_error << '\n';
}
std::cout << " [theme] Roblox local theme="
<< (dark_theme ? "dark" : "light") << '\n'
@@ -6006,6 +6003,10 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
experience_game_symbols;
std::shared_ptr<mocktail::runtime::RobloxExperienceComposition>
experience_composition;
std::unique_ptr<mocktail::runtime::RobloxPermissionsBridge>
eager_permissions_bridge;
std::unique_ptr<mocktail::runtime::RobloxCallProtocolBridge>
eager_call_protocol_bridge;
std::shared_ptr<ExperienceLifecycleTarget> experience_lifecycle_target;
std::shared_ptr<mocktail::runtime::RobloxWindowInputRuntime>
window_input_runtime;
@@ -6665,6 +6666,75 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
<< " run_start_app_with_params="
<< (run_start_app_with_params ? 1 : 0) << '\n' << std::flush;
// The direct GAME path still runs CoreScripts that query app protocols.
// Install these independently of ExperienceProtocol's dynamic launch path.
if (game_session_runtime != nullptr) {
mocktail::runtime::JniEnvironmentProvider environment{
raw_vm, jni_vm.get(), &RestoreGameSessionJniEnvironment};
JNIEnv* env = nullptr;
auto status = environment.Acquire(&env);
if (!status.ok()) {
std::cerr << "[FATAL] GAME protocol JNI environment unavailable\n";
return EXIT_FAILURE;
}
const auto symbols = mocktail::runtime::ResolveRobloxPlatformWebSymbols(
roblox_handle,
reinterpret_cast<mocktail::runtime::SubscribeExperienceLaunchRawFn>(
linker::ResolveSymbol(roblox_handle,
"Java_com_roblox_universalapp_messagebus_"
"MessageBus_doSubscribeRaw")),
reinterpret_cast<mocktail::runtime::DeleteMessageBusConnectionFn>(
linker::ResolveSymbol(roblox_handle,
"Java_com_roblox_universalapp_messagebus_"
"Connection_deleteSharedPtr")));
jclass bus_class =
env->FindClass("com/roblox/universalapp/messagebus/MessageBus");
jmethodID singleton =
bus_class ? env->GetStaticMethodID(
bus_class, "f",
"()Lcom/roblox/universalapp/messagebus/MessageBus;")
: nullptr;
jobject bus = singleton
? env->CallStaticObjectMethod(bus_class, singleton)
: nullptr;
if (bus_class)
env->DeleteLocalRef(bus_class);
if (!bus || env->ExceptionCheck()) {
if (env->ExceptionCheck())
env->ExceptionClear();
if (bus)
env->DeleteLocalRef(bus);
std::cerr << "[FATAL] GAME platform MessageBus is unavailable\n";
return EXIT_FAILURE;
}
const mocktail::runtime::RobloxMessageBusObjects objects{
bus,
jni_vm.get(),
&CreateExperienceRawCallback,
&ClearExperienceRawCallback,
&CreateAsyncMessageBusRequestHandler,
&ClearAsyncMessageBusRequestHandler};
eager_permissions_bridge =
std::make_unique<mocktail::runtime::RobloxPermissionsBridge>(
environment, symbols.permissions, objects,
runtime_config.microphone_enabled());
eager_call_protocol_bridge =
std::make_unique<mocktail::runtime::RobloxCallProtocolBridge>(
environment, symbols.permissions, objects);
status = eager_permissions_bridge->Initialize();
if (status.ok())
status = eager_call_protocol_bridge->Initialize();
env->DeleteLocalRef(bus);
if (!status.ok()) {
std::cerr << "[FATAL] GAME platform protocols did not initialize: "
<< status.message() << '\n';
return EXIT_FAILURE;
}
std::cout << " [platform] direct GAME PermissionsProtocol and "
"CallProtocol ready\n"
<< std::flush;
}
if (experience_game_symbols != nullptr) {
mocktail::runtime::RobloxExperienceMessageBusSymbols message_bus_symbols;
message_bus_symbols.get_launch_id =
@@ -7156,6 +7226,14 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
input_shutdown_completed = window_input_runtime->Shutdown().ok() &&
input_shutdown_completed;
}
if (eager_call_protocol_bridge != nullptr) {
(void)eager_call_protocol_bridge->Shutdown();
eager_call_protocol_bridge.reset();
}
if (eager_permissions_bridge != nullptr) {
(void)eager_permissions_bridge->Shutdown();
eager_permissions_bridge.reset();
}
bool experience_destroyed_app = false;
if (experience_composition != nullptr) {
jni_vm->ClearRobloxExperienceLifecycleCallbacks();
+5
View File
@@ -135,6 +135,7 @@ bool IsBionicLibcOnlySymbol(std::string_view symbol) {
return symbol == "__cmsg_nxthdr" || symbol == "__cxa_thread_atexit_impl" ||
symbol == "__readlink_chk" || symbol == "__register_atfork" ||
symbol == "arc4random_buf" || symbol == "geteuid" ||
(!mocktail::compat::kHostAtoiMatchesBionic && symbol == "atoi") ||
symbol == "getuid" || symbol == "lseek64" ||
symbol == "mallinfo" || symbol == "pread64" || symbol == "pwrite64" ||
symbol == "sigaction" || symbol == "strerror_r" ||
@@ -384,6 +385,10 @@ void RegisterBionicHostLibcRuntimeForLibc() {
RegisterSyntheticSymbol("libc.so", "arc4random_buf",
reinterpret_cast<void*>(
mocktail_bionic_arc4random_buf));
if (!mocktail::compat::kHostAtoiMatchesBionic) {
RegisterSyntheticSymbol("libc.so", "atoi",
reinterpret_cast<void*>(mocktail_bionic_atoi));
}
RegisterSyntheticSymbol("libc.so", "mallinfo",
reinterpret_cast<void*>(mocktail_bionic_mallinfo));
RegisterSyntheticSymbol("libc.so", "sysinfo",
+1
View File
@@ -10,6 +10,7 @@
#include <sys/eventfd.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <sys/un.h>
#include <unistd.h>
+123 -66
View File
@@ -12,6 +12,7 @@
#include <string_view>
#include <system_error>
#include <unistd.h>
#include <utility>
#include <vector>
namespace mocktail {
@@ -45,11 +46,9 @@ constexpr const char* kForeignArchitectures[] = {
};
#endif
struct HostGpus {
bool intel = false;
bool nvidia = false;
bool amd = false;
};
constexpr unsigned int kPciVendorAmd = 0x1002;
constexpr unsigned int kPciVendorNvidia = 0x10de;
constexpr unsigned int kPciVendorIntel = 0x8086;
bool ForeignArchitecture(const std::string& name) {
for (const char* architecture : kForeignArchitectures) {
@@ -110,75 +109,86 @@ bool ParsePciVendor(const std::string& raw, unsigned int* vendor) {
return true;
}
HostGpus DetectHostGpus() {
HostGpus gpus;
std::vector<HostGpu> DetectHostGpus() {
std::vector<HostGpu> gpus;
for (int index = 0; index < 16; ++index) {
const std::string path = "/sys/class/drm/card" + std::to_string(index) +
"/device/vendor";
std::ifstream input(path);
const std::string device =
"/sys/class/drm/card" + std::to_string(index) + "/device";
std::ifstream input(device + "/vendor");
std::string raw;
unsigned int vendor = 0;
if (!(input >> raw) || !ParsePciVendor(raw, &vendor)) {
HostGpu gpu;
if (!(input >> raw) || !ParsePciVendor(raw, &gpu.vendor)) {
continue;
}
if (vendor == 0x8086) {
gpus.intel = true;
} else if (vendor == 0x10de) {
gpus.nvidia = true;
} else if (vendor == 0x1002) {
gpus.amd = true;
std::error_code error;
const std::filesystem::path driver =
std::filesystem::read_symlink(device + "/driver", error);
if (!error) {
gpu.kernel_driver = driver.filename().string();
}
gpus.push_back(std::move(gpu));
}
return gpus;
}
std::string FindIcdFile(const char* filename_needle) {
if (filename_needle == nullptr || filename_needle[0] == '\0') {
return {};
}
std::vector<std::filesystem::path> directories;
for (const char* directory : kIcdDirectories) {
directories.emplace_back(directory);
}
return SelectVulkanIcdManifest(directories, filename_needle);
bool HasVendor(const std::vector<HostGpu>& gpus, unsigned int vendor) {
return std::any_of(gpus.begin(), gpus.end(), [vendor](const HostGpu& gpu) {
return gpu.vendor == vendor;
});
}
std::string SelectHardwareIcd(const HostGpus& gpus) {
const bool prefer_discrete = !EnvIsOff("DRI_PRIME") &&
!EnvIsOff("__NV_PRIME_RENDER_OFFLOAD");
if (prefer_discrete && gpus.nvidia) {
std::string nvidia = FindIcdFile("nvidia_icd");
if (nvidia.empty()) {
nvidia = FindIcdFile("nouveau_icd");
}
if (!nvidia.empty()) {
return nvidia;
}
const char* VendorName(unsigned int vendor) {
switch (vendor) {
case kPciVendorAmd:
return "AMD";
case kPciVendorNvidia:
return "NVIDIA";
case kPciVendorIntel:
return "Intel";
default:
return "unknown";
}
if (prefer_discrete && gpus.amd) {
const std::string amd = FindIcdFile("radeon_icd");
if (!amd.empty()) {
return amd;
}
}
// ICD manifest names that can drive `gpu`, best first. Empty when the bound
// kernel driver has no Vulkan driver at all.
std::vector<const char*> VulkanIcdsFor(const HostGpu& gpu) {
switch (gpu.vendor) {
case kPciVendorNvidia:
if (gpu.kernel_driver == "nvidia") {
return {"nvidia_icd"};
}
if (gpu.kernel_driver == "nouveau") {
return {"nouveau_icd"};
}
return {"nvidia_icd", "nouveau_icd"};
case kPciVendorAmd:
// RADV needs the amdgpu kernel driver.
if (gpu.kernel_driver == "radeon") {
return {};
}
return {"radeon_icd"};
case kPciVendorIntel:
return {"intel_icd", "intel_hasvk_icd"};
default:
return {};
}
if (gpus.intel) {
std::string intel = FindIcdFile("intel_icd");
if (intel.empty()) {
intel = FindIcdFile("intel_hasvk_icd");
}
return intel;
}
std::string SkipReason(const HostGpu& gpu,
const std::vector<const char*>& icds) {
std::string reason = std::string(VendorName(gpu.vendor)) + " card";
if (!gpu.kernel_driver.empty()) {
reason += " on the \"" + gpu.kernel_driver + "\" kernel driver";
}
if (gpus.nvidia) {
std::string nvidia = FindIcdFile("nvidia_icd");
if (nvidia.empty()) {
nvidia = FindIcdFile("nouveau_icd");
}
return nvidia;
if (icds.empty()) {
return reason + " has no Vulkan support";
}
if (gpus.amd) {
return FindIcdFile("radeon_icd");
reason += " has no installed Vulkan driver (looked for";
for (const char* icd : icds) {
reason += std::string(" ") + icd;
}
return {};
return reason + ")";
}
// Match Mesa ANV: 75% of RAM when the machine has more than 4GiB, else 50%.
@@ -197,7 +207,8 @@ const char* AnvSysMemLimitPercent() {
return "50";
}
bool ApplyVulkanIcdPolicy(const HostGpus& gpus, std::string* error) {
bool ApplyVulkanIcdPolicy(const std::vector<HostGpu>& gpus,
std::string* error) {
// Drop software/emulation ICDs even when the user already pinned a driver
// list. Old loaders ignore this variable.
if (!SetDefault("VK_LOADER_DRIVERS_DISABLE",
@@ -210,17 +221,61 @@ bool ApplyVulkanIcdPolicy(const HostGpus& gpus, std::string* error) {
(existing_icds != nullptr && existing_icds[0] != '\0')) {
return true;
}
const std::string icd = SelectHardwareIcd(gpus);
if (icd.empty()) {
std::vector<std::filesystem::path> directories;
for (const char* directory : kIcdDirectories) {
directories.emplace_back(directory);
}
const bool prefer_discrete = !EnvIsOff("DRI_PRIME") &&
!EnvIsOff("__NV_PRIME_RENDER_OFFLOAD");
const VulkanIcdSelection selection =
SelectHostVulkanIcd(gpus, directories, prefer_discrete);
for (const std::string& reason : selection.skipped) {
std::fprintf(stderr, " [runtime] vulkan: %s\n", reason.c_str());
}
if (selection.manifest.empty()) {
if (!selection.skipped.empty()) {
std::fprintf(stderr,
" [runtime] vulkan: no GPU here can run Vulkan; set "
"graphics.backend: opengl in the config\n");
}
return true;
}
std::fprintf(stderr, " [runtime] vulkan ICD=%s\n", icd.c_str());
return SetDefault("VK_DRIVER_FILES", icd, error) &&
SetDefault("VK_ICD_FILENAMES", icd, error);
std::fprintf(stderr, " [runtime] vulkan ICD=%s\n",
selection.manifest.c_str());
return SetDefault("VK_DRIVER_FILES", selection.manifest, error) &&
SetDefault("VK_ICD_FILENAMES", selection.manifest, error);
}
} // namespace
VulkanIcdSelection SelectHostVulkanIcd(
const std::vector<HostGpu>& gpus,
const std::vector<std::filesystem::path>& directories,
bool prefer_discrete) {
constexpr unsigned int kDiscreteFirst[] = {kPciVendorNvidia, kPciVendorAmd,
kPciVendorIntel};
constexpr unsigned int kIntegratedFirst[] = {
kPciVendorIntel, kPciVendorNvidia, kPciVendorAmd};
VulkanIcdSelection selection;
for (const unsigned int vendor :
prefer_discrete ? kDiscreteFirst : kIntegratedFirst) {
for (const HostGpu& gpu : gpus) {
if (gpu.vendor != vendor) {
continue;
}
const std::vector<const char*> icds = VulkanIcdsFor(gpu);
for (const char* icd : icds) {
selection.manifest = SelectVulkanIcdManifest(directories, icd);
if (!selection.manifest.empty()) {
return selection;
}
}
selection.skipped.push_back(SkipReason(gpu, icds));
}
}
return selection;
}
std::string SelectVulkanIcdManifest(
const std::vector<std::filesystem::path>& directories,
std::string_view vendor) {
@@ -291,7 +346,7 @@ bool ApplyGraphicsLaunchPolicy(const RuntimeConfig& config,
if (direct_vulkan) {
const char* wsi_mode =
UnthrottledPresentation(config) ? "immediate" : "mailbox";
const HostGpus gpus = DetectHostGpus();
const std::vector<HostGpu> gpus = DetectHostGpus();
if (!SetDefault("MOCKTAIL_CLIENT_SETTINGS_OVERRIDES_JSON",
kVulkanClientSettingsOverrides, error) ||
!SetDefault("ANV_SYS_MEM_LIMIT", AnvSysMemLimitPercent(), error) ||
@@ -304,7 +359,9 @@ bool ApplyGraphicsLaunchPolicy(const RuntimeConfig& config,
}
// Low FRM only on Intel-only machines. Hybrid NVIDIA/AMD laptops should
// keep the desktop quality default on the discrete GPU.
if (gpus.intel && !gpus.nvidia && !gpus.amd &&
if (HasVendor(gpus, kPciVendorIntel) &&
!HasVendor(gpus, kPciVendorNvidia) &&
!HasVendor(gpus, kPciVendorAmd) &&
!SetDefault("MOCKTAIL_GRAPHICS_QUALITY", "1", error)) {
return false;
}
+3 -2
View File
@@ -243,7 +243,9 @@ bool MergePerformanceClientSettingsOverrides(
// ForceCacheSize settings are byte counts. Let Roblox size its mesh and
// SLIM content caches; values like 256/128 would cap them to a few bytes.
const std::array<ClientSetting, 68> rendering_settings = {{
// Leave MSAA selection to Roblox or explicit client-settings overrides.
// Forcing one sample disables anti-aliasing even at maximum quality.
const std::array<ClientSetting, 67> rendering_settings = {{
{"FIntSmoothClusterTaskQueueMaxParallelTasks", workers},
{"FIntOcclusionWorkerThreadCount", occlusion_workers},
{"FFlagMovePrerenderV2", "True"},
@@ -300,7 +302,6 @@ bool MergePerformanceClientSettingsOverrides(
{"FFlagRenderAllocateShadowMapResourcesOnDemand", "True"},
{"FIntRenderShadowMapDepthCacheMemLimit", "16"},
{"FIntTM2ShadowMapMaxMips", "1"},
{"FIntDebugForceMSAASamples", "1"},
{"FFlagLuauStartupGcSuppression", "False"},
{"FIntLuauGcStepMul", "300"},
{"FIntLuauGcGoalCore", "120"},
+20 -23
View File
@@ -359,46 +359,43 @@ RobloxThemeCacheResult ReadRobloxThemeCache(
return result;
}
const auto read_theme = [&result](const nlohmann::json& value,
std::string_view name) {
const auto read_theme = [](const nlohmann::json& value) -> std::optional<bool> {
if (!value.is_string()) {
result.error = std::string(name) + " is not a string";
return;
return std::nullopt;
}
const std::string& theme = value.get_ref<const std::string&>();
if (theme != "dark" && theme != "light") {
result.error = std::string(name) + " must be dark or light";
return;
if (theme == "dark") {
return true;
}
result.dark_theme = theme == "dark";
if (theme == "light") {
return false;
}
return std::nullopt;
};
if (authenticated_user_id >= 0) {
const auto encoded_device_themes = storage.value.find("DeviceLevelTheme");
if (encoded_device_themes != storage.value.end()) {
if (!encoded_device_themes->is_string()) {
result.error = "Roblox DeviceLevelTheme is not an encoded object";
return result;
}
if (encoded_device_themes != storage.value.end() &&
encoded_device_themes->is_string()) {
const nlohmann::json device_themes = nlohmann::json::parse(
encoded_device_themes->get_ref<const std::string&>(), nullptr, false,
true);
if (device_themes.is_discarded() || !device_themes.is_object()) {
result.error = "Roblox DeviceLevelTheme encoding is invalid";
return result;
}
const auto account_theme =
device_themes.find(std::to_string(authenticated_user_id));
if (account_theme != device_themes.end()) {
read_theme(*account_theme, "Roblox account theme");
return result;
if (device_themes.is_object()) {
const auto account_theme =
device_themes.find(std::to_string(authenticated_user_id));
if (account_theme != device_themes.end()) {
result.dark_theme = read_theme(*account_theme);
if (result.dark_theme.has_value()) {
return result;
}
}
}
}
}
const auto authenticated_theme = storage.value.find("AuthenticatedTheme");
if (authenticated_theme != storage.value.end()) {
read_theme(*authenticated_theme, "Roblox authenticated theme");
result.dark_theme = read_theme(*authenticated_theme);
}
return result;
}
@@ -0,0 +1,33 @@
#include "runtime/roblox_call_protocol_bridge.h"
namespace mocktail {
namespace runtime {
RobloxCallProtocolBridge::RobloxCallProtocolBridge(
JniEnvironmentProvider environment, RobloxMessageBusSymbols symbols,
RobloxMessageBusObjects objects)
: bridge_(environment, symbols, objects, "Call", {"getCallState"},
[](JNIEnv* env, jstring request, std::size_t) {
// CallProtocolCore registers the wire protocol "Call" (not the
// Lua module name "CallProtocol"). Its handler ignores the
// payload and returns CallState::serializeCallStateJson. Match
// the no-call state; preserve the caller's default-muted
// initialization path.
if (request && env->GetStringUTFLength(request) > 65536) {
return RobloxMessageBusReply{"{}", 13, "oversized_request"};
}
return RobloxMessageBusReply{
R"({"status":"Idle","muted":true,"camEnabled":false})", 0,
"idle"};
}) {}
Status RobloxCallProtocolBridge::Initialize() {
return bridge_.Initialize();
}
Status RobloxCallProtocolBridge::Shutdown() {
return bridge_.Shutdown();
}
} // namespace runtime
} // namespace mocktail
+4
View File
@@ -361,6 +361,10 @@ bool MergeDesktopAppPolicyClientSettingsOverride(std::string_view policy_json,
// leave the whole LuaApp blank. Default to the working legacy page while
// preserving explicit user overrides for testing newer UI patches.
overrides.emplace("FFlagLuaAppChartsAppPage", "False");
// Roblox enrolls desktop clients in the flyout side panel through a
// server-side experiment that does not reach this client. Variant 2 is the
// desktop side panel; an explicit user override is preserved.
overrides.emplace("FIntFlyoutTestVariantRollout12", "2");
*merged_json = overrides.dump();
return true;
}
+150 -70
View File
@@ -138,11 +138,16 @@ Status PublishSystemTheme(JNIEnv* env,
status = CheckJni(env, "publish SystemTheme.systemThemeUpdated");
}
if (message_id != nullptr) env->DeleteLocalRef(message_id);
if (payload != nullptr) env->DeleteLocalRef(payload);
if (message != nullptr) env->DeleteLocalRef(message);
if (protocol != nullptr) env->DeleteLocalRef(protocol);
if (message_bus_class != nullptr) env->DeleteLocalRef(message_bus_class);
if (message_id != nullptr)
env->DeleteLocalRef(message_id);
if (payload != nullptr)
env->DeleteLocalRef(payload);
if (message != nullptr)
env->DeleteLocalRef(message);
if (protocol != nullptr)
env->DeleteLocalRef(protocol);
if (message_bus_class != nullptr)
env->DeleteLocalRef(message_bus_class);
return status;
}
@@ -203,7 +208,8 @@ template <>
bool SetField<jint>(JNIEnv* env, jobject object, jclass clazz, const char* name,
const char* signature, jint value) {
jfieldID field = env->GetFieldID(clazz, name, signature);
if (field == nullptr) return false;
if (field == nullptr)
return false;
env->SetIntField(object, field, value);
return true;
}
@@ -212,7 +218,8 @@ template <>
bool SetField<jlong>(JNIEnv* env, jobject object, jclass clazz,
const char* name, const char* signature, jlong value) {
jfieldID field = env->GetFieldID(clazz, name, signature);
if (field == nullptr) return false;
if (field == nullptr)
return false;
env->SetLongField(object, field, value);
return true;
}
@@ -222,7 +229,8 @@ bool SetField<jboolean>(JNIEnv* env, jobject object, jclass clazz,
const char* name, const char* signature,
jboolean value) {
jfieldID field = env->GetFieldID(clazz, name, signature);
if (field == nullptr) return false;
if (field == nullptr)
return false;
env->SetBooleanField(object, field, value);
return true;
}
@@ -231,7 +239,8 @@ template <>
bool SetField<jfloat>(JNIEnv* env, jobject object, jclass clazz,
const char* name, const char* signature, jfloat value) {
jfieldID field = env->GetFieldID(clazz, name, signature);
if (field == nullptr) return false;
if (field == nullptr)
return false;
env->SetFloatField(object, field, value);
return true;
}
@@ -240,7 +249,8 @@ template <>
bool SetField<jobject>(JNIEnv* env, jobject object, jclass clazz,
const char* name, const char* signature, jobject value) {
jfieldID field = env->GetFieldID(clazz, name, signature);
if (field == nullptr) return false;
if (field == nullptr)
return false;
env->SetObjectField(object, field, value);
return true;
}
@@ -352,7 +362,9 @@ RobloxExperienceComposition::RobloxExperienceComposition(
}
}
RobloxExperienceComposition::~RobloxExperienceComposition() { Shutdown(); }
RobloxExperienceComposition::~RobloxExperienceComposition() {
Shutdown();
}
Status RobloxExperienceComposition::InitializePlatformProtocols() {
if (!web_view_cookie_initialization_error_.empty()) {
@@ -366,7 +378,8 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
{
std::lock_guard<std::mutex> lock(mutex_);
if (platform_protocols_initialized_ || objects_ != nullptr ||
web_view_bridge_ != nullptr || permissions_bridge_ != nullptr) {
web_view_bridge_ != nullptr || permissions_bridge_ != nullptr ||
call_protocol_bridge_ != nullptr) {
return FailedPrecondition("platform protocols are already initialized");
}
}
@@ -443,6 +456,16 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
(void)ReleaseGlobalObjects();
return status;
}
auto call_protocol_bridge = std::make_unique<RobloxCallProtocolBridge>(
environment_, permissions_symbols_, permissions_objects);
status = call_protocol_bridge->Initialize();
if (!status.ok()) {
(void)permissions_bridge->Shutdown();
(void)browser_service_bridge->Shutdown();
(void)web_view_bridge->Shutdown();
(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;
@@ -469,12 +492,13 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
web_view_bridge_ = std::move(web_view_bridge);
browser_service_bridge_ = std::move(browser_service_bridge);
permissions_bridge_ = std::move(permissions_bridge);
call_protocol_bridge_ = std::move(call_protocol_bridge);
account_bridge_ = std::move(account_bridge);
platform_protocols_initialized_ = true;
}
std::fprintf(stderr,
" [platform] Android WebViewProtocol, BrowserService and "
"PermissionsProtocol "
"PermissionsProtocol and CallProtocol "
"bridges ready\n");
return Status::Ok();
}
@@ -494,7 +518,8 @@ Status RobloxExperienceComposition::OnLuaAppReady(
std::lock_guard<std::mutex> lock(mutex_);
if (!platform_protocols_initialized_ || objects_ == nullptr ||
objects_->message_bus == nullptr || web_view_bridge_ == nullptr ||
browser_service_bridge_ == nullptr || permissions_bridge_ == nullptr) {
browser_service_bridge_ == nullptr || permissions_bridge_ == nullptr ||
call_protocol_bridge_ == nullptr) {
return FailedPrecondition(
"platform protocols must be initialized before LuaApp readiness");
}
@@ -549,7 +574,8 @@ Status RobloxExperienceComposition::OnLuaAppReady(
controller_.reset();
}
}
if (!status.ok()) (void)ReleaseLuaAppGlobalObjects();
if (!status.ok())
(void)ReleaseLuaAppGlobalObjects();
if (status.ok()) {
std::fprintf(stderr,
" [theme] published SystemTheme.systemThemeUpdated (%s)\n",
@@ -622,8 +648,7 @@ Status RobloxExperienceComposition::OpenWebSurface(
!current->SetBackNavigationDisabled(
presentation.back_navigation_disabled) ||
!current->SetShowDomainAsTitle(presentation.show_domain_as_title) ||
!cookie_synchronized ||
(!spawn && !current->LoadUrl(url))) {
!cookie_synchronized || (!spawn && !current->LoadUrl(url))) {
(void)current->RequestClose();
return Unavailable("could not configure reusable Roblox web surface");
}
@@ -1211,10 +1236,10 @@ Status RobloxExperienceComposition::RefreshLateSurface() {
SurfaceUpdateStatus(SurfaceCreated(event.surface.generation));
break;
case window::WindowSurfaceEventType::kChanged:
status = SurfaceUpdateStatus(SurfaceChanged(
{event.surface.generation, event.surface.native_window,
event.surface.width, event.surface.height,
event.surface.dpi_scale}));
status = SurfaceUpdateStatus(
SurfaceChanged({event.surface.generation,
event.surface.native_window, event.surface.width,
event.surface.height, event.surface.dpi_scale}));
break;
case window::WindowSurfaceEventType::kDestroyed:
status =
@@ -1338,14 +1363,16 @@ Status RobloxExperienceComposition::DrainExternalLaunchRequests() {
void RobloxExperienceComposition::NotifyLuaAppDidReturn() {
std::lock_guard<std::mutex> lock(mutex_);
if (subscribed_) lua_app_return_pending_ = true;
if (subscribed_)
lua_app_return_pending_ = true;
}
Status RobloxExperienceComposition::DrainLaunchRequests() {
Status completed_result = Status::Ok();
if (launch_worker_.joinable()) {
const OwnedPthreadWaitResult wait = launch_worker_.WaitFor(0, 1);
if (wait.status == OwnedPthreadWaitStatus::kTimedOut) return Status::Ok();
if (wait.status == OwnedPthreadWaitStatus::kTimedOut)
return Status::Ok();
if (!wait.joined()) {
return Status::Error(StatusCode::kPlatformError,
"could not reap experience launch worker: " +
@@ -1405,7 +1432,8 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
}
Status external_launch_status = DrainExternalLaunchRequests();
if (!external_launch_status.ok()) return external_launch_status;
if (!external_launch_status.ok())
return external_launch_status;
bool switch_active_game = false;
std::optional<RobloxExperienceLaunchRequest> joining_request;
@@ -1414,7 +1442,8 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
if (!subscribed_ || controller_ == nullptr || objects_ == nullptr) {
return FailedPrecondition("experience composition is not subscribed");
}
if (controlled_switch_waiting_for_return_) return Status::Ok();
if (controlled_switch_waiting_for_return_)
return Status::Ok();
if (!launch_in_progress_ && active_launch_ == nullptr && game_active_ &&
!pending_launch_requests_.empty()) {
// Start the queued launch only after consuming this leave callback, so
@@ -1439,9 +1468,12 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
if (!subscribed_ || controller_ == nullptr || objects_ == nullptr) {
return FailedPrecondition("experience composition is not subscribed");
}
if (launch_in_progress_ || active_launch_ != nullptr) return Status::Ok();
if (game_active_) return Status::Ok();
if (pending_launch_requests_.empty()) return Status::Ok();
if (launch_in_progress_ || active_launch_ != nullptr)
return Status::Ok();
if (game_active_)
return Status::Ok();
if (pending_launch_requests_.empty())
return Status::Ok();
if (next_request_id_ == 0 ||
next_request_id_ == std::numeric_limits<uint64_t>::max()) {
pending_launch_requests_.clear();
@@ -1477,7 +1509,8 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
const int start_error =
launch_worker_.Start(&RobloxExperienceComposition::RunLaunchWorker, this,
kLaunchWorkerStackSize);
if (start_error == 0) return Status::Ok();
if (start_error == 0)
return Status::Ok();
{
std::lock_guard<std::mutex> lock(mutex_);
launch_in_progress_ = false;
@@ -1534,7 +1567,8 @@ void RobloxExperienceComposition::RunActiveLaunch() {
std::lock_guard<std::mutex> lock(mutex_);
task = active_launch_.get();
}
if (task == nullptr) return;
if (task == nullptr)
return;
Status status =
BuildLaunchObjects(task->context.game_surface, &task->context.surface,
&task->context.platform_params);
@@ -1658,6 +1692,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<RobloxCallProtocolBridge> call_protocol_bridge;
std::unique_ptr<RobloxAccountBridge> account_bridge;
std::shared_ptr<WebViewHelperProcess> web_surface_process;
jnivm::VM* late_lifecycle_vm = nullptr;
@@ -1683,6 +1718,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_);
call_protocol_bridge = std::move(call_protocol_bridge_);
account_bridge = std::move(account_bridge_);
web_surface_process = std::move(web_surface_process_);
web_surface_logical_exit_observer_ = {};
@@ -1700,16 +1736,23 @@ Status RobloxExperienceComposition::Shutdown() {
if (web_surface_process != nullptr) {
(void)web_surface_process->RequestClose();
}
Status status =
Status status = call_protocol_bridge != nullptr
? call_protocol_bridge->Shutdown()
: Status::Ok();
const Status account_status =
account_bridge != nullptr ? account_bridge->Shutdown() : Status::Ok();
if (status.ok())
status = account_status;
const Status permissions_status = permissions_bridge != nullptr
? permissions_bridge->Shutdown()
: Status::Ok();
if (status.ok()) status = permissions_status;
if (status.ok())
status = permissions_status;
const Status browser_status = browser_service_bridge != nullptr
? browser_service_bridge->Shutdown()
: Status::Ok();
if (status.ok()) status = browser_status;
if (status.ok())
status = browser_status;
const Status web_view_status =
web_view_bridge != nullptr ? web_view_bridge->Shutdown() : Status::Ok();
if (status.ok()) {
@@ -1721,6 +1764,7 @@ Status RobloxExperienceComposition::Shutdown() {
status = bridge_status;
}
browser_service_bridge.reset();
call_protocol_bridge.reset();
account_bridge.reset();
permissions_bridge.reset();
web_view_bridge.reset();
@@ -1739,14 +1783,16 @@ Status RobloxExperienceComposition::Shutdown() {
launch_in_progress_ = false;
lua_app_return_pending_ = false;
if (active_launch_ != nullptr) {
if (status.ok()) status = active_launch_->result;
if (status.ok())
status = active_launch_->result;
active_launch_.reset();
}
controller = std::move(controller_);
}
if (controller != nullptr) {
const Status controller_status = controller->Shutdown();
if (status.ok()) status = controller_status;
if (status.ok())
status = controller_status;
}
const Status release = ReleaseGlobalObjects();
return status.ok() ? release : status;
@@ -1777,7 +1823,8 @@ void RobloxExperienceComposition::NotifyPresence(
Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
if (!status.ok())
return status;
jobject bus_local = nullptr;
jclass bus_class =
@@ -1791,7 +1838,8 @@ Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
bus_local = singleton != nullptr
? env->CallStaticObjectMethod(bus_class, singleton)
: nullptr;
if (bus_class != nullptr) env->DeleteLocalRef(bus_class);
if (bus_class != nullptr)
env->DeleteLocalRef(bus_class);
status = bus_local != nullptr
? CheckJni(env, "obtain exact MessageBus.f singleton")
: Unavailable("MessageBus.f singleton is unavailable");
@@ -1804,7 +1852,8 @@ Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
}
}
if (bus_local != nullptr) env->DeleteLocalRef(bus_local);
if (bus_local != nullptr)
env->DeleteLocalRef(bus_local);
if (!status.ok()) {
if (global->message_bus != nullptr)
env->DeleteGlobalRef(global->message_bus);
@@ -1822,7 +1871,8 @@ Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
Status RobloxExperienceComposition::BuildLuaAppGlobalObjects() {
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
if (!status.ok())
return status;
jclass native_gl_local =
env->FindClass("com/roblox/engine/jni/NativeGLInterface");
@@ -1846,12 +1896,17 @@ Status RobloxExperienceComposition::BuildLuaAppGlobalObjects() {
}
}
if (activity_local != nullptr) env->DeleteLocalRef(activity_local);
if (activity_class != nullptr) env->DeleteLocalRef(activity_class);
if (native_gl_local != nullptr) env->DeleteLocalRef(native_gl_local);
if (activity_local != nullptr)
env->DeleteLocalRef(activity_local);
if (activity_class != nullptr)
env->DeleteLocalRef(activity_class);
if (native_gl_local != nullptr)
env->DeleteLocalRef(native_gl_local);
if (!status.ok()) {
if (activity_global != nullptr) env->DeleteGlobalRef(activity_global);
if (native_gl_global != nullptr) env->DeleteGlobalRef(native_gl_global);
if (activity_global != nullptr)
env->DeleteGlobalRef(activity_global);
if (native_gl_global != nullptr)
env->DeleteGlobalRef(native_gl_global);
return status;
}
@@ -1878,7 +1933,8 @@ Status RobloxExperienceComposition::BuildLaunchObjects(
*platform_params = nullptr;
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
if (!status.ok())
return status;
jclass surface_class = nullptr;
jobject surface_local = nullptr;
@@ -1912,8 +1968,9 @@ Status RobloxExperienceComposition::BuildLaunchObjects(
SetField<jobject>(env, params_local, params_class, "assetFolderPath",
"Ljava/lang/String;", asset_path) &&
SetField<jfloat>(env, params_local, params_class, "dpiScale", "F",
surface.dpi_scale > 0.0f ? surface.dpi_scale
: surface_config_.dpi_scale) &&
surface.dpi_scale > 0.0f
? surface.dpi_scale
: surface_config_.dpi_scale) &&
SetField<jint>(env, params_local, params_class, "viewportWidthMm", "I",
surface_config_.viewport_width_mm) &&
SetField<jint>(env, params_local, params_class, "viewportHeightMm", "I",
@@ -1932,12 +1989,17 @@ Status RobloxExperienceComposition::BuildLaunchObjects(
? CheckJni(env, "populate fresh launch PlatformParams")
: Unavailable("required PlatformParams fields are unavailable");
}
if (asset_path != nullptr) env->DeleteLocalRef(asset_path);
if (surface_class != nullptr) env->DeleteLocalRef(surface_class);
if (params_class != nullptr) env->DeleteLocalRef(params_class);
if (asset_path != nullptr)
env->DeleteLocalRef(asset_path);
if (surface_class != nullptr)
env->DeleteLocalRef(surface_class);
if (params_class != nullptr)
env->DeleteLocalRef(params_class);
if (!status.ok()) {
if (params_local != nullptr) env->DeleteLocalRef(params_local);
if (surface_local != nullptr) env->DeleteLocalRef(surface_local);
if (params_local != nullptr)
env->DeleteLocalRef(params_local);
if (surface_local != nullptr)
env->DeleteLocalRef(surface_local);
return status;
}
*surface_object = surface_local;
@@ -2008,13 +2070,19 @@ Status RobloxExperienceComposition::BuildLuaAppStartParams(
"required LuaApp StartAppParams fields are unavailable");
}
if (selected_theme != nullptr) env->DeleteLocalRef(selected_theme);
if (username != nullptr) env->DeleteLocalRef(username);
if (app_starter_script != nullptr) env->DeleteLocalRef(app_starter_script);
if (app_starter_place != nullptr) env->DeleteLocalRef(app_starter_place);
if (params_class != nullptr) env->DeleteLocalRef(params_class);
if (selected_theme != nullptr)
env->DeleteLocalRef(selected_theme);
if (username != nullptr)
env->DeleteLocalRef(username);
if (app_starter_script != nullptr)
env->DeleteLocalRef(app_starter_script);
if (app_starter_place != nullptr)
env->DeleteLocalRef(app_starter_place);
if (params_class != nullptr)
env->DeleteLocalRef(params_class);
if (!status.ok()) {
if (params_local != nullptr) env->DeleteLocalRef(params_local);
if (params_local != nullptr)
env->DeleteLocalRef(params_local);
return status;
}
*start_app_params = params_local;
@@ -2046,7 +2114,8 @@ Status RobloxExperienceComposition::RestartLuaAppSurface(
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
if (!status.ok())
return status;
jobject surface_object = nullptr;
jobject platform_params = nullptr;
@@ -2086,11 +2155,16 @@ Status RobloxExperienceComposition::RestartLuaAppSurface(
status = CheckJni(env, "restart LuaApp with recreated surface");
}
if (start_reason != nullptr) env->DeleteLocalRef(start_reason);
if (stop_reason != nullptr) env->DeleteLocalRef(stop_reason);
if (start_app_params != nullptr) env->DeleteLocalRef(start_app_params);
if (platform_params != nullptr) env->DeleteLocalRef(platform_params);
if (surface_object != nullptr) env->DeleteLocalRef(surface_object);
if (start_reason != nullptr)
env->DeleteLocalRef(start_reason);
if (stop_reason != nullptr)
env->DeleteLocalRef(stop_reason);
if (start_app_params != nullptr)
env->DeleteLocalRef(start_app_params);
if (platform_params != nullptr)
env->DeleteLocalRef(platform_params);
if (surface_object != nullptr)
env->DeleteLocalRef(surface_object);
if (status.ok()) {
std::fprintf(
stderr,
@@ -2150,7 +2224,8 @@ Status RobloxExperienceComposition::RebindLuaAppSurface(
Status RobloxExperienceComposition::ReleaseGlobalObjects() {
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
if (!status.ok())
return status;
std::unique_ptr<GlobalObjects> objects;
{
std::lock_guard<std::mutex> lock(mutex_);
@@ -2165,10 +2240,12 @@ Status RobloxExperienceComposition::ReleaseGlobalObjects() {
pending_launch_requests_.clear();
next_request_id_ = 1;
}
if (objects == nullptr) return Status::Ok();
if (objects == nullptr)
return Status::Ok();
if (objects->message_bus != nullptr)
env->DeleteGlobalRef(objects->message_bus);
if (objects->activity != nullptr) env->DeleteGlobalRef(objects->activity);
if (objects->activity != nullptr)
env->DeleteGlobalRef(objects->activity);
if (objects->native_gl_class != nullptr)
env->DeleteGlobalRef(objects->native_gl_class);
return CheckJni(env, "release experience composition JNI roots");
@@ -2177,7 +2254,8 @@ Status RobloxExperienceComposition::ReleaseGlobalObjects() {
Status RobloxExperienceComposition::ReleaseLuaAppGlobalObjects() {
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
if (!status.ok())
return status;
jclass native_gl_class = nullptr;
jobject activity = nullptr;
@@ -2197,8 +2275,10 @@ Status RobloxExperienceComposition::ReleaseLuaAppGlobalObjects() {
pending_launch_requests_.clear();
next_request_id_ = 1;
}
if (activity != nullptr) env->DeleteGlobalRef(activity);
if (native_gl_class != nullptr) env->DeleteGlobalRef(native_gl_class);
if (activity != nullptr)
env->DeleteGlobalRef(activity);
if (native_gl_class != nullptr)
env->DeleteGlobalRef(native_gl_class);
return CheckJni(env, "release LuaApp experience JNI roots");
}
+1 -7
View File
@@ -609,13 +609,7 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseButtonLocked(
}
const float clamped_x = mouse_x_;
const float clamped_y = mouse_y_;
if (event.pressed) {
const RobloxTextEditorSnapshot text = text_editor_.Snapshot();
if (text.focused) {
(void)text_editor_.EndFocusSession(text.textbox_handle, text.generation,
true);
}
}
// Let Roblox decide whether a click releases TextBox focus.
Status status = sink_.mouse_button(sink_.context, clamped_x, clamped_y,
event.pressed, roblox_button);
if (status.ok()) {
+33 -2
View File
@@ -2,6 +2,9 @@
#include <curl/curl.h>
// [patch.py] RequestFollowUser support
#include "runtime/roblox_presence_resolver.h"
#include <algorithm>
#include <array>
#include <cctype>
@@ -541,14 +544,42 @@ Status ValidateSelector(const LaunchFields& fields) {
? Status::Ok()
: Invalid("RequestPlayTogetherGame requires a conversation");
}
// [patch.py] RequestFollowUser support
if (request == "requestfollowuser") {
// By the time ValidateSelector runs, MakeRequest has already
// resolved user_id -> place_id and cleared user_id, so check the
// resolved place_id here rather than the now-zeroed user_id.
return fields.place_id > 0
? Status::Ok()
: Invalid("RequestFollowUser could not resolve a place");
}
return Invalid("Roblox PlaceLauncher request type is unsupported");
}
Status MakeRequest(const LaunchFields& fields,
// [patch.py] RequestFollowUser support
Status MakeRequest(const LaunchFields& fields_in,
RobloxExperienceLaunchRequest* request) {
if (fields.place_id <= 0) {
LaunchFields fields = fields_in;
const bool is_follow_user =
AsciiLower(fields.request_type) == "requestfollowuser";
if (fields.place_id <= 0 && !is_follow_user) {
return Invalid("Roblox launch URI requires a positive placeId");
}
if (fields.place_id <= 0 && is_follow_user) {
ResolvedFollowUserPlace resolved;
const Status resolve_status = ResolveFollowUserPlace(
fields.user_id, ReadStoredRoblosecurityCookie(), &resolved);
if (!resolve_status.ok()) {
return resolve_status;
}
fields.place_id = resolved.place_id;
if (!resolved.game_instance_id.empty()) {
fields.game_instance_id = resolved.game_instance_id;
}
// user_id was only needed to resolve which place to join; clear
// it so ValidateSelector does not see two conflicting selectors.
fields.user_id = 0;
}
Status status = ValidateSelector(fields);
if (!status.ok()) {
return status;
@@ -0,0 +1,295 @@
#include "runtime/roblox_message_bus_request_bridge.h"
#include <condition_variable>
#include <cstdio>
#include <utility>
namespace mocktail {
namespace runtime {
namespace {
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("MessageBus JNI operation failed");
}
// JNI local references are never retained across callback invocations.
struct LocalString {
JNIEnv* env;
jstring value;
LocalString(JNIEnv* source, const char* text)
: env(source), value(source->NewStringUTF(text)) {}
~LocalString() {
if (value)
env->DeleteLocalRef(value);
}
};
} // namespace
struct RobloxMessageBusRequestBridge::State {
struct Endpoint {
std::shared_ptr<State> state;
std::size_t method;
};
struct Registration {
jobject callback = nullptr;
jobject handler = nullptr;
jobject connection = nullptr;
jlong connection_handle = 0;
jstring method = nullptr;
bool handler_registered = false;
};
RobloxMessageBusSymbols symbols;
jobject bus = nullptr;
jstring protocol = nullptr;
std::string protocol_name;
std::vector<std::string> methods;
Handler handler;
std::vector<Registration> registrations;
std::mutex mutex;
std::condition_variable drained;
bool accepting = true;
std::size_t in_flight = 0;
static void Legacy(void* context, JNIEnv* env, jstring message) {
Dispatch(context, env, message, nullptr);
}
static void Async(void* context, JNIEnv* env, jstring message, jstring id) {
Dispatch(context, env, message, id);
}
static void Dispatch(void* context, JNIEnv* env, jstring message,
jstring id) {
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};
const auto response = state->handler(env, message, endpoint->method);
// Request bodies and correlation IDs must never be logged.
std::fprintf(stderr, " [messagebus] %s method=%s result=%s transport=%s\n",
state->protocol_name.c_str(),
state->methods[endpoint->method].c_str(), response.outcome,
id ? "async" : "legacy");
LocalString body(env, response.json.c_str());
LocalString telemetry(env, "{}");
LocalString method(env, state->methods[endpoint->method].c_str());
const Status allocated = CheckJni(env);
if (!body.value || !telemetry.value || !method.value || !allocated.ok()) {
std::fprintf(stderr, " [messagebus] response allocation failed\n");
return;
}
// The APK publishes the legacy response even for the newer async API,
// then resolves the individual request by its opaque response ID.
state->symbols.publish_response(env, state->bus, state->protocol,
method.value, body.value, response.code,
telemetry.value);
const Status published = CheckJni(env);
if (id) {
state->symbols.call_response_handler(env, state->bus, id, body.value);
}
if (!CheckJni(env).ok() || !published.ok()) {
std::fprintf(stderr, " [messagebus] response delivery failed\n");
}
}
};
RobloxMessageBusRequestBridge::RobloxMessageBusRequestBridge(
JniEnvironmentProvider environment, RobloxMessageBusSymbols symbols,
RobloxMessageBusObjects objects, std::string protocol,
std::vector<std::string> methods, Handler handler)
: environment_(environment),
symbols_(symbols),
objects_(objects),
protocol_name_(std::move(protocol)),
methods_(std::move(methods)),
handler_(std::move(handler)) {}
RobloxMessageBusRequestBridge::~RobloxMessageBusRequestBridge() {
(void)Shutdown();
}
Status RobloxMessageBusRequestBridge::Initialize() {
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
if (state_) {
return Status::Error(StatusCode::kFailedPrecondition,
"MessageBus request bridge is already initialized");
}
if (!environment_.valid() || !symbols_.complete() || !objects_.complete() ||
protocol_name_.empty() || methods_.empty() || methods_.size() > 64 ||
!handler_) {
return Unavailable(
"MessageBus request bridge prerequisites are incomplete");
}
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok())
return status;
state_ = std::make_shared<State>();
state_->symbols = symbols_;
state_->protocol_name = protocol_name_;
state_->methods = methods_;
state_->handler = handler_;
state_->registrations.resize(methods_.size());
state_->bus = env->NewGlobalRef(objects_.message_bus);
LocalString protocol(env, protocol_name_.c_str());
state_->protocol = static_cast<jstring>(env->NewGlobalRef(protocol.value));
auto fail = [this](Status failure) {
(void)ShutdownLocked();
return failure;
};
if (!state_->bus || !state_->protocol || !CheckJni(env).ok()) {
return fail(
Unavailable("could not retain MessageBus request bridge roots"));
}
for (std::size_t i = 0; i < methods_.size(); ++i) {
auto& entry = state_->registrations[i];
auto endpoint =
std::make_shared<State::Endpoint>(State::Endpoint{state_, i});
LocalString method(env, methods_[i].c_str());
entry.method = static_cast<jstring>(env->NewGlobalRef(method.value));
jobject callback = objects_.create_raw_callback(objects_.context, endpoint,
&State::Legacy);
if (callback) {
entry.callback = env->NewGlobalRef(callback);
if (!entry.callback)
objects_.clear_raw_callback(objects_.context, callback);
env->DeleteLocalRef(callback);
}
jobject handler = objects_.create_async_handler(objects_.context, endpoint,
&State::Async);
if (handler) {
entry.handler = env->NewGlobalRef(handler);
if (!entry.handler)
objects_.clear_async_handler(objects_.context, handler);
env->DeleteLocalRef(handler);
}
if (!entry.method || !entry.callback || !entry.handler ||
!CheckJni(env).ok()) {
return fail(
Unavailable("could not create MessageBus request bridge callbacks"));
}
jobject connection =
symbols_.subscribe_request(env, state_->bus, state_->protocol,
entry.method, entry.callback, JNI_FALSE);
if (connection) {
jclass cls = env->GetObjectClass(connection);
jfieldID pointer = cls ? env->GetFieldID(cls, "a", "J") : nullptr;
if (pointer)
entry.connection_handle = env->GetLongField(connection, pointer);
if (cls)
env->DeleteLocalRef(cls);
entry.connection = env->NewGlobalRef(connection);
if (!entry.connection && entry.connection_handle) {
symbols_.delete_connection(env, connection, entry.connection_handle);
entry.connection_handle = 0;
}
env->DeleteLocalRef(connection);
}
if (!entry.connection || !entry.connection_handle || !CheckJni(env).ok()) {
return fail(
Unavailable("could not subscribe MessageBus request bridge request"));
}
entry.handler_registered = true;
symbols_.set_async_handler(env, state_->bus, state_->protocol, entry.method,
entry.handler);
status = CheckJni(env);
if (!status.ok())
return fail(status);
}
std::fprintf(stderr, " [messagebus] %s ready legacy=%zu async=%zu\n",
protocol_name_.c_str(), methods_.size(), methods_.size());
return Status::Ok();
}
Status RobloxMessageBusRequestBridge::Shutdown() {
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
return ShutdownLocked();
}
Status RobloxMessageBusRequestBridge::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.handler_registered) {
symbols_.clear_handler(env, state_->bus, state_->protocol, entry.method);
const Status cleared = CheckJni(env);
if (!cleared.ok())
status = cleared;
entry.handler_registered = false;
}
if (entry.connection && entry.connection_handle) {
symbols_.delete_connection(env, entry.connection,
entry.connection_handle);
const Status disconnected = CheckJni(env);
if (!disconnected.ok())
status = disconnected;
entry.connection_handle = 0;
}
if (entry.callback) {
objects_.clear_raw_callback(objects_.context, entry.callback);
env->DeleteGlobalRef(entry.callback);
}
if (entry.handler) {
objects_.clear_async_handler(objects_.context, entry.handler);
env->DeleteGlobalRef(entry.handler);
}
if (entry.connection)
env->DeleteGlobalRef(entry.connection);
if (entry.method)
env->DeleteGlobalRef(entry.method);
entry = {};
const Status cleared = CheckJni(env);
if (!cleared.ok())
status = cleared;
}
if (state_->protocol)
env->DeleteGlobalRef(state_->protocol);
if (state_->bus)
env->DeleteGlobalRef(state_->bus);
state_.reset();
return status;
}
} // namespace runtime
} // namespace mocktail
+29 -257
View File
@@ -1,46 +1,14 @@
#include "runtime/roblox_permissions_bridge.h"
#include <array>
#include <condition_variable>
#include <cstdio>
#include <nlohmann/json.hpp>
#include <string>
#include <utility>
namespace mocktail {
namespace runtime {
namespace {
constexpr char kProtocol[] = "PermissionsProtocol";
constexpr char kMicrophone[] = "MICROPHONE_ACCESS";
constexpr char kLocalNetwork[] = "LOCAL_NETWORK";
constexpr const char* kMethods[] = {
"SupportsPermissions", "HasPermissions", "PermissionsRequest",
"ShouldShowPermissionUpsell", "ShouldShowRequestPermissionRationale"};
constexpr std::size_t kMethodCount = std::size(kMethods);
constexpr std::size_t kMaximumJsonBytes = 64 * 1024;
constexpr std::size_t kMaximumFeatures = 64;
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("PermissionsProtocol JNI operation failed");
}
// JNI local references are never retained across callback invocations.
struct LocalString {
JNIEnv* env;
jstring value;
LocalString(JNIEnv* source, const char* text)
: env(source), value(source->NewStringUTF(text)) {}
~LocalString() {
if (value) env->DeleteLocalRef(value);
}
};
constexpr char kMicrophone[] = "MICROPHONE_ACCESS";
constexpr char kLocalNetwork[] = "LOCAL_NETWORK";
struct Response {
nlohmann::json body;
@@ -63,7 +31,8 @@ bool HasBoundedDepth(const std::string& json) {
} else if (ch == '"') {
quoted = true;
} else if (ch == '{' || ch == '[') {
if (++depth > 8) return false;
if (++depth > 8)
return false;
} else if (ch == '}' || ch == ']') {
--depth;
}
@@ -82,18 +51,22 @@ Response Answer(JNIEnv* env, jstring message, std::size_t method,
: nlohmann::json{{"status", "DENIED"},
{"missingPermissions", {kMicrophone}}},
13, "invalid_request"};
if (!message) return rejected;
if (!message)
return rejected;
const jsize size = env->GetStringUTFLength(message);
if (size <= 0 || static_cast<std::size_t>(size) > kMaximumJsonBytes) {
return rejected;
}
const char* bytes = env->GetStringUTFChars(message, nullptr);
if (!bytes) return rejected;
if (!bytes)
return rejected;
const std::string owned(bytes, static_cast<std::size_t>(size));
env->ReleaseStringUTFChars(message, bytes);
if (!HasBoundedDepth(owned)) return rejected;
if (!HasBoundedDepth(owned))
return rejected;
const auto request = nlohmann::json::parse(owned, nullptr, false);
if (!request.is_object()) return rejected;
if (!request.is_object())
return rejected;
if (support) {
return {{{"permissions", {kMicrophone, kLocalNetwork}}}, 0, "supported"};
}
@@ -104,7 +77,8 @@ Response Answer(JNIEnv* env, jstring message, std::size_t method,
}
auto missing = nlohmann::json::array();
for (const auto& feature : *features) {
if (!feature.is_string()) return rejected;
if (!feature.is_string())
return rejected;
const auto& name = feature.get_ref<const std::string&>();
if (name.empty() || name.size() > 128 ||
name.find_first_of("\r\n\t") != std::string::npos ||
@@ -132,233 +106,31 @@ Response Answer(JNIEnv* env, jstring message, std::size_t method,
} // namespace
struct RobloxPermissionsBridge::State {
struct Endpoint {
std::shared_ptr<State> state;
std::size_t method;
};
struct Registration {
jobject callback = nullptr;
jobject handler = nullptr;
jobject connection = nullptr;
jlong connection_handle = 0;
jstring method = nullptr;
bool handler_registered = false;
};
RobloxPermissionsMessageBusSymbols symbols;
jobject bus = nullptr;
jstring protocol = nullptr;
bool microphone_enabled = false;
std::array<Registration, kMethodCount> registrations;
std::mutex mutex;
std::condition_variable drained;
bool accepting = true;
std::size_t in_flight = 0;
static void Legacy(void* context, JNIEnv* env, jstring message) {
Dispatch(context, env, message, nullptr);
}
static void Async(void* context, JNIEnv* env, jstring message, jstring id) {
Dispatch(context, env, message, id);
}
static void Dispatch(void* context, JNIEnv* env, jstring message,
jstring id) {
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};
Response response =
Answer(env, message, endpoint->method, state->microphone_enabled);
// No payload, feature strings, or correlation IDs in logs.
std::fprintf(stderr,
" [permissions] %s method=%s result=%s microphone=%s "
"transport=%s\n",
kProtocol, kMethods[endpoint->method], response.outcome,
state->microphone_enabled ? "enabled" : "disabled",
id ? "async" : "legacy");
LocalString body(env, response.body.dump().c_str());
LocalString telemetry(env, "{}");
LocalString method(env, kMethods[endpoint->method]);
const Status allocated = CheckJni(env);
if (!body.value || !telemetry.value || !method.value || !allocated.ok()) {
std::fprintf(stderr, " [permissions] response allocation failed\n");
return;
}
// The APK publishes the legacy response even for the newer async API,
// then resolves the individual request by its opaque response ID.
state->symbols.publish_response(env, state->bus, state->protocol,
method.value, body.value, response.code,
telemetry.value);
const Status published = CheckJni(env);
if (id) {
state->symbols.call_response_handler(env, state->bus, id, body.value);
}
if (!CheckJni(env).ok() || !published.ok()) {
std::fprintf(stderr, " [permissions] response delivery failed\n");
}
}
};
RobloxPermissionsBridge::RobloxPermissionsBridge(
JniEnvironmentProvider environment,
RobloxPermissionsMessageBusSymbols symbols,
RobloxPermissionsMessageBusObjects objects, bool microphone_enabled)
: environment_(environment),
symbols_(symbols),
objects_(objects),
microphone_enabled_(microphone_enabled) {}
: bridge_(std::make_unique<RobloxMessageBusRequestBridge>(
environment, symbols, objects, "PermissionsProtocol",
std::vector<std::string>{"SupportsPermissions", "HasPermissions",
"PermissionsRequest",
"ShouldShowPermissionUpsell",
"ShouldShowRequestPermissionRationale"},
[microphone_enabled](JNIEnv* env, jstring message,
std::size_t method) {
auto response = Answer(env, message, method, microphone_enabled);
return RobloxMessageBusReply{response.body.dump(), response.code,
response.outcome};
})) {}
RobloxPermissionsBridge::~RobloxPermissionsBridge() { (void)Shutdown(); }
RobloxPermissionsBridge::~RobloxPermissionsBridge() = default;
Status RobloxPermissionsBridge::Initialize() {
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
if (state_) {
return Status::Error(StatusCode::kFailedPrecondition,
"PermissionsProtocol is already initialized");
}
if (!environment_.valid() || !symbols_.complete() || !objects_.complete()) {
return Unavailable("PermissionsProtocol prerequisites are incomplete");
}
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
state_ = std::make_shared<State>();
state_->symbols = symbols_;
state_->microphone_enabled = microphone_enabled_;
state_->bus = env->NewGlobalRef(objects_.message_bus);
LocalString protocol(env, kProtocol);
state_->protocol = static_cast<jstring>(env->NewGlobalRef(protocol.value));
auto fail = [this](Status failure) {
(void)ShutdownLocked();
return failure;
};
if (!state_->bus || !state_->protocol || !CheckJni(env).ok()) {
return fail(Unavailable("could not retain PermissionsProtocol roots"));
}
for (std::size_t i = 0; i < kMethodCount; ++i) {
auto& entry = state_->registrations[i];
auto endpoint =
std::make_shared<State::Endpoint>(State::Endpoint{state_, i});
LocalString method(env, kMethods[i]);
entry.method = static_cast<jstring>(env->NewGlobalRef(method.value));
jobject callback = objects_.create_raw_callback(objects_.context, endpoint,
&State::Legacy);
if (callback) {
entry.callback = env->NewGlobalRef(callback);
if (!entry.callback)
objects_.clear_raw_callback(objects_.context, callback);
env->DeleteLocalRef(callback);
}
jobject handler = objects_.create_async_handler(objects_.context, endpoint,
&State::Async);
if (handler) {
entry.handler = env->NewGlobalRef(handler);
if (!entry.handler)
objects_.clear_async_handler(objects_.context, handler);
env->DeleteLocalRef(handler);
}
if (!entry.method || !entry.callback || !entry.handler ||
!CheckJni(env).ok()) {
return fail(
Unavailable("could not create PermissionsProtocol callbacks"));
}
jobject connection =
symbols_.subscribe_request(env, state_->bus, state_->protocol,
entry.method, entry.callback, JNI_FALSE);
if (connection) {
jclass cls = env->GetObjectClass(connection);
jfieldID pointer = cls ? env->GetFieldID(cls, "a", "J") : nullptr;
if (pointer)
entry.connection_handle = env->GetLongField(connection, pointer);
if (cls) env->DeleteLocalRef(cls);
entry.connection = env->NewGlobalRef(connection);
if (!entry.connection && entry.connection_handle) {
symbols_.delete_connection(env, connection, entry.connection_handle);
entry.connection_handle = 0;
}
env->DeleteLocalRef(connection);
}
if (!entry.connection || !entry.connection_handle || !CheckJni(env).ok()) {
return fail(
Unavailable("could not subscribe PermissionsProtocol request"));
}
entry.handler_registered = true;
symbols_.set_async_handler(env, state_->bus, state_->protocol, entry.method,
entry.handler);
status = CheckJni(env);
if (!status.ok()) return fail(status);
}
std::fprintf(stderr,
" [permissions] PermissionsProtocol ready microphone=%s "
"legacy=5 async=5\n",
microphone_enabled_ ? "enabled" : "disabled");
return Status::Ok();
return bridge_->Initialize();
}
Status RobloxPermissionsBridge::Shutdown() {
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
return ShutdownLocked();
return bridge_->Shutdown();
}
Status RobloxPermissionsBridge::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.handler_registered) {
symbols_.clear_handler(env, state_->bus, state_->protocol, entry.method);
const Status cleared = CheckJni(env);
if (!cleared.ok()) status = cleared;
entry.handler_registered = false;
}
if (entry.connection && entry.connection_handle) {
symbols_.delete_connection(env, entry.connection,
entry.connection_handle);
const Status disconnected = CheckJni(env);
if (!disconnected.ok()) status = disconnected;
entry.connection_handle = 0;
}
if (entry.callback) {
objects_.clear_raw_callback(objects_.context, entry.callback);
env->DeleteGlobalRef(entry.callback);
}
if (entry.handler) {
objects_.clear_async_handler(objects_.context, entry.handler);
env->DeleteGlobalRef(entry.handler);
}
if (entry.connection) env->DeleteGlobalRef(entry.connection);
if (entry.method) env->DeleteGlobalRef(entry.method);
entry = {};
const Status cleared = CheckJni(env);
if (!cleared.ok()) status = cleared;
}
if (state_->protocol) env->DeleteGlobalRef(state_->protocol);
if (state_->bus) env->DeleteGlobalRef(state_->bus);
state_.reset();
return status;
}
} // namespace runtime
} // namespace mocktail
@@ -125,6 +125,16 @@ RobloxPlatformWebSymbols ResolveRobloxPlatformWebSymbols(
roblox_library,
"Java_com_roblox_universalapp_cookie_JNICookieProtocol_"
"updateOnSetCookieHandler");
symbols.web_view.send_app_event_on_game_loaded =
Resolve<SendRobloxAppEventOnGameLoadedFn>(
roblox_library,
"Java_com_roblox_engine_jni_NativeGLInterface_"
"nativeAppBridgeV2SendAppEventOnGameLoaded");
symbols.web_view.send_app_event_on_app_ready =
Resolve<SendRobloxAppEventOnAppReadyFn>(
roblox_library,
"Java_com_roblox_engine_jni_NativeGLInterface_"
"nativeAppBridgeV2SendAppEventOnAppReady");
symbols.browser_service.bind = Resolve<BindRobloxMemStorageFn>(
roblox_library, "Java_com_roblox_engine_jni_memstorage_MemStorage_bind");
+141
View File
@@ -0,0 +1,141 @@
#include "runtime/roblox_presence_resolver.h"
#include <curl/curl.h>
#include <cstdlib>
#include <fstream>
#include <nlohmann/json.hpp>
#include <string>
#include <string_view>
namespace mocktail {
namespace runtime {
namespace {
Status Invalid(std::string message) {
return Status::Error(StatusCode::kInvalidArgument, std::move(message));
}
size_t WriteToString(char* data, size_t size, size_t nmemb, void* userdata) {
static_cast<std::string*>(userdata)->append(data, size * nmemb);
return size * nmemb;
}
} // namespace
// Reads mocktail's own persisted session cookie from
// $XDG_DATA_HOME/mocktail/auth/roblox.cookie (default
// ~/.local/share/mocktail/auth/roblox.cookie), matching the format
// mocktail's own auth code already writes there: a single line of the
// form ".ROBLOSECURITY=<value>". Strips the prefix so callers get just
// the bare value. Returns an empty string if no session is stored yet.
std::string ReadStoredRoblosecurityCookie() {
const char* home = std::getenv("HOME");
if (home == nullptr || home[0] == '\0') {
return std::string();
}
const char* xdg_data_home = std::getenv("XDG_DATA_HOME");
const std::string base =
(xdg_data_home != nullptr && xdg_data_home[0] != '\0')
? std::string(xdg_data_home)
: std::string(home) + "/.local/share";
const std::string path = base + "/mocktail/auth/roblox.cookie";
std::ifstream file(path);
if (!file.is_open()) {
return std::string();
}
std::string line;
std::getline(file, line);
while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) {
line.pop_back();
}
constexpr std::string_view kPrefix = ".ROBLOSECURITY=";
if (line.size() > kPrefix.size() &&
line.compare(0, kPrefix.size(), kPrefix) == 0) {
return line.substr(kPrefix.size());
}
return line;
}
Status ResolveFollowUserPlace(int64_t user_id, const std::string& auth_cookie,
ResolvedFollowUserPlace* out) {
if (out == nullptr || user_id <= 0) {
return Invalid("Roblox presence lookup requires a positive userId");
}
if (auth_cookie.empty()) {
return Invalid("Roblox presence lookup requires an authenticated session");
}
CURL* curl = curl_easy_init();
if (curl == nullptr) {
return Invalid("Roblox presence lookup could not initialize a request");
}
const nlohmann::json body = {{"userIds", {user_id}}};
const std::string payload = body.dump();
std::string response;
struct curl_slist* headers = nullptr;
headers = curl_slist_append(headers, "Content-Type: application/json");
const std::string cookie_header = "Cookie: .ROBLOSECURITY=" + auth_cookie;
headers = curl_slist_append(headers, cookie_header.c_str());
curl_easy_setopt(curl, CURLOPT_URL,
"https://presence.roblox.com/v1/presence/users");
curl_easy_setopt(curl, CURLOPT_POST, 1L);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteToString);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
const CURLcode result = curl_easy_perform(curl);
long http_status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_status);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (result != CURLE_OK) {
return Invalid("Roblox presence lookup failed to connect");
}
if (http_status != 200) {
return Invalid("Roblox presence lookup was rejected (status " +
std::to_string(http_status) + ")");
}
const nlohmann::json parsed = nlohmann::json::parse(
response, /*cb=*/nullptr, /*allow_exceptions=*/false);
if (parsed.is_discarded()) {
return Invalid("Roblox presence lookup returned malformed JSON");
}
if (!parsed.contains("userPresences") ||
!parsed["userPresences"].is_array() ||
parsed["userPresences"].empty()) {
return Invalid("Roblox presence lookup returned no data for user");
}
const auto& presence = parsed["userPresences"][0];
// userPresenceType: 0 offline, 1 online, 2 in-game, 3 in-studio.
const int presence_type = presence.value("userPresenceType", 0);
if (presence_type != 2) {
return Invalid("User is not currently in a joinable Roblox experience");
}
if (!presence.contains("placeId") || presence["placeId"].is_null()) {
return Invalid("Roblox presence lookup did not include a placeId");
}
out->place_id = presence.value("placeId", int64_t{0});
if (presence.contains("gameId") && !presence["gameId"].is_null()) {
out->game_instance_id = presence.value("gameId", std::string());
}
if (out->place_id <= 0) {
return Invalid("Roblox presence lookup returned an invalid placeId");
}
return Status::Ok();
}
} // namespace runtime
} // namespace mocktail
+117 -2
View File
@@ -36,6 +36,8 @@ constexpr char kNativeGlInterfaceClass[] =
"com/roblox/engine/jni/NativeGLInterface";
constexpr char kConnectionPointerField[] = "a";
constexpr std::size_t kMaximumPendingHostWindowEvents = 256;
// Digits of the largest signed 64-bit user id.
constexpr std::size_t kMaximumRobloxUserIdDigits = 19;
constexpr std::string_view kBrowserLoginUrl = "https://www.roblox.com/login";
bool EqualsIgnoreCase(std::string_view a, std::string_view b) {
@@ -96,6 +98,21 @@ bool IsLoginChallengeUrl(std::string_view url) {
return false;
}
// A positive decimal that fits a signed 64-bit integer, without sign, spaces
// or leading zero.
bool IsRobloxUserId(std::string_view value) {
constexpr std::string_view kLargest = "9223372036854775807";
if (value.empty() || value.size() > kLargest.size() || value[0] == '0') {
return false;
}
for (const char digit : value) {
if (digit < '0' || digit > '9') {
return false;
}
}
return value.size() < kLargest.size() || value <= kLargest;
}
Status Invalid(std::string message) {
return Status::Error(StatusCode::kInvalidArgument, std::move(message));
}
@@ -1355,7 +1372,20 @@ void RobloxWebViewBridge::QueueHostWindowEvent(HostWindowEventType type,
" [webview] pending host event queue overflow; preserving "
"newest lifecycle state\n");
}
pending_host_window_events_.push_back({type, generation});
pending_host_window_events_.push_back({type, generation, {}});
}
void RobloxWebViewBridge::QueueStartConversation(std::string user_id) {
std::lock_guard<std::mutex> lock(host_window_event_mutex_);
if (pending_host_window_events_.size() == kMaximumPendingHostWindowEvents) {
pending_host_window_events_.pop_front();
std::fprintf(stderr,
" [webview] pending host event queue overflow; preserving "
"newest lifecycle state\n");
}
pending_host_window_events_.push_back(
{HostWindowEventType::kStartConversationWithUser, 0,
std::move(user_id)});
}
Status RobloxWebViewBridge::DrainHostWindowEvents() {
@@ -1418,8 +1448,24 @@ Status RobloxWebViewBridge::DrainHostWindowEvents() {
}
break;
}
case HostWindowEventType::kStartConversationWithUser: {
// A failed send is dropped, not retried: the click that asked for
// the conversation is stale by the next drain.
const Status sent = SendStartConversationEvents(env, event.user_id);
if (sent.ok()) {
std::fprintf(stderr,
" [app-shell] Chat opened with a conversation "
"request\n");
} else {
std::fprintf(stderr,
" [app-shell] conversation request failed: %s\n",
sent.message().c_str());
}
break;
}
}
if (status.ok() && event.type != HostWindowEventType::kDataModelUnfocused) {
if (status.ok() && event.type != HostWindowEventType::kDataModelUnfocused &&
event.type != HostWindowEventType::kStartConversationWithUser) {
const std::shared_ptr<HostWindowCloseTarget> target =
host_window_close_target_;
if (target != nullptr) {
@@ -1472,6 +1518,28 @@ Status RobloxWebViewBridge::HandleDataModelNotification(JNIEnv* env,
OpenSource::kDataModelNotification);
}
if (owned_type == kRobloxLaunchConversationNotification) {
std::string user_id;
status = CopyJniString(env, data, kMaximumRobloxUserIdDigits,
"conversation user id", &user_id);
if (!status.ok()) {
return status;
}
if (!IsRobloxUserId(user_id)) {
return Invalid("LAUNCH_CONVERSATION payload is not a user id");
}
if (symbols_.send_app_event_on_game_loaded == nullptr ||
symbols_.send_app_event_on_app_ready == nullptr) {
return Unavailable("Roblox app event entrypoints are unavailable");
}
std::fprintf(stderr,
" [app-shell] LAUNCH_CONVERSATION notification received\n");
// The engine is inside its notification callback. The reply is sent from
// the next host event drain, as Android posts it to its main thread.
QueueStartConversation(std::move(user_id));
return Status::Ok();
}
RobloxWebViewOpenRequest request;
if (owned_type == kRobloxViewProfileNotification) {
std::string owned_url;
@@ -1652,6 +1720,53 @@ Status RobloxWebViewBridge::BroadcastDataModelFocus(JNIEnv *env,
return CheckJniException(env, "broadcast AppInput DataModel focus event");
}
// Mirrors the Android app shell's reply to LAUNCH_CONVERSATION: switch the
// Lua app to its Chat destination, then ask Chat to start the conversation
// once that app reports ready.
Status RobloxWebViewBridge::SendStartConversationEvents(
JNIEnv* env, const std::string& user_id) {
if (env == nullptr || symbols_.send_app_event_on_game_loaded == nullptr ||
symbols_.send_app_event_on_app_ready == nullptr) {
return Unavailable("Roblox app event entrypoints are unavailable");
}
jclass native_gl_class = env->FindClass(kNativeGlInterfaceClass);
const std::array<jstring, 7> strings = {
env->NewStringUTF("Navigations"),
env->NewStringUTF(R"({"appName":"Chat"})"),
env->NewStringUTF("Destination"),
env->NewStringUTF("AppShellNotifications"),
env->NewStringUTF(user_id.c_str()),
env->NewStringUTF("StartConversationWithUserId"),
env->NewStringUTF("Chat"),
};
bool allocated = native_gl_class != nullptr;
for (jstring value : strings) {
allocated = allocated && value != nullptr;
}
Status status = allocated
? Status::Ok()
: Unavailable("could not allocate conversation events");
if (status.ok()) {
symbols_.send_app_event_on_game_loaded(env, native_gl_class, strings[0],
strings[1], strings[2]);
status = CheckJniException(env, "send Chat destination app event");
}
if (status.ok()) {
symbols_.send_app_event_on_app_ready(env, native_gl_class, strings[3],
strings[4], strings[5], strings[6]);
status = CheckJniException(env, "send start conversation app event");
}
for (jstring value : strings) {
if (value != nullptr) {
env->DeleteLocalRef(value);
}
}
if (native_gl_class != nullptr) {
env->DeleteLocalRef(native_gl_class);
}
return status;
}
Status RobloxWebViewBridge::BeginDispatch() {
std::lock_guard<std::mutex> lock(mutex_);
if ((!initializing_ && !initialized_) || !accepting_messages_) {
+24 -3
View File
@@ -12,6 +12,7 @@
#include <array>
#include <cctype>
#include <cerrno>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstring>
@@ -707,6 +708,28 @@ std::string SessionLog::Header(const Environment& environment,
return output.str();
}
namespace {
constexpr std::chrono::seconds kLoggerExitTimeout(2);
// The writer exits once every copy of the pipe write ends is closed, and a
// child process can hold one open. Past the timeout it is killed so that
// shutdown cannot hang.
void ReapLogger(pid_t child) {
const auto deadline = std::chrono::steady_clock::now() + kLoggerExitTimeout;
for (;;) {
const pid_t reaped = waitpid(child, nullptr, WNOHANG);
if (reaped == child || (reaped < 0 && errno != EINTR)) return;
if (std::chrono::steady_clock::now() >= deadline) break;
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
(void)kill(child, SIGKILL);
while (waitpid(child, nullptr, 0) < 0 && errno == EINTR) {
}
}
} // namespace
void SessionLog::Stop() {
if (!active_) return;
std::cout.flush();
@@ -718,9 +741,7 @@ void SessionLog::Stop() {
close(original_stderr_);
original_stdout_ = -1;
original_stderr_ = -1;
const pid_t child = static_cast<pid_t>(logger_process_);
while (waitpid(child, nullptr, 0) < 0 && errno == EINTR) {
}
ReapLogger(static_cast<pid_t>(logger_process_));
logger_process_ = -1;
active_ = false;
}
+232 -39
View File
@@ -1,6 +1,9 @@
#include "runtime/webview_helper_launcher.h"
#include <arpa/inet.h>
#include <curl/curl.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <fcntl.h>
#include <poll.h>
#include <pthread.h>
@@ -21,6 +24,7 @@
#include <cstring>
#include <deque>
#include <filesystem>
#include <fstream>
#include <memory>
#include <mutex>
#include <new>
@@ -38,6 +42,8 @@ struct WebViewHelperProcess::State {
std::mutex control_mutex;
int control_descriptor = -1;
std::deque<WebViewHelperEvent> pending_events;
std::string receive_buffer;
bool tcp = false;
bool ready = false;
~State() {
@@ -67,7 +73,36 @@ class SensitiveStringGuard final {
WebViewHelperProcess::WebViewHelperProcess(std::shared_ptr<State> state)
: state_(std::move(state)) {}
bool WebViewHelperProcess::running() const { return process_id() > 0; }
bool WebViewHelperProcess::running() const {
if (state_ == nullptr) {
return false;
}
if (!state_->tcp) {
return process_id() > 0;
}
std::lock_guard<std::mutex> lock(state_->control_mutex);
if (state_->control_descriptor < 0 ||
state_->child.load(std::memory_order_acquire) <= 0) {
return false;
}
pollfd probe = {state_->control_descriptor, POLLIN, 0};
if (poll(&probe, 1, 0) > 0) {
if ((probe.revents & (POLLHUP | POLLERR | POLLNVAL)) != 0) {
state_->child.store(-1, std::memory_order_release);
return false;
}
if ((probe.revents & POLLIN) != 0) {
char peek;
const ssize_t count = recv(state_->control_descriptor, &peek, 1,
MSG_PEEK | MSG_DONTWAIT);
if (count == 0) {
state_->child.store(-1, std::memory_order_release);
return false;
}
}
}
return true;
}
pid_t WebViewHelperProcess::process_id() const {
return state_ != nullptr ? state_->child.load(std::memory_order_acquire) : -1;
@@ -135,6 +170,114 @@ private:
char *value_ = nullptr;
};
bool HostIsFreeBsd() {
char buffer[512] = {};
std::ifstream file("/proc/version", std::ios::binary);
file.read(buffer, sizeof(buffer) - 1);
const std::string_view text(buffer, static_cast<std::size_t>(file.gcount()));
return text.find("FreeBSD") != std::string_view::npos ||
text.find("freebsd.org") != std::string_view::npos;
}
std::string FramePacket(std::string_view packet) {
std::string out(4, '\0');
const std::size_t size = packet.size();
out[0] = static_cast<char>((size >> 24) & 0xff);
out[1] = static_cast<char>((size >> 16) & 0xff);
out[2] = static_cast<char>((size >> 8) & 0xff);
out[3] = static_cast<char>(size & 0xff);
out.append(packet);
return out;
}
bool ExtractFrame(std::string *buffer, std::string *frame) {
if (buffer->size() < 4) {
return false;
}
const std::size_t size =
(static_cast<std::size_t>(static_cast<unsigned char>((*buffer)[0])) << 24) |
(static_cast<std::size_t>(static_cast<unsigned char>((*buffer)[1])) << 16) |
(static_cast<std::size_t>(static_cast<unsigned char>((*buffer)[2])) << 8) |
static_cast<std::size_t>(static_cast<unsigned char>((*buffer)[3]));
if (size > kMaximumWebViewEventPacketBytes) {
buffer->clear();
return false;
}
if (buffer->size() < 4 + size) {
return false;
}
frame->assign(*buffer, 4, size);
buffer->erase(0, 4 + size);
return true;
}
int ConnectToFreeBsdHost() {
const int descriptor = socket(AF_INET, SOCK_STREAM, 0);
if (descriptor < 0) {
return -1;
}
fcntl(descriptor, F_SETFD, FD_CLOEXEC);
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_port = htons(2137);
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
int enabled = 1;
setsockopt(descriptor, IPPROTO_TCP, TCP_NODELAY, &enabled, sizeof(enabled));
if (connect(descriptor, reinterpret_cast<sockaddr *>(&address),
sizeof(address)) != 0) {
close(descriptor);
return -1;
}
const int flags = fcntl(descriptor, F_GETFL);
if (flags < 0 || fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) != 0) {
close(descriptor);
return -1;
}
return descriptor;
}
ssize_t ReceivePacket(WebViewHelperProcess::State *state,
std::string *packet) {
if (!state->tcp) {
packet->assign(kMaximumWebViewEventPacketBytes, '\0');
const ssize_t count = recv(state->control_descriptor, packet->data(),
packet->size(), MSG_DONTWAIT | MSG_TRUNC);
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
return -1;
}
if (count < 0) {
return -2;
}
if (count > 0 && static_cast<std::size_t>(count) <= packet->size()) {
packet->resize(static_cast<std::size_t>(count));
}
return count;
}
while (true) {
if (ExtractFrame(&state->receive_buffer, packet)) {
return static_cast<ssize_t>(packet->size());
}
char chunk[4096];
const ssize_t count = read(state->control_descriptor, chunk, sizeof(chunk));
if (count < 0 && errno == EINTR) {
continue;
}
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
return -1;
}
if (count < 0) {
return -2;
}
if (count == 0) {
return 0;
}
state->receive_buffer.append(chunk, static_cast<std::size_t>(count));
if (state->receive_buffer.size() > kMaximumWebViewEventPacketBytes * 2) {
return -2;
}
}
}
bool IsRobloxHost(std::string_view host) {
constexpr std::string_view kRoot = "roblox.com";
std::string normalized(host);
@@ -258,13 +401,39 @@ bool SendControlCommand(
std::lock_guard<std::mutex> lock(state->control_mutex);
if (state->child.load(std::memory_order_acquire) > 0 &&
state->control_descriptor >= 0) {
ssize_t count = -1;
do {
// FreeBSD requires an explicit record boundary for SOCK_SEQPACKET.
count = send(state->control_descriptor, packet.data(), packet.size(),
MSG_DONTWAIT | MSG_NOSIGNAL | MSG_EOR);
} while (count < 0 && errno == EINTR);
sent = count == static_cast<ssize_t>(packet.size());
if (state->tcp) {
const std::string framed = FramePacket(packet);
std::size_t offset = 0;
sent = true;
while (offset < framed.size()) {
const ssize_t count =
send(state->control_descriptor, framed.data() + offset,
framed.size() - offset, MSG_NOSIGNAL);
if (count < 0 && errno == EINTR) {
continue;
}
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
pollfd writable = {state->control_descriptor, POLLOUT, 0};
if (poll(&writable, 1, 1000) <= 0) {
sent = false;
break;
}
continue;
}
if (count <= 0) {
sent = false;
break;
}
offset += static_cast<std::size_t>(count);
}
} else {
ssize_t count = -1;
do {
count = send(state->control_descriptor, packet.data(), packet.size(),
MSG_DONTWAIT | MSG_NOSIGNAL | MSG_EOR);
} while (count < 0 && errno == EINTR);
sent = count == static_cast<ssize_t>(packet.size());
}
}
}
std::fill(packet.begin(), packet.end(), '\0');
@@ -740,31 +909,41 @@ bool WebViewHelperProcess::WaitUntilReady(
if (remaining.count() <= 0) {
break;
}
std::string packet;
bool closed = false;
while (!state_->ready) {
const ssize_t count = ReceivePacket(state_.get(), &packet);
if (count == 0 || count == -2) {
closed = true;
break;
}
if (count < 0) {
break;
}
WebViewHelperEvent event;
if (!DecodeWebViewHelperEventPacket(packet, &event)) {
continue;
}
if (event.type == WebViewHelperEventType::kReady) {
state_->ready = true;
} else {
state_->pending_events.push_back(std::move(event));
}
}
if (closed || state_->ready) {
break;
}
pollfd descriptor = {state_->control_descriptor, POLLIN, 0};
int poll_status = -1;
do {
poll_status = poll(&descriptor, 1, static_cast<int>(remaining.count()));
} while (poll_status < 0 && errno == EINTR);
if (poll_status <= 0 || (descriptor.revents & POLLIN) == 0) {
if (poll_status < 0) {
break;
}
std::string packet(kMaximumWebViewEventPacketBytes, '\0');
const ssize_t count = recv(state_->control_descriptor, packet.data(),
packet.size(), MSG_DONTWAIT | MSG_TRUNC);
if (count <= 0 || static_cast<std::size_t>(count) > packet.size()) {
if (poll_status > 0 && (descriptor.revents & (POLLERR | POLLNVAL)) != 0) {
break;
}
WebViewHelperEvent event;
if (!DecodeWebViewHelperEventPacket(
std::string_view(packet.data(), static_cast<std::size_t>(count)),
&event)) {
continue;
}
if (event.type == WebViewHelperEventType::kReady) {
state_->ready = true;
} else {
state_->pending_events.push_back(std::move(event));
}
}
return state_->ready;
}
@@ -782,26 +961,17 @@ bool WebViewHelperProcess::DrainEvents(
events->push_back(std::move(state_->pending_events.front()));
state_->pending_events.pop_front();
}
std::string packet(kMaximumWebViewEventPacketBytes, '\0');
std::string packet;
while (true) {
ssize_t count = recv(state_->control_descriptor, packet.data(),
packet.size(), MSG_DONTWAIT | MSG_TRUNC);
if (count < 0 && errno == EINTR) {
continue;
}
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
const ssize_t count = ReceivePacket(state_.get(), &packet);
if (count == -1 || count == 0) {
return true;
}
if (count <= 0) {
return count == 0;
}
if (static_cast<std::size_t>(count) > packet.size()) {
continue;
if (count < 0) {
return false;
}
WebViewHelperEvent event;
if (DecodeWebViewHelperEventPacket(
std::string_view(packet.data(), static_cast<std::size_t>(count)),
&event)) {
if (DecodeWebViewHelperEventPacket(packet, &event)) {
if (event.type == WebViewHelperEventType::kReady) {
state_->ready = true;
} else {
@@ -819,6 +989,9 @@ bool WebViewHelperProcess::RequestClose() const {
if (SendControlCommand(state_, WebViewHelperControlOperation::kClose, {})) {
return true;
}
if (state_->tcp) {
return false;
}
const pid_t current_child = process_id();
if (current_child <= 0) {
return true;
@@ -835,6 +1008,26 @@ WebViewHelperLaunchResult LaunchWebViewHelper(
if (!NormalizeWebViewUrl(url, &normalized_url, &result.error)) {
return result;
}
if (HostIsFreeBsd()) {
const int descriptor = ConnectToFreeBsdHost();
if (descriptor < 0) {
result.error = "cannot connect to the FreeBSD webview host";
return result;
}
auto tcp_state = std::make_shared<WebViewHelperProcess::State>();
tcp_state->tcp = true;
tcp_state->control_descriptor = descriptor;
tcp_state->child.store(1, std::memory_order_release);
result.process = std::shared_ptr<WebViewHelperProcess>(
new WebViewHelperProcess(std::move(tcp_state)));
result.spawned = true;
result.process_id = 1;
if (!result.process->LoadUrl(normalized_url)) {
result.spawned = false;
result.error = "cannot send the initial URL to the FreeBSD webview host";
}
return result;
}
std::error_code filesystem_error;
const std::filesystem::file_status helper_status =
std::filesystem::symlink_status(helper, filesystem_error);
+31 -1
View File
@@ -53,6 +53,14 @@ bool ReadStorage(const std::filesystem::path& path, nlohmann::json* storage,
*storage = nlohmann::json::object();
return !filesystem_error;
}
// The storage directory may have been writable by other users, so a file
// another user owns, a symlink, or a second hard link is not trusted.
struct stat metadata = {};
if (lstat(path.c_str(), &metadata) != 0 || !S_ISREG(metadata.st_mode) ||
metadata.st_uid != geteuid() || metadata.st_nlink != 1) {
*error = "appStorage is not a private regular file";
return false;
}
const auto size = std::filesystem::file_size(path, filesystem_error);
if (filesystem_error || size > kMaximumStorageBytes) {
*error = "appStorage is unavailable or exceeds the size limit";
@@ -193,6 +201,23 @@ bool IsPrivateDirectory(const struct stat& metadata) {
(metadata.st_mode & (S_IWGRP | S_IWOTH)) == 0;
}
// Removes group and world write access from a directory this user owns.
// Roblox creates its storage directories with the process umask, which is 002
// on many desktops. A directory owned by another user is left untouched.
void TightenOwnedDirectory(const std::filesystem::path& directory) {
const int descriptor = open(directory.c_str(),
O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW);
if (descriptor < 0) return;
struct stat metadata = {};
if (fstat(descriptor, &metadata) == 0 && S_ISDIR(metadata.st_mode) &&
metadata.st_uid == geteuid() &&
(metadata.st_mode & (S_IWGRP | S_IWOTH)) != 0) {
(void)fchmod(descriptor,
(metadata.st_mode & 07777) & ~(S_IWGRP | S_IWOTH));
}
close(descriptor);
}
bool FindExistingDirectoryAncestor(const std::filesystem::path& directory,
std::filesystem::path* ancestor) {
if (directory.empty() || ancestor == nullptr) return false;
@@ -491,6 +516,7 @@ BrowserTrackerResult BrowserTrackerService::EnsureInitialized(
return result;
}
const bool lock_parent_existed = existing_lock_ancestor == lock_parent;
if (lock_parent_existed) TightenOwnedDirectory(lock_parent);
const bool lock_parent_created =
CreatePrivateDirectories(lock_parent, existing_lock_ancestor);
struct stat lock_parent_status = {};
@@ -537,10 +563,14 @@ BrowserTrackerResult BrowserTrackerService::EnsureInitialized(
const auto existing = storage.find("BrowserTrackerId");
std::string stored_id;
// Roblox stores "0" when it starts without a tracker identity.
const bool stored_unset =
existing != storage.end() && existing->is_string() &&
existing->get_ref<const std::string&>() == "0";
if (existing != storage.end() && existing->is_string() &&
IsValidId(existing->get_ref<const std::string&>())) {
stored_id = existing->get<std::string>();
} else if (existing != storage.end()) {
} else if (existing != storage.end() && !stored_unset) {
result.error = "appStorage BrowserTrackerId is invalid";
close(lock);
return result;
+14 -3
View File
@@ -73,13 +73,24 @@ CompatibilityCatalogResult LoadCompatibilityCatalog(
const nlohmann::json document =
nlohmann::json::parse(contents, nullptr, false, true);
if (document.is_discarded() || !document.is_object() ||
document.value("schema_version", 0) != 1 ||
!document.contains("profiles") || !document["profiles"].is_array()) {
!document.contains("schema_version") ||
!document["schema_version"].is_number_integer() ||
document["schema_version"] != 1 || !document.contains("profiles") ||
!document["profiles"].is_array()) {
result.error = "compatibility manifest has an unsupported schema";
return result;
}
for (const nlohmann::json& profile : document["profiles"]) {
if (!profile.is_object() || profile.value("status", "") != "supported" ||
if (!profile.is_object()) continue;
if ((profile.contains("status") && !profile["status"].is_string()) ||
(profile.contains("default_allowed") &&
!profile["default_allowed"].is_boolean()) ||
(profile.contains("allow_legacy_binary_patches") &&
!profile["allow_legacy_binary_patches"].is_boolean())) {
result.error = "compatibility profile flags have invalid types";
return result;
}
if (profile.value("status", "") != "supported" ||
!profile.value("default_allowed", false) ||
profile.value("allow_legacy_binary_patches", true)) {
continue;
+440 -66
View File
@@ -887,6 +887,10 @@ struct SignatureSpec {
bool registry_size_may_change = false;
std::size_t minimum_anchor_bytes = 6;
std::array<std::size_t, 2> registry_size_indices = {8, 12};
// Instructions whose register operands the compiler may have chosen
// differently. Each reference register must stand for one candidate
// register across all of them, and the reverse.
std::vector<std::size_t> renamed_register_indices = {};
};
struct SignatureMatch {
@@ -895,10 +899,46 @@ struct SignatureMatch {
std::vector<Instruction> candidate;
};
// The 64-bit register that `name` is all or part of, such as rax for eax.
std::string RegisterFamily(std::string_view name) {
if (name.size() >= 2 && name[0] == 'r' && name[1] >= '0' && name[1] <= '9') {
while (!name.empty() && (name.back() < '0' || name.back() > '9')) {
name.remove_suffix(1);
}
return std::string(name);
}
for (const std::string_view family :
{"ax", "bx", "cx", "dx", "si", "di", "bp", "sp"}) {
if (name == family || (name.size() == 3 && name.substr(1) == family &&
(name[0] == 'e' || name[0] == 'r'))) {
return "r" + std::string(family);
}
}
return std::string(name);
}
// Reference register families and the candidate families that stand for them.
struct RegisterRenames {
std::map<std::string, std::string> forward;
std::map<std::string, std::string> backward;
bool Accept(std::string_view reference, std::string_view candidate) {
const std::string left = RegisterFamily(reference);
const std::string right = RegisterFamily(candidate);
const auto forward_found = forward.emplace(left, right).first;
const auto backward_found = backward.emplace(right, left).first;
return forward_found->second == right && backward_found->second == left;
}
};
bool SameSemanticOperand(const Operand& left, const Operand& right,
bool control_flow, bool wildcard_size) {
bool control_flow, bool wildcard_size,
RegisterRenames* renames = nullptr) {
if (left.type != right.type || left.size != right.size) return false;
if (left.type == Operand::Type::kRegister) {
if (renames != nullptr) {
return renames->Accept(left.register_name, right.register_name);
}
return left.register_name == right.register_name;
}
if (left.type == Operand::Type::kImmediate) {
@@ -918,6 +958,7 @@ bool ValidateSemantics(const std::vector<Instruction>& reference,
*error = "normalized signature instruction count changed";
return false;
}
RegisterRenames renames;
for (std::size_t index = 0; index < reference.size(); ++index) {
const Instruction& left = reference[index];
const Instruction& right = candidate[index];
@@ -925,6 +966,10 @@ bool ValidateSemantics(const std::vector<Instruction>& reference,
spec.registry_size_may_change &&
(index == spec.registry_size_indices[0] ||
index == spec.registry_size_indices[1]);
const bool renamed =
std::find(spec.renamed_register_indices.begin(),
spec.renamed_register_indices.end(),
index) != spec.renamed_register_indices.end();
if (left.mnemonic != right.mnemonic ||
left.operands.size() != right.operands.size()) {
*error = "normalized instruction semantics changed at index " +
@@ -933,7 +978,8 @@ bool ValidateSemantics(const std::vector<Instruction>& reference,
}
for (std::size_t operand = 0; operand < left.operands.size(); ++operand) {
if (!SameSemanticOperand(left.operands[operand], right.operands[operand],
left.control_flow, wildcard)) {
left.control_flow, wildcard,
renamed ? &renames : nullptr)) {
*error = "normalized instruction operand changed at index " +
std::to_string(index);
return false;
@@ -1569,6 +1615,74 @@ std::optional<std::uint64_t> FindRegistryFunctionEntry(
return entries.front();
}
// Finds the registry initializer body when a rebuild holds its temporaries in
// other registers, which changes the bytes the exact search needs.
//
// The body allocates the registry, clears it, and starts filling it:
//
// 0 mov edi, SIZE 6 xor esi, esi
// 1 call allocate 7 call memset
// 2 mov rbx, rax 8 mov ONE, 0x3f800000
// 3 xor ZERO, ZERO 9 mov dword ptr [rbx + 0x20], ONE
// 4 mov edx, SIZE 10 lea FIELD, [rbx + 0x28]
// 5 mov rdi, rax
//
// ZERO, ONE and FIELD are the compiler's choice. Instructions 4 to 7 use
// registers the calling convention fixes, so they are searched for by bytes
// and every hit is then checked against all eleven instructions.
std::optional<SignatureMatch> FindRenamedRegistryBodyMatch(
const ElfImage& reference, const ElfImage& candidate,
const Disassembler& disassembler,
const std::vector<Instruction>& reference_body,
std::size_t minimum_anchor_bytes, std::string* error) {
constexpr std::size_t kClearIndex = 4;
constexpr std::size_t kClearInstructionCount = 4;
const SignatureSpec clear_spec{
"registry-initializer-clear",
reference_body[kClearIndex].address,
kClearInstructionCount,
true,
minimum_anchor_bytes,
{0, 0},
};
const auto clear_matches = FindSignatureMatches(
reference, candidate, disassembler, clear_spec, true, error);
if (!clear_matches.has_value()) return std::nullopt;
SignatureSpec body_spec{
"registry-initializer-body",
reference_body.front().address,
reference_body.size(),
true,
minimum_anchor_bytes,
{0, 4},
};
body_spec.renamed_register_indices = {3, 8, 9, 10};
// Instructions 0 to 3 keep their lengths as long as ZERO stays one of r8
// to r15, so the body starts this far before the clear sequence.
const std::uint64_t lead =
reference_body[kClearIndex].address - reference_body.front().address;
std::vector<SignatureMatch> found;
for (const SignatureMatch& clear : *clear_matches) {
if (clear.rva < lead) continue;
std::string ignored;
const auto body = disassembler.Decode(candidate, clear.rva - lead,
reference_body.size(), 4096,
&ignored);
if (!body.has_value() || (*body)[kClearIndex].address != clear.rva ||
!ValidateSemantics(reference_body, *body, body_spec, &ignored)) {
continue;
}
found.push_back(SignatureMatch{clear.rva - lead, reference_body, *body});
}
if (found.size() != 1U) {
*error = "signature registry-initializer-body matched " +
std::to_string(found.size()) +
" candidate locations with renamed registers";
return std::nullopt;
}
return std::move(found.front());
}
std::optional<SignatureMatch> FindRegistrySignatureMatch(
const ElfImage& reference, const ElfImage& candidate,
const Disassembler& disassembler, const SignatureSpec& spec,
@@ -1590,8 +1704,18 @@ std::optional<SignatureMatch> FindRegistrySignatureMatch(
spec.minimum_anchor_bytes,
{0, 4},
};
const auto body_match =
auto body_match =
FindSignatureMatch(reference, candidate, disassembler, body_spec, error);
if (!body_match.has_value()) {
error->clear();
body_match = FindRenamedRegistryBodyMatch(
reference, candidate, disassembler,
std::vector<Instruction>(
std::next(reference_full->begin(), kBodyInstructionIndex),
std::next(reference_full->begin(),
kBodyInstructionIndex + kBodyInstructionCount)),
spec.minimum_anchor_bytes, error);
}
if (!body_match.has_value()) return std::nullopt;
const std::vector<Instruction> reference_prologue(
reference_full->begin(),
@@ -1633,39 +1757,48 @@ std::optional<std::uint64_t> DirectJumpTarget(
return static_cast<std::uint64_t>(instruction.operands[0].value);
}
// Matches a constructor that is one jump to its body. The body is found by
// signature, and the candidate constructor is the one of `candidate_entries`
// that jumps to it.
std::optional<SignatureMatch> FindConstructorBoundaryMatch(
const ElfImage& reference, const ElfImage& candidate,
const Disassembler& disassembler, const SignatureSpec& spec,
std::uint64_t reference_entry, std::uint64_t candidate_entry,
std::string* error) {
if (reference_entry != spec.reference_rva) {
*error = "reference constructor boundary moved outside init-array index 4";
return std::nullopt;
}
const std::vector<std::uint64_t>& candidate_entries, std::string* error) {
const auto reference_wrapper =
disassembler.Decode(reference, reference_entry, 1, 16, error);
disassembler.Decode(reference, spec.reference_rva, 1, 16, error);
if (!reference_wrapper.has_value()) return std::nullopt;
const auto reference_target = DirectJumpTarget(
reference_wrapper->front(), "reference constructor boundary", error);
if (!reference_target.has_value()) return std::nullopt;
const SignatureSpec target_spec{"constructor-4-target", *reference_target,
const SignatureSpec target_spec{spec.name + "-target", *reference_target,
spec.instruction_count};
const auto target_match = FindSignatureMatch(
reference, candidate, disassembler, target_spec, error);
if (!target_match.has_value()) return std::nullopt;
const auto candidate_wrapper =
disassembler.Decode(candidate, candidate_entry, 1, 16, error);
if (!candidate_wrapper.has_value()) return std::nullopt;
const auto candidate_target = DirectJumpTarget(
candidate_wrapper->front(), "candidate constructor boundary", error);
if (!candidate_target.has_value() || *candidate_target != target_match->rva) {
if (error->empty()) {
*error = "constructor index 4 no longer targets its verified boundary";
std::optional<SignatureMatch> found;
for (const std::uint64_t candidate_entry : candidate_entries) {
std::string ignored;
const auto candidate_wrapper =
disassembler.Decode(candidate, candidate_entry, 1, 16, &ignored);
if (!candidate_wrapper.has_value()) continue;
const auto candidate_target = DirectJumpTarget(
candidate_wrapper->front(), "candidate constructor boundary",
&ignored);
if (!candidate_target.has_value() ||
*candidate_target != target_match->rva) {
continue;
}
return std::nullopt;
if (found.has_value()) {
found.reset();
break;
}
found = SignatureMatch{candidate_entry, *reference_wrapper,
*candidate_wrapper};
}
return SignatureMatch{candidate_entry, *reference_wrapper,
*candidate_wrapper};
if (!found.has_value()) {
*error = spec.name + " no longer targets its verified boundary";
}
return found;
}
std::vector<std::uint64_t> RipTargets(const Instruction& instruction) {
@@ -1678,6 +1811,187 @@ std::vector<std::uint64_t> RipTargets(const Instruction& instruction) {
return result;
}
bool IsRegisterOperand(const Operand& operand, std::string_view name) {
return operand.type == Operand::Type::kRegister &&
operand.register_name == name;
}
// The instructions that publish the JNI singleton, which open its
// initializer at `entry`, after a stack frame if it has one:
//
// lea rax, [rip + OBJECT]
// xchg qword ptr [rip + SLOT], rax
// mov qword ptr [rip + OBJECT], rax
//
// OBJECT is the eight bytes after SLOT.
std::optional<std::vector<Instruction>> JniSingletonPublication(
const ElfImage& image, const Disassembler& disassembler,
std::uint64_t entry, std::string* error) {
for (const std::size_t frame : {std::size_t{0}, std::size_t{2}}) {
std::string ignored;
const auto decoded =
disassembler.Decode(image, entry, frame + 3, 64, &ignored);
if (!decoded.has_value()) continue;
if (frame != 0) {
const Instruction& push = (*decoded)[0];
const Instruction& link = (*decoded)[1];
if (push.mnemonic != "push" || push.operands.size() != 1 ||
!IsRegisterOperand(push.operands[0], "rbp") ||
link.mnemonic != "mov" || link.operands.size() != 2 ||
!IsRegisterOperand(link.operands[0], "rbp") ||
!IsRegisterOperand(link.operands[1], "rsp")) {
continue;
}
}
const Instruction& load = (*decoded)[frame];
const Instruction& exchange = (*decoded)[frame + 1];
const Instruction& store = (*decoded)[frame + 2];
const auto object = RipTargets(load);
const auto slot = RipTargets(exchange);
const auto stored = RipTargets(store);
if (load.mnemonic != "lea" || load.operands.size() != 2 ||
!IsRegisterOperand(load.operands[0], "rax") ||
exchange.mnemonic != "xchg" || exchange.operands.size() != 2 ||
exchange.operands[0].size != 8 ||
!IsRegisterOperand(exchange.operands[1], "rax") ||
store.mnemonic != "mov" || store.operands.size() != 2 ||
store.operands[0].size != 8 ||
!IsRegisterOperand(store.operands[1], "rax") || object.size() != 1 ||
slot.size() != 1 || stored.size() != 1 || object[0] != stored[0] ||
object[0] != slot[0] + 8) {
continue;
}
return std::vector<Instruction>(std::next(decoded->begin(), frame),
decoded->end());
}
*error = "JNI singleton initializer at " + FormatRva(entry) +
" does not publish its singleton";
return std::nullopt;
}
// Finds the JNI singleton initializer among `candidate_entries`. The
// instructions after the publication follow the singleton's layout, so when
// a release changes that layout only the publication is matched. The
// initializer itself runs from the init-array either way.
std::optional<SignatureMatch> FindJniSingletonMatch(
const ElfImage& reference, const ElfImage& candidate,
const Disassembler& disassembler, const SignatureSpec& spec,
const std::vector<std::uint64_t>& candidate_entries, std::string* error) {
auto whole =
FindSignatureMatch(reference, candidate, disassembler, spec, error);
if (whole.has_value()) return whole;
error->clear();
const auto reference_publication = JniSingletonPublication(
reference, disassembler, spec.reference_rva, error);
if (!reference_publication.has_value()) return std::nullopt;
const SignatureSpec publication_spec{
"jni-singleton-publication", reference_publication->front().address,
reference_publication->size(), false, 3};
const auto found = FindSignatureMatch(reference, candidate, disassembler,
publication_spec, error);
if (!found.has_value()) return std::nullopt;
for (const std::uint64_t entry : candidate_entries) {
std::string ignored;
const auto publication =
JniSingletonPublication(candidate, disassembler, entry, &ignored);
if (publication.has_value() &&
publication->front().address == found->rva) {
return SignatureMatch{entry, *reference_publication, *publication};
}
}
*error = "JNI singleton initializer is not a leading constructor";
return std::nullopt;
}
// The native replay starts at this init-array index on x86_64.
constexpr std::size_t kLeadingConstructorBegin = 2;
// The process-load constructor, the JNI singleton initializer and two wiring
// entries. A release may put them in any order.
constexpr std::size_t kLeadingConstructorCount = 4;
struct LeadingConstructors {
// For each reference leading constructor, the candidate init-array index
// of the same constructor. Both count from kLeadingConstructorBegin.
std::array<std::size_t, kLeadingConstructorCount> candidate_offset{};
SignatureMatch jni_singleton;
};
// Finds each reference leading constructor among the candidate's leading
// constructors by its code.
std::optional<LeadingConstructors> MatchLeadingConstructors(
const ElfImage& reference, const ElfImage& candidate,
const Disassembler& disassembler, const Json& constructor_anchors,
const SignatureSpec& jni_spec,
const std::vector<std::uint64_t>& reference_init,
const std::vector<std::uint64_t>& candidate_init, std::string* error) {
constexpr std::size_t kConstructorInstructionCount = 24;
constexpr char kMoved[] =
"constructor moved outside verified init-array boundary";
const std::size_t end = kLeadingConstructorBegin + kLeadingConstructorCount;
if (reference_init.size() < end || candidate_init.size() < end) {
*error = "init-array shape is not derivable";
return std::nullopt;
}
const std::vector<std::uint64_t> candidate_entries(
std::next(candidate_init.begin(), kLeadingConstructorBegin),
std::next(candidate_init.begin(), end));
LeadingConstructors result;
std::array<bool, kLeadingConstructorCount> taken{};
bool found_jni = false;
for (std::size_t offset = 0; offset < kLeadingConstructorCount; ++offset) {
const std::size_t index = kLeadingConstructorBegin + offset;
const std::uint64_t reference_entry = reference_init[index];
const std::string name = "constructor-" + std::to_string(index);
const auto anchor = ParseRva(
constructor_anchors.value(std::to_string(index), Json()), name, error);
if (!anchor.has_value()) return std::nullopt;
if (*anchor != reference_entry) {
*error = kMoved;
return std::nullopt;
}
std::optional<SignatureMatch> match;
if (reference_entry == jni_spec.reference_rva) {
match = FindJniSingletonMatch(reference, candidate, disassembler,
jni_spec, candidate_entries, error);
if (!match.has_value()) return std::nullopt;
result.jni_singleton = *match;
found_jni = true;
} else {
const auto first =
disassembler.Decode(reference, reference_entry, 1, 16, error);
if (!first.has_value()) return std::nullopt;
const SignatureSpec spec{name, reference_entry,
kConstructorInstructionCount};
match = first->front().mnemonic == "jmp"
? FindConstructorBoundaryMatch(reference, candidate,
disassembler, spec,
candidate_entries, error)
: FindSignatureMatch(reference, candidate, disassembler,
spec, error);
if (!match.has_value()) return std::nullopt;
}
const auto found = std::find(candidate_entries.begin(),
candidate_entries.end(), match->rva);
if (found == candidate_entries.end()) {
*error = kMoved;
return std::nullopt;
}
const std::size_t candidate_offset =
static_cast<std::size_t>(found - candidate_entries.begin());
if (taken[candidate_offset]) {
*error = kMoved;
return std::nullopt;
}
taken[candidate_offset] = true;
result.candidate_offset[offset] = candidate_offset;
}
if (!found_jni) {
*error = "JNI singleton initializer is not a leading constructor";
return std::nullopt;
}
return result;
}
std::optional<std::uint64_t> MappedRipTarget(const SignatureMatch& match,
std::uint64_t reference_target,
std::string_view description,
@@ -1755,16 +2069,23 @@ std::optional<std::size_t> PositiveSize(const Json& value,
return static_cast<std::size_t>(number);
}
std::optional<Json> AdjustRanges(const Json& ranges,
std::size_t reference_count,
std::size_t candidate_count,
std::string* error) {
// Rewrites the reference constructor ranges for the candidate init-array.
// Each leading constructor keeps its place in or out of the replay under its
// candidate index, and every later constructor runs.
std::optional<Json> RemapConstructorRanges(
const Json& ranges, const LeadingConstructors& leading,
std::size_t reference_count, std::size_t candidate_count,
std::string* error) {
if (!ranges.is_array() || ranges.empty()) {
*error = "constructor ranges are missing";
return std::nullopt;
}
Json result = Json::array();
const std::size_t leading_end =
kLeadingConstructorBegin + kLeadingConstructorCount;
std::array<bool, kLeadingConstructorCount> runs{};
std::size_t previous_end = 0;
std::size_t tail_begin = SIZE_MAX;
std::size_t tail_end = 0;
for (const Json& range : ranges) {
if (!range.is_object() || !range.contains("begin") ||
!range.contains("end_exclusive") ||
@@ -1774,15 +2095,39 @@ std::optional<Json> AdjustRanges(const Json& ranges,
return std::nullopt;
}
const std::size_t begin = range["begin"].get<std::size_t>();
std::size_t end = range["end_exclusive"].get<std::size_t>();
if (end == reference_count) end = candidate_count;
if (begin >= end || begin < previous_end || end > candidate_count) {
const std::size_t end = range["end_exclusive"].get<std::size_t>();
if (begin >= end || begin < previous_end || end > reference_count ||
begin < kLeadingConstructorBegin) {
*error = "constructor range cannot fit candidate init-array";
return std::nullopt;
}
result.push_back({{"begin", begin}, {"end_exclusive", end}});
for (std::size_t index = begin; index < std::min(end, leading_end);
++index) {
runs[leading.candidate_offset[index - kLeadingConstructorBegin]] = true;
}
previous_end = end;
tail_begin = begin;
tail_end = end;
}
if (tail_begin > leading_end || tail_end != reference_count ||
reference_count <= leading_end || candidate_count <= leading_end) {
*error = "constructor range cannot fit candidate init-array";
return std::nullopt;
}
Json result = Json::array();
std::size_t run_begin = SIZE_MAX;
for (std::size_t offset = 0; offset < kLeadingConstructorCount; ++offset) {
const std::size_t index = kLeadingConstructorBegin + offset;
if (runs[offset]) {
if (run_begin == SIZE_MAX) run_begin = index;
} else if (run_begin != SIZE_MAX) {
result.push_back({{"begin", run_begin}, {"end_exclusive", index}});
run_begin = SIZE_MAX;
}
}
result.push_back(
{{"begin", run_begin == SIZE_MAX ? leading_end : run_begin},
{"end_exclusive", candidate_count}});
return result;
}
@@ -1826,7 +2171,6 @@ std::optional<std::vector<SignatureSpec>> SignatureSpecs(const Json& sidecar,
};
const Json& seeds = profile["data_seeds"];
const Json& bootstrap = profile["native_pre_jni_bootstrap"];
const Json& constructors = anchors["constructor_rvas"];
std::vector<SignatureSpec> result;
const auto add = [&](std::string name, const Json& encoded, std::size_t count,
bool registry = false, std::size_t minimum = 6) -> bool {
@@ -1859,10 +2203,7 @@ std::optional<std::vector<SignatureSpec>> SignatureSpecs(const Json& sidecar,
!add("empty-string-initializer", anchors["empty_string_initializer_rva"],
23) ||
!add("jni-singleton-initializer",
anchors["jni_singleton_initializer_rva"], 11) ||
!add("constructor-2", constructors["2"], 24) ||
!add("constructor-3", constructors["3"], 24) ||
!add("constructor-4", constructors["4"], 24, false, 3)) {
anchors["jni_singleton_initializer_rva"], 11)) {
return std::nullopt;
}
return result;
@@ -1966,7 +2307,17 @@ std::optional<std::pair<Json, Json>> DeriveDocuments(const ElfImage& reference,
}
std::map<std::string, SignatureMatch> matches;
std::vector<SignatureMatch> allocate_matches;
std::optional<LeadingConstructors> leading;
for (const SignatureSpec& spec : *specs) {
if (spec.name == "jni-singleton-initializer") {
leading = MatchLeadingConstructors(
reference, candidate, disassembler,
sidecar["derivation_anchors"]["constructor_rvas"], spec,
*reference_init, *candidate_init, error);
if (!leading.has_value()) return std::nullopt;
matches.emplace(spec.name, leading->jni_singleton);
continue;
}
if (spec.name == "allocate") {
auto candidates = FindSignatureMatches(reference, candidate,
disassembler, spec, true, error);
@@ -1979,11 +2330,6 @@ std::optional<std::pair<Json, Json>> DeriveDocuments(const ElfImage& reference,
return FindRegistrySignatureMatch(reference, candidate, disassembler,
spec, error);
}
if (spec.name == "constructor-4") {
return FindConstructorBoundaryMatch(
reference, candidate, disassembler, spec, (*reference_init)[4],
(*candidate_init)[4], error);
}
return FindSignatureMatch(reference, candidate, disassembler, spec,
error);
}();
@@ -2028,21 +2374,7 @@ std::optional<std::pair<Json, Json>> DeriveDocuments(const ElfImage& reference,
return std::nullopt;
}
matches.emplace("allocate", std::move(*selected_allocate));
for (const std::size_t index : {2U, 3U, 4U}) {
const SignatureMatch& match =
matches["constructor-" + std::to_string(index)];
if ((*reference_init)[index] != match.reference[0].address ||
(*candidate_init)[index] != match.rva) {
*error = "constructor moved outside verified init-array boundary";
return std::nullopt;
}
}
const SignatureMatch& singleton = matches["jni-singleton-initializer"];
if ((*reference_init)[5] != singleton.reference[0].address ||
(*candidate_init)[5] != singleton.rva) {
*error = "JNI singleton initializer is not constructor index 5";
return std::nullopt;
}
Json candidate_bridges = Json::array();
std::map<std::string, std::uint64_t> bridge_rvas;
@@ -2112,21 +2444,29 @@ std::optional<std::pair<Json, Json>> DeriveDocuments(const ElfImage& reference,
return std::nullopt;
}
const auto constructor_ranges =
AdjustRanges(reference_profile["constructor_run_ranges"],
*reference_count, candidate_init->size(), error);
const auto native_ranges =
AdjustRanges(reference_profile["native_mimalloc_constructor_run_ranges"],
*reference_count, candidate_init->size(), error);
const auto constructor_ranges = RemapConstructorRanges(
reference_profile["constructor_run_ranges"], *leading, *reference_count,
candidate_init->size(), error);
const auto native_ranges = RemapConstructorRanges(
reference_profile["native_mimalloc_constructor_run_ranges"], *leading,
*reference_count, candidate_init->size(), error);
if (!constructor_ranges.has_value() || !native_ranges.has_value()) {
return std::nullopt;
}
const std::size_t thread_boundary = reference_profile.value(
const std::size_t reference_thread_boundary = reference_profile.value(
"native_mimalloc_thread_initializer_after_constructor", SIZE_MAX);
if (thread_boundary != 2) {
if (reference_thread_boundary < kLeadingConstructorBegin ||
reference_thread_boundary >=
kLeadingConstructorBegin + kLeadingConstructorCount) {
*error = "native allocator TLS boundary is no longer verified";
return std::nullopt;
}
// The thread initializer follows the process-load constructor to its
// candidate index.
const std::size_t thread_boundary =
kLeadingConstructorBegin +
leading->candidate_offset[reference_thread_boundary -
kLeadingConstructorBegin];
const auto singleton_bytes =
PositiveSize(seeds["jni_singleton_bytes"], "JNI singleton size", error);
const auto arena_slots = PositiveSize(seeds["arena_table_slot_count"],
@@ -2223,6 +2563,35 @@ std::optional<std::pair<Json, Json>> DeriveDocuments(const ElfImage& reference,
return std::pair<Json, Json>{std::move(derived), std::move(compatibility)};
}
// Derives the optional FMOD output-device bridge from `library`, another build
// the manifests describe. Any failure leaves the manifest without the bridge.
void AddFallbackFmodBridge(const std::filesystem::path& library,
const std::vector<std::filesystem::path>& manifests,
const ElfImage& candidate, Json* compatibility) {
std::string error;
ElfImage fallback;
if (!fallback.Open(library, &error) ||
fallback.build_id() == candidate.build_id() ||
!fallback.ValidateToolchain(&error)) {
return;
}
const auto anchors =
LoadRuntimeCompatibilityAnchors(manifests, fallback.build_id(), &error);
if (!anchors.has_value() || !anchors->fmod.has_value()) return;
Disassembler disassembler;
if (!disassembler.Open(&error)) return;
RuntimeCompatibilityAnchors source;
source.fmod = anchors->fmod;
const auto derived = DeriveRuntimeCompatibility(fallback, candidate,
disassembler, source, &error);
if (!derived.has_value() ||
!derived->contains("fmod_output_device_bridge")) {
return;
}
(*compatibility)["profiles"][0]["fmod_output_device_bridge"] =
(*derived)["fmod_output_device_bridge"];
}
} // namespace
HostAbiSidecarIdentity ReadHostAbiSidecarIdentity(
@@ -2280,10 +2649,15 @@ HostAbiDerivationResult DeriveHostAbiProfile(
options.reference_compatibility_manifests, reference.build_id(),
&result.error);
if (!runtime_source.has_value()) return result;
const auto documents = DeriveDocuments(reference, candidate, *sidecar,
*metadata, *runtime_source,
&result.error);
auto documents = DeriveDocuments(reference, candidate, *sidecar, *metadata,
*runtime_source, &result.error);
if (!documents.has_value()) return result;
if (!runtime_source->fmod.has_value() &&
!options.runtime_fallback_library.empty()) {
AddFallbackFmodBridge(options.runtime_fallback_library,
options.reference_compatibility_manifests, candidate,
&documents->second);
}
result.profile = options.output_directory / "host_abi_profile.json";
result.compatibility_manifest =
options.output_directory / "compatibility.json";
+3 -1
View File
@@ -145,8 +145,10 @@ mocktail::update::UpdatePaths ResolvePaths() {
runtime = public_runtime;
}
}
paths.runtime_binary =
paths.runtime_launcher =
Environment("MOCKTAIL_UPDATE_CANARY_BIN").value_or(runtime.string());
paths.runtime_binary = Environment("MOCKTAIL_UPDATE_RUNTIME_BINARY")
.value_or(paths.runtime_launcher.string());
return paths;
}
+27 -3
View File
@@ -12,6 +12,7 @@
#include <cctype>
#include <cerrno>
#include <filesystem>
#include <limits>
#include <mutex>
#include <nlohmann/json.hpp>
#include <string>
@@ -322,14 +323,35 @@ PayloadIntegrityResult InspectPreparedPayload(
const nlohmann::json document =
nlohmann::json::parse(contents, nullptr, false, true);
if (document.is_discarded() || !document.is_object() ||
document.value("schema_version", 0) != 1 ||
!document.contains("version_code") ||
!document.contains("schema_version") ||
!document["schema_version"].is_number_integer() ||
document["schema_version"] != 1 || !document.contains("version_code") ||
!document["version_code"].is_number_unsigned() ||
!document.contains("sha256") || !document["sha256"].is_object() ||
!document.contains("assets") || !document["assets"].is_object()) {
result.error = "payload metadata schema is invalid";
return result;
}
for (const char* field : {"package", "version_name", "elf_build_id"}) {
if (!document.contains(field) || !document[field].is_string()) {
result.error = "payload metadata identity or hashes are invalid";
return result;
}
}
for (const std::string& field :
{std::string("libroblox"), std::string("base_apk"),
std::string(compat::kGuestSplitApkHashKey)}) {
if (!document["sha256"].contains(field) ||
!document["sha256"][field].is_string()) {
result.error = "payload metadata identity or hashes are invalid";
return result;
}
}
if (!document["assets"].contains("sha256_tree") ||
!document["assets"]["sha256_tree"].is_string()) {
result.error = "payload metadata identity or hashes are invalid";
return result;
}
result.metadata.package_name = document.value("package", "");
result.metadata.version_name = document.value("version_name", "");
result.metadata.version_code = document["version_code"].get<std::uint64_t>();
@@ -339,7 +361,9 @@ PayloadIntegrityResult InspectPreparedPayload(
result.metadata.split_apk_sha256 =
document["sha256"].value(std::string(compat::kGuestSplitApkHashKey), "");
if (!document["assets"].contains("file_count") ||
!document["assets"]["file_count"].is_number_unsigned()) {
!document["assets"]["file_count"].is_number_unsigned() ||
document["assets"]["file_count"] >
std::numeric_limits<std::size_t>::max()) {
result.error = "payload metadata asset count is invalid";
return result;
}
+161 -37
View File
@@ -111,22 +111,32 @@ std::string ReadRegular(const std::filesystem::path& path, std::size_t maximum,
bool WriteAtomic(const std::filesystem::path& root,
const std::filesystem::path& destination,
std::string_view contents, std::string* error) {
std::string_view contents, std::string* error,
const ManifestWriteFault& fault = {}) {
static std::uint64_t generation = 0;
const std::filesystem::path temporary =
root / (".manifest-" + std::to_string(getpid()) + "-" +
std::to_string(++generation));
const auto fail = [&](ManifestWritePhase phase) {
if (!fault || !fault(destination, phase)) return false;
errno = EIO;
return true;
};
const int descriptor =
open(temporary.c_str(),
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0644);
fail(ManifestWritePhase::kCreate)
? -1
: open(temporary.c_str(),
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0644);
if (descriptor < 0) {
*error = "cannot create atomic payload manifest";
return false;
}
std::size_t offset = 0;
while (offset < contents.size()) {
const ssize_t bytes =
write(descriptor, contents.data() + offset, contents.size() - offset);
const ssize_t bytes = fail(ManifestWritePhase::kWrite)
? -1
: write(descriptor, contents.data() + offset,
contents.size() - offset);
if (bytes < 0) {
if (errno == EINTR) continue;
close(descriptor);
@@ -137,7 +147,8 @@ bool WriteAtomic(const std::filesystem::path& root,
}
offset += static_cast<std::size_t>(bytes);
}
const bool synced = fsync(descriptor) == 0;
const bool synced =
!fail(ManifestWritePhase::kFileFsync) && fsync(descriptor) == 0;
close(descriptor);
if (!synced) {
std::error_code ignored;
@@ -146,7 +157,11 @@ bool WriteAtomic(const std::filesystem::path& root,
return false;
}
std::error_code filesystem_error;
std::filesystem::rename(temporary, destination, filesystem_error);
if (fail(ManifestWritePhase::kRename)) {
filesystem_error = std::make_error_code(std::errc::io_error);
} else {
std::filesystem::rename(temporary, destination, filesystem_error);
}
if (filesystem_error) {
std::filesystem::remove(temporary, filesystem_error);
*error = "cannot publish atomic payload manifest";
@@ -297,7 +312,10 @@ bool CloneOrCopyFile(const std::filesystem::path& source,
}
bool copied = false;
#if defined(__linux__) && defined(FICLONE)
copied = ioctl(output, FICLONE, input) == 0;
struct stat cloned_metadata = {};
copied = ioctl(output, FICLONE, input) == 0 &&
fstat(output, &cloned_metadata) == 0 &&
cloned_metadata.st_size == metadata.st_size;
#endif
#if defined(__linux__)
if (!copied) {
@@ -453,14 +471,19 @@ ManifestIdentity ReadManifest(const std::filesystem::path& path,
const nlohmann::json document =
nlohmann::json::parse(result.contents, nullptr, false, true);
if (document.is_discarded() || !document.is_object() ||
document.value("schema_version", 0) != 1 ||
!document.contains("schema_version") ||
!document["schema_version"].is_number_integer() ||
document["schema_version"] != 1 ||
!document.contains("payload_id") || !document["payload_id"].is_string()) {
result.error = "active payload manifest is invalid";
return result;
}
result.payload_id = document["payload_id"].get<std::string>();
if (!ValidPayloadId(result.payload_id) ||
document.value("payload_path", "") != "payloads/" + result.payload_id) {
!document.contains("payload_path") ||
!document["payload_path"].is_string() ||
document["payload_path"].get_ref<const std::string&>() !=
"payloads/" + result.payload_id) {
result.error = "active payload manifest identity is invalid";
}
return result;
@@ -535,7 +558,14 @@ bool PopulateInspectedResult(
if (verified_payload != nullptr) *verified_payload = payload;
const nlohmann::json document =
nlohmann::json::parse(manifest.contents, nullptr, false, true);
if (document.is_discarded() ||
if (document.is_discarded() || !document.is_object() ||
!document.contains("version_name") ||
!document["version_name"].is_string() ||
!document.contains("elf_build_id") ||
!document["elf_build_id"].is_string() ||
!document.contains("version_code") ||
!document["version_code"].is_number_unsigned() ||
document["version_code"] == 0 ||
document.value("version_name", "") != payload.metadata.version_name ||
document.value("version_code", 0ULL) != payload.metadata.version_code ||
document.value("elf_build_id", "") != payload.metadata.build_id) {
@@ -550,6 +580,13 @@ bool PopulateInspectedResult(
result->error = "active payload manifest hash does not match payload";
return false;
}
for (const char* field : {"host_abi_profile_path",
"compatibility_manifest_path", "approval_path"}) {
if (document.contains(field) && !document[field].is_string()) {
result->error = "active payload approval reference set is invalid";
return false;
}
}
const std::string profile = document.value("host_abi_profile_path", "");
const std::string compatibility =
document.value("compatibility_manifest_path", "");
@@ -591,14 +628,120 @@ bool PersistPayloadSha256(const std::filesystem::path& root,
error);
}
constexpr const char* kPreviousRecovery = ".previous_good-recovery.json";
constexpr const char* kAbsentRecovery = ".previous_good-recovery.absent";
// Inspect the directory entry itself: a dangling symlink is also a pending
// recovery artifact, and an inspection failure must never authorize deletion.
bool NoPendingRecovery(const std::filesystem::path& root, std::string* error) {
for (const char* name : {kPreviousRecovery, kAbsentRecovery}) {
std::error_code filesystem_error;
const auto status =
std::filesystem::symlink_status(root / name, filesystem_error);
if (filesystem_error &&
filesystem_error != std::errc::no_such_file_or_directory) {
*error = "cannot inspect previous_good recovery: " +
filesystem_error.message();
return false;
}
if (status.type() != std::filesystem::file_type::not_found) {
*error = "previous_good recovery is pending: " + (root / name).string();
return false;
}
}
return true;
}
bool RemoveRecovery(const std::filesystem::path& recovery, std::string* error,
bool current_published = false) {
std::error_code filesystem_error;
std::filesystem::remove(recovery, filesystem_error);
if (!filesystem_error) return true;
if (!error->empty()) *error += "; ";
if (current_published) *error += "current manifest published; ";
*error +=
"cannot remove previous_good recovery: " + filesystem_error.message() +
"; recovery retained at " + recovery.string();
return false;
}
// Called only while StoreLock is held. This recovers a failed publication;
// it does not make two manifest renames atomic against a process crash.
bool PublishActivation(const std::filesystem::path& root,
std::string_view payload_id, std::string_view contents,
const ManifestWriteFault& fault, std::string* error) {
if (!NoPendingRecovery(root, error)) return false;
const auto current = ReadManifest(root / "current.json", true);
if (!current) {
*error = current.error;
return false;
}
if (current.contents.empty() || current.payload_id == payload_id) {
return WriteAtomic(root, root / "current.json", contents, error, fault);
}
std::error_code filesystem_error;
const auto previous_status = std::filesystem::symlink_status(
root / "previous_good.json", filesystem_error);
if (filesystem_error &&
filesystem_error != std::errc::no_such_file_or_directory) {
*error =
"cannot inspect previous_good manifest: " + filesystem_error.message();
return false;
}
const bool previous_present =
previous_status.type() != std::filesystem::file_type::not_found;
std::string previous;
if (previous_present) {
// Keep the original bytes, including an explicitly present empty file.
previous = ReadRegular(root / "previous_good.json", 1024U * 1024U, error);
if (!error->empty()) return false;
}
const auto recovery =
root / (previous_present ? kPreviousRecovery : kAbsentRecovery);
if (!WriteAtomic(root, recovery, previous_present ? previous : "absent\n",
error, fault)) {
return false;
}
if (!WriteAtomic(root, root / "previous_good.json", current.contents, error,
fault)) {
RemoveRecovery(recovery, error);
return false;
}
if (!WriteAtomic(root, root / "current.json", contents, error, fault)) {
std::string restore_error;
if (previous_present) {
WriteAtomic(root, root / "previous_good.json", previous, &restore_error,
fault);
} else {
filesystem_error.clear();
std::filesystem::remove(root / "previous_good.json", filesystem_error);
if (filesystem_error) {
restore_error = "cannot restore absence of previous_good: " +
filesystem_error.message();
}
}
if (!restore_error.empty()) {
*error += "; previous_good restoration failed: " + restore_error +
"; recovery retained at " + recovery.string();
} else {
RemoveRecovery(recovery, error);
}
return false;
}
return RemoveRecovery(recovery, error, true);
}
} // namespace
PayloadStore::PayloadStore(std::filesystem::path root,
std::filesystem::path compatibility_manifest,
std::filesystem::path runtime_binary)
std::filesystem::path runtime_binary,
ManifestWriteFault manifest_write_fault)
: root_(std::move(root)),
compatibility_manifest_(std::move(compatibility_manifest)),
runtime_binary_(std::move(runtime_binary)) {}
runtime_binary_(std::move(runtime_binary)),
manifest_write_fault_(std::move(manifest_write_fault)) {}
PayloadStoreResult PayloadStore::Stage(
const std::filesystem::path& prepared_payload) {
@@ -679,20 +822,9 @@ PayloadStoreResult PayloadStore::Promote(std::string_view payload_id) {
result.version_name = payload.metadata.version_name;
result.version_code = payload.metadata.version_code;
result.build_id = payload.metadata.build_id;
const ManifestIdentity current = ReadManifest(root_ / "current.json", true);
if (!current && !current.error.empty()) {
result.error = current.error;
return result;
}
if (!current.contents.empty() && current.payload_id != result.payload_id &&
!WriteAtomic(root_, root_ / "previous_good.json", current.contents,
&result.error)) {
return result;
}
const std::string contents = ActivationManifest(payload).dump(2) + "\n";
if (!WriteAtomic(root_, root_ / "current.json", contents, &result.error)) {
return result;
}
PublishActivation(root_, result.payload_id, contents, manifest_write_fault_,
&result.error);
return result;
}
@@ -742,17 +874,8 @@ PayloadStoreResult PayloadStore::PromoteProbation(
&result.error)) {
return result;
}
const ManifestIdentity current = ReadManifest(root_ / "current.json", true);
if (!current && !current.error.empty()) {
result.error = current.error;
return result;
}
if (!current.contents.empty() && current.payload_id != result.payload_id &&
!WriteAtomic(root_, root_ / "previous_good.json", current.contents,
&result.error)) {
return result;
}
if (!WriteAtomic(root_, root_ / "current.json", contents, &result.error)) {
if (!PublishActivation(root_, result.payload_id, contents,
manifest_write_fault_, &result.error)) {
return result;
}
result.version_name = payload.metadata.version_name;
@@ -875,6 +998,7 @@ PayloadGarbageResult PayloadStore::CollectGarbage(
PayloadGarbageResult result;
StoreLock lock(root_, &result.error);
if (!lock) return result;
if (!NoPendingRecovery(root_, &result.error)) return result;
std::set<std::string> retained(keep.begin(), keep.end());
for (const char* manifest : {"current.json", "previous_good.json"}) {
const ManifestIdentity identity = ReadManifest(root_ / manifest, true);
+18 -3
View File
@@ -302,6 +302,13 @@ CanaryResult RunReadinessCanary(const CanaryOptions& options) {
"DISPLAY",
"WAYLAND_DISPLAY",
"XDG_RUNTIME_DIR",
"XDG_SESSION_TYPE",
"SDL_VIDEO_DRIVER",
"SDL_VIDEODRIVER",
"MOCKTAIL_FORCE_WAYLAND",
"MOCKTAIL_FORCE_X11",
"MOCKTAIL_ANGLE_FORCE_X11",
"MOCKTAIL_PREFER_WAYLAND",
"DBUS_SESSION_BUS_ADDRESS",
"PULSE_SERVER",
"PIPEWIRE_REMOTE",
@@ -313,6 +320,7 @@ CanaryResult RunReadinessCanary(const CanaryOptions& options) {
"LOGNAME",
"VK_DRIVER_FILES",
"VK_ICD_FILENAMES",
"vk_require_etc2",
"MESA_LOADER_DRIVER_OVERRIDE",
"DRI_PRIME",
"__NV_PRIME_RENDER_OFFLOAD",
@@ -419,8 +427,13 @@ CanaryResult RunReadinessCanary(const CanaryOptions& options) {
}
pid_t child = -1;
if (spawn_status == 0) {
spawn_status = posix_spawn(&child, options.runtime_binary.c_str(), &actions,
nullptr, arguments, environment_pointers.data());
result.spawn_error =
options.spawn
? options.spawn(&child, options.runtime_binary.c_str(), &actions,
nullptr, arguments, environment_pointers.data())
: posix_spawn(&child, options.runtime_binary.c_str(), &actions,
nullptr, arguments, environment_pointers.data());
spawn_status = result.spawn_error;
}
posix_spawn_file_actions_destroy(&actions);
close(log_descriptor);
@@ -460,7 +473,9 @@ CanaryResult RunReadinessCanary(const CanaryOptions& options) {
}
std::filesystem::remove_all(isolated, filesystem_error);
if (!result.error.empty()) return result;
result.exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 128;
result.exit_code = WIFEXITED(status)
? WEXITSTATUS(status)
: 128 + (WIFSIGNALED(status) ? WTERMSIG(status) : 0);
if (result.exit_code != 0) {
result.error =
"graphics canary exited with status " +
+45 -13
View File
@@ -301,6 +301,9 @@ struct ReferenceProfile {
std::filesystem::path library;
std::filesystem::path profile;
std::vector<std::filesystem::path> compatibility_manifests;
// libroblox.so of the shipped reference payload when the reference is a
// different, machine-derived payload. Empty when it is not staged.
std::filesystem::path runtime_fallback_library;
std::string error;
explicit operator bool() const {
@@ -367,6 +370,23 @@ ReferenceProfile ResolveReference(
paths.compatibility_manifest)) {
result.compatibility_manifests.push_back(paths.compatibility_manifest);
}
// A machine-derived profile can lack the optional FMOD output-device
// anchors. The shipped reference payload stays staged and has them.
const std::optional<SupportedPayloadProfile> shipped =
ResolveReferenceProfile(paths.host_abi_reference_profile, profiles,
nullptr);
if (shipped.has_value()) {
const std::string shipped_id = std::to_string(shipped->version_code) +
"-" + shipped->elf_build_id;
const std::filesystem::path shipped_directory =
paths.data_root / "payloads" / shipped_id;
const PayloadIntegrityResult shipped_payload =
InspectPreparedPayload(shipped_directory);
if (shipped_id != installed.payload_id && shipped_payload &&
shipped_payload.payload_id == shipped_id) {
result.runtime_fallback_library = shipped_directory / "libroblox.so";
}
}
return result;
}
}
@@ -475,6 +495,7 @@ UpdateResult RunUnsafeLatest(
derivation.reference_profile = reference.profile;
derivation.reference_compatibility_manifests =
reference.compatibility_manifests;
derivation.runtime_fallback_library = reference.runtime_fallback_library;
derivation.candidate_payload_directory = candidate.staged.payload_directory;
derivation.output_directory = workspace / "derived";
Progress(request.progress_fd, "Deriving latest Roblox compatibility...");
@@ -495,7 +516,9 @@ UpdateResult RunUnsafeLatest(
// Staging preserves the immutable APK for inspection, but this path must
// never promote it or alter the normal current payload.
UnsafeLatestRunOptions launch;
launch.runtime_binary = paths.runtime_binary;
launch.runtime_binary = paths.runtime_launcher.empty()
? paths.runtime_binary
: paths.runtime_launcher;
launch.payload_directory = candidate.staged.payload_directory;
launch.compatibility_manifest = candidate.exact_supported
? paths.compatibility_manifest
@@ -519,9 +542,10 @@ UpdateResult RunUnsafeLatest(
bool RunCandidateCanaries(const UpdatePaths& paths, const Candidate& candidate,
bool run_canary,
CanaryGraphicsBackend graphics_backend,
int progress_fd,
int progress_fd, const CanarySpawn& spawn,
std::array<std::filesystem::path, 2>* logs,
std::string* error) {
std::string* error, bool* retryable) {
*retryable = false;
if (!run_canary) {
if (!candidate.exact_supported) {
*error = "latest candidate cannot be activated without Tier C probation";
@@ -536,7 +560,9 @@ bool RunCandidateCanaries(const UpdatePaths& paths, const Candidate& candidate,
: "Testing latest Roblox (" +
std::to_string(index + 1) + "/2)...");
CanaryOptions canary;
canary.runtime_binary = paths.runtime_binary;
canary.runtime_binary = paths.runtime_launcher.empty()
? paths.runtime_binary
: paths.runtime_launcher;
canary.payload_directory = candidate.staged.payload_directory;
canary.compatibility_manifest = candidate.exact_supported
? paths.compatibility_manifest
@@ -545,12 +571,14 @@ bool RunCandidateCanaries(const UpdatePaths& paths, const Candidate& candidate,
canary.cache_root = paths.cache_root;
canary.state_root = paths.state_root;
canary.graphics_backend = graphics_backend;
canary.spawn = spawn;
const CanaryResult canary_result = [&] {
StageTimer timer("canary[" + std::to_string(index + 1) + "/" +
std::to_string(runs) + "]");
return RunReadinessCanary(canary);
}();
if (!canary_result) {
*retryable = canary_result.spawn_error == EAGAIN;
*error = canary_result.error;
if (!canary_result.log_path.empty()) {
*error += "; log: " + canary_result.log_path.string();
@@ -565,11 +593,13 @@ bool RunCandidateCanaries(const UpdatePaths& paths, const Candidate& candidate,
PayloadStoreResult PromoteCandidate(const UpdatePaths& paths,
const UpdateRequest& request,
const Candidate& candidate,
PayloadStore* store, std::string* error) {
PayloadStore* store, std::string* error,
bool* retryable) {
*retryable = false;
std::array<std::filesystem::path, 2> logs;
if (!RunCandidateCanaries(paths, candidate, request.run_canary,
request.canary_graphics_backend,
request.progress_fd, &logs, error)) {
if (!RunCandidateCanaries(
paths, candidate, request.run_canary, request.canary_graphics_backend,
request.progress_fd, request.canary_spawn, &logs, error, retryable)) {
return {};
}
Progress(request.progress_fd, "Installing Roblox...");
@@ -824,14 +854,14 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
}
examined.push_back(candidate.staged.payload_id);
if (request.startup_preflight && current &&
RejectedForRuntime(paths, candidate,
request.canary_graphics_backend)) {
RejectedForRuntime(paths, candidate, request.canary_graphics_backend)) {
blocked = description + " already failed probation with this runtime";
continue;
}
std::string candidate_error;
bool candidate_rejected = false;
bool retryable = false;
if (!candidate.exact_supported && candidate.profile.empty()) {
const ReferenceProfile reference =
ResolveReference(paths, installed, catalog.profiles, &provider,
@@ -844,6 +874,8 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
derivation.reference_profile = reference.profile;
derivation.reference_compatibility_manifests =
reference.compatibility_manifests;
derivation.runtime_fallback_library =
reference.runtime_fallback_library;
derivation.candidate_payload_directory =
candidate.staged.payload_directory;
derivation.output_directory =
@@ -865,9 +897,9 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
}
PayloadStoreResult promoted;
if (candidate_error.empty()) {
promoted =
PromoteCandidate(paths, request, candidate, &store, &candidate_error);
candidate_rejected = !candidate_error.empty();
promoted = PromoteCandidate(paths, request, candidate, &store,
&candidate_error, &retryable);
candidate_rejected = !candidate_error.empty() && !retryable;
}
if (candidate_error.empty() && promoted) {
result.changed = !current || current.payload_id != promoted.payload_id;
+5
View File
@@ -1424,6 +1424,11 @@ void Activate(GtkApplication* application, gpointer user_data) {
state->disable_hardware_acceleration =
mocktail::webview::ShouldDisableWebViewHardwareAcceleration(
wayland_display, std::getenv("WEBKIT_DISABLE_COMPOSITING_MODE"));
// GTK picks its renderer when a window is realized, which is after this.
if (mocktail::webview::ShouldForceSoftwareGtkRenderer(
state->disable_hardware_acceleration, std::getenv("GSK_RENDERER"))) {
setenv("GSK_RENDERER", "cairo", 0);
}
std::cerr << "[webview] display=" << (wayland_display ? "wayland" : "other")
<< " compositing="
<< (state->disable_hardware_acceleration ? "software" : "default")
+5
View File
@@ -133,6 +133,11 @@ bool ShouldDisableWebViewHardwareAcceleration(
return wayland_display;
}
bool ShouldForceSoftwareGtkRenderer(bool software_compositing,
const char* renderer_override) {
return software_compositing && renderer_override == nullptr;
}
const char* AndroidBridgeSource() { return kBridgeSource; }
std::string BuildRobloxAndroidUserAgent() {
+7
View File
@@ -38,6 +38,13 @@ bool ShouldDisableWebKitSandbox(std::string_view kernel_version,
// Match WebKit's WEBKIT_DISABLE_COMPOSITING_MODE override semantics.
bool ShouldDisableWebViewHardwareAcceleration(
bool wayland_display, const char* compositing_override);
// GTK's GL renderer aborts in libepoxy when it snapshots a page switch on
// hosts where its GL context cannot become current. With software compositing
// the helper does not need GL for its UI, so use the Cairo renderer unless the
// user chose one through GSK_RENDERER.
bool ShouldForceSoftwareGtkRenderer(bool software_compositing,
const char* renderer_override);
const char* AndroidBridgeSource();
std::string BuildRobloxAndroidUserAgent();
bool IsBrowserLoginUrl(std::string_view url);
+76
View File
@@ -0,0 +1,76 @@
#ifndef MOCKTAIL_WINDOW_HOST_SURFACE_COMMIT_GATE_H_
#define MOCKTAIL_WINDOW_HOST_SURFACE_COMMIT_GATE_H_
#include <atomic>
#include <chrono>
#include <mutex>
#include <thread>
namespace mocktail {
namespace window {
// Keeps main-thread wl_surface commits out of a host vkQueuePresentKHR call.
// With explicit sync a Wayland driver sends its sync points, buffer attach
// and commit as separate requests inside that call. A commit from another
// thread between them carries sync points without a buffer, which is a
// no_buffer protocol error that disconnects the client.
class HostSurfaceCommitGate final {
public:
// A host present can block for as long as the compositor withholds a
// frame, so the main thread proceeds unguarded after this wait.
static constexpr std::chrono::milliseconds kMainThreadWait{50};
HostSurfaceCommitGate() = default;
HostSurfaceCommitGate(const HostSurfaceCommitGate&) = delete;
HostSurfaceCommitGate& operator=(const HostSurfaceCommitGate&) = delete;
void BeginHostPresent() {
if (present_thread_.load(std::memory_order_acquire) ==
std::this_thread::get_id()) {
// The previous present on this thread never reported its end.
return;
}
mutex_.lock();
present_thread_.store(std::this_thread::get_id(),
std::memory_order_release);
}
void EndHostPresent() {
if (present_thread_.load(std::memory_order_acquire) !=
std::this_thread::get_id()) {
return;
}
present_thread_.store(std::thread::id(), std::memory_order_release);
mutex_.unlock();
}
// Held around SDL calls that can commit the window surface.
class MainThreadScope final {
public:
explicit MainThreadScope(
HostSurfaceCommitGate& gate,
std::chrono::nanoseconds wait = kMainThreadWait)
: lock_(gate.mutex_, wait) {}
MainThreadScope(const MainThreadScope&) = delete;
MainThreadScope& operator=(const MainThreadScope&) = delete;
bool owns_lock() const { return lock_.owns_lock(); }
private:
std::unique_lock<std::timed_mutex> lock_;
};
private:
std::timed_mutex mutex_;
std::atomic<std::thread::id> present_thread_{};
};
inline HostSurfaceCommitGate& ProcessHostSurfaceCommitGate() {
static HostSurfaceCommitGate gate;
return gate;
}
} // namespace window
} // namespace mocktail
#endif // MOCKTAIL_WINDOW_HOST_SURFACE_COMMIT_GATE_H_
-5
View File
@@ -16,10 +16,6 @@ VideoDriverChoice ResolveVideoDriverChoice(
if (input.force_x11 && input.has_x11_display) {
return VideoDriverChoice::kX11;
}
if (input.uses_direct_vulkan && input.has_nvidia_kernel_driver &&
input.has_wayland_session && input.has_x11_display) {
return VideoDriverChoice::kNvidiaDirectVulkanX11;
}
if (input.prefer_wayland && input.has_wayland_session) {
return VideoDriverChoice::kWayland;
}
@@ -31,7 +27,6 @@ const char* VideoDriverChoiceName(VideoDriverChoice choice) {
case VideoDriverChoice::kWayland:
return "wayland";
case VideoDriverChoice::kX11:
case VideoDriverChoice::kNvidiaDirectVulkanX11:
return "x11";
case VideoDriverChoice::kSdlDefault:
return nullptr;
+2 -6
View File
@@ -11,7 +11,6 @@ enum class VideoDriverChoice {
kSdlDefault,
kWayland,
kX11,
kNvidiaDirectVulkanX11,
};
struct VideoDriverPolicyInput {
@@ -21,14 +20,11 @@ struct VideoDriverPolicyInput {
bool prefer_wayland = true;
bool has_wayland_session = false;
bool has_x11_display = false;
bool uses_direct_vulkan = false;
bool has_nvidia_kernel_driver = false;
};
// Resolves the SDL video backend before SDL_Init. An explicit SDL driver is
// always authoritative. NVIDIA's direct Vulkan WSI uses X11/XWayland by
// default when both display transports are available because a blocked native
// Wayland present cannot be cancelled without violating VkQueue ownership.
// always authoritative. A Wayland session uses native Wayland unless X11 is
// forced or the Wayland preference is disabled.
VideoDriverChoice ResolveVideoDriverChoice(const VideoDriverPolicyInput& input);
const char* VideoDriverChoiceName(VideoDriverChoice choice);
+213 -109
View File
@@ -13,17 +13,18 @@
#include <cstring>
#include <filesystem>
#include <memory>
#include <mutex>
#include <unordered_map>
#include <string>
#include <utility>
#include <vector>
#include "mocktail/graphics/angle_probe.h"
#include "mocktail/graphics/gles_text_overlay_compositor.h"
#include "mocktail/graphics/present_mode_policy.h"
#include "mocktail/platform/display_refresh_capabilities.h"
#include "mocktail/platform/sdl_application_metadata.h"
#include "mocktail/platform/sdl_event_converter.h"
#include "mocktail/platform/sdl_window_icon.h"
#include "window/host_surface_commit_gate.h"
#include "window/input_pump_pacer.h"
#include "window/main_thread_command_gate.h"
#include "window/roblox_fullscreen_menu_request_gate.h"
@@ -66,6 +67,11 @@ static constexpr EGLint EGL_ALPHA_SIZE_VAL = 0x3021;
static constexpr EGLint EGL_DEPTH_SIZE_VAL = 0x3025;
static constexpr EGLint EGL_STENCIL_SIZE_VAL = 0x3026;
static constexpr EGLint EGL_CONTEXT_CLIENT_VERSION_VAL = 0x3098;
static constexpr EGLint EGL_EXTENSIONS_VAL = 0x3055;
static constexpr EGLint EGL_DEBUG_MSG_CRITICAL_KHR_VAL = 0x33B9;
static constexpr EGLint EGL_DEBUG_MSG_ERROR_KHR_VAL = 0x33BA;
static constexpr EGLint EGL_DEBUG_MSG_WARN_KHR_VAL = 0x33BB;
static constexpr EGLint EGL_DEBUG_MSG_INFO_KHR_VAL = 0x33BC;
static constexpr EGLint EGL_PLATFORM_ANGLE_ANGLE_VAL = 0x3202;
static constexpr EGLint EGL_PLATFORM_ANGLE_TYPE_ANGLE_VAL = 0x3203;
static constexpr EGLint EGL_PLATFORM_ANGLE_DEVICE_TYPE_ANGLE_VAL = 0x3209;
@@ -76,6 +82,10 @@ static constexpr EGLint EGL_PLATFORM_ANGLE_DEBUG_LAYERS_ENABLED_ANGLE_VAL =
static constexpr EGLint EGL_PLATFORM_ANGLE_TYPE_VULKAN_ANGLE_VAL = 0x3450;
static constexpr EGLint EGL_PLATFORM_ANGLE_DEVICE_TYPE_SWIFTSHADER_ANGLE_VAL =
0x3487;
static constexpr EGLint EGL_PLATFORM_ANGLE_NATIVE_PLATFORM_TYPE_ANGLE_VAL =
0x348F;
static constexpr EGLint EGL_PLATFORM_X11_EXT_VAL = 0x31D5;
static constexpr EGLint EGL_PLATFORM_WAYLAND_EXT_VAL = 0x31D8;
static constexpr EGLint EGL_TRUE_VAL = 1;
static constexpr EGLint EGL_FALSE_VAL = 0;
static constexpr EGLBoolean EGL_TRUE_B = 1;
@@ -116,7 +126,9 @@ struct WindowState {
int height = 720;
bool initialised = false;
bool quit_requested = false;
bool visible = false;
// Written on the main thread. The render thread reads it to decide whether
// to ask for the window to be shown.
std::atomic<bool> visible{false};
bool software_window = false;
bool direct_vulkan = false;
bool input_test_sequence_queued = false;
@@ -135,6 +147,9 @@ struct WindowState {
static WindowState g_state;
static std::atomic<int> g_real_swap_count{0};
// Set by the render thread at its first frame; the main thread shows the
// window, because SDL window calls belong to the main thread.
static std::atomic<bool> g_show_window_requested{false};
static std::atomic<uint64_t> g_first_present_ticks_ns{0};
static InputPumpPacer g_input_pump_pacer;
static PresentLifecycleGate g_present_lifecycle;
@@ -157,8 +172,6 @@ static std::unique_ptr<SdlPointerCaptureBackend> g_pointer_capture_backend;
static std::mutex g_pointer_capture_owner_mutex;
static std::shared_ptr<WindowPointerCaptureOwner> g_pointer_capture_owner;
static std::unique_ptr<graphics::GlesTextOverlayCompositor> g_gles_text_overlay;
static char g_preferred_egl_library[4096];
static char g_preferred_gles_library[4096];
static bool g_auto_angle_retry_attempted = false;
static std::filesystem::path g_window_state_path;
@@ -239,17 +252,8 @@ const char* GetEnvNonEmpty(const char* name) {
if (name == nullptr) {
return nullptr;
}
static std::mutex mutex;
static std::unordered_map<const char*, const char*> cache;
std::lock_guard<std::mutex> lock(mutex);
const auto existing = cache.find(name);
if (existing != cache.end()) {
return existing->second;
}
const char* raw = std::getenv(name);
const char* value = (raw != nullptr && raw[0] != '\0') ? raw : nullptr;
cache.emplace(name, value);
return value;
return raw != nullptr && raw[0] != '\0' ? raw : nullptr;
}
bool IsEnabledEnv(const char* name) {
@@ -435,19 +439,6 @@ bool FileExists(const char* path) {
return path != nullptr && path[0] != '\0' && access(path, R_OK) == 0;
}
bool JoinExistingLibraryPath(const char* dir, const char* soname, char* out,
size_t out_size) {
if (dir == nullptr || dir[0] == '\0' || soname == nullptr ||
soname[0] == '\0' || out == nullptr || out_size == 0) {
return false;
}
int written = std::snprintf(out, out_size, "%s/%s", dir, soname);
if (written <= 0 || static_cast<size_t>(written) >= out_size) {
return false;
}
return FileExists(out);
}
bool ShouldUseAngleBackend() {
const char* backend = GetEnvNonEmpty("MOCKTAIL_GRAPHICS_BACKEND");
return StartsWith(backend, "angle") ||
@@ -490,9 +481,6 @@ VideoDriverChoice ResolveConfiguredVideoDriverChoice() {
!StringEquals(GetEnvNonEmpty("MOCKTAIL_FORCE_WAYLAND"), "0");
input.has_wayland_session = HasWaylandSession();
input.has_x11_display = GetEnvNonEmpty("DISPLAY") != nullptr;
input.uses_direct_vulkan = ShouldUseNativeVulkanBackend();
input.has_nvidia_kernel_driver =
access("/proc/driver/nvidia/version", R_OK) == 0;
return ResolveVideoDriverChoice(input);
}
@@ -553,25 +541,26 @@ bool ShouldShowWindowImmediately() {
return IsEnabledEnv("MOCKTAIL_DEBUG_SHOW_WINDOW_BEFORE_FRAME");
}
const char* FindAngleLibrary(const char* explicit_env, const char* soname,
char* out, size_t out_size) {
const char* explicit_path = GetEnvNonEmpty(explicit_env);
if (explicit_path != nullptr) {
return explicit_path;
}
struct GraphicsLibraries {
std::string egl_path;
std::string gles_path;
};
const char* angle_dir = GetEnvNonEmpty("MOCKTAIL_ANGLE_LIB_DIR");
if (JoinExistingLibraryPath(angle_dir, soname, out, out_size)) {
return out;
}
if (!ShouldUseAngleBackend()) {
return nullptr;
}
static const char* kAngleDirs[] = {
GraphicsLibraries FindInstalledAngleLibraries() {
const char* runtime_directory =
GetEnvNonEmpty("MOCKTAIL_RUNTIME_LIBRARY_DIR");
const std::string bundled_directory =
runtime_directory != nullptr
? (std::filesystem::path(runtime_directory) / "angle").string()
: std::string();
const char* kAngleDirs[] = {
bundled_directory.empty() ? nullptr : bundled_directory.c_str(),
"/usr/lib64/chromium",
"/usr/lib64/chromium-browser",
"/usr/lib/chromium",
"/usr/lib/chromium-browser",
"/usr/lib64/electron43",
"/usr/lib/electron43",
"/usr/lib/electron42",
"/usr/lib/electron41",
"/usr/lib/electron40",
@@ -582,47 +571,59 @@ const char* FindAngleLibrary(const char* explicit_env, const char* soname,
"/opt/google/chrome-unstable",
};
for (const char* dir : kAngleDirs) {
if (JoinExistingLibraryPath(dir, soname, out, out_size)) {
return out;
if (dir == nullptr) {
continue;
}
const std::filesystem::path directory(dir);
GraphicsLibraries libraries{(directory / "libEGL.so").string(),
(directory / "libGLESv2.so").string()};
if (FileExists(libraries.egl_path.c_str()) &&
FileExists(libraries.gles_path.c_str()) &&
graphics::InspectAngleLibraries(
{libraries.egl_path, libraries.gles_path})
.state == graphics::CapabilityState::kLoadable) {
return libraries;
}
}
return nullptr;
return {};
}
const char* FindInstalledAngleLibrary(const char* soname, char* out,
size_t out_size) {
static const char* kAngleDirs[] = {
"/usr/lib/chromium",
"/usr/lib/chromium-browser",
"/usr/lib/electron42",
"/usr/lib/electron41",
"/usr/lib/electron40",
"/usr/lib/electron39",
"/usr/lib/cef",
"/opt/google/chrome",
"/opt/google/chrome-beta",
"/opt/google/chrome-unstable",
};
for (const char* dir : kAngleDirs) {
if (JoinExistingLibraryPath(dir, soname, out, out_size)) {
return out;
GraphicsLibraries ResolveGraphicsLibraries() {
const char* egl_path = GetEnvNonEmpty("MOCKTAIL_EGL_LIBRARY");
const char* gles_path = GetEnvNonEmpty("MOCKTAIL_GLES_LIBRARY");
GraphicsLibraries libraries{egl_path != nullptr ? egl_path : "",
gles_path != nullptr ? gles_path : ""};
std::filesystem::path directory;
const char* angle_dir = GetEnvNonEmpty("MOCKTAIL_ANGLE_LIB_DIR");
if (angle_dir != nullptr) {
directory = angle_dir;
} else if (ShouldUseAngleBackend()) {
if (libraries.egl_path.empty() && libraries.gles_path.empty()) {
return FindInstalledAngleLibraries();
}
directory =
std::filesystem::path(libraries.egl_path.empty() ? libraries.gles_path
: libraries.egl_path)
.parent_path();
} else {
return libraries;
}
return nullptr;
if (libraries.egl_path.empty()) {
libraries.egl_path = (directory / "libEGL.so").string();
}
if (libraries.gles_path.empty()) {
libraries.gles_path = (directory / "libGLESv2.so").string();
}
return libraries;
}
bool HasInstalledAnglePair() {
char egl_path[4096];
char gles_path[4096];
return FindInstalledAngleLibrary("libEGL.so", egl_path, sizeof(egl_path)) !=
nullptr &&
FindInstalledAngleLibrary("libGLESv2.so", gles_path,
sizeof(gles_path)) != nullptr;
return !FindInstalledAngleLibraries().egl_path.empty();
}
SDL_EGLAttrib* SDLCALL AnglePlatformAttributes(void* /*userdata*/) {
SDL_EGLAttrib* attrs =
static_cast<SDL_EGLAttrib*>(SDL_malloc(sizeof(SDL_EGLAttrib) * 7));
static_cast<SDL_EGLAttrib*>(SDL_malloc(sizeof(SDL_EGLAttrib) * 9));
if (attrs == nullptr) {
return nullptr;
}
@@ -633,6 +634,13 @@ SDL_EGLAttrib* SDLCALL AnglePlatformAttributes(void* /*userdata*/) {
attrs[i++] = ShouldUseAngleSwiftShader()
? EGL_PLATFORM_ANGLE_DEVICE_TYPE_SWIFTSHADER_ANGLE_VAL
: EGL_PLATFORM_ANGLE_DEVICE_TYPE_HARDWARE_ANGLE_VAL;
const char* video_driver = SDL_GetCurrentVideoDriver();
if (StringEquals(video_driver, "x11") || StringEquals(video_driver, "wayland")) {
attrs[i++] = EGL_PLATFORM_ANGLE_NATIVE_PLATFORM_TYPE_ANGLE_VAL;
attrs[i++] = StringEquals(video_driver, "x11")
? EGL_PLATFORM_X11_EXT_VAL
: EGL_PLATFORM_WAYLAND_EXT_VAL;
}
if (IsEnabledEnv("MOCKTAIL_ANGLE_DEBUG_LAYERS")) {
attrs[i++] = EGL_PLATFORM_ANGLE_DEBUG_LAYERS_ENABLED_ANGLE_VAL;
attrs[i++] = EGL_TRUE_VAL;
@@ -641,10 +649,7 @@ SDL_EGLAttrib* SDLCALL AnglePlatformAttributes(void* /*userdata*/) {
return attrs;
}
void ConfigureGraphicsBackendBeforeSDL() {
g_preferred_egl_library[0] = '\0';
g_preferred_gles_library[0] = '\0';
bool ConfigureGraphicsBackendBeforeSDL() {
SanitizeSdlVideoDriverOverrides();
SDL_SetHint(SDL_HINT_VIDEO_FORCE_EGL, "1");
@@ -664,27 +669,48 @@ void ConfigureGraphicsBackendBeforeSDL() {
setenv("SDL_VIDEODRIVER", video_driver, 1);
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, video_driver);
}
if (video_driver_choice == VideoDriverChoice::kNvidiaDirectVulkanX11) {
fprintf(stderr,
" [window] NVIDIA direct Vulkan on Wayland session: using "
"X11/XWayland WSI; set SDL_VIDEODRIVER=wayland to override\n");
const GraphicsLibraries libraries = ResolveGraphicsLibraries();
const char* egl_library =
libraries.egl_path.empty() ? nullptr : libraries.egl_path.c_str();
const char* gles_library =
libraries.gles_path.empty() ? nullptr : libraries.gles_path.c_str();
if (ShouldUseAngleBackend() && !ShouldUseNativeVulkanBackend() &&
(!FileExists(egl_library) || !FileExists(gles_library))) {
const char* directory = GetEnvNonEmpty("MOCKTAIL_ANGLE_LIB_DIR");
SDL_SetError(
"ANGLE requires compatible libEGL.so and libGLESv2.so; "
"directory=%s egl=%s gles=%s",
directory != nullptr ? directory : "(automatic)",
egl_library != nullptr ? egl_library : "(missing)",
gles_library != nullptr ? gles_library : "(missing)");
fprintf(stderr, " [window] %s\n", SDL_GetError());
return false;
}
const char* egl_library = FindAngleLibrary(
"MOCKTAIL_EGL_LIBRARY", "libEGL.so", g_preferred_egl_library,
sizeof(g_preferred_egl_library));
const char* gles_library = FindAngleLibrary(
"MOCKTAIL_GLES_LIBRARY", "libGLESv2.so", g_preferred_gles_library,
sizeof(g_preferred_gles_library));
if (ShouldUseAngleVulkanBackend()) {
const auto capability =
graphics::InspectAngleLibraries({egl_library, gles_library});
if (capability.state != graphics::CapabilityState::kLoadable) {
SDL_SetError("ANGLE unavailable: %s", capability.detail.c_str());
fprintf(stderr, " [window] %s\n", SDL_GetError());
return false;
}
}
if (egl_library != nullptr) {
setenv("MOCKTAIL_EGL_LIBRARY", egl_library, 0);
SDL_SetHint(SDL_HINT_EGL_LIBRARY, egl_library);
SDL_SetHintWithPriority(SDL_HINT_EGL_LIBRARY, egl_library,
ShouldUseAngleBackend() ? SDL_HINT_OVERRIDE
: SDL_HINT_NORMAL);
}
if (gles_library != nullptr) {
setenv("MOCKTAIL_GLES_LIBRARY", gles_library, 0);
SDL_SetHint(SDL_HINT_OPENGL_LIBRARY, gles_library);
SDL_SetHint(SDL_HINT_OPENGL_ES_DRIVER, "1");
const auto priority = ShouldUseAngleBackend() ? SDL_HINT_OVERRIDE
: SDL_HINT_NORMAL;
SDL_SetHintWithPriority(SDL_HINT_OPENGL_LIBRARY, gles_library, priority);
SDL_SetHintWithPriority(SDL_HINT_OPENGL_ES_DRIVER, "1", priority);
}
if (ShouldUseAngleVulkanBackend()) {
@@ -693,37 +719,88 @@ void ConfigureGraphicsBackendBeforeSDL() {
if (ShouldUseAngleBackend() || egl_library != nullptr ||
gles_library != nullptr || WindowTraceEnabled()) {
const char* requested_video_driver = GetEnvNonEmpty("SDL_VIDEO_DRIVER");
if (requested_video_driver == nullptr) {
requested_video_driver = GetEnvNonEmpty("SDL_VIDEODRIVER");
}
fprintf(stderr, " [window] graphics backend=%s video=%s egl=%s gles=%s\n",
GetEnvNonEmpty("MOCKTAIL_GRAPHICS_BACKEND")
? GetEnvNonEmpty("MOCKTAIL_GRAPHICS_BACKEND")
: "system",
GetEnvNonEmpty("SDL_VIDEODRIVER")
? GetEnvNonEmpty("SDL_VIDEODRIVER")
: "(SDL default)",
requested_video_driver ? requested_video_driver : "(SDL default)",
egl_library ? egl_library : "(system)",
gles_library ? gles_library : "(system)");
}
return true;
}
void ConfigureGraphicsBackendAfterSDLInit() {
void LogEglError(EGLenum error, const char* command, EGLint /*message_type*/,
void* /*thread_label*/, void* /*object_label*/,
const char* message) {
fprintf(stderr, " [window][EGL] %s: error=0x%04x %s\n",
command != nullptr ? command : "(unknown call)", error,
message != nullptr ? message : "(no details)");
}
void EnableEglErrorLogging() {
using QueryString = const char* (*)(EGLDisplay, EGLint);
using DebugCallback = void (*)(EGLenum, const char*, EGLint, void*, void*,
const char*);
using DebugMessageControl = EGLint (*)(DebugCallback, const SDL_EGLAttrib*);
const auto query_string = reinterpret_cast<QueryString>(
SDL_EGL_GetProcAddress("eglQueryString"));
if (query_string == nullptr) {
return;
}
const char* extensions = query_string(EGL_NO_DISPLAY, EGL_EXTENSIONS_VAL);
const std::string extension_list =
std::string(" ") + (extensions != nullptr ? extensions : "") + " ";
if (extension_list.find(" EGL_KHR_debug ") == std::string::npos) {
return;
}
const auto debug_message_control = reinterpret_cast<DebugMessageControl>(
SDL_EGL_GetProcAddress("eglDebugMessageControlKHR"));
if (debug_message_control != nullptr) {
const SDL_EGLAttrib attributes[] = {
EGL_DEBUG_MSG_CRITICAL_KHR_VAL, EGL_TRUE_VAL,
EGL_DEBUG_MSG_ERROR_KHR_VAL, EGL_TRUE_VAL,
EGL_DEBUG_MSG_WARN_KHR_VAL, EGL_FALSE_VAL,
EGL_DEBUG_MSG_INFO_KHR_VAL, EGL_FALSE_VAL, EGL_NONE_VAL};
debug_message_control(LogEglError, attributes);
}
}
bool ConfigureGraphicsBackendAfterSDLInit() {
if (ShouldUseAngleVulkanBackend()) {
if (!SDL_GL_SetAttribute(SDL_GL_EGL_PLATFORM, EGL_PLATFORM_ANGLE_ANGLE_VAL)) {
fprintf(stderr, " [window] cannot select ANGLE EGL platform: %s\n",
SDL_GetError());
return false;
}
SDL_EGL_SetAttributeCallbacks(AnglePlatformAttributes, nullptr, nullptr,
nullptr);
fprintf(stderr,
" [window] ANGLE Vulkan EGL platform attributes enabled "
"(video=%s)\n", SDL_GetCurrentVideoDriver());
}
const char* gles_library = GetEnvNonEmpty("MOCKTAIL_GLES_LIBRARY");
if (gles_library != nullptr) {
if (!SDL_GL_LoadLibrary(gles_library)) {
fprintf(stderr, " [window] SDL_GL_LoadLibrary(%s) failed: %s\n",
gles_library, SDL_GetError());
} else if (WindowTraceEnabled() || ShouldUseAngleBackend()) {
return false;
}
if (WindowTraceEnabled() || ShouldUseAngleBackend()) {
fprintf(stderr, " [window] SDL_GL_LoadLibrary(%s) succeeded\n",
gles_library);
}
if (WindowTraceEnabled() || TraceFlagEnabled("MOCKTAIL_EGL_TRACE")) {
EnableEglErrorLogging();
}
}
if (ShouldUseAngleVulkanBackend()) {
SDL_GL_SetAttribute(SDL_GL_EGL_PLATFORM, EGL_PLATFORM_ANGLE_ANGLE_VAL);
SDL_EGL_SetAttributeCallbacks(AnglePlatformAttributes, nullptr, nullptr,
nullptr);
fprintf(stderr,
" [window] ANGLE Vulkan EGL platform attributes enabled\n");
}
return true;
}
void ShowWindowAccordingToStartupMode() {
@@ -1038,7 +1115,9 @@ bool Init(int width, int height, const char* title) {
g_fullscreen_menu_request_gate.Reset();
g_real_swap_count.store(0, std::memory_order_relaxed);
ConfigureGraphicsBackendBeforeSDL();
if (!ConfigureGraphicsBackendBeforeSDL()) {
return false;
}
if (StringEquals(GetEnvNonEmpty("MOCKTAIL_GRAPHICS_BACKEND"),
"direct-vulkan")) {
@@ -1178,11 +1257,10 @@ bool Init(int width, int height, const char* title) {
return false;
}
if (WindowTraceEnabled()) {
fprintf(stderr, " [window] SDL_Init(SDL_INIT_VIDEO) succeeded\n");
fprintf(stderr, " [window] SDL_Init(SDL_INIT_VIDEO) succeeded (video=%s)\n",
SDL_GetCurrentVideoDriver());
}
ConfigureGraphicsBackendAfterSDLInit();
// SDL may recheck this hint during context creation.
SDL_SetHint(SDL_HINT_VIDEO_FORCE_EGL, "1");
@@ -1197,6 +1275,11 @@ bool Init(int width, int height, const char* title) {
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
if (!ConfigureGraphicsBackendAfterSDLInit()) {
SDL_Quit();
return false;
}
const WindowStartupPresentationPlan presentation =
RestoredWindowPresentationPlan();
SDL_WindowFlags window_flags = ApplyHighPixelDensityWindowFlag(
@@ -1558,6 +1641,21 @@ void ShowIfHidden() {
IsDisabledEnv("MOCKTAIL_SHOW_WINDOW_ON_FIRST_SWAP")) {
return;
}
g_show_window_requested.store(true, std::memory_order_release);
}
// Main thread only. Shows the window once a frame has asked for it.
void ApplyShowWindowRequest() {
if (!g_show_window_requested.load(std::memory_order_acquire) ||
g_state.sdl_window == nullptr) {
return; // Without a window the request stays for the next pump.
}
g_show_window_requested.store(false, std::memory_order_relaxed);
if (g_state.visible) {
return;
}
HostSurfaceCommitGate::MainThreadScope commit_scope(
ProcessHostSurfaceCommitGate());
SDL_ShowWindow(g_state.sdl_window);
SDL_RaiseWindow(g_state.sdl_window);
g_state.visible = true;
@@ -1762,6 +1860,7 @@ extern "C" bool mocktail_window_uses_direct_vulkan() {
extern "C" void mocktail_window_note_vulkan_present() { NoteVulkanPresent(); }
extern "C" void mocktail_window_note_vulkan_host_present_begin() {
ProcessHostSurfaceCommitGate().BeginHostPresent();
g_vulkan_host_present_sequence =
g_vulkan_present_progress_gate.NotifyHostPresentBegin(
SDL_GetTicksNS(), SDL_GetCurrentThreadID());
@@ -1772,6 +1871,7 @@ extern "C" void mocktail_window_note_vulkan_host_present_end(
g_vulkan_present_progress_gate.NotifyHostPresentEnd(
g_vulkan_host_present_sequence);
g_vulkan_host_present_sequence = 0;
ProcessHostSurfaceCommitGate().EndHostPresent();
}
extern "C" std::uint64_t mocktail_window_note_vulkan_call_begin(
@@ -1911,6 +2011,8 @@ bool RequestFullscreenState(bool fullscreen, const char* reason) {
if (current_fullscreen != fullscreen) {
// Save the restore rectangle before SDL replaces it with monitor bounds.
CaptureWindowState();
HostSurfaceCommitGate::MainThreadScope commit_scope(
ProcessHostSurfaceCommitGate());
if (!SDL_SetWindowFullscreen(g_state.sdl_window, fullscreen)) {
fprintf(stderr, " [fullscreen] SDL request failed: %s\n",
SDL_GetError());
@@ -2108,6 +2210,7 @@ void MaybeReportVulkanPresentStall() {
}
bool PumpEvents() {
ApplyShowWindowRequest();
MaybeSynchronizeRestoredFullscreenState();
MaybeApplyRobloxFullscreenMenuRequest();
MaybeApplyAndroidFullscreenRequest();
@@ -2273,7 +2376,8 @@ bool PumpEvents() {
std::get_if<platform::MouseMotionEvent>(&pending_motion.payload);
const auto* nxt =
std::get_if<platform::MouseMotionEvent>(&platform_event.payload);
if (cur != nullptr && nxt != nullptr) {
if (cur != nullptr && nxt != nullptr &&
cur->relative == nxt->relative) {
cur->delta_x += nxt->delta_x;
cur->delta_y += nxt->delta_y;
cur->x = nxt->x;
@@ -5,6 +5,8 @@
#include <cstdio>
#include "window/host_surface_commit_gate.h"
namespace mocktail {
namespace window {
@@ -39,10 +41,14 @@ bool WindowPointerCaptureOwner::RegisterQuery(MouseLockQueryCallback callback,
void WindowPointerCaptureOwner::ClearQuery() {
std::unique_lock<std::mutex> lock(mutex_);
if (clearing_ && t_pointer_query_owner == this) {
return;
}
condition_.wait(lock, [this] { return !clearing_; });
clearing_ = true;
callback_ = nullptr;
context_ = nullptr;
condition_.notify_all();
condition_.wait(lock, [this] {
return in_flight_ == 0 || t_pointer_query_owner == this;
});
@@ -199,6 +205,10 @@ bool SdlPointerCaptureBackend::Apply(bool relative_mode, bool cursor_visible) {
return false;
}
// Leaving relative mode makes SDL warp the pointer, which commits the
// window surface on Wayland compositors without wp_pointer_warp_v1.
HostSurfaceCommitGate::MainThreadScope commit_scope(
ProcessHostSurfaceCommitGate());
if (relative_mode && !relative_mode_) {
SDL_GetMouseState(&capture_anchor_x_, &capture_anchor_y_);
capture_anchor_valid_ = true;
@@ -56,6 +56,8 @@ class WindowPointerCaptureOwner final {
bool cursor_visible() const { return cursor_visible_; }
private:
friend class WindowPointerCaptureOwnerTestPeer;
bool Apply(bool capture, bool cursor_visible);
PointerCaptureBackend* backend_ = nullptr;
+7 -1
View File
@@ -42,6 +42,7 @@ struct AAsset {
std::string path;
std::string apk_entry;
std::vector<unsigned char> data;
bool transcoded = false;
size_t offset = 0;
};
struct AAssetDir {};
@@ -341,7 +342,8 @@ AAsset* AAssetManager_open(AAssetManager* mgr, const char* filename,
return nullptr;
}
if (MocktailUsesDirectVulkan()) {
libc_shim::TranscodeEtc1SkyTextureForVulkan(path.c_str(), &asset->data);
asset->transcoded =
libc_shim::TranscodeEtc1SkyTextureForVulkan(path.c_str(), &asset->data);
}
if (AssetTraceEnabled()) {
std::fprintf(stderr, "[asset] open %s -> %s (%zu bytes)\n", filename,
@@ -455,6 +457,10 @@ int AAsset_openFileDescriptor(AAsset* asset, off_t* outStart,
if (asset == nullptr || asset->path.empty()) {
return -1;
}
// Force buffered reads: files still contain the original ETC1 bytes.
if (asset->transcoded) {
return -1;
}
int fd = -1;
off_t start = 0;
off_t descriptor_length = static_cast<off_t>(asset->data.size());
+54
View File
@@ -11,6 +11,13 @@ target_link_libraries(jnivm_test PRIVATE
GTest::gtest_main
)
add_executable(jnivm_fields_test jnivm_fields_test.cc)
target_link_libraries(jnivm_fields_test PRIVATE
Mocktail::LegacyJni
GTest::gtest_main
Threads::Threads
)
add_executable(runtime_config_test runtime_config_test.cc)
target_link_libraries(runtime_config_test PRIVATE
Mocktail::Runtime
@@ -22,6 +29,13 @@ target_link_libraries(fleasion_probe PRIVATE Mocktail::Services libc_shim)
mocktail_apply_compile_options(fleasion_probe)
find_program(MOCKTAIL_TEST_PYTHON python3)
find_program(MOCKTAIL_TEST_OPENSSL openssl)
if(MOCKTAIL_TEST_PYTHON)
add_test(NAME PayloadImportRollbackTest.PreservesPreviousInstallation
COMMAND ${MOCKTAIL_TEST_PYTHON}
${CMAKE_CURRENT_SOURCE_DIR}/payload_import_rollback_test.py)
set_tests_properties(PayloadImportRollbackTest.PreservesPreviousInstallation
PROPERTIES TIMEOUT 180)
endif()
if(MOCKTAIL_TEST_PYTHON AND MOCKTAIL_TEST_OPENSSL)
add_test(NAME FleasionIntegrationTest.ProxyAndTrust
COMMAND ${MOCKTAIL_TEST_PYTHON} ${CMAKE_CURRENT_SOURCE_DIR}/fleasion_integration_test.py
@@ -72,6 +86,12 @@ target_compile_definitions(native_update_test PRIVATE
MOCKTAIL_TEST_SOURCE_DIR="${PROJECT_SOURCE_DIR}"
)
add_executable(compatibility_catalog_test compatibility_catalog_test.cc)
target_link_libraries(compatibility_catalog_test PRIVATE
Mocktail::Update
GTest::gtest_main
)
add_executable(apk_provider_test apk_provider_test.cc)
target_link_libraries(apk_provider_test PRIVATE
Mocktail::Update
@@ -490,6 +510,16 @@ target_link_libraries(vulkan_surface_recovery_gate_test PRIVATE
)
mocktail_apply_compile_options(vulkan_surface_recovery_gate_test)
add_executable(host_surface_commit_gate_test
host_surface_commit_gate_test.cc
)
target_link_libraries(host_surface_commit_gate_test PRIVATE
mocktail_window
GTest::gtest_main
Threads::Threads
)
mocktail_apply_compile_options(host_surface_commit_gate_test)
add_executable(vulkan_present_progress_gate_test
vulkan_present_progress_gate_test.cc
)
@@ -539,6 +569,17 @@ target_link_libraries(texture_override_test PRIVATE GTest::gtest_main
PkgConfig::LIBPNG)
mocktail_apply_compile_options(texture_override_test)
add_executable(texture_resample_test
texture_resample_test.cc
${PROJECT_SOURCE_DIR}/src/graphics/texture_override.cc
)
target_include_directories(texture_resample_test PRIVATE
${PROJECT_SOURCE_DIR}/include
)
target_link_libraries(texture_resample_test PRIVATE GTest::gtest_main
PkgConfig::LIBPNG)
mocktail_apply_compile_options(texture_resample_test)
add_executable(chrome_trace_writer_test
chrome_trace_writer_test.cc
${PROJECT_SOURCE_DIR}/src/graphics/chrome_trace_writer.cc
@@ -978,11 +1019,13 @@ target_link_libraries(headless_graphics_test PRIVATE
include(GoogleTest)
gtest_discover_tests(jnivm_test)
gtest_discover_tests(jnivm_fields_test)
gtest_discover_tests(runtime_config_test)
gtest_discover_tests(runtime_config_file_test)
gtest_discover_tests(payload_update_preflight_test)
gtest_discover_tests(apk_provider_test)
gtest_discover_tests(native_update_test)
gtest_discover_tests(compatibility_catalog_test)
gtest_discover_tests(support_bundle_runtime_test)
gtest_discover_tests(vulkan_icd_selection_test)
gtest_discover_tests(session_log_test)
@@ -1036,9 +1079,12 @@ gtest_discover_tests(vulkan_present_observer_test)
gtest_discover_tests(input_pump_pacer_test)
gtest_discover_tests(input_pump_pacing_runtime_test)
gtest_discover_tests(vulkan_surface_recovery_gate_test)
gtest_discover_tests(host_surface_commit_gate_test)
gtest_discover_tests(vulkan_present_progress_gate_test)
gtest_discover_tests(vulkan_text_overlay_compositor_test)
gtest_discover_tests(etc2_decoder_test)
gtest_discover_tests(texture_override_test)
gtest_discover_tests(texture_resample_test)
gtest_discover_tests(chrome_trace_writer_test)
gtest_discover_tests(vulkan_etc2_emulation_test)
gtest_discover_tests(video_driver_policy_test)
@@ -1135,6 +1181,11 @@ add_test(
)
find_program(MOCKTAIL_PATCHELF_EXECUTABLE patchelf)
add_test(
NAME StandaloneWebkitCacheTest.RelocatesAllLoaderNames
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/standalone_webkit_cache_test.sh
${CMAKE_SOURCE_DIR}
)
if(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER AND MOCKTAIL_PATCHELF_EXECUTABLE)
add_test(
NAME PortablePackagingTest.RelocatableReadOnlyBundle
@@ -1142,6 +1193,9 @@ if(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER AND MOCKTAIL_PATCHELF_EXECUTABLE)
MOCKTAIL_PORTABLE_TEST_BUILD_DIR=${CMAKE_BINARY_DIR}
bash ${CMAKE_CURRENT_SOURCE_DIR}/portable_packaging_test.sh
)
# Copies and verifies a full JVM/WebKit bundle, unlike the unit tests.
set_tests_properties(PortablePackagingTest.RelocatableReadOnlyBundle
PROPERTIES TIMEOUT 600)
endif()
add_test(
+69 -13
View File
@@ -102,21 +102,69 @@ curl() {
}
pacman() {
[[ "$#" == 5 && "$1" == -U && "$2" == --noconfirm && "$3" == --needed ]]
if [[ "${SCENARIO}" == replaced ]]; then
cmp "${TEMP_DIR}/updated-ffmpeg" "$4"
else
cmp "${TEMP_DIR}/ffmpeg" "$4"
fi
cmp "${TEMP_DIR}/opus" "$5"
local downloads="${4%/*}"
local name
case "$1" in
-Qp)
# The stale scenario offers an ffmpeg older than the installed one.
name="${3##*/}"
name="${name%%-mini-*}"
if [[ "${SCENARIO}" == stale && "${name}" == ffmpeg ]]; then
printf '%s 1-1\n' "${name}"
else
printf '%s 2-1\n' "${name}"
fi
return 0 ;;
-Q)
printf '%s 2-1\n' "$3"
return 0 ;;
-S)
[[ "$2" == --noconfirm ]]
printf '%s\n' "${@:3}" >"${TEMP_DIR}/restored"
return 0 ;;
esac
[[ "$#" -ge 4 && "$1" == -U && "$2" == --noconfirm && "$3" == --needed ]]
local archive downloads="${4%/*}"
(cd "${downloads}" && sha256sum --check --strict SHA256SUMS)
cp "$4" "$5" "${downloads}/SHA256SUMS" "${downloads}/dependencies.tsv" \
for archive in "${@:4}"; do
case "${archive##*/}" in
"${FFMPEG}")
if [[ "${SCENARIO}" == replaced ]]; then
cmp "${TEMP_DIR}/updated-ffmpeg" "${archive}"
else
cmp "${TEMP_DIR}/ffmpeg" "${archive}"
fi ;;
"${OPUS}") cmp "${TEMP_DIR}/opus" "${archive}" ;;
*) Fail "unexpected archive: ${archive}" ;;
esac
cp "${archive}" "${TEMP_DIR}/${SCENARIO}/"
done
cp "${downloads}/SHA256SUMS" "${downloads}/dependencies.tsv" \
"${TEMP_DIR}/${SCENARIO}/"
touch "${TEMP_DIR}/installed"
}
export -f curl pacman Fail
vercmp() {
if [[ "$1" == "$2" ]]; then printf '0\n'; else printf -- '-1\n'; fi
}
# opus never resolves once its reduced package is installed. libadwaita-1
# stops resolving while the stale ffmpeg is installed.
pkg-config() {
case "$1" in
--list-package-names) printf 'ffmpeg\nlibadwaita-1\nopus\n' ;;
--exists)
if [[ "$2" == opus ]]; then
[[ ! -e "${TEMP_DIR}/${SCENARIO}/${OPUS}" ]]
return
fi
[[ "${SCENARIO}" != stale || "$2" != libadwaita-1 ||
! -e "${TEMP_DIR}/${SCENARIO}/${FFMPEG}" ||
-e "${TEMP_DIR}/restored" ]] ;;
*) Fail "unexpected pkg-config option: $1" ;;
esac
}
export -f curl pacman vercmp pkg-config Fail
export TEMP_DIR API FFMPEG OPUS SCENARIO TEST_RELEASE
ExpectRejectedSnapshot() {
@@ -190,7 +238,7 @@ for invalid_manifest in '../ffmpeg-mini-x86_64.pkg.tar.zst' \
fi
done
for SCENARIO in success replaced corrupted download-failure metadata-failure invalid-metadata; do
for SCENARIO in success replaced stale corrupted download-failure metadata-failure invalid-metadata; do
TEST_RELEASE="${TEMP_DIR}/release.json"
if [[ "${SCENARIO}" == invalid-metadata ]]; then
# A valid first row must not allow downloads when a later row is invalid.
@@ -206,13 +254,15 @@ for SCENARIO in success replaced corrupted download-failure metadata-failure inv
AnyLinuxDependenciesMain
' _ "${TEMP_DIR}" \
>"${TEMP_DIR}/${SCENARIO}.log" 2>&1; then
[[ "${SCENARIO}" == success || "${SCENARIO}" == replaced ]] ||
[[ "${SCENARIO}" == success || "${SCENARIO}" == replaced ||
"${SCENARIO}" == stale ]] ||
Fail "installed packages after ${SCENARIO}"
[[ -f "${TEMP_DIR}/installed" ]]
(cd "${TEMP_DIR}/${SCENARIO}" && sha256sum --check --strict SHA256SUMS)
rm -- "${TEMP_DIR}/installed"
else
if [[ "${SCENARIO}" == success || "${SCENARIO}" == replaced ]]; then
if [[ "${SCENARIO}" == success || "${SCENARIO}" == replaced ||
"${SCENARIO}" == stale ]]; then
cat -- "${TEMP_DIR}/${SCENARIO}.log" >&2
Fail "valid download failed: ${SCENARIO}"
fi
@@ -220,6 +270,8 @@ for SCENARIO in success replaced corrupted download-failure metadata-failure inv
fi
case "${SCENARIO}" in
success)
[[ ! -e "${TEMP_DIR}/restored" ]] ||
Fail 'restored a package that broke nothing'
[[ "$(wc -l <"${TEMP_DIR}/calls")" == 3 ]]
grep -Fq "${API}/assets/10" "${TEMP_DIR}/calls" ;;
replaced)
@@ -227,6 +279,10 @@ for SCENARIO in success replaced corrupted download-failure metadata-failure inv
grep -Fq "${API}/assets/11" "${TEMP_DIR}/calls"
grep -Fq "${API}/assets/11" "${TEMP_DIR}/replaced/dependencies.tsv"
grep -Fq "${API}/assets/21" "${TEMP_DIR}/replaced/dependencies.tsv" ;;
stale)
[[ "$(cat -- "${TEMP_DIR}/restored" 2>/dev/null)" == ffmpeg ]] ||
Fail 'kept a stale package that broke another package'
rm -- "${TEMP_DIR}/restored" ;;
corrupted)
grep -Fq 'dependency checksum verification failed' "${TEMP_DIR}/${SCENARIO}.log" ;;
download-failure)
+46
View File
@@ -11,6 +11,7 @@ readonly TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mocktail-anylinux-test.XXXXXX")"
trap 'rm -rf -- "${TEMP_DIR}"' EXIT
source "${ROOT}/scripts/package_anylinux.sh"
export UPINFO=""
mkdir -p -- "${TEMP_DIR}/fixtures"
# Compile the real adapter loader and updater canary. The fake runtime
@@ -79,6 +80,18 @@ if [[ "$1" == --make-appimage ]]; then
printf 'make-appimage\n' >>"${MOCKTAIL_PACKAGING_FIXTURES}/calls"
mkdir -p -- "${OUTPATH}"
cp -- "$(type -P true)" "${OUTPATH}/${OUTNAME}"
if [[ -n "${UPINFO:-}" ]]; then
printf '%s\n' "${UPINFO}" >"${OUTPATH}/update-info"
case "${MOCKTAIL_TEST_ZSYNC:-present}" in
present)
printf 'Filename: %s\nURL: %s\n\n\0checksum fixture' "${OUTNAME}" "${OUTNAME}" \
>"${OUTPATH}/${OUTNAME}.zsync"
cp -- "${OUTPATH}/${OUTNAME}.zsync" "${OUTPATH}/expected.zsync"
;;
empty) touch "${OUTPATH}/${OUTNAME}.zsync" ;;
missing) : ;;
esac
fi
exit 0
fi
[[ "$#" == 7 && "$1" == /usr/bin/mocktail &&
@@ -145,8 +158,41 @@ mkdir -p -- "${ANYLINUX_WORK}"
AnyLinuxVerifyInstalled() { :; }
AnyLinuxDeploy
[[ -x "${ANYLINUX_OUTPUT}" ]]
[[ ! -e "${ANYLINUX_OUTPUT}.zsync" ]]
[[ "$(<"${TEMP_DIR}/fixtures/calls")" == $'deploy-usr\nmake-appimage' ]]
for filename in mocktail-nightly.AppImage mocktail-nightly-aarch64.AppImage \
Mocktail-1.0.5-x86_64.AppImage; do
(
ANYLINUX_WORK="${TEMP_DIR}/updates/${filename}"
ANYLINUX_OUTPUT="${TEMP_DIR}/${filename}"
export UPINFO="gh-releases-zsync|owner|repository|continuous|${filename}.zsync"
if [[ "${filename}" == Mocktail-* ]]; then
UPINFO='gh-releases-zsync|owner|repository|latest|Mocktail-*-x86_64.AppImage.zsync'
fi
AnyLinuxDeploy
[[ "$(<"${ANYLINUX_WORK}/image/update-info")" == "${UPINFO}" ]]
cmp -- "${ANYLINUX_WORK}/image/expected.zsync" "${ANYLINUX_OUTPUT}.zsync"
grep -aFxq "URL: ${filename}" "${ANYLINUX_OUTPUT}.zsync"
[[ ! -e "${ANYLINUX_WORK}/image/${filename}.zsync" ]]
)
done
for sidecar in missing empty; do
if (
ANYLINUX_WORK="${TEMP_DIR}/updates/${sidecar}"
ANYLINUX_OUTPUT="${TEMP_DIR}/${sidecar}.AppImage"
export UPINFO="gh-releases-zsync|owner|repository|continuous|${sidecar}.AppImage.zsync"
export MOCKTAIL_TEST_ZSYNC="${sidecar}"
AnyLinuxDeploy
) >"${TEMP_DIR}/${sidecar}.log" 2>&1; then
printf 'packager accepted a %s .zsync file\n' "${sidecar}" >&2
exit 1
fi
grep -Fq 'no .zsync was generated' "${TEMP_DIR}/${sidecar}.log"
[[ ! -e "${TEMP_DIR}/${sidecar}.AppImage" ]]
done
mv -- "${ANYLINUX_WORK}/AppDir" "${TEMP_DIR}/relocated AppDir"
app_dir="${TEMP_DIR}/relocated AppDir"
AnyLinuxVerifyAppDir "${app_dir}"
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more