Compare commits

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

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

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

Fixes #169
2026-09-24 23:02:48 -05:00
vii 5a27aa0366 fix(runtime): build engine pump rest on aarch64 hosts 2026-09-24 18:01:41 -05:00
vii c99a55dc54 chore(merge): merge upstream komaruworld/mocktail main
Brings in aarch64 host support (#152, #162, #167), natural scrolling (#163)
and the arm64 CI build job. Packaging, Pages and README keep the Nightcap
x86_64 side; upstream's aarch64 bundles are not published.
2026-09-24 17:44:28 -05:00
珍惜andHttpAnimations 30d50f15d1 Fix catalog unable to tell arm64 from x86_64 profiles for the same version. (#167)
Co-authored-by: HttpAnimations <shawndouglasyt@gmail.com>
2026-09-24 20:12:18 +03:00
komaruworld ab940acfb5 add arm64 ci with build
fog, update faq
2026-09-24 19:03:22 +03:00
farwhile 719b1fdd22 fix/respect host natural scrolling direction (#163) 2026-09-23 18:31:41 +03:00
珍惜andHttpAnimations 1fa2e116b3 Fix crashing on x86 hosts (#162)
Co-authored-by: HttpAnimations <shawndouglasyt@gmail.com>
2026-09-23 12:04:53 +03:00
vii 35359f3575 perf(etc2): carve upload staging out of shared blocks
Each ETC2 upload created and destroyed its own staging buffer on the render
thread. Uploads now take ranges of 16 MiB blocks, reused once every range
is released; four empty blocks are kept. Profile traces gain per-frame CPU
time counters and slices for staging and driver memory calls.
2026-09-22 21:31:58 -05:00
珍惜andHttpAnimations 40237fae72 arm 64 (#152)
Co-authored-by: HttpAnimations <shawndouglasyt@gmail.com>
2026-09-21 20:09:37 +03:00
vii ad6a3c3147 docs(readme): match the DEB filename that CPack produces 2026-09-20 13:06:45 -05:00
vii d982cd9506 Merge pull request #2 from CoderDayton/release/0.3.1
Release 0.3.1: packaging, input and shutdown fixes
2026-09-20 12:53:44 -05:00
vii 65adb3bddc fix(packaging): fail the release when a package glob matches nothing
An unmatched glob reached sha256sum and gh as literal text. The publish job
now collects the assets into an array and stops if a package is missing.
2026-09-20 12:33:26 -05:00
vii 47e23df2e0 docs(changelog): release 0.3.1 2026-09-20 12:31:39 -05:00
vii c0ff1842ee docs(changelog): record the packaging, input and shutdown fixes 2026-09-20 12:30:00 -05:00
vii e073947b91 fix(window): keep the pointer capture owner alive across shutdown
Cross-thread callers take a strong reference under a mutex, so a shutdown
racing SetGameSessionActive no longer frees the owner mid-call.
2026-09-20 12:26:39 -05:00
vii 66de662e5b fix(input): decide pointer capture from state, not coordinates
The router inferred relative mode from SDL reporting zero absolute
coordinates. X11 does; Wayland reports a position that accumulates the
deltas and leaves the window, so every camera-drag event took the
absolute branch and walked Roblox's cursor to the viewport edge.

Motion, button and wheel events now carry the window's relative mouse
mode, and the RMB camera-drag fallback only captures inside an
experience so the app surface keeps a free cursor.
2026-09-20 12:05:48 -05:00
vii 6c3631058c chore(docs): ignore the working plans directory 2026-09-20 12:05:47 -05:00
vii 6829fd00ed perf(graphics): decode ETC2 uploads off the submitting thread
Uploads decode on a background dispatcher and the submit carries a
timeline semaphore wait, so the GPU waits for the texture rather than
the render thread. The gather of the application's compressed bytes
moves there too when they are already mapped; unmapping or freeing that
memory drains the decodes still reading it.

Staged copies gain an explicit host-write barrier. A queue submission
only makes host writes from before it visible to the device, and these
writes land after the submit.

Devices without timelineSemaphore, and submits whose pNext already sizes
arrays by their semaphore counts, decode inline as before.

Blade Ball, median of three: worst vkQueueSubmit 140ms -> 1ms, p99
4.9ms -> 0.05ms, total 2534ms -> 309ms. Frame interval unchanged; its
tail is the engine's own main-thread step.
2026-09-19 18:02:16 -05:00
vii 8be3618cd5 feat(packaging): ship DEB, RPM and pacman packages with each release
The release workflow now builds the three native packages beside the
AppImage and attaches them to the GitHub release with a checksum each.
Nothing is pushed to a distribution repository.

Packages are named nightcap and take their version from the release tag
instead of upstream's project version, so they no longer claim to be
Mocktail 1.0.4. Each variant conflicts with the others because they all own
/usr/bin/mocktail.

The AUR workflow no longer clones or pushes aur.archlinux.org; it refreshes
nightcap-git metadata on demand and uploads it as an artifact. The recipes
move to packaging/aur/nightcap{,-bin,-git}, point at this repository, and
version from the newest release tag.
2026-09-19 01:12:12 -05:00
vii 265b476440 docs(packaging): list Nightcap releases in the AppStream metadata
The releases block carried upstream's 1.0.x history, so a software centre
read the app as newer than every Nightcap tag and would never offer 0.3.x as
an update. Describe the 0.x line instead, and name Nightcap in the project
description.
2026-09-19 01:11:16 -05:00
vii ff905fe5b7 test(input): pin the caret position before a trailing space
SDL_ttf marks only the zero-length substring past the end of the text with
TTF_SUBSTRING_TEXT_END, so a caret left of a trailing space keeps the cluster
position. Cover both carets so the end-of-text branch cannot swallow the one
beside it.
2026-09-19 01:10:54 -05:00
vii 02108a9bb1 perf(input): reuse the TextBox overlay fonts across rasters
Every keystroke re-ran TTF_Init, a fontconfig sort, six TTF_OpenFont calls
and TTF_Quit on the render thread. Hold the opened fonts on the overlay and
reopen them only when the Roblox font or the point size changes, keeping the
current set when a reopen fails. Measured over 100 rasters, 1.59 ms to
0.12 ms per keystroke.
2026-09-19 00:32:31 -05:00
vii 7dd32335ca fix(input): advance the TextBox caret past trailing whitespace
The final cluster rect SDL_ttf reports stops at the last inked glyph, so a
caret at the end of a single-line field did not move when a trailing space
was typed. Use the laid-out width there; wrapped layouts keep the clamp
because their width is the widest line rather than the last one.
2026-09-19 00:32:31 -05:00
vii 5ed6ed5e84 chore(packaging): declare the libpng dependency
Texture overrides decode PNG through libpng, which stubs/CMakeLists.txt
already requires. Lists it in the Arch, AUR, and Nix packages, the thin
dependency installer, and the README build instructions.
2026-09-18 19:30:58 -05:00
vii cef1b3ec4a Merge upstream komaruworld/mocktail into main 2026-09-18 19:29:24 -05:00
komaruworld 854969457f Fix textures and device info from Nightcap
Ported from CoderDayton/nightcap
2026-09-18 16:55:29 +03:00
167 changed files with 12654 additions and 2720 deletions

No files matched your search

+5 -5
View File
@@ -11,14 +11,14 @@ body:
value: |
Please include enough detail for someone else to reproduce the bug.
Use any language you are comfortable with. The maintainer can read Russian and English directly, so either is preferred. If you are more comfortable in another language, use it.
Use any language you are comfortable with.
Please write issues and pull requests yourself. Do not use AI-generated text.
Please write issues and pull requests yourself.
- type: input
id: version
attributes:
label: Mocktail version
label: Nightcap version
description: Provide the release version, package version, or commit hash.
placeholder: "For example: v0.4.0 or 1a2b3c4"
validations:
@@ -37,7 +37,7 @@ body:
id: installation
attributes:
label: Installation method
description: Tell us how Mocktail was installed or built.
description: Tell us how Nightcap was installed or built.
placeholder: "For example: AppImage, Flatpak, AUR, or built from source"
validations:
required: true
@@ -56,7 +56,7 @@ body:
label: Steps to reproduce
description: List the smallest reliable sequence of actions that triggers the problem.
placeholder: |
1. Start Mocktail with ...
1. Start Nightcap with ...
2. Open ...
3. Select ...
4. Observe ...
+5 -4
View File
@@ -1,5 +1,6 @@
# Modified by vii from komaruworld/mocktail. See README "About this fork".
name: Feature request
description: Suggest an improvement or a new capability for Mocktail
description: Suggest an improvement or a new capability for Nightcap
title: "[Feature]: "
labels:
- enhancement
@@ -10,9 +11,9 @@ body:
value: |
Describe what you want to change and why it would be useful.
Use any language you are comfortable with. The maintainer can read Russian and English directly, so either is preferred. If you are more comfortable in another language, use it.
Use any language you are comfortable with.
Please write issues and pull requests yourself. Do not use AI-generated text.
Please write issues and pull requests yourself.
- type: textarea
id: problem
@@ -26,7 +27,7 @@ body:
id: proposal
attributes:
label: Proposed solution
description: Describe how you would like Mocktail to behave.
description: Describe how you would like Nightcap to behave.
validations:
required: true
+2 -1
View File
@@ -1,3 +1,4 @@
<!-- Briefly describe what this pull request adds, changes, or removes. Write it yourself and do not use AI-generated text. Link the issue with "Closes #123" or "Fixes #123". -->
<!-- Modified by vii from komaruworld/mocktail. See README "About this fork". -->
<!-- Briefly describe what this pull request adds, changes, or removes. Link the issue with "Closes #123" or "Fixes #123". -->
Closes #
+16 -74
View File
@@ -1,8 +1,6 @@
name: Publish AUR packages
name: Check AUR packages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
@@ -13,8 +11,8 @@ concurrency:
cancel-in-progress: true
jobs:
publish:
name: Publish AUR packages
metadata:
name: Refresh AUR metadata
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
@@ -27,10 +25,9 @@ jobs:
shell: bash
run: |
set -Eeuo pipefail
package_dir="$GITHUB_WORKSPACE/packaging/aur/mocktail-git"
version="$(sed -n \
's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' \
CMakeLists.txt | head -n 1)"
package_dir="$GITHUB_WORKSPACE/packaging/aur/nightcap-git"
version="$(git describe --tags --abbrev=0 --match='v[0-9]*')"
version="${version#v}"
[[ "$version" =~ ^[0-9]+(\.[0-9]+)*$ ]]
pkgver="${version}.r$(git rev-list --count HEAD).g$(git rev-parse --short=7 HEAD)"
sed -i "s/^pkgver=.*/pkgver=${pkgver}/" "$package_dir/PKGBUILD"
@@ -39,76 +36,21 @@ jobs:
--volume "$package_dir:/pkg" \
archlinux:base-devel bash -Eeuo pipefail -c '
useradd --create-home builder
install -d -o builder -g builder /tmp/mocktail-git
install -d -o builder -g builder /tmp/nightcap-git
install -o builder -g builder -m 0644 \
/pkg/PKGBUILD /tmp/mocktail-git/PKGBUILD
/pkg/PKGBUILD /tmp/nightcap-git/PKGBUILD
runuser -u builder -- bash -c \
"cd /tmp/mocktail-git && makepkg --printsrcinfo" \
"cd /tmp/nightcap-git && makepkg --printsrcinfo" \
> /pkg/.SRCINFO
'
grep -Fqx "pkgver=${pkgver}" "$package_dir/PKGBUILD"
grep -Fqx $'\tpkgver = '"${pkgver}" "$package_dir/.SRCINFO"
printf 'AUR_PKGVER=%s\n' "$pkgver" >> "$GITHUB_ENV"
- name: Publish to the AUR
shell: bash
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
AUR_SSH_KEY_PASSPHRASE: ${{ secrets.AUR_SSH_KEY_PASSPHRASE }}
run: |
set -Eeuo pipefail
[[ -n "$AUR_SSH_PRIVATE_KEY" ]]
[[ -n "$AUR_SSH_KEY_PASSPHRASE" ]]
private_key="$RUNNER_TEMP/aur_ed25519"
askpass="$RUNNER_TEMP/aur_askpass"
known_hosts="$RUNNER_TEMP/aur_known_hosts"
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$private_key"
unset AUR_SSH_PRIVATE_KEY
chmod 0600 "$private_key"
printf '%s\n' \
'#!/usr/bin/env bash' \
'printf '\''%s\n'\'' "$AUR_SSH_KEY_PASSPHRASE"' \
> "$askpass"
chmod 0700 "$askpass"
eval "$(ssh-agent -s)"
trap 'ssh-agent -k >/dev/null; rm -f -- "$private_key" "$askpass"' EXIT
DISPLAY=:0 SSH_ASKPASS="$askpass" SSH_ASKPASS_REQUIRE=force \
ssh-add "$private_key" </dev/null
unset AUR_SSH_KEY_PASSPHRASE
ssh-keyscan -T 10 -t ed25519 aur.archlinux.org > "$known_hosts"
fingerprint="$(ssh-keygen -lf "$known_hosts" -E sha256 | awk '{print $2}')"
[[ "$fingerprint" == \
'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4' ]]
export GIT_SSH_COMMAND="ssh -F /dev/null -i $private_key \
-o BatchMode=yes -o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=$known_hosts"
publish_aur_package() {
local package_name="$1"
local commit_message="$2"
local source_dir="packaging/aur/$package_name"
local aur_dir="$RUNNER_TEMP/${package_name}-aur"
git clone --branch master --single-branch \
"ssh://aur@aur.archlinux.org/${package_name}.git" "$aur_dir"
install -m 0644 "$source_dir/PKGBUILD" "$aur_dir/PKGBUILD"
install -m 0644 "$source_dir/.SRCINFO" "$aur_dir/.SRCINFO"
git -C "$aur_dir" config user.name komaruworld
git -C "$aur_dir" config user.email rtysus228@gmail.com
git -C "$aur_dir" add PKGBUILD .SRCINFO
if git -C "$aur_dir" diff --cached --quiet; then
printf '%s is already published\n' "$package_name"
return
fi
git -C "$aur_dir" commit -m "$commit_message"
git -C "$aur_dir" push origin HEAD:master
}
publish_aur_package mocktail-git "Update to ${AUR_PKGVER}"
publish_aur_package mocktail 'make builds stable'
publish_aur_package mocktail-bin 'make builds stable'
- name: Upload AUR metadata
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Nightcap-AUR
path: packaging/aur/nightcap-git/.SRCINFO
if-no-files-found: error
retention-days: 14
+61
View File
@@ -8,22 +8,32 @@ on:
- 'CMakeLists.txt'
- 'Makefile'
- 'README.md'
- '.gitmodules'
- 'cmake/**'
- 'config/**'
- 'include/**'
- 'packaging/**'
- 'scripts/**'
- 'src/**'
- 'stubs/**'
- 'tests/**'
- 'third_party/**'
pull_request:
paths:
- '.github/workflows/**'
- 'CMakeLists.txt'
- 'Makefile'
- 'README.md'
- '.gitmodules'
- 'cmake/**'
- 'config/**'
- 'include/**'
- 'packaging/**'
- 'scripts/**'
- 'src/**'
- 'stubs/**'
- 'tests/**'
- 'third_party/**'
workflow_dispatch:
permissions:
@@ -65,3 +75,54 @@ jobs:
bash tests/flatpak_packaging_test.sh .
bash tests/native_packaging_test.sh .
bash tests/anylinux_dependencies_test.sh .
native-build:
name: Native build (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-26.04
- arch: aarch64
runner: ubuntu-26.04-arm
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: recursive
- name: Install build dependencies
shell: bash
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends \
build-essential binutils cmake git ninja-build pkg-config lld \
libsdl3-dev libsdl3-ttf-dev libcurl4-openssl-dev libssl-dev \
nlohmann-json3-dev libyaml-dev libelf-dev libminizip-dev \
libcapstone-dev libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
libutf8proc-dev libfontconfig1-dev libegl-dev libvulkan-dev \
libplacebo-dev libpng-dev zlib1g-dev \
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=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
+174 -4
View File
@@ -126,10 +126,153 @@ jobs:
if-no-files-found: error
retention-days: 14
deb:
name: DEB (Ubuntu 26.04)
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
submodules: recursive
- name: Build DEB
shell: bash
run: |
set -Eeuo pipefail
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
--env MOCKTAIL_PACKAGE_VERSION="$(./scripts/package_version.sh)" \
ubuntu:26.04 bash -Eeuo pipefail -c '
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install --yes --no-install-recommends \
build-essential cmake git ninja-build pkg-config lld fakeroot \
dpkg-dev file libsdl3-dev libsdl3-ttf-dev \
libcurl4-openssl-dev libssl-dev nlohmann-json3-dev \
libyaml-dev libelf-dev libminizip-dev libcapstone-dev \
libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
libutf8proc-dev libfontconfig1-dev libegl-dev \
libvulkan-dev libplacebo-dev zlib1g-dev libpng-dev
cmake -S . -B build-deb -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DMOCKTAIL_PACKAGE_NAME=nightcap \
-DMOCKTAIL_PACKAGE_VERSION="$MOCKTAIL_PACKAGE_VERSION" \
-DBUILD_TESTING=OFF
cmake --build build-deb --parallel 4
cpack --config build-deb/CPackConfig.cmake \
-G DEB -B dist/deb
dpkg-deb --info dist/deb/*.deb >/dev/null
'
- name: Upload DEB
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Nightcap-DEB
path: dist/deb/*.deb
if-no-files-found: error
retention-days: 14
rpm:
name: RPM (Fedora 44)
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
submodules: recursive
- name: Build RPM
shell: bash
run: |
set -Eeuo pipefail
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
--env MOCKTAIL_PACKAGE_VERSION="$(./scripts/package_version.sh)" \
fedora:44 bash -Eeuo pipefail -c '
dnf install --assumeyes \
gcc-c++ cmake git ninja-build pkgconf-pkg-config lld rpm-build \
SDL3-devel SDL3_ttf-devel libcurl-devel openssl-devel \
nlohmann-json-devel libyaml-devel elfutils-libelf-devel \
minizip-ng-compat-devel capstone-devel gtk4-devel libadwaita-devel \
webkitgtk6.0-devel utf8proc-devel fontconfig-devel \
libglvnd-devel vulkan-headers vulkan-loader-devel \
libplacebo-devel zlib-ng-compat-devel libpng-devel
cmake -S . -B build-rpm -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_INSTALL_LIBDIR=lib \
-DMOCKTAIL_PACKAGE_NAME=nightcap \
-DMOCKTAIL_PACKAGE_VERSION="$MOCKTAIL_PACKAGE_VERSION" \
-DBUILD_TESTING=OFF
cmake --build build-rpm --parallel 4
cpack --config build-rpm/CPackConfig.cmake \
-G RPM -B dist/rpm
rpm --query --package --info dist/rpm/*.rpm >/dev/null
'
- name: Upload RPM
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Nightcap-RPM
path: dist/rpm/*.rpm
if-no-files-found: error
retention-days: 14
arch:
name: Pacman package (Arch Linux)
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
submodules: recursive
- name: Build pacman package
shell: bash
run: |
set -Eeuo pipefail
version="$(./scripts/package_version.sh)"
sed -i "s/^pkgver=.*/pkgver=${version}/" packaging/arch/PKGBUILD
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
pacman -Syu --needed --noconfirm \
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
nlohmann-json libyaml libelf minizip capstone gtk4 \
libadwaita webkitgtk-6.0 libutf8proc fontconfig libpng \
libplacebo vulkan-headers vulkan-icd-loader libglvnd zlib
useradd --create-home builder
chown -R builder:builder /workspace
runuser -u builder -- env MOCKTAIL_SOURCE_DIR=/workspace \
makepkg --dir /workspace/packaging/arch \
--noconfirm --cleanbuild --force
install -d dist/arch
cp packaging/arch/*.pkg.tar.zst dist/arch/
pacman -Qp dist/arch/*.pkg.tar.zst >/dev/null
'
- name: Upload pacman package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Nightcap-Arch
path: dist/arch/*.pkg.tar.zst
if-no-files-found: error
retention-days: 14
publish:
name: Publish GitHub release
if: startsWith(github.ref, 'refs/tags/v')
needs: [appimage]
needs: [appimage, deb, rpm, arch]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
@@ -138,6 +281,13 @@ jobs:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download packages
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: 'Nightcap-*'
merge-multiple: true
path: release
- name: Download AppImage
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
@@ -152,7 +302,28 @@ jobs:
set -Eeuo pipefail
tag="${GITHUB_REF_NAME}"
mv release/Mocktail-x86_64.AppImage release/Nightcap-x86_64.AppImage
(cd release && sha256sum Nightcap-x86_64.AppImage > Nightcap-x86_64.AppImage.sha256)
# An unmatched glob would otherwise reach sha256sum and gh as its own
# literal text, so every package pattern has to match before upload.
shopt -s nullglob
assets=(Nightcap-x86_64.AppImage)
for glob in '*.deb' '*.rpm' '*.pkg.tar.zst'; do
matched=(release/$glob)
if [[ ${#matched[@]} -eq 0 ]]; then
echo "::error::no ${glob} package among the release artifacts"
exit 1
fi
assets+=("${matched[@]#release/}")
done
uploads=()
for asset in "${assets[@]}"; do
uploads+=("release/$asset" "release/$asset.sha256")
done
(
cd release
for asset in "${assets[@]}"; do
sha256sum "$asset" > "$asset.sha256"
done
)
./scripts/release_notes.sh "$tag" > release/notes.md
# The Flatpak workflow creates the same release; whichever job
# arrives second sees "already exists" and continues.
@@ -164,5 +335,4 @@ jobs:
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" \
--title "Nightcap $tag" --notes-file release/notes.md
gh release upload "$tag" --repo "$GITHUB_REPOSITORY" --clobber \
release/Nightcap-x86_64.AppImage \
release/Nightcap-x86_64.AppImage.sha256
"${uploads[@]}"
+3
View File
@@ -177,3 +177,6 @@ lsan.log*
# Test fixtures
!/third_party/mcpelauncher-linker/core/libziparchive/**/*.zip
!/third_party/mcpelauncher-linker/core/libziparchive/**/*.apk
# Working plans, not part of the shipped documentation
/docs/plans/
+149 -1
View File
@@ -6,6 +6,151 @@ GitHub are taken from the matching section here.
## [Unreleased]
## [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.
- The Flatpak build now detects an NVIDIA driver from inside its sandbox, so it
uses X11 instead of native Wayland where both are available. This avoids
Wayland hangs and display disconnects.
## [0.3.2] - 2026-09-29
### Added
- Performance recordings made with `--profile` now show how busy the CPU was
during each frame, which makes it easier to tell what caused a stutter.
- Experimental support for ARM64 (`aarch64`) Linux, running Roblox's Android
`arm64-v8a` client.
### Fixed
- When Roblox asks for a Google Play device check during login, Nightcap now
answers right away that it isn't supported. Before, the "Verifying you're
not a bot" screen waited about a minute for an answer and then sent you
back to the login screen.
- Solving a login challenge now closes its window. Before, Roblox's
"challenge completed" signal was ignored and the window stayed open.
Thanks to @glook9001.
- Login no longer stalls after you pass a verification check. Before, Roblox
only saw the page's "challenge completed" signal after a one-minute
timeout, so it gave up and the login failed. On a Roblox build Nightcap
can't handle this way, the log says "WebViewProtocol getter not
recognized" and web pages behave as before.
- Login check windows and other web pages get 5 seconds to start instead of
3, so a slow start no longer closes them. The log says how long each one
took to open.
- Less stutter while joining a game and while textures load in. In Blade
Ball, loading hitches dropped by about half, and the worst one went from
0.8 seconds to 0.55 seconds.
- Clicking Play on a private server now joins right away. Before, nothing
happened until you clicked a sidebar tab like Home.
- Middle click now reaches Roblox as the middle mouse button.
- Scrolling follows your system's natural scrolling setting.
- Fixed a crash on some x86_64 hosts when Roblox asked about a thread other
than the one making the call.
- Roblox updates no longer mix up the ARM64 and x86_64 compatibility profiles
for the same version.
- A partial or out-of-range user id in `MOCKTAIL_ROBLOX_USER_ID` or
`MOCKTAIL_LOCAL_STORAGE_CURRENT_USER_ID` is now ignored instead of being
partly read.
### Changed
- `make flatpak` and `scripts/build_flatpak.sh` now build with 2 jobs by
default instead of 4, so a local Flatpak build is less likely to run out
of memory. Set `FLATPAK_JOBS` or `MOCKTAIL_FLATPAK_JOBS` to use more.
Thanks to @glook9001.
## [0.3.1] - 2026-09-20
### Added
- 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.
### 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.
## [0.3.0] - 2026-09-18
### Added
@@ -135,7 +280,10 @@ First Nightcap release, forked from komaruworld/mocktail.
- The AppImage build no longer fails on stale checksums for the slimmed
Arch packages.
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.0...HEAD
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.3...HEAD
[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
[0.2.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.2.0
[0.1.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.1.0
+39 -21
View File
@@ -10,7 +10,7 @@
cmake_minimum_required(VERSION 3.20)
project(Mocktail
VERSION 1.0.4
DESCRIPTION "Android x86-64 Roblox compatibility runtime for Linux"
DESCRIPTION "Android Roblox compatibility runtime for Linux"
LANGUAGES CXX
)
@@ -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,9 +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
@@ -385,7 +392,8 @@ add_library(mocktail_webview_helper_policy STATIC
src/webview/webview_helper_policy.cc
)
add_library(Mocktail::WebViewHelperPolicy ALIAS mocktail_webview_helper_policy)
target_include_directories(mocktail_webview_helper_policy PUBLIC src)
target_include_directories(mocktail_webview_helper_policy PUBLIC src
PRIVATE include)
target_link_libraries(mocktail_webview_helper_policy PUBLIC
PkgConfig::WEBKITGTK6
nlohmann_json::nlohmann_json
@@ -472,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
@@ -650,6 +658,11 @@ target_link_options(mocktail PRIVATE
)
# Native FreeBSD helper for Linuxulator's unsupported DF socket option.
# The helper is x86-64 assembly; other host architectures have nothing to
# assemble.
if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64|AMD64)$")
set(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER OFF)
endif()
option(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER
"Build the native FreeBSD Linuxulator socket helper" ON)
if(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER)
@@ -786,33 +799,39 @@ install(CODE "
endif()
")
set(MOCKTAIL_PACKAGE_NAMES mocktail mocktail-nightly nightcap nightcap-nightly)
set(MOCKTAIL_PACKAGE_NAME "mocktail" CACHE STRING
"Native package name"
)
set(MOCKTAIL_PACKAGE_VERSION "${PROJECT_VERSION}" CACHE STRING
"Native package version"
)
set(MOCKTAIL_PACKAGE_VERSION_SUFFIX "" CACHE STRING
"Native package version suffix"
)
if(NOT MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail" AND
NOT MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail-nightly")
if(NOT MOCKTAIL_PACKAGE_NAME IN_LIST MOCKTAIL_PACKAGE_NAMES)
string(JOIN ", " MOCKTAIL_PACKAGE_NAMES_TEXT ${MOCKTAIL_PACKAGE_NAMES})
message(FATAL_ERROR
"MOCKTAIL_PACKAGE_NAME must be mocktail or mocktail-nightly")
"MOCKTAIL_PACKAGE_NAME must be one of ${MOCKTAIL_PACKAGE_NAMES_TEXT}")
endif()
set(CPACK_PACKAGE_NAME "${MOCKTAIL_PACKAGE_NAME}")
set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}${MOCKTAIL_PACKAGE_VERSION_SUFFIX}")
set(CPACK_PACKAGE_VENDOR "Mocktail Project")
set(CPACK_PACKAGE_CONTACT "komaruworld <rtysus228@gmail.com>")
set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/komaruworld/mocktail")
set(CPACK_PACKAGE_VERSION
"${MOCKTAIL_PACKAGE_VERSION}${MOCKTAIL_PACKAGE_VERSION_SUFFIX}"
)
set(CPACK_PACKAGE_VENDOR "Nightcap")
set(CPACK_PACKAGE_CONTACT "vii <coderdayton14@gmail.com>")
set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/CoderDayton/nightcap")
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}")
set(CPACK_PACKAGE_DESCRIPTION
"Runs the Android x86-64 Roblox client on Linux."
"Runs the Android Roblox client on Linux."
)
set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/LICENSE")
set(CPACK_PACKAGING_INSTALL_PREFIX "/usr")
set(CPACK_STRIP_FILES TRUE)
set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT)
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "komaruworld <rtysus228@gmail.com>")
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "vii <coderdayton14@gmail.com>")
set(CPACK_DEBIAN_PACKAGE_SECTION "games")
set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional")
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
@@ -822,15 +841,14 @@ set(CPACK_RPM_PACKAGE_LICENSE "Apache-2.0")
set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games")
set(CPACK_RPM_PACKAGE_AUTOREQPROV ON)
if(MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail-nightly")
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "mocktail")
set(CPACK_DEBIAN_PACKAGE_REPLACES "mocktail")
set(CPACK_RPM_PACKAGE_CONFLICTS "mocktail")
else()
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "mocktail-nightly")
set(CPACK_DEBIAN_PACKAGE_REPLACES "mocktail-nightly")
set(CPACK_RPM_PACKAGE_CONFLICTS "mocktail-nightly")
endif()
# Every variant installs the same /usr/bin/mocktail and /usr/share/mocktail,
# so only one of them can be present at a time.
set(MOCKTAIL_PACKAGE_SIBLINGS ${MOCKTAIL_PACKAGE_NAMES})
list(REMOVE_ITEM MOCKTAIL_PACKAGE_SIBLINGS "${MOCKTAIL_PACKAGE_NAME}")
string(JOIN ", " MOCKTAIL_PACKAGE_CONFLICTS ${MOCKTAIL_PACKAGE_SIBLINGS})
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "${MOCKTAIL_PACKAGE_CONFLICTS}")
set(CPACK_DEBIAN_PACKAGE_REPLACES "${MOCKTAIL_PACKAGE_CONFLICTS}")
set(CPACK_RPM_PACKAGE_CONFLICTS "${MOCKTAIL_PACKAGE_CONFLICTS}")
include(CPack)
add_dependencies(mocktail all_stubs)
+12
View File
@@ -42,6 +42,18 @@ attach `latest.log`:
For native installs, a custom `$XDG_STATE_HOME` replaces `~/.local/state`.
## Can I run multiple Roblox instances?
No. Nightcap won't add multi-instance launching. It would mean working around
Roblox's restrictions on running several clients. The [Roblox Terms of Use](https://en.help.roblox.com/hc/en-us/articles/115004647846-Roblox-Terms-of-Use)
prohibit bypassing technical protections, and violating the terms can get your
account suspended.
Bloxstrap brought the option back in [v2.9.0](https://github.com/bloxstraplabs/bloxstrap/releases/tag/v2.9.0)
with a "use at your own risk" warning, then removed it in
[v2.10.0](https://github.com/bloxstraplabs/bloxstrap/releases/tag/v2.10.0)
after Roblox added measures against running multiple clients.
## Can I play in VR?
VR is experimental and lives on upstream's `vr` branch, which does not
+3 -3
View File
@@ -21,7 +21,7 @@ ANYLINUX_APPIMAGETOOL ?= appimagetool
PREFIX ?= /usr
FLATPAK_BUILD_DIR ?= build-flatpak
FLATPAK_MANIFEST ?= packaging/flatpak/io.github.CoderDayton.nightcap.json
FLATPAK_JOBS ?= 4
FLATPAK_JOBS ?= 2
define build_native_runtime
@git submodule update --init --recursive
@@ -130,8 +130,8 @@ auto-run: ## Build/run loop with crash summaries
run-gles: build ## Run strict system EGL/OpenGL ES 3 without Vulkan fallback
@$(BINARY) --graphics opengl
run-angle: ## Run with ANGLE/Vulkan compatibility backend
@MOCKTAIL_GRAPHICS_BACKEND=angle-vulkan ./scripts/run_sober.sh
run-angle: build ## Run with ANGLE/Vulkan compatibility backend
@$(BINARY) --graphics angle-vulkan
update-roblox: ## Validate and import the current x86_64 Roblox bundle cached by Sober
@./scripts/update_roblox_payload.sh
+46 -14
View File
@@ -1,9 +1,14 @@
<!-- Modified by vii from komaruworld/mocktail. See "About this fork" below. -->
# Nightcap
[![CI](https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml/badge.svg)](https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml)
[![Stars](https://img.shields.io/github/stars/CoderDayton/nightcap?style=flat&logo=github)](https://github.com/CoderDayton/nightcap/stargazers)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
<div align="center">
<img src="https://raw.githubusercontent.com/CoderDayton/nightcap/main/packaging/io.github.CoderDayton.nightcap.svg" alt="Nightcap logo" width="128">
<h1 style="margin-top: 5px;">
Nightcap
<br>
<a href="https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml"><img src="https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/CoderDayton/nightcap/stargazers"><img src="https://img.shields.io/github/stars/CoderDayton/nightcap?style=flat&logo=github" alt="Stars"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License"></a>
</h1>
</div>
Nightcap is Mocktail, tuned for playing on a real PC. It runs the Android
`x86_64` Roblox client on Linux, including a Linux userspace hosted by
@@ -61,10 +66,24 @@ chmod +x Nightcap-x86_64.AppImage
./Nightcap-x86_64.AppImage
```
Or build and install it from source using the steps under
[Building](#building). There are no AUR, DEB, or RPM packages of Nightcap.
Upstream ships those for unmodified Mocktail, see
[komaruworld/mocktail](https://github.com/komaruworld/mocktail#readme).
## Distribution packages
Each release also carries a DEB, an RPM, and a pacman package, with a
`.sha256` beside every file on the
[releases page](https://github.com/CoderDayton/nightcap/releases/latest):
```bash
sudo apt install ./nightcap_<version>_amd64.deb
sudo dnf install ./nightcap-<version>-1.x86_64.rpm
sudo pacman -U nightcap-<version>-1-x86_64.pkg.tar.zst
```
They install `/usr/bin/mocktail`, so they conflict with upstream's `mocktail`
package. Nightcap is not in any distribution repository or the AUR; the
recipes under `packaging/aur` are kept for local builds only.
Or build and install from source using the steps under
[Building](#building).
## How it works
@@ -76,6 +95,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>
@@ -116,8 +141,9 @@ Linux `x86_64` is supported. Experimental FreeBSD 15.1 Linuxulator support has
been tested with an `x86_64` Fedora 44 userspace. On FreeBSD, Nightcap runs
inside Linuxulator; it is not a native FreeBSD binary. Building requires CMake
3.20+, Git, pkg-config, LLD, binutils, a C++17 compiler, SDL 3.4+, SDL3_ttf,
Vulkan, EGL, libplacebo, fontconfig, libcurl, OpenSSL, libelf, libyaml, minizip,
Capstone 5, utf8proc, nlohmann/json, GTK4, libadwaita 1.6+, and WebKitGTK 6.0.
Vulkan, EGL, libplacebo, fontconfig, libcurl, OpenSSL, libelf, libyaml, libpng,
minizip, Capstone 5, utf8proc, nlohmann/json, GTK4, libadwaita 1.6+, and
WebKitGTK 6.0.
See the [FreeBSD Guide](packaging/freebsd/README.md) for setup and launch instructions.
@@ -128,7 +154,7 @@ See the [FreeBSD Guide](packaging/freebsd/README.md) for setup and launch instru
sudo apt update
sudo apt install build-essential cmake git ninja-build pkg-config lld \
libsdl3-dev libsdl3-ttf-dev libcurl4-openssl-dev libssl-dev \
nlohmann-json3-dev libyaml-dev libelf-dev libminizip-dev \
nlohmann-json3-dev libyaml-dev libpng-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 zlib1g-dev
@@ -140,7 +166,7 @@ sudo apt install build-essential cmake git ninja-build pkg-config lld \
```bash
sudo pacman -S --needed base-devel cmake git ninja pkgconf lld sdl3 sdl3_ttf \
curl openssl nlohmann-json libyaml libelf minizip capstone gtk4 \
curl openssl nlohmann-json libyaml libpng libelf minizip capstone gtk4 \
libadwaita webkitgtk-6.0 libutf8proc fontconfig libglvnd \
libplacebo vulkan-headers vulkan-icd-loader zlib
```
@@ -152,7 +178,8 @@ sudo pacman -S --needed base-devel cmake git ninja pkgconf lld sdl3 sdl3_ttf \
```bash
sudo dnf install gcc-c++ cmake git ninja-build pkgconf-pkg-config lld \
SDL3-devel SDL3_ttf-devel libcurl-devel openssl-devel \
nlohmann-json-devel libyaml-devel elfutils-libelf-devel minizip-ng-compat-devel \
nlohmann-json-devel libyaml-devel libpng-devel elfutils-libelf-devel \
minizip-ng-compat-devel \
capstone-devel gtk4-devel libadwaita-devel webkitgtk6.0-devel \
utf8proc-devel fontconfig-devel libglvnd-devel vulkan-headers \
vulkan-loader-devel libplacebo-devel zlib-ng-compat-devel
@@ -176,6 +203,11 @@ Use `sudo make install` instead to install system-wide under `/usr`. To make
Roblox website links open the build tree copy without installing, run
`make register-url-handler`.
To build and install the Flatpak from source, install `flatpak-builder` and run
`make flatpak`. It builds with 2 parallel jobs to keep memory use low. Pass
`FLATPAK_JOBS=N` to `make flatpak`, or set `MOCKTAIL_FLATPAK_JOBS=N` for
`scripts/build_flatpak.sh`, to change that.
To run the same checks as CI before each commit and push, install
[lefthook](https://github.com/evilmartians/lefthook) and run once:
+17
View File
@@ -4,6 +4,7 @@
{
"version_name": "2.721.1108",
"version_code": 2350,
"abi": "x86_64",
"elf_build_id": "50e1b0abd123350e794226062fe3a1ef360c5f0d",
"status": "legacy-researched",
"default_allowed": false,
@@ -15,6 +16,7 @@
{
"version_name": "2.725.1142",
"version_code": 2546,
"abi": "x86_64",
"elf_build_id": "d0cb1fa0deb3d9161b4cd77530cbcd2e50de3a21",
"status": "supported",
"default_allowed": true,
@@ -35,6 +37,7 @@
{
"version_name": "2.727.1199",
"version_code": 2628,
"abi": "x86_64",
"elf_build_id": "1686400865ae0e408cd7bd67de7a439625c6fd13",
"status": "supported",
"default_allowed": true,
@@ -46,6 +49,7 @@
{
"version_name": "2.734.917",
"version_code": 2908,
"abi": "x86_64",
"elf_build_id": "63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
"status": "supported",
"default_allowed": true,
@@ -66,6 +70,19 @@
{
"version_name": "2.736.1408",
"version_code": 2998,
"abi": "arm64-v8a",
"elf_build_id": "ed2b035768b1513561d0e6ea7f9bbec35b0e60e5",
"status": "supported",
"default_allowed": true,
"allow_legacy_binary_patches": false,
"allow_host_abi_bridges": true,
"allow_host_constructor_replay": true,
"reason": "AArch64 arm64-v8a payload of the same 2.736.1408 release as the verified x86-64 profile; allocator, constructor replay, and pre-JNI bootstrap RVAs were derived by cross-architecture semantic matching."
},
{
"version_name": "2.736.1408",
"version_code": 2998,
"abi": "x86_64",
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
"status": "supported",
"default_allowed": true,
+27 -2
View File
@@ -44,16 +44,30 @@ Each slice is named after the call it times, on the thread that made it.
| `present` | `host present`: the host driver's present, nested inside the slice above | |
| `wait` | `vkAcquireNextImageKHR`, `vkAcquireNextImage2KHR`, `vkWaitForFences`, `vkWaitSemaphores`, `vkWaitSemaphoresKHR`, `vkQueueWaitIdle`, `vkDeviceWaitIdle` | |
| `submit` | `vkQueueSubmit`, `vkQueueSubmit2`, `vkQueueSubmit2KHR` | `submits` |
| `texture` | `etc2 decode`: CPU decode of ETC2 textures before a submit | `uploads`, `compressed_bytes`, `decoded_bytes` |
| `texture` | `etc2 decode`: CPU decode of ETC2 textures. On a device with a timeline semaphore this runs on the decode dispatcher, not the submitting thread | `uploads`, `compressed_bytes`, `decoded_bytes` |
| `texture` | `etc2 gather`: the part of an asynchronous upload that stays on the submitting thread. Absent when the decode ran inline | `uploads`, `compressed_bytes`, `decoded_bytes`, `ticket` |
| `texture` | `etc2 staging create`: creating a staging block for ETC2 uploads, when no block has room left | `bytes` |
| `texture` | `etc2 staging destroy`: destroying empty staging blocks beyond the four kept idle | `buffers` |
| `texture` | `etc2 release wait`: a command buffer reset waiting for its uploads' decode. Only waits of 0.1 ms or more are recorded | `ticket` |
| `memory` | `vkAllocateMemory` | `bytes`, `type` (memory type index), `result` |
| `memory` | `vkFreeMemory`, `vkUnmapMemory`: both include waiting for ETC2 decodes that still read that memory | |
| `memory` | `vkMapMemory`, `vkCreateImage`, `vkDestroyImage` | |
| `pipeline` | `vkCreateGraphicsPipelines`, `vkCreateComputePipelines` | `count`, `cache` (1 when a pipeline cache was passed), `result` |
| `pipeline` | `vkCreateShaderModule` | `bytes`, `result` |
| `pipeline` | `vkCreatePipelineCache` | `initial_bytes`, `result` |
| `pump` | `nativeCallMessagesFromMainThread`: the engine's main-thread step, on the host main thread. Only calls of 20 µs or more are recorded; the empty polls between them are not. | |
| `pump` | `nativeCallMessagesFromMainThread`: the engine's main-thread step, on the host main thread. Only calls of 20 µs or more are recorded; the empty polls between them are not. | `cpu_us`: CPU time the main thread used during the call. Far below the slice's duration means the step spent it waiting |
The `frame interval (ms)` counter is the time between successive adapter
presents. `vkQueuePresentKHR` time minus `host present` time is the adapter's
own cost per frame.
Two more counters cover the same interval. `present thread cpu (ms)` is the
CPU time the thread that presents used in it, and is absent for a frame
presented from a different thread than the one before. `process cpu (ms)` is
the CPU time of every thread together, so dividing it by the frame interval
gives the number of cores busy. A long frame with little present-thread CPU
was spent waiting.
## The benchmark runs
Run every scenario before and after the change, on the same build type
@@ -61,6 +75,17 @@ Run every scenario before and after the change, on the same build type
Record `graphics.frame_rate_limit` and `graphics.vsync` in the pull request,
because a frame cap hides frame time gains.
Record `~/.config/gamemode.ini` too, and do not change it between the before
and after runs. `desiredgov` there decides the CPU governor for the whole
session and therefore which engine pump mode is chosen, which moves process
CPU by more than most changes under test. See
[PERFORMANCE.md](PERFORMANCE.md).
Frame interval p99 varies by 2 ms or more between identical runs, so a
difference that size is not a result. Three runs per scenario is the minimum
for any frame-time claim; slice totals for a specific subsystem are far less
noisy and can be read from fewer.
| Scenario | How to start | What to do | What it shows |
| --- | --- | --- | --- |
| Home screen, cold | `mocktail --profile home-cold.json` with an empty shader cache (below) | Wait 30 s on the home screen, exit | First-load stutter: `pipeline` and `texture` slices |
+48 -2
View File
@@ -27,20 +27,48 @@ request. If GameMode narrowed it, Nightcap ends the request and prints:
```
The trade is that GameMode's governor and priority boost go with it. To keep
those and lose only the pinning, turn pinning off for every game on the host:
those and lose only the pinning, turn pinning off for every game on the host.
These are the settings to run with:
```ini
# ~/.config/gamemode.ini
[general]
desiredgov=performance
[cpu]
pin_cores=no
park_cores=no
```
`desiredgov` is the one to check first. The packaged default in
`/usr/share/gamemode/gamemode.ini` is already `performance`, but a value in
your own file replaces it, and `desiredgov=powersave` there means GameMode
clocks the CPU **down** for the whole session while still reporting
`[gamemode] performance request active`. The symptoms are a low CPU clock
under load, a GPU that never leaves its lower SM clocks, and the engine pump
falling back to `tpause` — which costs about 70% of a core instead of 3%,
because `sleep` is only chosen when the governor reads `performance`. On one
13900K host, correcting `powersave` to `performance` took the process from
177% CPU to 42% and the package from 70°C to 53-63°C, at the same frame rate.
GameMode reads `$XDG_CONFIG_HOME`, `/usr/share/gamemode` and `/etc`, in that
order. It does not read anything under Nightcap's own config directory, so this
file has to live in one of those three places. The daemon reads it when it
starts, so restart it after editing: `systemctl --user restart gamemoded`.
Restarting the daemon drops the session of any game already running; relaunch
the game for new settings to apply to it.
`gamemoded` is D-Bus activated through
`com.feralinteractive.GameMode.service`, so `systemctl --user stop gamemoded`
does not hold — the next client request starts it again within seconds. To
measure with GameMode genuinely off, mask it:
```bash
systemctl --user mask gamemoded # and --unmask afterwards
```
The governor switch runs through polkit. Debian and Ubuntu only allow it for
members of the `gamemode` group; without that, the daemon's journal shows
`cpugovctl set performance: Not authorized` and the governor stays put while
@@ -59,9 +87,20 @@ to the affinity. `off` never contacts the daemon.
To see which CPUs the process may use:
```bash
taskset -pc $(pgrep -x mocktail)
pid=$(pgrep -x Main)
for t in /proc/$pid/task/*; do taskset -pc "${t##*/}"; done
```
Two traps here. The engine process reports its name as `Main`, not
`mocktail`; `pgrep -x mocktail` finds only the launcher, which sits near 0%
CPU and tells you nothing. And `taskset -p` reads one thread, not the whole
process, so a single call reports the main thread's mask and says nothing
about the workers — which is where the game's load actually is.
For the same reason, `ps -L -o psr=` shows which core each thread is running
on right now, not which cores it is allowed on. On a hybrid CPU the scheduler
preferring P-cores for runnable work is normal and is not a mask.
## The main-thread pump
Roblox posts its per-frame main-thread step at a time of its own choosing,
@@ -99,6 +138,13 @@ threads while textures load, which shows up as a burst rather than a steady
load. The enlarged textures then stay in GPU memory for the session and cost
extra sampling bandwidth on every frame that uses them.
That decode does not run on the thread that submits the frame. On a device
with a timeline semaphore, uploads decode on a background dispatcher and the
submit carries a semaphore wait, so the GPU waits for the texture instead of
the render thread. In the trace this is the `etc2 decode` slice on its own
thread, with only a negligible `etc2 gather` left on the submitting one. A
device without that feature decodes inline as before.
Set it to `1` to turn upscaling off:
```bash
+26 -35
View File
@@ -16,7 +16,7 @@ Goal: a small GTK4 settings window, reachable from the app menu and from a
- Input tab: the `input.raw_mouse` toggle and controller options.
- Advanced tab: FFlag overrides with a plain text editor for `fflags.json`.
- Discord tab: every field in the `discord_rpc` block, so a friend can see
what you are playing and you can switch it all off in one click.
what you are playing and you can switch it all off in one click.
- Changes apply on next launch. A restart button restarts Nightcap.
Done when every option in `config/mocktail.example.yaml` that a player would
@@ -29,62 +29,53 @@ already hot-plug and release their held inputs when unplugged. One rough edge
is left.
- Correct button glyphs for the connected pad. `SDL_GetGamepadType` is only
read for Xbox and PlayStation pads, so a Switch Pro, a GameCube pad, or any
generic pad SDL reports as `SDL_GAMEPAD_TYPE_STANDARD` reaches Roblox as
type 0 and draws generic glyphs. `SDL_GetRealGamepadType`, and the vendor
and product IDs, are available and unused.
read for Xbox and PlayStation pads, so a Switch Pro, a GameCube pad, or any
generic pad SDL reports as `SDL_GAMEPAD_TYPE_STANDARD` reaches Roblox as
type 0 and draws generic glyphs. `SDL_GetRealGamepadType`, and the vendor
and product IDs, are available and unused.
Done when a plugged-in pad shows its own buttons.
## 3. First-load stutter
The `--profile` trace now shows shader compiles and pipeline creation, so
the stutter on first entering an experience can be measured.
- Cut the stutter the trace shows, and prove it with a before and after
trace per [BENCHMARKING.md](BENCHMARKING.md).
Done when entering a fresh experience no longer drops frames on shader
compiles.
## Shipped
- Lower input latency: frames are presented as soon as they are ready.
- Controllers hot-plug without a restart, and a pad that is unplugged mid-game
releases its held buttons and sticks instead of leaving them stuck.
releases its held buttons and sticks instead of leaving them stuck.
- Mouse look keeps responding after a long turn, instead of going dead until
the pointer travels back from past the screen edge.
the pointer travels back from past the screen edge.
- Optional raw mouse input: `input.raw_mouse` in `config.yaml` sends look
deltas in host units, so fractional display scaling no longer changes
first-person sensitivity.
deltas in host units, so fractional display scaling no longer changes
first-person sensitivity.
- Small textures upscaled 4x by default, with PNG overrides in
`~/.config/mocktail/textures`.
`~/.config/mocktail/textures`.
- ETC2 textures decoded on desktop GPUs without stutter.
- No more error 319 kicks after joining a server.
- Roblox sees the real host RAM and screen size.
- Desktop launcher that works on Wayland and finds the installed binary.
- Discord Rich Presence on by default, with the experience name, creator,
elapsed time, the experience icon with a Roblox badge, and a join button
when the server is public. See the [FAQ](../FAQ.md) to switch it off.
elapsed time, the experience icon with a Roblox badge, and a join button
when the server is public. See the [FAQ](../FAQ.md) to switch it off.
- Custom Discord presence: title, details, state, and large and small icons
from `config.yaml`, with `{place_name}`, `{place_icon}`, and
`{creator_name}` placeholders.
from `config.yaml`, with `{place_name}`, `{place_icon}`, and
`{creator_name}` placeholders.
- `--profile` writes a Chrome-format trace of the Vulkan adapter's work,
including shader and pipeline creation, readable in `ui.perfetto.dev`.
[BENCHMARKING.md](BENCHMARKING.md) fixes the experiences to measure and
`scripts/summarize_profile_trace.py` builds the before and after table.
including shader and pipeline creation, readable in `ui.perfetto.dev`.
[BENCHMARKING.md](BENCHMARKING.md) fixes the experiences to measure and
`scripts/summarize_profile_trace.py` builds the before and after table.
- Texture uploads no longer stall the frame they land on: ETC2 decode and
resampling run on a persistent worker pool.
resampling run on a persistent worker pool.
- Less stutter while joining an experience and while textures load in. See
the [changelog](../CHANGELOG.md) for the numbers.
- The host main thread rests between engine polls instead of spinning, and
GameMode's core pinning is released, so Roblox's workers spread across
every core. See [PERFORMANCE.md](PERFORMANCE.md).
GameMode's core pinning is released, so Roblox's workers spread across
every core. See [PERFORMANCE.md](PERFORMANCE.md).
- Own app ID `io.github.CoderDayton.nightcap`, so Nightcap installs next to
upstream.
upstream.
- AppImage and signed Flatpak releases on every `v*` tag, with the Flatpak
repo on GitHub Pages.
repo on GitHub Pages.
## Not planned
- Replacing the `mocktail` binary and config names. Keeping them makes
upstream merges clean.
- DEB, RPM, and AUR packages. Upstream ships those for unmodified Mocktail.
upstream merges clean.
+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}"
+30
View File
@@ -33,6 +33,23 @@ void SetLegacyBionicDiagnosticsEnabled(bool enabled);
// guest layout explicit instead of exposing the incompatible host libc type.
using MocktailBionicMutexAttr = int64_t;
// Bionic LP64 pthread_attr_t: 56 bytes with explicit field offsets. The host
// glibc layout is opaque and wider on aarch64, so guest buffers must be read
// and written through this struct only.
struct MocktailBionicPthreadAttr {
std::uint32_t flags;
void* stack_base;
std::size_t stack_size;
std::size_t guard_size;
std::int32_t sched_policy;
std::int32_t sched_priority;
char reserved[16];
};
static_assert(sizeof(MocktailBionicPthreadAttr) == 56);
inline constexpr std::uint32_t kMocktailBionicAttrFlagDetached = 0x1;
inline constexpr std::uint32_t kMocktailBionicAttrFlagUserStack = 0x2;
extern "C" {
void mocktail_set_current_jni_env(void* env);
@@ -84,6 +101,19 @@ int mocktail_pthread_barrier_init(pthread_barrier_t* barrier,
int mocktail_pthread_barrier_destroy(pthread_barrier_t* barrier);
int mocktail_pthread_barrier_wait(pthread_barrier_t* barrier);
int mocktail_pthread_attr_init(MocktailBionicPthreadAttr* attr);
int mocktail_pthread_attr_destroy(MocktailBionicPthreadAttr* attr);
int mocktail_pthread_attr_setstacksize(MocktailBionicPthreadAttr* attr,
size_t stack_size);
int mocktail_pthread_attr_setdetachstate(MocktailBionicPthreadAttr* attr,
int detach_state);
int mocktail_pthread_attr_setschedparam(MocktailBionicPthreadAttr* attr,
const struct sched_param* parameters);
int mocktail_pthread_getattr_np(pthread_t thread,
MocktailBionicPthreadAttr* attr);
int mocktail_pthread_attr_getstack(const MocktailBionicPthreadAttr* attr,
void** stack_base, size_t* stack_size);
extern void* mocktail_gameactivity_on_start_native;
extern void* mocktail_gameactivity_on_resume_native;
extern void* mocktail_gameactivity_on_surface_created_native;
@@ -25,11 +25,12 @@ void ConfigureBionicPthreadThreadInitializer(
} // namespace mocktail::compat
struct MocktailBionicPthreadAttr;
// Rebuilds attributes instead of passing Android pthread_attr_t to glibc.
extern "C" int mocktail_bionic_pthread_create(pthread_t* thread,
const pthread_attr_t* attr,
void* (*start_routine)(void*),
void* argument);
extern "C" int mocktail_bionic_pthread_create(
pthread_t* thread, const MocktailBionicPthreadAttr* attr,
void* (*start_routine)(void*), void* argument);
// Guest real-time scheduling is denied to avoid the host RLIMIT_RTTIME watchdog.
extern "C" int mocktail_bionic_pthread_setschedparam(
+38
View File
@@ -0,0 +1,38 @@
#ifndef MOCKTAIL_COMPAT_GUEST_ABI_H_
#define MOCKTAIL_COMPAT_GUEST_ABI_H_
#include <elf.h>
#include <string_view>
namespace mocktail::compat {
#if defined(__aarch64__)
// Android ABI name as it appears in APK lib/ directories and build metadata.
inline constexpr std::string_view kGuestAbi = "arm64-v8a";
// APK split-config name carrying the guest native libraries.
inline constexpr std::string_view kGuestApkSplitName = "config.arm64_v8a";
// File name the payload store uses for the split APK.
inline constexpr std::string_view kGuestSplitApkFile =
"split_config.arm64_v8a.apk";
// sha256 map key holding the split APK digest in payload metadata.
inline constexpr std::string_view kGuestSplitApkHashKey = "arm64_v8a_split_apk";
// Short CPU label for device metadata and user-facing strings.
inline constexpr std::string_view kGuestCpuName = "aarch64";
inline constexpr int kGuestElfMachine = EM_AARCH64;
#elif defined(__x86_64__)
inline constexpr std::string_view kGuestAbi = "x86_64";
inline constexpr std::string_view kGuestApkSplitName = "config.x86_64";
inline constexpr std::string_view kGuestSplitApkFile = "split_config.x86_64.apk";
inline constexpr std::string_view kGuestSplitApkHashKey = "x86_64_split_apk";
inline constexpr std::string_view kGuestCpuName = "x86_64";
inline constexpr int kGuestElfMachine = EM_X86_64;
#else
#error "mocktail only supports aarch64 and x86_64 hosts"
#endif
// APK zip entry holding the guest libroblox.so.
inline constexpr std::string_view kGuestLibApkEntryPrefix = "lib/";
} // namespace mocktail::compat
#endif // MOCKTAIL_COMPAT_GUEST_ABI_H_
+190
View File
@@ -0,0 +1,190 @@
#ifndef MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
#define MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <optional>
#include <string_view>
// Roblox's Subsystem<IWebViewProtocol> getter is not exported. The exported
// WebViewProtocol.initializeAndroidWebViewProtocol calls it first, into a
// stack slot, then reads the protocol from the returned holder:
//
// x86-64: lea slot(%rbp),%rbx; mov %rbx,%rdi; call getter;
// mov (%rbx),%rbx; mov 0x18(%rbx),%rax
// AArch64: add x8, sp, #slot; bl getter; ldr xN, [sp, #slot];
// ldr x8, [xN, #0x18]
//
// These functions find that call site in the initializer's first bytes and
// check the getter's own code. Only relocated or build-varying fields (stack
// slots, frame sizes, PC-relative offsets, the call target) are masked.
// Verified on x86_64 2.734.917, 2.736.1408 and 2.738.1397, and on arm64-v8a
// 2.738.1397 and 2.740.931.
namespace mocktail::compat {
// Covers the call site in every verified build (x86-64 +0x27, AArch64 +0x34).
inline constexpr std::size_t kWebViewProtocolInitializerScanBytes = 128;
inline constexpr std::size_t kWebViewProtocolGetterBytesX86_64 = 44;
inline constexpr std::size_t kWebViewProtocolGetterBytesArm64 = 60;
namespace web_view_protocol_contract_detail {
inline bool MatchesMasked(std::string_view code, std::size_t offset,
std::string_view pattern,
std::string_view wildcard) {
if (offset > code.size() || code.size() - offset < pattern.size()) {
return false;
}
for (std::size_t index = 0; index < pattern.size(); ++index) {
if (wildcard[index] == 'x') continue;
if (code[offset + index] != pattern[index]) return false;
}
return true;
}
// AArch64 instructions are little-endian words.
inline std::uint32_t Word(std::string_view code, std::size_t index) {
std::uint32_t word = 0;
for (std::size_t byte = 0; byte < 4; ++byte) {
word |= static_cast<std::uint32_t>(
static_cast<unsigned char>(code[index * 4 + byte]))
<< (8 * byte);
}
return word;
}
// One AArch64 instruction word: bits outside `mask` may vary.
struct MaskedWord {
std::uint32_t mask;
std::uint32_t value;
};
inline constexpr std::uint32_t kExact = 0xffffffffU;
// adrp Xd, page: immlo and immhi vary.
inline constexpr std::uint32_t kAdrpMask = 0x9f00001fU;
// add Xd, Xn, #imm12: imm12 varies.
inline constexpr std::uint32_t kAddImmediateMask = 0xffc003ffU;
} // namespace web_view_protocol_contract_detail
// Returns the getter address called from the x86-64 initializer at
// `address`, or nullopt unless the frame setup and exactly one call site
// match.
inline std::optional<std::uintptr_t> FindWebViewProtocolGetterCallX86_64(
std::string_view code, std::uintptr_t address) {
using web_view_protocol_contract_detail::MatchesMasked;
// push %rbp; mov %rsp,%rbp
if (!MatchesMasked(code, 0, "\x55\x48\x89\xe5", "....")) {
return std::nullopt;
}
static constexpr std::string_view kCallSite(
"\x48\x8d\x5d\x00\x48\x89\xdf\xe8\x00\x00\x00\x00\x48\x8b\x1b\x48\x8b\x43"
"\x18",
19);
static constexpr std::string_view kCallSiteWildcard("...x....xxxx.......");
static constexpr std::size_t kCallDisplacement = 8;
const std::string_view window =
code.substr(0, kWebViewProtocolInitializerScanBytes);
std::optional<std::size_t> call_site;
for (std::size_t offset = 0; offset < window.size(); ++offset) {
if (MatchesMasked(window, offset, kCallSite, kCallSiteWildcard)) {
if (call_site.has_value()) return std::nullopt;
call_site = offset;
}
}
if (!call_site.has_value()) return std::nullopt;
std::int32_t displacement = 0;
std::memcpy(&displacement, window.data() + *call_site + kCallDisplacement,
sizeof(displacement));
const std::uintptr_t next_instruction =
address + *call_site + kCallDisplacement + sizeof(displacement);
return next_instruction +
static_cast<std::uintptr_t>(static_cast<std::intptr_t>(displacement));
}
// The x86-64 getter stores the holder returned by the subsystem's acquire
// into its result slot (%rdi) and returns that slot.
inline bool HasWebViewProtocolGetterContractX86_64(std::string_view code) {
return web_view_protocol_contract_detail::MatchesMasked(
code, 0,
std::string_view(
"\x55\x48\x89\xe5\x53\x50\x48\x89\xfb\x8a\x05\x00\x00\x00\x00\x84\xc0"
"\x74\x19\x48\x8d\x3d\x00\x00\x00\x00\xe8\x59\x00\x00\x00\x48\x89\x03"
"\x48\x89\xd8\x48\x83\xc4\x08\x5b\x5d\xc3",
kWebViewProtocolGetterBytesX86_64),
"...........xxxx.......xxxx..................");
}
// Returns the getter address called from the AArch64 initializer at
// `address`, or nullopt unless exactly one call site matches.
inline std::optional<std::uintptr_t> FindWebViewProtocolGetterCallArm64(
std::string_view code, std::uintptr_t address) {
using web_view_protocol_contract_detail::Word;
const std::size_t words =
code.substr(0, kWebViewProtocolInitializerScanBytes).size() / 4;
std::optional<std::uintptr_t> getter;
for (std::size_t index = 0; index + 4 <= words; ++index) {
const std::uint32_t slot_address = Word(code, index);
const std::uint32_t call = Word(code, index + 1);
const std::uint32_t handle_load = Word(code, index + 2);
const std::uint32_t protocol_load = Word(code, index + 3);
// add x8, sp, #slot
if ((slot_address & 0xffc003ffU) != 0x910003e8U) continue;
// bl getter
if ((call & 0xfc000000U) != 0x94000000U) continue;
// ldr xN, [sp, #slot]
if ((handle_load & 0xffc003e0U) != 0xf94003e0U) continue;
const std::uint32_t slot = (slot_address >> 10) & 0xfffU;
if (((handle_load >> 10) & 0xfffU) * 8 != slot) continue;
// ldr x8, [xN, #0x18]
const std::uint32_t handle_register = handle_load & 0x1fU;
if (protocol_load != (0xf9400c08U | (handle_register << 5))) continue;
if (getter.has_value()) return std::nullopt;
// imm26 is a signed word offset from the bl instruction.
const std::int64_t imm26 = call & 0x03ffffffU;
const std::int64_t words_from_call =
(imm26 & 0x02000000) != 0 ? imm26 - 0x04000000 : imm26;
getter = address + (index + 1) * 4 +
static_cast<std::uintptr_t>(words_from_call * 4);
}
return getter;
}
// The AArch64 getter saves its result slot (x8) and stores the holder
// returned by the subsystem's acquire there.
inline bool HasWebViewProtocolGetterContractArm64(std::string_view code) {
using namespace web_view_protocol_contract_detail;
static constexpr MaskedWord kGetter[] = {
{kExact, 0xa9be7bfdU}, // stp x29, x30, [sp, #-0x20]!
{kExact, 0xf9000bf3U}, // str x19, [sp, #0x10]
{kExact, 0x910003fdU}, // mov x29, sp
{kAdrpMask, 0x90000009U}, // adrp x9, guard
{kAddImmediateMask, 0x91000129U}, // add x9, x9, :lo12:guard
{kExact, 0xaa0803f3U}, // mov x19, x8
{kExact, 0x08dffd29U}, // ldarb w9, [x9]
{kExact, 0x36000109U}, // tbz w9, #0, initialize
{kAdrpMask, 0x90000000U}, // adrp x0, holder
{kAddImmediateMask, 0x91000000U}, // add x0, x0, :lo12:holder
{kExact, 0x94000017U}, // bl acquire
{kExact, 0xf9000260U}, // str x0, [x19]
{kExact, 0xf9400bf3U}, // ldr x19, [sp, #0x10]
{kExact, 0xa8c27bfdU}, // ldp x29, x30, [sp], #0x20
{kExact, 0xd65f03c0U}, // ret
};
static_assert(sizeof(kGetter) / sizeof(kGetter[0]) * 4 ==
kWebViewProtocolGetterBytesArm64);
if (code.size() < kWebViewProtocolGetterBytesArm64) return false;
for (std::size_t index = 0; index < sizeof(kGetter) / sizeof(kGetter[0]);
++index) {
if ((Word(code, index) & kGetter[index].mask) != kGetter[index].value) {
return false;
}
}
return true;
}
} // namespace mocktail::compat
#endif // MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
+6
View File
@@ -48,9 +48,15 @@ struct PlatformIdentity {
std::string manufacturer = "Mocktail";
std::string model = "Mocktail Headless";
std::string brand = "Mocktail";
#if defined(__aarch64__)
std::string device_code = "linux-arm64";
std::string device_sku = "mocktail-arm64";
std::string soc_model = "aarch64";
#else
std::string device_code = "linux-x86_64";
std::string device_sku = "mocktail-x86_64";
std::string soc_model = "x86_64";
#endif
};
struct RobloxCredentialView {
@@ -6,6 +6,9 @@
#if defined(__x86_64__)
#include <xmmintrin.h>
#endif
#if defined(__aarch64__)
#include <cstdint>
#endif
namespace mocktail::audio {
@@ -22,16 +25,23 @@ class FmodThreadFloatingPointMode final {
delete;
bool Enable(const char* thread_name) noexcept {
#if defined(__x86_64__)
#if defined(__x86_64__) || defined(__aarch64__)
if (active_ || thread_name == nullptr) return false;
const std::string_view name(thread_name);
if (name != "FMOD mixer thread" && name != "FMOD feeder thread" &&
name != "FMOD Convolution thread" && name != "FMOD Worker Thread") {
return false;
}
#if defined(__x86_64__)
const unsigned int control = _mm_getcsr();
original_mode_ = control & kModeMask;
_mm_setcsr(control | kModeMask);
#else
std::uint64_t control;
__asm__ volatile("mrs %0, fpcr" : "=r"(control));
original_mode_ = control & kModeMask;
__asm__ volatile("msr fpcr, %0" ::"r"(control | kModeMask));
#endif
active_ = true;
return true;
#else
@@ -41,11 +51,18 @@ class FmodThreadFloatingPointMode final {
}
void Restore() noexcept {
#if defined(__x86_64__)
#if defined(__x86_64__) || defined(__aarch64__)
if (active_) {
// Restore only our two bits, preserving rounding, exception masks and
// status flags set by the guest while the thread was attached.
#if defined(__x86_64__)
_mm_setcsr((_mm_getcsr() & ~kModeMask) | original_mode_);
#else
std::uint64_t control;
__asm__ volatile("mrs %0, fpcr" : "=r"(control));
__asm__ volatile("msr fpcr, %0" ::"r"((control & ~kModeMask) |
original_mode_));
#endif
active_ = false;
}
#endif
@@ -56,6 +73,13 @@ class FmodThreadFloatingPointMode final {
static constexpr unsigned int kModeMask = (1U << 15) | (1U << 6);
unsigned int original_mode_ = 0;
bool active_ = false;
#elif defined(__aarch64__)
// FPCR FZ flushes single/double subnormals; FZ16 covers the half-precision
// path used by some voice DSP filters.
static constexpr std::uint64_t kModeMask =
(std::uint64_t{1} << 24) | (std::uint64_t{1} << 19);
std::uint64_t original_mode_ = 0;
bool active_ = false;
#endif
};
+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);
@@ -24,6 +24,35 @@ struct TraceArg {
// CLOCK_MONOTONIC in nanoseconds.
std::uint64_t TraceClockNanos();
// CPU time used so far by the calling thread, in nanoseconds.
std::uint64_t TraceThreadCpuNanos();
// CPU time used so far by every thread of the process, in nanoseconds.
std::uint64_t TraceProcessCpuNanos();
// CPU time spent between two successive presents.
struct FrameCpuSample {
double process_ms = 0;
// Set only when the previous present came from the same thread; thread CPU
// clocks of different threads cannot be subtracted.
bool has_thread = false;
double thread_ms = 0;
};
// Turns CPU clocks read at each present into per-frame CPU time.
// Thread-safe.
class FrameCpuSampler final {
public:
// Returns false for the first present, which ends no frame.
bool Sample(std::uint64_t thread_id, std::uint64_t thread_cpu_ns,
std::uint64_t process_cpu_ns, FrameCpuSample* sample);
private:
std::mutex mutex_;
bool has_previous_ = false;
std::uint64_t previous_thread_id_ = 0;
std::uint64_t previous_thread_cpu_ns_ = 0;
std::uint64_t previous_process_cpu_ns_ = 0;
};
// Writes the Trace Event Format JSON array read by chrome://tracing and
// ui.perfetto.dev. Recording threads only format into a shared buffer; a
@@ -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
@@ -47,17 +47,30 @@ inline constexpr std::uint32_t kSmallTextureMaxExtent = 64;
inline constexpr std::uint32_t kDefaultSmallTextureUpscale = 4;
std::uint32_t SmallTextureUpscale(const char* value);
// Staging for decoded uploads is carved out of host-visible blocks this
// large; an upload that needs more gets a block of its own.
inline constexpr VkDeviceSize kEtc2StagingBlockBytes = 16 * 1024 * 1024;
// A timeline semaphore and the value an asynchronous decode will signal.
// `semaphore` is VK_NULL_HANDLE when the decode already finished on the
// calling thread and the submit needs no extra wait.
struct Etc2SubmitWait {
VkSemaphore semaphore = VK_NULL_HANDLE;
std::uint64_t value = 0;
};
// Reports ETC2/EAC support on hosts without native ETC2 (desktop GPUs) and
// stores those textures decoded. Images are created with the host format;
// each staged upload into one is redirected to a host-visible staging buffer
// that is filled by decoding the application's staging bytes when the
// command buffer is submitted. Every submit decodes again, as a native copy
// re-reads its source on each execution.
// Staging buffers live until their command buffer is begun, reset or freed.
// Staging ranges live until their command buffer is begun, reset or freed.
// MOCKTAIL_DISABLE_ETC2_EMULATION=1 turns the emulation off.
class VulkanEtc2Emulation final {
public:
VulkanEtc2Emulation();
explicit VulkanEtc2Emulation(
VkDeviceSize staging_block_bytes = kEtc2StagingBlockBytes);
~VulkanEtc2Emulation();
VulkanEtc2Emulation(const VulkanEtc2Emulation&) = delete;
VulkanEtc2Emulation& operator=(const VulkanEtc2Emulation&) = delete;
@@ -66,10 +79,14 @@ class VulkanEtc2Emulation final {
bool PhysicalDeviceNeedsEmulation(VkPhysicalDevice physical_device,
PFN_vkGetPhysicalDeviceFeatures host);
// `timeline_semaphore` states whether the device was created with the
// timelineSemaphore feature enabled. Without it every decode runs on the
// calling thread.
void RegisterDevice(VkDevice device, VkPhysicalDevice physical_device,
bool emulated,
const VkPhysicalDeviceMemoryProperties& memory,
PFN_vkGetDeviceProcAddr get_device_proc_addr);
PFN_vkGetDeviceProcAddr get_device_proc_addr,
bool timeline_semaphore = false);
void DestroyDevice(VkDevice device);
VkResult CreateImage(VkDevice device, const VkImageCreateInfo* create_info,
@@ -112,12 +129,27 @@ class VulkanEtc2Emulation final {
const VkCommandBuffer* secondaries);
// Decodes every upload recorded into these command buffers and the
// secondary command buffers they execute. Large batches decode on worker
// threads; this returns once every upload is decoded.
void PrepareSubmit(const VkCommandBuffer* command_buffers,
std::uint32_t count);
// secondary command buffers they execute.
//
// With `allow_async` on a device that has a timeline semaphore, the decode
// runs on worker threads, and so does the gather of the compressed bytes
// whenever the application already has them mapped. The returned semaphore
// and value must then be added to the submit's waits, so the GPU copy
// cannot execute before the decode finishes. Values are signalled in the
// order they are handed out.
//
// Those threads read the application's source bytes after this returns,
// which Vulkan allows: the application may not write them between the
// submit and the copy's completion. Unmapping or freeing that memory waits
// for the decodes still reading it.
//
// Returns `{VK_NULL_HANDLE, 0}` when there was nothing to decode, when
// `allow_async` is false, or when the device has no timeline semaphore. In
// those cases every upload is already decoded when this returns.
Etc2SubmitWait PrepareSubmit(const VkCommandBuffer* command_buffers,
std::uint32_t count, bool allow_async = false);
// Destroys the staging buffers owned by a command buffer that is no longer
// pending.
// pending, after waiting for any decode still writing into them.
void ReleaseCommandBuffer(VkCommandBuffer command_buffer);
struct State;
@@ -152,6 +152,10 @@ struct MouseMotionEvent {
float delta_x = 0.0f;
float delta_y = 0.0f;
std::uint32_t buttons = 0;
// True while the window holds relative mouse mode. The absolute coordinates
// are meaningless then: X11 reports zeroes, Wayland reports a position that
// accumulates the deltas and can leave the window entirely.
bool relative = false;
};
struct MouseButtonEvent {
@@ -160,6 +164,8 @@ struct MouseButtonEvent {
std::uint8_t clicks = 0;
float x = 0.0f;
float y = 0.0f;
// See MouseMotionEvent::relative.
bool relative = false;
};
struct MouseWheelEvent {
@@ -167,6 +173,8 @@ struct MouseWheelEvent {
float delta_y = 0.0f;
float mouse_x = 0.0f;
float mouse_y = 0.0f;
// See MouseMotionEvent::relative.
bool relative = false;
};
struct TouchEvent {
+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);
+94
View File
@@ -0,0 +1,94 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
#include <jni.h>
#include <memory>
#include <mutex>
#include <string>
#include "mocktail/status.h"
#include "runtime/roblox_game_session_native_adapter.h"
namespace mocktail {
namespace runtime {
// Exact JNI entrypoints behind the APK AccountProtocol device-integrity
// handlers: JNIAccountProtocol name getters and MessageBus raw handlers.
struct RobloxAccountProtocolSymbols {
using GetStringFn = jstring (*)(JNIEnv*, jclass);
GetStringFn get_protocol_name = nullptr;
GetStringFn get_device_integrity_available_method_name = nullptr;
GetStringFn get_integrity_token_method_name = nullptr;
GetStringFn get_support_key = nullptr;
GetStringFn get_token_key = nullptr;
GetStringFn get_result_key = nullptr;
void (*set_request_handler_raw)(JNIEnv*, jobject, jstring, jstring,
jobject) = nullptr;
void (*clear_request_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
bool complete() const {
return get_protocol_name && get_device_integrity_available_method_name &&
get_integrity_token_method_name && get_support_key &&
get_token_key && get_result_key && set_request_handler_raw &&
clear_request_handler;
}
};
struct RobloxAccountProtocolObjects {
jobject message_bus = nullptr;
void* context = nullptr;
jobject (*create_request_handler)(void*, std::shared_ptr<void>,
std::string (*)(void*, JNIEnv*,
jstring)) = nullptr;
void (*clear_request_handler)(void*, jobject) = nullptr;
bool complete() const {
return message_bus && context && create_request_handler &&
clear_request_handler;
}
};
// Answers AccountProtocol device-integrity requests for a host that cannot
// produce Play Integrity tokens. No integrity token is ever produced.
//
// DeviceIntegrityAvailable reports no support. The APK handler always reports
// support, so this answer differs from Android: it tells Roblox's challenge
// flow up front that no token can come, instead of letting it request one.
//
// GetIntegrityToken returns at once with an empty token and
// TOKEN_PROVIDER_UNINITIALIZED, the APK's answer when its Play token provider
// never initialized. The APK answers INVALID_NONCE for a request without a
// requestHash; this bridge never inspects the request, so it cannot.
// LuaApp bounds each token request with its
// DeviceIntegrityNativeTimeoutMilliseconds flag; an immediate answer keeps a
// login challenge from sitting out that bound.
class RobloxAccountBridge final {
public:
RobloxAccountBridge(JniEnvironmentProvider environment,
RobloxAccountProtocolSymbols symbols,
RobloxAccountProtocolObjects objects);
~RobloxAccountBridge();
RobloxAccountBridge(const RobloxAccountBridge&) = delete;
RobloxAccountBridge& operator=(const RobloxAccountBridge&) = delete;
Status Initialize();
Status Shutdown();
private:
struct State;
Status ShutdownLocked();
const JniEnvironmentProvider environment_;
const RobloxAccountProtocolSymbols symbols_;
const RobloxAccountProtocolObjects objects_;
// Serializes lifecycle only. Native dispatch never holds this mutex.
std::mutex lifecycle_mutex_;
std::shared_ptr<State> state_;
};
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
@@ -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
@@ -3,6 +3,7 @@
#include <jni.h>
#include <chrono>
#include <cstdint>
#include <deque>
#include <memory>
@@ -14,9 +15,11 @@
#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"
#include "runtime/roblox_account_bridge.h"
#include "runtime/roblox_permissions_bridge.h"
#include "runtime/roblox_web_view_bridge.h"
#include "runtime/webview_roblox_cookie.h"
@@ -95,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
@@ -120,7 +125,8 @@ class RobloxExperienceComposition final {
RobloxExperienceSurfaceProvider surface_provider = {},
RobloxExperiencePresenceObserver presence_observer = {},
bool clear_persisted_web_view_cookie = false,
bool microphone_enabled = false);
bool microphone_enabled = false,
RobloxAccountProtocolSymbols account_symbols = {});
~RobloxExperienceComposition();
RobloxExperienceComposition(const RobloxExperienceComposition&) = delete;
@@ -162,6 +168,7 @@ class RobloxExperienceComposition final {
bool visible = true;
bool back_navigation_disabled = false;
bool show_domain_as_title = false;
bool close_when_challenge_solved = false;
};
static Status DispatchLaunch(void* context,
@@ -201,8 +208,11 @@ class RobloxExperienceComposition final {
const WebSurfacePresentation& presentation);
// WebViewProtocol and BrowserService are two logical transports over the
// APK's single physical WebView container. Either protocol may close or
// mutate the surface that the other one opened.
Status CloseWebSurface();
// mutate the surface that the other one opened. A nonzero
// expected_logical_generation closes only that surface.
Status CloseWebSurface(uint64_t expected_logical_generation = 0);
// Closes a solved challenge that Roblox left open past its grace period.
Status CloseUnclaimedSolvedChallenge();
void HandleWebSurfaceExit(uint64_t process_generation);
Status Dispatch(const RobloxExperienceLaunchRequest& request);
Status PromoteAuthenticatedSession();
@@ -229,6 +239,7 @@ class RobloxExperienceComposition final {
const RobloxWebViewMessageBusSymbols web_view_symbols_;
const RobloxBrowserServiceSymbols browser_service_symbols_;
const RobloxPermissionsMessageBusSymbols permissions_symbols_;
const RobloxAccountProtocolSymbols account_symbols_;
const bool microphone_enabled_;
const RobloxGameSessionSymbols game_symbols_;
const RobloxExperienceJniFactory jni_factory_;
@@ -248,6 +259,9 @@ 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_;
std::shared_ptr<WebSurfaceExitTarget> web_surface_exit_target_;
std::shared_ptr<LifecycleTarget> lifecycle_target_;
@@ -257,6 +271,13 @@ class RobloxExperienceComposition final {
uint64_t web_surface_logical_generation_ = 0;
uint64_t next_web_surface_process_generation_ = 1;
uint64_t next_web_surface_logical_generation_ = 1;
// The current surface closes as soon as its solved challenge is forwarded.
bool web_surface_close_when_challenge_solved_ = false;
// Set when a solved challenge is forwarded; applies only to that logical
// surface generation.
std::optional<std::chrono::steady_clock::time_point>
solved_challenge_close_deadline_;
uint64_t solved_challenge_logical_generation_ = 0;
SecureWebViewRobloxCookie web_view_cookie_;
std::string web_view_cookie_initialization_error_;
bool web_view_cookie_synchronized_ = false;
+4 -3
View File
@@ -40,9 +40,10 @@ struct AndroidKeyMapping {
// through with an ABI-incompatible numeric value.
AndroidKeyMapping MapSdlKeyToAndroid(uint32_t sdl_scancode, uint32_t sdl_key = 0);
// Mirrors the APK's MotionEvent.getActionButton() - 1 conversion. SDL button
// numbers are not numerically compatible with Android button bit values.
int32_t MapSdlMouseButtonToAndroid(uint8_t sdl_button);
// Translates SDL button numbers to the nativePassMouseButton argument.
// The first three are Roblox's zero-based MouseButton1/2/3 indices, not Android
// MotionEvent button bits.
int32_t MapSdlMouseButtonToRoblox(uint8_t sdl_button);
int32_t MapSdlGamepadButtonToAndroid(uint8_t sdl_button);
int32_t RobloxGamepadType(platform::GamepadFamily family);
+7
View File
@@ -19,6 +19,13 @@ inline constexpr std::size_t kMaximumRobloxLaunchUriBytes = 64 * 1024;
Status ParseRobloxLaunchUri(std::string_view uri,
RobloxExperienceLaunchRequest* request);
// Converts a WebView launchGame command
// ({"functionName":"launchGame","params":{"request":{...}}}) into the same
// contract. Returns kUnsupported for other commands and kInvalidArgument for
// a malformed launchGame request.
Status ParseRobloxWebViewLaunchGame(std::string_view command,
RobloxExperienceLaunchRequest* request);
} // namespace runtime
} // namespace mocktail
@@ -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
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PLATFORM_WEB_SYMBOLS_H_
#define MOCKTAIL_RUNTIME_ROBLOX_PLATFORM_WEB_SYMBOLS_H_
#include "runtime/roblox_account_bridge.h"
#include "runtime/roblox_browser_service_bridge.h"
#include "runtime/roblox_permissions_bridge.h"
#include "runtime/roblox_web_view_bridge.h"
@@ -15,6 +16,8 @@ struct RobloxPlatformWebSymbols {
RobloxWebViewMessageBusSymbols web_view;
RobloxBrowserServiceSymbols browser_service;
RobloxPermissionsMessageBusSymbols permissions;
// Optional and excluded from complete(); see RobloxExperienceComposition.
RobloxAccountProtocolSymbols account;
bool complete() const {
return web_view.complete() && browser_service.complete() &&
@@ -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_
@@ -11,6 +11,8 @@
#include "mocktail/status.h"
#include "runtime/roblox_text_display_state.h"
struct TTF_Font;
namespace mocktail {
namespace runtime {
@@ -39,6 +41,8 @@ class RobloxTextSurfaceOverlay final {
const RobloxTextDisplayUpdate& update);
void ApplyUpdate(const RobloxTextDisplayUpdate& update);
Status RasterizeLocked();
Status OpenFontsLocked(std::int32_t font, float point_size);
void CloseFontsLocked();
void ClearFrameLocked();
void RecordFailureLocked(Status status);
@@ -46,10 +50,14 @@ class RobloxTextSurfaceOverlay final {
RobloxTextDisplayState state_;
RobloxTextOverlayViewport viewport_;
std::vector<std::uint8_t> rgba_;
std::vector<TTF_Font*> fonts_;
std::int32_t fonts_font_ = 0;
float fonts_point_size_ = 0.0F;
std::uint64_t state_revision_ = 0;
std::uint64_t raster_revision_ = 0;
Status failure_;
bool initialized_ = false;
bool ttf_initialized_ = false;
bool failure_logged_ = false;
bool ready_logged_ = false;
};
+42
View File
@@ -13,6 +13,8 @@
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <type_traits>
#include "mocktail/status.h"
#include "runtime/roblox_game_session_native_adapter.h"
@@ -42,6 +44,10 @@ struct RobloxWebViewOpenRequest {
std::optional<bool> show_domain_as_title;
std::optional<bool> back_button_visible;
std::optional<bool> hide_header;
// Set by the bridge, never parsed. The host closes an OPEN_CAPTCHA_VIEW
// window once its solved captcha has been forwarded, as Android's captcha
// activity finishes itself on success.
bool close_when_challenge_solved = false;
};
// WebViewProtocol.mutateWindow is a partial update. Field absence must remain
@@ -61,6 +67,9 @@ Status ParseRobloxWebViewMutationJson(const std::string& json,
RobloxWebViewMutationRequest* request);
Status ParseRobloxCaptchaOpenJson(const std::string& json,
RobloxWebViewOpenRequest* request);
// True for a page's Navigation.navigateToFeature command reporting a solved
// verification (see webview::IsRobloxChallengeSolvedFeature).
bool IsRobloxWebViewChallengeSuccess(std::string_view command);
Status ParseRobloxProfileViewUrl(const std::string& url,
RobloxWebViewOpenRequest* request);
Status ParseRobloxPurchaseRobuxNotification(const std::string& json,
@@ -108,6 +117,33 @@ using BroadcastWebViewDataModelFocusFn = void (*)(JNIEnv *, jclass, jstring,
using SignalWebViewJavascriptCallbackFn = void (*)(JNIEnv*, jclass, jstring);
using UpdateRobloxCookieSetHandlerFn =
void (*)(JNIEnv*, jobject, jobject);
// Roblox's counted handle to its Subsystem<IWebViewProtocol>. The protocol,
// and its handleJavascriptCallback subscriber, exist only while a handle is
// held. Roblox's handle type is not trivially destructible, so its getter
// returns it through the caller-supplied result slot (rdi on x86-64, x8 on
// AArch64). The user-provided destructor gives this type the same calling
// convention. It does not release the handle.
class RobloxWebViewProtocolHandle final {
public:
RobloxWebViewProtocolHandle() = default;
explicit RobloxWebViewProtocolHandle(void* holder) : holder_(holder) {}
RobloxWebViewProtocolHandle(const RobloxWebViewProtocolHandle&) = default;
RobloxWebViewProtocolHandle& operator=(const RobloxWebViewProtocolHandle&) =
default;
~RobloxWebViewProtocolHandle() {}
void* holder() const { return holder_; }
private:
void* holder_ = nullptr;
};
static_assert(sizeof(RobloxWebViewProtocolHandle) == sizeof(void*));
static_assert(!std::is_trivially_destructible_v<RobloxWebViewProtocolHandle>);
// Roblox's Subsystem<IWebViewProtocol> getter. It is not exported; the
// resolver finds it through the first call in
// WebViewProtocol.initializeAndroidWebViewProtocol.
using AcquireWebViewProtocolFn = RobloxWebViewProtocolHandle (*)();
struct RobloxWebViewMessageBusSymbols {
GetWebViewOpenWindowIdFn get_open_window_id = nullptr;
@@ -129,6 +165,8 @@ struct RobloxWebViewMessageBusSymbols {
GetWebViewCloseWindowIdFn get_close_window_id = nullptr;
SignalWebViewJavascriptCallbackFn signal_javascript_callback = nullptr;
UpdateRobloxCookieSetHandlerFn update_cookie_set_handler = nullptr;
// Optional: found by code contract, not by symbol name.
AcquireWebViewProtocolFn acquire_web_view_protocol = nullptr;
bool complete() const {
return get_open_window_id != nullptr &&
@@ -287,6 +325,10 @@ private:
std::array<std::shared_ptr<RawCallbackTarget>, kSubscriptionCount>
raw_callback_targets_{};
std::shared_ptr<HostWindowCloseTarget> host_window_close_target_;
// The reference this counts is kept for the process lifetime, as Roblox's
// Android app start keeps its own. Neither Shutdown nor destruction
// releases it.
RobloxWebViewProtocolHandle web_view_protocol_handle_;
std::size_t in_flight_dispatches_ = 0;
bool initializing_ = false;
bool shutting_down_ = false;
+1 -1
View File
@@ -14,7 +14,7 @@ struct ActivePayloadPaths {
std::filesystem::path root;
std::filesystem::path roblox_library;
std::filesystem::path base_apk;
std::filesystem::path x86_64_split_apk;
std::filesystem::path arch_split_apk;
std::filesystem::path assets_content;
std::filesystem::path compatibility_manifest;
std::filesystem::path host_abi_profile;
+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 {
@@ -0,0 +1,19 @@
#ifndef MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
#define MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
#include <string_view>
namespace mocktail::webview {
// Navigation.navigateToFeature features a Roblox verification page sends when
// the user has solved it. The captcha page sends CaptchaSuccess; the generic
// challenge page (challenge/cdn/hybrid) sends challengeCompleted. The WebView
// helper and the runtime both classify page events with this list.
inline bool IsRobloxChallengeSolvedFeature(std::string_view feature) {
return feature == "CaptchaSuccess" || feature == "challengeCompleted" ||
feature == "ChallengeCompleted";
}
} // namespace mocktail::webview
#endif // MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
@@ -0,0 +1,10 @@
# Allowed reduced aarch64 packages from pkgforge-dev/archlinux-pkgs-debloated.
# The installer verifies the rolling release asset IDs and SHA256 digests.
ffmpeg-mini-aarch64.pkg.tar.zst
gdk-pixbuf2-mini-aarch64.pkg.tar.xz
gtk4-mini-aarch64.pkg.tar.zst
icu-mini-aarch64.pkg.tar.zst
librsvg-mini-aarch64.pkg.tar.zst
libxml2-mini-aarch64.pkg.tar.zst
mesa-mini-aarch64.pkg.tar.zst
opus-mini-aarch64.pkg.tar.zst
+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
+8 -5
View File
@@ -1,12 +1,14 @@
# Maintainer: komaruworld <rtysus228@gmail.com>
# Maintainer: vii <coderdayton14@gmail.com>
pkgname=mocktail
pkgver=1.0.3
pkgrel=2
pkgname=nightcap
pkgver=0.3.1
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
url='https://github.com/CoderDayton/nightcap'
license=('Apache-2.0')
provides=('mocktail')
conflicts=('mocktail')
depends=(
'capstone'
'curl'
@@ -16,6 +18,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libutf8proc'
'libyaml'
'minizip'
@@ -1,8 +1,8 @@
pkgbase = mocktail-bin
pkgbase = nightcap-bin
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux (prebuilt)
pkgver = 1.0.4
pkgver = 0.3.1
pkgrel = 1
url = https://github.com/komaruworld/mocktail
url = https://github.com/CoderDayton/nightcap
arch = x86_64
license = Apache-2.0
depends = capstone
@@ -17,6 +17,7 @@ pkgbase = mocktail-bin
depends = libelf
depends = libglvnd
depends = libplacebo
depends = libpng
depends = libsoup3
depends = libutf8proc
depends = libyaml
@@ -29,12 +30,14 @@ pkgbase = mocktail-bin
depends = zlib
optdepends = gamemode: Feral GameMode integration
provides = mocktail
provides = nightcap
conflicts = mocktail
conflicts = mocktail-git
noextract = mocktail-1.0.4-x86_64.pkg.tar.zst
conflicts = nightcap
conflicts = nightcap-git
noextract = nightcap-0.3.1-1-x86_64.pkg.tar.zst
options = !debug
options = !strip
source = mocktail-1.0.4-x86_64.pkg.tar.zst::https://github.com/komaruworld/mocktail/releases/download/1.0.4/mocktail-1.0.4-x86_64.pkg.tar.zst
sha256sums = d5cc7216e88fd2889369e8eb7fba4276b2746c2b084dce566aaaa7505bc2ffb7
source = nightcap-0.3.1-1-x86_64.pkg.tar.zst::https://github.com/CoderDayton/nightcap/releases/download/v0.3.1/nightcap-0.3.1-1-x86_64.pkg.tar.zst
sha256sums = SKIP
pkgname = mocktail-bin
pkgname = nightcap-bin
@@ -1,11 +1,11 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
# Maintainer: vii <coderdayton14 at gmail dot com>
pkgname=mocktail-bin
pkgver=1.0.4
pkgname=nightcap-bin
pkgver=0.3.1
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux (prebuilt)'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
url='https://github.com/CoderDayton/nightcap'
license=('Apache-2.0')
depends=(
'capstone'
@@ -20,6 +20,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libsoup3'
'libutf8proc'
'libyaml'
@@ -32,17 +33,18 @@ depends=(
'zlib'
)
optdepends=('gamemode: Feral GameMode integration')
provides=('mocktail')
conflicts=('mocktail' 'mocktail-git')
provides=('mocktail' 'nightcap')
conflicts=('mocktail' 'nightcap' 'nightcap-git')
options=('!debug' '!strip')
# Release asset names do not include the AUR package release.
_archive="mocktail-${pkgver}-${CARCH}.pkg.tar.zst"
source=("${_archive}::https://github.com/komaruworld/mocktail/releases/download/${pkgver}/${_archive}")
# The release asset carries the pacman package release built by release.yml.
_archive="nightcap-${pkgver}-1-${CARCH}.pkg.tar.zst"
source=("${_archive}::https://github.com/CoderDayton/nightcap/releases/download/v${pkgver}/${_archive}")
noextract=("${_archive}")
sha256sums=('d5cc7216e88fd2889369e8eb7fba4276b2746c2b084dce566aaaa7505bc2ffb7')
# Replace with the checksum published beside the release asset.
sha256sums=('SKIP')
package() {
bsdtar -xf "${srcdir}/${_archive}" -C "${pkgdir}" usr
install -Dm644 "${pkgdir}/usr/share/licenses/mocktail/LICENSE" \
install -Dm644 "${pkgdir}/usr/share/licenses/nightcap/LICENSE" \
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
@@ -1,8 +1,8 @@
pkgbase = mocktail
pkgbase = nightcap-git
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
pkgver = 1.0.4
pkgver = 0.3.1.r0.g0000000
pkgrel = 1
url = https://github.com/komaruworld/mocktail
url = https://github.com/CoderDayton/nightcap
arch = x86_64
license = Apache-2.0
makedepends = cmake>=3.20
@@ -24,6 +24,7 @@ pkgbase = mocktail
depends = libelf
depends = libglvnd
depends = libplacebo
depends = libpng
depends = libsoup3
depends = libutf8proc
depends = libyaml
@@ -35,11 +36,13 @@ pkgbase = mocktail
depends = webkitgtk-6.0
depends = zlib
optdepends = gamemode: Feral GameMode integration
conflicts = mocktail-bin
conflicts = mocktail-git
provides = mocktail
provides = nightcap
conflicts = mocktail
conflicts = nightcap
conflicts = nightcap-bin
options = !debug
options = !lto
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=1.0.4
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#branch=main
sha256sums = SKIP
pkgname = mocktail
pkgname = nightcap-git
@@ -1,11 +1,11 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
# Maintainer: vii <coderdayton14 at gmail dot com>
pkgname=mocktail-git
pkgver=1.0.2.r24.g9bdd737
pkgname=nightcap-git
pkgver=0.3.1.r0.g0000000
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
url='https://github.com/CoderDayton/nightcap'
license=('Apache-2.0')
depends=(
'capstone'
@@ -20,6 +20,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libsoup3'
'libutf8proc'
'libyaml'
@@ -41,31 +42,30 @@ makedepends=(
'vulkan-headers'
)
optdepends=('gamemode: Feral GameMode integration')
provides=('mocktail')
conflicts=('mocktail' 'mocktail-bin')
provides=('mocktail' 'nightcap')
conflicts=('mocktail' 'nightcap' 'nightcap-bin')
options=('!debug')
source=(
'mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main'
'nightcap::git+https://github.com/CoderDayton/nightcap.git#branch=main'
)
sha256sums=(
'SKIP'
)
pkgver() {
cd mocktail
cd nightcap
local version
version="$(sed -n \
's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' \
CMakeLists.txt | head -n 1)"
version="$(git describe --tags --abbrev=0 --match='v[0-9]*' 2>/dev/null)"
version="${version#v}"
printf '%s.r%s.g%s' \
"${version}" \
"${version:-0.0.0}" \
"$(git rev-list --count HEAD)" \
"$(git rev-parse --short=7 HEAD)"
}
prepare() {
cd mocktail
cd nightcap
sed -i \
's|add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)|find_package(VulkanHeaders CONFIG REQUIRED)|' \
@@ -73,7 +73,7 @@ prepare() {
}
build() {
cmake -S mocktail -B build -G Ninja \
cmake -S nightcap -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_INSTALL_LIBDIR=lib \
@@ -86,6 +86,6 @@ build() {
package() {
DESTDIR="${pkgdir}" cmake --install build
install -Dm644 mocktail/LICENSE \
install -Dm644 nightcap/LICENSE \
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
@@ -1,8 +1,8 @@
pkgbase = mocktail-git
pkgbase = nightcap
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
pkgver = 1.0.2.r24.g9bdd737
pkgver = 0.3.1
pkgrel = 1
url = https://github.com/komaruworld/mocktail
url = https://github.com/CoderDayton/nightcap
arch = x86_64
license = Apache-2.0
makedepends = cmake>=3.20
@@ -24,6 +24,7 @@ pkgbase = mocktail-git
depends = libelf
depends = libglvnd
depends = libplacebo
depends = libpng
depends = libsoup3
depends = libutf8proc
depends = libyaml
@@ -37,9 +38,11 @@ pkgbase = mocktail-git
optdepends = gamemode: Feral GameMode integration
provides = mocktail
conflicts = mocktail
conflicts = mocktail-bin
conflicts = nightcap-bin
conflicts = nightcap-git
options = !debug
source = mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main
options = !lto
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#tag=v0.3.1
sha256sums = SKIP
pkgname = mocktail-git
pkgname = nightcap
@@ -1,11 +1,11 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
# Maintainer: vii <coderdayton14 at gmail dot com>
pkgname=mocktail
pkgver=1.0.4
pkgname=nightcap
pkgver=0.3.1
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
url='https://github.com/CoderDayton/nightcap'
license=('Apache-2.0')
depends=(
'capstone'
@@ -20,6 +20,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libsoup3'
'libutf8proc'
'libyaml'
@@ -41,17 +42,18 @@ makedepends=(
'vulkan-headers'
)
optdepends=('gamemode: Feral GameMode integration')
conflicts=('mocktail-bin' 'mocktail-git')
provides=('mocktail')
conflicts=('mocktail' 'nightcap-bin' 'nightcap-git')
options=('!debug' '!lto')
source=(
"mocktail::git+https://github.com/komaruworld/mocktail.git#tag=${pkgver}"
"nightcap::git+https://github.com/CoderDayton/nightcap.git#tag=v${pkgver}"
)
sha256sums=(
'SKIP'
)
prepare() {
cd mocktail
cd nightcap
sed -i \
's|add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)|find_package(VulkanHeaders CONFIG REQUIRED)|' \
@@ -59,7 +61,7 @@ prepare() {
}
build() {
cmake -S mocktail -B build -G Ninja \
cmake -S nightcap -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_INSTALL_LIBDIR=lib \
@@ -72,6 +74,6 @@ build() {
package() {
DESTDIR="${pkgdir}" cmake --install build
install -Dm644 mocktail/LICENSE \
install -Dm644 nightcap/LICENSE \
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
@@ -9,7 +9,7 @@
"--share=ipc",
"--share=network",
"--socket=wayland",
"--socket=fallback-x11",
"--socket=x11",
"--socket=pulseaudio",
"--device=dri",
"--talk-name=com.feralinteractive.GameMode",
@@ -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;
}
@@ -21,8 +21,9 @@
Sign in to Roblox, browse experiences, and play them on Linux.
</p>
<p>
Mocktail is an independent community project. It is not affiliated with
Roblox Corporation or VinegarHQ, and the Roblox client is not included.
Nightcap is an independent community project, forked from Mocktail. It
is not affiliated with Roblox Corporation or VinegarHQ, and the Roblox
client is not included.
</p>
</description>
<screenshots>
@@ -56,45 +57,75 @@
<binary>mocktail</binary>
</provides>
<releases>
<release version="1.0.4" date="2026-09-11">
<release version="0.3.3" date="2026-10-04">
<description>
<ul>
<li>Fixed login issues</li>
<li>Fixed low-resolution textures, skybox rendering and HiDPI scaling.</li>
<li>Added vc support.</li>
<li>Added Xbox and PlayStation gamepad support.</li>
<li>Added Nix support</li>
<li>Improved text input, fullscreen mode, window sizing, and mouse controls.</li>
<li>Updated support for Roblox 2.736.1408.</li>
<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>
<li>The Flatpak build on NVIDIA now uses X11 to avoid Wayland hangs.</li>
</ul>
</description>
</release>
<release version="1.0.3" date="2026-08-16">
<release version="0.3.2" date="2026-09-29">
<description>
<ul>
<li>Fixed issues with downloading Roblox on first launch</li>
<li>Added fflags.json for custom FFlags</li>
<li>Added a message if your cookies have expired</li>
<li>Added experimental support for ARM64 Linux.</li>
<li>Fixed login stalling after passing a verification check.</li>
<li>Fixed login check windows staying open after they were solved.</li>
<li>Fixed Play on a private server doing nothing until a sidebar tab was clicked.</li>
<li>Less stutter while joining a game and while textures load in.</li>
<li>Middle click and natural scrolling now work as expected.</li>
</ul>
</description>
</release>
<release version="1.0.2" date="2026-08-14">
<release version="0.3.1" date="2026-09-20">
<description>
<ul>
<li>Fixed the “Roblox is out of date” bug</li>
<li>Improved logging</li>
<li>Fixed issues with Discord RPC where the game you were playing wasn’t displayed</li>
<li>Added DEB, RPM, and pacman packages to every release.</li>
<li>Smoother frames: textures no longer decode on the thread that submits the frame.</li>
<li>Fixed the mouse pointer jumping when a right-click camera drag ended.</li>
<li>Fixed the text caret not moving when a trailing space was typed.</li>
<li>Faster typing in text boxes.</li>
</ul>
</description>
</release>
<release version="1.0.1" date="2026-08-13">
<release version="0.3.0" date="2026-09-18">
<description>
<ul>
<li>Fix issues with GameMode</li>
<li>Add Discord RPC support</li>
<li>Added a custom Discord presence: set the title, state, and icons.</li>
<li>Added --profile to record a trace of the renderer's work.</li>
<li>Faster texture loading: compressed textures decode and resample on worker threads.</li>
<li>Lower CPU use while playing.</li>
<li>Fixed GameMode pinning the game to a few CPU cores.</li>
</ul>
</description>
</release>
<release version="0.2.0" date="2026-09-16">
<description>
<ul>
<li>Discord Rich Presence is on by default.</li>
<li>Added input.raw_mouse for mouse look in the host's own units.</li>
<li>Fixed mouse look after a long turn.</li>
<li>Fixed a first-run failure with "cannot lock appStorage".</li>
<li>Values in fflags.json now win over the performance preset.</li>
</ul>
</description>
</release>
<release version="0.1.0" date="2026-09-15">
<description>
<ul>
<li>First Nightcap release, forked from Mocktail.</li>
<li>Added ETC2 and EAC texture emulation on Vulkan.</li>
<li>Added PNG texture overrides.</li>
<li>Lower input latency.</li>
<li>Roblox now sees the real host RAM and display size.</li>
</ul>
</description>
</release>
<release version="0.1.0" date="2026-08-12"/>
</releases>
</component>
+2
View File
@@ -23,6 +23,7 @@
elfutils,
libglvnd,
libplacebo,
libpng,
libsoup_3,
libutf8proc,
libyaml,
@@ -92,6 +93,7 @@ stdenv.mkDerivation (finalAttrs: {
elfutils
libglvnd
libplacebo
libpng
libsoup_3
libutf8proc
libyaml
+2
View File
@@ -461,6 +461,7 @@ LatestSupportedBootstrapProfile() {
.version_code <= ($remote_version_code | tonumber)) and
.status == "supported" and .default_allowed == true and
.allow_legacy_binary_patches == false and
(.abi // "x86_64") == "x86_64" and
(.version_name | type) == "string" and
(.version_name | length) > 0)] |
group_by(.version_code) |
@@ -495,6 +496,7 @@ SelectSupportedBootstrapFallback() {
bootstrap_source="$(jq -er --argjson version_code "${fallback_version_code}" '
[.sources[]? |
select(.version_code == $version_code and
(.abi // "x86_64") == "x86_64" and
.provider == "uptodown")] |
if length == 1 then .[0].provider else empty end
' "${BOOTSTRAP_SOURCES_PATH}" 2>/dev/null || true)"
+2 -1
View File
@@ -329,6 +329,7 @@ def run_trial(args, max_ctors, skipped_offsets, trial_id):
log_path = args.log_dir / f"trial_{trial_id:03d}_max_{max_ctors}.log"
env = os.environ.copy()
env.update({
"ROBLOX_LIB_PATH": str(args.libroblox),
"MOCKTAIL_RUN_LIBROBLOX_CTORS": "1",
"MOCKTAIL_LIBROBLOX_CTOR_POLICY": args.ctor_policy,
"MOCKTAIL_GRAPHICS_BACKEND": args.graphics_backend,
@@ -500,7 +501,7 @@ def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--project-root", type=Path, default=project_root)
parser.add_argument("--run-script", type=Path,
default=project_root / "scripts" / "run_sober.sh")
default=project_root / "build" / "mocktail")
parser.add_argument("--libroblox", type=Path,
default=project_root / "rbx_bin" / "libroblox.so")
parser.add_argument("--log-dir", type=Path,
+9 -6
View File
@@ -10,18 +10,18 @@ readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)"
BUILD_DIR="${PROJECT_ROOT}/build-flatpak"
MANIFEST="${PROJECT_ROOT}/packaging/flatpak/io.github.CoderDayton.nightcap.json"
JOBS="${MOCKTAIL_FLATPAK_JOBS:-4}"
JOBS="${MOCKTAIL_FLATPAK_JOBS:-2}"
Usage() {
cat <<'EOF'
Usage: scripts/build_flatpak.sh [OPTIONS]
Build and install Mocktail as a per-user x86-64 Flatpak.
Build and install Mocktail as a per-user Flatpak for the host architecture.
Options:
--build-dir DIR Builder output directory (default: build-flatpak).
--manifest FILE Flatpak manifest path.
--jobs N Maximum parallel build jobs (default: 4).
--jobs N Maximum parallel build jobs (default: 2).
-h, --help Show this help.
EOF
}
@@ -93,8 +93,11 @@ done
[[ "${JOBS}" =~ ^[1-9][0-9]*$ && "${JOBS}" -le 256 ]] ||
Die "--jobs must be an integer between 1 and 256"
[[ "$(uname -m)" == x86_64 ]] ||
Die "the Roblox payload and Flatpak manifest currently require x86-64"
HOST_ARCH="$(uname -m)"
case "${HOST_ARCH}" in
x86_64|aarch64) ;;
*) Die "Flatpak builds require an x86_64 or aarch64 Linux host" ;;
esac
command -v flatpak >/dev/null 2>&1 || Die "flatpak is required"
MANIFEST="$(realpath -e -- "${MANIFEST}")" ||
@@ -123,7 +126,7 @@ CleanupStaleBuilderMounts
cd -- "${PROJECT_ROOT}"
exec "${builder[@]}" \
--arch=x86_64 \
--arch="${HOST_ARCH}" \
--jobs="${JOBS}" \
--force-clean \
--install-deps-from=flathub \
+1
View File
@@ -61,6 +61,7 @@ def supported_profiles(catalog: dict) -> list[dict]:
if isinstance(profile, dict)
and profile.get("status") == "supported"
and profile.get("default_allowed") is True
and profile.get("abi", ARCHITECTURE) == ARCHITECTURE
and isinstance(profile.get("version_name"), str)
and isinstance(profile.get("version_code"), int)
]
+12 -3
View File
@@ -18,12 +18,15 @@ AnyLinuxDependencyRequireContainer() {
}
AnyLinuxDependencySnapshot() {
local manifest="$1" release="$2" filename remainder names_json
local manifest="$1" release="$2" host_arch="${3:-$(uname -m)}"
local filename remainder names_json
[[ "${host_arch}" == x86_64 || "${host_arch}" == aarch64 ]] ||
AnyLinuxDependencyDie 'unsupported AppImage architecture'
local -a names=()
local -A seen=()
while read -r filename remainder || [[ -n "${filename}" ]]; do
[[ -n "${filename}" && "${filename}" != \#* ]] || continue
[[ "${filename}" =~ ^[a-z0-9-]+-mini-x86_64\.pkg\.tar\.zst$ &&
[[ "${filename}" =~ ^[a-z0-9-]+-mini-${host_arch}\.pkg\.tar\.(zst|xz)$ &&
-z "${remainder}" && -z "${seen[${filename}]:-}" ]] ||
AnyLinuxDependencyDie 'invalid or duplicate dependency manifest entry'
seen["${filename}"]=1
@@ -108,7 +111,13 @@ AnyLinuxDependenciesMain() (
AnyLinuxDependencyRequireContainer
(( $# == 0 )) || AnyLinuxDependencyDie 'no arguments are supported'
readonly ROOT="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
readonly MANIFEST="${ROOT}/packaging/anylinux-dependencies.txt"
local host_arch="$(uname -m)" manifest_name
case "${host_arch}" in
x86_64) manifest_name=anylinux-dependencies.txt ;;
aarch64) manifest_name=anylinux-dependencies-aarch64.txt ;;
*) AnyLinuxDependencyDie "unsupported AppImage architecture: ${host_arch}" ;;
esac
readonly MANIFEST="${ROOT}/packaging/${manifest_name}"
readonly DOWNLOADS="$(mktemp -d "${TMPDIR:-/tmp}/mocktail-debloated.XXXXXX")"
trap 'rm -rf -- "${DOWNLOADS}"' EXIT
AnyLinuxDownloadDependencies "${MANIFEST}" "${DOWNLOADS}"
+3
View File
@@ -119,6 +119,7 @@ libyaml
fontconfig
minizip
libplacebo
libpng
util-linux'
INSTALL_ARGS='-S --needed --noconfirm'
;;
@@ -144,6 +145,7 @@ libyaml
fontconfig
minizip
libplacebo
libpng
util-linux'
if HostUsesMusl; then
PACKAGES="$PACKAGES gcompat"
@@ -172,6 +174,7 @@ libyaml
fontconfig
minizip
libplacebo
libpng
coreutils
findutils
util-linux'
+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
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Modified by vii from komaruworld/mocktail. See README "About this fork".
# Copyright 2026 Mocktail Project Authors
# SPDX-License-Identifier: Apache-2.0
#
# Prints the native package version for the current ref. A release tag gives
# its own version; anything else gives 0.0.0, which sorts below every release.
set -Eeuo pipefail
ref="${1:-${GITHUB_REF_NAME:-}}"
version="${ref#v}"
if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
printf '%s\n' "${version}"
else
printf '0.0.0\n'
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"
+8 -2
View File
@@ -110,8 +110,14 @@ for elf in "$@"; do
fi
machine="$(LC_ALL=C readelf --file-header "${elf}" 2>/dev/null |
sed -n 's/^[[:space:]]*Machine:[[:space:]]*//p')"
if [[ "${machine}" != *X86-64* ]]; then
printf 'ABI check: %s is not x86-64 (%s)\n' "${elf}" "${machine}" >&2
case "$(uname -m)" in
x86_64) expected_machine='X86-64' ;;
aarch64) expected_machine='AArch64' ;;
*) expected_machine='X86-64' ;;
esac
if [[ "${machine}" != *"${expected_machine}"* ]]; then
printf 'ABI check: %s is not %s (%s)\n' "${elf}" "${expected_machine}" \
"${machine}" >&2
status=1
fi
+5
View File
@@ -22,6 +22,10 @@
<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
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
cd nightcap &amp;&amp; make build
@@ -40,6 +44,7 @@ cd nightcap &amp;&amp; make build
<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>
+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;
+16 -2
View File
@@ -16,6 +16,8 @@
#include <cstdlib>
#include <cstring>
#include "compat/bionic_pthread_create_runtime.h"
#include "compat/guest_abi.h"
#include "compat/http_client_spin_guard.h"
#include "window/window.h"
@@ -31,6 +33,14 @@ const char* EnvironmentOr(const char* name, const char* fallback) {
return value != nullptr && value[0] != '\0' ? value : fallback;
}
const char* GuestAbiPropertyValue() {
#if defined(__aarch64__)
return "arm64-v8a";
#else
return "x86_64";
#endif
}
int CallHostVsnprintf(char* dst, size_t count, const char* format,
va_list args) {
using HostVsnprintf = int (*)(char*, size_t, const char*, va_list);
@@ -173,8 +183,12 @@ int __system_property_get(const char* name, char* value) {
result = "35";
} else if (std::strcmp(name, "ro.build.version.release") == 0) {
result = "15";
} else if (std::strcmp(name, "ro.product.cpu.abi") == 0) {
result = "x86_64";
} else if (std::strcmp(name, "ro.product.cpu.abi") == 0 ||
std::strcmp(name, "ro.product.cpu.abilist") == 0 ||
std::strcmp(name, "ro.product.cpu.abilist64") == 0) {
result = GuestAbiPropertyValue();
} else if (std::strcmp(name, "ro.product.cpu.abilist32") == 0) {
result = "";
} else if (std::strcmp(name, "ro.product.manufacturer") == 0) {
result = EnvironmentOr("MOCKTAIL_DEVICE_MANUFACTURER", "Mocktail");
} else if (std::strcmp(name, "ro.product.model") == 0) {
+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;
+167 -71
View File
@@ -1,10 +1,14 @@
#include "compat/bionic_pthread_create_runtime.h"
#include "compat/bionic_abi_exports.h"
#include <errno.h>
#include <stdio.h>
#include <unistd.h>
#include <algorithm>
#include <atomic>
#include <cstring>
#include <limits>
#include <new>
@@ -69,24 +73,28 @@ size_t HostStackSizeForGuest(size_t guest_stack_size) noexcept {
return guest_stack_size;
}
int CopySupportedThreadAttributes(const pthread_attr_t& source,
// Guest pthread_attr_t buffers use the Bionic LP64 layout; they are never a
// host pthread_attr_t, so decode the fixed fields directly.
int GuestDetachState(const MocktailBionicPthreadAttr& source) noexcept {
return (source.flags & kMocktailBionicAttrFlagDetached) != 0
? PTHREAD_CREATE_DETACHED
: PTHREAD_CREATE_JOINABLE;
}
size_t GuestStackSize(const MocktailBionicPthreadAttr& source) noexcept {
return source.stack_size != 0 ? source.stack_size
: kBionicLp64DefaultThreadStackSize;
}
int CopySupportedThreadAttributes(const MocktailBionicPthreadAttr& source,
pthread_attr_t* destination) noexcept {
int detach_state = PTHREAD_CREATE_JOINABLE;
int result = pthread_attr_getdetachstate(&source, &detach_state);
if (result != 0) {
return result;
}
result = pthread_attr_setdetachstate(destination, detach_state);
int result =
pthread_attr_setdetachstate(destination, GuestDetachState(source));
if (result != 0) {
return result;
}
size_t guard_size = 0;
result = pthread_attr_getguardsize(&source, &guard_size);
if (result != 0) {
return result;
}
result = pthread_attr_setguardsize(destination, guard_size);
result = pthread_attr_setguardsize(destination, source.guard_size);
if (result != 0) {
return result;
}
@@ -95,53 +103,38 @@ int CopySupportedThreadAttributes(const pthread_attr_t& source,
// pthread_attr_setstack. Copying pthread_attr_getstack's address is unsafe:
// glibc represents an automatically allocated stack with a synthetic
// address derived from its size.
size_t stack_size = 0;
result = pthread_attr_getstacksize(&source, &stack_size);
if (result != 0) {
return result;
}
result = pthread_attr_setstacksize(destination,
HostStackSizeForGuest(stack_size));
result = pthread_attr_setstacksize(
destination, HostStackSizeForGuest(GuestStackSize(source)));
if (result != 0) {
return result;
}
int inherit_scheduler = PTHREAD_INHERIT_SCHED;
result = pthread_attr_getinheritsched(&source, &inherit_scheduler);
if (result != 0) {
return result;
// Bionic applies the attribute's scheduling policy at creation whenever it
// differs from the normal policy.
if (RejectRealtimeScheduling(source.sched_policy)) {
return EPERM;
}
if (inherit_scheduler == PTHREAD_EXPLICIT_SCHED) {
int scheduler_policy = 0;
sched_param scheduler_parameters{};
result = pthread_attr_getschedpolicy(&source, &scheduler_policy);
if (result == 0) {
result = pthread_attr_getschedparam(&source, &scheduler_parameters);
}
if (result == 0 && RejectRealtimeScheduling(scheduler_policy)) {
return EPERM;
}
if (result == 0) {
result = pthread_attr_setschedpolicy(destination, scheduler_policy);
}
if (result == 0) {
result = pthread_attr_setschedparam(destination, &scheduler_parameters);
}
if (source.sched_policy != SCHED_NORMAL) {
result = pthread_attr_setschedpolicy(destination, source.sched_policy);
if (result != 0) {
return result;
}
sched_param scheduler_parameters{};
scheduler_parameters.sched_priority = source.sched_priority;
result = pthread_attr_setschedparam(destination, &scheduler_parameters);
if (result != 0) {
return result;
}
return pthread_attr_setinheritsched(destination, PTHREAD_EXPLICIT_SCHED);
}
return pthread_attr_setinheritsched(destination, inherit_scheduler);
return pthread_attr_setinheritsched(destination, PTHREAD_INHERIT_SCHED);
}
int CopyRequiredThreadAttributes(const pthread_attr_t& source,
int CopyRequiredThreadAttributes(const MocktailBionicPthreadAttr& source,
pthread_attr_t* destination) noexcept {
int detach_state = PTHREAD_CREATE_JOINABLE;
int result = pthread_attr_getdetachstate(&source, &detach_state);
if (result != 0) {
return result;
}
result = pthread_attr_setdetachstate(destination, detach_state);
int result =
pthread_attr_setdetachstate(destination, GuestDetachState(source));
if (result != 0) {
return result;
}
@@ -149,32 +142,20 @@ int CopyRequiredThreadAttributes(const pthread_attr_t& source,
// The retry must retain an explicitly requested guest stack. Falling back
// to a null/default host attr is unsafe on musl, whose default stack is much
// smaller than Bionic's and can be exhausted by a single libroblox frame.
size_t stack_size = 0;
result = pthread_attr_getstacksize(&source, &stack_size);
if (result != 0) {
return result;
}
return pthread_attr_setstacksize(destination,
HostStackSizeForGuest(stack_size));
return pthread_attr_setstacksize(
destination, HostStackSizeForGuest(GuestStackSize(source)));
}
int ConfigureHostSafeStackFallback(const pthread_attr_t* source,
int ConfigureHostSafeStackFallback(const MocktailBionicPthreadAttr* source,
pthread_attr_t* destination) noexcept {
size_t guest_stack_size = 0;
if (source != nullptr) {
int detach_state = PTHREAD_CREATE_JOINABLE;
int result = pthread_attr_getdetachstate(source, &detach_state);
if (result != 0) {
return result;
}
result = pthread_attr_setdetachstate(destination, detach_state);
if (result != 0) {
return result;
}
result = pthread_attr_getstacksize(source, &guest_stack_size);
int result =
pthread_attr_setdetachstate(destination, GuestDetachState(*source));
if (result != 0) {
return result;
}
guest_stack_size = GuestStackSize(*source);
}
size_t host_default_stack_size = 0;
@@ -207,7 +188,8 @@ void ConfigureBionicPthreadThreadInitializer(
g_thread_initializer.store(initializer, std::memory_order_release);
}
int CreateBionicPthread(pthread_t* thread, const pthread_attr_t* attr,
int CreateBionicPthread(pthread_t* thread,
const MocktailBionicPthreadAttr* attr,
void* (*start_routine)(void*), void* argument) {
if (thread == nullptr || start_routine == nullptr) {
return EINVAL;
@@ -289,10 +271,124 @@ int CreateBionicPthread(pthread_t* thread, const pthread_attr_t* attr,
} // namespace mocktail::compat
extern "C" int mocktail_bionic_pthread_create(pthread_t* thread,
const pthread_attr_t* attr,
void* (*start_routine)(void*),
void* argument) {
extern "C" {
int mocktail_pthread_attr_init(MocktailBionicPthreadAttr* attr) {
if (attr == nullptr) {
return EINVAL;
}
attr->flags = 0;
attr->stack_base = nullptr;
attr->stack_size =
mocktail::compat::kBionicLp64DefaultThreadStackSize;
const long page_size = sysconf(_SC_PAGESIZE);
attr->guard_size = page_size > 0 ? static_cast<size_t>(page_size) : 0;
attr->sched_policy = SCHED_OTHER;
attr->sched_priority = 0;
std::memset(attr->reserved, 0, sizeof(attr->reserved));
return 0;
}
int mocktail_pthread_attr_destroy(MocktailBionicPthreadAttr* attr) {
(void)attr;
return 0;
}
int mocktail_pthread_attr_setstacksize(MocktailBionicPthreadAttr* attr,
size_t stack_size) {
if (attr == nullptr || stack_size < PTHREAD_STACK_MIN) {
return EINVAL;
}
attr->stack_size = stack_size;
attr->flags &= ~kMocktailBionicAttrFlagUserStack;
return 0;
}
int mocktail_pthread_attr_setdetachstate(MocktailBionicPthreadAttr* attr,
int detach_state) {
if (attr == nullptr || (detach_state != PTHREAD_CREATE_DETACHED &&
detach_state != PTHREAD_CREATE_JOINABLE)) {
return EINVAL;
}
if (detach_state == PTHREAD_CREATE_DETACHED) {
attr->flags |= kMocktailBionicAttrFlagDetached;
} else {
attr->flags &= ~kMocktailBionicAttrFlagDetached;
}
return 0;
}
int mocktail_pthread_attr_setschedparam(MocktailBionicPthreadAttr* attr,
const struct sched_param* parameters) {
if (attr == nullptr || parameters == nullptr) {
return EINVAL;
}
attr->sched_priority = parameters->sched_priority;
return 0;
}
int mocktail_pthread_getattr_np(pthread_t thread,
MocktailBionicPthreadAttr* attr) {
if (attr == nullptr) {
return EINVAL;
}
pthread_attr_t host_attr;
int result = pthread_getattr_np(thread, &host_attr);
if (result != 0) {
return result;
}
void* stack_base = nullptr;
size_t stack_size = 0;
result = pthread_attr_getstack(&host_attr, &stack_base, &stack_size);
size_t guard_size = 0;
if (result == 0) {
result = pthread_attr_getguardsize(&host_attr, &guard_size);
}
int detach_state = PTHREAD_CREATE_JOINABLE;
if (result == 0) {
result = pthread_attr_getdetachstate(&host_attr, &detach_state);
}
sched_param parameters{};
int policy = SCHED_OTHER;
if (result == 0) {
result = pthread_getschedparam(thread, &policy, &parameters);
}
pthread_attr_destroy(&host_attr);
if (result != 0) {
return result;
}
attr->flags = detach_state == PTHREAD_CREATE_DETACHED
? kMocktailBionicAttrFlagDetached
: 0;
attr->stack_base = stack_base;
attr->stack_size = stack_size;
attr->guard_size = guard_size;
attr->sched_policy = policy;
attr->sched_priority = parameters.sched_priority;
std::memset(attr->reserved, 0, sizeof(attr->reserved));
return 0;
}
int mocktail_pthread_attr_getstack(const MocktailBionicPthreadAttr* attr,
void** stack_base, size_t* stack_size) {
if (attr == nullptr) {
return EINVAL;
}
if (stack_base != nullptr) {
*stack_base = attr->stack_base;
}
if (stack_size != nullptr) {
*stack_size = attr->stack_size;
}
return 0;
}
} // extern "C"
extern "C" int mocktail_bionic_pthread_create(
pthread_t* thread, const MocktailBionicPthreadAttr* attr,
void* (*start_routine)(void*), void* argument) {
return mocktail::compat::CreateBionicPthread(thread, attr, start_routine,
argument);
}
+11
View File
@@ -39,12 +39,23 @@ bool PatchAbsoluteJump(uintptr_t address, void *target) noexcept {
return false;
}
#if defined(__aarch64__)
// ldr x16, #8 ; br x16 ; .quad target
// x16 is the intra-procedure-call scratch register, free to clobber.
unsigned char patch[] = {
0x50, 0x00, 0x00, 0x58, 0x00, 0x02, 0x1f, 0xd6,
0, 0, 0, 0, 0, 0, 0, 0,
};
const uintptr_t target_address = reinterpret_cast<uintptr_t>(target);
std::memcpy(patch + 8, &target_address, sizeof(target_address));
#else
unsigned char patch[] = {
0x48, 0xb8, // movabs rax, imm64
0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xe0, // jmp rax
};
const uintptr_t target_address = reinterpret_cast<uintptr_t>(target);
std::memcpy(patch + 2, &target_address, sizeof(target_address));
#endif
const uintptr_t page_mask = static_cast<uintptr_t>(page_size) - 1;
const uintptr_t page = address & ~page_mask;
+28
View File
@@ -6,6 +6,34 @@ namespace mocktail::compat {
namespace {
constexpr HostAbiProfile kProfiles[] = {
// Roblox 2.736.1408 arm64-v8a. Every RVA was derived from the AArch64
// payload of the same release as the x86-64 reference above, matched by
// allocator internals, init_array call graphs, and static state layout.
// Constructor policy mirrors x86-64 by semantic role: the pre-init
// entries 0..3 stay skipped, index 4 is mi_process_load (thread init
// checkpoint), index 5 is allocator wiring and index 6 is the lazy clock
// singleton getter, both excluded under host bridges only.
{"ed2b035768b1513561d0e6ea7f9bbec35b0e60e5",
{{{0x1d53840, HostBridgeKind::kAllocate, "small-allocate"},
{0x1d56c34, HostBridgeKind::kUsableSize, "usable-size"},
{0x1d6ef80, HostBridgeKind::kReallocate, "reallocate"},
{0x1d53568, HostBridgeKind::kAllocate, "allocate"},
{0x61d3850, HostBridgeKind::kAlignedAllocate,
"aligned-allocate-direct"},
{0x1d57328, HostBridgeKind::kFree, "free"}}},
6,
{0, 0, 0x7296c88, 0x400, 0x1d53c54, 0x1dc76a0,
0x71d6c90, 0x71d2c40, 0x400000},
{0x1d53568, 0x1d57328},
0x6733128,
3565,
{{{4, 5}, {7, 3565}}},
2,
{{{4, 3565}}},
1,
4,
{0x2132c5c, 0x71d6d30},
HostAllocatorStrategy::kNativeMimalloc},
// Roblox 2.736.1408. Derived from the verified 2908 profile and validated
// by two isolated no-recovery Vulkan canaries and real game sessions.
{"ade08266c67aee88ec9c1d00902150e1684dad3a",
+19 -10
View File
@@ -32,6 +32,7 @@
#include <openssl/evp.h>
#include "compat/elf_build_id.h"
#include "compat/guest_abi.h"
namespace mocktail::compat {
namespace {
@@ -611,19 +612,26 @@ std::unique_ptr<OwnedHostAbiProfile> ParseProfile(const Json& root,
*error = "external host ABI profile has inconsistent constructor policy";
return nullptr;
}
if (owned->profile.init_array_count <= 5 ||
// The verified constructor layout is the same on both guest ABIs: a single
// process-load constructor at the thread-initializer checkpoint, two wiring
// entries excluded under host bridges, and one contiguous native-mimalloc
// replay from the checkpoint to the end of .init_array. Only the checkpoint
// index itself differs between payloads.
const std::size_t checkpoint =
owned->profile.native_mimalloc_thread_initializer_after_constructor;
if (owned->profile.init_array_count <= checkpoint + 3 ||
owned->profile.constructor_run_range_count != 2 ||
owned->profile.constructor_run_ranges[0].begin != 2 ||
owned->profile.constructor_run_ranges[0].end_exclusive != 3 ||
owned->profile.constructor_run_ranges[1].begin != 5 ||
owned->profile.constructor_run_ranges[0].begin != checkpoint ||
owned->profile.constructor_run_ranges[0].end_exclusive != checkpoint + 1 ||
owned->profile.constructor_run_ranges[1].begin != checkpoint + 3 ||
owned->profile.constructor_run_ranges[1].end_exclusive !=
owned->profile.init_array_count ||
owned->profile.native_mimalloc_constructor_run_range_count != 1 ||
owned->profile.native_mimalloc_constructor_run_ranges[0].begin != 2 ||
owned->profile.native_mimalloc_constructor_run_ranges[0].begin !=
checkpoint ||
owned->profile.native_mimalloc_constructor_run_ranges[0].end_exclusive !=
owned->profile.init_array_count ||
owned->profile.native_mimalloc_thread_initializer_after_constructor !=
2) {
(kGuestElfMachine == EM_X86_64 && checkpoint != 2)) {
*error =
"external host ABI profile changed the verified constructor policy";
return nullptr;
@@ -669,9 +677,10 @@ bool ValidateProfileAgainstElf(const std::string& payload_path,
}
GElf_Ehdr header{};
if (gelf_getehdr(elf.get(), &header) == nullptr ||
gelf_getclass(elf.get()) != ELFCLASS64 || header.e_machine != EM_X86_64 ||
header.e_type != ET_DYN) {
*error = "payload must be an x86-64 ET_DYN ELF object";
gelf_getclass(elf.get()) != ELFCLASS64 ||
header.e_machine != kGuestElfMachine || header.e_type != ET_DYN) {
*error = "payload must be a " + std::string(kGuestAbi) +
" ET_DYN ELF object";
return false;
}
+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
+193 -21
View File
@@ -2,6 +2,7 @@
// Android Vulkan loader ABI -> host Vulkan loader + SDL3 WSI.
#include <dlfcn.h>
#include <pthread.h>
#include <time.h>
#include <vulkan/vulkan.h>
@@ -11,6 +12,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <mutex>
#include <string>
#include <utility>
@@ -1149,16 +1151,28 @@ ObservedHostQueuePresent(VkQueue queue, const VkPresentInfoKHR* present_info) {
return result;
}
// Samples the time between successive adapter present calls.
// Samples the time between successive adapter present calls, and the CPU
// time the presenting thread and the whole process used in it.
void RecordProfiledFrame(mocktail::graphics::ChromeTraceWriter* trace,
std::uint64_t present_start_ns) {
static std::atomic<std::uint64_t> previous_start_ns{0};
static mocktail::graphics::FrameCpuSampler cpu_sampler;
const std::uint64_t previous =
previous_start_ns.exchange(present_start_ns, std::memory_order_relaxed);
if (previous != 0 && present_start_ns > previous) {
trace->Counter("frame interval (ms)", present_start_ns,
static_cast<double>(present_start_ns - previous) / 1e6);
}
mocktail::graphics::FrameCpuSample cpu;
if (cpu_sampler.Sample(static_cast<std::uint64_t>(pthread_self()),
mocktail::graphics::TraceThreadCpuNanos(),
mocktail::graphics::TraceProcessCpuNanos(), &cpu)) {
trace->Counter("process cpu (ms)", present_start_ns, cpu.process_ms);
if (cpu.has_thread) {
trace->Counter("present thread cpu (ms)", present_start_ns,
cpu.thread_ms);
}
}
}
// Pipeline and shader creation is wrapped only while profiling, so a normal
@@ -1240,7 +1254,32 @@ VkResult VKAPI_CALL ProfiledCreatePipelineCache(
return result;
}
VkResult VKAPI_CALL ProfiledAllocateMemory(
VkDevice device, const VkMemoryAllocateInfo* allocate_info,
const VkAllocationCallbacks* allocator, VkDeviceMemory* memory) {
const auto host = reinterpret_cast<PFN_vkAllocateMemory>(
HostDeviceProc(device, "vkAllocateMemory"));
if (host == nullptr) {
return VK_ERROR_INITIALIZATION_FAILED;
}
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkAllocateMemory", "memory");
const VkResult result = host(device, allocate_info, allocator, memory);
scope.Arg("bytes", allocate_info != nullptr
? static_cast<std::int64_t>(
allocate_info->allocationSize)
: 0);
scope.Arg("type", allocate_info != nullptr
? static_cast<std::int64_t>(
allocate_info->memoryTypeIndex)
: -1);
scope.Arg("result", result);
return result;
}
// Null unless profiling is on and `name` is a profiled device command.
// These take precedence over the ETC2 wrappers, so a command both wrap is
// timed inside its adapter function instead.
PFN_vkVoidFunction ProfileAdapterProc(const char* name) {
if (name == nullptr || mocktail::graphics::ActiveProfileTrace() == nullptr) {
return nullptr;
@@ -1259,6 +1298,9 @@ PFN_vkVoidFunction ProfileAdapterProc(const char* name) {
if (std::strcmp(name, "vkCreatePipelineCache") == 0) {
return reinterpret_cast<PFN_vkVoidFunction>(ProfiledCreatePipelineCache);
}
if (std::strcmp(name, "vkAllocateMemory") == 0) {
return reinterpret_cast<PFN_vkVoidFunction>(ProfiledAllocateMemory);
}
return nullptr;
}
@@ -1300,18 +1342,134 @@ void ReleaseEtc2PoolCommandBuffers(VkDevice device,
}
}
void PrepareEtc2Submit2(std::uint32_t submit_count,
const VkSubmitInfo2* submits) {
if (submits == nullptr) {
return;
// The stage the decoded staging bytes are read at. Every emulated upload is
// a buffer-to-image copy, so the transfer stage is the earliest point the GPU
// may touch them.
constexpr VkPipelineStageFlags kEtc2WaitStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
// Holds the arrays a rewritten submit points at, so they outlive the call.
// Deques keep earlier elements addressable as later ones are appended.
struct Etc2SubmitRewrite {
std::deque<std::vector<VkSemaphore>> wait_semaphores;
std::deque<std::vector<VkPipelineStageFlags>> wait_stages;
std::deque<std::vector<std::uint64_t>> wait_values;
std::deque<std::vector<std::uint64_t>> signal_values;
std::deque<VkTimelineSemaphoreSubmitInfo> timelines;
std::deque<std::vector<VkSemaphoreSubmitInfo>> wait_infos;
std::vector<VkSubmitInfo> rewritten;
std::vector<VkSubmitInfo2> rewritten2;
};
// Adds each submit's decode wait to its wait list. A submit whose pNext chain
// already sizes arrays by its own semaphore counts is decoded on this thread
// instead: a VkTimelineSemaphoreSubmitInfo would have to be rebuilt, and a
// VkDeviceGroupSubmitInfo's pWaitSemaphoreDeviceIndices would be left one
// entry short of the grown wait list.
const VkSubmitInfo* PrepareEtc2Submit(std::uint32_t submit_count,
const VkSubmitInfo* submits,
Etc2SubmitRewrite* rewrite) {
if (submits == nullptr || submit_count == 0) {
return submits;
}
bool rewritten_any = false;
for (std::uint32_t index = 0; index < submit_count; ++index) {
for (std::uint32_t info = 0; info < submits[index].commandBufferInfoCount;
++info) {
State().etc2.PrepareSubmit(
&submits[index].pCommandBufferInfos[info].commandBuffer, 1);
const VkSubmitInfo& original = submits[index];
const bool fixed_wait_list =
FindFeature(original.pNext,
VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO) !=
nullptr ||
FindFeature(original.pNext,
VK_STRUCTURE_TYPE_DEVICE_GROUP_SUBMIT_INFO) != nullptr;
const mocktail::graphics::Etc2SubmitWait wait = State().etc2.PrepareSubmit(
original.pCommandBuffers, original.commandBufferCount,
!fixed_wait_list);
if (wait.semaphore == VK_NULL_HANDLE) {
continue;
}
if (!rewritten_any) {
rewrite->rewritten.assign(submits, submits + submit_count);
rewritten_any = true;
}
std::vector<VkSemaphore>& semaphores = rewrite->wait_semaphores.emplace_back(
original.pWaitSemaphores,
original.pWaitSemaphores + original.waitSemaphoreCount);
semaphores.push_back(wait.semaphore);
std::vector<VkPipelineStageFlags>& stages =
rewrite->wait_stages.emplace_back(
original.pWaitDstStageMask,
original.pWaitDstStageMask + original.waitSemaphoreCount);
stages.push_back(kEtc2WaitStage);
// Binary semaphores ignore their value; only the appended entry is read.
std::vector<std::uint64_t>& values = rewrite->wait_values.emplace_back(
original.waitSemaphoreCount, 0);
values.push_back(wait.value);
VkTimelineSemaphoreSubmitInfo& timeline =
rewrite->timelines.emplace_back();
timeline.sType = VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO;
timeline.pNext = original.pNext;
timeline.waitSemaphoreValueCount = static_cast<std::uint32_t>(values.size());
timeline.pWaitSemaphoreValues = values.data();
if (original.signalSemaphoreCount != 0) {
std::vector<std::uint64_t>& signals =
rewrite->signal_values.emplace_back(original.signalSemaphoreCount, 0);
timeline.signalSemaphoreValueCount =
static_cast<std::uint32_t>(signals.size());
timeline.pSignalSemaphoreValues = signals.data();
}
VkSubmitInfo& target = rewrite->rewritten[index];
target.pNext = &timeline;
target.waitSemaphoreCount = static_cast<std::uint32_t>(semaphores.size());
target.pWaitSemaphores = semaphores.data();
target.pWaitDstStageMask = stages.data();
}
return rewritten_any ? rewrite->rewritten.data() : submits;
}
const VkSubmitInfo2* PrepareEtc2Submit2(std::uint32_t submit_count,
const VkSubmitInfo2* submits,
Etc2SubmitRewrite* rewrite) {
if (submits == nullptr || submit_count == 0) {
return submits;
}
bool rewritten_any = false;
for (std::uint32_t index = 0; index < submit_count; ++index) {
const VkSubmitInfo2& original = submits[index];
std::vector<VkCommandBuffer> command_buffers;
command_buffers.reserve(original.commandBufferInfoCount);
for (std::uint32_t info = 0; info < original.commandBufferInfoCount;
++info) {
command_buffers.push_back(
original.pCommandBufferInfos[info].commandBuffer);
}
const mocktail::graphics::Etc2SubmitWait wait = State().etc2.PrepareSubmit(
command_buffers.data(),
static_cast<std::uint32_t>(command_buffers.size()), true);
if (wait.semaphore == VK_NULL_HANDLE) {
continue;
}
if (!rewritten_any) {
rewrite->rewritten2.assign(submits, submits + submit_count);
rewritten_any = true;
}
std::vector<VkSemaphoreSubmitInfo>& waits = rewrite->wait_infos.emplace_back(
original.pWaitSemaphoreInfos,
original.pWaitSemaphoreInfos + original.waitSemaphoreInfoCount);
VkSemaphoreSubmitInfo decoded{};
decoded.sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO;
decoded.semaphore = wait.semaphore;
decoded.value = wait.value;
// Matches kEtc2WaitStage: the staging bytes are read by transfer
// commands, which includes the blits an upscaled upload records.
decoded.stageMask = VK_PIPELINE_STAGE_2_ALL_TRANSFER_BIT;
waits.push_back(decoded);
VkSubmitInfo2& target = rewrite->rewritten2[index];
target.waitSemaphoreInfoCount = static_cast<std::uint32_t>(waits.size());
target.pWaitSemaphoreInfos = waits.data();
}
return rewritten_any ? rewrite->rewritten2.data() : submits;
}
} // namespace
@@ -1664,7 +1822,8 @@ VKAPI_ATTR VkResult VKAPI_CALL vkCreateDevice(
host_memory_properties(physical_device, &memory_properties);
}
State().etc2.RegisterDevice(*device, physical_device, etc2_emulated,
memory_properties, host_get_device_proc_addr);
memory_properties, host_get_device_proc_addr,
enabled.vulkan12.timelineSemaphore == VK_TRUE);
std::uint32_t queue_family_count = 0;
const auto host_get_queue_families =
@@ -2143,14 +2302,11 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit(
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkQueueSubmit", "submit");
scope.Arg("submits", submit_count);
if (submits != nullptr) {
for (std::uint32_t index = 0; index < submit_count; ++index) {
State().etc2.PrepareSubmit(submits[index].pCommandBuffers,
submits[index].commandBufferCount);
}
}
Etc2SubmitRewrite rewrite;
const VkSubmitInfo* prepared =
PrepareEtc2Submit(submit_count, submits, &rewrite);
const VkResult result = State().text_overlay.QueueSubmit(
queue, submit_count, submits, fence, host_submit);
queue, submit_count, prepared, fence, host_submit);
observation.SetResult(result);
return result;
}
@@ -2168,9 +2324,11 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit2(
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkQueueSubmit2", "submit");
scope.Arg("submits", submit_count);
PrepareEtc2Submit2(submit_count, submits);
Etc2SubmitRewrite rewrite;
const VkSubmitInfo2* prepared =
PrepareEtc2Submit2(submit_count, submits, &rewrite);
const VkResult result = State().text_overlay.QueueSubmit2(
queue, submit_count, submits, fence, host_submit);
queue, submit_count, prepared, fence, host_submit);
observation.SetResult(result);
return result;
}
@@ -2188,9 +2346,11 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit2KHR(
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkQueueSubmit2KHR", "submit");
scope.Arg("submits", submit_count);
PrepareEtc2Submit2(submit_count, submits);
Etc2SubmitRewrite rewrite;
const VkSubmitInfo2* prepared =
PrepareEtc2Submit2(submit_count, submits, &rewrite);
const VkResult result = State().text_overlay.QueueSubmit2(
queue, submit_count, submits, fence,
queue, submit_count, prepared, fence,
reinterpret_cast<PFN_vkQueueSubmit2>(host_submit));
observation.SetResult(result);
return result;
@@ -2533,11 +2693,15 @@ VKAPI_ATTR VkResult VKAPI_CALL vkGetPhysicalDeviceImageFormatProperties2(
VKAPI_ATTR VkResult VKAPI_CALL vkCreateImage(
VkDevice device, const VkImageCreateInfo* create_info,
const VkAllocationCallbacks* allocator, VkImage* image) {
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkCreateImage", "memory");
return State().etc2.CreateImage(device, create_info, allocator, image);
}
VKAPI_ATTR void VKAPI_CALL vkDestroyImage(
VkDevice device, VkImage image, const VkAllocationCallbacks* allocator) {
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkDestroyImage", "memory");
State().etc2.DestroyImage(device, image, allocator);
}
@@ -2566,6 +2730,8 @@ VKAPI_ATTR VkResult VKAPI_CALL vkMapMemory(VkDevice device,
VkDeviceSize size,
VkMemoryMapFlags flags,
void** data) {
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkMapMemory", "memory");
return State().etc2.MapMemory(device, memory, offset, size, flags, data);
}
@@ -2577,6 +2743,9 @@ VKAPI_ATTR VkResult VKAPI_CALL vkMapMemory2(VkDevice device,
VKAPI_ATTR void VKAPI_CALL vkUnmapMemory(VkDevice device,
VkDeviceMemory memory) {
// Includes waiting for ETC2 decodes that still read this memory.
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkUnmapMemory", "memory");
State().etc2.UnmapMemory(device, memory);
}
@@ -2588,6 +2757,9 @@ VKAPI_ATTR VkResult VKAPI_CALL vkUnmapMemory2(VkDevice device,
VKAPI_ATTR void VKAPI_CALL vkFreeMemory(
VkDevice device, VkDeviceMemory memory,
const VkAllocationCallbacks* allocator) {
// Includes waiting for ETC2 decodes that still read this memory.
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkFreeMemory", "memory");
State().etc2.FreeMemory(device, memory, allocator);
}
+48
View File
@@ -4,6 +4,7 @@
#include <time.h>
#include <unistd.h>
#include <algorithm>
#include <cerrno>
#include <charconv>
#include <chrono>
@@ -106,6 +107,53 @@ std::uint64_t TraceClockNanos() {
static_cast<std::uint64_t>(now.tv_nsec);
}
namespace {
std::uint64_t ClockNanos(clockid_t clock) {
timespec now{};
clock_gettime(clock, &now);
return static_cast<std::uint64_t>(now.tv_sec) * 1'000'000'000ULL +
static_cast<std::uint64_t>(now.tv_nsec);
}
} // namespace
std::uint64_t TraceThreadCpuNanos() {
return ClockNanos(CLOCK_THREAD_CPUTIME_ID);
}
std::uint64_t TraceProcessCpuNanos() {
return ClockNanos(CLOCK_PROCESS_CPUTIME_ID);
}
bool FrameCpuSampler::Sample(std::uint64_t thread_id,
std::uint64_t thread_cpu_ns,
std::uint64_t process_cpu_ns,
FrameCpuSample* sample) {
std::lock_guard<std::mutex> lock(mutex_);
const bool had_previous = has_previous_;
// Clocks are read before the lock, so a reading can arrive after a later
// one; a reused thread id restarts its thread clock. Neither may wrap.
if (had_previous) {
*sample = {};
if (process_cpu_ns > previous_process_cpu_ns_) {
sample->process_ms =
static_cast<double>(process_cpu_ns - previous_process_cpu_ns_) / 1e6;
}
if (thread_id == previous_thread_id_ &&
thread_cpu_ns >= previous_thread_cpu_ns_) {
sample->has_thread = true;
sample->thread_ms =
static_cast<double>(thread_cpu_ns - previous_thread_cpu_ns_) / 1e6;
}
}
has_previous_ = true;
previous_thread_id_ = thread_id;
previous_thread_cpu_ns_ = thread_cpu_ns;
previous_process_cpu_ns_ = std::max(previous_process_cpu_ns_, process_cpu_ns);
return had_previous;
}
std::unique_ptr<ChromeTraceWriter> ChromeTraceWriter::Open(
const std::filesystem::path& path, std::string* error) {
std::FILE* file = std::fopen(path.c_str(), "wb");
+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);
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -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
+448
View File
@@ -0,0 +1,448 @@
#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;
}
auto ref_it = g_jni_ref_counts.find(obj);
if (ref_it != g_jni_ref_counts.end()) {
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
+67 -1042
View File
File diff suppressed because it is too large. Load diff
+153 -16
View File
@@ -37,7 +37,9 @@
#include <fcntl.h>
#include <unistd.h>
#include <sys/prctl.h>
#if defined(__x86_64__)
#include <asm/prctl.h>
#endif
#include <sys/syscall.h>
#include <vector>
@@ -50,6 +52,7 @@
#include "compat/bionic_socket_runtime.h"
#include "compat/build_profile.h"
#include "compat/elf_build_id.h"
#include "compat/guest_abi.h"
#include "compat/host_abi_experiment.h"
#include "compat/host_abi_profile.h"
#include "compat/host_allocator_bridge.h"
@@ -1046,6 +1049,15 @@ void PumpRobloxMainThreadMessagesOnce() {
mocktail::graphics::ChromeTraceWriter* trace =
hooks.active != nullptr ? hooks.active() : nullptr;
const std::uint64_t pump_start_ns = trace != nullptr ? hooks.clock() : 0;
// CPU time separates a step that computes from one that waits.
const auto thread_cpu_ns = [] {
timespec now{};
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &now);
return static_cast<std::uint64_t>(now.tv_sec) * 1'000'000'000ULL +
static_cast<std::uint64_t>(now.tv_nsec);
};
const std::uint64_t pump_start_cpu_ns =
trace != nullptr ? thread_cpu_ns() : 0;
g_native_call_messages_from_main_thread(
env, g_native_gl_class_for_main_thread);
if (trace != nullptr) {
@@ -1053,8 +1065,11 @@ void PumpRobloxMainThreadMessagesOnce() {
// calls that ran a message are recorded.
const std::uint64_t pump_end_ns = hooks.clock();
if (pump_end_ns - pump_start_ns >= 20'000) {
const mocktail::graphics::TraceArg args[] = {
{"cpu_us", static_cast<std::int64_t>(
(thread_cpu_ns() - pump_start_cpu_ns) / 1000)}};
hooks.slice(trace, "nativeCallMessagesFromMainThread", "pump",
pump_start_ns, pump_end_ns, nullptr, 0);
pump_start_ns, pump_end_ns, args, 1);
}
}
if (__builtin_expect(trace_pump, 0)) {
@@ -1096,7 +1111,7 @@ struct BionicAddrInfo {
};
static_assert(sizeof(BionicAddrInfo) == 48,
"unexpected x86_64 bionic addrinfo size");
"unexpected bionic addrinfo size");
bool DnsTraceEnabled() {
return IsEnabled("MOCKTAIL_DNS_TRACE");
@@ -1461,15 +1476,31 @@ bool IsUnsafeSoftTimeoutModule(void* rip) {
void JniOnLoadTimeoutAlarm(int, siginfo_t* info, void* context) {
static_cast<void>(info);
auto* uc = static_cast<ucontext_t*>(context);
#if defined(__x86_64__)
auto rip = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RIP]) : 0;
auto rsp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RSP]) : 0;
auto rbp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RBP]) : 0;
auto rax = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RAX]) : 0;
const char* regs_format =
" [timeout] RIP=0x%016llx RSP=0x%016llx RBP=0x%016llx "
"RAX=0x%016llx\n";
#elif defined(__aarch64__)
auto rip = uc ? static_cast<uintptr_t>(uc->uc_mcontext.pc) : 0;
auto rsp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.sp) : 0;
auto rbp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.regs[29]) : 0;
auto rax = uc ? static_cast<uintptr_t>(uc->uc_mcontext.regs[0]) : 0;
const char* regs_format =
" [timeout] PC=0x%016llx SP=0x%016llx FP=0x%016llx "
"X0=0x%016llx\n";
#else
uintptr_t rip = 0, rsp = 0, rbp = 0, rax = 0;
const char* regs_format =
" [timeout] PC=0x%016llx SP=0x%016llx FP=0x%016llx "
"X0=0x%016llx\n";
#endif
char regs_msg[192];
int len = std::snprintf(
regs_msg, sizeof(regs_msg),
" [timeout] RIP=0x%016llx RSP=0x%016llx RBP=0x%016llx "
"RAX=0x%016llx\n",
regs_msg, sizeof(regs_msg), regs_format,
static_cast<unsigned long long>(rip), static_cast<unsigned long long>(rsp),
static_cast<unsigned long long>(rbp), static_cast<unsigned long long>(rax));
write(2, regs_msg, static_cast<size_t>(len));
@@ -2517,10 +2548,12 @@ jobject BuildDeviceParams(JNIEnv* env) {
GetEnvString("MOCKTAIL_DEVICE_NAME", "Mocktail Linux");
const std::string manufacturer =
GetEnvString("MOCKTAIL_DEVICE_MANUFACTURER", "Mocktail");
const std::string device_sku =
GetEnvString("MOCKTAIL_DEVICE_SKU", "mocktail-x86_64");
const std::string device_sku = GetEnvString(
"MOCKTAIL_DEVICE_SKU",
("mocktail-" + std::string(mocktail::compat::kGuestCpuName)).c_str());
const std::string soc_model =
GetEnvString("MOCKTAIL_DEVICE_SOC_MODEL", "x86_64");
GetEnvString("MOCKTAIL_DEVICE_SOC_MODEL",
std::string(mocktail::compat::kGuestCpuName).c_str());
SetStringField(env, params, "osVersion", "33");
SetStringField(env, params, "deviceName", device_name.c_str());
const std::string app_version =
@@ -4718,12 +4751,14 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
// Roblox internal threads trigger SI_KERNEL traps from CET shadow-stack
// return mismatches. Unsupported kernels ignore this request.
#if defined(__x86_64__)
{
long r = syscall(SYS_arch_prctl, ARCH_SHSTK_DISABLE, ARCH_SHSTK_SHSTK);
if (r == 0) {
std::cout << " [cet] shadow-stack (SHSTK) disabled\n";
}
}
#endif
if (build_profile.allow_legacy_binary_patches) {
std::cerr
@@ -4916,9 +4951,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;
@@ -4929,7 +4963,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) {
@@ -4937,9 +4971,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'
@@ -5643,6 +5676,27 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
reinterpret_cast<void*>(mocktail_pthread_spin_trylock));
linker::RegisterSymbol("pthread_spin_unlock",
reinterpret_cast<void*>(mocktail_pthread_spin_unlock));
linker::RegisterSymbol(
"pthread_attr_init",
reinterpret_cast<void*>(mocktail_pthread_attr_init));
linker::RegisterSymbol(
"pthread_attr_destroy",
reinterpret_cast<void*>(mocktail_pthread_attr_destroy));
linker::RegisterSymbol(
"pthread_attr_setstacksize",
reinterpret_cast<void*>(mocktail_pthread_attr_setstacksize));
linker::RegisterSymbol(
"pthread_attr_setdetachstate",
reinterpret_cast<void*>(mocktail_pthread_attr_setdetachstate));
linker::RegisterSymbol(
"pthread_attr_setschedparam",
reinterpret_cast<void*>(mocktail_pthread_attr_setschedparam));
linker::RegisterSymbol(
"pthread_getattr_np",
reinterpret_cast<void*>(mocktail_pthread_getattr_np));
linker::RegisterSymbol(
"pthread_attr_getstack",
reinterpret_cast<void*>(mocktail_pthread_attr_getstack));
linker::RegisterSymbol("pthread_barrier_init",
reinterpret_cast<void*>(mocktail_pthread_barrier_init));
linker::RegisterSymbol(
@@ -5762,7 +5816,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
if (roblox_handle == nullptr) {
std::cerr << "\n[FATAL] Could not load '" << library_path << "'.\n"
<< " Extract lib/x86_64/libroblox.so from a Roblox APK and\n"
<< " Extract lib/" << compat::kGuestAbi
<< "/libroblox.so from a Roblox APK and\n"
<< " place it at the path above (or set ROBLOX_LIB_PATH).\n";
return EXIT_FAILURE;
}
@@ -5949,6 +6004,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;
@@ -6608,6 +6667,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 =
@@ -6665,7 +6793,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
mocktail::runtime::RobloxExperienceSurfaceProvider{},
discord_rpc.observer(),
dependencies.clear_persisted_web_view_cookie(),
runtime_config.microphone_enabled());
runtime_config.microphone_enabled(),
platform_web_symbols.account);
const mocktail::Status platform_protocol_status =
experience_composition->InitializePlatformProtocols();
if (!platform_protocol_status.ok()) {
@@ -7098,6 +7227,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();
+11 -11
View File
@@ -61,24 +61,24 @@ bool ConvertSdlEvent(SDL_Window* window, const SDL_Event& source,
return true;
case SDL_EVENT_MOUSE_MOTION:
destination->payload = MouseMotionEvent{
source.motion.x, source.motion.y, source.motion.xrel,
source.motion.yrel, static_cast<std::uint32_t>(source.motion.state)};
source.motion.x, source.motion.y,
source.motion.xrel, source.motion.yrel,
static_cast<std::uint32_t>(source.motion.state),
SDL_GetWindowRelativeMouseMode(window)};
return true;
case SDL_EVENT_MOUSE_BUTTON_DOWN:
case SDL_EVENT_MOUSE_BUTTON_UP:
destination->payload = MouseButtonEvent{
source.button.down, source.button.button, source.button.clicks,
source.button.x, source.button.y};
source.button.down, source.button.button, source.button.clicks,
source.button.x, source.button.y,
SDL_GetWindowRelativeMouseMode(window)};
return true;
case SDL_EVENT_MOUSE_WHEEL: {
float delta_x = source.wheel.x;
float delta_y = source.wheel.y;
if (source.wheel.direction == SDL_MOUSEWHEEL_FLIPPED) {
delta_x = -delta_x;
delta_y = -delta_y;
}
// SDL already applies the host's natural-scrolling preference to x/y.
// Undoing SDL_MOUSEWHEEL_FLIPPED here would ignore the OS setting.
destination->payload = MouseWheelEvent{
delta_x, delta_y, source.wheel.mouse_x, source.wheel.mouse_y};
source.wheel.x, source.wheel.y, source.wheel.mouse_x,
source.wheel.mouse_y, SDL_GetWindowRelativeMouseMode(window)};
return true;
}
case SDL_EVENT_FINGER_DOWN:
+5 -3
View File
@@ -6,6 +6,7 @@
#include <string>
#include <string_view>
#include "compat/guest_abi.h"
#include "runtime/roblox_launch_uri.h"
namespace mocktail {
@@ -262,11 +263,12 @@ std::string CommandLineUsage(const std::string& program_name) {
usage
<< "Usage: " << (program_name.empty() ? "mocktail" : program_name)
<< " [options] [roblox-uri]\n\n"
<< "Normal startup downloads and activates a verified managed x86_64 "
"Roblox payload automatically.\n\n"
<< "Normal startup downloads and activates a verified managed "
<< compat::kGuestAbi
<< " Roblox payload automatically.\n\n"
<< "Options:\n"
<< " --roblox-lib <path> Development override for a specific "
"x86_64 libroblox.so\n"
<< compat::kGuestAbi << " libroblox.so\n"
<< " --headless Run without creating an SDL window\n"
<< " --windowed Force windowed startup (default)\n"
<< " --graphics <backend> direct-vulkan | opengl | system | "
+18 -2
View File
@@ -1,10 +1,13 @@
#include "runtime/engine_pump_rest.h"
#include <cpuid.h>
#include <immintrin.h>
#include <sys/prctl.h>
#include <time.h>
#if defined(__x86_64__)
#include <cpuid.h>
#include <immintrin.h>
#include <x86intrin.h>
#endif
#include <cstdio>
#include <cstdlib>
@@ -23,11 +26,20 @@ uint64_t NowNs() {
// The kernel caps one TPAUSE at umwait_control/max_time TSC cycles (100000 by
// default), so the wait is a loop of short pauses up to the deadline. Control
// bit 0 clear selects C0.2, the deeper of the two light states.
#if defined(__x86_64__)
__attribute__((target("waitpkg"))) void TpauseUntil(uint64_t deadline_ns) {
while (NowNs() < deadline_ns) {
_tpause(0, __rdtsc() + 100000ULL);
}
}
#else
// TPAUSE is x86-only. CpuHasWaitPkg() is false on other hosts, so kTpause is
// never chosen there; this waits out the deadline if it is forced anyway.
void TpauseUntil(uint64_t deadline_ns) {
while (NowNs() < deadline_ns) {
}
}
#endif
void LowerTimerSlackOnce() {
// Only the main thread rests, so the slack is set once per process.
@@ -88,11 +100,15 @@ bool CpuGovernorIsPerformance(const char* path) {
}
bool CpuHasWaitPkg() {
#if defined(__x86_64__)
unsigned eax = 0, ebx = 0, ecx = 0, edx = 0;
if (__get_cpuid_count(7, 0, &eax, &ebx, &ecx, &edx) == 0) {
return false;
}
return (ecx & (1u << 5)) != 0;
#else
return false;
#endif
}
EnginePumpRestMode ActiveEnginePumpRestMode() {
+9
View File
@@ -29,12 +29,21 @@ constexpr const char* kIcdDirectories[] = {
"/usr/local/share/vulkan/icd.d",
};
#if defined(__aarch64__)
constexpr char kNativeArchitecture[] = "aarch64";
constexpr const char* kForeignArchitectures[] = {
"i686", "i586", "i486", "i386", "x86_64", "x86.",
"amd64", "armhf", "armv7", "ppc64", "riscv64", "s390x",
};
#else
constexpr char kNativeArchitecture[] = "x86_64";
constexpr const char* kForeignArchitectures[] = {
"i686", "i586", "i486", "i386", "x86.", "aarch64",
"arm64", "armhf", "armv7", "ppc64", "riscv64", "s390x",
};
#endif
struct HostGpus {
bool intel = false;
+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;
}
+239
View File
@@ -0,0 +1,239 @@
#include "runtime/roblox_account_bridge.h"
#include <array>
#include <condition_variable>
#include <cstdio>
#include <iterator>
#include <nlohmann/json.hpp>
#include <string>
#include <utility>
namespace mocktail {
namespace runtime {
namespace {
constexpr char kAccountProtocolClass[] =
"com/roblox/universalapp/account/JNIAccountProtocol";
// Serialized name of the APK status enum for a missing Play token provider.
constexpr char kTokenProviderUninitialized[] = "TOKEN_PROVIDER_UNINITIALIZED";
constexpr std::size_t kMaximumNameBytes = 128;
enum Method : std::size_t { kDeviceIntegrityAvailable, kGetIntegrityToken };
constexpr std::size_t kMethodCount = 2;
constexpr const char* kMethodLabels[kMethodCount] = {"DeviceIntegrityAvailable",
"GetIntegrityToken"};
Status Unavailable(const char* message) {
return Status::Error(StatusCode::kUnavailable, message);
}
Status CheckJni(JNIEnv* env) {
if (!env->ExceptionCheck()) return Status::Ok();
env->ExceptionClear();
return Unavailable("AccountProtocol JNI operation failed");
}
// Copies a non-empty, bounded engine-provided name.
bool ReadName(JNIEnv* env, jstring value, std::string* name) {
if (!value) return false;
const jsize size = env->GetStringUTFLength(value);
const char* bytes = env->GetStringUTFChars(value, nullptr);
const bool valid = bytes && size > 0 &&
static_cast<std::size_t>(size) <= kMaximumNameBytes;
if (valid) name->assign(bytes, static_cast<std::size_t>(size));
if (bytes) env->ReleaseStringUTFChars(value, bytes);
return valid;
}
// Pins a valid engine-provided name as a global reference.
jstring RetainName(JNIEnv* env, jstring value) {
std::string ignored;
return ReadName(env, value, &ignored)
? static_cast<jstring>(env->NewGlobalRef(value))
: nullptr;
}
} // namespace
struct RobloxAccountBridge::State {
struct Endpoint {
std::shared_ptr<State> state;
Method method;
};
struct Registration {
jobject handler = nullptr;
jstring method = nullptr;
bool registered = false;
};
jobject bus = nullptr;
jstring protocol = nullptr;
std::string support_key;
std::string token_key;
std::string result_key;
std::array<Registration, kMethodCount> registrations;
std::mutex mutex;
std::condition_variable drained;
bool accepting = true;
std::size_t in_flight = 0;
// The request payload is never parsed or logged: both answers are fixed.
static std::string Answer(void* context, JNIEnv*, jstring) {
const auto* endpoint = static_cast<Endpoint*>(context);
const auto state = endpoint->state;
{
std::lock_guard<std::mutex> lock(state->mutex);
if (!state->accepting) return {};
++state->in_flight;
}
struct Finish {
std::shared_ptr<State> state;
~Finish() {
std::lock_guard<std::mutex> lock(state->mutex);
--state->in_flight;
state->drained.notify_all();
}
} finish{state};
nlohmann::json body;
const char* result = nullptr;
if (endpoint->method == kDeviceIntegrityAvailable) {
body = {{state->support_key, false}};
result = "unsupported";
} else {
body = {{state->token_key, ""},
{state->result_key, kTokenProviderUninitialized}};
result = kTokenProviderUninitialized;
}
std::fprintf(stderr, " [account] AccountProtocol method=%s result=%s\n",
kMethodLabels[endpoint->method], result);
return body.dump();
}
};
RobloxAccountBridge::RobloxAccountBridge(JniEnvironmentProvider environment,
RobloxAccountProtocolSymbols symbols,
RobloxAccountProtocolObjects objects)
: environment_(environment), symbols_(symbols), objects_(objects) {}
RobloxAccountBridge::~RobloxAccountBridge() { (void)Shutdown(); }
Status RobloxAccountBridge::Initialize() {
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
if (state_) {
return Status::Error(StatusCode::kFailedPrecondition,
"AccountProtocol is already initialized");
}
if (!environment_.valid() || !symbols_.complete() || !objects_.complete()) {
return Unavailable("AccountProtocol prerequisites are incomplete");
}
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
jclass protocol_class = env->FindClass(kAccountProtocolClass);
if (!protocol_class) {
(void)CheckJni(env);
return Unavailable("JNIAccountProtocol class is unavailable");
}
const jstring protocol = symbols_.get_protocol_name(env, protocol_class);
const jstring methods[kMethodCount] = {
symbols_.get_device_integrity_available_method_name(env, protocol_class),
symbols_.get_integrity_token_method_name(env, protocol_class)};
const jstring keys[] = {symbols_.get_support_key(env, protocol_class),
symbols_.get_token_key(env, protocol_class),
symbols_.get_result_key(env, protocol_class)};
env->DeleteLocalRef(protocol_class);
state_ = std::make_shared<State>();
std::string* const key_targets[] = {&state_->support_key,
&state_->token_key, &state_->result_key};
bool names_valid = true;
for (std::size_t i = 0; i < std::size(keys); ++i) {
names_valid = ReadName(env, keys[i], key_targets[i]) && names_valid;
if (keys[i]) env->DeleteLocalRef(keys[i]);
}
state_->protocol = RetainName(env, protocol);
if (protocol) env->DeleteLocalRef(protocol);
for (std::size_t i = 0; i < kMethodCount; ++i) {
state_->registrations[i].method = RetainName(env, methods[i]);
names_valid = state_->registrations[i].method && names_valid;
if (methods[i]) env->DeleteLocalRef(methods[i]);
}
state_->bus = env->NewGlobalRef(objects_.message_bus);
auto fail = [this](Status failure) {
(void)ShutdownLocked();
return failure;
};
if (!CheckJni(env).ok() || !names_valid || !state_->protocol ||
!state_->bus) {
return fail(Unavailable("AccountProtocol names are unavailable"));
}
for (std::size_t i = 0; i < kMethodCount; ++i) {
auto& entry = state_->registrations[i];
auto endpoint = std::make_shared<State::Endpoint>(
State::Endpoint{state_, static_cast<Method>(i)});
jobject handler = objects_.create_request_handler(objects_.context,
endpoint, &State::Answer);
if (handler) {
entry.handler = env->NewGlobalRef(handler);
if (!entry.handler) {
objects_.clear_request_handler(objects_.context, handler);
}
env->DeleteLocalRef(handler);
}
if (!entry.handler || !CheckJni(env).ok()) {
return fail(Unavailable("could not create AccountProtocol handler"));
}
entry.registered = true;
symbols_.set_request_handler_raw(env, state_->bus, state_->protocol,
entry.method, entry.handler);
status = CheckJni(env);
if (!status.ok()) return fail(status);
}
std::fprintf(stderr,
" [account] AccountProtocol ready device_integrity=unsupported\n");
return Status::Ok();
}
Status RobloxAccountBridge::Shutdown() {
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
return ShutdownLocked();
}
Status RobloxAccountBridge::ShutdownLocked() {
if (!state_) return Status::Ok();
{
std::unique_lock<std::mutex> lock(state_->mutex);
state_->accepting = false;
state_->drained.wait(lock, [this] { return state_->in_flight == 0; });
}
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status; // Keep roots for a later shutdown retry.
// Clear any exception from partially failed initialization before cleanup.
const Status pending = CheckJni(env);
if (!pending.ok()) status = pending;
for (auto& entry : state_->registrations) {
if (entry.registered) {
symbols_.clear_request_handler(env, state_->bus, state_->protocol,
entry.method);
const Status cleared = CheckJni(env);
if (!cleared.ok()) status = cleared;
}
if (entry.handler) {
objects_.clear_request_handler(objects_.context, entry.handler);
env->DeleteGlobalRef(entry.handler);
}
if (entry.method) env->DeleteGlobalRef(entry.method);
entry = {};
}
if (state_->protocol) env->DeleteGlobalRef(state_->protocol);
if (state_->bus) env->DeleteGlobalRef(state_->bus);
state_.reset();
return status;
}
} // namespace runtime
} // namespace mocktail
@@ -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
+287 -81
View File
@@ -11,6 +11,7 @@
#include "jnivm/jnivm.h"
#include "runtime/external_launch_broker.h"
#include "runtime/roblox_launch_uri.h"
#include "runtime/webview_helper_launcher.h"
#include "window/window.h"
@@ -21,7 +22,13 @@ namespace {
constexpr size_t kMaxPendingLaunchRequests = 8;
// libroblox's static TLS requires the proven 16 MiB guest-thread stack floor.
constexpr size_t kLaunchWorkerStackSize = 64ULL * 1024 * 1024;
constexpr std::chrono::milliseconds kWebSurfaceReadyTimeout{3000};
// Covers helper exec through window creation, including cold Flatpak starts.
constexpr std::chrono::milliseconds kWebSurfaceReadyTimeout{5000};
// Roblox closes a solved WebViewProtocol challenge itself with closeWindow,
// as on Android. The host closes it only if Roblox has not done so within
// this period. Captcha windows close at once instead (see
// RobloxWebViewOpenRequest::close_when_challenge_solved).
constexpr std::chrono::milliseconds kSolvedChallengeCloseGrace{3000};
constexpr char kRobloxBaseUrl[] = "https://www.roblox.com/";
Status Invalid(std::string message) {
@@ -131,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;
}
@@ -158,24 +170,33 @@ Status LaunchRobloxWebSurface(
}
std::fprintf(stderr, " [%s] opening validated Roblox web surface\n",
transport);
const auto started = std::chrono::steady_clock::now();
const WebViewHelperLaunchResult launched =
LaunchWebViewHelper(helper, url, std::move(exit_observer));
if (!launched) {
return Unavailable("could not display Roblox web surface: " +
launched.error);
}
if (launched.process == nullptr ||
!launched.process->WaitUntilReady(kWebSurfaceReadyTimeout)) {
const bool ready = launched.process != nullptr &&
launched.process->WaitUntilReady(kWebSurfaceReadyTimeout);
const long long elapsed_ms =
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - started)
.count();
if (!ready) {
if (launched.process != nullptr) {
(void)launched.process->RequestClose();
}
return Unavailable(
"Roblox web surface did not become ready before the deadline");
"Roblox web surface did not become ready before the deadline (" +
std::to_string(elapsed_ms) + " ms of " +
std::to_string(kWebSurfaceReadyTimeout.count()) + " ms)");
}
if (process != nullptr) {
*process = launched.process;
}
std::fprintf(stderr, " [%s] Roblox web surface launched\n", transport);
std::fprintf(stderr, " [%s] Roblox web surface launched in %lld ms\n",
transport, elapsed_ms);
return Status::Ok();
}
@@ -187,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;
}
@@ -196,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;
}
@@ -206,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;
}
@@ -215,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;
}
@@ -224,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;
}
@@ -299,12 +325,14 @@ RobloxExperienceComposition::RobloxExperienceComposition(
const SecureRobloxCredential* initial_web_view_credential,
RobloxExperienceSurfaceProvider surface_provider,
RobloxExperiencePresenceObserver presence_observer,
bool clear_persisted_web_view_cookie, bool microphone_enabled)
bool clear_persisted_web_view_cookie, bool microphone_enabled,
RobloxAccountProtocolSymbols account_symbols)
: environment_(environment),
message_bus_symbols_(message_bus_symbols),
web_view_symbols_(web_view_symbols),
browser_service_symbols_(browser_service_symbols),
permissions_symbols_(permissions_symbols),
account_symbols_(account_symbols),
microphone_enabled_(microphone_enabled),
game_symbols_(game_symbols),
jni_factory_(jni_factory),
@@ -334,7 +362,9 @@ RobloxExperienceComposition::RobloxExperienceComposition(
}
}
RobloxExperienceComposition::~RobloxExperienceComposition() { Shutdown(); }
RobloxExperienceComposition::~RobloxExperienceComposition() {
Shutdown();
}
Status RobloxExperienceComposition::InitializePlatformProtocols() {
if (!web_view_cookie_initialization_error_.empty()) {
@@ -348,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");
}
}
@@ -425,16 +456,49 @@ 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;
if (account_symbols_.complete()) {
account_bridge = std::make_unique<RobloxAccountBridge>(
environment_, account_symbols_,
RobloxAccountProtocolObjects{web_view_objects.message_bus,
jni_factory_.context,
jni_factory_.create_request_handler,
jni_factory_.clear_request_handler});
const Status account_status = account_bridge->Initialize();
if (!account_status.ok()) {
std::fprintf(stderr, " [account] AccountProtocol unavailable: %s\n",
account_status.message().c_str());
account_bridge.reset();
}
} else {
std::fprintf(stderr,
" [account] AccountProtocol entrypoints are missing; "
"device-integrity challenges wait for Roblox's timeout\n");
}
{
std::lock_guard<std::mutex> lock(mutex_);
web_view_bridge_ = std::move(web_view_bridge);
browser_service_bridge_ = std::move(browser_service_bridge);
permissions_bridge_ = std::move(permissions_bridge);
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();
}
@@ -454,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");
}
@@ -509,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",
@@ -582,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");
}
@@ -602,6 +667,8 @@ Status RobloxExperienceComposition::OpenWebSurface(
web_surface_process_generation_ = process_generation;
web_surface_route_ = route;
web_surface_logical_generation_ = logical_generation;
web_surface_close_when_challenge_solved_ =
presentation.close_when_challenge_solved;
web_surface_logical_exit_observer_ = std::move(exit_observer);
}
if (superseded_observer.valid()) {
@@ -614,13 +681,16 @@ Status RobloxExperienceComposition::OpenWebSurface(
return Status::Ok();
}
Status RobloxExperienceComposition::CloseWebSurface() {
Status RobloxExperienceComposition::CloseWebSurface(
uint64_t expected_logical_generation) {
std::lock_guard<std::mutex> operation_lock(web_surface_operation_mutex_);
std::shared_ptr<WebViewHelperProcess> process;
WebViewHelperExitObserver exit_observer;
{
std::lock_guard<std::mutex> lock(mutex_);
if (web_surface_route_ == WebSurfaceRoute::kNone) {
if (web_surface_route_ == WebSurfaceRoute::kNone ||
(expected_logical_generation != 0 &&
expected_logical_generation != web_surface_logical_generation_)) {
return Status::Ok();
}
process = std::move(web_surface_process_);
@@ -628,6 +698,7 @@ Status RobloxExperienceComposition::CloseWebSurface() {
exit_observer = std::move(web_surface_logical_exit_observer_);
web_surface_route_ = WebSurfaceRoute::kNone;
web_surface_logical_generation_ = 0;
web_surface_close_when_challenge_solved_ = false;
}
// A closed challenge must stop executing. Reusing its hidden document lets
// late callbacks from the old attempt reach the next login attempt. The APK
@@ -643,6 +714,33 @@ Status RobloxExperienceComposition::CloseWebSurface() {
return Status::Ok();
}
Status RobloxExperienceComposition::CloseUnclaimedSolvedChallenge() {
uint64_t generation = 0;
{
std::lock_guard<std::mutex> lock(mutex_);
if (!solved_challenge_close_deadline_.has_value()) {
return Status::Ok();
}
const bool still_open =
web_surface_route_ != WebSurfaceRoute::kNone &&
solved_challenge_logical_generation_ == web_surface_logical_generation_;
if (still_open &&
std::chrono::steady_clock::now() < *solved_challenge_close_deadline_) {
return Status::Ok();
}
solved_challenge_close_deadline_.reset();
if (!still_open) {
return Status::Ok();
}
generation = solved_challenge_logical_generation_;
}
std::fprintf(stderr,
" [webview] closing solved challenge window; Roblox did not "
"close it within %lld ms\n",
static_cast<long long>(kSolvedChallengeCloseGrace.count()));
return CloseWebSurface(generation);
}
void RobloxExperienceComposition::WebSurfaceExited(void* context) {
auto* exit_context = static_cast<WebSurfaceExitContext*>(context);
if (exit_context == nullptr || exit_context->target == nullptr ||
@@ -668,6 +766,7 @@ void RobloxExperienceComposition::HandleWebSurfaceExit(
web_surface_process_generation_ = 0;
web_surface_route_ = WebSurfaceRoute::kNone;
web_surface_logical_generation_ = 0;
web_surface_close_when_challenge_solved_ = false;
exit_observer = std::move(web_surface_logical_exit_observer_);
}
if (exit_observer.valid()) {
@@ -693,6 +792,8 @@ Status RobloxExperienceComposition::DispatchWebViewOpen(
// host back navigation too.
presentation.back_navigation_disabled =
request.back_button_visible.has_value() && !*request.back_button_visible;
presentation.close_when_challenge_solved =
request.close_when_challenge_solved;
return composition->OpenWebSurface(request.url, "webview",
WebSurfaceRoute::kWebView,
std::move(exit_observer), presentation);
@@ -951,9 +1052,45 @@ Status RobloxExperienceComposition::RouteCurrentWebSurfaceEvent(
route = web_surface_route_;
web_view_bridge = web_view_bridge_.get();
}
return event.type == WebViewHelperEventType::kRobloxCookie
? AcceptWebViewRobloxCookie(event.payload)
: RouteWebSurfaceEvent(route, event, web_view_bridge);
if (event.type == WebViewHelperEventType::kRobloxCookie) {
return AcceptWebViewRobloxCookie(event.payload);
}
if (event.type == WebViewHelperEventType::kExecuteRoblox) {
// A web Play button's launchGame is launched through the experience
// pipeline and closes the surface. It is never forwarded, so Roblox
// cannot start a second join from the same command.
RobloxExperienceLaunchRequest request;
if (ParseRobloxWebViewLaunchGame(event.payload, &request).ok() &&
Dispatch(request).ok()) {
std::fprintf(stderr,
"[experience] WebView launchGame queued place_id=%lld\n",
static_cast<long long>(request.place_id));
return CloseWebSurface();
}
}
const Status status = RouteWebSurfaceEvent(route, event, web_view_bridge);
if (!status.ok() || !IsRobloxWebViewChallengeSuccess(event.payload)) {
return status;
}
std::fprintf(stderr, " [webview] solved challenge forwarded to Roblox\n");
bool close_now = false;
{
std::lock_guard<std::mutex> lock(mutex_);
if (logical_generation != web_surface_logical_generation_) {
return status;
}
close_now = web_surface_close_when_challenge_solved_;
if (!close_now) {
solved_challenge_close_deadline_ =
std::chrono::steady_clock::now() + kSolvedChallengeCloseGrace;
solved_challenge_logical_generation_ = logical_generation;
}
}
if (!close_now) {
return status;
}
std::fprintf(stderr, " [webview] closing solved captcha window\n");
return CloseWebSurface(logical_generation);
}
Status RobloxExperienceComposition::DrainPlatformEvents() {
@@ -1002,6 +1139,9 @@ Status RobloxExperienceComposition::DrainPlatformEvents() {
status = drain_helper(web_surface_process, process_generation,
logical_generation);
}
if (status.ok()) {
status = CloseUnclaimedSolvedChallenge();
}
if (status.ok() && browser_service_bridge != nullptr) {
status = browser_service_bridge->DrainOutgoingEvents();
}
@@ -1096,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 =
@@ -1223,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: " +
@@ -1242,6 +1384,7 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
if (active_launch_ != nullptr) {
completed_result = active_launch_->result;
game_active_ = completed_result.ok();
window::SetGameSessionActive(game_active_);
active_game_canonical_json_ =
completed_result.ok() ? active_launch_->request.canonical_json
: std::string();
@@ -1279,6 +1422,7 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
return observed;
}
game_active_ = false;
window::SetGameSessionActive(false);
active_game_canonical_json_.clear();
presence_request_.reset();
playing_presence_published_ = false;
@@ -1288,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;
@@ -1297,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
@@ -1322,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();
@@ -1360,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;
@@ -1417,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);
@@ -1454,6 +1605,7 @@ Status RobloxExperienceComposition::LeaveGame() {
if (status.ok()) {
std::lock_guard<std::mutex> lock(mutex_);
game_active_ = false;
window::SetGameSessionActive(false);
active_game_canonical_json_.clear();
}
return status;
@@ -1540,6 +1692,8 @@ 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;
{
@@ -1552,6 +1706,7 @@ Status RobloxExperienceComposition::Shutdown() {
std::lock_guard<std::mutex> lock(mutex_);
subscribed_ = false;
game_active_ = false;
window::SetGameSessionActive(false);
active_game_canonical_json_.clear();
presence_request_.reset();
playing_presence_published_ = false;
@@ -1563,6 +1718,8 @@ 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_ = {};
web_surface_route_ = WebSurfaceRoute::kNone;
@@ -1579,12 +1736,23 @@ Status RobloxExperienceComposition::Shutdown() {
if (web_surface_process != nullptr) {
(void)web_surface_process->RequestClose();
}
Status status = permissions_bridge != nullptr ? permissions_bridge->Shutdown()
: Status::Ok();
Status status = 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;
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()) {
@@ -1596,6 +1764,8 @@ Status RobloxExperienceComposition::Shutdown() {
status = bridge_status;
}
browser_service_bridge.reset();
call_protocol_bridge.reset();
account_bridge.reset();
permissions_bridge.reset();
web_view_bridge.reset();
bridge.reset();
@@ -1613,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;
@@ -1651,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 =
@@ -1665,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");
@@ -1678,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);
@@ -1696,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");
@@ -1720,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;
}
@@ -1752,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;
@@ -1786,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",
@@ -1806,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;
@@ -1882,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;
@@ -1920,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;
@@ -1960,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,
@@ -2024,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_);
@@ -2039,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");
@@ -2051,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;
@@ -2071,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");
}
+24 -26
View File
@@ -274,14 +274,15 @@ AndroidKeyMapping MapSdlKeyToAndroid(uint32_t sdl_scancode,
return {};
}
int32_t MapSdlMouseButtonToAndroid(uint8_t sdl_button) {
int32_t MapSdlMouseButtonToRoblox(uint8_t sdl_button) {
switch (sdl_button) {
case SDL_BUTTON_LEFT:
return 0;
case SDL_BUTTON_RIGHT:
return 1;
case SDL_BUTTON_MIDDLE:
return 3;
// MouseButton3 is index 2, not BUTTON_TERTIARY (4) minus one.
return 2;
case SDL_BUTTON_X1:
return 7;
case SDL_BUTTON_X2:
@@ -550,13 +551,14 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseMotionLocked(
const float delta_x = transform.HostLogicalToGuestX(event.delta_x);
const float delta_y = transform.HostLogicalToGuestY(event.delta_y);
// Relative-mode motion reports zero absolute coordinates and a delta. The
// pointer holds its captured position while the delta passes through, so a
// long camera turn neither parks the reported position at the viewport edge
// The pointer holds its captured position while the delta passes through, so
// a long camera turn neither parks the reported position at the viewport edge
// nor swallows the reversal. Roblox draws its cursor at the held position
// when the capture ends, which matches where the host pointer is warped.
if (event.x == 0.0f && event.y == 0.0f &&
(delta_x != 0.0f || delta_y != 0.0f)) {
// Capture is read from the event, not inferred: only X11 zeroes the absolute
// coordinates, while Wayland reports a position that accumulates the deltas.
if (event.relative || (event.x == 0.0f && event.y == 0.0f &&
(delta_x != 0.0f || delta_y != 0.0f))) {
HoldPointerLocked(transform);
} else {
const float max_x = std::max(0.0F, transform.guest_width() - 1.0F);
@@ -585,19 +587,20 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseButtonLocked(
RobloxInputEventKind::kMouseButton,
Unsupported("native mouse buttons are unavailable"));
}
const int32_t android_button = MapSdlMouseButtonToAndroid(event.button);
if (android_button < 0) {
const int32_t roblox_button = MapSdlMouseButtonToRoblox(event.button);
if (roblox_button < 0) {
return Result(RobloxInputDispatchState::kIgnoredUnsupported,
RobloxInputEventKind::kMouseButton,
Unsupported("SDL mouse button has no Android mapping"));
Unsupported("SDL mouse button has no Roblox mapping"));
}
const platform::SurfaceCoordinateTransform transform =
CoordinateTransform(snapshot_.viewport);
const float max_x = std::max(0.0F, transform.guest_width() - 1.0F);
const float max_y = std::max(0.0F, transform.guest_height() - 1.0F);
// Button events during pointer capture carry zero coordinates and must not
// move the held position.
if (event.x > 0.0f || event.y > 0.0f) {
// Button events during pointer capture must not move the held position.
// Their coordinates are zeroed on X11 and drifted on Wayland, so the capture
// flag decides.
if (!event.relative && (event.x > 0.0f || event.y > 0.0f)) {
mouse_x_ = std::clamp(transform.HostLogicalToGuestX(event.x), 0.0f, max_x);
mouse_y_ = std::clamp(transform.HostLogicalToGuestY(event.y), 0.0f, max_y);
pointer_anchored_ = true;
@@ -606,20 +609,14 @@ 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, android_button);
event.pressed, roblox_button);
if (status.ok()) {
const auto active = std::find(active_mouse_buttons_.begin(),
active_mouse_buttons_.end(), android_button);
active_mouse_buttons_.end(), roblox_button);
if (event.pressed && active == active_mouse_buttons_.end()) {
active_mouse_buttons_.push_back(android_button);
active_mouse_buttons_.push_back(roblox_button);
} else if (!event.pressed && active != active_mouse_buttons_.end()) {
active_mouse_buttons_.erase(active);
}
@@ -638,9 +635,10 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseWheelLocked(
}
const platform::SurfaceCoordinateTransform transform =
CoordinateTransform(snapshot_.viewport);
// Wheel events during pointer capture carry zero coordinates and must not
// move the held position.
if (event.mouse_x > 0.0f || event.mouse_y > 0.0f) {
// Wheel events during pointer capture must not move the held position.
// Their coordinates are zeroed on X11 and drifted on Wayland, so the capture
// flag decides.
if (!event.relative && (event.mouse_x > 0.0f || event.mouse_y > 0.0f)) {
const float max_x = std::max(0.0F, transform.guest_width() - 1.0F);
const float max_y = std::max(0.0F, transform.guest_height() - 1.0F);
mouse_x_ = std::clamp(transform.HostLogicalToGuestX(event.mouse_x), 0.0f,
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more