mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-24 01:01:00 +02:00
Compare commits
90
Commits
v0.3.2
...
continuous
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3a2b76a06 | ||
|
|
f333399e18 | ||
|
|
3062909209 | ||
|
|
049784d0f8 | ||
|
|
ccf910f064 | ||
|
|
23e7831149 | ||
|
|
5b2ee2c7f3 | ||
|
|
63ed62b4aa | ||
|
|
81de528668 | ||
|
|
ff81882cb0 | ||
|
|
a87373d003 | ||
|
|
b46098c211 | ||
|
|
144350090e | ||
|
|
3ed0418997 | ||
|
|
8410070eec | ||
|
|
a1166fb6ba | ||
|
|
c9eb4fb7f0 | ||
|
|
b59c070cc0 | ||
|
|
4a9acf8c49 | ||
|
|
c342bab7b5 | ||
|
|
a3b9c872a5 | ||
|
|
4f45baf3f7 | ||
|
|
0ddb546ba8 | ||
|
|
7ba752ebb7 | ||
|
|
227895faa4 | ||
|
|
dfc3c8d555 | ||
|
|
e490dff97f | ||
|
|
b5b9990017 | ||
|
|
d278c0fe0a | ||
|
|
044f97e476 | ||
|
|
321ba5b3a2 | ||
|
|
fd364a7bf2 | ||
|
|
5253563e3f | ||
|
|
551d2a1829 | ||
|
|
37b88103f4 | ||
|
|
763faed0e3 | ||
|
|
74eedada0b | ||
|
|
6cb3dd00a9 | ||
|
|
fe2f641a9e | ||
|
|
cb2e9dd598 | ||
|
|
bc108106db | ||
|
|
aa31cdf395 | ||
|
|
3e6f63ea93 | ||
|
|
bfa6811417 | ||
|
|
84502c0a1a | ||
|
|
46d1d5ffbe | ||
|
|
545fd8344f | ||
|
|
4c40fcdc99 | ||
|
|
d4c7a6f741 | ||
|
|
491925f59e | ||
|
|
2917bc5423 | ||
|
|
2451264696 | ||
|
|
a9a96c6861 | ||
|
|
5bf4eeb41b | ||
|
|
d937dd7ea6 | ||
|
|
169e65bf60 | ||
|
|
69476f0f47 | ||
|
|
79d0cdfb40 | ||
|
|
273209b9ea | ||
|
|
bbfd603e17 | ||
|
|
1ba2941e7f | ||
|
|
68c90ce428 | ||
|
|
068f07e125 | ||
|
|
a469689bbb | ||
|
|
51836a980d | ||
|
|
ad546a7781 | ||
|
|
3c45450dfc | ||
|
|
185c11b57f | ||
|
|
2c3d7b485e | ||
|
|
6f8344a28b | ||
|
|
b60c43e8f0 | ||
|
|
b5e4273277 | ||
|
|
d2858fedc8 | ||
|
|
326a0659ea | ||
|
|
dd45467056 | ||
|
|
94b1036296 | ||
|
|
4c61daad0c | ||
|
|
f5f224875a | ||
|
|
90d4891a4e | ||
|
|
46dd99266c | ||
|
|
77fce18a95 | ||
|
|
d6f0f2c755 | ||
|
|
c6afc5af24 | ||
|
|
74e7455ee1 | ||
|
|
000d021452 | ||
|
|
2eef2c3821 | ||
|
|
4928443f90 | ||
|
|
b37497db7b | ||
|
|
6bbdc39d7e | ||
|
|
1c11336ba1 |
No files matched your search
@@ -0,0 +1,119 @@
|
|||||||
|
name: Publish AUR packages
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: Release tag to publish, such as v0.3.4
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
secrets:
|
||||||
|
AUR_SSH_PRIVATE_KEY:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: aur-publish
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Publish nightcap and nightcap-bin
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 15
|
||||||
|
environment: release
|
||||||
|
steps:
|
||||||
|
- name: Check out the release
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Render the recipes
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
[[ "$TAG" =~ ^v[0-9]+(\.[0-9]+)*$ ]]
|
||||||
|
version="${TAG#v}"
|
||||||
|
archive="nightcap-${version}-1-x86_64.pkg.tar.zst"
|
||||||
|
# nightcap-bin installs the pacman package from the release page, so
|
||||||
|
# its checksum is the one published beside that asset.
|
||||||
|
checksum="$(curl -fsSL \
|
||||||
|
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${archive}.sha256" |
|
||||||
|
awk '{print $1}')"
|
||||||
|
[[ "$checksum" =~ ^[0-9a-f]{64}$ ]]
|
||||||
|
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=${version}/" \
|
||||||
|
packaging/aur/nightcap/PKGBUILD packaging/aur/nightcap-bin/PKGBUILD
|
||||||
|
sed -i "s/^sha256sums=('SKIP')$/sha256sums=('${checksum}')/" \
|
||||||
|
packaging/aur/nightcap-bin/PKGBUILD
|
||||||
|
grep -Fqx "sha256sums=('${checksum}')" packaging/aur/nightcap-bin/PKGBUILD
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--volume "$GITHUB_WORKSPACE/packaging/aur:/aur" \
|
||||||
|
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
|
||||||
|
useradd --create-home builder
|
||||||
|
for package in nightcap nightcap-bin; do
|
||||||
|
install -d -o builder -g builder "/tmp/${package}"
|
||||||
|
install -o builder -g builder -m 0644 \
|
||||||
|
"/aur/${package}/PKGBUILD" "/tmp/${package}/PKGBUILD"
|
||||||
|
runuser -u builder -- bash -c \
|
||||||
|
"cd /tmp/${package} && makepkg --printsrcinfo" \
|
||||||
|
> "/aur/${package}/.SRCINFO"
|
||||||
|
done
|
||||||
|
'
|
||||||
|
|
||||||
|
for package in nightcap nightcap-bin; do
|
||||||
|
grep -Fqx "pkgver=${version}" "packaging/aur/${package}/PKGBUILD"
|
||||||
|
grep -Fqx $'\tpkgver = '"${version}" "packaging/aur/${package}/.SRCINFO"
|
||||||
|
done
|
||||||
|
echo "VERSION=${version}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Push to the AUR
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
if [[ -z "${AUR_SSH_PRIVATE_KEY}" ]]; then
|
||||||
|
echo "::notice::AUR_SSH_PRIVATE_KEY is not set; nothing was pushed to the AUR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$ssh_dir"' EXIT
|
||||||
|
install -m 0600 /dev/null "$ssh_dir/key"
|
||||||
|
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$ssh_dir/key"
|
||||||
|
# The AUR lists this key's fingerprint on its front page:
|
||||||
|
# SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4
|
||||||
|
printf '%s\n' \
|
||||||
|
'aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN' \
|
||||||
|
> "$ssh_dir/known_hosts"
|
||||||
|
export GIT_SSH_COMMAND="ssh -i $ssh_dir/key -o IdentitiesOnly=yes -o UserKnownHostsFile=$ssh_dir/known_hosts -o StrictHostKeyChecking=yes"
|
||||||
|
|
||||||
|
for package in nightcap nightcap-bin; do
|
||||||
|
checkout="$ssh_dir/$package"
|
||||||
|
git clone "ssh://aur@aur.archlinux.org/${package}.git" "$checkout"
|
||||||
|
cp "packaging/aur/${package}/PKGBUILD" "packaging/aur/${package}/.SRCINFO" \
|
||||||
|
"$checkout/"
|
||||||
|
git -C "$checkout" add PKGBUILD .SRCINFO
|
||||||
|
if git -C "$checkout" diff --cached --quiet; then
|
||||||
|
echo "::notice::${package} is already at ${VERSION}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
git -C "$checkout" \
|
||||||
|
-c user.name='Nightcap release' \
|
||||||
|
-c user.email='CoderDayton@users.noreply.github.com' \
|
||||||
|
commit --quiet -m "Update to ${VERSION}"
|
||||||
|
# The AUR only accepts the master branch.
|
||||||
|
git -C "$checkout" push origin HEAD:master
|
||||||
|
done
|
||||||
@@ -104,14 +104,25 @@ jobs:
|
|||||||
nlohmann-json3-dev libyaml-dev libelf-dev libminizip-dev \
|
nlohmann-json3-dev libyaml-dev libelf-dev libminizip-dev \
|
||||||
libcapstone-dev libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
|
libcapstone-dev libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
|
||||||
libutf8proc-dev libfontconfig1-dev libegl-dev libvulkan-dev \
|
libutf8proc-dev libfontconfig1-dev libegl-dev libvulkan-dev \
|
||||||
libplacebo-dev libpng-dev zlib1g-dev
|
libplacebo-dev libpng-dev zlib1g-dev \
|
||||||
|
xvfb xauth libgl1-mesa-dri jq patchelf
|
||||||
|
|
||||||
- name: Build and check native executables
|
- name: Build and check native executables
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
test "$(uname -m)" = "${{ matrix.arch }}"
|
test "$(uname -m)" = "${{ matrix.arch }}"
|
||||||
cmake -S . -B build-ci -G Ninja \
|
cmake -S . -B build-ci -G Ninja \
|
||||||
-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF
|
-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
|
||||||
cmake --build build-ci --parallel 4
|
cmake --build build-ci --parallel 4
|
||||||
./build-ci/mocktail --help
|
./build-ci/mocktail --help
|
||||||
./build-ci/mocktail_updater status
|
./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
|
||||||
@@ -38,6 +38,9 @@ jobs:
|
|||||||
name: Flatpak build
|
name: Flatpak build
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
|
# The signing key lives in the release environment, which only main and
|
||||||
|
# v* tags may use. Builds that do not publish run without it.
|
||||||
|
environment: ${{ (startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')) && 'release' || '' }}
|
||||||
env:
|
env:
|
||||||
# Publish on release tags and on manual runs from main. Pull requests
|
# Publish on release tags and on manual runs from main. Pull requests
|
||||||
# only verify that the manifest still builds.
|
# only verify that the manifest still builds.
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ jobs:
|
|||||||
if: needs.probe.outputs.build == 'true'
|
if: needs.probe.outputs.build == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
|
env:
|
||||||
|
UPINFO: gh-releases-zsync|${{ github.repository_owner }}|${{ github.event.repository.name }}|continuous|Nightcap-nightly-x86_64.AppImage.zsync
|
||||||
steps:
|
steps:
|
||||||
- name: Check out source
|
- name: Check out source
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
@@ -60,6 +62,7 @@ jobs:
|
|||||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||||
--workdir /workspace \
|
--workdir /workspace \
|
||||||
--env GH_TOKEN \
|
--env GH_TOKEN \
|
||||||
|
--env UPINFO \
|
||||||
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
|
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
|
||||||
pacman -Syu --noconfirm --needed \
|
pacman -Syu --noconfirm --needed \
|
||||||
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
|
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
|
||||||
@@ -123,11 +126,12 @@ jobs:
|
|||||||
export RUNTIME=/tmp/uruntime
|
export RUNTIME=/tmp/uruntime
|
||||||
export DWARFS_CMD=/tmp/mkdwarfs
|
export DWARFS_CMD=/tmp/mkdwarfs
|
||||||
make appimage BUILD_DIR=build-appimage JOBS=4 \
|
make appimage BUILD_DIR=build-appimage JOBS=4 \
|
||||||
APPIMAGE=dist/Mocktail-x86_64.AppImage \
|
APPIMAGE=dist/Nightcap-nightly-x86_64.AppImage \
|
||||||
PORTABLE_MODE=standalone APPIMAGE_FORMAT=anylinux \
|
PORTABLE_MODE=standalone APPIMAGE_FORMAT=anylinux \
|
||||||
ANYLINUX_PACKAGER=/tmp/quick-sharun \
|
ANYLINUX_PACKAGER=/tmp/quick-sharun \
|
||||||
ANYLINUX_APPIMAGETOOL=/tmp/appimagetool
|
ANYLINUX_APPIMAGETOOL=/tmp/appimagetool
|
||||||
test -x dist/Mocktail-x86_64.AppImage
|
test -x dist/Nightcap-nightly-x86_64.AppImage
|
||||||
|
test -s dist/Nightcap-nightly-x86_64.AppImage.zsync
|
||||||
'
|
'
|
||||||
|
|
||||||
- name: Smoke-test AppImage without FUSE
|
- name: Smoke-test AppImage without FUSE
|
||||||
@@ -140,14 +144,17 @@ jobs:
|
|||||||
export XDG_CACHE_HOME="$smoke_dir/cache"
|
export XDG_CACHE_HOME="$smoke_dir/cache"
|
||||||
export XDG_STATE_HOME="$smoke_dir/state"
|
export XDG_STATE_HOME="$smoke_dir/state"
|
||||||
mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME"
|
mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME"
|
||||||
timeout 60s dist/Mocktail-x86_64.AppImage --appimage-extract-and-run --help
|
test "$(dist/Nightcap-nightly-x86_64.AppImage --appimage-updateinformation)" = "$UPINFO"
|
||||||
timeout 60s dist/Mocktail-x86_64.AppImage --appimage-extract-and-run mocktail_updater status
|
timeout 60s dist/Nightcap-nightly-x86_64.AppImage --appimage-extract-and-run --help
|
||||||
|
timeout 60s dist/Nightcap-nightly-x86_64.AppImage --appimage-extract-and-run mocktail_updater status
|
||||||
|
|
||||||
- name: Upload AppImage
|
- name: Upload AppImage
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: Nightcap-AppImage
|
name: Nightcap-AppImage
|
||||||
path: dist/Mocktail-x86_64.AppImage
|
path: |
|
||||||
|
dist/Nightcap-nightly-x86_64.AppImage
|
||||||
|
dist/Nightcap-nightly-x86_64.AppImage.zsync
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
@@ -173,13 +180,14 @@ jobs:
|
|||||||
name: Nightcap-AppImage
|
name: Nightcap-AppImage
|
||||||
path: release
|
path: release
|
||||||
|
|
||||||
- name: Name and checksum the nightly build
|
- name: Checksum the nightly build
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
# The Makefile builds under upstream's artifact name; the published
|
# The .zsync file names the AppImage it updates, so the build already
|
||||||
# asset carries this fork's.
|
# carries the published name.
|
||||||
mv -- release/Mocktail-x86_64.AppImage release/Nightcap-nightly-x86_64.AppImage
|
test -s release/Nightcap-nightly-x86_64.AppImage
|
||||||
|
test -s release/Nightcap-nightly-x86_64.AppImage.zsync
|
||||||
(cd release && sha256sum Nightcap-nightly-x86_64.AppImage \
|
(cd release && sha256sum Nightcap-nightly-x86_64.AppImage \
|
||||||
> Nightcap-nightly-x86_64.AppImage.sha256)
|
> Nightcap-nightly-x86_64.AppImage.sha256)
|
||||||
find release -maxdepth 1 -type f -print
|
find release -maxdepth 1 -type f -print
|
||||||
@@ -206,7 +214,7 @@ jobs:
|
|||||||
# Drop assets left by earlier runs under other names.
|
# Drop assets left by earlier runs under other names.
|
||||||
while IFS= read -r asset; do
|
while IFS= read -r asset; do
|
||||||
case "$asset" in
|
case "$asset" in
|
||||||
Nightcap-nightly-x86_64.AppImage|Nightcap-nightly-x86_64.AppImage.sha256)
|
Nightcap-nightly-x86_64.AppImage|Nightcap-nightly-x86_64.AppImage.zsync|Nightcap-nightly-x86_64.AppImage.sha256)
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
gh release delete-asset continuous "$asset" --yes
|
gh release delete-asset continuous "$asset" --yes
|
||||||
|
|||||||
@@ -336,3 +336,15 @@ jobs:
|
|||||||
--title "Nightcap $tag" --notes-file release/notes.md
|
--title "Nightcap $tag" --notes-file release/notes.md
|
||||||
gh release upload "$tag" --repo "$GITHUB_REPOSITORY" --clobber \
|
gh release upload "$tag" --repo "$GITHUB_REPOSITORY" --clobber \
|
||||||
"${uploads[@]}"
|
"${uploads[@]}"
|
||||||
|
|
||||||
|
# nightcap-bin downloads the pacman package from the release page, so this
|
||||||
|
# waits for the upload.
|
||||||
|
aur:
|
||||||
|
name: Publish AUR packages
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
needs: publish
|
||||||
|
uses: ./.github/workflows/aur-publish.yml
|
||||||
|
with:
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
secrets:
|
||||||
|
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||||
+230
-175
@@ -1,206 +1,265 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to Nightcap. The format follows
|
All notable changes to Nightcap. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Release notes on
|
Release notes on GitHub are taken from the matching section here.
|
||||||
GitHub are taken from the matching section here.
|
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- An asset overlay folder, `~/.local/share/mocktail/asset_overlay`. A file there replaces the file Roblox
|
||||||
|
ships at the same path, such as a cursor, a font or a sky texture, and it stays in place when Roblox
|
||||||
|
updates. The layout matches Sober's `asset_overlay` folder. `MOCKTAIL_ASSET_OVERLAY_DIR` moves it. Thanks to
|
||||||
|
@dim-ghub.
|
||||||
|
- The asset overlay folder now also replaces files Roblox downloads and no longer ships, such as the shift
|
||||||
|
lock and text cursors. Put the file under `content/`, at the path Roblox uses. Thanks to @dim-ghub for the
|
||||||
|
idea.
|
||||||
|
- `MOCKTAIL_ASSET_TRACE=1` now also prints the files Roblox asks for and does not get, as `[asset] miss`.
|
||||||
|
- Arch Linux: `nightcap-git` is on the AUR and builds the current `main` branch. Thanks to @dim-ghub, who
|
||||||
|
maintains it.
|
||||||
|
- The log now names each Roblox Java call that Nightcap has no handler for, once per call, as
|
||||||
|
`[JNI] no handler for <class>.<method><signature>`. This shows what a new Roblox version needs.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Nightcap answers Roblox's Java calls with less work per call, and the audio thread no longer allocates
|
||||||
|
memory for each block of sound it plays.
|
||||||
|
|
||||||
|
## [0.3.4] - 2026-10-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- The AppImage build now makes a `.zsync` file, so tools like `appimageupdatetool` can update the AppImage.
|
||||||
|
- The desktop side panel menu now shows by default on PC profiles. Set `FIntFlyoutTestVariantRollout12` in
|
||||||
|
`fflags.json` to change it.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The Flatpak build asks for the X11 socket only when there is no Wayland session, as before 0.3.3.
|
||||||
|
- Nightcap now uses Wayland on a Wayland session with NVIDIA drivers too, however it is started. It used to
|
||||||
|
fall back to X11 there when started from a terminal.
|
||||||
|
- Building Nightcap no longer needs libexecinfo.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed Roblox 2.742 being rejected as an update, which left the game on the older version. Nightcap now
|
||||||
|
accepts Roblox versions that change the order of their start-up steps.
|
||||||
|
- Fixed Nightcap reporting Mocktail's version number, 1.0.4, in session logs and support bundles. It now
|
||||||
|
reports its own version.
|
||||||
|
- Fixed the old Roblox home screen showing instead of the new one with the Me tab and the "For you" and
|
||||||
|
"Charts" tabs. Nightcap now runs the Roblox start-up step that loads the new interface for your account.
|
||||||
|
- Fixed anti-aliasing being forced off at every graphics quality level. Roblox now picks it, and a
|
||||||
|
`FIntDebugForceMSAASamples` value in `fflags.json` still applies. Thanks to @qutad.
|
||||||
|
- Fixed the voice chat microphone only working on Roblox 2.738. It no longer depends on one Roblox version.
|
||||||
|
- Fixed other players not hearing you in voice chat after a Roblox update. The updater now rebuilds the
|
||||||
|
audio device data when the previous version's profile has none.
|
||||||
|
- Fixed frame hitches while textures load on graphics cards that need ETC2 textures decoded on the CPU.
|
||||||
|
- Fixed problems on Chimera Linux and other musl systems, including error messages that differed from the
|
||||||
|
ones Roblox expects.
|
||||||
|
- Fixed the nightly AppImage build failing when a bundled GTK 4 was older than the one libadwaita needs.
|
||||||
|
- Fixed a random crash on Wayland with NVIDIA drivers, where the game window closed after you let go of the
|
||||||
|
camera or left mouse lock. The log showed `wp_linux_drm_syncobj_surface_v1` error 3.
|
||||||
|
- Fixed some menu actions being ignored. The Chat button on a profile now opens the Party tab with that
|
||||||
|
player, and clicking a search suggestion now runs the search instead of closing the search menu.
|
||||||
|
- Fixed possible crashes when the game calls an Android function Nightcap did not provide, or releases the
|
||||||
|
same reference twice.
|
||||||
|
- Fixed a possible hang on close when a web view process stayed open. Nightcap now stops waiting for the log
|
||||||
|
writer after 2 seconds.
|
||||||
|
- Fixed a possible missing picture on graphics drivers that reject the low-latency present mode. Nightcap now
|
||||||
|
retries with the game's own mode.
|
||||||
|
- Fixed a possible Wayland crash when the window first appears on the OpenGL graphics path.
|
||||||
|
- Fixed a missing picture on machines where the graphics card runs a different driver than the one Nightcap
|
||||||
|
picked, such as an NVIDIA card on nouveau or a laptop with an Intel chip and an older second card. Nightcap
|
||||||
|
now picks the Vulkan driver that matches the running kernel driver, and says so when no card can run Vulkan.
|
||||||
|
|
||||||
|
## [0.3.3] - 2026-10-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Joining a friend from a Roblox "follow user" link now works. Nightcap looks up which game they are in and
|
||||||
|
joins it.
|
||||||
|
- Nix users get a desktop entry for the app.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Roblox textures now decode about 3 times faster, which shortens the stalls when a place loads and when new
|
||||||
|
parts of the map stream in. Thanks to @glook9001.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `make update-roblox` no longer deletes your previous Roblox install when an import fails partway through. If
|
||||||
|
a step fails, the old files are put back, or kept in a folder whose path is printed. Thanks to @kimlherme.
|
||||||
|
- A failed Roblox update now restores your previous version's records instead of leaving them half switched. A
|
||||||
|
damaged version file is rejected instead of being half read, and a temporary failure to start the startup
|
||||||
|
check no longer marks a working Roblox build as broken for good. Thanks to @kimlherme.
|
||||||
|
- Fixed a possible crash when Roblox clears its audio queue while sound is still playing, and a memory leak in
|
||||||
|
lists of objects passed to Roblox's Java code. Thanks to @kimlherme.
|
||||||
|
- Fixed a possible crash when the text overlay shuts down, a freeze when Roblox releases the mouse lock while
|
||||||
|
another release is already in progress, and network lookups that misread Roblox's options. Thanks to
|
||||||
|
@kimlherme.
|
||||||
|
- Fixed the sky sometimes rendering with the wrong textures in the Vulkan renderer.
|
||||||
|
- Clicking while a Roblox text box has focus no longer closes it. Only Roblox decides when a click ends
|
||||||
|
typing.
|
||||||
|
- Going to another page in the web view, such as Friends from a profile, no longer closes the web view. It
|
||||||
|
crashed on every page change on Wayland systems whose GL setup GTK could not use.
|
||||||
|
- Fixed a crash when Roblox's saved dark or light theme setting was missing or damaged.
|
||||||
|
|
||||||
## [0.3.2] - 2026-09-29
|
## [0.3.2] - 2026-09-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Performance recordings made with `--profile` now show how busy the CPU was
|
- Performance recordings made with `--profile` now show how busy the CPU was during each frame, which makes it
|
||||||
during each frame, which makes it easier to tell what caused a stutter.
|
easier to tell what caused a stutter.
|
||||||
- Experimental support for ARM64 (`aarch64`) Linux, running Roblox's Android
|
- Experimental support for ARM64 (`aarch64`) Linux, running Roblox's Android `arm64-v8a` client.
|
||||||
`arm64-v8a` client.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- When Roblox asks for a Google Play device check during login, Nightcap now
|
- When Roblox asks for a Google Play device check during login, Nightcap now answers right away that it isn't
|
||||||
answers right away that it isn't supported. Before, the "Verifying you're
|
supported. Before, the "Verifying you're not a bot" screen waited about a minute for an answer and then sent
|
||||||
not a bot" screen waited about a minute for an answer and then sent you
|
you back to the login screen.
|
||||||
back to the login screen.
|
- Solving a login challenge now closes its window. Before, Roblox's "challenge completed" signal was ignored
|
||||||
- Solving a login challenge now closes its window. Before, Roblox's
|
and the window stayed open. Thanks to @glook9001.
|
||||||
"challenge completed" signal was ignored and the window stayed open.
|
- Login no longer stalls after you pass a verification check. Before, Roblox only saw the page's "challenge
|
||||||
Thanks to @glook9001.
|
completed" signal after a one-minute timeout, so it gave up and the login failed. On a Roblox build Nightcap
|
||||||
- Login no longer stalls after you pass a verification check. Before, Roblox
|
can't handle this way, the log says "WebViewProtocol getter not recognized" and web pages behave as before.
|
||||||
only saw the page's "challenge completed" signal after a one-minute
|
- Login check windows and other web pages get 5 seconds to start instead of 3, so a slow start no longer
|
||||||
timeout, so it gave up and the login failed. On a Roblox build Nightcap
|
closes them. The log says how long each one took to open.
|
||||||
can't handle this way, the log says "WebViewProtocol getter not
|
- Less stutter while joining a game and while textures load in. In Blade Ball, loading hitches dropped by
|
||||||
recognized" and web pages behave as before.
|
about half, and the worst one went from 0.8 seconds to 0.55 seconds.
|
||||||
- Login check windows and other web pages get 5 seconds to start instead of
|
- Clicking Play on a private server now joins right away. Before, nothing happened until you clicked a sidebar
|
||||||
3, so a slow start no longer closes them. The log says how long each one
|
tab like Home.
|
||||||
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.
|
- Middle click now reaches Roblox as the middle mouse button.
|
||||||
- Scrolling follows your system's natural scrolling setting.
|
- Scrolling follows your system's natural scrolling setting.
|
||||||
- Fixed a crash on some x86_64 hosts when Roblox asked about a thread other
|
- Fixed a crash on some x86_64 hosts when Roblox asked about a thread other than the one making the call.
|
||||||
than the one making the call.
|
- Roblox updates no longer mix up the ARM64 and x86_64 compatibility profiles for the same version.
|
||||||
- Roblox updates no longer mix up the ARM64 and x86_64 compatibility profiles
|
- A partial or out-of-range user id in `MOCKTAIL_ROBLOX_USER_ID` or `MOCKTAIL_LOCAL_STORAGE_CURRENT_USER_ID`
|
||||||
for the same version.
|
is now ignored instead of being partly read.
|
||||||
- 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
|
### Changed
|
||||||
|
|
||||||
- `make flatpak` and `scripts/build_flatpak.sh` now build with 2 jobs by
|
- `make flatpak` and `scripts/build_flatpak.sh` now build with 2 jobs by default instead of 4, so a local
|
||||||
default instead of 4, so a local Flatpak build is less likely to run out
|
Flatpak build is less likely to run out of memory. Set `FLATPAK_JOBS` or `MOCKTAIL_FLATPAK_JOBS` to use
|
||||||
of memory. Set `FLATPAK_JOBS` or `MOCKTAIL_FLATPAK_JOBS` to use more.
|
more. Thanks to @glook9001.
|
||||||
Thanks to @glook9001.
|
|
||||||
|
|
||||||
## [0.3.1] - 2026-09-20
|
## [0.3.1] - 2026-09-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Each release carries a DEB, an RPM, and a pacman package beside the
|
- Each release carries a DEB, an RPM, and a pacman package beside the AppImage, with a `.sha256` for every
|
||||||
AppImage, with a `.sha256` for every file. They install `/usr/bin/mocktail`
|
file. They install `/usr/bin/mocktail` and conflict with each other and with upstream's `mocktail` package.
|
||||||
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}`
|
||||||
Nightcap is in no distribution repository or the AUR; the recipes under
|
build from this repository locally.
|
||||||
`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
|
||||||
- The AppStream metadata lists the Nightcap releases, so software centres
|
upstream's.
|
||||||
show the release history instead of upstream's.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- ETC2 texture decoding no longer blocks the thread that submits the frame.
|
- ETC2 texture decoding no longer blocks the thread that submits the frame. Uploads decode on a background
|
||||||
Uploads decode on a background dispatcher and the submit carries a timeline
|
dispatcher and the submit carries a timeline semaphore wait, so the GPU waits for the texture instead of the
|
||||||
semaphore wait, so the GPU waits for the texture instead of the render
|
render thread. Reading the application's compressed bytes moves to that dispatcher too whenever they are
|
||||||
thread. Reading the application's compressed bytes moves to that dispatcher
|
already mapped. In Blade Ball the worst `vkQueueSubmit` fell from 140ms to 1ms, its p99 from 4.9ms to
|
||||||
too whenever they are already mapped. In Blade Ball the worst
|
0.05ms, and the total time spent in submits from 2534ms to 309ms. Frame interval is unchanged: the remaining
|
||||||
`vkQueueSubmit` fell from 140ms to 1ms, its p99 from 4.9ms to 0.05ms, and
|
tail is the engine's own main-thread step. A device without the `timelineSemaphore` feature decodes inline
|
||||||
the total time spent in submits from 2534ms to 309ms. Frame interval is
|
as before, as does a submit whose `pNext` already sizes arrays by its semaphore counts.
|
||||||
unchanged: the remaining tail is the engine's own main-thread step. A device
|
- The mouse pointer jumped when a right-click camera drag ended, and drifted to the viewport edge during one.
|
||||||
without the `timelineSemaphore` feature decodes inline as before, as does a
|
The input router inferred pointer capture from SDL reporting zero absolute coordinates, which holds on X11
|
||||||
submit whose `pNext` already sizes arrays by its semaphore counts.
|
but not on Wayland, where the reported position accumulates the relative deltas and leaves the window.
|
||||||
- The mouse pointer jumped when a right-click camera drag ended, and drifted
|
Motion, button, and wheel events now carry the window's relative mouse mode. The right-click camera fallback
|
||||||
to the viewport edge during one. The input router inferred pointer capture
|
also only captures inside an experience, so the cursor stays free over the app surface.
|
||||||
from SDL reporting zero absolute coordinates, which holds on X11 but not on
|
- Shutting the window down while an experience was starting or leaving could free the pointer capture owner
|
||||||
Wayland, where the reported position accumulates the relative deltas and
|
underneath the thread using it. Callers off the SDL thread now hold a reference for the length of their
|
||||||
leaves the window. Motion, button, and wheel events now carry the window's
|
call.
|
||||||
relative mouse mode. The right-click camera fallback also only captures
|
- The TextBox caret did not move when a trailing space was typed in a single-line field. The final cluster
|
||||||
inside an experience, so the cursor stays free over the app surface.
|
rect SDL_ttf reports stops at the last inked glyph, so the laid-out width is used there instead. Wrapped
|
||||||
- Shutting the window down while an experience was starting or leaving could
|
layouts keep the clamp, because their width is the widest line rather than the last.
|
||||||
free the pointer capture owner underneath the thread using it. Callers off
|
- Every TextBox keystroke re-ran `TTF_Init`, a fontconfig sort, six `TTF_OpenFont` calls, and `TTF_Quit` on
|
||||||
the SDL thread now hold a reference for the length of their call.
|
the render thread. The overlay holds its opened fonts and reopens them only when the Roblox font or the
|
||||||
- The TextBox caret did not move when a trailing space was typed in a
|
point size changes, keeping the current set when a reopen fails. Measured over 100 rasters, 1.59ms to 0.12ms
|
||||||
single-line field. The final cluster rect SDL_ttf reports stops at the last
|
per keystroke.
|
||||||
inked glyph, so the laid-out width is used there instead. Wrapped layouts
|
- Staged ETC2 copies now carry an explicit host-write barrier. A queue submission only makes host writes from
|
||||||
keep the clamp, because their width is the widest line rather than the last.
|
before it visible to the device, and an asynchronous decode writes after the submit, so without the barrier
|
||||||
- Every TextBox keystroke re-ran `TTF_Init`, a fontconfig sort, six
|
those texels were not guaranteed to reach the copy that reads them.
|
||||||
`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
|
### Changed
|
||||||
|
|
||||||
- `docs/PERFORMANCE.md` documents the GameMode settings to run with. A
|
- `docs/PERFORMANCE.md` documents the GameMode settings to run with. A `desiredgov=powersave` in
|
||||||
`desiredgov=powersave` in `~/.config/gamemode.ini` overrides the packaged
|
`~/.config/gamemode.ini` overrides the packaged `performance` default and clocks the CPU down for the whole
|
||||||
`performance` default and clocks the CPU down for the whole session while
|
session while still reporting an active performance request.
|
||||||
still reporting an active performance request.
|
|
||||||
|
|
||||||
## [0.3.0] - 2026-09-18
|
## [0.3.0] - 2026-09-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Custom Discord presence. `integrations.discord_rpc.text.title` replaces the
|
- Custom Discord presence. `integrations.discord_rpc.text.title` replaces the name in "Playing Nightcap", and
|
||||||
name in "Playing Nightcap", and `integrations.discord_rpc.images` sets the
|
`integrations.discord_rpc.images` sets the large and small icons and their hover text. Text and image fields
|
||||||
large and small icons and their hover text. Text and image fields accept
|
accept `{place_name}`, `{place_icon}`, and `{creator_name}`. A field that expands an empty placeholder is
|
||||||
`{place_name}`, `{place_icon}`, and `{creator_name}`. A field that expands
|
left out, and `text.state: ""` hides the state line.
|
||||||
an empty placeholder is left out, and `text.state: ""` hides the state line.
|
- The default Discord card now matches Sober: the title is "Roblox", the details line is "Playing
|
||||||
- The default Discord card now matches Sober: the title is "Roblox", the
|
<experience>", the state is "by <creator>", and the Roblox badge sits inside the experience icon. The
|
||||||
details line is "Playing <experience>", the state is "by <creator>", and
|
creator comes from the same Roblox games request that already fetches the experience name. With
|
||||||
the Roblox badge sits inside the experience icon. The creator comes from the
|
`show_place_name: false` the creator is hidden too, so the card shows only the title and timer.
|
||||||
same Roblox games request that already fetches the experience name. With
|
- `--profile <file>` writes a Chrome-format trace of the Vulkan adapter's work: presents, GPU waits, submits,
|
||||||
`show_place_name: false` the creator is hidden too, so the card shows only
|
ETC2 texture decode, and pipeline and shader creation. Open it in ui.perfetto.dev. `docs/BENCHMARKING.md`
|
||||||
the title and timer.
|
describes the benchmark runs, and `scripts/summarize_profile_trace.py` turns traces into a before and after
|
||||||
- `--profile <file>` writes a Chrome-format trace of the Vulkan adapter's
|
table.
|
||||||
work: presents, GPU waits, submits, ETC2 texture decode, and pipeline and
|
|
||||||
shader creation. Open it in ui.perfetto.dev. `docs/BENCHMARKING.md`
|
|
||||||
describes the benchmark runs, and `scripts/summarize_profile_trace.py`
|
|
||||||
turns traces into a before and after table.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Texture uploads stalled the frame they were submitted on. ETC2 batches
|
- Texture uploads stalled the frame they were submitted on. ETC2 batches decoded on worker threads but were
|
||||||
decoded on worker threads but were then resampled one upload at a time on
|
then resampled one upload at a time on the submitting thread, which at the default 4x upscale is sixteen
|
||||||
the submitting thread, which at the default 4x upscale is sixteen times the
|
times the decoded texels. Resampling now runs on the same worker pool, in two passes so mip level 0 still
|
||||||
decoded texels. Resampling now runs on the same worker pool, in two passes so
|
resolves before the mips built from it. In a 70k-part place the worst `vkQueueSubmit` fell from 167ms to
|
||||||
mip level 0 still resolves before the mips built from it. In a 70k-part
|
92ms.
|
||||||
place the worst `vkQueueSubmit` fell from 167ms to 92ms.
|
- Most ETC2 batches decoded on a single thread. Workers were created and joined per batch, so the parallel
|
||||||
- Most ETC2 batches decoded on a single thread. Workers were created and
|
floor had to cover that cost and sat at a 1024x1024 image; batches under it ran on the caller, which was 54%
|
||||||
joined per batch, so the parallel floor had to cover that cost and sat at a
|
of all decode time in a 70k-part place. Decode workers are now a persistent pool, and the floor drops to a
|
||||||
1024x1024 image; batches under it ran on the caller, which was 54% of all
|
128x128 image. Mean decode time in that place fell from 8.2ms to 4.7ms, and 11.8ms before both texture
|
||||||
decode time in a 70k-part place. Decode workers are now a persistent pool,
|
fixes.
|
||||||
and the floor drops to a 128x128 image. Mean decode time in that place fell
|
- On CPUs with a wide core-speed spread, Feral GameMode pinned the process to the few fastest cores,
|
||||||
from 8.2ms to 4.7ms, and 11.8ms before both texture fixes.
|
concentrating Roblox's worker pool onto them instead of spreading it. With `performance.gamemode: auto`, a
|
||||||
- On CPUs with a wide core-speed spread, Feral GameMode pinned the process to
|
request that narrows the CPU affinity is now released again, which releases the pin. Set `pin_cores=no` in
|
||||||
the few fastest cores, concentrating Roblox's worker pool onto them instead
|
`~/.config/gamemode.ini` to keep GameMode without the pinning, or `performance.gamemode: on` to accept it.
|
||||||
of spreading it. With `performance.gamemode: auto`, a request that narrows
|
See `docs/PERFORMANCE.md`.
|
||||||
the CPU affinity is now released again, which releases the pin. Set
|
- With `show_place_name: false`, Discord no longer shows the experience thumbnail or its name on hover.
|
||||||
`pin_cores=no` in `~/.config/gamemode.ini` to keep GameMode without the
|
- The host main thread no longer spins on the engine's message pump, which cost about half a core and 146,000
|
||||||
pinning, or `performance.gamemode: on` to accept it. See
|
empty calls a second. It now rests 100 µs between polls: sleeping when the governor is `performance`,
|
||||||
`docs/PERFORMANCE.md`.
|
parking the core with TPAUSE on CPUs that have it, spinning otherwise. With GameMode setting the governor,
|
||||||
- With `show_place_name: false`, Discord no longer shows the experience
|
process CPU in a 60k-part place fell from 216% to 107% at the same 120 fps. `MOCKTAIL_ENGINE_PUMP_REST`
|
||||||
thumbnail or its name on hover.
|
forces a mode; see `docs/PERFORMANCE.md`.
|
||||||
- The host main thread no longer spins on the engine's message pump, which
|
- ETC2 uploads with a `bufferRowLength` or `bufferImageHeight` larger than the image read the padded rows as
|
||||||
cost about half a core and 146,000 empty calls a second. It now rests 100 µs
|
packed data and decoded garbage.
|
||||||
between polls: sleeping when the governor is `performance`, parking the
|
- ETC2 resampling created and joined worker threads on every batch. It now runs on the persistent decode pool.
|
||||||
core with TPAUSE on CPUs that have it, spinning otherwise. With GameMode
|
- A failed trace write left the profile writer thread waking every 250 ms for the rest of the session. It now
|
||||||
setting the governor, process CPU in a 60k-part place fell from 216% to
|
exits.
|
||||||
107% at the same 120 fps. `MOCKTAIL_ENGINE_PUMP_REST` forces a mode; see
|
- The `--profile` trace gains a `pump` slice for the engine's main-thread step.
|
||||||
`docs/PERFORMANCE.md`.
|
|
||||||
- ETC2 uploads with a `bufferRowLength` or `bufferImageHeight` larger than
|
|
||||||
the image read the padded rows as packed data and decoded garbage.
|
|
||||||
- ETC2 resampling created and joined worker threads on every batch. It now
|
|
||||||
runs on the persistent decode pool.
|
|
||||||
- A failed trace write left the profile writer thread waking every 250 ms
|
|
||||||
for the rest of the session. It now exits.
|
|
||||||
- The `--profile` trace gains a `pump` slice for the engine's main-thread
|
|
||||||
step.
|
|
||||||
|
|
||||||
## [0.2.0] - 2026-09-16
|
## [0.2.0] - 2026-09-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- FAQ covering Discord RPC, log locations, and VR, adapted from upstream.
|
- FAQ covering Discord RPC, log locations, and VR, adapted from upstream.
|
||||||
- `input.raw_mouse` in `config.yaml` sends mouse look deltas in the host's
|
- `input.raw_mouse` in `config.yaml` sends mouse look deltas in the host's own units. Turn it on when
|
||||||
own units. Turn it on when first-person aiming feels too slow or too fast
|
first-person aiming feels too slow or too fast under fractional display scaling. Off by default.
|
||||||
under fractional display scaling. Off by default.
|
- Nightly AppImage builds, published as the `continuous` prerelease. They are untested; use a tagged release
|
||||||
- Nightly AppImage builds, published as the `continuous` prerelease. They are
|
for normal play.
|
||||||
untested; use a tagged release for normal play.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Values in `fflags.json` now win over the performance preset instead of
|
- Values in `fflags.json` now win over the performance preset instead of stopping the launch. Startup prints
|
||||||
stopping the launch. Startup prints each key it kept.
|
each key it kept.
|
||||||
- Discord Rich Presence uses Nightcap's own Discord application, so it shows
|
- Discord Rich Presence uses Nightcap's own Discord application, so it shows as "Playing Nightcap". Terms and
|
||||||
as "Playing Nightcap". Terms and privacy pages for it live on the
|
privacy pages for it live on the project site.
|
||||||
project site.
|
- Discord Rich Presence is on by default. Set `integrations.discord_rpc.enabled: false` to turn it off.
|
||||||
- Discord Rich Presence is on by default. Set
|
- The AppImage build verifies its reduced dependency packages against digests taken at build time, rather than
|
||||||
`integrations.discord_rpc.enabled: false` to turn it off.
|
a digest list kept in the repository.
|
||||||
- The AppImage build verifies its reduced dependency packages against digests
|
|
||||||
taken at build time, rather than a digest list kept in the repository.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Mouse look keeps responding after a long turn. The tracked pointer position
|
- Mouse look keeps responding after a long turn. The tracked pointer position could drift past the edge of the
|
||||||
could drift past the edge of the view, which swallowed every reversal until
|
view, which swallowed every reversal until the mouse travelled all the way back.
|
||||||
the mouse travelled all the way back.
|
- Nightcap no longer refuses to start with `cannot lock appStorage`. It created its own credential directory
|
||||||
- Nightcap no longer refuses to start with `cannot lock appStorage`. It
|
using the process umask, then rejected that directory for being group writable. Systems with a permissive
|
||||||
created its own credential directory using the process umask, then rejected
|
umask, such as `002`, failed on first run.
|
||||||
that directory for being group writable. Systems with a permissive umask,
|
|
||||||
such as `002`, failed on first run.
|
|
||||||
|
|
||||||
## [0.1.0] - 2026-09-15
|
## [0.1.0] - 2026-09-15
|
||||||
|
|
||||||
@@ -208,37 +267,33 @@ First Nightcap release, forked from komaruworld/mocktail.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ETC2 and EAC texture emulation on Vulkan, so games that use compressed
|
- ETC2 and EAC texture emulation on Vulkan, so games that use compressed mobile textures render correctly on
|
||||||
mobile textures render correctly on desktop GPUs. Decoding runs on worker
|
desktop GPUs. Decoding runs on worker threads to avoid stutter.
|
||||||
threads to avoid stutter.
|
- PNG texture overrides in `~/.config/mocktail/textures`, keyed by upload hash, with a dump mode to find the
|
||||||
- PNG texture overrides in `~/.config/mocktail/textures`, keyed by upload
|
texture you want to replace.
|
||||||
hash, with a dump mode to find the texture you want to replace.
|
- Small textures are upscaled 4x by default. Set `MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn it off.
|
||||||
- Small textures are upscaled 4x by default. Set
|
- AppImage and signed Flatpak builds on every release tag. The Flatpak repo is published at
|
||||||
`MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn it off.
|
https://coderdayton.github.io/nightcap/.
|
||||||
- AppImage and signed Flatpak builds on every release tag. The Flatpak repo
|
|
||||||
is published at https://coderdayton.github.io/nightcap/.
|
|
||||||
- Roadmap in `docs/ROADMAP.md`.
|
- Roadmap in `docs/ROADMAP.md`.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Frames are presented as soon as they are ready instead of waiting in a
|
- Frames are presented as soon as they are ready instead of waiting in a queue, which lowers input latency.
|
||||||
queue, which lowers input latency.
|
- Roblox now sees the real host RAM and display size instead of a low-end phone profile.
|
||||||
- Roblox now sees the real host RAM and display size instead of a low-end
|
- App ID is `io.github.CoderDayton.nightcap`, so Nightcap installs next to upstream Mocktail. The binary and
|
||||||
phone profile.
|
config paths keep the `mocktail` name.
|
||||||
- App ID is `io.github.CoderDayton.nightcap`, so Nightcap installs next to
|
|
||||||
upstream Mocktail. The binary and config paths keep the `mocktail` name.
|
|
||||||
- New name, icon, and colors.
|
- New name, icon, and colors.
|
||||||
- The desktop entry launches on Wayland and points at the installed binary.
|
- The desktop entry launches on Wayland and points at the installed binary.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Servers no longer kick with error 319 shortly after joining.
|
- Servers no longer kick with error 319 shortly after joining.
|
||||||
- The FMOD output-device bridge is optional, so the updater no longer fails
|
- The FMOD output-device bridge is optional, so the updater no longer fails without it.
|
||||||
without it.
|
- The AppImage build no longer fails on stale checksums for the slimmed Arch packages.
|
||||||
- The AppImage build no longer fails on stale checksums for the slimmed
|
|
||||||
Arch packages.
|
|
||||||
|
|
||||||
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.2...HEAD
|
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.4...HEAD
|
||||||
|
[0.3.4]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.4
|
||||||
|
[0.3.3]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.3
|
||||||
[0.3.2]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.2
|
[0.3.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.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.3.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.0
|
||||||
|
|||||||
+10
-47
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
cmake_minimum_required(VERSION 3.20)
|
cmake_minimum_required(VERSION 3.20)
|
||||||
project(Mocktail
|
project(Mocktail
|
||||||
VERSION 1.0.4
|
VERSION 0.3.4
|
||||||
DESCRIPTION "Android Roblox compatibility runtime for Linux"
|
DESCRIPTION "Android Roblox compatibility runtime for Linux"
|
||||||
LANGUAGES CXX
|
LANGUAGES CXX
|
||||||
)
|
)
|
||||||
@@ -249,6 +249,10 @@ mocktail_apply_compile_options(mocktail_updater)
|
|||||||
|
|
||||||
add_library(mocktail_legacy_jnivm STATIC
|
add_library(mocktail_legacy_jnivm STATIC
|
||||||
src/jnivm/jnivm.cc
|
src/jnivm/jnivm.cc
|
||||||
|
src/jnivm/jni_references.cc
|
||||||
|
src/jnivm/jni_fields.cc
|
||||||
|
src/jnivm/jni_strings.cc
|
||||||
|
src/jnivm/jni_method_names.cc
|
||||||
)
|
)
|
||||||
add_library(Mocktail::LegacyJni ALIAS mocktail_legacy_jnivm)
|
add_library(Mocktail::LegacyJni ALIAS mocktail_legacy_jnivm)
|
||||||
target_include_directories(mocktail_legacy_jnivm PUBLIC include)
|
target_include_directories(mocktail_legacy_jnivm PUBLIC include)
|
||||||
@@ -285,13 +289,17 @@ add_library(mocktail_runtime STATIC
|
|||||||
src/runtime/roblox_capability_resolver.cc
|
src/runtime/roblox_capability_resolver.cc
|
||||||
src/runtime/roblox_experience_launch_bridge.cc
|
src/runtime/roblox_experience_launch_bridge.cc
|
||||||
src/runtime/roblox_launch_uri.cc
|
src/runtime/roblox_launch_uri.cc
|
||||||
|
src/runtime/roblox_presence_resolver.cc
|
||||||
src/runtime/roblox_game_session_native_adapter.cc
|
src/runtime/roblox_game_session_native_adapter.cc
|
||||||
src/runtime/roblox_web_view_bridge.cc
|
src/runtime/roblox_web_view_bridge.cc
|
||||||
src/runtime/roblox_permissions_bridge.cc
|
src/runtime/roblox_permissions_bridge.cc
|
||||||
src/runtime/roblox_account_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/payload_update_preflight.cc
|
||||||
src/runtime/process_launch_policy.cc
|
src/runtime/process_launch_policy.cc
|
||||||
src/runtime/process_diagnostics.cc
|
src/runtime/process_diagnostics.cc
|
||||||
|
src/runtime/asset_overlay_names.cc
|
||||||
src/runtime/runtime_config.cc
|
src/runtime/runtime_config.cc
|
||||||
src/runtime/runtime_config_bootstrap.cc
|
src/runtime/runtime_config_bootstrap.cc
|
||||||
src/runtime/runtime_config_file.cc
|
src/runtime/runtime_config_file.cc
|
||||||
@@ -474,7 +482,7 @@ target_link_libraries(mocktail_window PUBLIC
|
|||||||
Mocktail::SdlVulkanWsi
|
Mocktail::SdlVulkanWsi
|
||||||
SDL3::SDL3
|
SDL3::SDL3
|
||||||
${CMAKE_DL_LIBS}
|
${CMAKE_DL_LIBS}
|
||||||
PRIVATE Mocktail::Runtime Mocktail::GlesTextOverlay
|
PRIVATE Mocktail::Runtime Mocktail::GlesTextOverlay Mocktail::GraphicsFoundation
|
||||||
)
|
)
|
||||||
|
|
||||||
add_library(mocktail_roblox_output_device_runtime STATIC
|
add_library(mocktail_roblox_output_device_runtime STATIC
|
||||||
@@ -555,50 +563,6 @@ target_link_libraries(mocktail_bionic_abi_exports PRIVATE
|
|||||||
Threads::Threads
|
Threads::Threads
|
||||||
)
|
)
|
||||||
|
|
||||||
include(CheckCXXSourceCompiles)
|
|
||||||
include(CheckIncludeFileCXX)
|
|
||||||
include(CMakePushCheckState)
|
|
||||||
|
|
||||||
check_include_file_cxx(execinfo.h MOCKTAIL_HAVE_EXECINFO_H)
|
|
||||||
if(NOT MOCKTAIL_HAVE_EXECINFO_H)
|
|
||||||
message(FATAL_ERROR
|
|
||||||
"execinfo.h is required for crash diagnostics; on musl install "
|
|
||||||
"libexecinfo-devel"
|
|
||||||
)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
set(MOCKTAIL_EXECINFO_LINK_LIBRARY "")
|
|
||||||
set(_mocktail_execinfo_link_probe [=[
|
|
||||||
#include <execinfo.h>
|
|
||||||
int main() {
|
|
||||||
void* frames[1] = {};
|
|
||||||
const int count = backtrace(frames, 1);
|
|
||||||
return backtrace_symbols(frames, count) == nullptr;
|
|
||||||
}
|
|
||||||
]=])
|
|
||||||
check_cxx_source_compiles(
|
|
||||||
"${_mocktail_execinfo_link_probe}"
|
|
||||||
MOCKTAIL_EXECINFO_IN_HOST_LIBC
|
|
||||||
)
|
|
||||||
if(NOT MOCKTAIL_EXECINFO_IN_HOST_LIBC)
|
|
||||||
find_library(MOCKTAIL_EXECINFO_LIBRARY NAMES execinfo REQUIRED)
|
|
||||||
cmake_push_check_state(RESET)
|
|
||||||
set(CMAKE_REQUIRED_LIBRARIES "${MOCKTAIL_EXECINFO_LIBRARY}")
|
|
||||||
check_cxx_source_compiles(
|
|
||||||
"${_mocktail_execinfo_link_probe}"
|
|
||||||
MOCKTAIL_EXECINFO_IN_EXTERNAL_LIBRARY
|
|
||||||
)
|
|
||||||
cmake_pop_check_state()
|
|
||||||
if(NOT MOCKTAIL_EXECINFO_IN_EXTERNAL_LIBRARY)
|
|
||||||
message(FATAL_ERROR
|
|
||||||
"execinfo.h is available, but backtrace and backtrace_symbols cannot "
|
|
||||||
"be linked"
|
|
||||||
)
|
|
||||||
endif()
|
|
||||||
set(MOCKTAIL_EXECINFO_LINK_LIBRARY "${MOCKTAIL_EXECINFO_LIBRARY}")
|
|
||||||
endif()
|
|
||||||
unset(_mocktail_execinfo_link_probe)
|
|
||||||
|
|
||||||
add_library(mocktail_legacy_runtime STATIC
|
add_library(mocktail_legacy_runtime STATIC
|
||||||
src/legacy/legacy_runtime.cc
|
src/legacy/legacy_runtime.cc
|
||||||
src/runtime/roblox_text_input_jni_bridge.cc
|
src/runtime/roblox_text_input_jni_bridge.cc
|
||||||
@@ -623,7 +587,6 @@ target_link_libraries(mocktail_legacy_runtime PRIVATE
|
|||||||
libc_shim
|
libc_shim
|
||||||
mocktail_window
|
mocktail_window
|
||||||
mocktail_graphics_foundation
|
mocktail_graphics_foundation
|
||||||
${MOCKTAIL_EXECINFO_LINK_LIBRARY}
|
|
||||||
)
|
)
|
||||||
target_compile_definitions(mocktail_legacy_runtime PRIVATE
|
target_compile_definitions(mocktail_legacy_runtime PRIVATE
|
||||||
"MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=\"${MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST}\""
|
"MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=\"${MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST}\""
|
||||||
|
|||||||
@@ -56,15 +56,14 @@ after Roblox added measures against running multiple clients.
|
|||||||
|
|
||||||
## Can I play in VR?
|
## Can I play in VR?
|
||||||
|
|
||||||
VR is experimental and lives on upstream's `vr` branch, which does not
|
Yes, as an experiment. VR lives on Nightcap's `vr` branch and is not in the
|
||||||
include the Nightcap changes. Install the
|
releases yet. It works on Roblox builds 2998, 3092 and 3222. Install the
|
||||||
[build dependencies](README.md#building), then switch to that branch and
|
[build dependencies](README.md#building), then switch to that branch and
|
||||||
build:
|
build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote add upstream https://github.com/komaruworld/mocktail.git
|
git fetch origin
|
||||||
git fetch upstream
|
git switch vr
|
||||||
git switch -c vr upstream/vr
|
|
||||||
git submodule update --init --recursive
|
git submodule update --init --recursive
|
||||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DMOCKTAIL_ENABLE_VR=ON
|
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DMOCKTAIL_ENABLE_VR=ON
|
||||||
cmake --build build -j4
|
cmake --build build -j4
|
||||||
@@ -73,5 +72,10 @@ cmake --build build -j4
|
|||||||
Start WiVRn or SteamVR/ALVR, connect your headset, then run:
|
Start WiVRn or SteamVR/ALVR, connect your headset, then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./build/mocktail -vr
|
./build/mocktail --vr
|
||||||
```
|
```
|
||||||
|
|
||||||
|
That branch's
|
||||||
|
[README](https://github.com/CoderDayton/nightcap/blob/vr/README.md#experimental-vr)
|
||||||
|
and [docs/VR.md](https://github.com/CoderDayton/nightcap/blob/vr/docs/VR.md)
|
||||||
|
have the details, including how to try it without a headset.
|
||||||
@@ -7,13 +7,16 @@
|
|||||||
<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/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="https://github.com/CoderDayton/nightcap/stargazers"><img src="https://img.shields.io/github/stars/CoderDayton/nightcap?style=flat&logo=github" alt="Stars"></a>
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License"></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License"></a>
|
||||||
|
<a href="https://discord.gg/DbAUG9hBFU"><img alt="Discord" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Fv10%2Finvites%2FDbAUG9hBFU%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&label=Discord&logo=discord&logoColor=white&color=5865F2&suffix=%20members"></a>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Nightcap is Mocktail, tuned for playing on a real PC. It runs the Android
|
Nightcap is Roblox on Linux, tuned for desktop play. It is a Mocktail fork
|
||||||
`x86_64` Roblox client on Linux, including a Linux userspace hosted by
|
that keeps up with Roblox updates, with extra bug fixes, custom assets and
|
||||||
FreeBSD's Linuxulator. It provides the Android ABI and JNI pieces the client
|
more. It runs the Android `x86_64` Roblox client on Linux, including a Linux
|
||||||
expects, then connects them to SDL3 and Vulkan or OpenGL on the Linux side.
|
userspace hosted by FreeBSD's Linuxulator. It provides the Android ABI and JNI
|
||||||
|
pieces the client expects, then connects them to SDL3 and Vulkan or OpenGL on
|
||||||
|
the Linux side.
|
||||||
|
|
||||||
Nightcap is an independent community project. It is not affiliated with Roblox
|
Nightcap is an independent community project. It is not affiliated with Roblox
|
||||||
Corporation or VinegarHQ and does not distribute the Roblox client.
|
Corporation or VinegarHQ and does not distribute the Roblox client.
|
||||||
@@ -26,23 +29,25 @@ with extra fixes for playing on a PC. The program and config paths keep the
|
|||||||
`io.github.CoderDayton.nightcap`, so Nightcap and upstream can be installed
|
`io.github.CoderDayton.nightcap`, so Nightcap and upstream can be installed
|
||||||
side by side.
|
side by side.
|
||||||
|
|
||||||
- **Lower input lag.** Frames are shown as soon as they are ready instead of
|
- **Keeps up with Roblox.** New Roblox versions work soon after they come out.
|
||||||
waiting in a queue.
|
- **Plays more games.** Nightcap answers a server check that some experiences
|
||||||
- **Sharper textures.** Small textures are upscaled 4x by default, and you
|
use, so you can join and stay in those too.
|
||||||
can replace any texture with your own PNG in `~/.config/mocktail/textures`.
|
- **Voice chat that keeps working.** Your microphone still works after a
|
||||||
A ready-made crosshair is in [`textures/`](textures/README.md). Set
|
Roblox update.
|
||||||
`MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn the upscale off.
|
- **Logging in works.** Verification checks and captchas finish instead of
|
||||||
- **ETC2 textures on any GPU.** Games that use compressed mobile textures now
|
hanging.
|
||||||
render correctly on desktop GPUs, without stutter.
|
- **The current Roblox look.** The new home screen and side menu, like on
|
||||||
- **No more error 319 kicks.** Servers no longer drop you shortly after
|
Windows.
|
||||||
joining.
|
- **Your own cursors, fonts and skies.** Drop files in
|
||||||
- **Correct device info.** Roblox sees your real RAM and screen size.
|
`~/.local/share/mocktail/asset_overlay` and Nightcap uses them. The layout
|
||||||
- **Better desktop launcher.** Runs on Wayland and always finds the installed
|
matches Sober's `asset_overlay` folder.
|
||||||
binary.
|
- **Smoother mouse and a cooler PC.** Optional raw mouse input, and less CPU
|
||||||
|
use while idle.
|
||||||
|
|
||||||
Everything else works the same as upstream. See the
|
Everything else works the same as upstream. See
|
||||||
[roadmap](docs/ROADMAP.md) for what is coming next, the
|
[`textures/`](textures/README.md) for custom textures, a ready-made crosshair
|
||||||
[changelog](CHANGELOG.md) for what each release changed,
|
and the asset overlay folder, the [roadmap](docs/ROADMAP.md) for what is
|
||||||
|
coming next, the [changelog](CHANGELOG.md) for what each release changed,
|
||||||
[performance](docs/PERFORMANCE.md) for CPU load, heat and the knobs that change
|
[performance](docs/PERFORMANCE.md) for CPU load, heat and the knobs that change
|
||||||
them, and the [FAQ](FAQ.md) for Discord RPC, logs, and VR.
|
them, and the [FAQ](FAQ.md) for Discord RPC, logs, and VR.
|
||||||
|
|
||||||
@@ -79,8 +84,17 @@ sudo pacman -U nightcap-<version>-1-x86_64.pkg.tar.zst
|
|||||||
```
|
```
|
||||||
|
|
||||||
They install `/usr/bin/mocktail`, so they conflict with upstream's `mocktail`
|
They install `/usr/bin/mocktail`, so they conflict with upstream's `mocktail`
|
||||||
package. Nightcap is not in any distribution repository or the AUR; the
|
package.
|
||||||
recipes under `packaging/aur` are kept for local builds only.
|
|
||||||
|
On Arch Linux, [`nightcap-git`](https://aur.archlinux.org/packages/nightcap-git)
|
||||||
|
is on the AUR. It builds the current `main` branch and is maintained by
|
||||||
|
[@dim-ghub](https://github.com/dim-ghub):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yay -S nightcap-git
|
||||||
|
```
|
||||||
|
|
||||||
|
Nightcap is in no other distribution repository.
|
||||||
|
|
||||||
Or build and install from source using the steps under
|
Or build and install from source using the steps under
|
||||||
[Building](#building).
|
[Building](#building).
|
||||||
@@ -95,6 +109,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
|
launch and is not bundled with Nightcap. The last working copy is kept in case
|
||||||
an update fails.
|
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>
|
<details>
|
||||||
<summary>Screenshots</summary>
|
<summary>Screenshots</summary>
|
||||||
|
|
||||||
@@ -102,8 +122,6 @@ an update fails.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## FFlag overrides
|
## FFlag overrides
|
||||||
@@ -115,6 +133,9 @@ PC profiles use the legacy Charts page by default to avoid the blank screen
|
|||||||
caused by `Color3` errors in the newer SDUI page. An explicit
|
caused by `Color3` errors in the newer SDUI page. An explicit
|
||||||
`FFlagLuaAppChartsAppPage` override takes precedence over this default.
|
`FFlagLuaAppChartsAppPage` override takes precedence over this default.
|
||||||
|
|
||||||
|
PC profiles also set `FIntFlyoutTestVariantRollout12` to `2`, which shows the
|
||||||
|
desktop side panel menu. An explicit override takes precedence.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"FFlagExample": "True",
|
"FFlagExample": "True",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 620 KiB After Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1009 KiB |
@@ -32,7 +32,7 @@
|
|||||||
"mode": "vendored-patched-snapshot",
|
"mode": "vendored-patched-snapshot",
|
||||||
"url": "https://github.com/minecraft-linux/mcpelauncher-linker.git",
|
"url": "https://github.com/minecraft-linux/mcpelauncher-linker.git",
|
||||||
"upstream_commit": "1ac3ea6c1cf4f73a84d73f0bff510fba94a7d0f2",
|
"upstream_commit": "1ac3ea6c1cf4f73a84d73f0bff510fba94a7d0f2",
|
||||||
"vendor_tree": "fca44d85e3773c135ccfc603b14abdf6aeb8efd1",
|
"vendor_tree": "0637221e48f382ec8d0bc701385f4b10bcd07a65",
|
||||||
"patch": "third_party/patches/mcpelauncher-linker.patch",
|
"patch": "third_party/patches/mcpelauncher-linker.patch",
|
||||||
"nested_sources": {
|
"nested_sources": {
|
||||||
"android_bionic": {
|
"android_bionic": {
|
||||||
|
|||||||
@@ -22,20 +22,6 @@ what you are playing and you can switch it all off in one click.
|
|||||||
Done when every option in `config/mocktail.example.yaml` that a player would
|
Done when every option in `config/mocktail.example.yaml` that a player would
|
||||||
touch can be set without a text editor.
|
touch can be set without a text editor.
|
||||||
|
|
||||||
## 2. Controller button glyphs
|
|
||||||
|
|
||||||
Upstream added Xbox and PlayStation gamepad support through SDL. Controllers
|
|
||||||
already hot-plug and release their held inputs when unplugged. One rough edge
|
|
||||||
is left.
|
|
||||||
|
|
||||||
- Correct button glyphs for the connected pad. `SDL_GetGamepadType` is only
|
|
||||||
read for Xbox and PlayStation pads, so a Switch Pro, a GameCube pad, or any
|
|
||||||
generic pad SDL reports as `SDL_GAMEPAD_TYPE_STANDARD` reaches Roblox as
|
|
||||||
type 0 and draws generic glyphs. `SDL_GetRealGamepadType`, and the vendor
|
|
||||||
and product IDs, are available and unused.
|
|
||||||
|
|
||||||
Done when a plugged-in pad shows its own buttons.
|
|
||||||
|
|
||||||
## Shipped
|
## Shipped
|
||||||
|
|
||||||
- Lower input latency: frames are presented as soon as they are ready.
|
- Lower input latency: frames are presented as soon as they are ready.
|
||||||
|
|||||||
@@ -49,6 +49,17 @@
|
|||||||
gdb
|
gdb
|
||||||
];
|
];
|
||||||
|
|
||||||
|
desktopItems = [
|
||||||
|
(pkgs.makeDesktopItem {
|
||||||
|
name = "Mocktail";
|
||||||
|
exec = "mocktail";
|
||||||
|
icon = "mocktail";
|
||||||
|
desktopName = "Mocktail";
|
||||||
|
comment = "Experimental Roblox compatibility runtime for Linux and FreeBSD";
|
||||||
|
categories = [ "Game" ];
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath package.buildInputs}"
|
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath package.buildInputs}"
|
||||||
|
|
||||||
|
|||||||
@@ -11,19 +11,27 @@
|
|||||||
|
|
||||||
namespace mocktail::compat {
|
namespace mocktail::compat {
|
||||||
|
|
||||||
|
// glibc's atoi is (int)strtol(), exactly like Bionic's, so the host symbol is
|
||||||
|
// used as-is there. Other libcs (musl) differ on overflow and need the shim.
|
||||||
|
#ifdef __GLIBC__
|
||||||
|
inline constexpr bool kHostAtoiMatchesBionic = true;
|
||||||
|
#else
|
||||||
|
inline constexpr bool kHostAtoiMatchesBionic = false;
|
||||||
|
#endif
|
||||||
|
|
||||||
// Android uses size_t fields here; glibc uses int and musl has no equivalent.
|
// Android uses size_t fields here; glibc uses int and musl has no equivalent.
|
||||||
// An all-zero fallback means unknown, not zero usage.
|
// An all-zero fallback means unknown, not zero usage.
|
||||||
struct BionicMallinfoSnapshot {
|
struct BionicMallinfoSnapshot {
|
||||||
size_t arena = 0;
|
size_t arena;
|
||||||
size_t ordblks = 0;
|
size_t ordblks;
|
||||||
size_t smblks = 0;
|
size_t smblks;
|
||||||
size_t hblks = 0;
|
size_t hblks;
|
||||||
size_t hblkhd = 0;
|
size_t hblkhd;
|
||||||
size_t usmblks = 0;
|
size_t usmblks;
|
||||||
size_t fsmblks = 0;
|
size_t fsmblks;
|
||||||
size_t uordblks = 0;
|
size_t uordblks;
|
||||||
size_t fordblks = 0;
|
size_t fordblks;
|
||||||
size_t keepcost = 0;
|
size_t keepcost;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct BionicLocaleState;
|
struct BionicLocaleState;
|
||||||
@@ -90,6 +98,7 @@ int mocktail_bionic_cxa_thread_atexit_impl(
|
|||||||
mocktail::compat::BionicThreadDestructor destructor, void* argument,
|
mocktail::compat::BionicThreadDestructor destructor, void* argument,
|
||||||
void* dso_handle);
|
void* dso_handle);
|
||||||
void mocktail_bionic_arc4random_buf(void* buffer, size_t size);
|
void mocktail_bionic_arc4random_buf(void* buffer, size_t size);
|
||||||
|
int mocktail_bionic_atoi(const char* text);
|
||||||
mocktail::compat::BionicMallinfoSnapshot mocktail_bionic_mallinfo();
|
mocktail::compat::BionicMallinfoSnapshot mocktail_bionic_mallinfo();
|
||||||
int mocktail_bionic_sysinfo(struct sysinfo* info);
|
int mocktail_bionic_sysinfo(struct sysinfo* info);
|
||||||
int mocktail_bionic_uname(struct utsname* name);
|
int mocktail_bionic_uname(struct utsname* name);
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ struct FmodOutputDeviceBridgeProfile {
|
|||||||
? std::array<std::size_t, 4>{9, 10, 12, 18}
|
? std::array<std::size_t, 4>{9, 10, 12, 18}
|
||||||
: std::array<std::size_t, 4>{8, 9, 10, 16};
|
: std::array<std::size_t, 4>{8, 9, 10, 16};
|
||||||
}
|
}
|
||||||
|
std::array<std::size_t, 6> capture_vtable_indexes() const {
|
||||||
|
return vtable_layout_version == 2
|
||||||
|
? std::array<std::size_t, 6>{4, 13, 14, 15, 16, 17}
|
||||||
|
: std::array<std::size_t, 6>{4, 11, 12, 13, 14, 15};
|
||||||
|
}
|
||||||
|
|
||||||
bool valid_vtable_layout() const {
|
bool valid_vtable_layout() const {
|
||||||
return vtable_layout_version == 1 || vtable_layout_version == 2;
|
return vtable_layout_version == 1 || vtable_layout_version == 2;
|
||||||
|
|||||||
@@ -109,6 +109,15 @@ struct HostAbiProfile {
|
|||||||
size_t NativeMimallocConstructorRangeEndExclusive() const noexcept;
|
size_t NativeMimallocConstructorRangeEndExclusive() const noexcept;
|
||||||
bool ShouldInitializeNativeMimallocThreadAfterConstructor(
|
bool ShouldInitializeNativeMimallocThreadAfterConstructor(
|
||||||
size_t index) const noexcept;
|
size_t index) const noexcept;
|
||||||
|
// True when both replays have the one verified shape. The native replay is
|
||||||
|
// one range that runs to the end of .init_array. Its first four
|
||||||
|
// constructors are the process-load constructor at the thread-initializer
|
||||||
|
// checkpoint, the JNI singleton initializer, and two wiring entries. The
|
||||||
|
// host-bridge replay runs the same constructors without the two wiring
|
||||||
|
// entries. `jni_singleton_constructor` is the .init_array index of the JNI
|
||||||
|
// singleton initializer.
|
||||||
|
bool HasVerifiedConstructorPolicy(
|
||||||
|
size_t jni_singleton_constructor) const noexcept;
|
||||||
bool HasValidNativePreJniBootstrap() const noexcept;
|
bool HasValidNativePreJniBootstrap() const noexcept;
|
||||||
HostAllocatorStrategy ResolveAllocatorStrategy(
|
HostAllocatorStrategy ResolveAllocatorStrategy(
|
||||||
bool host_bridges_override_present,
|
bool host_bridges_override_present,
|
||||||
|
|||||||
@@ -61,8 +61,7 @@ class RobloxOutputDeviceBridge final {
|
|||||||
void SelectDevice(int index, bool input = false);
|
void SelectDevice(int index, bool input = false);
|
||||||
void ConstructGuestString(void* destination, std::string_view value) const;
|
void ConstructGuestString(void* destination, std::string_view value) const;
|
||||||
|
|
||||||
std::unique_ptr<NativeInputCapture> capture_;
|
std::shared_ptr<NativeInputCapture> capture_;
|
||||||
bool capture_profile_supported_ = false;
|
|
||||||
std::array<std::uintptr_t, 6> original_capture_methods_{};
|
std::array<std::uintptr_t, 6> original_capture_methods_{};
|
||||||
mutable std::mutex mutex_;
|
mutable std::mutex mutex_;
|
||||||
compat::FmodOutputDeviceBridgeProfile profile_;
|
compat::FmodOutputDeviceBridgeProfile profile_;
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ struct AngleProbeOptions {
|
|||||||
bool allow_software_device = false;
|
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
|
// Loads a pinned ANGLE pair, verifies the required EGL/GLES symbols, and
|
||||||
// initializes an EGL_PLATFORM_ANGLE Vulkan display. No context is fabricated.
|
// initializes an EGL_PLATFORM_ANGLE Vulkan display. No context is fabricated.
|
||||||
BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options);
|
BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
#define MOCKTAIL_GRAPHICS_PRESENT_MODE_POLICY_H_
|
#define MOCKTAIL_GRAPHICS_PRESENT_MODE_POLICY_H_
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -29,6 +30,36 @@ FilterPresentModes(PresentModePolicy policy,
|
|||||||
const char *PresentModePolicyName(PresentModePolicy policy);
|
const char *PresentModePolicyName(PresentModePolicy policy);
|
||||||
const char *PresentModeKhrName(VkPresentModeKHR mode);
|
const char *PresentModeKhrName(VkPresentModeKHR mode);
|
||||||
|
|
||||||
|
// Creates the swapchain with the mode in `info`. A driver can reject a mode
|
||||||
|
// that the device did not enable, so a failed create is retried once with the
|
||||||
|
// mode the game asked for. `create` takes the info and returns a VkResult.
|
||||||
|
template <typename Create>
|
||||||
|
VkResult CreateSwapchainWithPresentModeFallback(VkSwapchainCreateInfoKHR &info,
|
||||||
|
VkPresentModeKHR requested,
|
||||||
|
Create create) {
|
||||||
|
const VkResult result = create(info);
|
||||||
|
if (result == VK_SUCCESS || info.presentMode == requested) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
switch (result) {
|
||||||
|
case VK_ERROR_OUT_OF_HOST_MEMORY:
|
||||||
|
case VK_ERROR_OUT_OF_DEVICE_MEMORY:
|
||||||
|
case VK_ERROR_DEVICE_LOST:
|
||||||
|
case VK_ERROR_SURFACE_LOST_KHR:
|
||||||
|
case VK_ERROR_NATIVE_WINDOW_IN_USE_KHR:
|
||||||
|
return result; // Another present mode cannot fix these.
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::fprintf(stderr,
|
||||||
|
" [vulkan] swapchain presentMode %s failed (%d); retrying "
|
||||||
|
"with %s\n",
|
||||||
|
PresentModeKhrName(info.presentMode), static_cast<int>(result),
|
||||||
|
PresentModeKhrName(requested));
|
||||||
|
info.presentMode = requested;
|
||||||
|
return create(info);
|
||||||
|
}
|
||||||
|
|
||||||
// Extra images so acquire does not wait on the compositor-held buffer.
|
// Extra images so acquire does not wait on the compositor-held buffer.
|
||||||
std::uint32_t PreferSwapchainMinImageCount(PresentModePolicy policy,
|
std::uint32_t PreferSwapchainMinImageCount(PresentModePolicy policy,
|
||||||
std::uint32_t requested,
|
std::uint32_t requested,
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ void ResampleRgba(const RgbaImage& source, std::uint32_t width,
|
|||||||
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
|
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
|
||||||
std::uint32_t source_height, std::uint32_t width,
|
std::uint32_t source_height, std::uint32_t width,
|
||||||
std::uint32_t height, std::uint8_t* destination);
|
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
|
// 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
|
// bytes. An override lives at <override_dir>/<hash>.png. With a dump
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#ifndef MOCKTAIL_RUNTIME_ASSET_OVERLAY_NAMES_H_
|
||||||
|
#define MOCKTAIL_RUNTIME_ASSET_OVERLAY_NAMES_H_
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace mocktail {
|
||||||
|
namespace runtime {
|
||||||
|
|
||||||
|
// The rbxasset:// name of every regular file under <overlay_dir>/content, such
|
||||||
|
// as rbxasset://textures/MouseLockedCursor.png. Sorted.
|
||||||
|
std::vector<std::string> OverlayRbxAssetNames(
|
||||||
|
const std::filesystem::path& overlay_dir);
|
||||||
|
|
||||||
|
// Roblox opens no local file for an rbxasset:// name it maps to a download.
|
||||||
|
// Writes '!' over the first byte of each whole NUL-terminated copy of a name
|
||||||
|
// in `data`, which unmaps it. Returns the number of copies changed.
|
||||||
|
std::size_t DisableAssetNameMappings(std::uint8_t* data, std::size_t size,
|
||||||
|
const std::vector<std::string>& names);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -18,6 +18,26 @@ std::string SelectVulkanIcdManifest(
|
|||||||
const std::vector<std::filesystem::path>& directories,
|
const std::vector<std::filesystem::path>& directories,
|
||||||
std::string_view vendor);
|
std::string_view vendor);
|
||||||
|
|
||||||
|
// One GPU as the kernel reports it.
|
||||||
|
struct HostGpu {
|
||||||
|
unsigned int vendor = 0; // PCI vendor ID.
|
||||||
|
std::string kernel_driver; // Bound kernel driver; empty when unknown.
|
||||||
|
};
|
||||||
|
|
||||||
|
struct VulkanIcdSelection {
|
||||||
|
std::string manifest; // Empty when no GPU has a usable driver.
|
||||||
|
std::vector<std::string> skipped; // Why each passed-over GPU was skipped.
|
||||||
|
};
|
||||||
|
|
||||||
|
// ICD manifest for the first GPU whose bound kernel driver has an installed
|
||||||
|
// Vulkan driver. NVIDIA and AMD cards are tried before Intel when
|
||||||
|
// `prefer_discrete` is set, and after it otherwise. A GPU with an unknown
|
||||||
|
// kernel driver is matched by vendor alone.
|
||||||
|
VulkanIcdSelection SelectHostVulkanIcd(
|
||||||
|
const std::vector<HostGpu>& gpus,
|
||||||
|
const std::vector<std::filesystem::path>& directories,
|
||||||
|
bool prefer_discrete);
|
||||||
|
|
||||||
// Publishes the resolved graphics backend before the managed payload updater
|
// Publishes the resolved graphics backend before the managed payload updater
|
||||||
// starts. OpenGL is a strict system EGL/GLES path; it never silently retries
|
// starts. OpenGL is a strict system EGL/GLES path; it never silently retries
|
||||||
// through ANGLE/Vulkan or accepts a window without a real graphics context.
|
// through ANGLE/Vulkan or accepts a window without a real graphics context.
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ struct RobloxThemeCacheResult {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Reads the current account's Roblox theme without changing appStorage. A
|
// 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
|
// missing or unrecognized value falls back to AuthenticatedTheme, then the
|
||||||
// startup default.
|
// caller's startup default.
|
||||||
RobloxThemeCacheResult ReadRobloxThemeCache(
|
RobloxThemeCacheResult ReadRobloxThemeCache(
|
||||||
const std::filesystem::path& app_storage_file,
|
const std::filesystem::path& app_storage_file,
|
||||||
std::int64_t authenticated_user_id);
|
std::int64_t authenticated_user_id);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#ifndef MOCKTAIL_RUNTIME_ROBLOX_CALL_PROTOCOL_BRIDGE_H_
|
||||||
|
#define MOCKTAIL_RUNTIME_ROBLOX_CALL_PROTOCOL_BRIDGE_H_
|
||||||
|
|
||||||
|
#include "runtime/roblox_message_bus_request_bridge.h"
|
||||||
|
|
||||||
|
namespace mocktail {
|
||||||
|
namespace runtime {
|
||||||
|
|
||||||
|
// In-experience CoreScripts query the app's cross-experience call state before
|
||||||
|
// initializing voice. Mocktail does not start or accept cross-experience calls,
|
||||||
|
// so that state is Idle. This query neither joins voice nor opens a microphone.
|
||||||
|
class RobloxCallProtocolBridge final {
|
||||||
|
public:
|
||||||
|
RobloxCallProtocolBridge(JniEnvironmentProvider environment,
|
||||||
|
RobloxMessageBusSymbols symbols,
|
||||||
|
RobloxMessageBusObjects objects);
|
||||||
|
Status Initialize();
|
||||||
|
Status Shutdown();
|
||||||
|
|
||||||
|
private:
|
||||||
|
RobloxMessageBusRequestBridge bridge_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace runtime
|
||||||
|
} // namespace mocktail
|
||||||
|
#endif
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
#include "mocktail/status.h"
|
#include "mocktail/status.h"
|
||||||
#include "runtime/owned_pthread.h"
|
#include "runtime/owned_pthread.h"
|
||||||
#include "runtime/roblox_browser_service_bridge.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_launch_bridge.h"
|
||||||
#include "runtime/roblox_experience_presence.h"
|
#include "runtime/roblox_experience_presence.h"
|
||||||
#include "runtime/roblox_fresh_game_launch_controller.h"
|
#include "runtime/roblox_fresh_game_launch_controller.h"
|
||||||
@@ -97,7 +98,9 @@ struct RobloxExperienceSurfaceProvider {
|
|||||||
void* context = nullptr;
|
void* context = nullptr;
|
||||||
GameSurface (*snapshot)(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
|
// Production composition for the APK platform protocols and ExperienceProtocol
|
||||||
@@ -256,6 +259,7 @@ class RobloxExperienceComposition final {
|
|||||||
std::unique_ptr<RobloxWebViewBridge> web_view_bridge_;
|
std::unique_ptr<RobloxWebViewBridge> web_view_bridge_;
|
||||||
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge_;
|
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge_;
|
||||||
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge_;
|
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge_;
|
||||||
|
std::unique_ptr<RobloxCallProtocolBridge> call_protocol_bridge_;
|
||||||
// Optional: a payload without the AccountProtocol entrypoints still starts.
|
// Optional: a payload without the AccountProtocol entrypoints still starts.
|
||||||
std::unique_ptr<RobloxAccountBridge> account_bridge_;
|
std::unique_ptr<RobloxAccountBridge> account_bridge_;
|
||||||
std::shared_ptr<WebViewHelperProcess> web_surface_process_;
|
std::shared_ptr<WebViewHelperProcess> web_surface_process_;
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#ifndef MOCKTAIL_RUNTIME_ROBLOX_MESSAGE_BUS_REQUEST_BRIDGE_H_
|
||||||
|
#define MOCKTAIL_RUNTIME_ROBLOX_MESSAGE_BUS_REQUEST_BRIDGE_H_
|
||||||
|
|
||||||
|
#include <jni.h>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "mocktail/status.h"
|
||||||
|
#include "runtime/roblox_game_session_native_adapter.h"
|
||||||
|
|
||||||
|
namespace mocktail {
|
||||||
|
namespace runtime {
|
||||||
|
struct RobloxMessageBusSymbols {
|
||||||
|
jobject (*subscribe_request)(JNIEnv*, jobject, jstring, jstring, jobject,
|
||||||
|
jboolean) = nullptr;
|
||||||
|
void (*delete_connection)(JNIEnv*, jobject, jlong) = nullptr;
|
||||||
|
void (*publish_response)(JNIEnv*, jobject, jstring, jstring, jstring, jint,
|
||||||
|
jstring) = nullptr;
|
||||||
|
void (*set_async_handler)(JNIEnv*, jobject, jstring, jstring,
|
||||||
|
jobject) = nullptr;
|
||||||
|
void (*clear_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||||
|
void (*call_response_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||||
|
|
||||||
|
bool complete() const {
|
||||||
|
return subscribe_request && delete_connection && publish_response &&
|
||||||
|
set_async_handler && clear_handler && call_response_handler;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RobloxMessageBusObjects {
|
||||||
|
jobject message_bus = nullptr;
|
||||||
|
void* context = nullptr;
|
||||||
|
jobject (*create_raw_callback)(void*, std::shared_ptr<void>,
|
||||||
|
void (*)(void*, JNIEnv*, jstring)) = nullptr;
|
||||||
|
void (*clear_raw_callback)(void*, jobject) = nullptr;
|
||||||
|
jobject (*create_async_handler)(void*, std::shared_ptr<void>,
|
||||||
|
void (*)(void*, JNIEnv*, jstring,
|
||||||
|
jstring)) = nullptr;
|
||||||
|
void (*clear_async_handler)(void*, jobject) = nullptr;
|
||||||
|
|
||||||
|
bool complete() const {
|
||||||
|
return message_bus && context && create_raw_callback &&
|
||||||
|
clear_raw_callback && create_async_handler && clear_async_handler;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RobloxMessageBusReply {
|
||||||
|
std::string json;
|
||||||
|
jint code = 0;
|
||||||
|
const char* outcome = "ok";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Owns both legacy subscriptions and correlated async request handlers.
|
||||||
|
// A policy receives only a request's method index and JNI-local payload.
|
||||||
|
class RobloxMessageBusRequestBridge final {
|
||||||
|
public:
|
||||||
|
using Handler =
|
||||||
|
std::function<RobloxMessageBusReply(JNIEnv*, jstring, std::size_t)>;
|
||||||
|
RobloxMessageBusRequestBridge(JniEnvironmentProvider environment,
|
||||||
|
RobloxMessageBusSymbols symbols,
|
||||||
|
RobloxMessageBusObjects objects,
|
||||||
|
std::string protocol,
|
||||||
|
std::vector<std::string> methods,
|
||||||
|
Handler handler);
|
||||||
|
~RobloxMessageBusRequestBridge();
|
||||||
|
Status Initialize();
|
||||||
|
Status Shutdown();
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct State;
|
||||||
|
Status ShutdownLocked();
|
||||||
|
const JniEnvironmentProvider environment_;
|
||||||
|
const RobloxMessageBusSymbols symbols_;
|
||||||
|
const RobloxMessageBusObjects objects_;
|
||||||
|
const std::string protocol_name_;
|
||||||
|
const std::vector<std::string> methods_;
|
||||||
|
const Handler handler_;
|
||||||
|
std::mutex lifecycle_mutex_;
|
||||||
|
std::shared_ptr<State> state_;
|
||||||
|
};
|
||||||
|
} // namespace runtime
|
||||||
|
} // namespace mocktail
|
||||||
|
#endif
|
||||||
@@ -1,58 +1,12 @@
|
|||||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
|
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
|
||||||
#define MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
|
#define MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
|
||||||
|
#include "runtime/roblox_message_bus_request_bridge.h"
|
||||||
#include <jni.h>
|
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
#include <mutex>
|
|
||||||
|
|
||||||
#include "mocktail/status.h"
|
|
||||||
#include "runtime/roblox_game_session_native_adapter.h"
|
|
||||||
|
|
||||||
namespace mocktail {
|
namespace mocktail {
|
||||||
namespace runtime {
|
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 {
|
class RobloxPermissionsBridge final {
|
||||||
public:
|
public:
|
||||||
RobloxPermissionsBridge(JniEnvironmentProvider environment,
|
RobloxPermissionsBridge(JniEnvironmentProvider environment,
|
||||||
@@ -60,26 +14,12 @@ class RobloxPermissionsBridge final {
|
|||||||
RobloxPermissionsMessageBusObjects objects,
|
RobloxPermissionsMessageBusObjects objects,
|
||||||
bool microphone_enabled);
|
bool microphone_enabled);
|
||||||
~RobloxPermissionsBridge();
|
~RobloxPermissionsBridge();
|
||||||
RobloxPermissionsBridge(const RobloxPermissionsBridge&) = delete;
|
|
||||||
RobloxPermissionsBridge& operator=(const RobloxPermissionsBridge&) = delete;
|
|
||||||
|
|
||||||
Status Initialize();
|
Status Initialize();
|
||||||
Status Shutdown();
|
Status Shutdown();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct State;
|
std::unique_ptr<RobloxMessageBusRequestBridge> bridge_;
|
||||||
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_;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace runtime
|
} // namespace runtime
|
||||||
} // namespace mocktail
|
} // namespace mocktail
|
||||||
|
#endif
|
||||||
#endif // MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
|
|
||||||
@@ -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_
|
||||||
@@ -32,6 +32,9 @@ inline constexpr char kRobloxOpenCaptchaViewNotification[] =
|
|||||||
"OPEN_CAPTCHA_VIEW";
|
"OPEN_CAPTCHA_VIEW";
|
||||||
inline constexpr char kRobloxViewProfileNotification[] = "VIEW_PROFILE";
|
inline constexpr char kRobloxViewProfileNotification[] = "VIEW_PROFILE";
|
||||||
inline constexpr char kRobloxPurchaseRobuxNotification[] = "PURCHASE_ROBUX";
|
inline constexpr char kRobloxPurchaseRobuxNotification[] = "PURCHASE_ROBUX";
|
||||||
|
// Sent with a decimal user id when the app asks for a chat with that user.
|
||||||
|
inline constexpr char kRobloxLaunchConversationNotification[] =
|
||||||
|
"LAUNCH_CONVERSATION";
|
||||||
|
|
||||||
// Owned subset of WebViewProtocol's openWindow payload. The URL is copied
|
// Owned subset of WebViewProtocol's openWindow payload. The URL is copied
|
||||||
// before the MessageBus RawCallback returns, so no JNI local reference escapes.
|
// before the MessageBus RawCallback returns, so no JNI local reference escapes.
|
||||||
@@ -115,6 +118,14 @@ using PublishWebViewRawFn = void (*)(JNIEnv *, jobject, jstring, jstring);
|
|||||||
using BroadcastWebViewDataModelFocusFn = void (*)(JNIEnv *, jclass, jstring,
|
using BroadcastWebViewDataModelFocusFn = void (*)(JNIEnv *, jclass, jstring,
|
||||||
jstring, jstring);
|
jstring, jstring);
|
||||||
using SignalWebViewJavascriptCallbackFn = void (*)(JNIEnv*, jclass, jstring);
|
using SignalWebViewJavascriptCallbackFn = void (*)(JNIEnv*, jclass, jstring);
|
||||||
|
// NativeGLInterface.nativeAppBridgeV2SendAppEventOnGameLoaded(namespace,
|
||||||
|
// detail, detailType).
|
||||||
|
using SendRobloxAppEventOnGameLoadedFn = void (*)(JNIEnv*, jclass, jstring,
|
||||||
|
jstring, jstring);
|
||||||
|
// NativeGLInterface.nativeAppBridgeV2SendAppEventOnAppReady(namespace, detail,
|
||||||
|
// detailType, appName). The engine holds the event until appName is ready.
|
||||||
|
using SendRobloxAppEventOnAppReadyFn = void (*)(JNIEnv*, jclass, jstring,
|
||||||
|
jstring, jstring, jstring);
|
||||||
using UpdateRobloxCookieSetHandlerFn =
|
using UpdateRobloxCookieSetHandlerFn =
|
||||||
void (*)(JNIEnv*, jobject, jobject);
|
void (*)(JNIEnv*, jobject, jobject);
|
||||||
// Roblox's counted handle to its Subsystem<IWebViewProtocol>. The protocol,
|
// Roblox's counted handle to its Subsystem<IWebViewProtocol>. The protocol,
|
||||||
@@ -167,6 +178,9 @@ struct RobloxWebViewMessageBusSymbols {
|
|||||||
UpdateRobloxCookieSetHandlerFn update_cookie_set_handler = nullptr;
|
UpdateRobloxCookieSetHandlerFn update_cookie_set_handler = nullptr;
|
||||||
// Optional: found by code contract, not by symbol name.
|
// Optional: found by code contract, not by symbol name.
|
||||||
AcquireWebViewProtocolFn acquire_web_view_protocol = nullptr;
|
AcquireWebViewProtocolFn acquire_web_view_protocol = nullptr;
|
||||||
|
// Optional: without them LAUNCH_CONVERSATION is reported as unavailable.
|
||||||
|
SendRobloxAppEventOnGameLoadedFn send_app_event_on_game_loaded = nullptr;
|
||||||
|
SendRobloxAppEventOnAppReadyFn send_app_event_on_app_ready = nullptr;
|
||||||
|
|
||||||
bool complete() const {
|
bool complete() const {
|
||||||
return get_open_window_id != nullptr &&
|
return get_open_window_id != nullptr &&
|
||||||
@@ -270,11 +284,14 @@ private:
|
|||||||
kDataModelUnfocused,
|
kDataModelUnfocused,
|
||||||
kDataModelFocused,
|
kDataModelFocused,
|
||||||
kMessageBusWindowClose,
|
kMessageBusWindowClose,
|
||||||
|
kStartConversationWithUser,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct PendingHostWindowEvent {
|
struct PendingHostWindowEvent {
|
||||||
HostWindowEventType type = HostWindowEventType::kDataModelUnfocused;
|
HostWindowEventType type = HostWindowEventType::kDataModelUnfocused;
|
||||||
uint64_t generation = 0;
|
uint64_t generation = 0;
|
||||||
|
// Decimal user id of a kStartConversationWithUser event.
|
||||||
|
std::string user_id;
|
||||||
};
|
};
|
||||||
|
|
||||||
Status CheckJniException(JNIEnv *env, const char *operation) const;
|
Status CheckJniException(JNIEnv *env, const char *operation) const;
|
||||||
@@ -283,6 +300,8 @@ private:
|
|||||||
Status DispatchOpenRequest(RobloxWebViewOpenRequest request,
|
Status DispatchOpenRequest(RobloxWebViewOpenRequest request,
|
||||||
OpenSource source);
|
OpenSource source);
|
||||||
void QueueHostWindowEvent(HostWindowEventType type, uint64_t generation);
|
void QueueHostWindowEvent(HostWindowEventType type, uint64_t generation);
|
||||||
|
void QueueStartConversation(std::string user_id);
|
||||||
|
Status SendStartConversationEvents(JNIEnv* env, const std::string& user_id);
|
||||||
Status BeginDispatch();
|
Status BeginDispatch();
|
||||||
void EndDispatch();
|
void EndDispatch();
|
||||||
static void RawMessageCallback(void *context, JNIEnv *env, jstring message);
|
static void RawMessageCallback(void *context, JNIEnv *env, jstring message);
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ class RuntimePaths {
|
|||||||
return active_payload_manifest_;
|
return active_payload_manifest_;
|
||||||
}
|
}
|
||||||
const std::filesystem::path& cookie_file() const { return cookie_file_; }
|
const std::filesystem::path& cookie_file() const { return cookie_file_; }
|
||||||
|
const std::filesystem::path& asset_overlay_dir() const {
|
||||||
|
return asset_overlay_dir_;
|
||||||
|
}
|
||||||
bool use_real_sober_paths() const { return use_real_sober_paths_; }
|
bool use_real_sober_paths() const { return use_real_sober_paths_; }
|
||||||
|
|
||||||
std::filesystem::path DefaultAssetPath() const;
|
std::filesystem::path DefaultAssetPath() const;
|
||||||
@@ -99,6 +102,7 @@ class RuntimePaths {
|
|||||||
std::filesystem::path config_file_;
|
std::filesystem::path config_file_;
|
||||||
std::filesystem::path active_payload_manifest_;
|
std::filesystem::path active_payload_manifest_;
|
||||||
std::filesystem::path cookie_file_;
|
std::filesystem::path cookie_file_;
|
||||||
|
std::filesystem::path asset_overlay_dir_;
|
||||||
bool use_real_sober_paths_ = false;
|
bool use_real_sober_paths_ = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ struct HostAbiDerivationOptions {
|
|||||||
std::filesystem::path reference_library;
|
std::filesystem::path reference_library;
|
||||||
std::filesystem::path reference_profile;
|
std::filesystem::path reference_profile;
|
||||||
std::vector<std::filesystem::path> reference_compatibility_manifests;
|
std::vector<std::filesystem::path> reference_compatibility_manifests;
|
||||||
|
// Optional libroblox.so of another build that the compatibility manifests
|
||||||
|
// describe. It supplies the FMOD output-device anchors when the manifests
|
||||||
|
// have none for the reference build.
|
||||||
|
std::filesystem::path runtime_fallback_library;
|
||||||
std::filesystem::path candidate_payload_directory;
|
std::filesystem::path candidate_payload_directory;
|
||||||
std::filesystem::path output_directory;
|
std::filesystem::path output_directory;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <array>
|
#include <array>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -33,11 +34,18 @@ struct PayloadGarbageResult {
|
|||||||
explicit operator bool() const { return error.empty(); }
|
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 {
|
class PayloadStore final {
|
||||||
public:
|
public:
|
||||||
PayloadStore(std::filesystem::path root,
|
PayloadStore(std::filesystem::path root,
|
||||||
std::filesystem::path compatibility_manifest,
|
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 Stage(const std::filesystem::path& prepared_payload);
|
||||||
PayloadStoreResult Promote(std::string_view payload_id);
|
PayloadStoreResult Promote(std::string_view payload_id);
|
||||||
@@ -60,6 +68,7 @@ class PayloadStore final {
|
|||||||
std::filesystem::path root_;
|
std::filesystem::path root_;
|
||||||
std::filesystem::path compatibility_manifest_;
|
std::filesystem::path compatibility_manifest_;
|
||||||
std::filesystem::path runtime_binary_;
|
std::filesystem::path runtime_binary_;
|
||||||
|
ManifestWriteFault manifest_write_fault_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace mocktail::update
|
} // namespace mocktail::update
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
#ifndef MOCKTAIL_UPDATE_READINESS_CANARY_H_
|
#ifndef MOCKTAIL_UPDATE_READINESS_CANARY_H_
|
||||||
#define MOCKTAIL_UPDATE_READINESS_CANARY_H_
|
#define MOCKTAIL_UPDATE_READINESS_CANARY_H_
|
||||||
|
|
||||||
|
#include <spawn.h>
|
||||||
|
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
|
||||||
@@ -22,6 +25,12 @@ std::string_view CanaryGraphicsBackendName(CanaryGraphicsBackend backend);
|
|||||||
bool ParseCanaryGraphicsBackend(std::string_view name,
|
bool ParseCanaryGraphicsBackend(std::string_view name,
|
||||||
CanaryGraphicsBackend* backend);
|
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 {
|
struct CanaryOptions {
|
||||||
std::filesystem::path runtime_binary;
|
std::filesystem::path runtime_binary;
|
||||||
std::filesystem::path payload_directory;
|
std::filesystem::path payload_directory;
|
||||||
@@ -29,9 +38,9 @@ struct CanaryOptions {
|
|||||||
std::filesystem::path host_abi_profile;
|
std::filesystem::path host_abi_profile;
|
||||||
std::filesystem::path cache_root;
|
std::filesystem::path cache_root;
|
||||||
std::filesystem::path state_root;
|
std::filesystem::path state_root;
|
||||||
CanaryGraphicsBackend graphics_backend =
|
CanaryGraphicsBackend graphics_backend = CanaryGraphicsBackend::kDirectVulkan;
|
||||||
CanaryGraphicsBackend::kDirectVulkan;
|
|
||||||
int timeout_seconds = 150;
|
int timeout_seconds = 150;
|
||||||
|
CanarySpawn spawn;
|
||||||
|
|
||||||
bool probation_candidate() const { return !host_abi_profile.empty(); }
|
bool probation_candidate() const { return !host_abi_profile.empty(); }
|
||||||
};
|
};
|
||||||
@@ -39,6 +48,8 @@ struct CanaryOptions {
|
|||||||
struct CanaryResult {
|
struct CanaryResult {
|
||||||
std::filesystem::path log_path;
|
std::filesystem::path log_path;
|
||||||
int exit_code = -1;
|
int exit_code = -1;
|
||||||
|
// Direct posix_spawn return code; zero when spawn was not attempted.
|
||||||
|
int spawn_error = 0;
|
||||||
std::string error;
|
std::string error;
|
||||||
|
|
||||||
explicit operator bool() const {
|
explicit operator bool() const {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ struct UpdatePaths {
|
|||||||
// build the single file happens to describe.
|
// build the single file happens to describe.
|
||||||
std::filesystem::path host_abi_reference_profile;
|
std::filesystem::path host_abi_reference_profile;
|
||||||
std::filesystem::path runtime_binary;
|
std::filesystem::path runtime_binary;
|
||||||
|
// Optional packaging wrapper; runtime_binary identifies the actual ELF.
|
||||||
|
std::filesystem::path runtime_launcher;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct UpdateRequest {
|
struct UpdateRequest {
|
||||||
@@ -38,6 +40,7 @@ struct UpdateRequest {
|
|||||||
CanaryGraphicsBackend canary_graphics_backend =
|
CanaryGraphicsBackend canary_graphics_backend =
|
||||||
CanaryGraphicsBackend::kDirectVulkan;
|
CanaryGraphicsBackend::kDirectVulkan;
|
||||||
int progress_fd = -1;
|
int progress_fd = -1;
|
||||||
|
CanarySpawn canary_spawn;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct UpdateResult {
|
struct UpdateResult {
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ pre-commit:
|
|||||||
desktop-entry:
|
desktop-entry:
|
||||||
glob: "*.desktop"
|
glob: "*.desktop"
|
||||||
run: desktop-file-validate {staged_files}
|
run: desktop-file-validate {staged_files}
|
||||||
|
changelog-width:
|
||||||
|
glob: "CHANGELOG.md"
|
||||||
|
run: >-
|
||||||
|
git diff --cached -U0 -- CHANGELOG.md |
|
||||||
|
awk '/^\+[^+]/ && length($0) > 111 { print "CHANGELOG.md: added line over 110 characters: " substr($0, 2); bad = 1 } END { exit bad }'
|
||||||
|
|
||||||
pre-push:
|
pre-push:
|
||||||
commands:
|
commands:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ MOCKTAIL_PORTABLE_MODE=standalone
|
|||||||
MOCKTAIL_RUNTIME_LIBRARY_DIR=${SHARUN_DIR}/lib/mocktail
|
MOCKTAIL_RUNTIME_LIBRARY_DIR=${SHARUN_DIR}/lib/mocktail
|
||||||
MOCKTAIL_BIN=${SHARUN_DIR}/bin/mocktail
|
MOCKTAIL_BIN=${SHARUN_DIR}/bin/mocktail
|
||||||
MOCKTAIL_UPDATE_CANARY_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_UPDATE_HELPER=${SHARUN_DIR}/bin/mocktail_updater
|
||||||
MOCKTAIL_WEBVIEW_HELPER=${SHARUN_DIR}/bin/mocktail_webview_helper
|
MOCKTAIL_WEBVIEW_HELPER=${SHARUN_DIR}/bin/mocktail_webview_helper
|
||||||
MOCKTAIL_FAILURE_DIALOG_HELPER=${SHARUN_DIR}/bin/mocktail_failure_dialog
|
MOCKTAIL_FAILURE_DIALOG_HELPER=${SHARUN_DIR}/bin/mocktail_failure_dialog
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: vii <coderdayton14@gmail.com>
|
# Maintainer: vii <coderdayton14@gmail.com>
|
||||||
|
|
||||||
pkgname=nightcap
|
pkgname=nightcap
|
||||||
pkgver=0.3.1
|
pkgver=0.3.4
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = nightcap-bin
|
pkgbase = nightcap-bin
|
||||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux (prebuilt)
|
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux (prebuilt)
|
||||||
pkgver = 0.3.1
|
pkgver = 0.3.4
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/CoderDayton/nightcap
|
url = https://github.com/CoderDayton/nightcap
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -34,10 +34,10 @@ pkgbase = nightcap-bin
|
|||||||
conflicts = mocktail
|
conflicts = mocktail
|
||||||
conflicts = nightcap
|
conflicts = nightcap
|
||||||
conflicts = nightcap-git
|
conflicts = nightcap-git
|
||||||
noextract = nightcap-0.3.1-1-x86_64.pkg.tar.zst
|
noextract = nightcap-0.3.4-1-x86_64.pkg.tar.zst
|
||||||
options = !debug
|
options = !debug
|
||||||
options = !strip
|
options = !strip
|
||||||
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
|
source = nightcap-0.3.4-1-x86_64.pkg.tar.zst::https://github.com/CoderDayton/nightcap/releases/download/v0.3.4/nightcap-0.3.4-1-x86_64.pkg.tar.zst
|
||||||
sha256sums = SKIP
|
sha256sums = SKIP
|
||||||
|
|
||||||
pkgname = nightcap-bin
|
pkgname = nightcap-bin
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: vii <coderdayton14 at gmail dot com>
|
# Maintainer: vii <coderdayton14 at gmail dot com>
|
||||||
|
|
||||||
pkgname=nightcap-bin
|
pkgname=nightcap-bin
|
||||||
pkgver=0.3.1
|
pkgver=0.3.4
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux (prebuilt)'
|
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux (prebuilt)'
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = nightcap
|
pkgbase = nightcap
|
||||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
|
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
|
||||||
pkgver = 0.3.1
|
pkgver = 0.3.4
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/CoderDayton/nightcap
|
url = https://github.com/CoderDayton/nightcap
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -42,7 +42,7 @@ pkgbase = nightcap
|
|||||||
conflicts = nightcap-git
|
conflicts = nightcap-git
|
||||||
options = !debug
|
options = !debug
|
||||||
options = !lto
|
options = !lto
|
||||||
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#tag=v0.3.1
|
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#tag=v0.3.4
|
||||||
sha256sums = SKIP
|
sha256sums = SKIP
|
||||||
|
|
||||||
pkgname = nightcap
|
pkgname = nightcap
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: vii <coderdayton14 at gmail dot com>
|
# Maintainer: vii <coderdayton14 at gmail dot com>
|
||||||
|
|
||||||
pkgname=nightcap
|
pkgname=nightcap
|
||||||
pkgver=0.3.1
|
pkgver=0.3.4
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -28,16 +28,12 @@
|
|||||||
</description>
|
</description>
|
||||||
<screenshots>
|
<screenshots>
|
||||||
<screenshot type="default">
|
<screenshot type="default">
|
||||||
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-home.png</image>
|
<image>https://raw.githubusercontent.com/CoderDayton/nightcap/main/assets/screenshots/flatpak-home.png</image>
|
||||||
<caption>Browse Roblox experiences</caption>
|
<caption>Browse Roblox experiences</caption>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
<screenshot>
|
<screenshot>
|
||||||
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-gameplay-tower.png</image>
|
<image>https://raw.githubusercontent.com/CoderDayton/nightcap/main/assets/screenshots/flatpak-gameplay-tower.png</image>
|
||||||
<caption>Play Tower of Hell on Linux</caption>
|
<caption>Play Murder Mystery 2 on Linux</caption>
|
||||||
</screenshot>
|
|
||||||
<screenshot>
|
|
||||||
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-gameplay-lobby.png</image>
|
|
||||||
<caption>Play DOORS on Linux</caption>
|
|
||||||
</screenshot>
|
</screenshot>
|
||||||
</screenshots>
|
</screenshots>
|
||||||
<launchable type="desktop-id">io.github.CoderDayton.nightcap.desktop</launchable>
|
<launchable type="desktop-id">io.github.CoderDayton.nightcap.desktop</launchable>
|
||||||
@@ -57,6 +53,32 @@
|
|||||||
<binary>mocktail</binary>
|
<binary>mocktail</binary>
|
||||||
</provides>
|
</provides>
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.3.4" date="2026-10-11">
|
||||||
|
<description>
|
||||||
|
<ul>
|
||||||
|
<li>Fixed Roblox 2.742 being rejected as an update.</li>
|
||||||
|
<li>The new Roblox home screen and its side menu now show.</li>
|
||||||
|
<li>Fixed the voice chat microphone after Roblox updates.</li>
|
||||||
|
<li>Anti-aliasing is no longer forced off.</li>
|
||||||
|
<li>Nightcap now uses Wayland on NVIDIA Wayland sessions.</li>
|
||||||
|
<li>Fixed a missing picture on machines whose graphics card runs a different driver.</li>
|
||||||
|
<li>Fewer frame hitches while textures load.</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.3.3" date="2026-10-04">
|
||||||
|
<description>
|
||||||
|
<ul>
|
||||||
|
<li>Joining a friend from a follow user link now works.</li>
|
||||||
|
<li>Roblox textures decode about 3 times faster, so loading stalls are shorter.</li>
|
||||||
|
<li>A failed Roblox update now restores your previous install instead of leaving it half changed.</li>
|
||||||
|
<li>Fixed crashes and freezes with audio, the text overlay, the mouse lock and saved themes.</li>
|
||||||
|
<li>Going to another page in the web view no longer closes it.</li>
|
||||||
|
<li>Clicking while typing no longer closes the text box.</li>
|
||||||
|
<li>Fixed the sky showing the wrong textures.</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.3.2" date="2026-09-29">
|
<release version="0.3.2" date="2026-09-29">
|
||||||
<description>
|
<description>
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ Branch=stable
|
|||||||
Url=${BASE_URL}/repo/
|
Url=${BASE_URL}/repo/
|
||||||
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||||
Homepage=https://github.com/CoderDayton/nightcap
|
Homepage=https://github.com/CoderDayton/nightcap
|
||||||
Comment=Roblox on Linux, tuned for a real PC
|
Comment=Roblox on Linux, tuned for desktop play
|
||||||
Icon=${BASE_URL}/nightcap.svg
|
Icon=${BASE_URL}/nightcap.svg
|
||||||
GPGKey=${GPG_KEY}
|
GPGKey=${GPG_KEY}
|
||||||
IsRuntime=false
|
IsRuntime=false
|
||||||
@@ -72,3 +72,20 @@ install -m 0644 -- "${PROJECT_DIR}/site/index.html" "${OUTPUT}/index.html"
|
|||||||
install -m 0644 -- "${PROJECT_DIR}/site/styles.css" "${OUTPUT}/styles.css"
|
install -m 0644 -- "${PROJECT_DIR}/site/styles.css" "${OUTPUT}/styles.css"
|
||||||
install -m 0644 -- "${PROJECT_DIR}/site/terms.html" "${OUTPUT}/terms.html"
|
install -m 0644 -- "${PROJECT_DIR}/site/terms.html" "${OUTPUT}/terms.html"
|
||||||
install -m 0644 -- "${PROJECT_DIR}/site/privacy.html" "${OUTPUT}/privacy.html"
|
install -m 0644 -- "${PROJECT_DIR}/site/privacy.html" "${OUTPUT}/privacy.html"
|
||||||
|
install -m 0644 -- "${PROJECT_DIR}/site/preview.png" "${OUTPUT}/preview.png"
|
||||||
|
|
||||||
|
cat >"${OUTPUT}/robots.txt" <<EOF
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: ${BASE_URL}/sitemap.xml
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat >"${OUTPUT}/sitemap.xml" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url><loc>${BASE_URL}/</loc></url>
|
||||||
|
<url><loc>${BASE_URL}/terms.html</loc></url>
|
||||||
|
<url><loc>${BASE_URL}/privacy.html</loc></url>
|
||||||
|
</urlset>
|
||||||
|
EOF
|
||||||
@@ -23,6 +23,7 @@ supported.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import contextlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -161,6 +162,10 @@ class SignatureSpec:
|
|||||||
allow_registry_object_size_change: bool = False
|
allow_registry_object_size_change: bool = False
|
||||||
minimum_anchor_bytes: int = 6
|
minimum_anchor_bytes: int = 6
|
||||||
registry_size_indices: tuple[int, int] = (8, 12)
|
registry_size_indices: tuple[int, int] = (8, 12)
|
||||||
|
# Instructions whose register operands the compiler may have chosen
|
||||||
|
# differently. Each reference register must stand for one candidate
|
||||||
|
# register across all of them, and the reverse.
|
||||||
|
renamed_register_indices: tuple[int, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -1075,6 +1080,51 @@ def semantic_shape(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_family(name: str) -> str:
|
||||||
|
"""Return the 64-bit register that name is all or part of."""
|
||||||
|
if len(name) >= 2 and name[0] == "r" and name[1].isdigit():
|
||||||
|
return name.rstrip("bwd")
|
||||||
|
for family in ("ax", "bx", "cx", "dx", "si", "di", "bp", "sp"):
|
||||||
|
if name in (family, "e" + family, "r" + family):
|
||||||
|
return "r" + family
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def renamed_shapes_match(
|
||||||
|
reference_shape: tuple[Any, ...],
|
||||||
|
candidate_shape: tuple[Any, ...],
|
||||||
|
forward: dict[str, str],
|
||||||
|
backward: dict[str, str],
|
||||||
|
) -> bool:
|
||||||
|
"""Compare two semantic shapes, letting register operands be renamed.
|
||||||
|
|
||||||
|
forward and backward hold the renames accepted so far and gain new ones.
|
||||||
|
"""
|
||||||
|
reference_mnemonic, reference_operands = reference_shape
|
||||||
|
candidate_mnemonic, candidate_operands = candidate_shape
|
||||||
|
if reference_mnemonic != candidate_mnemonic or len(reference_operands) != len(
|
||||||
|
candidate_operands
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
for left, right in zip(reference_operands, candidate_operands):
|
||||||
|
if left[0] != "reg" or right[0] != "reg":
|
||||||
|
if left != right:
|
||||||
|
return False
|
||||||
|
continue
|
||||||
|
if left[2] != right[2]:
|
||||||
|
return False
|
||||||
|
reference_family = register_family(left[1])
|
||||||
|
candidate_family = register_family(right[1])
|
||||||
|
if (
|
||||||
|
forward.setdefault(reference_family, candidate_family)
|
||||||
|
!= candidate_family
|
||||||
|
or backward.setdefault(candidate_family, reference_family)
|
||||||
|
!= reference_family
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def validate_semantic_match(
|
def validate_semantic_match(
|
||||||
reference: Sequence[Any],
|
reference: Sequence[Any],
|
||||||
candidate: Sequence[Any],
|
candidate: Sequence[Any],
|
||||||
@@ -1082,6 +1132,8 @@ def validate_semantic_match(
|
|||||||
) -> None:
|
) -> None:
|
||||||
if len(reference) != len(candidate):
|
if len(reference) != len(candidate):
|
||||||
raise AnalyzerError("normalized signature instruction count changed")
|
raise AnalyzerError("normalized signature instruction count changed")
|
||||||
|
forward: dict[str, str] = {}
|
||||||
|
backward: dict[str, str] = {}
|
||||||
for index, (reference_instruction, candidate_instruction) in enumerate(
|
for index, (reference_instruction, candidate_instruction) in enumerate(
|
||||||
zip(reference, candidate)
|
zip(reference, candidate)
|
||||||
):
|
):
|
||||||
@@ -1089,8 +1141,14 @@ def validate_semantic_match(
|
|||||||
spec.allow_registry_object_size_change
|
spec.allow_registry_object_size_change
|
||||||
and index in spec.registry_size_indices
|
and index in spec.registry_size_indices
|
||||||
)
|
)
|
||||||
if semantic_shape(reference_instruction, wildcard_size) != semantic_shape(
|
reference_shape = semantic_shape(reference_instruction, wildcard_size)
|
||||||
candidate_instruction, wildcard_size
|
candidate_shape = semantic_shape(candidate_instruction, wildcard_size)
|
||||||
|
if (
|
||||||
|
not renamed_shapes_match(
|
||||||
|
reference_shape, candidate_shape, forward, backward
|
||||||
|
)
|
||||||
|
if index in spec.renamed_register_indices
|
||||||
|
else reference_shape != candidate_shape
|
||||||
):
|
):
|
||||||
raise AnalyzerError(
|
raise AnalyzerError(
|
||||||
f"normalized instruction semantics changed at signature index {index}"
|
f"normalized instruction semantics changed at signature index {index}"
|
||||||
@@ -1298,6 +1356,35 @@ def has_fmod_device_lists_select_contract(image: ElfImage, rva: int) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FMOD_OUTPUT_FIELDS = frozenset({
|
||||||
|
"vtable_rva",
|
||||||
|
"string_constructor_rva",
|
||||||
|
"count_method_rva",
|
||||||
|
"info_method_rva",
|
||||||
|
"current_method_rva",
|
||||||
|
"select_method_rva",
|
||||||
|
})
|
||||||
|
FMOD_INPUT_FIELDS = (
|
||||||
|
"input_count_method_rva",
|
||||||
|
"input_info_method_rva",
|
||||||
|
"input_current_method_rva",
|
||||||
|
"input_select_method_rva",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fmod_vtable_slots(layout: int, with_input: bool) -> dict[str, int]:
|
||||||
|
"""Vtable slot of each FMOD method field in vtable layout 1 or 2."""
|
||||||
|
slots = {
|
||||||
|
"count_method_rva": 5,
|
||||||
|
"info_method_rva": 6,
|
||||||
|
"current_method_rva": 8 if layout == 2 else 7,
|
||||||
|
"select_method_rva": 19 if layout == 2 else 17,
|
||||||
|
}
|
||||||
|
if with_input:
|
||||||
|
slots.update(zip(FMOD_INPUT_FIELDS, (9, 10, 12, 18) if layout == 2 else (8, 9, 10, 16)))
|
||||||
|
return slots
|
||||||
|
|
||||||
|
|
||||||
def load_reference_runtime_profile(
|
def load_reference_runtime_profile(
|
||||||
paths: Sequence[Path], reference_build_id: str
|
paths: Sequence[Path], reference_build_id: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -1323,21 +1410,11 @@ def load_reference_runtime_profile(
|
|||||||
)
|
)
|
||||||
if "fmod_output_device_bridge" in profile:
|
if "fmod_output_device_bridge" in profile:
|
||||||
bridge = profile["fmod_output_device_bridge"]
|
bridge = profile["fmod_output_device_bridge"]
|
||||||
fields = {
|
|
||||||
"vtable_rva",
|
|
||||||
"string_constructor_rva",
|
|
||||||
"count_method_rva",
|
|
||||||
"info_method_rva",
|
|
||||||
"current_method_rva",
|
|
||||||
"select_method_rva",
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
not isinstance(bridge, dict)
|
not isinstance(bridge, dict)
|
||||||
or not fields <= set(bridge)
|
or not FMOD_OUTPUT_FIELDS <= set(bridge)
|
||||||
or set(bridge) - fields - {"vtable_layout_version"} - {
|
or set(bridge) - FMOD_OUTPUT_FIELDS - {"vtable_layout_version"}
|
||||||
"input_count_method_rva", "input_info_method_rva",
|
- set(FMOD_INPUT_FIELDS)
|
||||||
"input_current_method_rva", "input_select_method_rva",
|
|
||||||
}
|
|
||||||
or profile.get("allow_host_abi_bridges") is not True
|
or profile.get("allow_host_abi_bridges") is not True
|
||||||
):
|
):
|
||||||
raise AnalyzerError(
|
raise AnalyzerError(
|
||||||
@@ -1357,10 +1434,7 @@ def load_reference_runtime_profile(
|
|||||||
if field in result:
|
if field in result:
|
||||||
previous = result[field]
|
previous = result[field]
|
||||||
if field == "fmod_output_device_bridge":
|
if field == "fmod_output_device_bridge":
|
||||||
input_fields = {
|
input_fields = set(FMOD_INPUT_FIELDS)
|
||||||
"input_count_method_rva", "input_info_method_rva",
|
|
||||||
"input_current_method_rva", "input_select_method_rva",
|
|
||||||
}
|
|
||||||
compatible = ((set(previous) ^ set(value)) <= input_fields and
|
compatible = ((set(previous) ^ set(value)) <= input_fields and
|
||||||
all(previous[key] == value[key]
|
all(previous[key] == value[key]
|
||||||
for key in previous.keys() & value.keys()))
|
for key in previous.keys() & value.keys()))
|
||||||
@@ -1382,12 +1456,7 @@ def derive_fmod_output_device_bridge(
|
|||||||
source: dict[str, int],
|
source: dict[str, int],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
source_layout = source.get("vtable_layout_version", 1)
|
source_layout = source.get("vtable_layout_version", 1)
|
||||||
indexes = {
|
indexes = fmod_vtable_slots(source_layout, False)
|
||||||
"count_method_rva": 5,
|
|
||||||
"info_method_rva": 6,
|
|
||||||
"current_method_rva": 8 if source_layout == 2 else 7,
|
|
||||||
"select_method_rva": 19 if source_layout == 2 else 17,
|
|
||||||
}
|
|
||||||
source_vtable = source["vtable_rva"]
|
source_vtable = source["vtable_rva"]
|
||||||
reference.require_relro_rva(source_vtable, (max(indexes.values()) + 1) * 8)
|
reference.require_relro_rva(source_vtable, (max(indexes.values()) + 1) * 8)
|
||||||
reference_relocations = relative_relocation_map(reference)
|
reference_relocations = relative_relocation_map(reference)
|
||||||
@@ -1472,7 +1541,10 @@ def derive_fmod_output_device_bridge(
|
|||||||
continue
|
continue
|
||||||
vtable = offset - indexes["info_method_rva"] * 8
|
vtable = offset - indexes["info_method_rva"] * 8
|
||||||
count_method = relocations.get(vtable + indexes["count_method_rva"] * 8)
|
count_method = relocations.get(vtable + indexes["count_method_rva"] * 8)
|
||||||
for layout, current_index, select_index in ((1, 7, 17), (2, 8, 19)):
|
for layout in (1, 2):
|
||||||
|
layout_slots = fmod_vtable_slots(layout, False)
|
||||||
|
current_index = layout_slots["current_method_rva"]
|
||||||
|
select_index = layout_slots["select_method_rva"]
|
||||||
current_method = relocations.get(vtable + current_index * 8)
|
current_method = relocations.get(vtable + current_index * 8)
|
||||||
selected = relocations.get(vtable + select_index * 8)
|
selected = relocations.get(vtable + select_index * 8)
|
||||||
if (
|
if (
|
||||||
@@ -1510,18 +1582,13 @@ def derive_fmod_output_device_bridge(
|
|||||||
}
|
}
|
||||||
if layout != 1:
|
if layout != 1:
|
||||||
result["vtable_layout_version"] = layout
|
result["vtable_layout_version"] = layout
|
||||||
input_fields = (
|
if any(field in source for field in FMOD_INPUT_FIELDS):
|
||||||
"input_count_method_rva", "input_info_method_rva",
|
if not all(field in source for field in FMOD_INPUT_FIELDS):
|
||||||
"input_current_method_rva", "input_select_method_rva",
|
|
||||||
)
|
|
||||||
if any(field in source for field in input_fields):
|
|
||||||
if not all(field in source for field in input_fields):
|
|
||||||
raise AnalyzerError("reference FMOD input profile is incomplete")
|
raise AnalyzerError("reference FMOD input profile is incomplete")
|
||||||
source_slots = (9, 10, 12, 18) if source_layout == 2 else (8, 9, 10, 16)
|
source_slots = fmod_vtable_slots(source_layout, True)
|
||||||
target_slots = (9, 10, 12, 18) if layout == 2 else (8, 9, 10, 16)
|
target_slots = fmod_vtable_slots(layout, True)
|
||||||
for field, old_slot, new_slot, length in zip(
|
for field, length in zip(FMOD_INPUT_FIELDS, (4, 16, 12, 16)):
|
||||||
input_fields, source_slots, target_slots, (4, 16, 12, 16)
|
old_slot, new_slot = source_slots[field], target_slots[field]
|
||||||
):
|
|
||||||
if reference_relocations.get(source_vtable + old_slot * 8) != source[field]:
|
if reference_relocations.get(source_vtable + old_slot * 8) != source[field]:
|
||||||
raise AnalyzerError("reference FMOD input slot changed")
|
raise AnalyzerError("reference FMOD input slot changed")
|
||||||
target = relocations.get(vtable + new_slot * 8)
|
target = relocations.get(vtable + new_slot * 8)
|
||||||
@@ -1541,6 +1608,7 @@ def derive_runtime_compatibility(
|
|||||||
reference: ElfImage,
|
reference: ElfImage,
|
||||||
candidate: ElfImage,
|
candidate: ElfImage,
|
||||||
manifests: Sequence[Path],
|
manifests: Sequence[Path],
|
||||||
|
fallback: ElfImage | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
source = load_reference_runtime_profile(manifests, reference.build_id)
|
source = load_reference_runtime_profile(manifests, reference.build_id)
|
||||||
result: dict[str, Any] = {}
|
result: dict[str, Any] = {}
|
||||||
@@ -1563,9 +1631,38 @@ def derive_runtime_compatibility(
|
|||||||
result["fmod_output_device_bridge"] = derive_fmod_output_device_bridge(
|
result["fmod_output_device_bridge"] = derive_fmod_output_device_bridge(
|
||||||
reference, candidate, fmod
|
reference, candidate, fmod
|
||||||
)
|
)
|
||||||
|
elif fallback is not None:
|
||||||
|
bridge = derive_fallback_fmod_output_device_bridge(
|
||||||
|
fallback, candidate, manifests
|
||||||
|
)
|
||||||
|
if bridge is not None:
|
||||||
|
result["fmod_output_device_bridge"] = bridge
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def derive_fallback_fmod_output_device_bridge(
|
||||||
|
fallback: ElfImage,
|
||||||
|
candidate: ElfImage,
|
||||||
|
manifests: Sequence[Path],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Derive the optional FMOD bridge from another build in the manifests.
|
||||||
|
|
||||||
|
Any failure leaves the bridge out.
|
||||||
|
"""
|
||||||
|
if fallback.build_id == candidate.build_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
fallback.validate_toolchain()
|
||||||
|
fmod = load_reference_runtime_profile(manifests, fallback.build_id).get(
|
||||||
|
"fmod_output_device_bridge"
|
||||||
|
)
|
||||||
|
if fmod is None:
|
||||||
|
return None
|
||||||
|
return derive_fmod_output_device_bridge(fallback, candidate, fmod)
|
||||||
|
except AnalyzerError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def find_registry_function_entry(
|
def find_registry_function_entry(
|
||||||
candidate: ElfImage,
|
candidate: ElfImage,
|
||||||
body_rva: int,
|
body_rva: int,
|
||||||
@@ -1613,6 +1710,73 @@ def find_registry_function_entry(
|
|||||||
return entries[0]
|
return entries[0]
|
||||||
|
|
||||||
|
|
||||||
|
def find_renamed_registry_body_match(
|
||||||
|
reference: ElfImage,
|
||||||
|
candidate: ElfImage,
|
||||||
|
reference_body: Sequence[Any],
|
||||||
|
minimum_anchor_bytes: int,
|
||||||
|
) -> SignatureMatch:
|
||||||
|
"""Find the registry initializer body under other temporary registers.
|
||||||
|
|
||||||
|
The body allocates the registry, clears it, and starts filling it:
|
||||||
|
|
||||||
|
0 mov edi, SIZE 6 xor esi, esi
|
||||||
|
1 call allocate 7 call memset
|
||||||
|
2 mov rbx, rax 8 mov ONE, 0x3f800000
|
||||||
|
3 xor ZERO, ZERO 9 mov dword ptr [rbx + 0x20], ONE
|
||||||
|
4 mov edx, SIZE 10 lea FIELD, [rbx + 0x28]
|
||||||
|
5 mov rdi, rax
|
||||||
|
|
||||||
|
ZERO, ONE and FIELD are the compiler's choice. Instructions 4 to 7 use
|
||||||
|
registers the calling convention fixes, so they are searched for by bytes
|
||||||
|
and every hit is then checked against all eleven instructions.
|
||||||
|
"""
|
||||||
|
clear_index = 4
|
||||||
|
clear_matches = find_signature_matches(
|
||||||
|
reference,
|
||||||
|
candidate,
|
||||||
|
SignatureSpec(
|
||||||
|
"registry-initializer-clear",
|
||||||
|
reference_body[clear_index].address,
|
||||||
|
4,
|
||||||
|
True,
|
||||||
|
minimum_anchor_bytes,
|
||||||
|
(0, 0),
|
||||||
|
),
|
||||||
|
allow_multiple_candidates=True,
|
||||||
|
)
|
||||||
|
body_spec = SignatureSpec(
|
||||||
|
"registry-initializer-body",
|
||||||
|
reference_body[0].address,
|
||||||
|
len(reference_body),
|
||||||
|
True,
|
||||||
|
minimum_anchor_bytes,
|
||||||
|
(0, 4),
|
||||||
|
(3, 8, 9, 10),
|
||||||
|
)
|
||||||
|
# Instructions 0 to 3 keep their lengths as long as ZERO stays one of r8
|
||||||
|
# to r15, so the body starts this far before the clear sequence.
|
||||||
|
lead = reference_body[clear_index].address - reference_body[0].address
|
||||||
|
found = []
|
||||||
|
for clear in clear_matches:
|
||||||
|
if clear.rva < lead:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
body = disassemble(candidate, clear.rva - lead, len(reference_body))
|
||||||
|
if body[clear_index].address != clear.rva:
|
||||||
|
continue
|
||||||
|
validate_semantic_match(reference_body, body, body_spec)
|
||||||
|
except AnalyzerError:
|
||||||
|
continue
|
||||||
|
found.append(SignatureMatch(clear.rva - lead, tuple(reference_body), body))
|
||||||
|
if len(found) != 1:
|
||||||
|
raise AnalyzerError(
|
||||||
|
f"signature registry-initializer-body matched {len(found)} "
|
||||||
|
"candidate locations with renamed registers"
|
||||||
|
)
|
||||||
|
return found[0]
|
||||||
|
|
||||||
|
|
||||||
def find_registry_signature_match(
|
def find_registry_signature_match(
|
||||||
reference: ElfImage,
|
reference: ElfImage,
|
||||||
candidate: ElfImage,
|
candidate: ElfImage,
|
||||||
@@ -1633,7 +1797,18 @@ def find_registry_signature_match(
|
|||||||
spec.minimum_anchor_bytes,
|
spec.minimum_anchor_bytes,
|
||||||
(0, 4),
|
(0, 4),
|
||||||
)
|
)
|
||||||
body_match = find_signature_match(reference, candidate, body_spec)
|
try:
|
||||||
|
body_match = find_signature_match(reference, candidate, body_spec)
|
||||||
|
except AnalyzerError:
|
||||||
|
body_match = find_renamed_registry_body_match(
|
||||||
|
reference,
|
||||||
|
candidate,
|
||||||
|
reference_full[
|
||||||
|
body_instruction_index : body_instruction_index
|
||||||
|
+ body_instruction_count
|
||||||
|
],
|
||||||
|
spec.minimum_anchor_bytes,
|
||||||
|
)
|
||||||
candidate_entry = find_registry_function_entry(
|
candidate_entry = find_registry_function_entry(
|
||||||
candidate,
|
candidate,
|
||||||
body_match.rva,
|
body_match.rva,
|
||||||
@@ -1671,14 +1846,14 @@ def find_constructor_boundary_match(
|
|||||||
reference: ElfImage,
|
reference: ElfImage,
|
||||||
candidate: ElfImage,
|
candidate: ElfImage,
|
||||||
spec: SignatureSpec,
|
spec: SignatureSpec,
|
||||||
reference_entry: int,
|
candidate_entries: Sequence[int],
|
||||||
candidate_entry: int,
|
|
||||||
) -> SignatureMatch:
|
) -> SignatureMatch:
|
||||||
if reference_entry != spec.reference_rva:
|
"""Match a constructor that is one jump to its body.
|
||||||
raise AnalyzerError(
|
|
||||||
"reference constructor boundary moved outside init-array index 4"
|
The body is found by signature, and the candidate constructor is the one
|
||||||
)
|
of candidate_entries that jumps to it.
|
||||||
reference_wrapper = disassemble(reference, reference_entry, 1)
|
"""
|
||||||
|
reference_wrapper = disassemble(reference, spec.reference_rva, 1)
|
||||||
reference_target = direct_jump_target(
|
reference_target = direct_jump_target(
|
||||||
reference_wrapper[0], "reference constructor boundary"
|
reference_wrapper[0], "reference constructor boundary"
|
||||||
)
|
)
|
||||||
@@ -1686,20 +1861,29 @@ def find_constructor_boundary_match(
|
|||||||
reference,
|
reference,
|
||||||
candidate,
|
candidate,
|
||||||
SignatureSpec(
|
SignatureSpec(
|
||||||
"constructor-4-target",
|
f"{spec.name}-target",
|
||||||
reference_target,
|
reference_target,
|
||||||
spec.instruction_count,
|
spec.instruction_count,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
candidate_wrapper = disassemble(candidate, candidate_entry, 1)
|
found = []
|
||||||
candidate_target = direct_jump_target(
|
for candidate_entry in candidate_entries:
|
||||||
candidate_wrapper[0], "candidate constructor boundary"
|
try:
|
||||||
)
|
candidate_wrapper = disassemble(candidate, candidate_entry, 1)
|
||||||
if candidate_target != target_match.rva:
|
candidate_target = direct_jump_target(
|
||||||
|
candidate_wrapper[0], "candidate constructor boundary"
|
||||||
|
)
|
||||||
|
except AnalyzerError:
|
||||||
|
continue
|
||||||
|
if candidate_target == target_match.rva:
|
||||||
|
found.append(
|
||||||
|
SignatureMatch(candidate_entry, reference_wrapper, candidate_wrapper)
|
||||||
|
)
|
||||||
|
if len(found) != 1:
|
||||||
raise AnalyzerError(
|
raise AnalyzerError(
|
||||||
"constructor index 4 no longer targets its verified boundary"
|
f"{spec.name} no longer targets its verified boundary"
|
||||||
)
|
)
|
||||||
return SignatureMatch(candidate_entry, reference_wrapper, candidate_wrapper)
|
return found[0]
|
||||||
|
|
||||||
|
|
||||||
def rip_targets(instruction: Any) -> tuple[int, ...]:
|
def rip_targets(instruction: Any) -> tuple[int, ...]:
|
||||||
@@ -1713,6 +1897,161 @@ def rip_targets(instruction: Any) -> tuple[int, ...]:
|
|||||||
return tuple(targets)
|
return tuple(targets)
|
||||||
|
|
||||||
|
|
||||||
|
def is_register_operand(operand: Any, register: int) -> bool:
|
||||||
|
return operand.type == capstone_x86.X86_OP_REG and operand.reg == register
|
||||||
|
|
||||||
|
|
||||||
|
def jni_singleton_publication(image: ElfImage, entry: int) -> tuple[Any, ...]:
|
||||||
|
"""Return the instructions that publish the JNI singleton.
|
||||||
|
|
||||||
|
They open its initializer at entry, after a stack frame if it has one:
|
||||||
|
|
||||||
|
lea rax, [rip + OBJECT]
|
||||||
|
xchg qword ptr [rip + SLOT], rax
|
||||||
|
mov qword ptr [rip + OBJECT], rax
|
||||||
|
|
||||||
|
OBJECT is the eight bytes after SLOT.
|
||||||
|
"""
|
||||||
|
for frame in (0, 2):
|
||||||
|
try:
|
||||||
|
decoded = disassemble(image, entry, frame + 3)
|
||||||
|
except AnalyzerError:
|
||||||
|
continue
|
||||||
|
if frame:
|
||||||
|
push, link = decoded[:2]
|
||||||
|
if (
|
||||||
|
push.mnemonic != "push"
|
||||||
|
or len(push.operands) != 1
|
||||||
|
or not is_register_operand(push.operands[0], capstone_x86.X86_REG_RBP)
|
||||||
|
or link.mnemonic != "mov"
|
||||||
|
or len(link.operands) != 2
|
||||||
|
or not is_register_operand(link.operands[0], capstone_x86.X86_REG_RBP)
|
||||||
|
or not is_register_operand(link.operands[1], capstone_x86.X86_REG_RSP)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
load, exchange, store = decoded[frame:]
|
||||||
|
object_targets = rip_targets(load)
|
||||||
|
slot_targets = rip_targets(exchange)
|
||||||
|
stored_targets = rip_targets(store)
|
||||||
|
if (
|
||||||
|
load.mnemonic == "lea"
|
||||||
|
and len(load.operands) == 2
|
||||||
|
and is_register_operand(load.operands[0], capstone_x86.X86_REG_RAX)
|
||||||
|
and exchange.mnemonic == "xchg"
|
||||||
|
and len(exchange.operands) == 2
|
||||||
|
and exchange.operands[0].size == 8
|
||||||
|
and is_register_operand(exchange.operands[1], capstone_x86.X86_REG_RAX)
|
||||||
|
and store.mnemonic == "mov"
|
||||||
|
and len(store.operands) == 2
|
||||||
|
and store.operands[0].size == 8
|
||||||
|
and is_register_operand(store.operands[1], capstone_x86.X86_REG_RAX)
|
||||||
|
and len(object_targets) == 1
|
||||||
|
and len(slot_targets) == 1
|
||||||
|
and len(stored_targets) == 1
|
||||||
|
and object_targets[0] == stored_targets[0]
|
||||||
|
and object_targets[0] == slot_targets[0] + 8
|
||||||
|
):
|
||||||
|
return decoded[frame:]
|
||||||
|
raise AnalyzerError(
|
||||||
|
f"JNI singleton initializer at {format_rva(entry)} "
|
||||||
|
"does not publish its singleton"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_jni_singleton_match(
|
||||||
|
reference: ElfImage,
|
||||||
|
candidate: ElfImage,
|
||||||
|
spec: SignatureSpec,
|
||||||
|
candidate_entries: Sequence[int],
|
||||||
|
) -> SignatureMatch:
|
||||||
|
"""Find the JNI singleton initializer among candidate_entries.
|
||||||
|
|
||||||
|
The instructions after the publication follow the singleton's layout, so
|
||||||
|
when a release changes that layout only the publication is matched. The
|
||||||
|
initializer itself runs from the init-array either way.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return find_signature_match(reference, candidate, spec)
|
||||||
|
except AnalyzerError:
|
||||||
|
pass
|
||||||
|
reference_publication = jni_singleton_publication(reference, spec.reference_rva)
|
||||||
|
found = find_signature_match(
|
||||||
|
reference,
|
||||||
|
candidate,
|
||||||
|
SignatureSpec(
|
||||||
|
"jni-singleton-publication",
|
||||||
|
reference_publication[0].address,
|
||||||
|
len(reference_publication),
|
||||||
|
minimum_anchor_bytes=3,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for entry in candidate_entries:
|
||||||
|
try:
|
||||||
|
publication = jni_singleton_publication(candidate, entry)
|
||||||
|
except AnalyzerError:
|
||||||
|
continue
|
||||||
|
if publication[0].address == found.rva:
|
||||||
|
return SignatureMatch(entry, reference_publication, publication)
|
||||||
|
raise AnalyzerError("JNI singleton initializer is not a leading constructor")
|
||||||
|
|
||||||
|
|
||||||
|
# The native replay starts at this init-array index on x86_64.
|
||||||
|
LEADING_CONSTRUCTOR_BEGIN = 2
|
||||||
|
# The process-load constructor, the JNI singleton initializer and two wiring
|
||||||
|
# entries. A release may put them in any order.
|
||||||
|
LEADING_CONSTRUCTOR_COUNT = 4
|
||||||
|
|
||||||
|
|
||||||
|
def match_leading_constructors(
|
||||||
|
reference: ElfImage,
|
||||||
|
candidate: ElfImage,
|
||||||
|
constructor_anchors: dict[str, Any],
|
||||||
|
jni_spec: SignatureSpec,
|
||||||
|
reference_init: Sequence[int],
|
||||||
|
candidate_init: Sequence[int],
|
||||||
|
) -> tuple[tuple[int, ...], SignatureMatch]:
|
||||||
|
"""Find each reference leading constructor in the candidate by its code.
|
||||||
|
|
||||||
|
Returns, for each reference leading constructor, the candidate init-array
|
||||||
|
index of the same constructor, both counted from
|
||||||
|
LEADING_CONSTRUCTOR_BEGIN, and the JNI singleton initializer match.
|
||||||
|
"""
|
||||||
|
moved = "constructor moved outside verified init-array boundary"
|
||||||
|
end = LEADING_CONSTRUCTOR_BEGIN + LEADING_CONSTRUCTOR_COUNT
|
||||||
|
if len(reference_init) < end or len(candidate_init) < end:
|
||||||
|
raise AnalyzerError("init-array shape is not derivable")
|
||||||
|
candidate_entries = tuple(candidate_init[LEADING_CONSTRUCTOR_BEGIN:end])
|
||||||
|
candidate_offsets = []
|
||||||
|
jni_match = None
|
||||||
|
for index in range(LEADING_CONSTRUCTOR_BEGIN, end):
|
||||||
|
reference_entry = reference_init[index]
|
||||||
|
name = f"constructor-{index}"
|
||||||
|
if parse_rva(constructor_anchors.get(str(index)), name) != reference_entry:
|
||||||
|
raise AnalyzerError(moved)
|
||||||
|
if reference_entry == jni_spec.reference_rva:
|
||||||
|
match = find_jni_singleton_match(
|
||||||
|
reference, candidate, jni_spec, candidate_entries
|
||||||
|
)
|
||||||
|
jni_match = match
|
||||||
|
else:
|
||||||
|
spec = SignatureSpec(name, reference_entry, 24)
|
||||||
|
if disassemble(reference, reference_entry, 1)[0].mnemonic == "jmp":
|
||||||
|
match = find_constructor_boundary_match(
|
||||||
|
reference, candidate, spec, candidate_entries
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
match = find_signature_match(reference, candidate, spec)
|
||||||
|
if match.rva not in candidate_entries:
|
||||||
|
raise AnalyzerError(moved)
|
||||||
|
candidate_offset = candidate_entries.index(match.rva)
|
||||||
|
if candidate_offset in candidate_offsets:
|
||||||
|
raise AnalyzerError(moved)
|
||||||
|
candidate_offsets.append(candidate_offset)
|
||||||
|
if jni_match is None:
|
||||||
|
raise AnalyzerError("JNI singleton initializer is not a leading constructor")
|
||||||
|
return tuple(candidate_offsets), jni_match
|
||||||
|
|
||||||
|
|
||||||
def mapped_rip_target(
|
def mapped_rip_target(
|
||||||
match: SignatureMatch, reference_target: int, description: str
|
match: SignatureMatch, reference_target: int, description: str
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -1805,7 +2144,6 @@ def create_signature_specs(
|
|||||||
bridges = bridge_map(profile)
|
bridges = bridge_map(profile)
|
||||||
data_seeds = profile["data_seeds"]
|
data_seeds = profile["data_seeds"]
|
||||||
bootstrap = profile["native_pre_jni_bootstrap"]
|
bootstrap = profile["native_pre_jni_bootstrap"]
|
||||||
constructor_rvas = anchors["constructor_rvas"]
|
|
||||||
return [
|
return [
|
||||||
SignatureSpec(
|
SignatureSpec(
|
||||||
"small-allocate",
|
"small-allocate",
|
||||||
@@ -1870,45 +2208,60 @@ def create_signature_specs(
|
|||||||
parse_rva(anchors["jni_singleton_initializer_rva"], "JNI singleton anchor"),
|
parse_rva(anchors["jni_singleton_initializer_rva"], "JNI singleton anchor"),
|
||||||
11,
|
11,
|
||||||
),
|
),
|
||||||
SignatureSpec(
|
|
||||||
"constructor-2", parse_rva(constructor_rvas["2"], "constructor 2"), 24
|
|
||||||
),
|
|
||||||
SignatureSpec(
|
|
||||||
"constructor-3", parse_rva(constructor_rvas["3"], "constructor 3"), 24
|
|
||||||
),
|
|
||||||
SignatureSpec(
|
|
||||||
"constructor-4",
|
|
||||||
parse_rva(constructor_rvas["4"], "constructor 4"),
|
|
||||||
24,
|
|
||||||
minimum_anchor_bytes=3,
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def adjusted_ranges(
|
def remapped_constructor_ranges(
|
||||||
ranges: list[dict[str, int]], reference_count: int, candidate_count: int
|
ranges: list[dict[str, int]],
|
||||||
|
candidate_offsets: Sequence[int],
|
||||||
|
reference_count: int,
|
||||||
|
candidate_count: int,
|
||||||
) -> list[dict[str, int]]:
|
) -> list[dict[str, int]]:
|
||||||
result = []
|
"""Rewrite the reference constructor ranges for the candidate init-array.
|
||||||
|
|
||||||
|
Each leading constructor keeps its place in or out of the replay under
|
||||||
|
its candidate index, and every later constructor runs.
|
||||||
|
"""
|
||||||
|
cannot_fit = "reference constructor range cannot fit candidate init-array"
|
||||||
|
leading_end = LEADING_CONSTRUCTOR_BEGIN + LEADING_CONSTRUCTOR_COUNT
|
||||||
|
ranges = validated_ranges(ranges, "reference constructor ranges", reference_count)
|
||||||
|
if (
|
||||||
|
ranges[0]["begin"] < LEADING_CONSTRUCTOR_BEGIN
|
||||||
|
or ranges[-1]["begin"] > leading_end
|
||||||
|
or ranges[-1]["end_exclusive"] != reference_count
|
||||||
|
or reference_count <= leading_end
|
||||||
|
or candidate_count <= leading_end
|
||||||
|
):
|
||||||
|
raise AnalyzerError(cannot_fit)
|
||||||
|
runs = [False] * LEADING_CONSTRUCTOR_COUNT
|
||||||
for item in ranges:
|
for item in ranges:
|
||||||
end = (
|
for index in range(item["begin"], min(item["end_exclusive"], leading_end)):
|
||||||
candidate_count
|
runs[candidate_offsets[index - LEADING_CONSTRUCTOR_BEGIN]] = True
|
||||||
if item["end_exclusive"] == reference_count
|
result = []
|
||||||
else item["end_exclusive"]
|
run_begin = None
|
||||||
)
|
for offset, run in enumerate(runs):
|
||||||
if end > candidate_count:
|
index = LEADING_CONSTRUCTOR_BEGIN + offset
|
||||||
raise AnalyzerError(
|
if run:
|
||||||
"reference constructor range cannot fit candidate init-array"
|
if run_begin is None:
|
||||||
)
|
run_begin = index
|
||||||
result.append({"begin": item["begin"], "end_exclusive": end})
|
elif run_begin is not None:
|
||||||
|
result.append({"begin": run_begin, "end_exclusive": index})
|
||||||
|
run_begin = None
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"begin": leading_end if run_begin is None else run_begin,
|
||||||
|
"end_exclusive": candidate_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
return validated_ranges(result, "derived constructor ranges", candidate_count)
|
return validated_ranges(result, "derived constructor ranges", candidate_count)
|
||||||
|
|
||||||
|
|
||||||
def derive_profile(
|
def check_derivation_inputs(
|
||||||
reference: ElfImage,
|
reference: ElfImage,
|
||||||
candidate: ElfImage,
|
candidate: ElfImage,
|
||||||
reference_sidecar: dict[str, Any],
|
reference_sidecar: dict[str, Any],
|
||||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
||||||
require_capstone()
|
"""Rejects inputs no derivation may use; returns both init-arrays."""
|
||||||
if (
|
if (
|
||||||
reference.build_id != reference_sidecar["elf_build_id"]
|
reference.build_id != reference_sidecar["elf_build_id"]
|
||||||
or reference.sha256 != reference_sidecar["payload_sha256"]
|
or reference.sha256 != reference_sidecar["payload_sha256"]
|
||||||
@@ -1926,17 +2279,28 @@ def derive_profile(
|
|||||||
"candidate is missing required JNI exports: "
|
"candidate is missing required JNI exports: "
|
||||||
+ ", ".join(sorted(missing_exports))
|
+ ", ".join(sorted(missing_exports))
|
||||||
)
|
)
|
||||||
|
|
||||||
reference_profile = reference_sidecar["profile"]
|
|
||||||
anchors = reference_sidecar["derivation_anchors"]
|
|
||||||
reference_init = reference.init_array_relocations()
|
reference_init = reference.init_array_relocations()
|
||||||
candidate_init = candidate.init_array_relocations()
|
candidate_init = candidate.init_array_relocations()
|
||||||
if len(reference_init) != reference_profile["init_array_count"]:
|
if len(reference_init) != reference_sidecar["profile"]["init_array_count"]:
|
||||||
raise AnalyzerError(
|
raise AnalyzerError(
|
||||||
"reference sidecar init-array count differs from ELF metadata"
|
"reference sidecar init-array count differs from ELF metadata"
|
||||||
)
|
)
|
||||||
if len(candidate_init) < 8:
|
if len(candidate_init) < 8:
|
||||||
raise AnalyzerError("candidate init-array is unexpectedly short")
|
raise AnalyzerError("candidate init-array is unexpectedly short")
|
||||||
|
return reference_init, candidate_init
|
||||||
|
|
||||||
|
|
||||||
|
def derive_profile(
|
||||||
|
reference: ElfImage,
|
||||||
|
candidate: ElfImage,
|
||||||
|
reference_sidecar: dict[str, Any],
|
||||||
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
require_capstone()
|
||||||
|
reference_init, candidate_init = check_derivation_inputs(
|
||||||
|
reference, candidate, reference_sidecar
|
||||||
|
)
|
||||||
|
reference_profile = reference_sidecar["profile"]
|
||||||
|
anchors = reference_sidecar["derivation_anchors"]
|
||||||
matches = {}
|
matches = {}
|
||||||
allocate_matches = ()
|
allocate_matches = ()
|
||||||
for spec in create_signature_specs(reference_profile, anchors):
|
for spec in create_signature_specs(reference_profile, anchors):
|
||||||
@@ -1948,13 +2312,14 @@ def derive_profile(
|
|||||||
matches[spec.name] = find_registry_signature_match(
|
matches[spec.name] = find_registry_signature_match(
|
||||||
reference, candidate, spec
|
reference, candidate, spec
|
||||||
)
|
)
|
||||||
elif spec.name == "constructor-4":
|
elif spec.name == "jni-singleton-initializer":
|
||||||
matches[spec.name] = find_constructor_boundary_match(
|
leading_offsets, matches[spec.name] = match_leading_constructors(
|
||||||
reference,
|
reference,
|
||||||
candidate,
|
candidate,
|
||||||
|
anchors["constructor_rvas"],
|
||||||
spec,
|
spec,
|
||||||
reference_init[4],
|
reference_init,
|
||||||
candidate_init[4],
|
candidate_init,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
matches[spec.name] = find_signature_match(reference, candidate, spec)
|
matches[spec.name] = find_signature_match(reference, candidate, spec)
|
||||||
@@ -1990,21 +2355,7 @@ def derive_profile(
|
|||||||
"registry allocator call"
|
"registry allocator call"
|
||||||
)
|
)
|
||||||
matches["allocate"] = selected_allocate[0]
|
matches["allocate"] = selected_allocate[0]
|
||||||
for index in (2, 3, 4):
|
|
||||||
match = matches[f"constructor-{index}"]
|
|
||||||
if (
|
|
||||||
reference_init[index] != match.reference_instructions[0].address
|
|
||||||
or candidate_init[index] != match.rva
|
|
||||||
):
|
|
||||||
raise AnalyzerError(
|
|
||||||
f"constructor {index} moved outside its verified init-array boundary"
|
|
||||||
)
|
|
||||||
singleton_match = matches["jni-singleton-initializer"]
|
singleton_match = matches["jni-singleton-initializer"]
|
||||||
if (
|
|
||||||
reference_init[5] != singleton_match.reference_instructions[0].address
|
|
||||||
or candidate_init[5] != singleton_match.rva
|
|
||||||
):
|
|
||||||
raise AnalyzerError("JNI singleton initializer is not constructor index 5")
|
|
||||||
|
|
||||||
candidate_bridge_entries = []
|
candidate_bridge_entries = []
|
||||||
for entry in reference_profile["bridge_entries"]:
|
for entry in reference_profile["bridge_entries"]:
|
||||||
@@ -2075,28 +2426,34 @@ def derive_profile(
|
|||||||
init_section = candidate.sections[".init_array"]
|
init_section = candidate.sections[".init_array"]
|
||||||
reference_count = reference_profile["init_array_count"]
|
reference_count = reference_profile["init_array_count"]
|
||||||
candidate_count = len(candidate_init)
|
candidate_count = len(candidate_init)
|
||||||
constructor_ranges = adjusted_ranges(
|
constructor_ranges = remapped_constructor_ranges(
|
||||||
validated_ranges(
|
reference_profile["constructor_run_ranges"],
|
||||||
reference_profile["constructor_run_ranges"],
|
leading_offsets,
|
||||||
"reference constructor ranges",
|
|
||||||
reference_count,
|
|
||||||
),
|
|
||||||
reference_count,
|
reference_count,
|
||||||
candidate_count,
|
candidate_count,
|
||||||
)
|
)
|
||||||
native_ranges = adjusted_ranges(
|
native_ranges = remapped_constructor_ranges(
|
||||||
validated_ranges(
|
reference_profile["native_mimalloc_constructor_run_ranges"],
|
||||||
reference_profile["native_mimalloc_constructor_run_ranges"],
|
leading_offsets,
|
||||||
"reference native constructor ranges",
|
|
||||||
reference_count,
|
|
||||||
),
|
|
||||||
reference_count,
|
reference_count,
|
||||||
candidate_count,
|
candidate_count,
|
||||||
)
|
)
|
||||||
thread_boundary = reference_profile[
|
reference_thread_boundary = reference_profile[
|
||||||
"native_mimalloc_thread_initializer_after_constructor"
|
"native_mimalloc_thread_initializer_after_constructor"
|
||||||
]
|
]
|
||||||
if thread_boundary != 2 or not any(
|
if not (
|
||||||
|
LEADING_CONSTRUCTOR_BEGIN
|
||||||
|
<= reference_thread_boundary
|
||||||
|
< LEADING_CONSTRUCTOR_BEGIN + LEADING_CONSTRUCTOR_COUNT
|
||||||
|
):
|
||||||
|
raise AnalyzerError("native allocator TLS boundary is no longer verified")
|
||||||
|
# The thread initializer follows the process-load constructor to its
|
||||||
|
# candidate index.
|
||||||
|
thread_boundary = (
|
||||||
|
LEADING_CONSTRUCTOR_BEGIN
|
||||||
|
+ leading_offsets[reference_thread_boundary - LEADING_CONSTRUCTOR_BEGIN]
|
||||||
|
)
|
||||||
|
if not any(
|
||||||
item["begin"] <= thread_boundary < item["end_exclusive"]
|
item["begin"] <= thread_boundary < item["end_exclusive"]
|
||||||
for item in native_ranges
|
for item in native_ranges
|
||||||
):
|
):
|
||||||
@@ -2243,13 +2600,22 @@ def analyze(arguments: argparse.Namespace) -> tuple[dict[str, Any], dict[str, An
|
|||||||
metadata_path = Path(arguments.payload_metadata)
|
metadata_path = Path(arguments.payload_metadata)
|
||||||
sidecar_path = Path(arguments.reference_profile)
|
sidecar_path = Path(arguments.reference_profile)
|
||||||
reference_sidecar = validated_sidecar(sidecar_path)
|
reference_sidecar = validated_sidecar(sidecar_path)
|
||||||
with ElfImage(reference_path) as reference, ElfImage(candidate_path) as candidate:
|
with contextlib.ExitStack() as stack:
|
||||||
|
reference = stack.enter_context(ElfImage(reference_path))
|
||||||
|
candidate = stack.enter_context(ElfImage(candidate_path))
|
||||||
|
fallback = None
|
||||||
|
if arguments.runtime_fallback_lib is not None:
|
||||||
|
with contextlib.suppress(AnalyzerError):
|
||||||
|
fallback = stack.enter_context(
|
||||||
|
ElfImage(Path(arguments.runtime_fallback_lib))
|
||||||
|
)
|
||||||
metadata = validated_payload_metadata(metadata_path, candidate_path, candidate)
|
metadata = validated_payload_metadata(metadata_path, candidate_path, candidate)
|
||||||
profile, anchors = derive_profile(reference, candidate, reference_sidecar)
|
profile, anchors = derive_profile(reference, candidate, reference_sidecar)
|
||||||
runtime_compatibility = derive_runtime_compatibility(
|
runtime_compatibility = derive_runtime_compatibility(
|
||||||
reference,
|
reference,
|
||||||
candidate,
|
candidate,
|
||||||
tuple(Path(path) for path in arguments.reference_compatibility),
|
tuple(Path(path) for path in arguments.reference_compatibility),
|
||||||
|
fallback,
|
||||||
)
|
)
|
||||||
return output_documents(
|
return output_documents(
|
||||||
candidate,
|
candidate,
|
||||||
@@ -2274,6 +2640,12 @@ def parse_arguments(argv: Sequence[str]) -> argparse.Namespace:
|
|||||||
metavar="MANIFEST",
|
metavar="MANIFEST",
|
||||||
help="compatibility manifest containing exact reference runtime anchors",
|
help="compatibility manifest containing exact reference runtime anchors",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--runtime-fallback-lib",
|
||||||
|
metavar="LIB",
|
||||||
|
help="libroblox.so of another build in the compatibility manifests; "
|
||||||
|
"supplies the FMOD output-device anchors when the reference has none",
|
||||||
|
)
|
||||||
parser.add_argument("--candidate-lib", required=True, metavar="LIB")
|
parser.add_argument("--candidate-lib", required=True, metavar="LIB")
|
||||||
parser.add_argument("--payload-metadata", required=True, metavar="META")
|
parser.add_argument("--payload-metadata", required=True, metavar="META")
|
||||||
parser.add_argument("--output", required=True, metavar="PROFILE")
|
parser.add_argument("--output", required=True, metavar="PROFILE")
|
||||||
|
|||||||
@@ -107,6 +107,28 @@ AnyLinuxDownloadDependencies() {
|
|||||||
AnyLinuxDependencyDie 'dependency downloads failed after 3 snapshots'
|
AnyLinuxDependencyDie 'dependency downloads failed after 3 snapshots'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Prints the package name and path of each archive that is older than the
|
||||||
|
# installed package.
|
||||||
|
AnyLinuxDependencyDowngrades() {
|
||||||
|
local package name version installed
|
||||||
|
for package in "$@"; do
|
||||||
|
read -r name version < <(pacman -Qp -- "${package}")
|
||||||
|
installed="$(pacman -Q -- "${name}" 2>/dev/null | cut -d ' ' -f 2)" || continue
|
||||||
|
[[ -n "${installed}" ]] || continue
|
||||||
|
if (( $(vercmp "${version}" "${installed}") < 0 )); then
|
||||||
|
printf '%s\t%s\n' "${name}" "${package}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prints each pkg-config module whose requirements are not met.
|
||||||
|
AnyLinuxDependencyBrokenModules() {
|
||||||
|
local module
|
||||||
|
while read -r module; do
|
||||||
|
pkg-config --exists "${module}" 2>/dev/null || printf '%s\n' "${module}"
|
||||||
|
done < <(pkg-config --list-package-names 2>/dev/null)
|
||||||
|
}
|
||||||
|
|
||||||
AnyLinuxDependenciesMain() (
|
AnyLinuxDependenciesMain() (
|
||||||
AnyLinuxDependencyRequireContainer
|
AnyLinuxDependencyRequireContainer
|
||||||
(( $# == 0 )) || AnyLinuxDependencyDie 'no arguments are supported'
|
(( $# == 0 )) || AnyLinuxDependencyDie 'no arguments are supported'
|
||||||
@@ -126,7 +148,35 @@ AnyLinuxDependenciesMain() (
|
|||||||
while IFS=$'\t' read -r digest filename url; do
|
while IFS=$'\t' read -r digest filename url; do
|
||||||
packages+=("${DOWNLOADS}/${filename}")
|
packages+=("${DOWNLOADS}/${filename}")
|
||||||
done <"${DOWNLOADS}/dependencies.tsv"
|
done <"${DOWNLOADS}/dependencies.tsv"
|
||||||
pacman -U --noconfirm --needed "${packages[@]}"
|
local broken name package
|
||||||
|
local -a current=() older=() older_names=()
|
||||||
|
local -A is_older=()
|
||||||
|
while IFS=$'\t' read -r name package; do
|
||||||
|
older_names+=("${name}")
|
||||||
|
is_older["${package}"]=1
|
||||||
|
done < <(AnyLinuxDependencyDowngrades "${packages[@]}")
|
||||||
|
for package in "${packages[@]}"; do
|
||||||
|
if [[ -n "${is_older[${package}]:-}" ]]; then
|
||||||
|
older+=("${package}")
|
||||||
|
else
|
||||||
|
current+=("${package}")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
(( ${#current[@]} == 0 )) || pacman -U --noconfirm --needed "${current[@]}"
|
||||||
|
(( ${#older[@]} > 0 )) || return 0
|
||||||
|
# A reduced package can lag the distribution. If the older version leaves
|
||||||
|
# another package's pkg-config requirements unmet, build with the
|
||||||
|
# distribution's package for that library. Modules that the reduced
|
||||||
|
# packages of the current version already leave unmet do not count.
|
||||||
|
broken="$(AnyLinuxDependencyBrokenModules)"
|
||||||
|
pacman -U --noconfirm --needed "${older[@]}"
|
||||||
|
if [[ "$(AnyLinuxDependencyBrokenModules)" != "${broken}" ]]; then
|
||||||
|
printf '[anylinux-dependencies] reduced packages are too old; restoring %s\n' \
|
||||||
|
"${older_names[*]}" >&2
|
||||||
|
pacman -S --noconfirm "${older_names[@]}"
|
||||||
|
[[ "$(AnyLinuxDependencyBrokenModules)" == "${broken}" ]] ||
|
||||||
|
AnyLinuxDependencyDie 'reduced packages break pkg-config requirements'
|
||||||
|
fi
|
||||||
)
|
)
|
||||||
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
|||||||
@@ -207,7 +207,13 @@ EOF
|
|||||||
"${ANYLINUX_TOOL}" --make-appimage
|
"${ANYLINUX_TOOL}" --make-appimage
|
||||||
local result="${ANYLINUX_WORK}/image/$(basename -- "${ANYLINUX_OUTPUT}")"
|
local result="${ANYLINUX_WORK}/image/$(basename -- "${ANYLINUX_OUTPUT}")"
|
||||||
[[ -x "${result}" ]] || AnyLinuxDie "quick-sharun did not create the requested AppImage"
|
[[ -x "${result}" ]] || AnyLinuxDie "quick-sharun did not create the requested AppImage"
|
||||||
|
if [[ -n "${UPINFO:-}" ]]; then
|
||||||
|
[[ -s "${result}.zsync" ]] || AnyLinuxDie "update information was set but no .zsync was generated"
|
||||||
|
fi
|
||||||
mv -f -- "${result}" "${ANYLINUX_OUTPUT}"
|
mv -f -- "${result}" "${ANYLINUX_OUTPUT}"
|
||||||
|
if [[ -f "${result}.zsync" ]]; then
|
||||||
|
mv -fT -- "${result}.zsync" "${ANYLINUX_OUTPUT}.zsync"
|
||||||
|
fi
|
||||||
printf '[anylinux] AppImage ready: %s\n' "${ANYLINUX_OUTPUT}" >&2
|
printf '[anylinux] AppImage ready: %s\n' "${ANYLINUX_OUTPUT}" >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -409,9 +409,11 @@ WriteClassicPixbufCache() {
|
|||||||
Die "classic GdkPixbuf loader cache is unavailable"
|
Die "classic GdkPixbuf loader cache is unavailable"
|
||||||
sed -E \
|
sed -E \
|
||||||
-e 's@^# LoaderDir = .*$@# LoaderDir = lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders@' \
|
-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}"
|
"${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
|
"${destination_cache}"; then
|
||||||
Die "GdkPixbuf cache still contains an absolute loader path"
|
Die "GdkPixbuf cache still contains an absolute loader path"
|
||||||
fi
|
fi
|
||||||
@@ -663,4 +665,6 @@ Main() {
|
|||||||
PublishResources
|
PublishResources
|
||||||
}
|
}
|
||||||
|
|
||||||
Main "$@"
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
Main "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright 2026 Mocktail Project Authors
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Run the HostAbi deriver and the solver on one update and compare them.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 roblox_abi_check.py --reference-lib OLD.so \\
|
||||||
|
--reference-profile OLD_PROFILE.json \\
|
||||||
|
--reference-compatibility config/roblox_compatibility.json \\
|
||||||
|
--candidate-lib NEW.so
|
||||||
|
|
||||||
|
Prints every field where the two disagree. Exit status: 0 agree, 1 differ or
|
||||||
|
one side failed, 2 both failed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
def _load_solver():
|
||||||
|
name = "mocktail_roblox_abi_solver"
|
||||||
|
if name in sys.modules:
|
||||||
|
return sys.modules[name]
|
||||||
|
path = Path(__file__).resolve().with_name("roblox_abi_solver.py")
|
||||||
|
specification = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if specification is None or specification.loader is None:
|
||||||
|
raise RuntimeError("cannot load roblox_abi_solver.py")
|
||||||
|
module = importlib.util.module_from_spec(specification)
|
||||||
|
sys.modules[name] = module
|
||||||
|
specification.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
SOLVER = _load_solver()
|
||||||
|
DERIVER = SOLVER.DERIVER
|
||||||
|
AnalyzerError = SOLVER.AnalyzerError
|
||||||
|
format_rva = SOLVER.format_rva
|
||||||
|
|
||||||
|
|
||||||
|
def compare_results(derived: Any, solved: Any, path: str = "") -> list[str]:
|
||||||
|
"""Returns one line for every leaf value where the two results differ."""
|
||||||
|
if isinstance(derived, dict) and isinstance(solved, dict):
|
||||||
|
lines = []
|
||||||
|
for key in sorted(derived.keys() | solved.keys(), key=str):
|
||||||
|
child = f"{path}.{key}" if path else str(key)
|
||||||
|
if key not in solved:
|
||||||
|
lines.append(f"{child}: deriver {derived[key]}, solver missing")
|
||||||
|
elif key not in derived:
|
||||||
|
lines.append(f"{child}: deriver missing, solver {solved[key]}")
|
||||||
|
else:
|
||||||
|
lines.extend(compare_results(derived[key], solved[key], child))
|
||||||
|
return lines
|
||||||
|
if isinstance(derived, list) and isinstance(solved, list) and len(derived) == len(solved):
|
||||||
|
return [
|
||||||
|
line
|
||||||
|
for index, (left, right) in enumerate(zip(derived, solved))
|
||||||
|
for line in compare_results(left, right, f"{path}[{index}]")
|
||||||
|
]
|
||||||
|
return [] if derived == solved else [f"{path}: deriver {derived}, solver {solved}"]
|
||||||
|
|
||||||
|
|
||||||
|
def check_status(
|
||||||
|
derived: Any, solved: Any, deriver_error: str | None, solver_error: str | None
|
||||||
|
) -> tuple[int, list[str]]:
|
||||||
|
"""Exit status and report lines: 0 agree, 1 differ or one failed, 2 both failed."""
|
||||||
|
lines = []
|
||||||
|
if deriver_error is not None:
|
||||||
|
lines.append(f"deriver failed: {deriver_error}")
|
||||||
|
if solver_error is not None:
|
||||||
|
lines.append(f"solver failed: {solver_error}")
|
||||||
|
if deriver_error is not None and solver_error is not None:
|
||||||
|
return 2, lines
|
||||||
|
if deriver_error is not None or solver_error is not None:
|
||||||
|
return 1, lines
|
||||||
|
differences = compare_results(derived, solved)
|
||||||
|
if differences:
|
||||||
|
return 1, differences
|
||||||
|
return 0, ["deriver and solver agree on every field"]
|
||||||
|
|
||||||
|
|
||||||
|
def _solver_runtime_source(source: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
# The deriver's loader returns integers; the solver reads hex strings.
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for key, value in source.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
result[key] = {
|
||||||
|
field: item if field == "vtable_layout_version" else format_rva(item)
|
||||||
|
for field, item in value.items()
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
result[key] = format_rva(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _describe(error: Exception) -> str:
|
||||||
|
if isinstance(error, AnalyzerError):
|
||||||
|
return str(error)
|
||||||
|
return f"{type(error).__name__}: {error}"
|
||||||
|
|
||||||
|
|
||||||
|
def check(arguments: argparse.Namespace) -> int:
|
||||||
|
"""Runs the deriver and the solver on one update and compares them."""
|
||||||
|
sidecar = DERIVER.validated_sidecar(Path(arguments.reference_profile))
|
||||||
|
manifests = [Path(path) for path in arguments.reference_compatibility]
|
||||||
|
cache_dir = Path(arguments.cache_dir) if arguments.cache_dir else None
|
||||||
|
derived = solved = deriver_error = solver_error = None
|
||||||
|
with DERIVER.ElfImage(Path(arguments.reference_lib)) as reference, DERIVER.ElfImage(
|
||||||
|
Path(arguments.candidate_lib)
|
||||||
|
) as candidate:
|
||||||
|
try:
|
||||||
|
profile, anchors = DERIVER.derive_profile(reference, candidate, sidecar)
|
||||||
|
runtime = DERIVER.derive_runtime_compatibility(reference, candidate, manifests)
|
||||||
|
derived = {"profile": profile, "derivation_anchors": anchors, "runtime": runtime}
|
||||||
|
except Exception as error:
|
||||||
|
deriver_error = _describe(error)
|
||||||
|
try:
|
||||||
|
# The input checks are cheap; indexing both libraries is not.
|
||||||
|
SOLVER.check_inputs(reference, candidate, sidecar)
|
||||||
|
solver = SOLVER.Solver(reference, candidate, cache_dir)
|
||||||
|
profile, anchors = solver.derive_profile(sidecar)
|
||||||
|
source = DERIVER.load_reference_runtime_profile(manifests, reference.build_id)
|
||||||
|
runtime = solver.derive_runtime_compatibility(_solver_runtime_source(source))
|
||||||
|
solved = {"profile": profile, "derivation_anchors": anchors, "runtime": runtime}
|
||||||
|
except Exception as error:
|
||||||
|
solver_error = _describe(error)
|
||||||
|
status, lines = check_status(derived, solved, deriver_error, solver_error)
|
||||||
|
for line in lines:
|
||||||
|
print(line)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Run the HostAbi deriver and the solver on one update and compare them"
|
||||||
|
)
|
||||||
|
parser.add_argument("--reference-lib", required=True, metavar="REF")
|
||||||
|
parser.add_argument("--reference-profile", required=True, metavar="REFJSON")
|
||||||
|
parser.add_argument(
|
||||||
|
"--reference-compatibility", action="append", default=[], metavar="MANIFEST"
|
||||||
|
)
|
||||||
|
parser.add_argument("--candidate-lib", required=True, metavar="LIB")
|
||||||
|
parser.add_argument(
|
||||||
|
"--cache-dir",
|
||||||
|
default=str(Path.home() / ".cache" / "mocktail" / "function_index"),
|
||||||
|
help="where function indexes are kept; empty to disable",
|
||||||
|
)
|
||||||
|
arguments = parser.parse_args(sys.argv[1:] if argv is None else argv)
|
||||||
|
try:
|
||||||
|
return check(arguments)
|
||||||
|
except AnalyzerError as error:
|
||||||
|
print(f"roblox-abi-check: error: {error}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright 2026 Mocktail Project Authors
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Evidence-scoring solver that finds HostAbi addresses in a new libroblox.
|
||||||
|
|
||||||
|
Each function in both builds is indexed by `roblox_function_index.py`. A
|
||||||
|
reference function is matched through independent clues:
|
||||||
|
|
||||||
|
- the same instruction shape, unique in both builds
|
||||||
|
- callers and callees that are already matched
|
||||||
|
- the strings it references
|
||||||
|
- its data references, shifted by the distance mapped for those addresses
|
||||||
|
- its place between matched neighbours
|
||||||
|
|
||||||
|
A match is accepted only with a score of at least ACCEPT_SCORE and a lead of
|
||||||
|
at least ACCEPT_LEAD over the runner-up. `roblox_abi_targets.py` turns the
|
||||||
|
matches into a profile with the same shape as the one
|
||||||
|
`derive_roblox_host_abi_profile.py` writes, and `roblox_abi_check.py`
|
||||||
|
compares the two on one update.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import bisect
|
||||||
|
import collections
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import difflib
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from typing import Any, Callable, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sibling(name: str, file_name: str):
|
||||||
|
if name in sys.modules:
|
||||||
|
return sys.modules[name]
|
||||||
|
path = Path(__file__).resolve().with_name(file_name)
|
||||||
|
specification = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if specification is None or specification.loader is None:
|
||||||
|
raise RuntimeError(f"cannot load {file_name}")
|
||||||
|
module = importlib.util.module_from_spec(specification)
|
||||||
|
sys.modules[name] = module
|
||||||
|
specification.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
INDEX = _load_sibling("mocktail_roblox_function_index", "roblox_function_index.py")
|
||||||
|
TARGETS = _load_sibling("mocktail_roblox_abi_targets", "roblox_abi_targets.py")
|
||||||
|
DERIVER = INDEX.DERIVER
|
||||||
|
FunctionIndex = INDEX.FunctionIndex
|
||||||
|
Instruction = INDEX.Instruction
|
||||||
|
AnalyzerError = DERIVER.AnalyzerError
|
||||||
|
format_rva = DERIVER.format_rva
|
||||||
|
check_inputs = TARGETS.check_inputs
|
||||||
|
validate_fmod_source = TARGETS.validate_fmod_source
|
||||||
|
|
||||||
|
ACCEPT_SCORE = 0.6
|
||||||
|
ACCEPT_LEAD = 0.2
|
||||||
|
# Candidates that get the full, disassembling score after the cheap one.
|
||||||
|
FULL_SCORE_LIMIT = 64
|
||||||
|
# A data address used by more functions than this is too common to narrow
|
||||||
|
# the candidates by itself.
|
||||||
|
XREF_FANOUT_LIMIT = 64
|
||||||
|
# Scores are float sums; a lead that equals ACCEPT_LEAD on paper may be
|
||||||
|
# a few ulps short.
|
||||||
|
_TOLERANCE = 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def choose_candidate(
|
||||||
|
scored: Sequence[tuple[float, int]], tie_breaker: int | None = None
|
||||||
|
) -> int | None:
|
||||||
|
"""Returns the leading candidate, or None when no lead is clear.
|
||||||
|
|
||||||
|
`tie_breaker` picks among the candidates that clear ACCEPT_SCORE and sit
|
||||||
|
within ACCEPT_LEAD of the best; it cannot lift or overrule any other.
|
||||||
|
"""
|
||||||
|
if not scored:
|
||||||
|
return None
|
||||||
|
ranked = sorted(scored, key=lambda item: item[0], reverse=True)
|
||||||
|
best_score, best = ranked[0]
|
||||||
|
runner_up = ranked[1][0] if len(ranked) > 1 else 0.0
|
||||||
|
if best_score >= ACCEPT_SCORE and best_score - runner_up >= ACCEPT_LEAD - _TOLERANCE:
|
||||||
|
return best
|
||||||
|
tied = {
|
||||||
|
rva for value, rva in ranked
|
||||||
|
if value >= ACCEPT_SCORE and best_score - value < ACCEPT_LEAD - _TOLERANCE
|
||||||
|
}
|
||||||
|
return tie_breaker if tie_breaker in tied else None
|
||||||
|
|
||||||
|
|
||||||
|
def align_instructions(
|
||||||
|
reference_shapes: Sequence[str], candidate_shapes: Sequence[str]
|
||||||
|
) -> dict[int, int]:
|
||||||
|
"""Maps reference instruction indexes to candidate indexes of equal shape."""
|
||||||
|
matcher = difflib.SequenceMatcher(
|
||||||
|
None, reference_shapes, candidate_shapes, autojunk=False
|
||||||
|
)
|
||||||
|
mapping = {}
|
||||||
|
for block in matcher.get_matching_blocks():
|
||||||
|
for offset in range(block.size):
|
||||||
|
mapping[block.a + offset] = block.b + offset
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Match:
|
||||||
|
rva: int
|
||||||
|
score: float
|
||||||
|
lead: float
|
||||||
|
# "score" when the score alone decided, "vtable" when a slot broke a tie.
|
||||||
|
decided_by: str
|
||||||
|
|
||||||
|
|
||||||
|
def _share(expected: Sequence[Any], present: set[Any]) -> float | None:
|
||||||
|
if not expected:
|
||||||
|
return None
|
||||||
|
return sum(1 for item in expected if item in present) / len(expected)
|
||||||
|
|
||||||
|
|
||||||
|
def _weighted(parts: Sequence[tuple[float | None, float]]) -> float:
|
||||||
|
present = [(value, weight) for value, weight in parts if value is not None]
|
||||||
|
return sum(value * weight for value, weight in present) / sum(
|
||||||
|
weight for _, weight in present
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Solver(TARGETS.TargetDerivation):
|
||||||
|
def __init__(self, reference: Any, candidate: Any, cache_dir: Path | None = None):
|
||||||
|
self._start(FunctionIndex(reference, cache_dir), FunctionIndex(candidate, cache_dir))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_indexes(cls, reference: Any, candidate: Any) -> "Solver":
|
||||||
|
solver = cls.__new__(cls)
|
||||||
|
solver._start(reference, candidate)
|
||||||
|
return solver
|
||||||
|
|
||||||
|
def _start(self, reference: Any, candidate: Any) -> None:
|
||||||
|
self.reference = reference
|
||||||
|
self.candidate = candidate
|
||||||
|
self.pairs: dict[int, int] = {}
|
||||||
|
self._owners: dict[int, int] = {}
|
||||||
|
for start, other in self._shape_anchors().items():
|
||||||
|
self._pair(start, other)
|
||||||
|
self._propagate_calls()
|
||||||
|
self.data_map = self._map_data_references()
|
||||||
|
self.matches: dict[str, Match] = {}
|
||||||
|
self._claimed: dict[int, int] = {}
|
||||||
|
|
||||||
|
def _pair(self, start: int, other: int) -> None:
|
||||||
|
"""Pairs two functions, dropping any pair that held either one."""
|
||||||
|
owner = self._owners.get(other)
|
||||||
|
if owner is not None and owner != start:
|
||||||
|
del self.pairs[owner]
|
||||||
|
previous = self.pairs.get(start)
|
||||||
|
if previous is not None and previous != other:
|
||||||
|
del self._owners[previous]
|
||||||
|
self.pairs[start] = other
|
||||||
|
self._owners[other] = start
|
||||||
|
|
||||||
|
def _shape_anchors(self) -> dict[int, int]:
|
||||||
|
reference_hashes = collections.defaultdict(list)
|
||||||
|
candidate_hashes = collections.defaultdict(list)
|
||||||
|
for start, info in self.reference.functions.items():
|
||||||
|
reference_hashes[info.shape_hash].append(start)
|
||||||
|
for start, info in self.candidate.functions.items():
|
||||||
|
candidate_hashes[info.shape_hash].append(start)
|
||||||
|
pairs = {}
|
||||||
|
for shape_hash, starts in reference_hashes.items():
|
||||||
|
others = candidate_hashes.get(shape_hash, ())
|
||||||
|
if len(starts) == 1 and len(others) == 1:
|
||||||
|
pairs[starts[0]] = others[0]
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
def _propagate_calls(self) -> None:
|
||||||
|
# Matched functions with as many calls pair their callees in order.
|
||||||
|
reference, candidate = self.reference.functions, self.candidate.functions
|
||||||
|
pending = list(self.pairs.items())
|
||||||
|
while pending:
|
||||||
|
discovered = []
|
||||||
|
for reference_start, candidate_start in pending:
|
||||||
|
reference_calls = reference[reference_start].calls
|
||||||
|
candidate_calls = candidate[candidate_start].calls
|
||||||
|
if len(reference_calls) != len(candidate_calls):
|
||||||
|
continue
|
||||||
|
for callee, other in zip(reference_calls, candidate_calls):
|
||||||
|
if (
|
||||||
|
callee in self.pairs
|
||||||
|
or other in self._owners
|
||||||
|
or callee not in reference
|
||||||
|
or other not in candidate
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
size = reference[callee].size
|
||||||
|
if abs(size - candidate[other].size) <= max(16, size // 4):
|
||||||
|
self._pair(callee, other)
|
||||||
|
discovered.append((callee, other))
|
||||||
|
pending = discovered
|
||||||
|
|
||||||
|
def _map_data_references(self) -> dict[int, int]:
|
||||||
|
votes: dict[int, collections.Counter] = collections.defaultdict(collections.Counter)
|
||||||
|
for reference_start, candidate_start in self.pairs.items():
|
||||||
|
reference_refs = self.reference.functions[reference_start].refs
|
||||||
|
candidate_refs = self.candidate.functions[candidate_start].refs
|
||||||
|
if len(reference_refs) == len(candidate_refs):
|
||||||
|
for reference_target, candidate_target in zip(reference_refs, candidate_refs):
|
||||||
|
votes[reference_target][candidate_target] += 1
|
||||||
|
return {target: counter.most_common(1)[0][0] for target, counter in votes.items()}
|
||||||
|
|
||||||
|
def _gap_candidates(self, start: int) -> list[int]:
|
||||||
|
starts = self.reference.starts
|
||||||
|
index = bisect.bisect_left(starts, start)
|
||||||
|
below = next(
|
||||||
|
(self.pairs[starts[i]] for i in range(index - 1, -1, -1) if starts[i] in self.pairs),
|
||||||
|
self.candidate.text.address,
|
||||||
|
)
|
||||||
|
above = next(
|
||||||
|
(self.pairs[starts[i]] for i in range(index + 1, len(starts)) if starts[i] in self.pairs),
|
||||||
|
self.candidate.text.address + self.candidate.text.size,
|
||||||
|
)
|
||||||
|
if above <= below:
|
||||||
|
return []
|
||||||
|
low = bisect.bisect_left(self.candidate.starts, below)
|
||||||
|
high = bisect.bisect_right(self.candidate.starts, above)
|
||||||
|
return self.candidate.starts[low:high]
|
||||||
|
|
||||||
|
def _neighbour_candidates(self, start: int) -> set[int]:
|
||||||
|
found = set()
|
||||||
|
for callee in self.reference.functions[start].calls:
|
||||||
|
if callee in self.pairs:
|
||||||
|
found.update(self.candidate.callers.get(self.pairs[callee], ()))
|
||||||
|
for caller in self.reference.callers.get(start, ()):
|
||||||
|
if caller in self.pairs:
|
||||||
|
found.update(
|
||||||
|
target
|
||||||
|
for target in self.candidate.functions[self.pairs[caller]].calls
|
||||||
|
if target in self.candidate.functions
|
||||||
|
)
|
||||||
|
return found
|
||||||
|
|
||||||
|
def _pattern_candidates(self, start: int) -> set[int]:
|
||||||
|
# Data a function touches usually moves as one block, so its
|
||||||
|
# references shifted by a mapped distance find it in the candidate.
|
||||||
|
refs = set(self.reference.functions[start].refs)
|
||||||
|
distances = collections.Counter(
|
||||||
|
self.data_map[target] - target for target in refs if target in self.data_map
|
||||||
|
)
|
||||||
|
# A common address adds a vote but cannot bring in a candidate alone.
|
||||||
|
votes = collections.Counter()
|
||||||
|
found_by_rare = set()
|
||||||
|
for target in refs:
|
||||||
|
users = set()
|
||||||
|
for distance, _ in distances.most_common(3):
|
||||||
|
found = self.candidate.data_users.get(target + distance, ())
|
||||||
|
users.update(found)
|
||||||
|
if len(found) <= XREF_FANOUT_LIMIT:
|
||||||
|
found_by_rare.update(found)
|
||||||
|
votes.update(users)
|
||||||
|
needed = max(1, len(refs) // 2)
|
||||||
|
return {user for user in found_by_rare if votes[user] >= needed}
|
||||||
|
|
||||||
|
def _graph_score(self, start: int, other: int) -> float:
|
||||||
|
reference = self.reference.functions[start]
|
||||||
|
candidate = self.candidate.functions[other]
|
||||||
|
size = min(reference.size, candidate.size) / max(reference.size, candidate.size)
|
||||||
|
calls = [self.pairs[c] for c in reference.calls if c in self.pairs]
|
||||||
|
callers = [self.pairs[c] for c in self.reference.callers.get(start, ()) if c in self.pairs]
|
||||||
|
data = [self.data_map[t] for t in reference.refs if t in self.data_map]
|
||||||
|
return _weighted(
|
||||||
|
(
|
||||||
|
(size, 1.0),
|
||||||
|
(_share(calls, set(candidate.calls)), 2.0),
|
||||||
|
(_share(callers, set(self.candidate.callers.get(other, ()))), 2.0),
|
||||||
|
(_share(data, set(candidate.refs)), 2.0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _full_score(self, start: int, other: int, graph: float) -> float:
|
||||||
|
similarity = difflib.SequenceMatcher(
|
||||||
|
None, self.reference.shapes(start), self.candidate.shapes(other), autojunk=False
|
||||||
|
).ratio()
|
||||||
|
strings = [
|
||||||
|
text
|
||||||
|
for text in map(self.reference.string_at, self.reference.functions[start].refs)
|
||||||
|
if text is not None
|
||||||
|
]
|
||||||
|
candidate_strings = set(
|
||||||
|
map(self.candidate.string_at, self.candidate.functions[other].refs)
|
||||||
|
)
|
||||||
|
return _weighted(
|
||||||
|
(
|
||||||
|
(similarity, 3.0),
|
||||||
|
(_share(strings, candidate_strings), 2.0),
|
||||||
|
(graph, 5.0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def match_function(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
start: int,
|
||||||
|
contract: Callable[[Any, int], bool] | None = None,
|
||||||
|
vtable_slot: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Matches one reference function.
|
||||||
|
|
||||||
|
A contract drops candidates outright. `vtable_slot` is the candidate
|
||||||
|
that sits in the matching slot of an already located vtable; it only
|
||||||
|
breaks a tie between candidates that pass on their own evidence.
|
||||||
|
"""
|
||||||
|
if start not in self.reference.functions:
|
||||||
|
raise AnalyzerError(f"reference {name} is not a function start")
|
||||||
|
if name in self.matches:
|
||||||
|
return self.matches[name].rva
|
||||||
|
candidates = set(self._gap_candidates(start))
|
||||||
|
candidates |= self._neighbour_candidates(start)
|
||||||
|
candidates |= self._pattern_candidates(start)
|
||||||
|
if start in self.pairs:
|
||||||
|
candidates.add(self.pairs[start])
|
||||||
|
if vtable_slot in self.candidate.functions:
|
||||||
|
candidates.add(vtable_slot)
|
||||||
|
size = self.reference.functions[start].size
|
||||||
|
candidates = {
|
||||||
|
other
|
||||||
|
for other in candidates
|
||||||
|
if self._claimed.get(other, start) == start
|
||||||
|
and self.candidate.functions[other].size <= 8 * size + 64
|
||||||
|
and (contract is None or contract(self.candidate.image, other))
|
||||||
|
}
|
||||||
|
graph = {other: self._graph_score(start, other) for other in candidates}
|
||||||
|
shortlist = sorted(candidates, key=graph.__getitem__, reverse=True)[:FULL_SCORE_LIMIT]
|
||||||
|
if vtable_slot in candidates and vtable_slot not in shortlist:
|
||||||
|
shortlist.append(vtable_slot)
|
||||||
|
scored = [(self._full_score(start, other, graph[other]), other) for other in shortlist]
|
||||||
|
chosen = choose_candidate(scored)
|
||||||
|
decided_by = "score"
|
||||||
|
if chosen is None and vtable_slot is not None:
|
||||||
|
chosen = choose_candidate(scored, tie_breaker=vtable_slot)
|
||||||
|
decided_by = "vtable"
|
||||||
|
if chosen is None:
|
||||||
|
ranked = sorted(scored, reverse=True)[:2]
|
||||||
|
raise AnalyzerError(
|
||||||
|
f"{name} has no clear candidate: "
|
||||||
|
+ ", ".join(f"{format_rva(rva)}={value:.2f}" for value, rva in ranked)
|
||||||
|
)
|
||||||
|
chosen_score = next(value for value, other in scored if other == chosen)
|
||||||
|
runner_up = max((value for value, other in scored if other != chosen), default=0.0)
|
||||||
|
self.matches[name] = Match(chosen, chosen_score, chosen_score - runner_up, decided_by)
|
||||||
|
self._claimed[chosen] = start
|
||||||
|
self._pair(start, chosen)
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
def _aligned(self, name: str, start: int) -> tuple[list[Instruction], list[Instruction], dict[int, int]]:
|
||||||
|
other = self.match_function(name, start)
|
||||||
|
reference = self.reference.instructions(start)
|
||||||
|
candidate = self.candidate.instructions(other)
|
||||||
|
mapping = align_instructions(
|
||||||
|
[item.shape for item in reference], [item.shape for item in candidate]
|
||||||
|
)
|
||||||
|
return reference, candidate, mapping
|
||||||
|
|
||||||
|
def map_instruction(self, name: str, rva: int) -> int:
|
||||||
|
start = self.reference.containing(rva)
|
||||||
|
if start is None:
|
||||||
|
raise AnalyzerError(f"reference {name} is outside every function")
|
||||||
|
reference, candidate, mapping = self._aligned(f"{name} function", start)
|
||||||
|
index = next((i for i, item in enumerate(reference) if item.address == rva), None)
|
||||||
|
if index is None or index not in mapping:
|
||||||
|
raise AnalyzerError(f"{name} instruction has no counterpart")
|
||||||
|
return candidate[mapping[index]].address
|
||||||
|
|
||||||
|
def map_data(self, name: str, function_name: str, start: int, target: int) -> int:
|
||||||
|
reference, candidate, mapping = self._aligned(function_name, start)
|
||||||
|
found = set()
|
||||||
|
for index, item in enumerate(reference):
|
||||||
|
if target not in item.rip_targets:
|
||||||
|
continue
|
||||||
|
if index not in mapping:
|
||||||
|
raise AnalyzerError(f"{name} reference instruction has no counterpart")
|
||||||
|
other = candidate[mapping[index]]
|
||||||
|
if len(other.rip_targets) != len(item.rip_targets):
|
||||||
|
raise AnalyzerError(f"{name} RIP-relative operand count changed")
|
||||||
|
found.update(
|
||||||
|
other.rip_targets[position]
|
||||||
|
for position, value in enumerate(item.rip_targets)
|
||||||
|
if value == target
|
||||||
|
)
|
||||||
|
if len(found) != 1:
|
||||||
|
raise AnalyzerError(f"{name} has no unique mapped data address")
|
||||||
|
return found.pop()
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright 2026 Mocktail Project Authors
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""HostAbi targets that `roblox_abi_solver.py` derives from its matches.
|
||||||
|
|
||||||
|
`TargetDerivation` turns matched functions into the HostAbi profile, the
|
||||||
|
derivation anchors and the runtime compatibility block, in the shape that
|
||||||
|
`derive_roblox_host_abi_profile.py` writes. It reuses the deriver's input
|
||||||
|
checks, contracts and post-processing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
def _load_index():
|
||||||
|
name = "mocktail_roblox_function_index"
|
||||||
|
if name in sys.modules:
|
||||||
|
return sys.modules[name]
|
||||||
|
path = Path(__file__).resolve().with_name("roblox_function_index.py")
|
||||||
|
specification = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if specification is None or specification.loader is None:
|
||||||
|
raise RuntimeError("cannot load roblox_function_index.py")
|
||||||
|
module = importlib.util.module_from_spec(specification)
|
||||||
|
sys.modules[name] = module
|
||||||
|
specification.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
DERIVER = _load_index().DERIVER
|
||||||
|
AnalyzerError = DERIVER.AnalyzerError
|
||||||
|
format_rva = DERIVER.format_rva
|
||||||
|
FMOD_OUTPUT_FIELDS = DERIVER.FMOD_OUTPUT_FIELDS
|
||||||
|
FMOD_INPUT_FIELDS = DERIVER.FMOD_INPUT_FIELDS
|
||||||
|
fmod_slots = DERIVER.fmod_vtable_slots
|
||||||
|
|
||||||
|
|
||||||
|
def check_inputs(reference: Any, candidate: Any, reference_sidecar: dict[str, Any]) -> None:
|
||||||
|
"""Applies the deriver's identity and preflight checks to both builds."""
|
||||||
|
DERIVER.check_derivation_inputs(reference, candidate, reference_sidecar)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_fmod_source(source: Any) -> int:
|
||||||
|
"""Checks a reference FMOD bridge the way the deriver does; returns its layout."""
|
||||||
|
if (
|
||||||
|
not isinstance(source, dict)
|
||||||
|
or not FMOD_OUTPUT_FIELDS <= source.keys()
|
||||||
|
or source.keys() - FMOD_OUTPUT_FIELDS - set(FMOD_INPUT_FIELDS) - {"vtable_layout_version"}
|
||||||
|
):
|
||||||
|
raise AnalyzerError("reference FMOD output-device profile is incomplete")
|
||||||
|
present = [field for field in FMOD_INPUT_FIELDS if field in source]
|
||||||
|
if present and len(present) != len(FMOD_INPUT_FIELDS):
|
||||||
|
raise AnalyzerError("reference FMOD input profile is incomplete")
|
||||||
|
layout = source.get("vtable_layout_version", 1)
|
||||||
|
if type(layout) is not int or layout not in (1, 2):
|
||||||
|
raise AnalyzerError("reference FMOD vtable layout is unsupported")
|
||||||
|
return layout
|
||||||
|
|
||||||
|
|
||||||
|
class TargetDerivation:
|
||||||
|
"""Mixin for `Solver`: needs `reference`, `candidate`, `matches`,
|
||||||
|
`match_function`, `map_data` and `map_instruction`."""
|
||||||
|
|
||||||
|
def _check_registry_allocator(
|
||||||
|
self,
|
||||||
|
reference_registry: int,
|
||||||
|
candidate_registry: int,
|
||||||
|
reference_allocate: int,
|
||||||
|
candidate_allocate: int,
|
||||||
|
) -> None:
|
||||||
|
# The registry initializer's tenth instruction calls the allocator bridge.
|
||||||
|
for index, start, allocate, side in (
|
||||||
|
(self.reference, reference_registry, reference_allocate, "reference"),
|
||||||
|
(self.candidate, candidate_registry, candidate_allocate, "candidate"),
|
||||||
|
):
|
||||||
|
code = index.instructions(start)
|
||||||
|
if len(code) <= 9 or code[9].shape != "call" or code[9].branch_target != allocate:
|
||||||
|
raise AnalyzerError(
|
||||||
|
f"{side} registry initializer does not call its allocator bridge"
|
||||||
|
)
|
||||||
|
|
||||||
|
def derive_profile(
|
||||||
|
self, reference_sidecar: dict[str, Any]
|
||||||
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
reference_profile = reference_sidecar["profile"]
|
||||||
|
anchors = reference_sidecar["derivation_anchors"]
|
||||||
|
DERIVER.validate_profile_shape(reference_profile, anchors)
|
||||||
|
reference_image = self.reference.image
|
||||||
|
candidate_image = self.candidate.image
|
||||||
|
check_inputs(reference_image, candidate_image, reference_sidecar)
|
||||||
|
parse = DERIVER.parse_rva
|
||||||
|
bridges = []
|
||||||
|
found = {}
|
||||||
|
for entry in reference_profile["bridge_entries"]:
|
||||||
|
rva = self.match_function(entry["label"], parse(entry["rva"], entry["label"]))
|
||||||
|
found[entry["label"]] = rva
|
||||||
|
bridges.append({"rva": format_rva(rva), "kind": entry["kind"], "label": entry["label"]})
|
||||||
|
|
||||||
|
seeds = reference_profile["data_seeds"]
|
||||||
|
arena_initializer = parse(seeds["arena_initializer"], "arena initializer")
|
||||||
|
thread_initializer = parse(seeds["allocator_thread_initializer"], "thread initializer")
|
||||||
|
self.match_function("arena-initializer", arena_initializer)
|
||||||
|
self.match_function("allocator-thread-initializer", thread_initializer)
|
||||||
|
|
||||||
|
bootstrap = reference_profile["native_pre_jni_bootstrap"]
|
||||||
|
DERIVER.validate_reference_registry_slot(reference_image, bootstrap)
|
||||||
|
registry = self.match_function(
|
||||||
|
"registry-initializer", parse(bootstrap["registry_initializer"], "registry")
|
||||||
|
)
|
||||||
|
reference_allocate = parse(
|
||||||
|
DERIVER.bridge_map(reference_profile)["allocate"]["rva"], "reference allocate"
|
||||||
|
)
|
||||||
|
self._check_registry_allocator(
|
||||||
|
parse(bootstrap["registry_initializer"], "registry"),
|
||||||
|
registry, reference_allocate, found["allocate"],
|
||||||
|
)
|
||||||
|
registry_slot = DERIVER.derive_registry_slot(candidate_image, registry)
|
||||||
|
|
||||||
|
reference_init = reference_image.init_array_relocations()
|
||||||
|
candidate_init = candidate_image.init_array_relocations()
|
||||||
|
begin = DERIVER.LEADING_CONSTRUCTOR_BEGIN
|
||||||
|
count = DERIVER.LEADING_CONSTRUCTOR_COUNT
|
||||||
|
leading = candidate_init[begin : begin + count]
|
||||||
|
offsets = []
|
||||||
|
for index in range(begin, begin + count):
|
||||||
|
expected = parse(anchors["constructor_rvas"][str(index)], f"constructor {index}")
|
||||||
|
if reference_init[index] != expected:
|
||||||
|
raise AnalyzerError(f"reference constructor {index} differs from its anchor")
|
||||||
|
rva = self.match_function(f"constructor-{index}", expected)
|
||||||
|
if leading.count(rva) != 1:
|
||||||
|
raise AnalyzerError(f"constructor {index} left the leading constructors")
|
||||||
|
offsets.append(leading.index(rva))
|
||||||
|
if sorted(offsets) != list(range(count)):
|
||||||
|
raise AnalyzerError("leading constructors do not map one to one")
|
||||||
|
|
||||||
|
singleton = parse(anchors["jni_singleton_initializer_rva"], "JNI singleton")
|
||||||
|
jni_singleton = self.match_function("jni-singleton-initializer", singleton)
|
||||||
|
allocator_anchor = parse(anchors["allocator_object_initializer_rva"], "allocator anchor")
|
||||||
|
empty_anchor = parse(anchors["empty_string_initializer_rva"], "empty string anchor")
|
||||||
|
allocator_function = self.reference.containing(allocator_anchor)
|
||||||
|
empty_function = self.reference.containing(empty_anchor)
|
||||||
|
if allocator_function is None or empty_function is None:
|
||||||
|
raise AnalyzerError("reference anchor is outside every function")
|
||||||
|
|
||||||
|
slots = {
|
||||||
|
"allocator_object_slot": self.map_data(
|
||||||
|
"allocator object slot", "allocator-object-initializer function",
|
||||||
|
allocator_function, parse(seeds["allocator_object_slot"], "allocator slot"),
|
||||||
|
),
|
||||||
|
"empty_string_slot": self.map_data(
|
||||||
|
"empty string slot", "empty-string-initializer function",
|
||||||
|
empty_function, parse(seeds["empty_string_slot"], "empty string slot"),
|
||||||
|
),
|
||||||
|
"jni_singleton_slot": self.map_data(
|
||||||
|
"JNI singleton slot", "jni-singleton-initializer",
|
||||||
|
singleton, parse(seeds["jni_singleton_slot"], "JNI singleton slot"),
|
||||||
|
),
|
||||||
|
"arena_guard_slot": self.map_data(
|
||||||
|
"arena guard slot", "arena-initializer",
|
||||||
|
arena_initializer, parse(seeds["arena_guard_slot"], "arena guard slot"),
|
||||||
|
),
|
||||||
|
"arena_table_slot": self.map_data(
|
||||||
|
"arena table slot", "usable-size",
|
||||||
|
parse(DERIVER.bridge_map(reference_profile)["usable-size"]["rva"], "usable size"),
|
||||||
|
parse(seeds["arena_table_slot"], "arena table slot"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for slot in slots.values():
|
||||||
|
candidate_image.require_writable_rva(slot, 8)
|
||||||
|
|
||||||
|
candidate_count = len(candidate_init)
|
||||||
|
reference_count = reference_profile["init_array_count"]
|
||||||
|
boundary = reference_profile["native_mimalloc_thread_initializer_after_constructor"]
|
||||||
|
if not begin <= boundary < begin + count:
|
||||||
|
raise AnalyzerError("native allocator TLS boundary is no longer verified")
|
||||||
|
thread_boundary = begin + offsets[boundary - begin]
|
||||||
|
native_ranges = DERIVER.remapped_constructor_ranges(
|
||||||
|
reference_profile["native_mimalloc_constructor_run_ranges"],
|
||||||
|
offsets, reference_count, candidate_count,
|
||||||
|
)
|
||||||
|
if not any(item["begin"] <= thread_boundary < item["end_exclusive"] for item in native_ranges):
|
||||||
|
raise AnalyzerError("native allocator TLS boundary is no longer verified")
|
||||||
|
profile = {
|
||||||
|
"elf_build_id": candidate_image.build_id,
|
||||||
|
"bridge_entries": bridges,
|
||||||
|
"data_seeds": {
|
||||||
|
"allocator_object_slot": format_rva(slots["allocator_object_slot"]),
|
||||||
|
"empty_string_slot": format_rva(slots["empty_string_slot"]),
|
||||||
|
"jni_singleton_slot": format_rva(slots["jni_singleton_slot"]),
|
||||||
|
"jni_singleton_bytes": seeds["jni_singleton_bytes"],
|
||||||
|
"arena_initializer": format_rva(self.matches["arena-initializer"].rva),
|
||||||
|
"allocator_thread_initializer": format_rva(
|
||||||
|
self.matches["allocator-thread-initializer"].rva
|
||||||
|
),
|
||||||
|
"arena_guard_slot": format_rva(slots["arena_guard_slot"]),
|
||||||
|
"arena_table_slot": format_rva(slots["arena_table_slot"]),
|
||||||
|
"arena_table_slot_count": seeds["arena_table_slot_count"],
|
||||||
|
},
|
||||||
|
"native_allocator": {
|
||||||
|
"allocate": format_rva(found["allocate"]),
|
||||||
|
"deallocate": format_rva(found["free"]),
|
||||||
|
},
|
||||||
|
"init_array_offset": format_rva(candidate_image.sections[".init_array"].address),
|
||||||
|
"init_array_count": candidate_count,
|
||||||
|
"constructor_run_ranges": DERIVER.remapped_constructor_ranges(
|
||||||
|
reference_profile["constructor_run_ranges"],
|
||||||
|
offsets, reference_count, candidate_count,
|
||||||
|
),
|
||||||
|
"native_mimalloc_constructor_run_ranges": native_ranges,
|
||||||
|
"native_mimalloc_thread_initializer_after_constructor": thread_boundary,
|
||||||
|
"native_pre_jni_bootstrap": {
|
||||||
|
"registry_initializer": format_rva(registry),
|
||||||
|
"registry_slot": format_rva(registry_slot),
|
||||||
|
},
|
||||||
|
"default_allocator_strategy": "native_mimalloc",
|
||||||
|
}
|
||||||
|
derived_anchors = {
|
||||||
|
"signature_version": 1,
|
||||||
|
"allocator_object_initializer_rva": format_rva(
|
||||||
|
self.map_instruction("allocator-object-initializer", allocator_anchor)
|
||||||
|
),
|
||||||
|
"empty_string_initializer_rva": format_rva(
|
||||||
|
self.map_instruction("empty-string-initializer", empty_anchor)
|
||||||
|
),
|
||||||
|
"jni_singleton_initializer_rva": format_rva(jni_singleton),
|
||||||
|
"constructor_rvas": {
|
||||||
|
str(index): format_rva(candidate_init[index])
|
||||||
|
for index in range(begin, begin + count)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
DERIVER.validate_profile_shape(profile, derived_anchors)
|
||||||
|
return profile, derived_anchors
|
||||||
|
|
||||||
|
def derive_runtime_compatibility(self, source: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
fullscreen = source.get("user_game_settings_fullscreen_setter_rva")
|
||||||
|
if fullscreen is not None:
|
||||||
|
reference_setter = DERIVER.parse_rva(fullscreen, "fullscreen setter")
|
||||||
|
if not DERIVER.has_fullscreen_setter_contract(self.reference.image, reference_setter):
|
||||||
|
raise AnalyzerError("reference fullscreen setter contract changed")
|
||||||
|
setter = self.match_function(
|
||||||
|
"fullscreen-setter", reference_setter, DERIVER.has_fullscreen_setter_contract
|
||||||
|
)
|
||||||
|
result["user_game_settings_fullscreen_setter_rva"] = format_rva(setter)
|
||||||
|
fmod = source.get("fmod_output_device_bridge")
|
||||||
|
if fmod is not None:
|
||||||
|
result["fmod_output_device_bridge"] = self._derive_fmod_bridge(fmod)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _derive_fmod_bridge(self, source: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
layout = validate_fmod_source(source)
|
||||||
|
has_input = FMOD_INPUT_FIELDS[0] in source
|
||||||
|
slots = fmod_slots(layout, has_input)
|
||||||
|
parse = DERIVER.parse_rva
|
||||||
|
vtable = parse(source["vtable_rva"], "FMOD vtable")
|
||||||
|
reference_relocations = DERIVER.relative_relocation_map(self.reference.image)
|
||||||
|
methods = {}
|
||||||
|
unresolved = []
|
||||||
|
for field, slot in slots.items():
|
||||||
|
method = parse(source[field], field)
|
||||||
|
if reference_relocations.get(vtable + slot * 8) != method:
|
||||||
|
raise AnalyzerError("reference FMOD vtable does not match its methods")
|
||||||
|
try:
|
||||||
|
methods[field] = self.match_function(
|
||||||
|
f"fmod-{field}", method, self._fmod_contract(layout, field)
|
||||||
|
)
|
||||||
|
except AnalyzerError:
|
||||||
|
unresolved.append(field)
|
||||||
|
string_constructor = parse(source["string_constructor_rva"], "FMOD string constructor")
|
||||||
|
if not DERIVER.has_fmod_string_constructor_contract(self.reference.image, string_constructor):
|
||||||
|
raise AnalyzerError("reference FMOD string constructor contract changed")
|
||||||
|
candidate_string_constructor = self.match_function(
|
||||||
|
"fmod-string-constructor",
|
||||||
|
string_constructor,
|
||||||
|
DERIVER.has_fmod_string_constructor_contract,
|
||||||
|
)
|
||||||
|
|
||||||
|
relocations = DERIVER.relative_relocation_map(self.candidate.image)
|
||||||
|
located = self._locate_fmod_vtables(relocations, methods)
|
||||||
|
vtables = sorted({vtable for vtable, _ in located})
|
||||||
|
if len(vtables) != 1:
|
||||||
|
raise AnalyzerError(f"FMOD output vtable matched {len(vtables)} candidate locations")
|
||||||
|
# The located vtable breaks ties for the methods that did not resolve.
|
||||||
|
# Count and info share slots 5 and 6 in both layouts, so the vtable can
|
||||||
|
# fit both; exactly one layout must then hold every method.
|
||||||
|
solutions = []
|
||||||
|
for located_vtable, target_layout in located:
|
||||||
|
target_slots = fmod_slots(target_layout, has_input)
|
||||||
|
resolved = dict(methods)
|
||||||
|
try:
|
||||||
|
for field in unresolved:
|
||||||
|
resolved[field] = self.match_function(
|
||||||
|
f"fmod-{field}",
|
||||||
|
parse(source[field], field),
|
||||||
|
self._fmod_contract(target_layout, field),
|
||||||
|
relocations.get(located_vtable + target_slots[field] * 8),
|
||||||
|
)
|
||||||
|
except AnalyzerError:
|
||||||
|
continue
|
||||||
|
if self._locate_fmod_vtables(relocations, resolved) != [
|
||||||
|
(located_vtable, target_layout)
|
||||||
|
]:
|
||||||
|
continue
|
||||||
|
if target_layout == 2 and not DERIVER.has_fmod_device_lists_select_contract(
|
||||||
|
self.candidate.image, resolved["select_method_rva"]
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
solutions.append((located_vtable, target_layout, target_slots, resolved))
|
||||||
|
if len(solutions) != 1:
|
||||||
|
raise AnalyzerError(f"FMOD output vtable holds every method in {len(solutions)} layouts")
|
||||||
|
located_vtable, target_layout, target_slots, methods = solutions[0]
|
||||||
|
self.candidate.image.require_relro_rva(
|
||||||
|
located_vtable, (max(target_slots.values()) + 1) * 8
|
||||||
|
)
|
||||||
|
result = {"vtable_rva": format_rva(located_vtable)}
|
||||||
|
result["string_constructor_rva"] = format_rva(candidate_string_constructor)
|
||||||
|
result.update((field, format_rva(rva)) for field, rva in methods.items())
|
||||||
|
if target_layout != 1:
|
||||||
|
result["vtable_layout_version"] = target_layout
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmod_contract(layout: int, field: str) -> Callable[[Any, int], bool] | None:
|
||||||
|
if layout == 2 and field == "select_method_rva":
|
||||||
|
return DERIVER.has_fmod_device_lists_select_contract
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _locate_fmod_vtables(
|
||||||
|
relocations: dict[int, int], methods: dict[str, int]
|
||||||
|
) -> list[tuple[int, int]]:
|
||||||
|
"""Returns (vtable, layout) for each vtable holding every method."""
|
||||||
|
located = []
|
||||||
|
for layout in (1, 2):
|
||||||
|
slots = fmod_slots(layout, True)
|
||||||
|
held = {slots[field]: rva for field, rva in methods.items()}
|
||||||
|
located.extend(
|
||||||
|
(vtable, layout)
|
||||||
|
for vtable in TargetDerivation._vtables_holding(relocations, held)
|
||||||
|
)
|
||||||
|
return sorted(located)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _vtables_holding(relocations: dict[int, int], methods: dict[int, int]) -> list[int]:
|
||||||
|
"""Returns every vtable whose slots hold all of `methods`."""
|
||||||
|
if not methods:
|
||||||
|
raise AnalyzerError("no vtable method was matched")
|
||||||
|
slot, method = next(iter(methods.items()))
|
||||||
|
return sorted(
|
||||||
|
offset - slot * 8
|
||||||
|
for offset, value in relocations.items()
|
||||||
|
if value == method
|
||||||
|
and offset >= slot * 8
|
||||||
|
and (offset - slot * 8) % 8 == 0
|
||||||
|
and all(
|
||||||
|
relocations.get(offset - slot * 8 + index * 8) == rva
|
||||||
|
for index, rva in methods.items()
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright 2026 Mocktail Project Authors
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Function index of one libroblox image for `roblox_abi_solver.py`.
|
||||||
|
|
||||||
|
Every function listed in `.eh_frame_hdr` is decoded once and reduced to a
|
||||||
|
normalized instruction shape, its direct calls and its RIP-relative data
|
||||||
|
references. Shapes drop branch targets, RIP displacements and immediates of
|
||||||
|
0x1000 and above, so a function moved by the linker keeps its shape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import bisect
|
||||||
|
import collections
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _load_deriver():
|
||||||
|
name = "mocktail_host_abi_deriver_for_solver"
|
||||||
|
if name in sys.modules:
|
||||||
|
return sys.modules[name]
|
||||||
|
path = Path(__file__).resolve().with_name("derive_roblox_host_abi_profile.py")
|
||||||
|
specification = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if specification is None or specification.loader is None:
|
||||||
|
raise RuntimeError("cannot load the HostAbi deriver")
|
||||||
|
module = importlib.util.module_from_spec(specification)
|
||||||
|
sys.modules[name] = module
|
||||||
|
specification.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
DERIVER = _load_deriver()
|
||||||
|
AnalyzerError = DERIVER.AnalyzerError
|
||||||
|
|
||||||
|
_RIP_OPERAND = re.compile(r"\[rip ([+-]) (0x[0-9a-f]+|\d+)\]")
|
||||||
|
_IMMEDIATE = re.compile(r"0x[0-9a-f]+|\b\d+\b")
|
||||||
|
# Smaller immediates are offsets and flags that stay put between builds.
|
||||||
|
# Larger ones are addresses and sizes that move.
|
||||||
|
_STABLE_IMMEDIATE_LIMIT = 0x1000
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_immediate(match: re.Match) -> str:
|
||||||
|
return "I" if int(match.group(0), 0) >= _STABLE_IMMEDIATE_LIMIT else match.group(0)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Instruction:
|
||||||
|
address: int
|
||||||
|
shape: str
|
||||||
|
rip_targets: tuple[int, ...]
|
||||||
|
branch_target: int | None
|
||||||
|
|
||||||
|
|
||||||
|
def decode_function(
|
||||||
|
decoder: Any, code: bytes, start: int
|
||||||
|
) -> list[Instruction]:
|
||||||
|
end = start + len(code)
|
||||||
|
instructions = []
|
||||||
|
for address, size, mnemonic, operands in decoder.disasm_lite(code, start):
|
||||||
|
if (mnemonic == "call" or mnemonic.startswith("j")) and operands.startswith("0x"):
|
||||||
|
target = int(operands, 16)
|
||||||
|
if mnemonic == "call":
|
||||||
|
shape = "call"
|
||||||
|
else:
|
||||||
|
shape = mnemonic + (" in" if start <= target < end else " out")
|
||||||
|
instructions.append(Instruction(address, shape, (), target))
|
||||||
|
continue
|
||||||
|
rip_targets = []
|
||||||
|
for match in _RIP_OPERAND.finditer(operands):
|
||||||
|
displacement = int(match.group(2), 0)
|
||||||
|
rip_targets.append(
|
||||||
|
address + size + (-displacement if match.group(1) == "-" else displacement)
|
||||||
|
)
|
||||||
|
operands = _IMMEDIATE.sub(_stable_immediate, _RIP_OPERAND.sub("[rip]", operands))
|
||||||
|
instructions.append(
|
||||||
|
Instruction(address, f"{mnemonic} {operands}", tuple(rip_targets), None)
|
||||||
|
)
|
||||||
|
return instructions
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FunctionInfo:
|
||||||
|
size: int
|
||||||
|
shape_hash: bytes
|
||||||
|
calls: tuple[int, ...]
|
||||||
|
refs: tuple[int, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def function_table(image: Any) -> list[tuple[int, int]]:
|
||||||
|
"""Returns (start, size) for every FDE indexed by `.eh_frame_hdr`."""
|
||||||
|
header = image.sections.get(".eh_frame_hdr")
|
||||||
|
if header is None:
|
||||||
|
raise AnalyzerError("libroblox has no .eh_frame_hdr")
|
||||||
|
raw = image.section_bytes(header)
|
||||||
|
# Version 1, a 4-byte eh_frame_ptr, udata4 count, datarel sdata4 table.
|
||||||
|
if len(raw) < 12 or raw[0] != 1 or raw[2] != 0x03 or raw[3] != 0x3B:
|
||||||
|
raise AnalyzerError(".eh_frame_hdr uses an unsupported encoding")
|
||||||
|
if raw[1] == 0xFF or raw[1] & 0x0F not in (0x03, 0x0B):
|
||||||
|
raise AnalyzerError(".eh_frame_hdr eh_frame_ptr encoding is not 4 bytes")
|
||||||
|
count = struct.unpack_from("<I", raw, 8)[0]
|
||||||
|
if 12 + count * 8 > len(raw):
|
||||||
|
raise AnalyzerError(".eh_frame_hdr table exceeds its section")
|
||||||
|
table = []
|
||||||
|
for index in range(count):
|
||||||
|
location, fde = struct.unpack_from("<ii", raw, 12 + index * 8)
|
||||||
|
start = header.address + location
|
||||||
|
fde_rva = header.address + fde
|
||||||
|
offset = image.rva_to_offset(fde_rva, 16)
|
||||||
|
length, _, begin, size = struct.unpack_from("<IIiI", image.data, offset)
|
||||||
|
# An FDE starts with its length, CIE pointer, then a pcrel sdata4
|
||||||
|
# pc_begin and its udata4 range.
|
||||||
|
if length == 0xFFFFFFFF or fde_rva + 8 + begin != start:
|
||||||
|
raise AnalyzerError(".eh_frame FDE does not match its index entry")
|
||||||
|
table.append((start, size))
|
||||||
|
table.sort()
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
_SOURCE_DIGEST = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def cache_path(cache_dir: Path, library_sha256: str) -> Path:
|
||||||
|
"""Cache file for one library; shapes depend on this code and capstone."""
|
||||||
|
version = getattr(DERIVER.capstone, "__version__", "unknown")
|
||||||
|
return cache_dir / f"{library_sha256}-{_SOURCE_DIGEST}-capstone{version}.json.gz"
|
||||||
|
|
||||||
|
|
||||||
|
def link_functions(
|
||||||
|
functions: dict[int, FunctionInfo],
|
||||||
|
) -> tuple[dict[int, list[int]], dict[int, list[int]]]:
|
||||||
|
"""Returns each function's callers and each data address's users, once each."""
|
||||||
|
callers: dict[int, list[int]] = collections.defaultdict(list)
|
||||||
|
data_users: dict[int, list[int]] = collections.defaultdict(list)
|
||||||
|
for start, info in functions.items():
|
||||||
|
for target in dict.fromkeys(info.calls):
|
||||||
|
if target in functions:
|
||||||
|
callers[target].append(start)
|
||||||
|
for target in dict.fromkeys(info.refs):
|
||||||
|
data_users[target].append(start)
|
||||||
|
return callers, data_users
|
||||||
|
|
||||||
|
|
||||||
|
def _int_list(value: Any) -> tuple[int, ...]:
|
||||||
|
if not isinstance(value, list) or not all(type(item) is int for item in value):
|
||||||
|
raise ValueError("cached index entry is malformed")
|
||||||
|
return tuple(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cache(path: Path) -> dict[int, FunctionInfo] | None:
|
||||||
|
"""Returns the cached functions, or None when the file is missing or bad."""
|
||||||
|
try:
|
||||||
|
with gzip.open(path, "rt", encoding="utf-8") as stream:
|
||||||
|
entries = json.load(stream)
|
||||||
|
if not isinstance(entries, list) or not entries:
|
||||||
|
raise ValueError("cached index is empty")
|
||||||
|
functions = {}
|
||||||
|
for entry in entries:
|
||||||
|
start, size, shape_hash, calls, refs = entry
|
||||||
|
if type(start) is not int or type(size) is not int:
|
||||||
|
raise ValueError("cached index entry is malformed")
|
||||||
|
functions[start] = FunctionInfo(
|
||||||
|
size, bytes.fromhex(shape_hash), _int_list(calls), _int_list(refs)
|
||||||
|
)
|
||||||
|
return functions
|
||||||
|
except (OSError, EOFError, ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cache(path: Path, functions: dict[int, FunctionInfo]) -> None:
|
||||||
|
# A cache that cannot be written only costs the next run a rebuild.
|
||||||
|
entries = [
|
||||||
|
[start, info.size, info.shape_hash.hex(), list(info.calls), list(info.refs)]
|
||||||
|
for start, info in functions.items()
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
descriptor, temporary = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "wb") as raw, gzip.open(raw, "wt", encoding="utf-8") as stream:
|
||||||
|
json.dump(entries, stream, separators=(",", ":"))
|
||||||
|
os.replace(temporary, path)
|
||||||
|
except OSError:
|
||||||
|
Path(temporary).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionIndex:
|
||||||
|
"""Normalized shapes, calls and data references of every function.
|
||||||
|
|
||||||
|
With `cache_dir`, the per-function data is kept in a file named after the
|
||||||
|
library's SHA-256, this module's source and the capstone version, and read
|
||||||
|
back instead of decoding the library again.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, image: Any, cache_dir: Path | None = None):
|
||||||
|
DERIVER.require_capstone()
|
||||||
|
self.image = image
|
||||||
|
self.decoder = DERIVER.capstone.Cs(
|
||||||
|
DERIVER.capstone.CS_ARCH_X86, DERIVER.capstone.CS_MODE_64
|
||||||
|
)
|
||||||
|
self.text = image.sections[".text"]
|
||||||
|
self._decoded: dict[int, list[Instruction]] = {}
|
||||||
|
path = None if cache_dir is None else cache_path(Path(cache_dir), image.sha256)
|
||||||
|
functions = None if path is None else _read_cache(path)
|
||||||
|
if functions is None:
|
||||||
|
functions = self._decode_all()
|
||||||
|
if path is not None:
|
||||||
|
_write_cache(path, functions)
|
||||||
|
self.functions = functions
|
||||||
|
self.starts = sorted(self.functions)
|
||||||
|
self.callers, self.data_users = link_functions(self.functions)
|
||||||
|
|
||||||
|
def _decode_all(self) -> dict[int, FunctionInfo]:
|
||||||
|
text_end = self.text.address + self.text.size
|
||||||
|
functions = {}
|
||||||
|
for start, size in function_table(self.image):
|
||||||
|
if size == 0 or not self.text.address <= start < text_end:
|
||||||
|
continue
|
||||||
|
instructions = self.instructions(start, size, cache=False)
|
||||||
|
shape_hash = hashlib.blake2b(
|
||||||
|
"\n".join(item.shape for item in instructions).encode(), digest_size=8
|
||||||
|
).digest()
|
||||||
|
calls = tuple(
|
||||||
|
item.branch_target
|
||||||
|
for item in instructions
|
||||||
|
if item.branch_target is not None
|
||||||
|
and (item.shape == "call" or item.shape.endswith(" out"))
|
||||||
|
)
|
||||||
|
refs = tuple(target for item in instructions for target in item.rip_targets)
|
||||||
|
functions[start] = FunctionInfo(size, shape_hash, calls, refs)
|
||||||
|
return functions
|
||||||
|
|
||||||
|
def instructions(
|
||||||
|
self, start: int, size: int | None = None, cache: bool = True
|
||||||
|
) -> list[Instruction]:
|
||||||
|
if cache and start in self._decoded:
|
||||||
|
return self._decoded[start]
|
||||||
|
if size is None:
|
||||||
|
size = self.functions[start].size
|
||||||
|
offset = self.text.offset + start - self.text.address
|
||||||
|
decoded = decode_function(self.decoder, self.image.data[offset : offset + size], start)
|
||||||
|
if cache:
|
||||||
|
self._decoded[start] = decoded
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
def shapes(self, start: int) -> list[str]:
|
||||||
|
# Scoring reads each candidate once; only aligned functions stay cached.
|
||||||
|
return [item.shape for item in self.instructions(start, cache=start in self._decoded)]
|
||||||
|
|
||||||
|
def containing(self, rva: int) -> int | None:
|
||||||
|
index = bisect.bisect_right(self.starts, rva) - 1
|
||||||
|
if index < 0:
|
||||||
|
return None
|
||||||
|
start = self.starts[index]
|
||||||
|
return start if rva < start + self.functions[start].size else None
|
||||||
|
|
||||||
|
def string_at(self, rva: int) -> bytes | None:
|
||||||
|
segment = self.image.segment_for_rva(rva)
|
||||||
|
if segment is None or not segment.contains_file_data(rva):
|
||||||
|
return None
|
||||||
|
offset = self.image.rva_to_offset(rva)
|
||||||
|
raw = self.image.data[offset : offset + 96]
|
||||||
|
end = raw.find(b"\0")
|
||||||
|
if end < 4 or not all(32 <= byte < 127 for byte in raw[:end]):
|
||||||
|
return None
|
||||||
|
return raw[:end]
|
||||||
@@ -28,6 +28,7 @@ STORE_ROOT=""
|
|||||||
TEMP_DIR=""
|
TEMP_DIR=""
|
||||||
ROLLBACK_DIR=""
|
ROLLBACK_DIR=""
|
||||||
ROLLBACK_NEEDED=false
|
ROLLBACK_NEEDED=false
|
||||||
|
BACKUP_COMPLETE=false
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
@@ -146,34 +147,54 @@ certificate_digests() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
restore_payload() {
|
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"
|
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
|
local path active_path failed_path restore_status=0
|
||||||
for path in libroblox.so sober_apk assets; do
|
for path in libroblox.so sober_apk assets roblox_payload.json; do
|
||||||
if [[ -e "${RBX_BIN_DIR}/${path}" ]]; then
|
active_path="${RBX_BIN_DIR}/${path}"
|
||||||
mv -- "${RBX_BIN_DIR}/${path}" "${TEMP_DIR}/failed-${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
|
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
|
fi
|
||||||
done
|
done
|
||||||
|
return "${restore_status}"
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
local -r status=$?
|
local status=$?
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
set +e
|
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
|
if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then
|
||||||
rm -rf -- "${TEMP_DIR}"
|
rm -rf -- "${TEMP_DIR}"
|
||||||
fi
|
fi
|
||||||
@@ -401,17 +422,18 @@ main() {
|
|||||||
mkdir -p "${VERSIONS_DIR}" "$(dirname -- "${METADATA_PATH}")"
|
mkdir -p "${VERSIONS_DIR}" "$(dirname -- "${METADATA_PATH}")"
|
||||||
ROLLBACK_DIR="${TEMP_DIR}/rollback"
|
ROLLBACK_DIR="${TEMP_DIR}/rollback"
|
||||||
mkdir -p "${ROLLBACK_DIR}"
|
mkdir -p "${ROLLBACK_DIR}"
|
||||||
|
ROLLBACK_NEEDED=true
|
||||||
|
|
||||||
local active_path
|
local active_path
|
||||||
for active_path in libroblox.so sober_apk assets; do
|
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}"
|
mv -- "${RBX_BIN_DIR}/${active_path}" "${ROLLBACK_DIR}/${active_path}"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [[ -e "${METADATA_PATH}" ]]; then
|
if [[ -e "${METADATA_PATH}" || -L "${METADATA_PATH}" ]]; then
|
||||||
mv -- "${METADATA_PATH}" "${ROLLBACK_DIR}/roblox_payload.json"
|
mv -- "${METADATA_PATH}" "${ROLLBACK_DIR}/roblox_payload.json"
|
||||||
fi
|
fi
|
||||||
ROLLBACK_NEEDED=true
|
BACKUP_COMPLETE=true
|
||||||
|
|
||||||
mv -- "${stage_dir}/libroblox.so" "${RBX_BIN_DIR}/libroblox.so"
|
mv -- "${stage_dir}/libroblox.so" "${RBX_BIN_DIR}/libroblox.so"
|
||||||
mv -- "${stage_dir}/sober_apk" "${RBX_BIN_DIR}/sober_apk"
|
mv -- "${stage_dir}/sober_apk" "${RBX_BIN_DIR}/sober_apk"
|
||||||
|
|||||||
+123
-43
@@ -5,52 +5,132 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
<title>Nightcap for Linux</title>
|
<title>Nightcap: Roblox on Linux</title>
|
||||||
|
<meta name="description" content="Roblox on Linux, tuned for desktop play. A Mocktail fork that keeps up with Roblox updates, with extra bug fixes, custom assets and more.">
|
||||||
|
<meta name="google-site-verification" content="vznL8W2txRlmfwhGg7ZBPSxQaPV9OtaIUoYPvmd9aUA">
|
||||||
|
<link rel="canonical" href="https://coderdayton.github.io/nightcap/">
|
||||||
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
|
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="Nightcap">
|
||||||
|
<meta property="og:title" content="Nightcap: Roblox on Linux">
|
||||||
|
<meta property="og:description" content="Roblox on Linux, tuned for desktop play. A Mocktail fork that keeps up with Roblox updates, with extra bug fixes, custom assets and more.">
|
||||||
|
<meta property="og:url" content="https://coderdayton.github.io/nightcap/">
|
||||||
|
<meta property="og:image" content="https://coderdayton.github.io/nightcap/preview.png">
|
||||||
|
<meta property="og:image:alt" content="Roblox running on Linux with Nightcap">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": "Nightcap",
|
||||||
|
"description": "Roblox on Linux, tuned for desktop play. A Mocktail fork that keeps up with Roblox updates, with extra bug fixes, custom assets and more.",
|
||||||
|
"applicationCategory": "GameApplication",
|
||||||
|
"operatingSystem": "Linux",
|
||||||
|
"url": "https://coderdayton.github.io/nightcap/",
|
||||||
|
"downloadUrl": "https://github.com/CoderDayton/nightcap/releases/latest",
|
||||||
|
"license": "https://www.apache.org/licenses/LICENSE-2.0",
|
||||||
|
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body><main>
|
<body>
|
||||||
<img src="nightcap.svg" alt="Nightcap logo">
|
<header class="hero">
|
||||||
<h1>Nightcap</h1>
|
<div class="wrap">
|
||||||
<p>Play Roblox on Linux like it was made for your PC. Nightcap is Mocktail, tuned to feel fast and look sharp.</p>
|
<p class="top">
|
||||||
<a class="button" href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">Download AppImage</a>
|
<a href="https://github.com/CoderDayton/nightcap" aria-label="Nightcap on GitHub" title="GitHub"><svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg></a>
|
||||||
<a class="button secondary" href="nightcap.flatpakref">Install with Flatpak</a>
|
<a href="https://discord.gg/DbAUG9hBFU" aria-label="Nightcap on Discord" title="Discord"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg></a>
|
||||||
<div class="commands">
|
</p>
|
||||||
<p>AppImage</p>
|
<p class="eyebrow">Roblox on Linux, tuned for desktop play</p>
|
||||||
<code>chmod +x Nightcap-x86_64.AppImage
|
<h1><img src="nightcap.svg" alt=""><span>Nightcap</span></h1>
|
||||||
./Nightcap-x86_64.AppImage</code>
|
<p class="lede">A Mocktail fork that keeps up with Roblox updates, with extra bug fixes, custom assets and more.</p>
|
||||||
<p>Flatpak</p>
|
<p class="actions">
|
||||||
<code>flatpak install --user https://coderdayton.github.io/nightcap/nightcap.flatpakref
|
<a class="button" href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">Download AppImage</a>
|
||||||
flatpak run io.github.CoderDayton.nightcap</code>
|
<a class="button secondary" href="nightcap.flatpakref">Install with Flatpak</a>
|
||||||
<p>DEB, RPM and pacman</p>
|
</p>
|
||||||
<code>sudo apt install ./nightcap_<version>-1_amd64.deb
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="wrap">
|
||||||
|
<section class="row stacked">
|
||||||
|
<h2>Install</h2>
|
||||||
|
<dl class="body install">
|
||||||
|
<dt>AppImage</dt>
|
||||||
|
<dd><pre><code>chmod +x Nightcap-x86_64.AppImage
|
||||||
|
./Nightcap-x86_64.AppImage</code></pre></dd>
|
||||||
|
<dt>Flatpak</dt>
|
||||||
|
<dd><pre><code>flatpak install --user https://coderdayton.github.io/nightcap/nightcap.flatpakref
|
||||||
|
flatpak run io.github.CoderDayton.nightcap</code></pre></dd>
|
||||||
|
<dt>DEB, RPM, pacman</dt>
|
||||||
|
<dd><pre><code>sudo apt install ./nightcap_<version>-1_amd64.deb
|
||||||
sudo dnf install ./nightcap-<version>-1.x86_64.rpm
|
sudo dnf install ./nightcap-<version>-1.x86_64.rpm
|
||||||
sudo pacman -U nightcap-<version>-1-x86_64.pkg.tar.zst</code>
|
sudo pacman -U nightcap-<version>-1-x86_64.pkg.tar.zst</code></pre></dd>
|
||||||
<p>Build from source</p>
|
<dt>Source</dt>
|
||||||
<code>git clone --recurse-submodules https://github.com/CoderDayton/nightcap.git
|
<dd><pre><code>git clone --recurse-submodules https://github.com/CoderDayton/nightcap.git
|
||||||
cd nightcap && make build
|
cd nightcap && make build
|
||||||
./build/mocktail</code>
|
./build/mocktail</code></pre></dd>
|
||||||
</div>
|
</dl>
|
||||||
<h2>What's different</h2>
|
</section>
|
||||||
<ul>
|
|
||||||
<li>Lower input lag. Frames are shown as soon as they are ready.</li>
|
<section class="row">
|
||||||
<li>Sharper textures, with your own PNG overrides if you want them.</li>
|
<h2>What's different</h2>
|
||||||
<li>ETC2 textures render correctly on desktop GPUs.</li>
|
<dl class="body facts">
|
||||||
<li>No more error 319 kicks after joining a server.</li>
|
<dt>Roblox updates</dt>
|
||||||
<li>Roblox sees your real RAM and screen size.</li>
|
<dd>New Roblox versions work soon after they come out.</dd>
|
||||||
</ul>
|
<dt>More games</dt>
|
||||||
<h2>Direct downloads</h2>
|
<dd>Answers a server check some experiences use, so you can play those too.</dd>
|
||||||
<p>Prebuilt builds for Linux x86_64.</p>
|
<dt>Voice chat</dt>
|
||||||
<p class="links">
|
<dd>Your microphone still works after a Roblox update.</dd>
|
||||||
<a href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">AppImage</a>
|
<dt>Login</dt>
|
||||||
<a href="Nightcap-x86_64.flatpak">Flatpak bundle</a>
|
<dd>Verification checks and captchas finish instead of hanging.</dd>
|
||||||
<a href="https://github.com/CoderDayton/nightcap/releases/latest">DEB, RPM, pacman</a>
|
<dt>Look</dt>
|
||||||
<a href="nightcap-flatpak.gpg">Signing key</a>
|
<dd>The new home screen and side menu, like on Windows.</dd>
|
||||||
<a href="terms.html">Terms</a>
|
<dt>Your own assets</dt>
|
||||||
<a href="privacy.html">Privacy</a>
|
<dd>Drop in your own cursors, fonts and skies. The folder layout matches Sober's.</dd>
|
||||||
</p>
|
<dt>Mouse and CPU</dt>
|
||||||
<p><a class="source" href="https://github.com/CoderDayton/nightcap">Source code</a> ·
|
<dd>Optional raw mouse input, and less CPU use while idle.</dd>
|
||||||
<a class="source" href="https://github.com/komaruworld/mocktail">Upstream Mocktail</a></p>
|
</dl>
|
||||||
<p>Nightcap is an independent community project and is not affiliated with Roblox Corporation.</p>
|
</section>
|
||||||
</main></body>
|
|
||||||
|
<section class="row">
|
||||||
|
<h2>Direct downloads</h2>
|
||||||
|
<div class="body">
|
||||||
|
<p>Prebuilt builds for Linux x86_64.</p>
|
||||||
|
<ul class="links">
|
||||||
|
<li><a href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage"><span class="name">AppImage</span><span class="file">Nightcap-x86_64.AppImage</span><span class="go" aria-hidden="true">↓</span></a></li>
|
||||||
|
<li><a href="Nightcap-x86_64.flatpak"><span class="name">Flatpak bundle</span><span class="file">Nightcap-x86_64.flatpak</span><span class="go" aria-hidden="true">↓</span></a></li>
|
||||||
|
<li><a href="https://github.com/CoderDayton/nightcap/releases/latest"><span class="name">DEB, RPM, pacman</span><span class="file">GitHub releases page</span><span class="go" aria-hidden="true">↗</span></a></li>
|
||||||
|
<li><a href="nightcap-flatpak.gpg"><span class="name">Signing key</span><span class="file">nightcap-flatpak.gpg</span><span class="go" aria-hidden="true">↓</span></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="wrap foot">
|
||||||
|
<p>
|
||||||
|
<a href="https://github.com/CoderDayton/nightcap">Source code</a> ·
|
||||||
|
<a href="https://github.com/komaruworld/mocktail">Upstream Mocktail</a> ·
|
||||||
|
<a href="terms.html">Terms</a> ·
|
||||||
|
<a href="privacy.html">Privacy</a>
|
||||||
|
</p>
|
||||||
|
<p>Nightcap is an independent community project and is not affiliated with Roblox Corporation.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Adds a copy button to each command block. The page works without it.
|
||||||
|
document.querySelectorAll('.install dd').forEach(function (block) {
|
||||||
|
var button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.textContent = 'copy';
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
if (!navigator.clipboard) return;
|
||||||
|
navigator.clipboard.writeText(block.querySelector('code').textContent).then(function () {
|
||||||
|
button.textContent = 'copied';
|
||||||
|
setTimeout(function () { button.textContent = 'copy'; }, 1600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
block.appendChild(button);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 419 KiB |
+36
-26
@@ -8,36 +8,46 @@
|
|||||||
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
|
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body><main>
|
<body>
|
||||||
<h1>Privacy Policy</h1>
|
<header class="wrap bar">
|
||||||
<p>Nightcap is a Linux runtime for the Roblox Android client. This policy covers Nightcap itself and its Discord application. Roblox and Discord have their own policies for the data they handle.</p>
|
<a class="brand" href="index.html"><img src="nightcap.svg" alt="" width="30" height="30"><span>Nightcap</span></a>
|
||||||
|
<p class="top">
|
||||||
|
<a href="https://github.com/CoderDayton/nightcap" aria-label="Nightcap on GitHub" title="GitHub"><svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg></a>
|
||||||
|
<a href="https://discord.gg/DbAUG9hBFU" aria-label="Nightcap on Discord" title="Discord"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg></a>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<h2>What Nightcap collects</h2>
|
<main class="wrap prose">
|
||||||
<p>Nothing. Nightcap has no server. It does not collect, store, or send telemetry, analytics, crash reports, or account data to the Nightcap project.</p>
|
<h1>Privacy Policy</h1>
|
||||||
|
<p>Nightcap is a Linux runtime for the Roblox Android client. This policy covers Nightcap itself and its Discord application. Roblox and Discord have their own policies for the data they handle.</p>
|
||||||
|
|
||||||
<h2>What stays on your machine</h2>
|
<h2>What Nightcap collects</h2>
|
||||||
<ul>
|
<p>Nothing. Nightcap has no server. It does not collect, store, or send telemetry, analytics, crash reports, or account data to the Nightcap project.</p>
|
||||||
<li>Configuration in <code>~/.config/mocktail/</code></li>
|
|
||||||
<li>Session logs and support bundles in <code>~/.local/state/mocktail/</code></li>
|
|
||||||
<li>Roblox login session under <code>~/.local/share/mocktail/</code></li>
|
|
||||||
<li>Roblox cache, downloaded payloads, and shader cache under <code>~/.cache/mocktail/</code></li>
|
|
||||||
</ul>
|
|
||||||
<p>Flatpak installs keep the same layout under <code>~/.var/app/io.github.CoderDayton.nightcap/</code>.</p>
|
|
||||||
|
|
||||||
<h2>Discord Rich Presence</h2>
|
<h2>What stays on your machine</h2>
|
||||||
<p>When Rich Presence is on, Nightcap sends the current experience name, its public icon URL, elapsed time, and an optional public server join code to the Discord desktop client on the same machine over a local socket. Discord displays this on your profile under its own privacy settings. Nightcap never signs in to Discord, never reads a Discord token, and never contacts Discord's servers.</p>
|
<ul>
|
||||||
<p>Set <code>integrations.discord_rpc.enabled: false</code> in <code>config.yaml</code> to turn it off.</p>
|
<li>Configuration in <code>~/.config/mocktail/</code></li>
|
||||||
|
<li>Session logs and support bundles in <code>~/.local/state/mocktail/</code></li>
|
||||||
|
<li>Roblox login session under <code>~/.local/share/mocktail/</code></li>
|
||||||
|
<li>Roblox cache, downloaded payloads, and shader cache under <code>~/.cache/mocktail/</code></li>
|
||||||
|
</ul>
|
||||||
|
<p>Flatpak installs keep the same layout under <code>~/.var/app/io.github.CoderDayton.nightcap/</code>.</p>
|
||||||
|
|
||||||
<h2>Roblox</h2>
|
<h2>Discord Rich Presence</h2>
|
||||||
<p>Nightcap talks to Roblox's services exactly as the Roblox client would. That traffic is covered by the Roblox Privacy Policy.</p>
|
<p>When Rich Presence is on, Nightcap sends the current experience name, its public icon URL, elapsed time, and an optional public server join code to the Discord desktop client on the same machine over a local socket. Discord displays this on your profile under its own privacy settings. Nightcap never signs in to Discord, never reads a Discord token, and never contacts Discord's servers.</p>
|
||||||
|
<p>Set <code>integrations.discord_rpc.enabled: false</code> in <code>config.yaml</code> to turn it off.</p>
|
||||||
|
|
||||||
<h2>Contact</h2>
|
<h2>Roblox</h2>
|
||||||
<p>Open an issue at <a class="source" href="https://github.com/CoderDayton/nightcap/issues">github.com/CoderDayton/nightcap/issues</a>.</p>
|
<p>Nightcap talks to Roblox's services exactly as the Roblox client would. That traffic is covered by the Roblox Privacy Policy.</p>
|
||||||
|
|
||||||
<p class="links">
|
<h2>Contact</h2>
|
||||||
<a href="index.html">Home</a>
|
<p>Open an issue at <a href="https://github.com/CoderDayton/nightcap/issues">github.com/CoderDayton/nightcap/issues</a>.</p>
|
||||||
<a href="terms.html">Terms of Service</a>
|
|
||||||
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
|
<p class="legal-nav">
|
||||||
</p>
|
<a href="index.html">Home</a>
|
||||||
</main></body>
|
<a href="terms.html">Terms of Service</a>
|
||||||
|
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+260
-49
@@ -1,94 +1,305 @@
|
|||||||
/* Modified by vii from komaruworld/mocktail. See README "About this fork". */
|
/* Modified by vii from komaruworld/mocktail. See README "About this fork". */
|
||||||
:root {
|
:root {
|
||||||
font: 17px/1.55 system-ui, sans-serif;
|
--bg: #14122b;
|
||||||
|
--ink: #ece8ff;
|
||||||
|
--muted: #b3aad6;
|
||||||
|
--accent: #6c4fd1;
|
||||||
|
--link: #a78bfa;
|
||||||
|
--line: #3a3470;
|
||||||
|
--code-bg: #1d1a3d;
|
||||||
|
font: 17px/1.6 system-ui, sans-serif;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #14122b;
|
background: var(--bg);
|
||||||
color: #ece8ff;
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
a { color: var(--link); }
|
||||||
max-width: 680px;
|
a:focus-visible,
|
||||||
margin: 12vh auto;
|
button:focus-visible { outline: 2px solid var(--link); outline-offset: 3px; }
|
||||||
padding: 32px;
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
max-width: 920px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
.hero { padding-bottom: clamp(48px, 10vh, 110px); }
|
||||||
width: 88px;
|
|
||||||
height: 88px;
|
.top {
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 22px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.top a {
|
||||||
margin: .2rem 0;
|
display: block;
|
||||||
font-size: clamp(2.5rem, 8vw, 4.5rem);
|
color: var(--muted);
|
||||||
|
line-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
.top a:hover { color: var(--ink); }
|
||||||
margin: 32px 0 0;
|
|
||||||
font-size: 1.15rem;
|
.eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin: clamp(48px, 12vh, 130px) 0 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--link);
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
p,
|
.hero h1 {
|
||||||
li {
|
margin: 18px 0 0;
|
||||||
color: #b3aad6;
|
font-size: clamp(3.6rem, 15vw, 9.5rem);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: .95;
|
||||||
|
letter-spacing: -.045em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .16em;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
.hero h1 img {
|
||||||
padding-left: 1.2rem;
|
height: .78em;
|
||||||
|
width: auto;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
max-width: 34ch;
|
||||||
|
margin: 22px 0 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions { margin: 28px 0 0; }
|
||||||
|
|
||||||
a.button {
|
a.button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 16px 8px 16px 0;
|
margin: 0 10px 10px 0;
|
||||||
padding: 12px 18px;
|
padding: 12px 20px;
|
||||||
border-radius: 9px;
|
border: 2px solid var(--accent);
|
||||||
background: #6c4fd1;
|
background: var(--accent);
|
||||||
color: white;
|
color: #fff;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.secondary {
|
a.button:hover { background: #7d62dd; border-color: #7d62dd; }
|
||||||
background: #2f2a6b;
|
|
||||||
|
a.button.secondary {
|
||||||
|
background: var(--bg);
|
||||||
|
border-color: var(--link);
|
||||||
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.commands {
|
a.button.secondary:hover { background: var(--code-bg); }
|
||||||
margin-top: 10px;
|
|
||||||
|
/* Every section is a label on the left and its content on the right. */
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
gap: 8px 32px;
|
||||||
|
padding: 36px 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.commands p,
|
h2 {
|
||||||
.download-label {
|
margin: 0;
|
||||||
margin: 14px 0 5px;
|
font-size: 1rem;
|
||||||
color: #ece8ff;
|
|
||||||
font-size: .82rem;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: .04em;
|
}
|
||||||
text-transform: uppercase;
|
|
||||||
|
.row.stacked { grid-template-columns: 1fr; gap: 20px; }
|
||||||
|
|
||||||
|
.body { margin: 0; min-width: 0; }
|
||||||
|
.body p { margin: 0 0 12px; color: var(--muted); }
|
||||||
|
|
||||||
|
dl.body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
padding: 9px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
padding: 9px 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts dt,
|
||||||
|
.facts dd { border-bottom: 1px solid var(--line); }
|
||||||
|
.facts dt:last-of-type,
|
||||||
|
.facts dd:last-of-type { border-bottom: 0; }
|
||||||
|
.facts dt { color: var(--ink); font-weight: 700; }
|
||||||
|
|
||||||
|
dl.body.install { display: block; }
|
||||||
|
|
||||||
|
.install dt {
|
||||||
|
padding: 18px 0 6px;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install dt:first-child { padding-top: 0; }
|
||||||
|
.install dd { padding: 0; }
|
||||||
|
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
background: var(--code-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
display: block;
|
font: .9rem/1.7 ui-monospace, "DejaVu Sans Mono", "JetBrains Mono", monospace;
|
||||||
padding: 14px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
border: 1px solid #3a3470;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #1d1a3d;
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dd button {
|
||||||
|
position: absolute;
|
||||||
|
top: -30px;
|
||||||
|
right: 0;
|
||||||
|
padding: 2px 0 2px 8px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font: .8rem ui-monospace, monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd button:hover { color: var(--ink); text-decoration: underline; }
|
||||||
|
|
||||||
.links {
|
.links {
|
||||||
margin: 10px 0 14px;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.links li { border-bottom: 1px solid var(--line); }
|
||||||
|
|
||||||
.links a {
|
.links a {
|
||||||
display: inline-block;
|
display: grid;
|
||||||
margin: 0 14px 6px 0;
|
grid-template-columns: 200px 1fr 24px;
|
||||||
color: #a78bfa;
|
align-items: baseline;
|
||||||
|
gap: 4px 16px;
|
||||||
|
margin: 0 -8px;
|
||||||
|
padding: 12px 24px 12px 8px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.source {
|
.links a:hover { background: var(--code-bg); }
|
||||||
color: #a78bfa;
|
.links a:hover .name { text-decoration: underline; text-decoration-color: var(--link); text-underline-offset: 4px; }
|
||||||
|
|
||||||
|
.links .file {
|
||||||
|
color: var(--muted);
|
||||||
|
font: 400 .88rem ui-monospace, "DejaVu Sans Mono", monospace;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links .go { color: var(--link); text-align: right; }
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
padding-bottom: 48px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot p { margin: 0 0 8px; }
|
||||||
|
|
||||||
|
/* Terms and privacy pages: a header bar and one readable column. */
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar .top { padding-top: 0; }
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand img { width: 30px; height: 30px; }
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
padding-top: clamp(32px, 8vh, 80px);
|
||||||
|
padding-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose > * { max-width: 700px; }
|
||||||
|
|
||||||
|
.prose h1 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: clamp(2rem, 6vw, 3rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h2 {
|
||||||
|
margin: 36px 0 6px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose p,
|
||||||
|
.prose li { margin: 0 0 12px; }
|
||||||
|
.prose ul { margin: 0 0 12px; padding-left: 1.2rem; }
|
||||||
|
|
||||||
|
.prose code {
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
font-size: .85rem;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-nav { display: flex; flex-wrap: wrap; gap: 4px 20px; margin-top: 28px; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.row { grid-template-columns: 1fr; padding: 28px 0; }
|
||||||
|
dl.body:not(.install) { grid-template-columns: 1fr; }
|
||||||
|
.facts dt { padding-bottom: 0; border-bottom: 0; }
|
||||||
|
.links a { grid-template-columns: 1fr 24px; }
|
||||||
|
.links .file { grid-column: 1; grid-row: 2; }
|
||||||
|
.links .go { grid-column: 2; grid-row: 1 / span 2; align-self: center; }
|
||||||
}
|
}
|
||||||
+29
-19
@@ -8,29 +8,39 @@
|
|||||||
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
|
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body><main>
|
<body>
|
||||||
<h1>Terms of Service</h1>
|
<header class="wrap bar">
|
||||||
<p>Nightcap is free and open-source software licensed under the Apache License, Version 2.0. The license text shipped with the source governs your use of the software.</p>
|
<a class="brand" href="index.html"><img src="nightcap.svg" alt="" width="30" height="30"><span>Nightcap</span></a>
|
||||||
|
<p class="top">
|
||||||
|
<a href="https://github.com/CoderDayton/nightcap" aria-label="Nightcap on GitHub" title="GitHub"><svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg></a>
|
||||||
|
<a href="https://discord.gg/DbAUG9hBFU" aria-label="Nightcap on Discord" title="Discord"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg></a>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<h2>No warranty</h2>
|
<main class="wrap prose">
|
||||||
<p>Nightcap is provided "as is", without warranty of any kind. The authors are not liable for any loss, damage, or account action that results from using it. See sections 7 and 8 of the Apache License.</p>
|
<h1>Terms of Service</h1>
|
||||||
|
<p>Nightcap is free and open-source software licensed under the Apache License, Version 2.0. The license text shipped with the source governs your use of the software.</p>
|
||||||
|
|
||||||
<h2>Not affiliated</h2>
|
<h2>No warranty</h2>
|
||||||
<p>Nightcap is an independent community project. It is not affiliated with, endorsed by, or supported by Roblox Corporation or Discord Inc. Roblox and Discord are trademarks of their respective owners.</p>
|
<p>Nightcap is provided "as is", without warranty of any kind. The authors are not liable for any loss, damage, or account action that results from using it. See sections 7 and 8 of the Apache License.</p>
|
||||||
|
|
||||||
<h2>Your responsibility</h2>
|
<h2>Not affiliated</h2>
|
||||||
<p>You are responsible for following the Roblox Terms of Use and the Discord Terms of Service while using Nightcap with those services. Nightcap does not modify Roblox game logic, does not bypass Roblox security, and is not a cheat.</p>
|
<p>Nightcap is an independent community project. It is not affiliated with, endorsed by, or supported by Roblox Corporation or Discord Inc. Roblox and Discord are trademarks of their respective owners.</p>
|
||||||
|
|
||||||
<h2>Discord application</h2>
|
<h2>Your responsibility</h2>
|
||||||
<p>The Nightcap Discord application only provides Rich Presence for the Nightcap desktop runtime. It requests no permissions, has no bot, and does not access your Discord account.</p>
|
<p>You are responsible for following the Roblox Terms of Use and the Discord Terms of Service while using Nightcap with those services. Nightcap does not modify Roblox game logic, does not bypass Roblox security, and is not a cheat.</p>
|
||||||
|
|
||||||
<h2>Changes</h2>
|
<h2>Discord application</h2>
|
||||||
<p>These terms may change with new releases. The current version always lives at this address.</p>
|
<p>The Nightcap Discord application only provides Rich Presence for the Nightcap desktop runtime. It requests no permissions, has no bot, and does not access your Discord account.</p>
|
||||||
|
|
||||||
<p class="links">
|
<h2>Changes</h2>
|
||||||
<a href="index.html">Home</a>
|
<p>These terms may change with new releases. The current version always lives at this address.</p>
|
||||||
<a href="privacy.html">Privacy Policy</a>
|
|
||||||
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
|
<p class="legal-nav">
|
||||||
</p>
|
<a href="index.html">Home</a>
|
||||||
</main></body>
|
<a href="privacy.html">Privacy Policy</a>
|
||||||
|
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <initializer_list>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
@@ -31,18 +32,18 @@ struct NativeOptionalFormat {
|
|||||||
struct NativeSharedSink { void* object; void* owner; };
|
struct NativeSharedSink { void* object; void* owner; };
|
||||||
static_assert(sizeof(NativeOptionalFormat) == 16);
|
static_assert(sizeof(NativeOptionalFormat) == 16);
|
||||||
static_assert(sizeof(NativePcmFormat) == 8);
|
static_assert(sizeof(NativePcmFormat) == 8);
|
||||||
inline std::atomic<NativeInputCapture*> g_native_capture{nullptr};
|
inline std::shared_ptr<NativeInputCapture> g_native_capture;
|
||||||
|
|
||||||
class NativeInputCapture {
|
class NativeInputCapture {
|
||||||
public:
|
public:
|
||||||
using DeliverPcm = std::size_t (*)(void*, const void*, std::size_t,
|
using DeliverPcm = std::size_t (*)(void*, const void*, std::size_t,
|
||||||
std::uint64_t, const NativePcmFormat*);
|
std::uint64_t, const NativePcmFormat*);
|
||||||
using Destroy = void (*)(NativeSharedSink*);
|
|
||||||
using OriginalLatency = void (*)(void*, std::uint32_t*, std::uint32_t*);
|
using OriginalLatency = void (*)(void*, std::uint32_t*, std::uint32_t*);
|
||||||
|
using ImageRange = bool (*)(std::uintptr_t, std::uintptr_t, std::size_t);
|
||||||
struct SinkAbi {
|
struct SinkAbi {
|
||||||
std::uintptr_t vtable;
|
std::uintptr_t image_base;
|
||||||
DeliverPcm deliver;
|
ImageRange is_image_relro;
|
||||||
Destroy release;
|
ImageRange is_image_code;
|
||||||
OriginalLatency latency;
|
OriginalLatency latency;
|
||||||
};
|
};
|
||||||
NativeInputCapture(SinkAbi abi, bool enabled)
|
NativeInputCapture(SinkAbi abi, bool enabled)
|
||||||
@@ -50,17 +51,22 @@ class NativeInputCapture {
|
|||||||
~NativeInputCapture() { Shutdown(); }
|
~NativeInputCapture() { Shutdown(); }
|
||||||
|
|
||||||
static NativeOptionalFormat Format(void*) {
|
static NativeOptionalFormat Format(void*) {
|
||||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
auto capture =
|
||||||
return {{}, capture && capture->enabled_ ? 1ULL : 0ULL};
|
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||||
|
if (!capture) return {};
|
||||||
|
std::lock_guard lock(capture->mutex_);
|
||||||
|
return {{}, capture->enabled_ && !capture->closed_ ? 1ULL : 0ULL};
|
||||||
}
|
}
|
||||||
static void Latency(void* self, std::uint32_t* playback,
|
static void Latency(void* self, std::uint32_t* playback,
|
||||||
std::uint32_t* recording) {
|
std::uint32_t* recording) {
|
||||||
// Preserve the native playback latency and report the host capture queue.
|
// Preserve the native playback latency and report the host capture queue.
|
||||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
auto capture =
|
||||||
|
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||||
if (!capture) return;
|
if (!capture) return;
|
||||||
|
std::lock_guard lock(capture->mutex_);
|
||||||
|
if (capture->closed_) return;
|
||||||
capture->abi_.latency(self, playback, recording);
|
capture->abi_.latency(self, playback, recording);
|
||||||
if (recording) {
|
if (recording) {
|
||||||
std::lock_guard lock(capture->mutex_);
|
|
||||||
*recording = 10;
|
*recording = 10;
|
||||||
auto it = capture->sessions_.find(self);
|
auto it = capture->sessions_.find(self);
|
||||||
if (it != capture->sessions_.end()) {
|
if (it != capture->sessions_.end()) {
|
||||||
@@ -70,33 +76,48 @@ class NativeInputCapture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
static bool Start(void* self, NativeSharedSink* sink) {
|
static bool Start(void* self, NativeSharedSink* sink) {
|
||||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
auto capture =
|
||||||
|
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||||
return capture && capture->StartSink(self, sink);
|
return capture && capture->StartSink(self, sink);
|
||||||
}
|
}
|
||||||
static void Stop(void* self, NativeSharedSink* sink) {
|
static void Stop(void* self, NativeSharedSink* sink) {
|
||||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
auto capture =
|
||||||
|
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||||
if (capture) capture->StopSink(self, sink);
|
if (capture) capture->StopSink(self, sink);
|
||||||
}
|
}
|
||||||
static bool Recording(void* self) {
|
static bool Recording(void* self) {
|
||||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
auto capture =
|
||||||
|
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||||
if (!capture) return false;
|
if (!capture) return false;
|
||||||
std::lock_guard lock(capture->mutex_);
|
std::lock_guard lock(capture->mutex_);
|
||||||
return capture->sessions_.find(self) != capture->sessions_.end();
|
return capture->sessions_.find(self) != capture->sessions_.end();
|
||||||
}
|
}
|
||||||
static void Poll(void* self) {
|
static void Poll(void* self) {
|
||||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
auto capture =
|
||||||
|
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||||
if (capture) capture->Deliver(self);
|
if (capture) capture->Deliver(self);
|
||||||
}
|
}
|
||||||
void Shutdown() {
|
void Shutdown() {
|
||||||
std::lock_guard lock(mutex_);
|
std::lock_guard lock(mutex_);
|
||||||
|
closed_ = true;
|
||||||
for (auto& [self, session] : sessions_) Close(*session);
|
for (auto& [self, session] : sessions_) Close(*session);
|
||||||
sessions_.clear();
|
sessions_.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
struct Sink {
|
||||||
|
NativeSharedSink shared;
|
||||||
|
DeliverPcm deliver;
|
||||||
|
};
|
||||||
|
struct SharedCount {
|
||||||
|
const std::uintptr_t* vtable;
|
||||||
|
long shared_owners;
|
||||||
|
long weak_owners;
|
||||||
|
};
|
||||||
|
using ControlBlockHook = void (*)(void*);
|
||||||
struct Session {
|
struct Session {
|
||||||
std::unique_ptr<AudioCapture> input;
|
std::unique_ptr<AudioCapture> input;
|
||||||
std::vector<NativeSharedSink> sinks;
|
std::vector<Sink> sinks;
|
||||||
std::mutex queue_mutex;
|
std::mutex queue_mutex;
|
||||||
// At most 200 ms; a stalled consumer must not accumulate stale speech.
|
// At most 200 ms; a stalled consumer must not accumulate stale speech.
|
||||||
std::array<std::array<std::int16_t, 480>, 20> frames{};
|
std::array<std::array<std::int16_t, 480>, 20> frames{};
|
||||||
@@ -118,20 +139,33 @@ class NativeInputCapture {
|
|||||||
session.input->buffer_data(), bytes);
|
session.input->buffer_data(), bytes);
|
||||||
++session.count;
|
++session.count;
|
||||||
}
|
}
|
||||||
|
bool IsGuestVtable(const void* object, std::size_t size,
|
||||||
|
std::initializer_list<std::size_t> methods) const {
|
||||||
|
const auto base = abi_.image_base;
|
||||||
|
const auto* table = *static_cast<const std::uintptr_t* const*>(object);
|
||||||
|
const auto address = reinterpret_cast<std::uintptr_t>(table);
|
||||||
|
if (address < base ||
|
||||||
|
!abi_.is_image_relro(base, address - base, size * sizeof(*table)))
|
||||||
|
return false;
|
||||||
|
for (const auto slot : methods)
|
||||||
|
if (table[slot] < base ||
|
||||||
|
!abi_.is_image_code(base, table[slot] - base, 1))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
bool StartSink(void* self, NativeSharedSink* sink) {
|
bool StartSink(void* self, NativeSharedSink* sink) {
|
||||||
std::lock_guard lock(mutex_);
|
std::lock_guard lock(mutex_);
|
||||||
if (!enabled_ || !self || !sink || !sink->object || !sink->owner)
|
if (closed_ || !enabled_ || !self || !sink || !sink->object || !sink->owner)
|
||||||
return false;
|
return false;
|
||||||
auto* table = *reinterpret_cast<std::uintptr_t**>(sink->object);
|
if (!IsGuestVtable(sink->object, 3, {2}) ||
|
||||||
if (reinterpret_cast<std::uintptr_t>(table) != abi_.vtable ||
|
!IsGuestVtable(sink->owner, 5, {2, 4})) {
|
||||||
table[2] != reinterpret_cast<std::uintptr_t>(abi_.deliver)) {
|
|
||||||
std::fprintf(stderr, " [audio-input] unsupported native PCM sink ABI\n");
|
std::fprintf(stderr, " [audio-input] unsupported native PCM sink ABI\n");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
auto it = sessions_.find(self);
|
auto it = sessions_.find(self);
|
||||||
if (it != sessions_.end()) {
|
if (it != sessions_.end()) {
|
||||||
for (const auto& existing : it->second->sinks)
|
for (const auto& existing : it->second->sinks)
|
||||||
if (existing.object == sink->object) return true;
|
if (existing.shared.object == sink->object) return true;
|
||||||
} else {
|
} else {
|
||||||
auto session = std::make_unique<Session>();
|
auto session = std::make_unique<Session>();
|
||||||
SdlAudioCaptureOptions options;
|
SdlAudioCaptureOptions options;
|
||||||
@@ -151,13 +185,20 @@ class NativeInputCapture {
|
|||||||
}
|
}
|
||||||
// The native parameter is a by-value shared_ptr. Retain its existing
|
// The native parameter is a by-value shared_ptr. Retain its existing
|
||||||
// ownership by moving it, leaving the caller's temporary empty.
|
// ownership by moving it, leaving the caller's temporary empty.
|
||||||
it->second->sinks.push_back(*sink);
|
const auto* table = *static_cast<const std::uintptr_t* const*>(sink->object);
|
||||||
|
it->second->sinks.push_back({*sink, reinterpret_cast<DeliverPcm>(table[2])});
|
||||||
*sink = {};
|
*sink = {};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
void Release(NativeSharedSink& sink) {
|
static void Release(Sink& sink) {
|
||||||
abi_.release(&sink);
|
auto* count = static_cast<SharedCount*>(sink.shared.owner);
|
||||||
sink = {};
|
sink = {};
|
||||||
|
if (__atomic_fetch_sub(&count->shared_owners, 1, __ATOMIC_ACQ_REL) != 0)
|
||||||
|
return;
|
||||||
|
reinterpret_cast<ControlBlockHook>(count->vtable[2])(count);
|
||||||
|
if (__atomic_load_n(&count->weak_owners, __ATOMIC_ACQUIRE) == 0 ||
|
||||||
|
__atomic_fetch_sub(&count->weak_owners, 1, __ATOMIC_ACQ_REL) == 0)
|
||||||
|
reinterpret_cast<ControlBlockHook>(count->vtable[4])(count);
|
||||||
}
|
}
|
||||||
void Close(Session& session) {
|
void Close(Session& session) {
|
||||||
session.input->Shutdown();
|
session.input->Shutdown();
|
||||||
@@ -174,7 +215,7 @@ class NativeInputCapture {
|
|||||||
if (it == sessions_.end() || !sink) return;
|
if (it == sessions_.end() || !sink) return;
|
||||||
auto& session = *it->second;
|
auto& session = *it->second;
|
||||||
auto existing = std::find_if(session.sinks.begin(), session.sinks.end(),
|
auto existing = std::find_if(session.sinks.begin(), session.sinks.end(),
|
||||||
[&](const auto& entry) { return entry.object == sink->object; });
|
[&](const auto& entry) { return entry.shared.object == sink->object; });
|
||||||
if (existing == session.sinks.end()) return;
|
if (existing == session.sinks.end()) return;
|
||||||
Release(*existing);
|
Release(*existing);
|
||||||
session.sinks.erase(existing);
|
session.sinks.erase(existing);
|
||||||
@@ -203,8 +244,7 @@ class NativeInputCapture {
|
|||||||
}
|
}
|
||||||
const NativePcmFormat format;
|
const NativePcmFormat format;
|
||||||
for (const auto& sink : session.sinks)
|
for (const auto& sink : session.sinks)
|
||||||
abi_.deliver(
|
sink.deliver(sink.shared.object, pcm.data(), pcm.size(), delay, &format);
|
||||||
sink.object, pcm.data(), pcm.size(), delay, &format);
|
|
||||||
if (++session.delivered == 1)
|
if (++session.delivered == 1)
|
||||||
std::fprintf(stderr, " [audio-input] PCM delivered to native voice sink\n");
|
std::fprintf(stderr, " [audio-input] PCM delivered to native voice sink\n");
|
||||||
if (!session.audible && std::any_of(pcm.begin(), pcm.end(),
|
if (!session.audible && std::any_of(pcm.begin(), pcm.end(),
|
||||||
@@ -216,6 +256,7 @@ class NativeInputCapture {
|
|||||||
}
|
}
|
||||||
SinkAbi abi_;
|
SinkAbi abi_;
|
||||||
bool enabled_;
|
bool enabled_;
|
||||||
|
bool closed_ = false;
|
||||||
std::mutex mutex_;
|
std::mutex mutex_;
|
||||||
std::map<void*, std::unique_ptr<Session>> sessions_;
|
std::map<void*, std::unique_ptr<Session>> sessions_;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,9 +12,7 @@
|
|||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <new>
|
#include <new>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace mocktail::audio {
|
namespace mocktail::audio {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -55,10 +53,8 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
|||||||
};
|
};
|
||||||
|
|
||||||
struct ReleaseTicket {
|
struct ReleaseTicket {
|
||||||
std::shared_ptr<State> state;
|
const std::shared_ptr<State> state;
|
||||||
std::uint64_t id = 0;
|
const std::uint64_t generation;
|
||||||
std::uint64_t generation = 0;
|
|
||||||
bool in_use = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
struct CallbackJob {
|
struct CallbackJob {
|
||||||
@@ -71,13 +67,7 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
|||||||
: sink(std::move(owned_sink)),
|
: sink(std::move(owned_sink)),
|
||||||
max_buffers(options.max_buffers),
|
max_buffers(options.max_buffers),
|
||||||
event_callback(options.event_callback),
|
event_callback(options.event_callback),
|
||||||
event_context(options.event_context),
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static const AndroidSimpleBufferQueueTable kQueueTable;
|
static const AndroidSimpleBufferQueueTable kQueueTable;
|
||||||
|
|
||||||
@@ -103,7 +93,6 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
|||||||
const std::shared_ptr<State> state = raw_state->shared_from_this();
|
const std::shared_ptr<State> state = raw_state->shared_from_this();
|
||||||
|
|
||||||
std::lock_guard<std::mutex> operation_lock(state->sink_operation_mutex);
|
std::lock_guard<std::mutex> operation_lock(state->sink_operation_mutex);
|
||||||
std::uint64_t id = 0;
|
|
||||||
ReleaseTicket* ticket = nullptr;
|
ReleaseTicket* ticket = nullptr;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(state->mutex);
|
std::lock_guard<std::mutex> lock(state->mutex);
|
||||||
@@ -111,24 +100,24 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
|||||||
return opensl_abi::kResultPreconditionsViolated;
|
return opensl_abi::kResultPreconditionsViolated;
|
||||||
}
|
}
|
||||||
if (state->pending_count.load(std::memory_order_relaxed) >=
|
if (state->pending_count.load(std::memory_order_relaxed) >=
|
||||||
state->max_buffers ||
|
state->max_buffers) {
|
||||||
state->free_tickets.empty()) {
|
|
||||||
return opensl_abi::kResultBufferInsufficient;
|
return opensl_abi::kResultBufferInsufficient;
|
||||||
}
|
}
|
||||||
const std::size_t slot_index = state->free_tickets.back();
|
ticket = new (std::nothrow)
|
||||||
state->free_tickets.pop_back();
|
ReleaseTicket{state, state->generation};
|
||||||
ticket = &state->ticket_pool[slot_index];
|
if (ticket == nullptr) {
|
||||||
id = state->next_id++;
|
state->last_status = Status::Error(
|
||||||
ticket->state = state;
|
StatusCode::kUnavailable, "unable to allocate OpenSL release ticket");
|
||||||
ticket->id = id;
|
return ToOpenSlResult(state->last_status);
|
||||||
ticket->generation = state->generation;
|
}
|
||||||
ticket->in_use = true;
|
|
||||||
state->pending_count.fetch_add(1, std::memory_order_relaxed);
|
state->pending_count.fetch_add(1, std::memory_order_relaxed);
|
||||||
++state->submitted_buffers;
|
++state->submitted_buffers;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PcmBuffer pcm{buffer, static_cast<std::size_t>(size),
|
const PcmBuffer pcm{buffer, static_cast<std::size_t>(size),
|
||||||
&State::ReleaseBuffer, ticket};
|
&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);
|
const Status status = state->sink->Enqueue(pcm);
|
||||||
if (status.ok()) {
|
if (status.ok()) {
|
||||||
state->NotifyEvent(OpenSlBufferQueueEvent::kSubmitted,
|
state->NotifyEvent(OpenSlBufferQueueEvent::kSubmitted,
|
||||||
@@ -136,22 +125,12 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
|||||||
return opensl_abi::kResultSuccess;
|
return opensl_abi::kResultSuccess;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const std::unique_ptr<ReleaseTicket> failed_ticket(ticket);
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(state->mutex);
|
std::lock_guard<std::mutex> lock(state->mutex);
|
||||||
state->last_status = status;
|
state->last_status = status;
|
||||||
if (ticket->in_use && ticket->id == id) {
|
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
|
||||||
ticket->in_use = false;
|
--state->submitted_buffers;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return ToOpenSlResult(status);
|
return ToOpenSlResult(status);
|
||||||
}
|
}
|
||||||
@@ -164,25 +143,14 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
|||||||
}
|
}
|
||||||
const std::shared_ptr<State> state = raw_state->shared_from_this();
|
const std::shared_ptr<State> state = raw_state->shared_from_this();
|
||||||
std::lock_guard<std::mutex> operation_lock(state->sink_operation_mutex);
|
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);
|
std::lock_guard<std::mutex> lock(state->mutex);
|
||||||
if (state->stopping) {
|
if (state->stopping) {
|
||||||
return opensl_abi::kResultPreconditionsViolated;
|
return opensl_abi::kResultPreconditionsViolated;
|
||||||
}
|
}
|
||||||
++state->generation;
|
++state->generation;
|
||||||
state->free_tickets.clear();
|
state->discarded_buffers +=
|
||||||
for (auto& ticket : state->ticket_pool) {
|
state->pending_count.load(std::memory_order_relaxed);
|
||||||
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->pending_count.store(0, std::memory_order_relaxed);
|
state->pending_count.store(0, std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
const Status status = state->sink->Clear();
|
const Status status = state->sink->Clear();
|
||||||
@@ -226,7 +194,8 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
|||||||
|
|
||||||
static void ReleaseBuffer(void* context, const void* /*data*/,
|
static void ReleaseBuffer(void* context, const void* /*data*/,
|
||||||
std::size_t size_bytes) {
|
std::size_t size_bytes) {
|
||||||
auto* ticket = static_cast<ReleaseTicket*>(context);
|
const std::unique_ptr<ReleaseTicket> ticket(
|
||||||
|
static_cast<ReleaseTicket*>(context));
|
||||||
if (ticket == nullptr) {
|
if (ticket == nullptr) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -239,29 +208,27 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
|||||||
bool discarded = false;
|
bool discarded = false;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(state->mutex);
|
std::lock_guard<std::mutex> lock(state->mutex);
|
||||||
if (ticket->in_use) {
|
// Clear already counted older generations as discarded. Their tickets
|
||||||
ticket->in_use = false;
|
// remain alive until release without affecting newly enqueued buffers.
|
||||||
ticket->state.reset();
|
if (ticket->generation != state->generation) {
|
||||||
const std::size_t slot_index =
|
return;
|
||||||
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) {
|
||||||
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
|
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
|
||||||
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
|
}
|
||||||
}
|
if (!state->stopping) {
|
||||||
if (!state->stopping && ticket->generation == state->generation) {
|
state->processed_buffers.fetch_add(1, std::memory_order_relaxed);
|
||||||
state->processed_buffers.fetch_add(1, std::memory_order_relaxed);
|
++state->consumed_buffers;
|
||||||
++state->consumed_buffers;
|
state->consumed_bytes += size_bytes;
|
||||||
state->consumed_bytes += size_bytes;
|
consumed = true;
|
||||||
consumed = true;
|
if (state->callback != nullptr) {
|
||||||
if (state->callback != nullptr) {
|
state->callback_jobs.push_back(
|
||||||
state->callback_jobs.push_back(
|
CallbackJob{state->callback, state->callback_context});
|
||||||
CallbackJob{state->callback, state->callback_context});
|
notify_worker = true;
|
||||||
notify_worker = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
++state->discarded_buffers;
|
|
||||||
discarded = true;
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
++state->discarded_buffers;
|
||||||
|
discarded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (consumed) {
|
if (consumed) {
|
||||||
@@ -374,14 +341,11 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
|||||||
void* const event_context;
|
void* const event_context;
|
||||||
Handle handle;
|
Handle handle;
|
||||||
pthread_t callback_thread{};
|
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::atomic<std::size_t> pending_count{0};
|
||||||
std::deque<CallbackJob> callback_jobs;
|
std::deque<CallbackJob> callback_jobs;
|
||||||
AndroidSimpleBufferQueueCallback callback = nullptr;
|
AndroidSimpleBufferQueueCallback callback = nullptr;
|
||||||
void* callback_context = nullptr;
|
void* callback_context = nullptr;
|
||||||
Status last_status;
|
Status last_status;
|
||||||
std::uint64_t next_id = 1;
|
|
||||||
std::uint64_t generation = 1;
|
std::uint64_t generation = 1;
|
||||||
std::atomic<std::uint64_t> processed_buffers{0};
|
std::atomic<std::uint64_t> processed_buffers{0};
|
||||||
std::uint64_t submitted_buffers = 0;
|
std::uint64_t submitted_buffers = 0;
|
||||||
@@ -547,7 +511,6 @@ void OpenSlSimpleBufferQueueAdapter::Shutdown() {
|
|||||||
}
|
}
|
||||||
state->shutdown_started = true;
|
state->shutdown_started = true;
|
||||||
state->stopping = true;
|
state->stopping = true;
|
||||||
++state->generation;
|
|
||||||
state->callback = nullptr;
|
state->callback = nullptr;
|
||||||
state->callback_context = nullptr;
|
state->callback_context = nullptr;
|
||||||
state->callback_jobs.clear();
|
state->callback_jobs.clear();
|
||||||
@@ -566,10 +529,6 @@ void OpenSlSimpleBufferQueueAdapter::Shutdown() {
|
|||||||
state->last_status = Status::Error(
|
state->last_status = Status::Error(
|
||||||
StatusCode::kPlatformError,
|
StatusCode::kPlatformError,
|
||||||
"audio sink did not release all borrowed OpenSL buffers");
|
"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->pending_count.store(0, std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
state->shutdown_complete = true;
|
state->shutdown_complete = true;
|
||||||
|
|||||||
@@ -130,14 +130,6 @@ std::uintptr_t FunctionAddress(Function function) {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
// ABI verified for Roblox 2.738.1397. Keep capture separate from the menu
|
|
||||||
// profile: relocating query methods alone does not establish a recording ABI.
|
|
||||||
namespace {
|
|
||||||
constexpr std::array<std::size_t, 6> kCaptureSlots{4, 13, 14, 15, 16, 17};
|
|
||||||
constexpr std::array<std::uintptr_t, 6> kCaptureRvas{
|
|
||||||
0x320c630, 0x320bcf6, 0x320d4b4, 0x320dd94, 0x320b844, 0x320e49a};
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
RobloxOutputDeviceBridge::RobloxOutputDeviceBridge() = default;
|
RobloxOutputDeviceBridge::RobloxOutputDeviceBridge() = default;
|
||||||
|
|
||||||
namespace internal {
|
namespace internal {
|
||||||
@@ -226,10 +218,6 @@ Status RobloxOutputDeviceBridge::Install(const compat::BuildProfile& profile) {
|
|||||||
"another Roblox output-device bridge owns the process");
|
"another Roblox output-device bridge owns the process");
|
||||||
}
|
}
|
||||||
profile_ = *profile.fmod_output_device_bridge;
|
profile_ = *profile.fmod_output_device_bridge;
|
||||||
capture_profile_supported_ =
|
|
||||||
profile.elf_build_id == "5f0704edd9064f566ee3d6df2bd2fabbcc709f03" &&
|
|
||||||
profile_.vtable_rva == 0x6cd3ce0 &&
|
|
||||||
profile_.vtable_layout_version == 2 && profile_.has_input_devices();
|
|
||||||
if (!linker::SetAndroidLibraryLoadObserver(&ObserveAndroidLibrary, this)) {
|
if (!linker::SetAndroidLibraryLoadObserver(&ObserveAndroidLibrary, this)) {
|
||||||
g_active_bridge.store(nullptr, std::memory_order_release);
|
g_active_bridge.store(nullptr, std::memory_order_release);
|
||||||
profile_ = {};
|
profile_ = {};
|
||||||
@@ -255,7 +243,10 @@ void RobloxOutputDeviceBridge::Shutdown() {
|
|||||||
" [audio-menu] failed to restore FmodAudioDevice "
|
" [audio-menu] failed to restore FmodAudioDevice "
|
||||||
"vtable protection\n");
|
"vtable protection\n");
|
||||||
}
|
}
|
||||||
g_native_capture.store(nullptr, std::memory_order_release);
|
std::atomic_store_explicit(&g_native_capture,
|
||||||
|
std::shared_ptr<NativeInputCapture>{},
|
||||||
|
std::memory_order_release);
|
||||||
|
if (capture_) capture_->Shutdown();
|
||||||
capture_.reset();
|
capture_.reset();
|
||||||
active_ = false;
|
active_ = false;
|
||||||
devices_.clear();
|
devices_.clear();
|
||||||
@@ -419,6 +410,7 @@ Status RobloxOutputDeviceBridge::Activate(std::uintptr_t image_base) {
|
|||||||
Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||||
const std::size_t kCurrentOutputVtableIndex = profile_.current_vtable_index();
|
const std::size_t kCurrentOutputVtableIndex = profile_.current_vtable_index();
|
||||||
const std::size_t kSelectOutputVtableIndex = profile_.select_vtable_index();
|
const std::size_t kSelectOutputVtableIndex = profile_.select_vtable_index();
|
||||||
|
const auto capture_indexes = profile_.capture_vtable_indexes();
|
||||||
if (profile_.vtable_rva >
|
if (profile_.vtable_rva >
|
||||||
std::numeric_limits<std::uintptr_t>::max() -
|
std::numeric_limits<std::uintptr_t>::max() -
|
||||||
(kSelectOutputVtableIndex + 1) * sizeof(std::uintptr_t)) {
|
(kSelectOutputVtableIndex + 1) * sizeof(std::uintptr_t)) {
|
||||||
@@ -461,26 +453,25 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
|||||||
"FmodAudioDevice string ABI contract validation failed");
|
"FmodAudioDevice string ABI contract validation failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (capture_profile_supported_) {
|
if (profile_.has_input_devices()) {
|
||||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) {
|
for (std::size_t i = 0; i < capture_indexes.size(); ++i) {
|
||||||
if (!IsExecutableImageRange(library_base_, kCaptureRvas[i], 1) ||
|
const auto method = vtable_[capture_indexes[i]];
|
||||||
vtable_[kCaptureSlots[i]] != library_base_ + kCaptureRvas[i])
|
if (method < library_base_ ||
|
||||||
|
!IsExecutableImageRange(library_base_, method - library_base_, 1) ||
|
||||||
|
std::find(profile_.input_method_rvas.begin(),
|
||||||
|
profile_.input_method_rvas.end(), method - library_base_) !=
|
||||||
|
profile_.input_method_rvas.end() ||
|
||||||
|
std::find(original_capture_methods_.begin(),
|
||||||
|
original_capture_methods_.begin() + i, method) !=
|
||||||
|
original_capture_methods_.begin() + i)
|
||||||
return FailedPrecondition("native input capture vtable ABI mismatch");
|
return FailedPrecondition("native input capture vtable ABI mismatch");
|
||||||
original_capture_methods_[i] = vtable_[kCaptureSlots[i]];
|
original_capture_methods_[i] = method;
|
||||||
}
|
}
|
||||||
if (!IsExecutableImageRange(library_base_, 0x3213154, 1) ||
|
capture_ = std::make_shared<NativeInputCapture>(
|
||||||
!IsExecutableImageRange(library_base_, 0x1dc4cd0, 1) ||
|
|
||||||
!IsRelroImageRange(library_base_, 0x6cd4550, 24))
|
|
||||||
return FailedPrecondition("native input capture sink ABI outside image");
|
|
||||||
capture_ = std::make_unique<NativeInputCapture>(
|
|
||||||
NativeInputCapture::SinkAbi{
|
NativeInputCapture::SinkAbi{
|
||||||
library_base_ + 0x6cd4550,
|
library_base_, &IsRelroImageRange, &IsExecutableImageRange,
|
||||||
reinterpret_cast<NativeInputCapture::DeliverPcm>(
|
|
||||||
library_base_ + 0x3213154),
|
|
||||||
reinterpret_cast<NativeInputCapture::Destroy>(
|
|
||||||
library_base_ + 0x1dc4cd0),
|
|
||||||
reinterpret_cast<NativeInputCapture::OriginalLatency>(
|
reinterpret_cast<NativeInputCapture::OriginalLatency>(
|
||||||
library_base_ + kCaptureRvas[1])},
|
original_capture_methods_[1])},
|
||||||
!input_devices_.empty());
|
!input_devices_.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +510,8 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
|||||||
__atomic_store_n(&vtable_[indexes[i]], methods[i], __ATOMIC_RELEASE);
|
__atomic_store_n(&vtable_[indexes[i]], methods[i], __ATOMIC_RELEASE);
|
||||||
}
|
}
|
||||||
if (capture_) {
|
if (capture_) {
|
||||||
g_native_capture.store(capture_.get(), std::memory_order_release);
|
std::atomic_store_explicit(&g_native_capture, capture_,
|
||||||
|
std::memory_order_release);
|
||||||
const std::array<std::uintptr_t, 6> methods{
|
const std::array<std::uintptr_t, 6> methods{
|
||||||
FunctionAddress(&NativeInputCapture::Format),
|
FunctionAddress(&NativeInputCapture::Format),
|
||||||
FunctionAddress(&NativeInputCapture::Latency),
|
FunctionAddress(&NativeInputCapture::Latency),
|
||||||
@@ -527,8 +519,8 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
|||||||
FunctionAddress(&NativeInputCapture::Stop),
|
FunctionAddress(&NativeInputCapture::Stop),
|
||||||
FunctionAddress(&NativeInputCapture::Poll),
|
FunctionAddress(&NativeInputCapture::Poll),
|
||||||
FunctionAddress(&NativeInputCapture::Recording)};
|
FunctionAddress(&NativeInputCapture::Recording)};
|
||||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
|
for (std::size_t i = 0; i < capture_indexes.size(); ++i)
|
||||||
__atomic_store_n(&vtable_[kCaptureSlots[i]], methods[i], __ATOMIC_RELEASE);
|
__atomic_store_n(&vtable_[capture_indexes[i]], methods[i], __ATOMIC_RELEASE);
|
||||||
}
|
}
|
||||||
if (!SetVtableWritable(vtable_, kSelectOutputVtableIndex, false)) {
|
if (!SetVtableWritable(vtable_, kSelectOutputVtableIndex, false)) {
|
||||||
__atomic_store_n(&vtable_[kOutputCountVtableIndex], original_methods_[0],
|
__atomic_store_n(&vtable_[kOutputCountVtableIndex], original_methods_[0],
|
||||||
@@ -546,10 +538,13 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
|||||||
__ATOMIC_RELEASE);
|
__ATOMIC_RELEASE);
|
||||||
}
|
}
|
||||||
if (capture_) {
|
if (capture_) {
|
||||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
|
for (std::size_t i = 0; i < capture_indexes.size(); ++i)
|
||||||
__atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i],
|
__atomic_store_n(&vtable_[capture_indexes[i]], original_capture_methods_[i],
|
||||||
__ATOMIC_RELEASE);
|
__ATOMIC_RELEASE);
|
||||||
g_native_capture.store(nullptr, std::memory_order_release);
|
std::atomic_store_explicit(&g_native_capture,
|
||||||
|
std::shared_ptr<NativeInputCapture>{},
|
||||||
|
std::memory_order_release);
|
||||||
|
capture_->Shutdown();
|
||||||
capture_.reset();
|
capture_.reset();
|
||||||
}
|
}
|
||||||
(void)SetVtableWritable(vtable_, kSelectOutputVtableIndex, false);
|
(void)SetVtableWritable(vtable_, kSelectOutputVtableIndex, false);
|
||||||
@@ -581,8 +576,9 @@ bool RobloxOutputDeviceBridge::RestoreVtableLocked() {
|
|||||||
__ATOMIC_RELEASE);
|
__ATOMIC_RELEASE);
|
||||||
}
|
}
|
||||||
if (capture_) {
|
if (capture_) {
|
||||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
|
const auto capture_indexes = profile_.capture_vtable_indexes();
|
||||||
__atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i],
|
for (std::size_t i = 0; i < capture_indexes.size(); ++i)
|
||||||
|
__atomic_store_n(&vtable_[capture_indexes[i]], original_capture_methods_[i],
|
||||||
__ATOMIC_RELEASE);
|
__ATOMIC_RELEASE);
|
||||||
}
|
}
|
||||||
return SetVtableWritable(vtable_, kSelectOutputVtableIndex, false);
|
return SetVtableWritable(vtable_, kSelectOutputVtableIndex, false);
|
||||||
|
|||||||
@@ -15,6 +15,49 @@ namespace {
|
|||||||
|
|
||||||
constexpr int kBionicAddressInfoFailure = 4;
|
constexpr int kBionicAddressInfoFailure = 4;
|
||||||
constexpr int kBionicAddressInfoMemory = 6;
|
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 = {
|
constexpr std::array<std::string_view, 3> kBlockedUploadSuffixes = {
|
||||||
"crashes.rbxinfra.com",
|
"crashes.rbxinfra.com",
|
||||||
@@ -63,7 +106,7 @@ int BionicAddressInfoError(int host_error) noexcept {
|
|||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
if (host_error == EAI_BADFLAGS) {
|
if (host_error == EAI_BADFLAGS) {
|
||||||
return 3;
|
return kBionicAddressInfoBadFlags;
|
||||||
}
|
}
|
||||||
if (host_error == EAI_FAIL) {
|
if (host_error == EAI_FAIL) {
|
||||||
return kBionicAddressInfoFailure;
|
return kBionicAddressInfoFailure;
|
||||||
@@ -102,7 +145,7 @@ int BionicAddressInfoError(int host_error) noexcept {
|
|||||||
addrinfo HostHints(const BionicAddressInfo* hints) noexcept {
|
addrinfo HostHints(const BionicAddressInfo* hints) noexcept {
|
||||||
addrinfo host_hints{};
|
addrinfo host_hints{};
|
||||||
if (hints != nullptr) {
|
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_family = hints->ai_family;
|
||||||
host_hints.ai_socktype = hints->ai_socktype;
|
host_hints.ai_socktype = hints->ai_socktype;
|
||||||
host_hints.ai_protocol = hints->ai_protocol;
|
host_hints.ai_protocol = hints->ai_protocol;
|
||||||
@@ -122,7 +165,7 @@ bool CopyHostAddressInfo(const addrinfo* host,
|
|||||||
BionicFreeAddressInfo(head);
|
BionicFreeAddressInfo(head);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
node->ai_flags = current->ai_flags;
|
node->ai_flags = BionicAddressInfoFlags(current->ai_flags);
|
||||||
node->ai_family = current->ai_family;
|
node->ai_family = current->ai_family;
|
||||||
node->ai_socktype = current->ai_socktype;
|
node->ai_socktype = current->ai_socktype;
|
||||||
node->ai_protocol = current->ai_protocol;
|
node->ai_protocol = current->ai_protocol;
|
||||||
@@ -187,6 +230,10 @@ int BionicGetAddressInfo(const char* node, const char* service,
|
|||||||
if (node != nullptr && IsBlockedCrashReportUploadHost(node)) {
|
if (node != nullptr && IsBlockedCrashReportUploadHost(node)) {
|
||||||
return kBionicAddressInfoNameNotFound;
|
return kBionicAddressInfoNameNotFound;
|
||||||
}
|
}
|
||||||
|
if (hints != nullptr &&
|
||||||
|
(hints->ai_flags & ~SupportedBionicAddressInfoFlags()) != 0) {
|
||||||
|
return kBionicAddressInfoBadFlags;
|
||||||
|
}
|
||||||
|
|
||||||
const addrinfo host_hints = HostHints(hints);
|
const addrinfo host_hints = HostHints(hints);
|
||||||
addrinfo* host_result = nullptr;
|
addrinfo* host_result = nullptr;
|
||||||
|
|||||||
@@ -150,6 +150,15 @@ Result ParseInHostCLocale(Parser parser) noexcept {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Highest errno in Bionic's message table (EHWPOISON). Numbers 41 and 58 are
|
||||||
|
// unassigned in the generic Linux errno list, so Bionic has no text for them.
|
||||||
|
constexpr int kBionicMaxErrno = 133;
|
||||||
|
|
||||||
|
constexpr bool HasBionicErrnoMessage(int error_number) noexcept {
|
||||||
|
return error_number >= 0 && error_number <= kBionicMaxErrno &&
|
||||||
|
error_number != 41 && error_number != 58;
|
||||||
|
}
|
||||||
|
|
||||||
template <typename Result>
|
template <typename Result>
|
||||||
const char* HostStrErrorResult(Result result, char* buffer) noexcept {
|
const char* HostStrErrorResult(Result result, char* buffer) noexcept {
|
||||||
if constexpr (std::is_integral_v<Result>) {
|
if constexpr (std::is_integral_v<Result>) {
|
||||||
@@ -318,10 +327,16 @@ int BionicStrError(int error_number, char* buffer,
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bionic's table ends at EHWPOISON. Unknown numbers get the same text on
|
||||||
|
// every host instead of whatever the host libc prints (musl has no
|
||||||
|
// "Unknown error N" form).
|
||||||
std::array<char, 256> host_buffer{};
|
std::array<char, 256> host_buffer{};
|
||||||
auto host_result =
|
const char* message = nullptr;
|
||||||
::strerror_r(error_number, host_buffer.data(), host_buffer.size());
|
if (HasBionicErrnoMessage(error_number)) {
|
||||||
const char* message = HostStrErrorResult(host_result, host_buffer.data());
|
auto host_result =
|
||||||
|
::strerror_r(error_number, host_buffer.data(), host_buffer.size());
|
||||||
|
message = HostStrErrorResult(host_result, host_buffer.data());
|
||||||
|
}
|
||||||
|
|
||||||
std::array<char, 64> unknown_buffer{};
|
std::array<char, 64> unknown_buffer{};
|
||||||
if (message == nullptr) {
|
if (message == nullptr) {
|
||||||
@@ -373,6 +388,10 @@ extern "C" void mocktail_bionic_arc4random_buf(void* buffer, size_t size) {
|
|||||||
mocktail::compat::BionicArc4RandomBuffer(buffer, size);
|
mocktail::compat::BionicArc4RandomBuffer(buffer, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extern "C" int mocktail_bionic_atoi(const char* text) {
|
||||||
|
return static_cast<int>(std::strtol(text, nullptr, 10));
|
||||||
|
}
|
||||||
|
|
||||||
extern "C" mocktail::compat::BionicMallinfoSnapshot mocktail_bionic_mallinfo() {
|
extern "C" mocktail::compat::BionicMallinfoSnapshot mocktail_bionic_mallinfo() {
|
||||||
return mocktail::compat::BionicMallinfo();
|
return mocktail::compat::BionicMallinfo();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ int CopySupportedThreadAttributes(const MocktailBionicPthreadAttr& source,
|
|||||||
if (RejectRealtimeScheduling(source.sched_policy)) {
|
if (RejectRealtimeScheduling(source.sched_policy)) {
|
||||||
return EPERM;
|
return EPERM;
|
||||||
}
|
}
|
||||||
if (source.sched_policy != SCHED_NORMAL) {
|
if (source.sched_policy != SCHED_OTHER) {
|
||||||
result = pthread_attr_setschedpolicy(destination, source.sched_policy);
|
result = pthread_attr_setschedpolicy(destination, source.sched_policy);
|
||||||
if (result != 0) {
|
if (result != 0) {
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -270,6 +270,42 @@ bool HostAbiProfile::ShouldInitializeNativeMimallocThreadAfterConstructor(
|
|||||||
index == native_mimalloc_thread_initializer_after_constructor;
|
index == native_mimalloc_thread_initializer_after_constructor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool HostAbiProfile::HasVerifiedConstructorPolicy(
|
||||||
|
size_t jni_singleton_constructor) const noexcept {
|
||||||
|
constexpr size_t kLeadingConstructors = 4;
|
||||||
|
constexpr size_t kWiringConstructors = 2;
|
||||||
|
if (!HasValidConstructorRanges() ||
|
||||||
|
!HasValidNativeMimallocConstructorRanges() ||
|
||||||
|
native_mimalloc_constructor_run_range_count != 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const size_t begin = native_mimalloc_constructor_run_ranges[0].begin;
|
||||||
|
const size_t checkpoint =
|
||||||
|
native_mimalloc_thread_initializer_after_constructor;
|
||||||
|
const ConstructorRange &tail =
|
||||||
|
constructor_run_ranges[constructor_run_range_count - 1];
|
||||||
|
if (init_array_count < begin + kLeadingConstructors ||
|
||||||
|
native_mimalloc_constructor_run_ranges[0].end_exclusive !=
|
||||||
|
init_array_count ||
|
||||||
|
checkpoint < begin || checkpoint >= begin + kLeadingConstructors ||
|
||||||
|
!AllowsConstructor(checkpoint) || jni_singleton_constructor < begin ||
|
||||||
|
jni_singleton_constructor >= begin + kLeadingConstructors ||
|
||||||
|
jni_singleton_constructor == checkpoint ||
|
||||||
|
!AllowsConstructor(jni_singleton_constructor) ||
|
||||||
|
ConstructorRangeBegin() < begin ||
|
||||||
|
tail.begin > begin + kLeadingConstructors ||
|
||||||
|
tail.end_exclusive != init_array_count) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
size_t excluded = 0;
|
||||||
|
for (size_t index = begin; index < begin + kLeadingConstructors; ++index) {
|
||||||
|
if (!AllowsConstructor(index)) {
|
||||||
|
++excluded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return excluded == kWiringConstructors;
|
||||||
|
}
|
||||||
|
|
||||||
bool HostAbiProfile::HasValidNativePreJniBootstrap() const noexcept {
|
bool HostAbiProfile::HasValidNativePreJniBootstrap() const noexcept {
|
||||||
return native_pre_jni_bootstrap.IsValid();
|
return native_pre_jni_bootstrap.IsValid();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -612,26 +612,26 @@ std::unique_ptr<OwnedHostAbiProfile> ParseProfile(const Json& root,
|
|||||||
*error = "external host ABI profile has inconsistent constructor policy";
|
*error = "external host ABI profile has inconsistent constructor policy";
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
// The verified constructor layout is the same on both guest ABIs: a single
|
// The verified constructor layout is the same on both guest ABIs. Payloads
|
||||||
// process-load constructor at the thread-initializer checkpoint, two wiring
|
// differ in the order of the four leading constructors, and the guest ABIs
|
||||||
// entries excluded under host bridges, and one contiguous native-mimalloc
|
// differ in the index the native replay starts at.
|
||||||
// replay from the checkpoint to the end of .init_array. Only the checkpoint
|
// derivation_code_rvas holds three initializer anchors, then the anchors of
|
||||||
// index itself differs between payloads.
|
// the four leading constructors in .init_array order.
|
||||||
const std::size_t checkpoint =
|
constexpr size_t kJniSingletonAnchor = 2;
|
||||||
owned->profile.native_mimalloc_thread_initializer_after_constructor;
|
constexpr size_t kFirstConstructorAnchor = 3;
|
||||||
if (owned->profile.init_array_count <= checkpoint + 3 ||
|
constexpr size_t kConstructorAnchors = 4;
|
||||||
owned->profile.constructor_run_range_count != 2 ||
|
size_t jni_singleton_constructor = owned->profile.init_array_count;
|
||||||
owned->profile.constructor_run_ranges[0].begin != checkpoint ||
|
for (size_t anchor = 0; anchor < kConstructorAnchors; ++anchor) {
|
||||||
owned->profile.constructor_run_ranges[0].end_exclusive != checkpoint + 1 ||
|
if (owned->derivation_code_rvas[kFirstConstructorAnchor + anchor] ==
|
||||||
owned->profile.constructor_run_ranges[1].begin != checkpoint + 3 ||
|
owned->derivation_code_rvas[kJniSingletonAnchor]) {
|
||||||
owned->profile.constructor_run_ranges[1].end_exclusive !=
|
jni_singleton_constructor =
|
||||||
owned->profile.init_array_count ||
|
owned->profile.NativeMimallocConstructorRangeBegin() + anchor;
|
||||||
owned->profile.native_mimalloc_constructor_run_range_count != 1 ||
|
break;
|
||||||
owned->profile.native_mimalloc_constructor_run_ranges[0].begin !=
|
}
|
||||||
checkpoint ||
|
}
|
||||||
owned->profile.native_mimalloc_constructor_run_ranges[0].end_exclusive !=
|
if (!owned->profile.HasVerifiedConstructorPolicy(jni_singleton_constructor) ||
|
||||||
owned->profile.init_array_count ||
|
(kGuestElfMachine == EM_X86_64 &&
|
||||||
(kGuestElfMachine == EM_X86_64 && checkpoint != 2)) {
|
owned->profile.NativeMimallocConstructorRangeBegin() != 2)) {
|
||||||
*error =
|
*error =
|
||||||
"external host ABI profile changed the verified constructor policy";
|
"external host ABI profile changed the verified constructor policy";
|
||||||
return nullptr;
|
return nullptr;
|
||||||
|
|||||||
@@ -67,9 +67,8 @@ bool HasSymbols(const SharedLibrary& library,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
BackendCapability CheckAngleLibraries(const AngleProbeOptions& options,
|
||||||
|
bool initialize_display) {
|
||||||
BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
|
|
||||||
if (options.egl_library_path.empty() || options.gles_library_path.empty()) {
|
if (options.egl_library_path.empty() || options.gles_library_path.empty()) {
|
||||||
return Unavailable(
|
return Unavailable(
|
||||||
"pinned ANGLE EGL and GLES library paths are both required");
|
"pinned ANGLE EGL and GLES library paths are both required");
|
||||||
@@ -104,6 +103,20 @@ BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
|
|||||||
missing);
|
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 =
|
auto get_proc_address =
|
||||||
reinterpret_cast<PFNEGLGETPROCADDRESSPROC>(egl.Find("eglGetProcAddress"));
|
reinterpret_cast<PFNEGLGETPROCADDRESSPROC>(egl.Find("eglGetProcAddress"));
|
||||||
auto get_platform_display = reinterpret_cast<PFNEGLGETPLATFORMDISPLAYEXTPROC>(
|
auto get_platform_display = reinterpret_cast<PFNEGLGETPLATFORMDISPLAYEXTPROC>(
|
||||||
@@ -121,9 +134,6 @@ BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
|
|||||||
reinterpret_cast<PFNEGLINITIALIZEPROC>(egl.Find("eglInitialize"));
|
reinterpret_cast<PFNEGLINITIALIZEPROC>(egl.Find("eglInitialize"));
|
||||||
auto terminate =
|
auto terminate =
|
||||||
reinterpret_cast<PFNEGLTERMINATEPROC>(egl.Find("eglTerminate"));
|
reinterpret_cast<PFNEGLTERMINATEPROC>(egl.Find("eglTerminate"));
|
||||||
auto query_string =
|
|
||||||
reinterpret_cast<PFNEGLQUERYSTRINGPROC>(egl.Find("eglQueryString"));
|
|
||||||
|
|
||||||
const EGLint device_type =
|
const EGLint device_type =
|
||||||
options.allow_software_device
|
options.allow_software_device
|
||||||
? EGL_PLATFORM_ANGLE_DEVICE_TYPE_SWIFTSHADER_ANGLE
|
? EGL_PLATFORM_ANGLE_DEVICE_TYPE_SWIFTSHADER_ANGLE
|
||||||
@@ -173,5 +183,15 @@ BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
|
|||||||
std::move(detail)};
|
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 graphics
|
||||||
} // namespace mocktail
|
} // namespace mocktail
|
||||||
@@ -2000,7 +2000,11 @@ VKAPI_ATTR VkResult VKAPI_CALL vkCreateSwapchainKHR(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const VkResult result =
|
const VkResult result =
|
||||||
host_create(device, &host_info, allocator, swapchain);
|
mocktail::graphics::CreateSwapchainWithPresentModeFallback(
|
||||||
|
host_info, create_info->presentMode,
|
||||||
|
[&](const VkSwapchainCreateInfoKHR& attempt) {
|
||||||
|
return host_create(device, &attempt, allocator, swapchain);
|
||||||
|
});
|
||||||
if (result != VK_SUCCESS) {
|
if (result != VK_SUCCESS) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
+266
-86
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <cstring>
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
|
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
|
||||||
|
#include <immintrin.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <condition_variable>
|
#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 Expand6(int value) { return (value << 2) | (value >> 4); }
|
||||||
int Expand7(int value) { return (value << 1) | (value >> 6); }
|
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).
|
// 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,
|
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};
|
static constexpr int kDelta[8] = {0, 1, 2, 3, -4, -3, -2, -1};
|
||||||
const bool diff_or_opaque = (b[3] & 2) != 0;
|
const bool diff_or_opaque = (b[3] & 2) != 0;
|
||||||
const bool opaque = !punchthrough || diff_or_opaque;
|
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) {
|
auto index_of = [msb, lsb](int texel) {
|
||||||
return (((msb >> texel) & 1) << 1) | ((lsb >> texel) & 1);
|
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 r_sum = (b[0] >> 3) + kDelta[b[0] & 7];
|
||||||
const int g_sum = (b[1] >> 3) + kDelta[b[1] & 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;
|
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) {
|
for (int texel = 0; texel < 16; ++texel) {
|
||||||
const int index = index_of(texel);
|
const int index = index_of(texel);
|
||||||
if (!opaque && index == 2) {
|
out32[texel] = (!opaque && index == 2) ? 0 : paint32[index];
|
||||||
write(texel, 0, 0, 0, 0);
|
|
||||||
} else {
|
|
||||||
write(texel, paint[index][0], paint[index][1], paint[index][2], 255);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -124,9 +158,10 @@ void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
|
|||||||
const int bv = Expand6(b[7] & 63);
|
const int bv = Expand6(b[7] & 63);
|
||||||
for (int x = 0; x < 4; ++x) {
|
for (int x = 0; x < 4; ++x) {
|
||||||
for (int y = 0; y < 4; ++y) {
|
for (int y = 0; y < 4; ++y) {
|
||||||
write(x * 4 + y, (x * (rh - ro) + y * (rv - ro) + 4 * ro + 2) >> 2,
|
out32[x * 4 + y] = MakeRgba32(
|
||||||
(x * (gh - go) + y * (gv - go) + 4 * go + 2) >> 2,
|
(x * (rh - ro) + y * (rv - ro) + 4 * ro + 2) >> 2,
|
||||||
(x * (bh - bo) + y * (bv - bo) + 4 * bo + 2) >> 2, 255);
|
(x * (gh - go) + y * (gv - go) + 4 * go + 2) >> 2,
|
||||||
|
(x * (bh - bo) + y * (bv - bo) + 4 * bo + 2) >> 2, 255);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
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 int tables[2] = {b[3] >> 5, (b[3] >> 2) & 7};
|
||||||
const bool flipped = (b[3] & 1) != 0;
|
const bool flipped = (b[3] & 1) != 0;
|
||||||
for (int x = 0; x < 4; ++x) {
|
|
||||||
for (int y = 0; y < 4; ++y) {
|
// Basis Universal palette precalculation: 8 colors per block instead of 48 additions/clamps.
|
||||||
const int texel = x * 4 + y;
|
std::uint32_t palette[2][4];
|
||||||
const int index = index_of(texel);
|
for (int sub = 0; sub < 2; ++sub) {
|
||||||
if (!opaque && index == 2) {
|
for (int idx = 0; idx < 4; ++idx) {
|
||||||
write(texel, 0, 0, 0, 0);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const int sub = flipped ? (y >= 2 ? 1 : 0) : (x >= 2 ? 1 : 0);
|
|
||||||
const int modifier =
|
const int modifier =
|
||||||
(!opaque && index == 0) ? 0 : kEtc1Modifiers[tables[sub]][index];
|
(!opaque && idx == 0) ? 0 : kEtc1Modifiers[tables[sub]][idx];
|
||||||
write(texel, base[sub][0] + modifier, base[sub][1] + modifier,
|
palette[sub][idx] = MakeRgba32(base[sub][0] + modifier,
|
||||||
base[sub][2] + modifier, 255);
|
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).
|
// 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;
|
std::uint64_t bits = 0;
|
||||||
for (int i = 2; i < 8; ++i) {
|
for (int i = 2; i < 8; ++i) {
|
||||||
bits = (bits << 8) | b[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);
|
return static_cast<int>((bits >> (45 - texel * 3)) & 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
int EacModifier(const std::uint8_t* b, int texel) {
|
inline std::uint16_t DecodeR11Modifier(const std::uint8_t* b, int multiplier,
|
||||||
return kEacModifiers[b[1] & 15][EacIndex(b, texel)];
|
const int* modifier_table,
|
||||||
}
|
int modifier_idx, bool is_signed) {
|
||||||
|
const int modifier = modifier_table[modifier_idx];
|
||||||
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);
|
|
||||||
const int scaled = multiplier != 0 ? modifier * multiplier * 8 : modifier;
|
const int scaled = multiplier != 0 ? modifier * multiplier * 8 : modifier;
|
||||||
if (!is_signed) {
|
if (!is_signed) {
|
||||||
const int value = std::clamp(b[0] * 8 + 4 + scaled, 0, 2047);
|
const int value = std::clamp(b[0] * 8 + 4 + scaled, 0, 2047);
|
||||||
@@ -308,12 +412,18 @@ class DecodePool {
|
|||||||
run_ = run;
|
run_ = run;
|
||||||
context_ = context;
|
context_ = context;
|
||||||
unclaimed_ = helpers;
|
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);
|
run(context);
|
||||||
std::unique_lock<std::mutex> lock(mutex_);
|
if (running_.load(std::memory_order_acquire) > 0) {
|
||||||
done_.wait(lock, [this] { return running_ == 0; });
|
std::unique_lock<std::mutex> lock(done_mutex_);
|
||||||
|
done_.wait(lock, [this] {
|
||||||
|
return running_.load(std::memory_order_acquire) == 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
run_ = nullptr;
|
run_ = nullptr;
|
||||||
context_ = nullptr;
|
context_ = nullptr;
|
||||||
return true;
|
return true;
|
||||||
@@ -343,29 +453,33 @@ class DecodePool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void WorkerLoop() {
|
void WorkerLoop() {
|
||||||
std::unique_lock<std::mutex> lock(mutex_);
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
ready_.wait(lock, [this] { return unclaimed_ > 0; });
|
void (*run)(void*) = nullptr;
|
||||||
--unclaimed_;
|
void* context = nullptr;
|
||||||
void (*run)(void*) = run_;
|
{
|
||||||
void* context = context_;
|
std::unique_lock<std::mutex> lock(mutex_);
|
||||||
lock.unlock();
|
ready_.wait(lock, [this] { return unclaimed_ > 0; });
|
||||||
|
--unclaimed_;
|
||||||
|
run = run_;
|
||||||
|
context = context_;
|
||||||
|
}
|
||||||
run(context);
|
run(context);
|
||||||
lock.lock();
|
if (running_.fetch_sub(1, std::memory_order_acq_rel) == 1) {
|
||||||
if (--running_ == 0) {
|
std::lock_guard<std::mutex> lock(done_mutex_);
|
||||||
done_.notify_all();
|
done_.notify_one();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::mutex batch_mutex_;
|
std::mutex batch_mutex_;
|
||||||
std::mutex mutex_;
|
std::mutex mutex_;
|
||||||
|
std::mutex done_mutex_;
|
||||||
std::condition_variable ready_;
|
std::condition_variable ready_;
|
||||||
std::condition_variable done_;
|
std::condition_variable done_;
|
||||||
void (*run_)(void*) = nullptr;
|
void (*run_)(void*) = nullptr;
|
||||||
void* context_ = nullptr;
|
void* context_ = nullptr;
|
||||||
std::size_t unclaimed_ = 0;
|
std::size_t unclaimed_ = 0;
|
||||||
std::size_t running_ = 0;
|
std::atomic<std::size_t> running_{0};
|
||||||
std::size_t threads_ = 0;
|
std::size_t threads_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -378,6 +492,16 @@ void DecodeEtcJobs(EtcDecodeJob* jobs, std::size_t count,
|
|||||||
}
|
}
|
||||||
DecodeBandQueue queue;
|
DecodeBandQueue queue;
|
||||||
std::vector<DecodeBand>& bands = queue.bands;
|
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;
|
std::uint64_t total_blocks = 0;
|
||||||
for (std::size_t index = 0; index < count; ++index) {
|
for (std::size_t index = 0; index < count; ++index) {
|
||||||
EtcDecodeJob& job = jobs[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 texel_bytes = EtcDecodedTexelBytes(format);
|
||||||
const std::size_t block_bytes = EtcBlockBytes(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 =
|
const std::uint64_t end_block_row =
|
||||||
static_cast<std::uint64_t>(first_block_row) + block_row_count;
|
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 by = first_block_row; by < end_block_row; ++by) {
|
||||||
for (std::uint64_t bx = 0; bx < blocks_wide; ++bx) {
|
for (std::uint64_t bx = 0; bx < blocks_wide; ++bx) {
|
||||||
const std::uint8_t* block =
|
const std::uint8_t* block =
|
||||||
source + (by * blocks_wide + bx) * block_bytes;
|
source + (by * blocks_wide + bx) * block_bytes;
|
||||||
if (format == EtcFormat::kEtc2Rgb8 || format == EtcFormat::kEtc2Rgb8A1) {
|
const bool interior = (by * 4 + 4 <= height) && (bx * 4 + 4 <= width);
|
||||||
DecodeRgbBlock(block, format == EtcFormat::kEtc2Rgb8A1, &rgba);
|
|
||||||
} else if (format == EtcFormat::kEtc2Rgba8) {
|
if (is_rgba_family) {
|
||||||
DecodeRgbBlock(block + 8, false, &rgba);
|
if (format == EtcFormat::kEtc2Rgb8 || format == EtcFormat::kEtc2Rgb8A1) {
|
||||||
}
|
DecodeRgbBlock(block, format == EtcFormat::kEtc2Rgb8A1, cols);
|
||||||
for (std::uint32_t y = 0; y < 4 && by * 4 + y < height; ++y) {
|
} else {
|
||||||
for (std::uint32_t x = 0; x < 4 && bx * 4 + x < width; ++x) {
|
DecodeRgbBlock(block + 8, false, cols);
|
||||||
const int texel = static_cast<int>(x * 4 + y);
|
const std::uint64_t alpha_bits = ReadEacBits(block);
|
||||||
std::uint8_t* out =
|
const int alpha_base = block[0];
|
||||||
destination + ((by * 4 + y) * width + bx * 4 + x) * texel_bytes;
|
const int alpha_multiplier = block[1] >> 4;
|
||||||
switch (format) {
|
const auto& alpha_table = kEacModifiers[block[1] & 15];
|
||||||
case EtcFormat::kEtc2Rgb8:
|
std::uint8_t alpha_palette[8];
|
||||||
case EtcFormat::kEtc2Rgb8A1:
|
for (int i = 0; i < 8; ++i) {
|
||||||
case EtcFormat::kEtc2Rgba8:
|
alpha_palette[i] =
|
||||||
std::copy_n(rgba.data() + texel * 4, 4, out);
|
Clamp255(alpha_base + alpha_table[i] * alpha_multiplier);
|
||||||
if (format == EtcFormat::kEtc2Rgba8) {
|
}
|
||||||
const int alpha =
|
for (int texel = 0; texel < 16; ++texel) {
|
||||||
block[0] + EacModifier(block, texel) * (block[1] >> 4);
|
cols[texel] =
|
||||||
out[3] = Clamp255(alpha);
|
(cols[texel] & 0x00FFFFFF) |
|
||||||
}
|
(static_cast<std::uint32_t>(
|
||||||
break;
|
alpha_palette[EacIndexFromBits(alpha_bits, texel)])
|
||||||
case EtcFormat::kEacR11:
|
<< 24);
|
||||||
case EtcFormat::kEacR11Signed:
|
}
|
||||||
case EtcFormat::kEacRg11:
|
}
|
||||||
case EtcFormat::kEacRg11Signed: {
|
|
||||||
const bool is_signed = format == EtcFormat::kEacR11Signed ||
|
if (interior) {
|
||||||
format == EtcFormat::kEacRg11Signed;
|
std::uint32_t* dst_row = reinterpret_cast<std::uint32_t*>(
|
||||||
const int channels = texel_bytes / 2;
|
destination + (by * 4 * width + bx * 4) * 4);
|
||||||
for (int channel = 0; channel < channels; ++channel) {
|
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
|
||||||
const std::uint16_t value =
|
__m128 r0 = _mm_load_ps(reinterpret_cast<const float*>(cols));
|
||||||
DecodeR11(block + channel * 8, texel, is_signed);
|
__m128 r1 = _mm_load_ps(reinterpret_cast<const float*>(cols + 4));
|
||||||
out[channel * 2] = static_cast<std::uint8_t>(value);
|
__m128 r2 = _mm_load_ps(reinterpret_cast<const float*>(cols + 8));
|
||||||
out[channel * 2 + 1] = static_cast<std::uint8_t>(value >> 8);
|
__m128 r3 = _mm_load_ps(reinterpret_cast<const float*>(cols + 12));
|
||||||
}
|
_MM_TRANSPOSE4_PS(r0, r1, r2, r3);
|
||||||
break;
|
_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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,20 +72,33 @@ void ResampleRgba(const RgbaImage& source, std::uint32_t width,
|
|||||||
static_cast<std::size_t>(source.width) * source.height * 4) {
|
static_cast<std::size_t>(source.width) * source.height * 4) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ResampleRgba(source.pixels.data(), source.width, source.height, width,
|
ResampleRgba(source.pixels.data(), source.width, source.height,
|
||||||
height, destination);
|
static_cast<std::size_t>(source.width) * 4, width, height,
|
||||||
|
destination);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_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 source_height, std::uint32_t width,
|
||||||
std::uint32_t height, std::uint8_t* destination) {
|
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 ||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// Source box of destination column x is [columns[2x], columns[2x + 1]).
|
// Source box of destination column x is [columns[2x], columns[2x + 1]).
|
||||||
thread_local std::vector<std::uint32_t> columns;
|
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;
|
bool one_texel_columns = true;
|
||||||
for (std::uint32_t x = 0; x < width; ++x) {
|
for (std::uint32_t x = 0; x < width; ++x) {
|
||||||
const std::uint32_t x0 = static_cast<std::uint32_t>(
|
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_y0 = y0;
|
||||||
previous_y1 = y1;
|
previous_y1 = y1;
|
||||||
if (one_texel_columns && y1 == y0 + 1) {
|
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) {
|
for (std::uint32_t x = 0; x < width; ++x) {
|
||||||
std::memcpy(out_row + static_cast<std::size_t>(x) * 4,
|
std::uint32_t pixel;
|
||||||
source_row + columns[2 * static_cast<std::size_t>(x)] * 4,
|
std::memcpy(&pixel, source_row + columns[2 * static_cast<std::size_t>(x)] * 4, 4);
|
||||||
4);
|
std::memcpy(out_row + static_cast<std::size_t>(x) * 4, &pixel, 4);
|
||||||
}
|
}
|
||||||
continue;
|
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];
|
const std::uint64_t x1 = columns[2 * static_cast<std::size_t>(x) + 1];
|
||||||
std::uint64_t sum[4] = {0, 0, 0, 0};
|
std::uint64_t sum[4] = {0, 0, 0, 0};
|
||||||
for (std::uint64_t sy = y0; sy < y1; ++sy) {
|
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) {
|
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) {
|
for (int channel = 0; channel < 4; ++channel) {
|
||||||
sum[channel] += pixel[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);
|
const std::uint64_t count = (y1 - y0) * (x1 - x0);
|
||||||
std::uint8_t* out = destination + (static_cast<std::size_t>(y) * width + x) * 4;
|
std::uint8_t* out = destination + (static_cast<std::size_t>(y) * width + x) * 4;
|
||||||
for (int channel = 0; channel < 4; ++channel) {
|
if (count == 1) {
|
||||||
out[channel] =
|
for (int channel = 0; channel < 4; ++channel) {
|
||||||
static_cast<std::uint8_t>((sum[channel] + count / 2) / count);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#if defined(__x86_64__)
|
||||||
|
#include <immintrin.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace mocktail::graphics {
|
namespace mocktail::graphics {
|
||||||
|
|
||||||
bool LookupEmulatedEtc2Format(VkFormat format, Etc2EmulatedFormat* out) {
|
bool LookupEmulatedEtc2Format(VkFormat format, Etc2EmulatedFormat* out) {
|
||||||
@@ -398,6 +402,45 @@ bool CreateStagingBlock(const HostDevice& dev, VkDeviceSize size,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if defined(__x86_64__)
|
||||||
|
__attribute__((target("sse4.1")))
|
||||||
|
inline void CopyHostVisibleMemory(std::uint8_t* dst, const std::uint8_t* src,
|
||||||
|
std::size_t bytes) {
|
||||||
|
if ((reinterpret_cast<std::uintptr_t>(src) & 15) != 0 || bytes < 16) {
|
||||||
|
std::memcpy(dst, src, bytes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::size_t i = 0;
|
||||||
|
for (; i + 64 <= bytes; i += 64) {
|
||||||
|
__m128i c0 = _mm_stream_load_si128(
|
||||||
|
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i)));
|
||||||
|
__m128i c1 = _mm_stream_load_si128(
|
||||||
|
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i + 16)));
|
||||||
|
__m128i c2 = _mm_stream_load_si128(
|
||||||
|
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i + 32)));
|
||||||
|
__m128i c3 = _mm_stream_load_si128(
|
||||||
|
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i + 48)));
|
||||||
|
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i), c0);
|
||||||
|
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i + 16), c1);
|
||||||
|
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i + 32), c2);
|
||||||
|
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i + 48), c3);
|
||||||
|
}
|
||||||
|
for (; i + 16 <= bytes; i += 16) {
|
||||||
|
__m128i c = _mm_stream_load_si128(
|
||||||
|
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i)));
|
||||||
|
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i), c);
|
||||||
|
}
|
||||||
|
if (i < bytes) {
|
||||||
|
std::memcpy(dst + i, src + i, bytes - i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
inline void CopyHostVisibleMemory(std::uint8_t* dst, const std::uint8_t* src,
|
||||||
|
std::size_t bytes) {
|
||||||
|
std::memcpy(dst, src, bytes);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Packs each upload's compressed bytes into `destination`, dropping the row
|
// Packs each upload's compressed bytes into `destination`, dropping the row
|
||||||
// and layer padding the application may have asked for. Sources are parallel
|
// and layer padding the application may have asked for. Sources are parallel
|
||||||
// to `uploads`.
|
// to `uploads`.
|
||||||
@@ -411,17 +454,18 @@ void GatherCompressed(const std::vector<PendingUpload>& uploads,
|
|||||||
std::uint8_t* target = destination + offset;
|
std::uint8_t* target = destination + offset;
|
||||||
const VkDeviceSize compressed_layer = upload.compressed / upload.layers;
|
const VkDeviceSize compressed_layer = upload.compressed / upload.layers;
|
||||||
if (upload.source_span == upload.compressed) {
|
if (upload.source_span == upload.compressed) {
|
||||||
std::memcpy(target, source, upload.compressed);
|
CopyHostVisibleMemory(target, source, upload.compressed);
|
||||||
} else {
|
} else {
|
||||||
const VkDeviceSize block_rows =
|
const VkDeviceSize block_rows =
|
||||||
(static_cast<VkDeviceSize>(upload.height) + 3) / 4;
|
(static_cast<VkDeviceSize>(upload.height) + 3) / 4;
|
||||||
const VkDeviceSize packed_row = compressed_layer / block_rows;
|
const VkDeviceSize packed_row = compressed_layer / block_rows;
|
||||||
for (std::uint32_t layer = 0; layer < upload.layers; ++layer) {
|
for (std::uint32_t layer = 0; layer < upload.layers; ++layer) {
|
||||||
for (VkDeviceSize row = 0; row < block_rows; ++row) {
|
for (VkDeviceSize row = 0; row < block_rows; ++row) {
|
||||||
std::memcpy(target + layer * compressed_layer + row * packed_row,
|
CopyHostVisibleMemory(
|
||||||
source + layer * upload.source_layer_stride +
|
target + layer * compressed_layer + row * packed_row,
|
||||||
row * upload.source_row_stride,
|
source + layer * upload.source_layer_stride +
|
||||||
packed_row);
|
row * upload.source_row_stride,
|
||||||
|
packed_row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -710,21 +754,11 @@ struct VulkanEtc2Emulation::State {
|
|||||||
upload.target);
|
upload.target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const std::uint8_t* source = decoded;
|
const std::size_t source_stride =
|
||||||
std::vector<std::uint8_t> cropped;
|
static_cast<std::size_t>(upload.width) * 4;
|
||||||
if (upload.source_width != upload.width ||
|
ResampleRgba(decoded, upload.source_width, upload.source_height,
|
||||||
upload.source_height != upload.height) {
|
source_stride, upload.target_width, upload.target_height,
|
||||||
const std::size_t row = static_cast<std::size_t>(upload.source_width) * 4;
|
upload.target);
|
||||||
cropped.resize(row * upload.source_height);
|
|
||||||
for (std::uint32_t y = 0; y < upload.source_height; ++y) {
|
|
||||||
std::memcpy(cropped.data() + y * row,
|
|
||||||
decoded + static_cast<std::size_t>(y) * upload.width * 4,
|
|
||||||
row);
|
|
||||||
}
|
|
||||||
source = cropped.data();
|
|
||||||
}
|
|
||||||
ResampleRgba(source, upload.source_width, upload.source_height,
|
|
||||||
upload.target_width, upload.target_height, upload.target);
|
|
||||||
} else {
|
} else {
|
||||||
std::memcpy(upload.target, decoded, upload.decoded);
|
std::memcpy(upload.target, decoded, upload.decoded);
|
||||||
}
|
}
|
||||||
@@ -1700,9 +1734,7 @@ Etc2SubmitWait VulkanEtc2Emulation::PrepareSubmit(
|
|||||||
collect(command_buffers[index]);
|
collect(command_buffers[index]);
|
||||||
const auto record = state_->commands.find(command_buffers[index]);
|
const auto record = state_->commands.find(command_buffers[index]);
|
||||||
if (record != state_->commands.end()) {
|
if (record != state_->commands.end()) {
|
||||||
const std::vector<VkCommandBuffer> secondaries =
|
for (VkCommandBuffer secondary : record->second.secondaries) {
|
||||||
record->second.secondaries;
|
|
||||||
for (VkCommandBuffer secondary : secondaries) {
|
|
||||||
collect(secondary);
|
collect(secondary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -749,8 +749,10 @@ void VulkanTextOverlayCompositor::DestroyDevice(VkDevice device) {
|
|||||||
if (iterator == impl_->devices.end()) {
|
if (iterator == impl_->devices.end()) {
|
||||||
return;
|
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);
|
impl_->devices.erase(iterator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#include "jni_method_names.h"
|
||||||
|
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace jnivm::internal {
|
||||||
|
|
||||||
|
JniMethod LookupJniMethod(std::string_view name) {
|
||||||
|
static const auto* const kMethods =
|
||||||
|
new std::unordered_map<std::string_view, JniMethod>{
|
||||||
|
#define MOCKTAIL_JNI_METHOD_ENTRY(id, text) {text, JniMethod::id},
|
||||||
|
MOCKTAIL_JNI_METHOD_NAMES(MOCKTAIL_JNI_METHOD_ENTRY)
|
||||||
|
#undef MOCKTAIL_JNI_METHOD_ENTRY
|
||||||
|
};
|
||||||
|
const auto it = kMethods->find(name);
|
||||||
|
return it == kMethods->end() ? JniMethod::kUnknown : it->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace jnivm::internal
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
#ifndef MOCKTAIL_JNIVM_JNI_METHOD_NAMES_H_
|
||||||
|
#define MOCKTAIL_JNIVM_JNI_METHOD_NAMES_H_
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace jnivm::internal {
|
||||||
|
|
||||||
|
// Every Java method name the JNI dispatch matches on. GetMethodID and
|
||||||
|
// GetStaticMethodID resolve a name to its entry once, so dispatch compares
|
||||||
|
// enum values. A name that is not listed resolves to JniMethod::kUnknown.
|
||||||
|
#define MOCKTAIL_JNI_METHOD_NAMES(X) \
|
||||||
|
X(kA, "a") \
|
||||||
|
X(kApply, "apply") \
|
||||||
|
X(kAttachObserver, "attachObserver") \
|
||||||
|
X(kBootstrapTheApp, "bootstrapTheApp") \
|
||||||
|
X(kCheckInit, "checkInit") \
|
||||||
|
X(kClose, "close") \
|
||||||
|
X(kCommit, "commit") \
|
||||||
|
X(kConnect, "connect") \
|
||||||
|
X(kConstructor, "<init>") \
|
||||||
|
X(kContains, "contains") \
|
||||||
|
X(kCurrentActivity, "currentActivity") \
|
||||||
|
X(kCurrentTimeMillis, "currentTimeMillis") \
|
||||||
|
X(kD, "d") \
|
||||||
|
X(kDeleteCurrentUserValues, "deleteCurrentUserValues") \
|
||||||
|
X(kDeleteSecureValue, "deleteSecureValue") \
|
||||||
|
X(kDeleteUserValues, "deleteUserValues") \
|
||||||
|
X(kDetachObserver, "detachObserver") \
|
||||||
|
X(kDispose, "dispose") \
|
||||||
|
X(kDoSubscribeProtocolMethodResponseRaw, "doSubscribeProtocolMethodResponseRaw") \
|
||||||
|
X(kEdit, "edit") \
|
||||||
|
X(kEnableBuiltInAEC, "enableBuiltInAEC") \
|
||||||
|
X(kEnableBuiltInAGC, "enableBuiltInAGC") \
|
||||||
|
X(kEnableBuiltInNS, "enableBuiltInNS") \
|
||||||
|
X(kF, "f") \
|
||||||
|
X(kFindClass, "findClass") \
|
||||||
|
X(kForName, "forName") \
|
||||||
|
X(kG, "g") \
|
||||||
|
X(kGameActivityHideKeyboard, "gameActivity_hideKeyboard") \
|
||||||
|
X(kGameActivityOnAppReady, "gameActivity_onAppReady") \
|
||||||
|
X(kGameActivityOnDidLogInReceived, "gameActivity_onDidLogInReceived") \
|
||||||
|
X(kGameActivityOnDidLogOutReceived, "gameActivity_onDidLogOutReceived") \
|
||||||
|
X(kGameActivityOnDidSignUp, "gameActivity_onDidSignUp") \
|
||||||
|
X(kGameActivityOnDidSwitchAccountReceived, "gameActivity_onDidSwitchAccountReceived") \
|
||||||
|
X(kGameActivityOnEngineInitialized, "gameActivity_onEngineInitialized") \
|
||||||
|
X(kGameActivityOnExperienceStart, "gameActivity_onExperienceStart") \
|
||||||
|
X(kGameActivityOnExperienceStop, "gameActivity_onExperienceStop") \
|
||||||
|
X(kGameActivityOnFlagsFailed, "gameActivity_onFlagsFailed") \
|
||||||
|
X(kGameActivityOnFlagsLoaded, "gameActivity_onFlagsLoaded") \
|
||||||
|
X(kGameActivityOnGameLoaded, "gameActivity_onGameLoaded") \
|
||||||
|
X(kGameActivityOnLuaAppDidReturn, "gameActivity_onLuaAppDidReturn") \
|
||||||
|
X(kGameActivityOnLuaTextBoxChanged, "gameActivity_onLuaTextBoxChanged") \
|
||||||
|
X(kGameActivityOnLuaTextBoxPropertyChanged, "gameActivity_onLuaTextBoxPropertyChanged") \
|
||||||
|
X(kGameActivityOnMotionEventListening, "gameActivity_onMotionEventListening") \
|
||||||
|
X(kGameActivityOnRestartLuaApp, "gameActivity_onRestartLuaApp") \
|
||||||
|
X(kGameActivityOnScanQrCode, "gameActivity_onScanQrCode") \
|
||||||
|
X(kGameActivityOnScreenOrientationChanged, "gameActivity_onScreenOrientationChanged") \
|
||||||
|
X(kGameActivityOnScreenshotReady, "gameActivity_onScreenshotReady") \
|
||||||
|
X(kGameActivitySetAppUpgradeStatus, "gameActivity_setAppUpgradeStatus") \
|
||||||
|
X(kGameActivityShowKeyboard, "gameActivity_showKeyboard") \
|
||||||
|
X(kGameDidLeave, "gameDidLeave") \
|
||||||
|
X(kGet, "get") \
|
||||||
|
X(kGetAbsolutePath, "getAbsolutePath") \
|
||||||
|
X(kGetActivity, "getActivity") \
|
||||||
|
X(kGetAlternateName, "getAlternateName") \
|
||||||
|
X(kGetAndroidApiVersion, "getAndroidApiVersion") \
|
||||||
|
X(kGetApiBaseUrl, "getApiBaseUrl") \
|
||||||
|
X(kGetApiGatewayUrl, "getApiGatewayUrl") \
|
||||||
|
X(kGetApiVersion, "getApiVersion") \
|
||||||
|
X(kGetApplicationContext, "getApplicationContext") \
|
||||||
|
X(kGetApplicationInfo, "getApplicationInfo") \
|
||||||
|
X(kGetAppVersion, "getAppVersion") \
|
||||||
|
X(kGetAssetManager, "getAssetManager") \
|
||||||
|
X(kGetAssets, "getAssets") \
|
||||||
|
X(kGetBaseContext, "getBaseContext") \
|
||||||
|
X(kGetBaseURL, "getBaseURL") \
|
||||||
|
X(kGetBaseUrl, "getBaseUrl") \
|
||||||
|
X(kGetBrand, "getBrand") \
|
||||||
|
X(kGetBufferSizeInFrames, "getBufferSizeInFrames") \
|
||||||
|
X(kGetBuildId, "getBuildId") \
|
||||||
|
X(kGetBuildRelease, "getBuildRelease") \
|
||||||
|
X(kGetBuildType, "getBuildType") \
|
||||||
|
X(kGetBytes, "getBytes") \
|
||||||
|
X(kGetCacheDir, "getCacheDir") \
|
||||||
|
X(kGetCanonicalPath, "getCanonicalPath") \
|
||||||
|
X(kGetClassLoader, "getClassLoader") \
|
||||||
|
X(kGetClientSettingsUrl, "getClientSettingsUrl") \
|
||||||
|
X(kGetContext, "getContext") \
|
||||||
|
X(kGetCookie, "getCookie") \
|
||||||
|
X(kGetCurrentText, "getCurrentText") \
|
||||||
|
X(kGetCurrentTextBoxInfo, "getCurrentTextBoxInfo") \
|
||||||
|
X(kGetCurrentUser, "getCurrentUser") \
|
||||||
|
X(kGetDecorView, "getDecorView") \
|
||||||
|
X(kGetDefaultDisplay, "getDefaultDisplay") \
|
||||||
|
X(kGetDensityDpi, "getDensityDpi") \
|
||||||
|
X(kGetDevice, "getDevice") \
|
||||||
|
X(kGetDeviceManufacturer, "getDeviceManufacturer") \
|
||||||
|
X(kGetDeviceModel, "getDeviceModel") \
|
||||||
|
X(kGetDeviceStaticParams, "getDeviceStaticParams") \
|
||||||
|
X(kGetDisplay, "getDisplay") \
|
||||||
|
X(kGetDisplayName, "getDisplayName") \
|
||||||
|
X(kGetExternalFilesDir, "getExternalFilesDir") \
|
||||||
|
X(kGetFilesDir, "getFilesDir") \
|
||||||
|
X(kGetGameLocale, "getGameLocale") \
|
||||||
|
X(kGetHeight, "getHeight") \
|
||||||
|
X(kGetHint, "getHint") \
|
||||||
|
X(kGetHolder, "getHolder") \
|
||||||
|
X(kGetId, "getId") \
|
||||||
|
X(kGetImplementation, "getImplementation") \
|
||||||
|
X(kGetInstallerPackageName, "getInstallerPackageName") \
|
||||||
|
X(kGetInt, "getInt") \
|
||||||
|
X(kGetLastLoggedInUser, "getLastLoggedInUser") \
|
||||||
|
X(kGetLastLoggedInUserId, "getLastLoggedInUserId") \
|
||||||
|
X(kGetLastRaw, "getLastRaw") \
|
||||||
|
X(kGetLocale, "getLocale") \
|
||||||
|
X(kGetManufacturer, "getManufacturer") \
|
||||||
|
X(kGetMessageBus, "getMessageBus") \
|
||||||
|
X(kGetMessageBusConnection, "getMessageBusConnection") \
|
||||||
|
X(kGetMode, "getMode") \
|
||||||
|
X(kGetModel, "getModel") \
|
||||||
|
X(kGetNativeHelper, "getNativeHelper") \
|
||||||
|
X(kGetNativeTextBoxInfo, "getNativeTextBoxInfo") \
|
||||||
|
X(kGetOutputBlockSize, "getOutputBlockSize") \
|
||||||
|
X(kGetOutputSampleRate, "getOutputSampleRate") \
|
||||||
|
X(kGetPackageInfo, "getPackageInfo") \
|
||||||
|
X(kGetPackageManager, "getPackageManager") \
|
||||||
|
X(kGetPackageName, "getPackageName") \
|
||||||
|
X(kGetPath, "getPath") \
|
||||||
|
X(kGetPlaceholder, "getPlaceholder") \
|
||||||
|
X(kGetPlatformName, "getPlatformName") \
|
||||||
|
X(kGetPlatformSystemDialogHandler, "getPlatformSystemDialogHandler") \
|
||||||
|
X(kGetProduct, "getProduct") \
|
||||||
|
X(kGetProperty, "getProperty") \
|
||||||
|
X(kGetPublicIPv4Addresseses, "getPublicIPv4Addresseses") \
|
||||||
|
X(kGetRefreshRate, "getRefreshRate") \
|
||||||
|
X(kGetResources, "getResources") \
|
||||||
|
X(kGetRobloxLocale, "getRobloxLocale") \
|
||||||
|
X(kGetRootView, "getRootView") \
|
||||||
|
X(kGetRotation, "getRotation") \
|
||||||
|
X(kGetScreenHeight, "getScreenHeight") \
|
||||||
|
X(kGetScreenWidth, "getScreenWidth") \
|
||||||
|
X(kGetSdkVersion, "getSdkVersion") \
|
||||||
|
X(kGetSecureValue, "getSecureValue") \
|
||||||
|
X(kGetSecureValueForCurrentUser, "getSecureValueForCurrentUser") \
|
||||||
|
X(kGetSecureValueForUser, "getSecureValueForUser") \
|
||||||
|
X(kGetSettingsUrl, "getSettingsUrl") \
|
||||||
|
X(kGetSharedPreferences, "getSharedPreferences") \
|
||||||
|
X(kGetStreamMaxVolume, "getStreamMaxVolume") \
|
||||||
|
X(kGetStreamVolume, "getStreamVolume") \
|
||||||
|
X(kGetString, "getString") \
|
||||||
|
X(kGetSurface, "getSurface") \
|
||||||
|
X(kGetSystemDialogHandler, "getSystemDialogHandler") \
|
||||||
|
X(kGetSystemService, "getSystemService") \
|
||||||
|
X(kGetSystemTheme, "getSystemTheme") \
|
||||||
|
X(kGetTargetSdkVersion, "getTargetSdkVersion") \
|
||||||
|
X(kGetText, "getText") \
|
||||||
|
X(kGetTextBoxInfo, "getTextBoxInfo") \
|
||||||
|
X(kGetTheme, "getTheme") \
|
||||||
|
X(kGetUserId, "getUserId") \
|
||||||
|
X(kGetUsername, "getUsername") \
|
||||||
|
X(kGetUsers, "getUsers") \
|
||||||
|
X(kGetVideoCodecs, "getVideoCodecs") \
|
||||||
|
X(kGetWidth, "getWidth") \
|
||||||
|
X(kGetWindow, "getWindow") \
|
||||||
|
X(kGetWindowManager, "getWindowManager") \
|
||||||
|
X(kGetWWWBaseUrl, "getWWWBaseUrl") \
|
||||||
|
X(kGetWwwBaseUrl, "getWwwBaseUrl") \
|
||||||
|
X(kH, "h") \
|
||||||
|
X(kHasNext, "hasNext") \
|
||||||
|
X(kHasSystemFeature, "hasSystemFeature") \
|
||||||
|
X(kHideKeyboard, "hideKeyboard") \
|
||||||
|
X(kIdentityHashCode, "identityHashCode") \
|
||||||
|
X(kInit, "init") \
|
||||||
|
X(kInitPlayout, "initPlayout") \
|
||||||
|
X(kInitRecording, "initRecording") \
|
||||||
|
X(kIsAAudioSupported, "isAAudioSupported") \
|
||||||
|
X(kIsAudioConfigVerified, "isAudioConfigVerified") \
|
||||||
|
X(kIsAudioSourceMatchingRecordingSession, "isAudioSourceMatchingRecordingSession") \
|
||||||
|
X(kIsAvailable, "isAvailable") \
|
||||||
|
X(kIsBluetoothA2dpOn, "isBluetoothA2dpOn") \
|
||||||
|
X(kIsBluetoothOn, "isBluetoothOn") \
|
||||||
|
X(kIsBluetoothScoOn, "isBluetoothScoOn") \
|
||||||
|
X(kIsChangingConfigurations, "isChangingConfigurations") \
|
||||||
|
X(kIsCommunicationModeEnabled, "isCommunicationModeEnabled") \
|
||||||
|
X(kIsConnected, "isConnected") \
|
||||||
|
X(kIsDestroyed, "isDestroyed") \
|
||||||
|
X(kIsDeviceBlacklistedForOpenSLESUsage, "isDeviceBlacklistedForOpenSLESUsage") \
|
||||||
|
X(kIsEmpty, "isEmpty") \
|
||||||
|
X(kIsFinishing, "isFinishing") \
|
||||||
|
X(kIsLowLatencyInputSupported, "isLowLatencyInputSupported") \
|
||||||
|
X(kIsLowLatencyOutputSupported, "isLowLatencyOutputSupported") \
|
||||||
|
X(kIsProAudioSupported, "isProAudioSupported") \
|
||||||
|
X(kIsSystemThemeAvailable, "isSystemThemeAvailable") \
|
||||||
|
X(kIsValid, "isValid") \
|
||||||
|
X(kIterator, "iterator") \
|
||||||
|
X(kK1, "k1") \
|
||||||
|
X(kL0, "l0") \
|
||||||
|
X(kLength, "length") \
|
||||||
|
X(kLoadClass, "loadClass") \
|
||||||
|
X(kLowLatencyFlag, "lowLatencyFlag") \
|
||||||
|
X(kNanoTime, "nanoTime") \
|
||||||
|
X(kNativeGetCookiesForDomain, "nativeGetCookiesForDomain") \
|
||||||
|
X(kNativeGetCookiesInNetscapeFormat, "nativeGetCookiesInNetscapeFormat") \
|
||||||
|
X(kNativeGetTextBoxInfo, "nativeGetTextBoxInfo") \
|
||||||
|
X(kOnAppBridgeNotification, "onAppBridgeNotification") \
|
||||||
|
X(kOnDataModelNotificationCallback, "onDataModelNotificationCallback") \
|
||||||
|
X(kOnItemSet, "onItemSet") \
|
||||||
|
X(kOnLuaTextBoxChangedCallback, "onLuaTextBoxChangedCallback") \
|
||||||
|
X(kOnLuaTextBoxPropertyChangedCallback, "onLuaTextBoxPropertyChangedCallback") \
|
||||||
|
X(kOnSetCookie, "onSetCookie") \
|
||||||
|
X(kOpen, "open") \
|
||||||
|
X(kOpenNativeOverlay, "openNativeOverlay") \
|
||||||
|
X(kOpenWebActivity, "openWebActivity") \
|
||||||
|
X(kOptString, "optString") \
|
||||||
|
X(kOutputAAudioHeadphonesChanged, "OutputAAudioHeadphonesChanged") \
|
||||||
|
X(kProAudioFlag, "proAudioFlag") \
|
||||||
|
X(kQ, "q") \
|
||||||
|
X(kRequestOrientationAsDefault, "requestOrientationAsDefault") \
|
||||||
|
X(kRequestResponse, "requestResponse") \
|
||||||
|
X(kRestartInput, "restartInput") \
|
||||||
|
X(kRun, "run") \
|
||||||
|
X(kRunOnUiThread, "runOnUiThread") \
|
||||||
|
X(kSetAppBridgeNotificationListener, "setAppBridgeNotificationListener") \
|
||||||
|
X(kSetBaseUrl, "setBaseUrl") \
|
||||||
|
X(kSetCookie, "setCookie") \
|
||||||
|
X(kSetCookiesFromDisk, "setCookiesFromDisk") \
|
||||||
|
X(kSetCurrentUser, "setCurrentUser") \
|
||||||
|
X(kSetImeEditorInfoFields, "setImeEditorInfoFields") \
|
||||||
|
X(kSetImplementation, "setImplementation") \
|
||||||
|
X(kSetMicrophoneMute, "setMicrophoneMute") \
|
||||||
|
X(kSetRequestHandler, "setRequestHandler") \
|
||||||
|
X(kSetRequestHandlerRaw, "setRequestHandlerRaw") \
|
||||||
|
X(kSetSecureValue, "setSecureValue") \
|
||||||
|
X(kSetSecureValueForCurrentUser, "setSecureValueForCurrentUser") \
|
||||||
|
X(kSetSecureValueForUser, "setSecureValueForUser") \
|
||||||
|
X(kSetSoftKeyboardActive, "setSoftKeyboardActive") \
|
||||||
|
X(kSetState, "setState") \
|
||||||
|
X(kSetStreamVolume, "setStreamVolume") \
|
||||||
|
X(kSetWindowFlags, "setWindowFlags") \
|
||||||
|
X(kShowKeyboard, "showKeyboard") \
|
||||||
|
X(kShowLeaveAppPrompt, "showLeaveAppPrompt") \
|
||||||
|
X(kSize, "size") \
|
||||||
|
X(kStartPlayout, "startPlayout") \
|
||||||
|
X(kStartRecording, "startRecording") \
|
||||||
|
X(kStopPlayout, "stopPlayout") \
|
||||||
|
X(kStopRecording, "stopRecording") \
|
||||||
|
X(kSubscribe, "subscribe") \
|
||||||
|
X(kSubscribeProtocolMethodResponseRaw, "subscribeProtocolMethodResponseRaw") \
|
||||||
|
X(kSupportsAAudio, "supportsAAudio") \
|
||||||
|
X(kSupportsLowLatency, "supportsLowLatency") \
|
||||||
|
X(kSyncCookiesFromEngine, "syncCookiesFromEngine") \
|
||||||
|
X(kWrite, "write")
|
||||||
|
|
||||||
|
enum class JniMethod : std::uint16_t {
|
||||||
|
kUnknown = 0,
|
||||||
|
#define MOCKTAIL_JNI_METHOD_ENUMERATOR(id, name) id,
|
||||||
|
MOCKTAIL_JNI_METHOD_NAMES(MOCKTAIL_JNI_METHOD_ENUMERATOR)
|
||||||
|
#undef MOCKTAIL_JNI_METHOD_ENUMERATOR
|
||||||
|
};
|
||||||
|
|
||||||
|
JniMethod LookupJniMethod(std::string_view name);
|
||||||
|
|
||||||
|
} // namespace jnivm::internal
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_JNIVM_JNI_METHOD_NAMES_H_
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
#include "jni_references.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
namespace jnivm {
|
||||||
|
// Encoded handles are also read by the guest ABI boundary.
|
||||||
|
void* my_segment[100000] = {};
|
||||||
|
int g_jni_ref_index = 1;
|
||||||
|
} // namespace jnivm
|
||||||
|
|
||||||
|
namespace jnivm::internal {
|
||||||
|
|
||||||
|
enum class SegmentType : uint8_t {
|
||||||
|
kEmpty = 0,
|
||||||
|
kObject = 1,
|
||||||
|
kClass = 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class handles are interned by binary name. Real JNI resolves the same class
|
||||||
|
// to the same runtime class object, and class handles are never released, so
|
||||||
|
// every FindClass/GetObjectClass used to consume a fresh permanent segment
|
||||||
|
// slot and exhaust the table during long sessions.
|
||||||
|
// Each frame counts how many local references it holds per handle, so
|
||||||
|
// DeleteLocalRef costs one hash lookup per open frame.
|
||||||
|
using LocalFrame = std::unordered_map<jobject, uint32_t>;
|
||||||
|
thread_local std::vector<LocalFrame> g_local_frames;
|
||||||
|
std::recursive_mutex g_jni_state_mutex;
|
||||||
|
std::unordered_map<jobject, std::unique_ptr<Object>> g_object_storage;
|
||||||
|
std::unordered_map<jobject, uint32_t> g_jni_ref_counts;
|
||||||
|
std::shared_ptr<void> g_segment_owners[kJniSegmentCapacity];
|
||||||
|
std::unordered_set<jclass> g_known_classes;
|
||||||
|
// One handle per Class. Class slots are never freed, so the table holds at
|
||||||
|
// most one slot per distinct class.
|
||||||
|
std::unordered_map<const Class*, jclass> g_class_handles;
|
||||||
|
std::unordered_map<std::string, jobject> g_singleton_objects;
|
||||||
|
std::unordered_set<jobject> g_singleton_handles;
|
||||||
|
std::unordered_map<std::string, std::shared_ptr<Class>> g_fallback_classes;
|
||||||
|
std::unordered_set<jstring> g_known_strings;
|
||||||
|
std::unordered_map<jarray, std::unique_ptr<PseudoArray>> g_array_storage;
|
||||||
|
static std::atomic<uint8_t> g_segment_types[kJniSegmentCapacity] = {};
|
||||||
|
std::vector<int> g_free_slots;
|
||||||
|
|
||||||
|
static bool g_shutting_down_jnivm = false;
|
||||||
|
|
||||||
|
struct JniVmShutdownHook {
|
||||||
|
JniVmShutdownHook() {
|
||||||
|
std::atexit([]() { g_shutting_down_jnivm = true; });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
static JniVmShutdownHook g_shutdown_hook;
|
||||||
|
|
||||||
|
bool TraceEnabled() {
|
||||||
|
static const bool enabled = std::getenv("MOCKTAIL_JNI_TRACE") != nullptr;
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
jobject JniHandleFromIndex(uint32_t index) {
|
||||||
|
return reinterpret_cast<jobject>(static_cast<uintptr_t>(index)
|
||||||
|
<< kJniHandleShift);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t JniIndexFromHandle(jobject obj) {
|
||||||
|
return static_cast<uint32_t>(reinterpret_cast<uintptr_t>(obj) >>
|
||||||
|
kJniHandleShift);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EnsureLocalFrame() {
|
||||||
|
if (g_local_frames.empty()) {
|
||||||
|
g_local_frames.emplace_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RegisterLocalRef(jobject obj) {
|
||||||
|
if (obj == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EnsureLocalFrame();
|
||||||
|
++g_local_frames.back()[obj];
|
||||||
|
}
|
||||||
|
|
||||||
|
void UnregisterLocalRef(jobject obj) {
|
||||||
|
if (obj == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (auto frame = g_local_frames.rbegin(); frame != g_local_frames.rend();
|
||||||
|
++frame) {
|
||||||
|
auto it = frame->find(obj);
|
||||||
|
if (it != frame->end()) {
|
||||||
|
if (--it->second == 0) {
|
||||||
|
frame->erase(it);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A detached thread can no longer use its local references, so they go the
|
||||||
|
// way ART drops them on detach.
|
||||||
|
void ReleaseThreadLocalRefs() {
|
||||||
|
std::vector<LocalFrame> frames = std::move(g_local_frames);
|
||||||
|
g_local_frames.clear();
|
||||||
|
for (const LocalFrame& frame : frames) {
|
||||||
|
for (const auto& [obj, count] : frame) {
|
||||||
|
for (uint32_t i = 0; i < count; ++i) {
|
||||||
|
ReleaseJniReference(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RetainJniReference(jobject obj) {
|
||||||
|
if (obj == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||||
|
auto it = g_jni_ref_counts.find(obj);
|
||||||
|
if (it != g_jni_ref_counts.end()) {
|
||||||
|
++it->second;
|
||||||
|
} else {
|
||||||
|
g_jni_ref_counts[obj] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReportHandleTableFull();
|
||||||
|
|
||||||
|
int AllocateSegmentSlot(void* value, std::shared_ptr<void> owner = nullptr,
|
||||||
|
SegmentType type = SegmentType::kObject) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||||
|
int index = 0;
|
||||||
|
if (!g_free_slots.empty()) {
|
||||||
|
index = g_free_slots.back();
|
||||||
|
g_free_slots.pop_back();
|
||||||
|
} else if (g_jni_ref_index > 0 &&
|
||||||
|
static_cast<uint32_t>(g_jni_ref_index) < kJniSegmentCapacity) {
|
||||||
|
index = g_jni_ref_index++;
|
||||||
|
} else {
|
||||||
|
ReportHandleTableFull();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
my_segment[index] = value;
|
||||||
|
g_segment_owners[index] = std::move(owner);
|
||||||
|
g_segment_types[index].store(static_cast<uint8_t>(type),
|
||||||
|
std::memory_order_release);
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<Class> FallbackClassForName(const std::string& class_name) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||||
|
auto it = g_fallback_classes.find(class_name);
|
||||||
|
if (it != g_fallback_classes.end()) {
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
auto cls = std::make_shared<Class>(class_name);
|
||||||
|
g_fallback_classes[class_name] = cls;
|
||||||
|
return cls;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<Class> ClassFromJClass(jclass clazz) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||||
|
if (!clazz) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
const uint32_t index = JniIndexFromHandle(reinterpret_cast<jobject>(clazz));
|
||||||
|
Class* cls = nullptr;
|
||||||
|
if (index > 0 && index < kJniSegmentCapacity) {
|
||||||
|
cls = reinterpret_cast<Class*>(my_segment[index]);
|
||||||
|
if (g_segment_owners[index]) {
|
||||||
|
return std::static_pointer_cast<Class>(g_segment_owners[index]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cls = reinterpret_cast<Class*>(clazz);
|
||||||
|
}
|
||||||
|
if (!cls) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return FallbackClassForName(cls->GetName());
|
||||||
|
}
|
||||||
|
|
||||||
|
jclass StoreClass(std::shared_ptr<Class> cls) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||||
|
if (!cls) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
Class* raw_ptr = cls.get();
|
||||||
|
if (const auto cached = g_class_handles.find(raw_ptr);
|
||||||
|
cached != g_class_handles.end()) {
|
||||||
|
return cached->second;
|
||||||
|
}
|
||||||
|
int index = AllocateSegmentSlot(raw_ptr, cls, SegmentType::kClass);
|
||||||
|
if (index <= 0) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
jclass handle = reinterpret_cast<jclass>(
|
||||||
|
JniHandleFromIndex(static_cast<uint32_t>(index)));
|
||||||
|
g_known_classes.insert(handle);
|
||||||
|
g_class_handles.emplace(raw_ptr, handle);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
jobject StoreObject(std::unique_ptr<Object> object) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||||
|
Object* raw_ptr = object.get();
|
||||||
|
int index = AllocateSegmentSlot(raw_ptr, nullptr, SegmentType::kObject);
|
||||||
|
if (index <= 0) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
jobject handle = JniHandleFromIndex(static_cast<uint32_t>(index));
|
||||||
|
g_object_storage[handle] = std::move(object);
|
||||||
|
g_jni_ref_counts[handle] = 1;
|
||||||
|
RegisterLocalRef(handle);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReleaseJniReference(jobject obj) {
|
||||||
|
if (obj == nullptr || g_shutting_down_jnivm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||||
|
if (g_shutting_down_jnivm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Every live object and array holds a count, so a handle without one was
|
||||||
|
// already released. Releasing it again would queue its slot twice.
|
||||||
|
auto ref_it = g_jni_ref_counts.find(obj);
|
||||||
|
if (ref_it == g_jni_ref_counts.end()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ref_it->second > 1) {
|
||||||
|
--ref_it->second;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_jni_ref_counts.erase(ref_it);
|
||||||
|
|
||||||
|
auto arr_it = g_array_storage.find(reinterpret_cast<jarray>(obj));
|
||||||
|
if (arr_it != g_array_storage.end()) {
|
||||||
|
std::unique_ptr<PseudoArray> dying_array = std::move(arr_it->second);
|
||||||
|
std::vector<jobject> elements = std::move(dying_array->objects);
|
||||||
|
g_array_storage.erase(arr_it);
|
||||||
|
// Release after detaching the array: elements may recursively erase other
|
||||||
|
// arrays. Keep dying_array alive until those releases have completed.
|
||||||
|
for (jobject element : elements) {
|
||||||
|
ReleaseJniReference(element);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (g_known_classes.find(reinterpret_cast<jclass>(obj)) !=
|
||||||
|
g_known_classes.end()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (g_singleton_handles.count(obj) != 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uint32_t index = JniIndexFromHandle(obj);
|
||||||
|
if (index > 0 && index < kJniSegmentCapacity) {
|
||||||
|
g_segment_types[index].store(static_cast<uint8_t>(SegmentType::kEmpty),
|
||||||
|
std::memory_order_release);
|
||||||
|
my_segment[index] = nullptr;
|
||||||
|
g_segment_owners[index].reset();
|
||||||
|
g_free_slots.push_back(static_cast<int>(index));
|
||||||
|
}
|
||||||
|
g_known_strings.erase(reinterpret_cast<jstring>(obj));
|
||||||
|
std::unique_ptr<Object> dying_object;
|
||||||
|
auto obj_it = g_object_storage.find(obj);
|
||||||
|
if (obj_it != g_object_storage.end()) {
|
||||||
|
dying_object = std::move(obj_it->second);
|
||||||
|
g_object_storage.erase(obj_it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PseudoJavaObject* PseudoObjectFromRef(jobject obj) {
|
||||||
|
if (__builtin_expect(obj == nullptr, 0)) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
const uint32_t index = JniIndexFromHandle(obj);
|
||||||
|
if (__builtin_expect(index > 0 && index < kJniSegmentCapacity, 1)) {
|
||||||
|
const uint8_t type = g_segment_types[index].load(std::memory_order_acquire);
|
||||||
|
if (__builtin_expect(type == static_cast<uint8_t>(SegmentType::kObject),
|
||||||
|
1)) {
|
||||||
|
void* raw_ptr = my_segment[index];
|
||||||
|
if (__builtin_expect(raw_ptr != nullptr, 1)) {
|
||||||
|
return static_cast<PseudoJavaObject*>(
|
||||||
|
reinterpret_cast<Object*>(raw_ptr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
jobject MakeObjectForClass(const std::string& class_name) {
|
||||||
|
auto cls = FallbackClassForName(class_name);
|
||||||
|
return StoreObject(std::make_unique<PseudoJavaObject>(std::move(cls)));
|
||||||
|
}
|
||||||
|
|
||||||
|
jobject MakeObject(jclass clazz) {
|
||||||
|
auto cls = ClassFromJClass(clazz);
|
||||||
|
if (!cls) {
|
||||||
|
cls = FallbackClassForName("java/lang/Object");
|
||||||
|
}
|
||||||
|
return StoreObject(std::make_unique<PseudoJavaObject>(std::move(cls)));
|
||||||
|
}
|
||||||
|
|
||||||
|
jobject SingletonObject(const std::string& class_name) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||||
|
auto it = g_singleton_objects.find(class_name);
|
||||||
|
if (it != g_singleton_objects.end()) {
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
jobject object = MakeObjectForClass(class_name);
|
||||||
|
g_singleton_objects[class_name] = object;
|
||||||
|
g_singleton_handles.insert(object);
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string_view ObjectClassName(jobject obj) {
|
||||||
|
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
|
||||||
|
if (!pseudo_object || !pseudo_object->GetClass()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return pseudo_object->GetClass()->GetName();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MarkSingletonHandle(jobject obj) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||||
|
g_singleton_handles.insert(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logs once per process which classes hold the live handles, so the leak
|
||||||
|
// behind a later null FindClass or NewStringUTF can be named.
|
||||||
|
void ReportHandleTableFull() {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||||
|
static bool reported = false;
|
||||||
|
if (reported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reported = true;
|
||||||
|
std::unordered_map<std::string_view, std::size_t> live_by_class;
|
||||||
|
for (const auto& entry : g_object_storage) {
|
||||||
|
std::string_view name = ObjectClassName(entry.first);
|
||||||
|
++live_by_class[name.empty() ? std::string_view("<unknown>") : name];
|
||||||
|
}
|
||||||
|
std::vector<std::pair<std::string_view, std::size_t>> ranked(
|
||||||
|
live_by_class.begin(), live_by_class.end());
|
||||||
|
const std::size_t shown = std::min<std::size_t>(ranked.size(), 10);
|
||||||
|
std::partial_sort(ranked.begin(), ranked.begin() + shown, ranked.end(),
|
||||||
|
[](const auto& a, const auto& b) {
|
||||||
|
return a.second > b.second;
|
||||||
|
});
|
||||||
|
fprintf(stderr,
|
||||||
|
"[JNI] handle table full (%u slots): %zu objects, %zu classes, "
|
||||||
|
"%zu arrays live; new objects return null\n",
|
||||||
|
kJniSegmentCapacity, g_object_storage.size(),
|
||||||
|
g_class_handles.size(), g_array_storage.size());
|
||||||
|
for (std::size_t i = 0; i < shown; ++i) {
|
||||||
|
fprintf(stderr, "[JNI] %zu x %.*s\n", ranked[i].second,
|
||||||
|
static_cast<int>(ranked[i].first.size()), ranked[i].first.data());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PseudoArray* ArrayFromRef(jarray array) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||||
|
auto it = g_array_storage.find(array);
|
||||||
|
return it == g_array_storage.end() ? nullptr : it->second.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
jbyteArray MakeByteArray(jsize len) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||||
|
auto array = std::make_unique<PseudoArray>();
|
||||||
|
if (len > 0) {
|
||||||
|
array->bytes.resize(static_cast<std::size_t>(len));
|
||||||
|
}
|
||||||
|
jbyteArray ref = reinterpret_cast<jbyteArray>(array.get());
|
||||||
|
g_array_storage[ref] = std::move(array);
|
||||||
|
g_jni_ref_counts[reinterpret_cast<jobject>(ref)] = 1;
|
||||||
|
RegisterLocalRef(reinterpret_cast<jobject>(ref));
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
jfloatArray MakeFloatArray(jsize len) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||||
|
auto array = std::make_unique<PseudoArray>();
|
||||||
|
if (len > 0) {
|
||||||
|
array->floats.resize(static_cast<std::size_t>(len));
|
||||||
|
}
|
||||||
|
jfloatArray ref = reinterpret_cast<jfloatArray>(array.get());
|
||||||
|
g_array_storage[ref] = std::move(array);
|
||||||
|
g_jni_ref_counts[reinterpret_cast<jobject>(ref)] = 1;
|
||||||
|
RegisterLocalRef(reinterpret_cast<jobject>(ref));
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
jobjectArray MakeObjectArray(jsize len, jobject init) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||||
|
auto array = std::make_unique<PseudoArray>();
|
||||||
|
if (len > 0) {
|
||||||
|
array->objects.resize(static_cast<std::size_t>(len), init);
|
||||||
|
if (init != nullptr) {
|
||||||
|
for (jsize index = 0; index < len; ++index) {
|
||||||
|
RetainJniReference(init);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jobjectArray ref = reinterpret_cast<jobjectArray>(array.get());
|
||||||
|
g_array_storage[ref] = std::move(array);
|
||||||
|
g_jni_ref_counts[reinterpret_cast<jobject>(ref)] = 1;
|
||||||
|
RegisterLocalRef(reinterpret_cast<jobject>(ref));
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
PseudoJavaObject::~PseudoJavaObject() {
|
||||||
|
for (const auto& field : object_fields) ReleaseJniReference(field.second);
|
||||||
|
}
|
||||||
|
|
||||||
|
jobject NewLocalJniReference(jobject obj) {
|
||||||
|
if (obj != nullptr) {
|
||||||
|
RetainJniReference(obj);
|
||||||
|
RegisterLocalRef(obj);
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeleteLocalJniReference(jobject obj) {
|
||||||
|
UnregisterLocalRef(obj);
|
||||||
|
ReleaseJniReference(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PushLocalJniFrame() {
|
||||||
|
EnsureLocalFrame();
|
||||||
|
g_local_frames.emplace_back();
|
||||||
|
}
|
||||||
|
|
||||||
|
jobject PopLocalJniFrame(jobject result) {
|
||||||
|
EnsureLocalFrame();
|
||||||
|
RetainJniReference(result);
|
||||||
|
LocalFrame frame = std::move(g_local_frames.back());
|
||||||
|
if (g_local_frames.size() > 1)
|
||||||
|
g_local_frames.pop_back();
|
||||||
|
else
|
||||||
|
g_local_frames.back().clear();
|
||||||
|
for (const auto& [obj, count] : frame) {
|
||||||
|
for (uint32_t i = 0; i < count; ++i) {
|
||||||
|
ReleaseJniReference(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RegisterLocalRef(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace jnivm::internal
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+958
-1690
File diff suppressed because it is too large.
Load diff
+133
-11
@@ -31,7 +31,6 @@
|
|||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
#include <sys/time.h>
|
#include <sys/time.h>
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
#include <execinfo.h>
|
|
||||||
#include <ucontext.h>
|
#include <ucontext.h>
|
||||||
#include <elf.h>
|
#include <elf.h>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
@@ -70,6 +69,7 @@
|
|||||||
#include "runtime/jnivm_platform_web_callbacks.h"
|
#include "runtime/jnivm_platform_web_callbacks.h"
|
||||||
#include "runtime/owned_pthread.h"
|
#include "runtime/owned_pthread.h"
|
||||||
#include "runtime/platform_cache_migration.h"
|
#include "runtime/platform_cache_migration.h"
|
||||||
|
#include "runtime/asset_overlay_names.h"
|
||||||
#include "runtime/roblox_app_lifecycle.h"
|
#include "runtime/roblox_app_lifecycle.h"
|
||||||
#include "runtime/roblox_capability_resolver.h"
|
#include "runtime/roblox_capability_resolver.h"
|
||||||
#include "runtime/roblox_platform_web_symbols.h"
|
#include "runtime/roblox_platform_web_symbols.h"
|
||||||
@@ -651,10 +651,10 @@ void ApplyRuntimeDefaults() {
|
|||||||
SetEnvDefault("MOCKTAIL_INIT_CLIENT_SETTINGS", "0");
|
SetEnvDefault("MOCKTAIL_INIT_CLIENT_SETTINGS", "0");
|
||||||
SetEnvDefault("MOCKTAIL_POST_CLIENT_SETTINGS", "0");
|
SetEnvDefault("MOCKTAIL_POST_CLIENT_SETTINGS", "0");
|
||||||
SetEnvDefault("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
|
SetEnvDefault("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
|
||||||
// Current Roblox Android builds drive app startup through NativeGLInterface's
|
// nativeAppBridgeAppStart runs Roblox's StartupController, which builds the
|
||||||
// V2 app bridge path. The legacy NativeAppBridgeInterface entry point stays
|
// experimentation protocol that writes ClientSettings/IxpSettings.json. It
|
||||||
// available by opt-in, but running it in parallel can block V2 init.
|
// runs inline before V2 init; on its own thread it can block V2 init.
|
||||||
SetEnvDefault("MOCKTAIL_APP_BRIDGE_APP_START", "0");
|
SetEnvDefault("MOCKTAIL_APP_BRIDGE_APP_START", "1");
|
||||||
SetEnvDefault("MOCKTAIL_APP_BRIDGE_APP_START_THREAD", "0");
|
SetEnvDefault("MOCKTAIL_APP_BRIDGE_APP_START_THREAD", "0");
|
||||||
SetEnvDefault("MOCKTAIL_START_LUA_APP_DM", "1");
|
SetEnvDefault("MOCKTAIL_START_LUA_APP_DM", "1");
|
||||||
SetEnvDefault("MOCKTAIL_START_LUA_APP_DM_INLINE", "0");
|
SetEnvDefault("MOCKTAIL_START_LUA_APP_DM_INLINE", "0");
|
||||||
@@ -1964,6 +1964,48 @@ extern "C" void MocktailConstructorWrapper() {
|
|||||||
MaybePrintLibRobloxConstructorSummary(idx);
|
MaybePrintLibRobloxConstructorSummary(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unmaps the rbxasset:// names that have a file in the asset overlay folder, so
|
||||||
|
// Roblox opens those files instead of downloading the stock asset. Must run
|
||||||
|
// before any libroblox code does: the segment loses PROT_EXEC while it is
|
||||||
|
// written.
|
||||||
|
void DisableOverlaidAssetMappings(uintptr_t base) {
|
||||||
|
const std::vector<std::string> names = mocktail::runtime::OverlayRbxAssetNames(
|
||||||
|
CurrentRuntimePaths().asset_overlay_dir());
|
||||||
|
const long page_size = sysconf(_SC_PAGESIZE);
|
||||||
|
if (names.empty() || page_size <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto* header = reinterpret_cast<const Elf64_Ehdr*>(base);
|
||||||
|
const auto* segments =
|
||||||
|
reinterpret_cast<const Elf64_Phdr*>(base + header->e_phoff);
|
||||||
|
std::size_t changed = 0;
|
||||||
|
for (std::size_t index = 0; index < header->e_phnum; ++index) {
|
||||||
|
const Elf64_Phdr& segment = segments[index];
|
||||||
|
if (segment.p_type != PT_LOAD || (segment.p_flags & PF_W) != 0 ||
|
||||||
|
segment.p_filesz == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uintptr_t begin = base + segment.p_vaddr;
|
||||||
|
const uintptr_t page_mask = static_cast<uintptr_t>(page_size) - 1;
|
||||||
|
const uintptr_t page_begin = begin & ~page_mask;
|
||||||
|
const std::size_t span =
|
||||||
|
((begin + segment.p_filesz + page_mask) & ~page_mask) - page_begin;
|
||||||
|
const int original =
|
||||||
|
PROT_READ | ((segment.p_flags & PF_X) != 0 ? PROT_EXEC : 0);
|
||||||
|
if (mprotect(reinterpret_cast<void*>(page_begin), span,
|
||||||
|
PROT_READ | PROT_WRITE) != 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
changed += mocktail::runtime::DisableAssetNameMappings(
|
||||||
|
reinterpret_cast<std::uint8_t*>(begin), segment.p_filesz, names);
|
||||||
|
mprotect(reinterpret_cast<void*>(page_begin), span, original);
|
||||||
|
}
|
||||||
|
std::cout << " [asset] overlay holds " << names.size()
|
||||||
|
<< " rbxasset file(s); unmapped " << changed
|
||||||
|
<< " download name(s)\n"
|
||||||
|
<< std::flush;
|
||||||
|
}
|
||||||
|
|
||||||
extern "C" void mocktail_before_soinfo_constructors(const char* realpath,
|
extern "C" void mocktail_before_soinfo_constructors(const char* realpath,
|
||||||
uintptr_t base) {
|
uintptr_t base) {
|
||||||
if (realpath == nullptr || std::strstr(realpath, "libroblox.so") == nullptr ||
|
if (realpath == nullptr || std::strstr(realpath, "libroblox.so") == nullptr ||
|
||||||
@@ -1972,6 +2014,7 @@ extern "C" void mocktail_before_soinfo_constructors(const char* realpath,
|
|||||||
}
|
}
|
||||||
|
|
||||||
g_libroblox_base = base;
|
g_libroblox_base = base;
|
||||||
|
DisableOverlaidAssetMappings(base);
|
||||||
g_mocktail_abort_libroblox_base = base;
|
g_mocktail_abort_libroblox_base = base;
|
||||||
g_libroblox_base_static = base;
|
g_libroblox_base_static = base;
|
||||||
|
|
||||||
@@ -4951,9 +4994,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
|||||||
mocktail::runtime::ReadRobloxThemeCache(
|
mocktail::runtime::ReadRobloxThemeCache(
|
||||||
app_storage_file, dependencies.account_identity().user_id);
|
app_storage_file, dependencies.account_identity().user_id);
|
||||||
if (!saved_theme) {
|
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';
|
<< saved_theme.error << '\n';
|
||||||
return EXIT_FAILURE;
|
|
||||||
}
|
}
|
||||||
if (saved_theme.dark_theme.has_value()) {
|
if (saved_theme.dark_theme.has_value()) {
|
||||||
dark_theme = *saved_theme.dark_theme;
|
dark_theme = *saved_theme.dark_theme;
|
||||||
@@ -4964,7 +5006,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
|||||||
if (runtime_config.theme_mode() == "roblox") {
|
if (runtime_config.theme_mode() == "roblox") {
|
||||||
std::cout << " [theme] "
|
std::cout << " [theme] "
|
||||||
<< (roblox_theme_found ? "Roblox saved theme="
|
<< (roblox_theme_found ? "Roblox saved theme="
|
||||||
: "Roblox theme missing; default=")
|
: "Roblox theme unavailable; default=")
|
||||||
<< (dark_theme ? "dark" : "light") << '\n'
|
<< (dark_theme ? "dark" : "light") << '\n'
|
||||||
<< std::flush;
|
<< std::flush;
|
||||||
} else if (app_storage_file != nullptr) {
|
} else if (app_storage_file != nullptr) {
|
||||||
@@ -4972,9 +5014,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
|||||||
if (!mocktail::runtime::ApplyRobloxThemeCacheOverride(
|
if (!mocktail::runtime::ApplyRobloxThemeCacheOverride(
|
||||||
app_storage_file, dependencies.account_identity().user_id,
|
app_storage_file, dependencies.account_identity().user_id,
|
||||||
dark_theme, &theme_error)) {
|
dark_theme, &theme_error)) {
|
||||||
std::cerr << "[FATAL] Roblox theme cache override failed: " << theme_error
|
std::cerr << " [theme] Cannot persist Roblox theme override: "
|
||||||
<< '\n';
|
<< theme_error << '\n';
|
||||||
return EXIT_FAILURE;
|
|
||||||
}
|
}
|
||||||
std::cout << " [theme] Roblox local theme="
|
std::cout << " [theme] Roblox local theme="
|
||||||
<< (dark_theme ? "dark" : "light") << '\n'
|
<< (dark_theme ? "dark" : "light") << '\n'
|
||||||
@@ -6006,6 +6047,10 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
|||||||
experience_game_symbols;
|
experience_game_symbols;
|
||||||
std::shared_ptr<mocktail::runtime::RobloxExperienceComposition>
|
std::shared_ptr<mocktail::runtime::RobloxExperienceComposition>
|
||||||
experience_composition;
|
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<ExperienceLifecycleTarget> experience_lifecycle_target;
|
||||||
std::shared_ptr<mocktail::runtime::RobloxWindowInputRuntime>
|
std::shared_ptr<mocktail::runtime::RobloxWindowInputRuntime>
|
||||||
window_input_runtime;
|
window_input_runtime;
|
||||||
@@ -6665,6 +6710,75 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
|||||||
<< " run_start_app_with_params="
|
<< " run_start_app_with_params="
|
||||||
<< (run_start_app_with_params ? 1 : 0) << '\n' << std::flush;
|
<< (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) {
|
if (experience_game_symbols != nullptr) {
|
||||||
mocktail::runtime::RobloxExperienceMessageBusSymbols message_bus_symbols;
|
mocktail::runtime::RobloxExperienceMessageBusSymbols message_bus_symbols;
|
||||||
message_bus_symbols.get_launch_id =
|
message_bus_symbols.get_launch_id =
|
||||||
@@ -7156,6 +7270,14 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
|||||||
input_shutdown_completed = window_input_runtime->Shutdown().ok() &&
|
input_shutdown_completed = window_input_runtime->Shutdown().ok() &&
|
||||||
input_shutdown_completed;
|
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;
|
bool experience_destroyed_app = false;
|
||||||
if (experience_composition != nullptr) {
|
if (experience_composition != nullptr) {
|
||||||
jni_vm->ClearRobloxExperienceLifecycleCallbacks();
|
jni_vm->ClearRobloxExperienceLifecycleCallbacks();
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ bool IsBionicLibcOnlySymbol(std::string_view symbol) {
|
|||||||
return symbol == "__cmsg_nxthdr" || symbol == "__cxa_thread_atexit_impl" ||
|
return symbol == "__cmsg_nxthdr" || symbol == "__cxa_thread_atexit_impl" ||
|
||||||
symbol == "__readlink_chk" || symbol == "__register_atfork" ||
|
symbol == "__readlink_chk" || symbol == "__register_atfork" ||
|
||||||
symbol == "arc4random_buf" || symbol == "geteuid" ||
|
symbol == "arc4random_buf" || symbol == "geteuid" ||
|
||||||
|
(!mocktail::compat::kHostAtoiMatchesBionic && symbol == "atoi") ||
|
||||||
symbol == "getuid" || symbol == "lseek64" ||
|
symbol == "getuid" || symbol == "lseek64" ||
|
||||||
symbol == "mallinfo" || symbol == "pread64" || symbol == "pwrite64" ||
|
symbol == "mallinfo" || symbol == "pread64" || symbol == "pwrite64" ||
|
||||||
symbol == "sigaction" || symbol == "strerror_r" ||
|
symbol == "sigaction" || symbol == "strerror_r" ||
|
||||||
@@ -384,6 +385,10 @@ void RegisterBionicHostLibcRuntimeForLibc() {
|
|||||||
RegisterSyntheticSymbol("libc.so", "arc4random_buf",
|
RegisterSyntheticSymbol("libc.so", "arc4random_buf",
|
||||||
reinterpret_cast<void*>(
|
reinterpret_cast<void*>(
|
||||||
mocktail_bionic_arc4random_buf));
|
mocktail_bionic_arc4random_buf));
|
||||||
|
if (!mocktail::compat::kHostAtoiMatchesBionic) {
|
||||||
|
RegisterSyntheticSymbol("libc.so", "atoi",
|
||||||
|
reinterpret_cast<void*>(mocktail_bionic_atoi));
|
||||||
|
}
|
||||||
RegisterSyntheticSymbol("libc.so", "mallinfo",
|
RegisterSyntheticSymbol("libc.so", "mallinfo",
|
||||||
reinterpret_cast<void*>(mocktail_bionic_mallinfo));
|
reinterpret_cast<void*>(mocktail_bionic_mallinfo));
|
||||||
RegisterSyntheticSymbol("libc.so", "sysinfo",
|
RegisterSyntheticSymbol("libc.so", "sysinfo",
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#include "runtime/asset_overlay_names.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <system_error>
|
||||||
|
|
||||||
|
namespace mocktail {
|
||||||
|
namespace runtime {
|
||||||
|
|
||||||
|
std::vector<std::string> OverlayRbxAssetNames(
|
||||||
|
const std::filesystem::path& overlay_dir) {
|
||||||
|
std::vector<std::string> names;
|
||||||
|
const std::filesystem::path content = overlay_dir / "content";
|
||||||
|
std::error_code error;
|
||||||
|
std::filesystem::recursive_directory_iterator it(
|
||||||
|
content, std::filesystem::directory_options::skip_permission_denied,
|
||||||
|
error);
|
||||||
|
const std::filesystem::recursive_directory_iterator end;
|
||||||
|
for (; !error && it != end; it.increment(error)) {
|
||||||
|
std::error_code entry_error;
|
||||||
|
if (!it->is_regular_file(entry_error)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const std::filesystem::path relative =
|
||||||
|
it->path().lexically_relative(content);
|
||||||
|
names.push_back("rbxasset://" + relative.generic_string());
|
||||||
|
}
|
||||||
|
std::sort(names.begin(), names.end());
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t DisableAssetNameMappings(std::uint8_t* data, std::size_t size,
|
||||||
|
const std::vector<std::string>& names) {
|
||||||
|
std::size_t changed = 0;
|
||||||
|
if (data == nullptr) {
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
std::uint8_t* const end = data + size;
|
||||||
|
for (const std::string& name : names) {
|
||||||
|
// The trailing NUL is part of the needle, so a longer string never matches.
|
||||||
|
const std::size_t needle_size = name.size() + 1;
|
||||||
|
if (name.empty() || needle_size > size) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
std::uint8_t* cursor = data;
|
||||||
|
while (cursor + needle_size <= end) {
|
||||||
|
auto* found = static_cast<std::uint8_t*>(
|
||||||
|
memmem(cursor, static_cast<std::size_t>(end - cursor), name.c_str(),
|
||||||
|
needle_size));
|
||||||
|
if (found == nullptr) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (found == data || found[-1] == 0) {
|
||||||
|
found[0] = '!';
|
||||||
|
++changed;
|
||||||
|
}
|
||||||
|
cursor = found + needle_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace runtime
|
||||||
|
} // namespace mocktail
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
#include <sys/eventfd.h>
|
#include <sys/eventfd.h>
|
||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
|
#include <sys/time.h>
|
||||||
#include <sys/un.h>
|
#include <sys/un.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <system_error>
|
#include <system_error>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace mocktail {
|
namespace mocktail {
|
||||||
@@ -45,11 +46,9 @@ constexpr const char* kForeignArchitectures[] = {
|
|||||||
};
|
};
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
struct HostGpus {
|
constexpr unsigned int kPciVendorAmd = 0x1002;
|
||||||
bool intel = false;
|
constexpr unsigned int kPciVendorNvidia = 0x10de;
|
||||||
bool nvidia = false;
|
constexpr unsigned int kPciVendorIntel = 0x8086;
|
||||||
bool amd = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
bool ForeignArchitecture(const std::string& name) {
|
bool ForeignArchitecture(const std::string& name) {
|
||||||
for (const char* architecture : kForeignArchitectures) {
|
for (const char* architecture : kForeignArchitectures) {
|
||||||
@@ -110,75 +109,86 @@ bool ParsePciVendor(const std::string& raw, unsigned int* vendor) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
HostGpus DetectHostGpus() {
|
std::vector<HostGpu> DetectHostGpus() {
|
||||||
HostGpus gpus;
|
std::vector<HostGpu> gpus;
|
||||||
for (int index = 0; index < 16; ++index) {
|
for (int index = 0; index < 16; ++index) {
|
||||||
const std::string path = "/sys/class/drm/card" + std::to_string(index) +
|
const std::string device =
|
||||||
"/device/vendor";
|
"/sys/class/drm/card" + std::to_string(index) + "/device";
|
||||||
std::ifstream input(path);
|
std::ifstream input(device + "/vendor");
|
||||||
std::string raw;
|
std::string raw;
|
||||||
unsigned int vendor = 0;
|
HostGpu gpu;
|
||||||
if (!(input >> raw) || !ParsePciVendor(raw, &vendor)) {
|
if (!(input >> raw) || !ParsePciVendor(raw, &gpu.vendor)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (vendor == 0x8086) {
|
std::error_code error;
|
||||||
gpus.intel = true;
|
const std::filesystem::path driver =
|
||||||
} else if (vendor == 0x10de) {
|
std::filesystem::read_symlink(device + "/driver", error);
|
||||||
gpus.nvidia = true;
|
if (!error) {
|
||||||
} else if (vendor == 0x1002) {
|
gpu.kernel_driver = driver.filename().string();
|
||||||
gpus.amd = true;
|
|
||||||
}
|
}
|
||||||
|
gpus.push_back(std::move(gpu));
|
||||||
}
|
}
|
||||||
return gpus;
|
return gpus;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string FindIcdFile(const char* filename_needle) {
|
bool HasVendor(const std::vector<HostGpu>& gpus, unsigned int vendor) {
|
||||||
if (filename_needle == nullptr || filename_needle[0] == '\0') {
|
return std::any_of(gpus.begin(), gpus.end(), [vendor](const HostGpu& gpu) {
|
||||||
return {};
|
return gpu.vendor == vendor;
|
||||||
}
|
});
|
||||||
std::vector<std::filesystem::path> directories;
|
|
||||||
for (const char* directory : kIcdDirectories) {
|
|
||||||
directories.emplace_back(directory);
|
|
||||||
}
|
|
||||||
return SelectVulkanIcdManifest(directories, filename_needle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string SelectHardwareIcd(const HostGpus& gpus) {
|
const char* VendorName(unsigned int vendor) {
|
||||||
const bool prefer_discrete = !EnvIsOff("DRI_PRIME") &&
|
switch (vendor) {
|
||||||
!EnvIsOff("__NV_PRIME_RENDER_OFFLOAD");
|
case kPciVendorAmd:
|
||||||
if (prefer_discrete && gpus.nvidia) {
|
return "AMD";
|
||||||
std::string nvidia = FindIcdFile("nvidia_icd");
|
case kPciVendorNvidia:
|
||||||
if (nvidia.empty()) {
|
return "NVIDIA";
|
||||||
nvidia = FindIcdFile("nouveau_icd");
|
case kPciVendorIntel:
|
||||||
}
|
return "Intel";
|
||||||
if (!nvidia.empty()) {
|
default:
|
||||||
return nvidia;
|
return "unknown";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (prefer_discrete && gpus.amd) {
|
}
|
||||||
const std::string amd = FindIcdFile("radeon_icd");
|
|
||||||
if (!amd.empty()) {
|
// ICD manifest names that can drive `gpu`, best first. Empty when the bound
|
||||||
return amd;
|
// kernel driver has no Vulkan driver at all.
|
||||||
}
|
std::vector<const char*> VulkanIcdsFor(const HostGpu& gpu) {
|
||||||
|
switch (gpu.vendor) {
|
||||||
|
case kPciVendorNvidia:
|
||||||
|
if (gpu.kernel_driver == "nvidia") {
|
||||||
|
return {"nvidia_icd"};
|
||||||
|
}
|
||||||
|
if (gpu.kernel_driver == "nouveau") {
|
||||||
|
return {"nouveau_icd"};
|
||||||
|
}
|
||||||
|
return {"nvidia_icd", "nouveau_icd"};
|
||||||
|
case kPciVendorAmd:
|
||||||
|
// RADV needs the amdgpu kernel driver.
|
||||||
|
if (gpu.kernel_driver == "radeon") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {"radeon_icd"};
|
||||||
|
case kPciVendorIntel:
|
||||||
|
return {"intel_icd", "intel_hasvk_icd"};
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
if (gpus.intel) {
|
}
|
||||||
std::string intel = FindIcdFile("intel_icd");
|
|
||||||
if (intel.empty()) {
|
std::string SkipReason(const HostGpu& gpu,
|
||||||
intel = FindIcdFile("intel_hasvk_icd");
|
const std::vector<const char*>& icds) {
|
||||||
}
|
std::string reason = std::string(VendorName(gpu.vendor)) + " card";
|
||||||
return intel;
|
if (!gpu.kernel_driver.empty()) {
|
||||||
|
reason += " on the \"" + gpu.kernel_driver + "\" kernel driver";
|
||||||
}
|
}
|
||||||
if (gpus.nvidia) {
|
if (icds.empty()) {
|
||||||
std::string nvidia = FindIcdFile("nvidia_icd");
|
return reason + " has no Vulkan support";
|
||||||
if (nvidia.empty()) {
|
|
||||||
nvidia = FindIcdFile("nouveau_icd");
|
|
||||||
}
|
|
||||||
return nvidia;
|
|
||||||
}
|
}
|
||||||
if (gpus.amd) {
|
reason += " has no installed Vulkan driver (looked for";
|
||||||
return FindIcdFile("radeon_icd");
|
for (const char* icd : icds) {
|
||||||
|
reason += std::string(" ") + icd;
|
||||||
}
|
}
|
||||||
return {};
|
return reason + ")";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match Mesa ANV: 75% of RAM when the machine has more than 4GiB, else 50%.
|
// Match Mesa ANV: 75% of RAM when the machine has more than 4GiB, else 50%.
|
||||||
@@ -197,7 +207,8 @@ const char* AnvSysMemLimitPercent() {
|
|||||||
return "50";
|
return "50";
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ApplyVulkanIcdPolicy(const HostGpus& gpus, std::string* error) {
|
bool ApplyVulkanIcdPolicy(const std::vector<HostGpu>& gpus,
|
||||||
|
std::string* error) {
|
||||||
// Drop software/emulation ICDs even when the user already pinned a driver
|
// Drop software/emulation ICDs even when the user already pinned a driver
|
||||||
// list. Old loaders ignore this variable.
|
// list. Old loaders ignore this variable.
|
||||||
if (!SetDefault("VK_LOADER_DRIVERS_DISABLE",
|
if (!SetDefault("VK_LOADER_DRIVERS_DISABLE",
|
||||||
@@ -210,17 +221,61 @@ bool ApplyVulkanIcdPolicy(const HostGpus& gpus, std::string* error) {
|
|||||||
(existing_icds != nullptr && existing_icds[0] != '\0')) {
|
(existing_icds != nullptr && existing_icds[0] != '\0')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const std::string icd = SelectHardwareIcd(gpus);
|
std::vector<std::filesystem::path> directories;
|
||||||
if (icd.empty()) {
|
for (const char* directory : kIcdDirectories) {
|
||||||
|
directories.emplace_back(directory);
|
||||||
|
}
|
||||||
|
const bool prefer_discrete = !EnvIsOff("DRI_PRIME") &&
|
||||||
|
!EnvIsOff("__NV_PRIME_RENDER_OFFLOAD");
|
||||||
|
const VulkanIcdSelection selection =
|
||||||
|
SelectHostVulkanIcd(gpus, directories, prefer_discrete);
|
||||||
|
for (const std::string& reason : selection.skipped) {
|
||||||
|
std::fprintf(stderr, " [runtime] vulkan: %s\n", reason.c_str());
|
||||||
|
}
|
||||||
|
if (selection.manifest.empty()) {
|
||||||
|
if (!selection.skipped.empty()) {
|
||||||
|
std::fprintf(stderr,
|
||||||
|
" [runtime] vulkan: no GPU here can run Vulkan; set "
|
||||||
|
"graphics.backend: opengl in the config\n");
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
std::fprintf(stderr, " [runtime] vulkan ICD=%s\n", icd.c_str());
|
std::fprintf(stderr, " [runtime] vulkan ICD=%s\n",
|
||||||
return SetDefault("VK_DRIVER_FILES", icd, error) &&
|
selection.manifest.c_str());
|
||||||
SetDefault("VK_ICD_FILENAMES", icd, error);
|
return SetDefault("VK_DRIVER_FILES", selection.manifest, error) &&
|
||||||
|
SetDefault("VK_ICD_FILENAMES", selection.manifest, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
VulkanIcdSelection SelectHostVulkanIcd(
|
||||||
|
const std::vector<HostGpu>& gpus,
|
||||||
|
const std::vector<std::filesystem::path>& directories,
|
||||||
|
bool prefer_discrete) {
|
||||||
|
constexpr unsigned int kDiscreteFirst[] = {kPciVendorNvidia, kPciVendorAmd,
|
||||||
|
kPciVendorIntel};
|
||||||
|
constexpr unsigned int kIntegratedFirst[] = {
|
||||||
|
kPciVendorIntel, kPciVendorNvidia, kPciVendorAmd};
|
||||||
|
VulkanIcdSelection selection;
|
||||||
|
for (const unsigned int vendor :
|
||||||
|
prefer_discrete ? kDiscreteFirst : kIntegratedFirst) {
|
||||||
|
for (const HostGpu& gpu : gpus) {
|
||||||
|
if (gpu.vendor != vendor) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const std::vector<const char*> icds = VulkanIcdsFor(gpu);
|
||||||
|
for (const char* icd : icds) {
|
||||||
|
selection.manifest = SelectVulkanIcdManifest(directories, icd);
|
||||||
|
if (!selection.manifest.empty()) {
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selection.skipped.push_back(SkipReason(gpu, icds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
|
||||||
std::string SelectVulkanIcdManifest(
|
std::string SelectVulkanIcdManifest(
|
||||||
const std::vector<std::filesystem::path>& directories,
|
const std::vector<std::filesystem::path>& directories,
|
||||||
std::string_view vendor) {
|
std::string_view vendor) {
|
||||||
@@ -291,7 +346,7 @@ bool ApplyGraphicsLaunchPolicy(const RuntimeConfig& config,
|
|||||||
if (direct_vulkan) {
|
if (direct_vulkan) {
|
||||||
const char* wsi_mode =
|
const char* wsi_mode =
|
||||||
UnthrottledPresentation(config) ? "immediate" : "mailbox";
|
UnthrottledPresentation(config) ? "immediate" : "mailbox";
|
||||||
const HostGpus gpus = DetectHostGpus();
|
const std::vector<HostGpu> gpus = DetectHostGpus();
|
||||||
if (!SetDefault("MOCKTAIL_CLIENT_SETTINGS_OVERRIDES_JSON",
|
if (!SetDefault("MOCKTAIL_CLIENT_SETTINGS_OVERRIDES_JSON",
|
||||||
kVulkanClientSettingsOverrides, error) ||
|
kVulkanClientSettingsOverrides, error) ||
|
||||||
!SetDefault("ANV_SYS_MEM_LIMIT", AnvSysMemLimitPercent(), error) ||
|
!SetDefault("ANV_SYS_MEM_LIMIT", AnvSysMemLimitPercent(), error) ||
|
||||||
@@ -304,7 +359,9 @@ bool ApplyGraphicsLaunchPolicy(const RuntimeConfig& config,
|
|||||||
}
|
}
|
||||||
// Low FRM only on Intel-only machines. Hybrid NVIDIA/AMD laptops should
|
// Low FRM only on Intel-only machines. Hybrid NVIDIA/AMD laptops should
|
||||||
// keep the desktop quality default on the discrete GPU.
|
// keep the desktop quality default on the discrete GPU.
|
||||||
if (gpus.intel && !gpus.nvidia && !gpus.amd &&
|
if (HasVendor(gpus, kPciVendorIntel) &&
|
||||||
|
!HasVendor(gpus, kPciVendorNvidia) &&
|
||||||
|
!HasVendor(gpus, kPciVendorAmd) &&
|
||||||
!SetDefault("MOCKTAIL_GRAPHICS_QUALITY", "1", error)) {
|
!SetDefault("MOCKTAIL_GRAPHICS_QUALITY", "1", error)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,7 +243,9 @@ bool MergePerformanceClientSettingsOverrides(
|
|||||||
|
|
||||||
// ForceCacheSize settings are byte counts. Let Roblox size its mesh and
|
// ForceCacheSize settings are byte counts. Let Roblox size its mesh and
|
||||||
// SLIM content caches; values like 256/128 would cap them to a few bytes.
|
// SLIM content caches; values like 256/128 would cap them to a few bytes.
|
||||||
const std::array<ClientSetting, 68> rendering_settings = {{
|
// Leave MSAA selection to Roblox or explicit client-settings overrides.
|
||||||
|
// Forcing one sample disables anti-aliasing even at maximum quality.
|
||||||
|
const std::array<ClientSetting, 67> rendering_settings = {{
|
||||||
{"FIntSmoothClusterTaskQueueMaxParallelTasks", workers},
|
{"FIntSmoothClusterTaskQueueMaxParallelTasks", workers},
|
||||||
{"FIntOcclusionWorkerThreadCount", occlusion_workers},
|
{"FIntOcclusionWorkerThreadCount", occlusion_workers},
|
||||||
{"FFlagMovePrerenderV2", "True"},
|
{"FFlagMovePrerenderV2", "True"},
|
||||||
@@ -300,7 +302,6 @@ bool MergePerformanceClientSettingsOverrides(
|
|||||||
{"FFlagRenderAllocateShadowMapResourcesOnDemand", "True"},
|
{"FFlagRenderAllocateShadowMapResourcesOnDemand", "True"},
|
||||||
{"FIntRenderShadowMapDepthCacheMemLimit", "16"},
|
{"FIntRenderShadowMapDepthCacheMemLimit", "16"},
|
||||||
{"FIntTM2ShadowMapMaxMips", "1"},
|
{"FIntTM2ShadowMapMaxMips", "1"},
|
||||||
{"FIntDebugForceMSAASamples", "1"},
|
|
||||||
{"FFlagLuauStartupGcSuppression", "False"},
|
{"FFlagLuauStartupGcSuppression", "False"},
|
||||||
{"FIntLuauGcStepMul", "300"},
|
{"FIntLuauGcStepMul", "300"},
|
||||||
{"FIntLuauGcGoalCore", "120"},
|
{"FIntLuauGcGoalCore", "120"},
|
||||||
|
|||||||
@@ -359,46 +359,43 @@ RobloxThemeCacheResult ReadRobloxThemeCache(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto read_theme = [&result](const nlohmann::json& value,
|
const auto read_theme = [](const nlohmann::json& value) -> std::optional<bool> {
|
||||||
std::string_view name) {
|
|
||||||
if (!value.is_string()) {
|
if (!value.is_string()) {
|
||||||
result.error = std::string(name) + " is not a string";
|
return std::nullopt;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const std::string& theme = value.get_ref<const std::string&>();
|
const std::string& theme = value.get_ref<const std::string&>();
|
||||||
if (theme != "dark" && theme != "light") {
|
if (theme == "dark") {
|
||||||
result.error = std::string(name) + " must be dark or light";
|
return true;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
result.dark_theme = theme == "dark";
|
if (theme == "light") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authenticated_user_id >= 0) {
|
if (authenticated_user_id >= 0) {
|
||||||
const auto encoded_device_themes = storage.value.find("DeviceLevelTheme");
|
const auto encoded_device_themes = storage.value.find("DeviceLevelTheme");
|
||||||
if (encoded_device_themes != storage.value.end()) {
|
if (encoded_device_themes != storage.value.end() &&
|
||||||
if (!encoded_device_themes->is_string()) {
|
encoded_device_themes->is_string()) {
|
||||||
result.error = "Roblox DeviceLevelTheme is not an encoded object";
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
const nlohmann::json device_themes = nlohmann::json::parse(
|
const nlohmann::json device_themes = nlohmann::json::parse(
|
||||||
encoded_device_themes->get_ref<const std::string&>(), nullptr, false,
|
encoded_device_themes->get_ref<const std::string&>(), nullptr, false,
|
||||||
true);
|
true);
|
||||||
if (device_themes.is_discarded() || !device_themes.is_object()) {
|
if (device_themes.is_object()) {
|
||||||
result.error = "Roblox DeviceLevelTheme encoding is invalid";
|
const auto account_theme =
|
||||||
return result;
|
device_themes.find(std::to_string(authenticated_user_id));
|
||||||
}
|
if (account_theme != device_themes.end()) {
|
||||||
const auto account_theme =
|
result.dark_theme = read_theme(*account_theme);
|
||||||
device_themes.find(std::to_string(authenticated_user_id));
|
if (result.dark_theme.has_value()) {
|
||||||
if (account_theme != device_themes.end()) {
|
return result;
|
||||||
read_theme(*account_theme, "Roblox account theme");
|
}
|
||||||
return result;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto authenticated_theme = storage.value.find("AuthenticatedTheme");
|
const auto authenticated_theme = storage.value.find("AuthenticatedTheme");
|
||||||
if (authenticated_theme != storage.value.end()) {
|
if (authenticated_theme != storage.value.end()) {
|
||||||
read_theme(*authenticated_theme, "Roblox authenticated theme");
|
result.dark_theme = read_theme(*authenticated_theme);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#include "runtime/roblox_call_protocol_bridge.h"
|
||||||
|
|
||||||
|
namespace mocktail {
|
||||||
|
namespace runtime {
|
||||||
|
|
||||||
|
RobloxCallProtocolBridge::RobloxCallProtocolBridge(
|
||||||
|
JniEnvironmentProvider environment, RobloxMessageBusSymbols symbols,
|
||||||
|
RobloxMessageBusObjects objects)
|
||||||
|
: bridge_(environment, symbols, objects, "Call", {"getCallState"},
|
||||||
|
[](JNIEnv* env, jstring request, std::size_t) {
|
||||||
|
// CallProtocolCore registers the wire protocol "Call" (not the
|
||||||
|
// Lua module name "CallProtocol"). Its handler ignores the
|
||||||
|
// payload and returns CallState::serializeCallStateJson. Match
|
||||||
|
// the no-call state; preserve the caller's default-muted
|
||||||
|
// initialization path.
|
||||||
|
if (request && env->GetStringUTFLength(request) > 65536) {
|
||||||
|
return RobloxMessageBusReply{"{}", 13, "oversized_request"};
|
||||||
|
}
|
||||||
|
return RobloxMessageBusReply{
|
||||||
|
R"({"status":"Idle","muted":true,"camEnabled":false})", 0,
|
||||||
|
"idle"};
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
Status RobloxCallProtocolBridge::Initialize() {
|
||||||
|
return bridge_.Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
Status RobloxCallProtocolBridge::Shutdown() {
|
||||||
|
return bridge_.Shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace runtime
|
||||||
|
} // namespace mocktail
|
||||||
@@ -361,6 +361,10 @@ bool MergeDesktopAppPolicyClientSettingsOverride(std::string_view policy_json,
|
|||||||
// leave the whole LuaApp blank. Default to the working legacy page while
|
// leave the whole LuaApp blank. Default to the working legacy page while
|
||||||
// preserving explicit user overrides for testing newer UI patches.
|
// preserving explicit user overrides for testing newer UI patches.
|
||||||
overrides.emplace("FFlagLuaAppChartsAppPage", "False");
|
overrides.emplace("FFlagLuaAppChartsAppPage", "False");
|
||||||
|
// Roblox enrolls desktop clients in the flyout side panel through a
|
||||||
|
// server-side experiment that does not reach this client. Variant 2 is the
|
||||||
|
// desktop side panel; an explicit user override is preserved.
|
||||||
|
overrides.emplace("FIntFlyoutTestVariantRollout12", "2");
|
||||||
*merged_json = overrides.dump();
|
*merged_json = overrides.dump();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,11 +138,16 @@ Status PublishSystemTheme(JNIEnv* env,
|
|||||||
status = CheckJni(env, "publish SystemTheme.systemThemeUpdated");
|
status = CheckJni(env, "publish SystemTheme.systemThemeUpdated");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message_id != nullptr) env->DeleteLocalRef(message_id);
|
if (message_id != nullptr)
|
||||||
if (payload != nullptr) env->DeleteLocalRef(payload);
|
env->DeleteLocalRef(message_id);
|
||||||
if (message != nullptr) env->DeleteLocalRef(message);
|
if (payload != nullptr)
|
||||||
if (protocol != nullptr) env->DeleteLocalRef(protocol);
|
env->DeleteLocalRef(payload);
|
||||||
if (message_bus_class != nullptr) env->DeleteLocalRef(message_bus_class);
|
if (message != nullptr)
|
||||||
|
env->DeleteLocalRef(message);
|
||||||
|
if (protocol != nullptr)
|
||||||
|
env->DeleteLocalRef(protocol);
|
||||||
|
if (message_bus_class != nullptr)
|
||||||
|
env->DeleteLocalRef(message_bus_class);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +208,8 @@ template <>
|
|||||||
bool SetField<jint>(JNIEnv* env, jobject object, jclass clazz, const char* name,
|
bool SetField<jint>(JNIEnv* env, jobject object, jclass clazz, const char* name,
|
||||||
const char* signature, jint value) {
|
const char* signature, jint value) {
|
||||||
jfieldID field = env->GetFieldID(clazz, name, signature);
|
jfieldID field = env->GetFieldID(clazz, name, signature);
|
||||||
if (field == nullptr) return false;
|
if (field == nullptr)
|
||||||
|
return false;
|
||||||
env->SetIntField(object, field, value);
|
env->SetIntField(object, field, value);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -212,7 +218,8 @@ template <>
|
|||||||
bool SetField<jlong>(JNIEnv* env, jobject object, jclass clazz,
|
bool SetField<jlong>(JNIEnv* env, jobject object, jclass clazz,
|
||||||
const char* name, const char* signature, jlong value) {
|
const char* name, const char* signature, jlong value) {
|
||||||
jfieldID field = env->GetFieldID(clazz, name, signature);
|
jfieldID field = env->GetFieldID(clazz, name, signature);
|
||||||
if (field == nullptr) return false;
|
if (field == nullptr)
|
||||||
|
return false;
|
||||||
env->SetLongField(object, field, value);
|
env->SetLongField(object, field, value);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -222,7 +229,8 @@ bool SetField<jboolean>(JNIEnv* env, jobject object, jclass clazz,
|
|||||||
const char* name, const char* signature,
|
const char* name, const char* signature,
|
||||||
jboolean value) {
|
jboolean value) {
|
||||||
jfieldID field = env->GetFieldID(clazz, name, signature);
|
jfieldID field = env->GetFieldID(clazz, name, signature);
|
||||||
if (field == nullptr) return false;
|
if (field == nullptr)
|
||||||
|
return false;
|
||||||
env->SetBooleanField(object, field, value);
|
env->SetBooleanField(object, field, value);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -231,7 +239,8 @@ template <>
|
|||||||
bool SetField<jfloat>(JNIEnv* env, jobject object, jclass clazz,
|
bool SetField<jfloat>(JNIEnv* env, jobject object, jclass clazz,
|
||||||
const char* name, const char* signature, jfloat value) {
|
const char* name, const char* signature, jfloat value) {
|
||||||
jfieldID field = env->GetFieldID(clazz, name, signature);
|
jfieldID field = env->GetFieldID(clazz, name, signature);
|
||||||
if (field == nullptr) return false;
|
if (field == nullptr)
|
||||||
|
return false;
|
||||||
env->SetFloatField(object, field, value);
|
env->SetFloatField(object, field, value);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -240,7 +249,8 @@ template <>
|
|||||||
bool SetField<jobject>(JNIEnv* env, jobject object, jclass clazz,
|
bool SetField<jobject>(JNIEnv* env, jobject object, jclass clazz,
|
||||||
const char* name, const char* signature, jobject value) {
|
const char* name, const char* signature, jobject value) {
|
||||||
jfieldID field = env->GetFieldID(clazz, name, signature);
|
jfieldID field = env->GetFieldID(clazz, name, signature);
|
||||||
if (field == nullptr) return false;
|
if (field == nullptr)
|
||||||
|
return false;
|
||||||
env->SetObjectField(object, field, value);
|
env->SetObjectField(object, field, value);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -352,7 +362,9 @@ RobloxExperienceComposition::RobloxExperienceComposition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RobloxExperienceComposition::~RobloxExperienceComposition() { Shutdown(); }
|
RobloxExperienceComposition::~RobloxExperienceComposition() {
|
||||||
|
Shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
Status RobloxExperienceComposition::InitializePlatformProtocols() {
|
Status RobloxExperienceComposition::InitializePlatformProtocols() {
|
||||||
if (!web_view_cookie_initialization_error_.empty()) {
|
if (!web_view_cookie_initialization_error_.empty()) {
|
||||||
@@ -366,7 +378,8 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
|
|||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
if (platform_protocols_initialized_ || objects_ != nullptr ||
|
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");
|
return FailedPrecondition("platform protocols are already initialized");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,6 +456,16 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
|
|||||||
(void)ReleaseGlobalObjects();
|
(void)ReleaseGlobalObjects();
|
||||||
return status;
|
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
|
// AccountProtocol only shortens a device-integrity login challenge that
|
||||||
// cannot succeed here, so its absence or failure never blocks startup.
|
// cannot succeed here, so its absence or failure never blocks startup.
|
||||||
std::unique_ptr<RobloxAccountBridge> account_bridge;
|
std::unique_ptr<RobloxAccountBridge> account_bridge;
|
||||||
@@ -469,12 +492,13 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
|
|||||||
web_view_bridge_ = std::move(web_view_bridge);
|
web_view_bridge_ = std::move(web_view_bridge);
|
||||||
browser_service_bridge_ = std::move(browser_service_bridge);
|
browser_service_bridge_ = std::move(browser_service_bridge);
|
||||||
permissions_bridge_ = std::move(permissions_bridge);
|
permissions_bridge_ = std::move(permissions_bridge);
|
||||||
|
call_protocol_bridge_ = std::move(call_protocol_bridge);
|
||||||
account_bridge_ = std::move(account_bridge);
|
account_bridge_ = std::move(account_bridge);
|
||||||
platform_protocols_initialized_ = true;
|
platform_protocols_initialized_ = true;
|
||||||
}
|
}
|
||||||
std::fprintf(stderr,
|
std::fprintf(stderr,
|
||||||
" [platform] Android WebViewProtocol, BrowserService and "
|
" [platform] Android WebViewProtocol, BrowserService and "
|
||||||
"PermissionsProtocol "
|
"PermissionsProtocol and CallProtocol "
|
||||||
"bridges ready\n");
|
"bridges ready\n");
|
||||||
return Status::Ok();
|
return Status::Ok();
|
||||||
}
|
}
|
||||||
@@ -494,7 +518,8 @@ Status RobloxExperienceComposition::OnLuaAppReady(
|
|||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
if (!platform_protocols_initialized_ || objects_ == nullptr ||
|
if (!platform_protocols_initialized_ || objects_ == nullptr ||
|
||||||
objects_->message_bus == nullptr || web_view_bridge_ == 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(
|
return FailedPrecondition(
|
||||||
"platform protocols must be initialized before LuaApp readiness");
|
"platform protocols must be initialized before LuaApp readiness");
|
||||||
}
|
}
|
||||||
@@ -549,7 +574,8 @@ Status RobloxExperienceComposition::OnLuaAppReady(
|
|||||||
controller_.reset();
|
controller_.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!status.ok()) (void)ReleaseLuaAppGlobalObjects();
|
if (!status.ok())
|
||||||
|
(void)ReleaseLuaAppGlobalObjects();
|
||||||
if (status.ok()) {
|
if (status.ok()) {
|
||||||
std::fprintf(stderr,
|
std::fprintf(stderr,
|
||||||
" [theme] published SystemTheme.systemThemeUpdated (%s)\n",
|
" [theme] published SystemTheme.systemThemeUpdated (%s)\n",
|
||||||
@@ -622,8 +648,7 @@ Status RobloxExperienceComposition::OpenWebSurface(
|
|||||||
!current->SetBackNavigationDisabled(
|
!current->SetBackNavigationDisabled(
|
||||||
presentation.back_navigation_disabled) ||
|
presentation.back_navigation_disabled) ||
|
||||||
!current->SetShowDomainAsTitle(presentation.show_domain_as_title) ||
|
!current->SetShowDomainAsTitle(presentation.show_domain_as_title) ||
|
||||||
!cookie_synchronized ||
|
!cookie_synchronized || (!spawn && !current->LoadUrl(url))) {
|
||||||
(!spawn && !current->LoadUrl(url))) {
|
|
||||||
(void)current->RequestClose();
|
(void)current->RequestClose();
|
||||||
return Unavailable("could not configure reusable Roblox web surface");
|
return Unavailable("could not configure reusable Roblox web surface");
|
||||||
}
|
}
|
||||||
@@ -1211,10 +1236,10 @@ Status RobloxExperienceComposition::RefreshLateSurface() {
|
|||||||
SurfaceUpdateStatus(SurfaceCreated(event.surface.generation));
|
SurfaceUpdateStatus(SurfaceCreated(event.surface.generation));
|
||||||
break;
|
break;
|
||||||
case window::WindowSurfaceEventType::kChanged:
|
case window::WindowSurfaceEventType::kChanged:
|
||||||
status = SurfaceUpdateStatus(SurfaceChanged(
|
status = SurfaceUpdateStatus(
|
||||||
{event.surface.generation, event.surface.native_window,
|
SurfaceChanged({event.surface.generation,
|
||||||
event.surface.width, event.surface.height,
|
event.surface.native_window, event.surface.width,
|
||||||
event.surface.dpi_scale}));
|
event.surface.height, event.surface.dpi_scale}));
|
||||||
break;
|
break;
|
||||||
case window::WindowSurfaceEventType::kDestroyed:
|
case window::WindowSurfaceEventType::kDestroyed:
|
||||||
status =
|
status =
|
||||||
@@ -1338,14 +1363,16 @@ Status RobloxExperienceComposition::DrainExternalLaunchRequests() {
|
|||||||
|
|
||||||
void RobloxExperienceComposition::NotifyLuaAppDidReturn() {
|
void RobloxExperienceComposition::NotifyLuaAppDidReturn() {
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
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 RobloxExperienceComposition::DrainLaunchRequests() {
|
||||||
Status completed_result = Status::Ok();
|
Status completed_result = Status::Ok();
|
||||||
if (launch_worker_.joinable()) {
|
if (launch_worker_.joinable()) {
|
||||||
const OwnedPthreadWaitResult wait = launch_worker_.WaitFor(0, 1);
|
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()) {
|
if (!wait.joined()) {
|
||||||
return Status::Error(StatusCode::kPlatformError,
|
return Status::Error(StatusCode::kPlatformError,
|
||||||
"could not reap experience launch worker: " +
|
"could not reap experience launch worker: " +
|
||||||
@@ -1405,7 +1432,8 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Status external_launch_status = DrainExternalLaunchRequests();
|
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;
|
bool switch_active_game = false;
|
||||||
std::optional<RobloxExperienceLaunchRequest> joining_request;
|
std::optional<RobloxExperienceLaunchRequest> joining_request;
|
||||||
@@ -1414,7 +1442,8 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
|||||||
if (!subscribed_ || controller_ == nullptr || objects_ == nullptr) {
|
if (!subscribed_ || controller_ == nullptr || objects_ == nullptr) {
|
||||||
return FailedPrecondition("experience composition is not subscribed");
|
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_ &&
|
if (!launch_in_progress_ && active_launch_ == nullptr && game_active_ &&
|
||||||
!pending_launch_requests_.empty()) {
|
!pending_launch_requests_.empty()) {
|
||||||
// Start the queued launch only after consuming this leave callback, so
|
// Start the queued launch only after consuming this leave callback, so
|
||||||
@@ -1439,9 +1468,12 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
|||||||
if (!subscribed_ || controller_ == nullptr || objects_ == nullptr) {
|
if (!subscribed_ || controller_ == nullptr || objects_ == nullptr) {
|
||||||
return FailedPrecondition("experience composition is not subscribed");
|
return FailedPrecondition("experience composition is not subscribed");
|
||||||
}
|
}
|
||||||
if (launch_in_progress_ || active_launch_ != nullptr) return Status::Ok();
|
if (launch_in_progress_ || active_launch_ != nullptr)
|
||||||
if (game_active_) return Status::Ok();
|
return Status::Ok();
|
||||||
if (pending_launch_requests_.empty()) return Status::Ok();
|
if (game_active_)
|
||||||
|
return Status::Ok();
|
||||||
|
if (pending_launch_requests_.empty())
|
||||||
|
return Status::Ok();
|
||||||
if (next_request_id_ == 0 ||
|
if (next_request_id_ == 0 ||
|
||||||
next_request_id_ == std::numeric_limits<uint64_t>::max()) {
|
next_request_id_ == std::numeric_limits<uint64_t>::max()) {
|
||||||
pending_launch_requests_.clear();
|
pending_launch_requests_.clear();
|
||||||
@@ -1477,7 +1509,8 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
|||||||
const int start_error =
|
const int start_error =
|
||||||
launch_worker_.Start(&RobloxExperienceComposition::RunLaunchWorker, this,
|
launch_worker_.Start(&RobloxExperienceComposition::RunLaunchWorker, this,
|
||||||
kLaunchWorkerStackSize);
|
kLaunchWorkerStackSize);
|
||||||
if (start_error == 0) return Status::Ok();
|
if (start_error == 0)
|
||||||
|
return Status::Ok();
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
launch_in_progress_ = false;
|
launch_in_progress_ = false;
|
||||||
@@ -1534,7 +1567,8 @@ void RobloxExperienceComposition::RunActiveLaunch() {
|
|||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
task = active_launch_.get();
|
task = active_launch_.get();
|
||||||
}
|
}
|
||||||
if (task == nullptr) return;
|
if (task == nullptr)
|
||||||
|
return;
|
||||||
Status status =
|
Status status =
|
||||||
BuildLaunchObjects(task->context.game_surface, &task->context.surface,
|
BuildLaunchObjects(task->context.game_surface, &task->context.surface,
|
||||||
&task->context.platform_params);
|
&task->context.platform_params);
|
||||||
@@ -1658,6 +1692,7 @@ Status RobloxExperienceComposition::Shutdown() {
|
|||||||
std::unique_ptr<RobloxWebViewBridge> web_view_bridge;
|
std::unique_ptr<RobloxWebViewBridge> web_view_bridge;
|
||||||
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge;
|
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge;
|
||||||
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge;
|
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge;
|
||||||
|
std::unique_ptr<RobloxCallProtocolBridge> call_protocol_bridge;
|
||||||
std::unique_ptr<RobloxAccountBridge> account_bridge;
|
std::unique_ptr<RobloxAccountBridge> account_bridge;
|
||||||
std::shared_ptr<WebViewHelperProcess> web_surface_process;
|
std::shared_ptr<WebViewHelperProcess> web_surface_process;
|
||||||
jnivm::VM* late_lifecycle_vm = nullptr;
|
jnivm::VM* late_lifecycle_vm = nullptr;
|
||||||
@@ -1683,6 +1718,7 @@ Status RobloxExperienceComposition::Shutdown() {
|
|||||||
web_view_bridge = std::move(web_view_bridge_);
|
web_view_bridge = std::move(web_view_bridge_);
|
||||||
browser_service_bridge = std::move(browser_service_bridge_);
|
browser_service_bridge = std::move(browser_service_bridge_);
|
||||||
permissions_bridge = std::move(permissions_bridge_);
|
permissions_bridge = std::move(permissions_bridge_);
|
||||||
|
call_protocol_bridge = std::move(call_protocol_bridge_);
|
||||||
account_bridge = std::move(account_bridge_);
|
account_bridge = std::move(account_bridge_);
|
||||||
web_surface_process = std::move(web_surface_process_);
|
web_surface_process = std::move(web_surface_process_);
|
||||||
web_surface_logical_exit_observer_ = {};
|
web_surface_logical_exit_observer_ = {};
|
||||||
@@ -1700,16 +1736,23 @@ Status RobloxExperienceComposition::Shutdown() {
|
|||||||
if (web_surface_process != nullptr) {
|
if (web_surface_process != nullptr) {
|
||||||
(void)web_surface_process->RequestClose();
|
(void)web_surface_process->RequestClose();
|
||||||
}
|
}
|
||||||
Status status =
|
Status status = call_protocol_bridge != nullptr
|
||||||
|
? call_protocol_bridge->Shutdown()
|
||||||
|
: Status::Ok();
|
||||||
|
const Status account_status =
|
||||||
account_bridge != nullptr ? account_bridge->Shutdown() : Status::Ok();
|
account_bridge != nullptr ? account_bridge->Shutdown() : Status::Ok();
|
||||||
|
if (status.ok())
|
||||||
|
status = account_status;
|
||||||
const Status permissions_status = permissions_bridge != nullptr
|
const Status permissions_status = permissions_bridge != nullptr
|
||||||
? permissions_bridge->Shutdown()
|
? permissions_bridge->Shutdown()
|
||||||
: Status::Ok();
|
: Status::Ok();
|
||||||
if (status.ok()) status = permissions_status;
|
if (status.ok())
|
||||||
|
status = permissions_status;
|
||||||
const Status browser_status = browser_service_bridge != nullptr
|
const Status browser_status = browser_service_bridge != nullptr
|
||||||
? browser_service_bridge->Shutdown()
|
? browser_service_bridge->Shutdown()
|
||||||
: Status::Ok();
|
: Status::Ok();
|
||||||
if (status.ok()) status = browser_status;
|
if (status.ok())
|
||||||
|
status = browser_status;
|
||||||
const Status web_view_status =
|
const Status web_view_status =
|
||||||
web_view_bridge != nullptr ? web_view_bridge->Shutdown() : Status::Ok();
|
web_view_bridge != nullptr ? web_view_bridge->Shutdown() : Status::Ok();
|
||||||
if (status.ok()) {
|
if (status.ok()) {
|
||||||
@@ -1721,6 +1764,7 @@ Status RobloxExperienceComposition::Shutdown() {
|
|||||||
status = bridge_status;
|
status = bridge_status;
|
||||||
}
|
}
|
||||||
browser_service_bridge.reset();
|
browser_service_bridge.reset();
|
||||||
|
call_protocol_bridge.reset();
|
||||||
account_bridge.reset();
|
account_bridge.reset();
|
||||||
permissions_bridge.reset();
|
permissions_bridge.reset();
|
||||||
web_view_bridge.reset();
|
web_view_bridge.reset();
|
||||||
@@ -1739,14 +1783,16 @@ Status RobloxExperienceComposition::Shutdown() {
|
|||||||
launch_in_progress_ = false;
|
launch_in_progress_ = false;
|
||||||
lua_app_return_pending_ = false;
|
lua_app_return_pending_ = false;
|
||||||
if (active_launch_ != nullptr) {
|
if (active_launch_ != nullptr) {
|
||||||
if (status.ok()) status = active_launch_->result;
|
if (status.ok())
|
||||||
|
status = active_launch_->result;
|
||||||
active_launch_.reset();
|
active_launch_.reset();
|
||||||
}
|
}
|
||||||
controller = std::move(controller_);
|
controller = std::move(controller_);
|
||||||
}
|
}
|
||||||
if (controller != nullptr) {
|
if (controller != nullptr) {
|
||||||
const Status controller_status = controller->Shutdown();
|
const Status controller_status = controller->Shutdown();
|
||||||
if (status.ok()) status = controller_status;
|
if (status.ok())
|
||||||
|
status = controller_status;
|
||||||
}
|
}
|
||||||
const Status release = ReleaseGlobalObjects();
|
const Status release = ReleaseGlobalObjects();
|
||||||
return status.ok() ? release : status;
|
return status.ok() ? release : status;
|
||||||
@@ -1777,7 +1823,8 @@ void RobloxExperienceComposition::NotifyPresence(
|
|||||||
Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
|
Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
|
||||||
JNIEnv* env = nullptr;
|
JNIEnv* env = nullptr;
|
||||||
Status status = environment_.Acquire(&env);
|
Status status = environment_.Acquire(&env);
|
||||||
if (!status.ok()) return status;
|
if (!status.ok())
|
||||||
|
return status;
|
||||||
|
|
||||||
jobject bus_local = nullptr;
|
jobject bus_local = nullptr;
|
||||||
jclass bus_class =
|
jclass bus_class =
|
||||||
@@ -1791,7 +1838,8 @@ Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
|
|||||||
bus_local = singleton != nullptr
|
bus_local = singleton != nullptr
|
||||||
? env->CallStaticObjectMethod(bus_class, singleton)
|
? env->CallStaticObjectMethod(bus_class, singleton)
|
||||||
: nullptr;
|
: nullptr;
|
||||||
if (bus_class != nullptr) env->DeleteLocalRef(bus_class);
|
if (bus_class != nullptr)
|
||||||
|
env->DeleteLocalRef(bus_class);
|
||||||
status = bus_local != nullptr
|
status = bus_local != nullptr
|
||||||
? CheckJni(env, "obtain exact MessageBus.f singleton")
|
? CheckJni(env, "obtain exact MessageBus.f singleton")
|
||||||
: Unavailable("MessageBus.f singleton is unavailable");
|
: Unavailable("MessageBus.f singleton is unavailable");
|
||||||
@@ -1804,7 +1852,8 @@ Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bus_local != nullptr) env->DeleteLocalRef(bus_local);
|
if (bus_local != nullptr)
|
||||||
|
env->DeleteLocalRef(bus_local);
|
||||||
if (!status.ok()) {
|
if (!status.ok()) {
|
||||||
if (global->message_bus != nullptr)
|
if (global->message_bus != nullptr)
|
||||||
env->DeleteGlobalRef(global->message_bus);
|
env->DeleteGlobalRef(global->message_bus);
|
||||||
@@ -1822,7 +1871,8 @@ Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
|
|||||||
Status RobloxExperienceComposition::BuildLuaAppGlobalObjects() {
|
Status RobloxExperienceComposition::BuildLuaAppGlobalObjects() {
|
||||||
JNIEnv* env = nullptr;
|
JNIEnv* env = nullptr;
|
||||||
Status status = environment_.Acquire(&env);
|
Status status = environment_.Acquire(&env);
|
||||||
if (!status.ok()) return status;
|
if (!status.ok())
|
||||||
|
return status;
|
||||||
|
|
||||||
jclass native_gl_local =
|
jclass native_gl_local =
|
||||||
env->FindClass("com/roblox/engine/jni/NativeGLInterface");
|
env->FindClass("com/roblox/engine/jni/NativeGLInterface");
|
||||||
@@ -1846,12 +1896,17 @@ Status RobloxExperienceComposition::BuildLuaAppGlobalObjects() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activity_local != nullptr) env->DeleteLocalRef(activity_local);
|
if (activity_local != nullptr)
|
||||||
if (activity_class != nullptr) env->DeleteLocalRef(activity_class);
|
env->DeleteLocalRef(activity_local);
|
||||||
if (native_gl_local != nullptr) env->DeleteLocalRef(native_gl_local);
|
if (activity_class != nullptr)
|
||||||
|
env->DeleteLocalRef(activity_class);
|
||||||
|
if (native_gl_local != nullptr)
|
||||||
|
env->DeleteLocalRef(native_gl_local);
|
||||||
if (!status.ok()) {
|
if (!status.ok()) {
|
||||||
if (activity_global != nullptr) env->DeleteGlobalRef(activity_global);
|
if (activity_global != nullptr)
|
||||||
if (native_gl_global != nullptr) env->DeleteGlobalRef(native_gl_global);
|
env->DeleteGlobalRef(activity_global);
|
||||||
|
if (native_gl_global != nullptr)
|
||||||
|
env->DeleteGlobalRef(native_gl_global);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1878,7 +1933,8 @@ Status RobloxExperienceComposition::BuildLaunchObjects(
|
|||||||
*platform_params = nullptr;
|
*platform_params = nullptr;
|
||||||
JNIEnv* env = nullptr;
|
JNIEnv* env = nullptr;
|
||||||
Status status = environment_.Acquire(&env);
|
Status status = environment_.Acquire(&env);
|
||||||
if (!status.ok()) return status;
|
if (!status.ok())
|
||||||
|
return status;
|
||||||
|
|
||||||
jclass surface_class = nullptr;
|
jclass surface_class = nullptr;
|
||||||
jobject surface_local = nullptr;
|
jobject surface_local = nullptr;
|
||||||
@@ -1912,8 +1968,9 @@ Status RobloxExperienceComposition::BuildLaunchObjects(
|
|||||||
SetField<jobject>(env, params_local, params_class, "assetFolderPath",
|
SetField<jobject>(env, params_local, params_class, "assetFolderPath",
|
||||||
"Ljava/lang/String;", asset_path) &&
|
"Ljava/lang/String;", asset_path) &&
|
||||||
SetField<jfloat>(env, params_local, params_class, "dpiScale", "F",
|
SetField<jfloat>(env, params_local, params_class, "dpiScale", "F",
|
||||||
surface.dpi_scale > 0.0f ? surface.dpi_scale
|
surface.dpi_scale > 0.0f
|
||||||
: surface_config_.dpi_scale) &&
|
? surface.dpi_scale
|
||||||
|
: surface_config_.dpi_scale) &&
|
||||||
SetField<jint>(env, params_local, params_class, "viewportWidthMm", "I",
|
SetField<jint>(env, params_local, params_class, "viewportWidthMm", "I",
|
||||||
surface_config_.viewport_width_mm) &&
|
surface_config_.viewport_width_mm) &&
|
||||||
SetField<jint>(env, params_local, params_class, "viewportHeightMm", "I",
|
SetField<jint>(env, params_local, params_class, "viewportHeightMm", "I",
|
||||||
@@ -1932,12 +1989,17 @@ Status RobloxExperienceComposition::BuildLaunchObjects(
|
|||||||
? CheckJni(env, "populate fresh launch PlatformParams")
|
? CheckJni(env, "populate fresh launch PlatformParams")
|
||||||
: Unavailable("required PlatformParams fields are unavailable");
|
: Unavailable("required PlatformParams fields are unavailable");
|
||||||
}
|
}
|
||||||
if (asset_path != nullptr) env->DeleteLocalRef(asset_path);
|
if (asset_path != nullptr)
|
||||||
if (surface_class != nullptr) env->DeleteLocalRef(surface_class);
|
env->DeleteLocalRef(asset_path);
|
||||||
if (params_class != nullptr) env->DeleteLocalRef(params_class);
|
if (surface_class != nullptr)
|
||||||
|
env->DeleteLocalRef(surface_class);
|
||||||
|
if (params_class != nullptr)
|
||||||
|
env->DeleteLocalRef(params_class);
|
||||||
if (!status.ok()) {
|
if (!status.ok()) {
|
||||||
if (params_local != nullptr) env->DeleteLocalRef(params_local);
|
if (params_local != nullptr)
|
||||||
if (surface_local != nullptr) env->DeleteLocalRef(surface_local);
|
env->DeleteLocalRef(params_local);
|
||||||
|
if (surface_local != nullptr)
|
||||||
|
env->DeleteLocalRef(surface_local);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
*surface_object = surface_local;
|
*surface_object = surface_local;
|
||||||
@@ -2008,13 +2070,19 @@ Status RobloxExperienceComposition::BuildLuaAppStartParams(
|
|||||||
"required LuaApp StartAppParams fields are unavailable");
|
"required LuaApp StartAppParams fields are unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected_theme != nullptr) env->DeleteLocalRef(selected_theme);
|
if (selected_theme != nullptr)
|
||||||
if (username != nullptr) env->DeleteLocalRef(username);
|
env->DeleteLocalRef(selected_theme);
|
||||||
if (app_starter_script != nullptr) env->DeleteLocalRef(app_starter_script);
|
if (username != nullptr)
|
||||||
if (app_starter_place != nullptr) env->DeleteLocalRef(app_starter_place);
|
env->DeleteLocalRef(username);
|
||||||
if (params_class != nullptr) env->DeleteLocalRef(params_class);
|
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 (!status.ok()) {
|
||||||
if (params_local != nullptr) env->DeleteLocalRef(params_local);
|
if (params_local != nullptr)
|
||||||
|
env->DeleteLocalRef(params_local);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
*start_app_params = params_local;
|
*start_app_params = params_local;
|
||||||
@@ -2046,7 +2114,8 @@ Status RobloxExperienceComposition::RestartLuaAppSurface(
|
|||||||
|
|
||||||
JNIEnv* env = nullptr;
|
JNIEnv* env = nullptr;
|
||||||
Status status = environment_.Acquire(&env);
|
Status status = environment_.Acquire(&env);
|
||||||
if (!status.ok()) return status;
|
if (!status.ok())
|
||||||
|
return status;
|
||||||
|
|
||||||
jobject surface_object = nullptr;
|
jobject surface_object = nullptr;
|
||||||
jobject platform_params = nullptr;
|
jobject platform_params = nullptr;
|
||||||
@@ -2086,11 +2155,16 @@ Status RobloxExperienceComposition::RestartLuaAppSurface(
|
|||||||
status = CheckJni(env, "restart LuaApp with recreated surface");
|
status = CheckJni(env, "restart LuaApp with recreated surface");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (start_reason != nullptr) env->DeleteLocalRef(start_reason);
|
if (start_reason != nullptr)
|
||||||
if (stop_reason != nullptr) env->DeleteLocalRef(stop_reason);
|
env->DeleteLocalRef(start_reason);
|
||||||
if (start_app_params != nullptr) env->DeleteLocalRef(start_app_params);
|
if (stop_reason != nullptr)
|
||||||
if (platform_params != nullptr) env->DeleteLocalRef(platform_params);
|
env->DeleteLocalRef(stop_reason);
|
||||||
if (surface_object != nullptr) env->DeleteLocalRef(surface_object);
|
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()) {
|
if (status.ok()) {
|
||||||
std::fprintf(
|
std::fprintf(
|
||||||
stderr,
|
stderr,
|
||||||
@@ -2150,7 +2224,8 @@ Status RobloxExperienceComposition::RebindLuaAppSurface(
|
|||||||
Status RobloxExperienceComposition::ReleaseGlobalObjects() {
|
Status RobloxExperienceComposition::ReleaseGlobalObjects() {
|
||||||
JNIEnv* env = nullptr;
|
JNIEnv* env = nullptr;
|
||||||
Status status = environment_.Acquire(&env);
|
Status status = environment_.Acquire(&env);
|
||||||
if (!status.ok()) return status;
|
if (!status.ok())
|
||||||
|
return status;
|
||||||
std::unique_ptr<GlobalObjects> objects;
|
std::unique_ptr<GlobalObjects> objects;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
@@ -2165,10 +2240,12 @@ Status RobloxExperienceComposition::ReleaseGlobalObjects() {
|
|||||||
pending_launch_requests_.clear();
|
pending_launch_requests_.clear();
|
||||||
next_request_id_ = 1;
|
next_request_id_ = 1;
|
||||||
}
|
}
|
||||||
if (objects == nullptr) return Status::Ok();
|
if (objects == nullptr)
|
||||||
|
return Status::Ok();
|
||||||
if (objects->message_bus != nullptr)
|
if (objects->message_bus != nullptr)
|
||||||
env->DeleteGlobalRef(objects->message_bus);
|
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)
|
if (objects->native_gl_class != nullptr)
|
||||||
env->DeleteGlobalRef(objects->native_gl_class);
|
env->DeleteGlobalRef(objects->native_gl_class);
|
||||||
return CheckJni(env, "release experience composition JNI roots");
|
return CheckJni(env, "release experience composition JNI roots");
|
||||||
@@ -2177,7 +2254,8 @@ Status RobloxExperienceComposition::ReleaseGlobalObjects() {
|
|||||||
Status RobloxExperienceComposition::ReleaseLuaAppGlobalObjects() {
|
Status RobloxExperienceComposition::ReleaseLuaAppGlobalObjects() {
|
||||||
JNIEnv* env = nullptr;
|
JNIEnv* env = nullptr;
|
||||||
Status status = environment_.Acquire(&env);
|
Status status = environment_.Acquire(&env);
|
||||||
if (!status.ok()) return status;
|
if (!status.ok())
|
||||||
|
return status;
|
||||||
|
|
||||||
jclass native_gl_class = nullptr;
|
jclass native_gl_class = nullptr;
|
||||||
jobject activity = nullptr;
|
jobject activity = nullptr;
|
||||||
@@ -2197,8 +2275,10 @@ Status RobloxExperienceComposition::ReleaseLuaAppGlobalObjects() {
|
|||||||
pending_launch_requests_.clear();
|
pending_launch_requests_.clear();
|
||||||
next_request_id_ = 1;
|
next_request_id_ = 1;
|
||||||
}
|
}
|
||||||
if (activity != nullptr) env->DeleteGlobalRef(activity);
|
if (activity != nullptr)
|
||||||
if (native_gl_class != nullptr) env->DeleteGlobalRef(native_gl_class);
|
env->DeleteGlobalRef(activity);
|
||||||
|
if (native_gl_class != nullptr)
|
||||||
|
env->DeleteGlobalRef(native_gl_class);
|
||||||
return CheckJni(env, "release LuaApp experience JNI roots");
|
return CheckJni(env, "release LuaApp experience JNI roots");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -609,13 +609,7 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseButtonLocked(
|
|||||||
}
|
}
|
||||||
const float clamped_x = mouse_x_;
|
const float clamped_x = mouse_x_;
|
||||||
const float clamped_y = mouse_y_;
|
const float clamped_y = mouse_y_;
|
||||||
if (event.pressed) {
|
// Let Roblox decide whether a click releases TextBox focus.
|
||||||
const RobloxTextEditorSnapshot text = text_editor_.Snapshot();
|
|
||||||
if (text.focused) {
|
|
||||||
(void)text_editor_.EndFocusSession(text.textbox_handle, text.generation,
|
|
||||||
true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Status status = sink_.mouse_button(sink_.context, clamped_x, clamped_y,
|
Status status = sink_.mouse_button(sink_.context, clamped_x, clamped_y,
|
||||||
event.pressed, roblox_button);
|
event.pressed, roblox_button);
|
||||||
if (status.ok()) {
|
if (status.ok()) {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
#include <curl/curl.h>
|
#include <curl/curl.h>
|
||||||
|
|
||||||
|
// [patch.py] RequestFollowUser support
|
||||||
|
#include "runtime/roblox_presence_resolver.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -541,14 +544,42 @@ Status ValidateSelector(const LaunchFields& fields) {
|
|||||||
? Status::Ok()
|
? Status::Ok()
|
||||||
: Invalid("RequestPlayTogetherGame requires a conversation");
|
: Invalid("RequestPlayTogetherGame requires a conversation");
|
||||||
}
|
}
|
||||||
|
// [patch.py] RequestFollowUser support
|
||||||
|
if (request == "requestfollowuser") {
|
||||||
|
// By the time ValidateSelector runs, MakeRequest has already
|
||||||
|
// resolved user_id -> place_id and cleared user_id, so check the
|
||||||
|
// resolved place_id here rather than the now-zeroed user_id.
|
||||||
|
return fields.place_id > 0
|
||||||
|
? Status::Ok()
|
||||||
|
: Invalid("RequestFollowUser could not resolve a place");
|
||||||
|
}
|
||||||
return Invalid("Roblox PlaceLauncher request type is unsupported");
|
return Invalid("Roblox PlaceLauncher request type is unsupported");
|
||||||
}
|
}
|
||||||
|
|
||||||
Status MakeRequest(const LaunchFields& fields,
|
// [patch.py] RequestFollowUser support
|
||||||
|
Status MakeRequest(const LaunchFields& fields_in,
|
||||||
RobloxExperienceLaunchRequest* request) {
|
RobloxExperienceLaunchRequest* request) {
|
||||||
if (fields.place_id <= 0) {
|
LaunchFields fields = fields_in;
|
||||||
|
const bool is_follow_user =
|
||||||
|
AsciiLower(fields.request_type) == "requestfollowuser";
|
||||||
|
if (fields.place_id <= 0 && !is_follow_user) {
|
||||||
return Invalid("Roblox launch URI requires a positive placeId");
|
return Invalid("Roblox launch URI requires a positive placeId");
|
||||||
}
|
}
|
||||||
|
if (fields.place_id <= 0 && is_follow_user) {
|
||||||
|
ResolvedFollowUserPlace resolved;
|
||||||
|
const Status resolve_status = ResolveFollowUserPlace(
|
||||||
|
fields.user_id, ReadStoredRoblosecurityCookie(), &resolved);
|
||||||
|
if (!resolve_status.ok()) {
|
||||||
|
return resolve_status;
|
||||||
|
}
|
||||||
|
fields.place_id = resolved.place_id;
|
||||||
|
if (!resolved.game_instance_id.empty()) {
|
||||||
|
fields.game_instance_id = resolved.game_instance_id;
|
||||||
|
}
|
||||||
|
// user_id was only needed to resolve which place to join; clear
|
||||||
|
// it so ValidateSelector does not see two conflicting selectors.
|
||||||
|
fields.user_id = 0;
|
||||||
|
}
|
||||||
Status status = ValidateSelector(fields);
|
Status status = ValidateSelector(fields);
|
||||||
if (!status.ok()) {
|
if (!status.ok()) {
|
||||||
return status;
|
return status;
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
#include "runtime/roblox_message_bus_request_bridge.h"
|
||||||
|
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace mocktail {
|
||||||
|
namespace runtime {
|
||||||
|
namespace {
|
||||||
|
Status Unavailable(const char* message) {
|
||||||
|
return Status::Error(StatusCode::kUnavailable, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Status CheckJni(JNIEnv* env) {
|
||||||
|
if (!env->ExceptionCheck())
|
||||||
|
return Status::Ok();
|
||||||
|
env->ExceptionClear();
|
||||||
|
return Unavailable("MessageBus JNI operation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// JNI local references are never retained across callback invocations.
|
||||||
|
struct LocalString {
|
||||||
|
JNIEnv* env;
|
||||||
|
jstring value;
|
||||||
|
|
||||||
|
LocalString(JNIEnv* source, const char* text)
|
||||||
|
: env(source), value(source->NewStringUTF(text)) {}
|
||||||
|
|
||||||
|
~LocalString() {
|
||||||
|
if (value)
|
||||||
|
env->DeleteLocalRef(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
struct RobloxMessageBusRequestBridge::State {
|
||||||
|
struct Endpoint {
|
||||||
|
std::shared_ptr<State> state;
|
||||||
|
std::size_t method;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Registration {
|
||||||
|
jobject callback = nullptr;
|
||||||
|
jobject handler = nullptr;
|
||||||
|
jobject connection = nullptr;
|
||||||
|
jlong connection_handle = 0;
|
||||||
|
jstring method = nullptr;
|
||||||
|
bool handler_registered = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
RobloxMessageBusSymbols symbols;
|
||||||
|
jobject bus = nullptr;
|
||||||
|
jstring protocol = nullptr;
|
||||||
|
std::string protocol_name;
|
||||||
|
std::vector<std::string> methods;
|
||||||
|
Handler handler;
|
||||||
|
std::vector<Registration> registrations;
|
||||||
|
std::mutex mutex;
|
||||||
|
std::condition_variable drained;
|
||||||
|
bool accepting = true;
|
||||||
|
std::size_t in_flight = 0;
|
||||||
|
|
||||||
|
static void Legacy(void* context, JNIEnv* env, jstring message) {
|
||||||
|
Dispatch(context, env, message, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Async(void* context, JNIEnv* env, jstring message, jstring id) {
|
||||||
|
Dispatch(context, env, message, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Dispatch(void* context, JNIEnv* env, jstring message,
|
||||||
|
jstring id) {
|
||||||
|
const auto* endpoint = static_cast<Endpoint*>(context);
|
||||||
|
const auto state = endpoint->state;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(state->mutex);
|
||||||
|
if (!state->accepting)
|
||||||
|
return;
|
||||||
|
++state->in_flight;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Finish {
|
||||||
|
std::shared_ptr<State> state;
|
||||||
|
|
||||||
|
~Finish() {
|
||||||
|
std::lock_guard<std::mutex> lock(state->mutex);
|
||||||
|
--state->in_flight;
|
||||||
|
state->drained.notify_all();
|
||||||
|
}
|
||||||
|
} finish{state};
|
||||||
|
|
||||||
|
const auto response = state->handler(env, message, endpoint->method);
|
||||||
|
// Request bodies and correlation IDs must never be logged.
|
||||||
|
std::fprintf(stderr, " [messagebus] %s method=%s result=%s transport=%s\n",
|
||||||
|
state->protocol_name.c_str(),
|
||||||
|
state->methods[endpoint->method].c_str(), response.outcome,
|
||||||
|
id ? "async" : "legacy");
|
||||||
|
LocalString body(env, response.json.c_str());
|
||||||
|
LocalString telemetry(env, "{}");
|
||||||
|
LocalString method(env, state->methods[endpoint->method].c_str());
|
||||||
|
const Status allocated = CheckJni(env);
|
||||||
|
if (!body.value || !telemetry.value || !method.value || !allocated.ok()) {
|
||||||
|
std::fprintf(stderr, " [messagebus] response allocation failed\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The APK publishes the legacy response even for the newer async API,
|
||||||
|
// then resolves the individual request by its opaque response ID.
|
||||||
|
state->symbols.publish_response(env, state->bus, state->protocol,
|
||||||
|
method.value, body.value, response.code,
|
||||||
|
telemetry.value);
|
||||||
|
const Status published = CheckJni(env);
|
||||||
|
if (id) {
|
||||||
|
state->symbols.call_response_handler(env, state->bus, id, body.value);
|
||||||
|
}
|
||||||
|
if (!CheckJni(env).ok() || !published.ok()) {
|
||||||
|
std::fprintf(stderr, " [messagebus] response delivery failed\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
RobloxMessageBusRequestBridge::RobloxMessageBusRequestBridge(
|
||||||
|
JniEnvironmentProvider environment, RobloxMessageBusSymbols symbols,
|
||||||
|
RobloxMessageBusObjects objects, std::string protocol,
|
||||||
|
std::vector<std::string> methods, Handler handler)
|
||||||
|
: environment_(environment),
|
||||||
|
symbols_(symbols),
|
||||||
|
objects_(objects),
|
||||||
|
protocol_name_(std::move(protocol)),
|
||||||
|
methods_(std::move(methods)),
|
||||||
|
handler_(std::move(handler)) {}
|
||||||
|
|
||||||
|
RobloxMessageBusRequestBridge::~RobloxMessageBusRequestBridge() {
|
||||||
|
(void)Shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
Status RobloxMessageBusRequestBridge::Initialize() {
|
||||||
|
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
|
||||||
|
if (state_) {
|
||||||
|
return Status::Error(StatusCode::kFailedPrecondition,
|
||||||
|
"MessageBus request bridge is already initialized");
|
||||||
|
}
|
||||||
|
if (!environment_.valid() || !symbols_.complete() || !objects_.complete() ||
|
||||||
|
protocol_name_.empty() || methods_.empty() || methods_.size() > 64 ||
|
||||||
|
!handler_) {
|
||||||
|
return Unavailable(
|
||||||
|
"MessageBus request bridge prerequisites are incomplete");
|
||||||
|
}
|
||||||
|
JNIEnv* env = nullptr;
|
||||||
|
Status status = environment_.Acquire(&env);
|
||||||
|
if (!status.ok())
|
||||||
|
return status;
|
||||||
|
state_ = std::make_shared<State>();
|
||||||
|
state_->symbols = symbols_;
|
||||||
|
state_->protocol_name = protocol_name_;
|
||||||
|
state_->methods = methods_;
|
||||||
|
state_->handler = handler_;
|
||||||
|
state_->registrations.resize(methods_.size());
|
||||||
|
state_->bus = env->NewGlobalRef(objects_.message_bus);
|
||||||
|
LocalString protocol(env, protocol_name_.c_str());
|
||||||
|
state_->protocol = static_cast<jstring>(env->NewGlobalRef(protocol.value));
|
||||||
|
auto fail = [this](Status failure) {
|
||||||
|
(void)ShutdownLocked();
|
||||||
|
return failure;
|
||||||
|
};
|
||||||
|
if (!state_->bus || !state_->protocol || !CheckJni(env).ok()) {
|
||||||
|
return fail(
|
||||||
|
Unavailable("could not retain MessageBus request bridge roots"));
|
||||||
|
}
|
||||||
|
for (std::size_t i = 0; i < methods_.size(); ++i) {
|
||||||
|
auto& entry = state_->registrations[i];
|
||||||
|
auto endpoint =
|
||||||
|
std::make_shared<State::Endpoint>(State::Endpoint{state_, i});
|
||||||
|
LocalString method(env, methods_[i].c_str());
|
||||||
|
entry.method = static_cast<jstring>(env->NewGlobalRef(method.value));
|
||||||
|
jobject callback = objects_.create_raw_callback(objects_.context, endpoint,
|
||||||
|
&State::Legacy);
|
||||||
|
if (callback) {
|
||||||
|
entry.callback = env->NewGlobalRef(callback);
|
||||||
|
if (!entry.callback)
|
||||||
|
objects_.clear_raw_callback(objects_.context, callback);
|
||||||
|
env->DeleteLocalRef(callback);
|
||||||
|
}
|
||||||
|
jobject handler = objects_.create_async_handler(objects_.context, endpoint,
|
||||||
|
&State::Async);
|
||||||
|
if (handler) {
|
||||||
|
entry.handler = env->NewGlobalRef(handler);
|
||||||
|
if (!entry.handler)
|
||||||
|
objects_.clear_async_handler(objects_.context, handler);
|
||||||
|
env->DeleteLocalRef(handler);
|
||||||
|
}
|
||||||
|
if (!entry.method || !entry.callback || !entry.handler ||
|
||||||
|
!CheckJni(env).ok()) {
|
||||||
|
return fail(
|
||||||
|
Unavailable("could not create MessageBus request bridge callbacks"));
|
||||||
|
}
|
||||||
|
jobject connection =
|
||||||
|
symbols_.subscribe_request(env, state_->bus, state_->protocol,
|
||||||
|
entry.method, entry.callback, JNI_FALSE);
|
||||||
|
if (connection) {
|
||||||
|
jclass cls = env->GetObjectClass(connection);
|
||||||
|
jfieldID pointer = cls ? env->GetFieldID(cls, "a", "J") : nullptr;
|
||||||
|
if (pointer)
|
||||||
|
entry.connection_handle = env->GetLongField(connection, pointer);
|
||||||
|
if (cls)
|
||||||
|
env->DeleteLocalRef(cls);
|
||||||
|
entry.connection = env->NewGlobalRef(connection);
|
||||||
|
if (!entry.connection && entry.connection_handle) {
|
||||||
|
symbols_.delete_connection(env, connection, entry.connection_handle);
|
||||||
|
entry.connection_handle = 0;
|
||||||
|
}
|
||||||
|
env->DeleteLocalRef(connection);
|
||||||
|
}
|
||||||
|
if (!entry.connection || !entry.connection_handle || !CheckJni(env).ok()) {
|
||||||
|
return fail(
|
||||||
|
Unavailable("could not subscribe MessageBus request bridge request"));
|
||||||
|
}
|
||||||
|
entry.handler_registered = true;
|
||||||
|
symbols_.set_async_handler(env, state_->bus, state_->protocol, entry.method,
|
||||||
|
entry.handler);
|
||||||
|
status = CheckJni(env);
|
||||||
|
if (!status.ok())
|
||||||
|
return fail(status);
|
||||||
|
}
|
||||||
|
std::fprintf(stderr, " [messagebus] %s ready legacy=%zu async=%zu\n",
|
||||||
|
protocol_name_.c_str(), methods_.size(), methods_.size());
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Status RobloxMessageBusRequestBridge::Shutdown() {
|
||||||
|
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
|
||||||
|
return ShutdownLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
Status RobloxMessageBusRequestBridge::ShutdownLocked() {
|
||||||
|
if (!state_)
|
||||||
|
return Status::Ok();
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lock(state_->mutex);
|
||||||
|
state_->accepting = false;
|
||||||
|
state_->drained.wait(lock, [this] {
|
||||||
|
return state_->in_flight == 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
JNIEnv* env = nullptr;
|
||||||
|
Status status = environment_.Acquire(&env);
|
||||||
|
if (!status.ok())
|
||||||
|
return status; // Keep roots for a later shutdown retry.
|
||||||
|
// Clear any exception from partially failed initialization before cleanup.
|
||||||
|
const Status pending = CheckJni(env);
|
||||||
|
if (!pending.ok())
|
||||||
|
status = pending;
|
||||||
|
for (auto& entry : state_->registrations) {
|
||||||
|
if (entry.handler_registered) {
|
||||||
|
symbols_.clear_handler(env, state_->bus, state_->protocol, entry.method);
|
||||||
|
const Status cleared = CheckJni(env);
|
||||||
|
if (!cleared.ok())
|
||||||
|
status = cleared;
|
||||||
|
entry.handler_registered = false;
|
||||||
|
}
|
||||||
|
if (entry.connection && entry.connection_handle) {
|
||||||
|
symbols_.delete_connection(env, entry.connection,
|
||||||
|
entry.connection_handle);
|
||||||
|
const Status disconnected = CheckJni(env);
|
||||||
|
if (!disconnected.ok())
|
||||||
|
status = disconnected;
|
||||||
|
entry.connection_handle = 0;
|
||||||
|
}
|
||||||
|
if (entry.callback) {
|
||||||
|
objects_.clear_raw_callback(objects_.context, entry.callback);
|
||||||
|
env->DeleteGlobalRef(entry.callback);
|
||||||
|
}
|
||||||
|
if (entry.handler) {
|
||||||
|
objects_.clear_async_handler(objects_.context, entry.handler);
|
||||||
|
env->DeleteGlobalRef(entry.handler);
|
||||||
|
}
|
||||||
|
if (entry.connection)
|
||||||
|
env->DeleteGlobalRef(entry.connection);
|
||||||
|
if (entry.method)
|
||||||
|
env->DeleteGlobalRef(entry.method);
|
||||||
|
entry = {};
|
||||||
|
const Status cleared = CheckJni(env);
|
||||||
|
if (!cleared.ok())
|
||||||
|
status = cleared;
|
||||||
|
}
|
||||||
|
if (state_->protocol)
|
||||||
|
env->DeleteGlobalRef(state_->protocol);
|
||||||
|
if (state_->bus)
|
||||||
|
env->DeleteGlobalRef(state_->bus);
|
||||||
|
state_.reset();
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace runtime
|
||||||
|
} // namespace mocktail
|
||||||
@@ -1,46 +1,14 @@
|
|||||||
#include "runtime/roblox_permissions_bridge.h"
|
#include "runtime/roblox_permissions_bridge.h"
|
||||||
|
|
||||||
#include <array>
|
|
||||||
#include <condition_variable>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
#include <string>
|
|
||||||
#include <utility>
|
|
||||||
|
|
||||||
namespace mocktail {
|
namespace mocktail {
|
||||||
namespace runtime {
|
namespace runtime {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr char kProtocol[] = "PermissionsProtocol";
|
|
||||||
constexpr char kMicrophone[] = "MICROPHONE_ACCESS";
|
|
||||||
constexpr char kLocalNetwork[] = "LOCAL_NETWORK";
|
|
||||||
constexpr const char* kMethods[] = {
|
|
||||||
"SupportsPermissions", "HasPermissions", "PermissionsRequest",
|
|
||||||
"ShouldShowPermissionUpsell", "ShouldShowRequestPermissionRationale"};
|
|
||||||
constexpr std::size_t kMethodCount = std::size(kMethods);
|
|
||||||
constexpr std::size_t kMaximumJsonBytes = 64 * 1024;
|
constexpr std::size_t kMaximumJsonBytes = 64 * 1024;
|
||||||
constexpr std::size_t kMaximumFeatures = 64;
|
constexpr std::size_t kMaximumFeatures = 64;
|
||||||
|
constexpr char kMicrophone[] = "MICROPHONE_ACCESS";
|
||||||
Status Unavailable(const char* message) {
|
constexpr char kLocalNetwork[] = "LOCAL_NETWORK";
|
||||||
return Status::Error(StatusCode::kUnavailable, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
Status CheckJni(JNIEnv* env) {
|
|
||||||
if (!env->ExceptionCheck()) return Status::Ok();
|
|
||||||
env->ExceptionClear();
|
|
||||||
return Unavailable("PermissionsProtocol JNI operation failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// JNI local references are never retained across callback invocations.
|
|
||||||
struct LocalString {
|
|
||||||
JNIEnv* env;
|
|
||||||
jstring value;
|
|
||||||
LocalString(JNIEnv* source, const char* text)
|
|
||||||
: env(source), value(source->NewStringUTF(text)) {}
|
|
||||||
~LocalString() {
|
|
||||||
if (value) env->DeleteLocalRef(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
struct Response {
|
struct Response {
|
||||||
nlohmann::json body;
|
nlohmann::json body;
|
||||||
@@ -63,7 +31,8 @@ bool HasBoundedDepth(const std::string& json) {
|
|||||||
} else if (ch == '"') {
|
} else if (ch == '"') {
|
||||||
quoted = true;
|
quoted = true;
|
||||||
} else if (ch == '{' || ch == '[') {
|
} else if (ch == '{' || ch == '[') {
|
||||||
if (++depth > 8) return false;
|
if (++depth > 8)
|
||||||
|
return false;
|
||||||
} else if (ch == '}' || ch == ']') {
|
} else if (ch == '}' || ch == ']') {
|
||||||
--depth;
|
--depth;
|
||||||
}
|
}
|
||||||
@@ -82,18 +51,22 @@ Response Answer(JNIEnv* env, jstring message, std::size_t method,
|
|||||||
: nlohmann::json{{"status", "DENIED"},
|
: nlohmann::json{{"status", "DENIED"},
|
||||||
{"missingPermissions", {kMicrophone}}},
|
{"missingPermissions", {kMicrophone}}},
|
||||||
13, "invalid_request"};
|
13, "invalid_request"};
|
||||||
if (!message) return rejected;
|
if (!message)
|
||||||
|
return rejected;
|
||||||
const jsize size = env->GetStringUTFLength(message);
|
const jsize size = env->GetStringUTFLength(message);
|
||||||
if (size <= 0 || static_cast<std::size_t>(size) > kMaximumJsonBytes) {
|
if (size <= 0 || static_cast<std::size_t>(size) > kMaximumJsonBytes) {
|
||||||
return rejected;
|
return rejected;
|
||||||
}
|
}
|
||||||
const char* bytes = env->GetStringUTFChars(message, nullptr);
|
const char* bytes = env->GetStringUTFChars(message, nullptr);
|
||||||
if (!bytes) return rejected;
|
if (!bytes)
|
||||||
|
return rejected;
|
||||||
const std::string owned(bytes, static_cast<std::size_t>(size));
|
const std::string owned(bytes, static_cast<std::size_t>(size));
|
||||||
env->ReleaseStringUTFChars(message, bytes);
|
env->ReleaseStringUTFChars(message, bytes);
|
||||||
if (!HasBoundedDepth(owned)) return rejected;
|
if (!HasBoundedDepth(owned))
|
||||||
|
return rejected;
|
||||||
const auto request = nlohmann::json::parse(owned, nullptr, false);
|
const auto request = nlohmann::json::parse(owned, nullptr, false);
|
||||||
if (!request.is_object()) return rejected;
|
if (!request.is_object())
|
||||||
|
return rejected;
|
||||||
if (support) {
|
if (support) {
|
||||||
return {{{"permissions", {kMicrophone, kLocalNetwork}}}, 0, "supported"};
|
return {{{"permissions", {kMicrophone, kLocalNetwork}}}, 0, "supported"};
|
||||||
}
|
}
|
||||||
@@ -104,7 +77,8 @@ Response Answer(JNIEnv* env, jstring message, std::size_t method,
|
|||||||
}
|
}
|
||||||
auto missing = nlohmann::json::array();
|
auto missing = nlohmann::json::array();
|
||||||
for (const auto& feature : *features) {
|
for (const auto& feature : *features) {
|
||||||
if (!feature.is_string()) return rejected;
|
if (!feature.is_string())
|
||||||
|
return rejected;
|
||||||
const auto& name = feature.get_ref<const std::string&>();
|
const auto& name = feature.get_ref<const std::string&>();
|
||||||
if (name.empty() || name.size() > 128 ||
|
if (name.empty() || name.size() > 128 ||
|
||||||
name.find_first_of("\r\n\t") != std::string::npos ||
|
name.find_first_of("\r\n\t") != std::string::npos ||
|
||||||
@@ -132,233 +106,31 @@ Response Answer(JNIEnv* env, jstring message, std::size_t method,
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
struct RobloxPermissionsBridge::State {
|
|
||||||
struct Endpoint {
|
|
||||||
std::shared_ptr<State> state;
|
|
||||||
std::size_t method;
|
|
||||||
};
|
|
||||||
struct Registration {
|
|
||||||
jobject callback = nullptr;
|
|
||||||
jobject handler = nullptr;
|
|
||||||
jobject connection = nullptr;
|
|
||||||
jlong connection_handle = 0;
|
|
||||||
jstring method = nullptr;
|
|
||||||
bool handler_registered = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
RobloxPermissionsMessageBusSymbols symbols;
|
|
||||||
jobject bus = nullptr;
|
|
||||||
jstring protocol = nullptr;
|
|
||||||
bool microphone_enabled = false;
|
|
||||||
std::array<Registration, kMethodCount> registrations;
|
|
||||||
std::mutex mutex;
|
|
||||||
std::condition_variable drained;
|
|
||||||
bool accepting = true;
|
|
||||||
std::size_t in_flight = 0;
|
|
||||||
|
|
||||||
static void Legacy(void* context, JNIEnv* env, jstring message) {
|
|
||||||
Dispatch(context, env, message, nullptr);
|
|
||||||
}
|
|
||||||
static void Async(void* context, JNIEnv* env, jstring message, jstring id) {
|
|
||||||
Dispatch(context, env, message, id);
|
|
||||||
}
|
|
||||||
static void Dispatch(void* context, JNIEnv* env, jstring message,
|
|
||||||
jstring id) {
|
|
||||||
const auto* endpoint = static_cast<Endpoint*>(context);
|
|
||||||
const auto state = endpoint->state;
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(state->mutex);
|
|
||||||
if (!state->accepting) return;
|
|
||||||
++state->in_flight;
|
|
||||||
}
|
|
||||||
struct Finish {
|
|
||||||
std::shared_ptr<State> state;
|
|
||||||
~Finish() {
|
|
||||||
std::lock_guard<std::mutex> lock(state->mutex);
|
|
||||||
--state->in_flight;
|
|
||||||
state->drained.notify_all();
|
|
||||||
}
|
|
||||||
} finish{state};
|
|
||||||
|
|
||||||
Response response =
|
|
||||||
Answer(env, message, endpoint->method, state->microphone_enabled);
|
|
||||||
// No payload, feature strings, or correlation IDs in logs.
|
|
||||||
std::fprintf(stderr,
|
|
||||||
" [permissions] %s method=%s result=%s microphone=%s "
|
|
||||||
"transport=%s\n",
|
|
||||||
kProtocol, kMethods[endpoint->method], response.outcome,
|
|
||||||
state->microphone_enabled ? "enabled" : "disabled",
|
|
||||||
id ? "async" : "legacy");
|
|
||||||
LocalString body(env, response.body.dump().c_str());
|
|
||||||
LocalString telemetry(env, "{}");
|
|
||||||
LocalString method(env, kMethods[endpoint->method]);
|
|
||||||
const Status allocated = CheckJni(env);
|
|
||||||
if (!body.value || !telemetry.value || !method.value || !allocated.ok()) {
|
|
||||||
std::fprintf(stderr, " [permissions] response allocation failed\n");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The APK publishes the legacy response even for the newer async API,
|
|
||||||
// then resolves the individual request by its opaque response ID.
|
|
||||||
state->symbols.publish_response(env, state->bus, state->protocol,
|
|
||||||
method.value, body.value, response.code,
|
|
||||||
telemetry.value);
|
|
||||||
const Status published = CheckJni(env);
|
|
||||||
if (id) {
|
|
||||||
state->symbols.call_response_handler(env, state->bus, id, body.value);
|
|
||||||
}
|
|
||||||
if (!CheckJni(env).ok() || !published.ok()) {
|
|
||||||
std::fprintf(stderr, " [permissions] response delivery failed\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
RobloxPermissionsBridge::RobloxPermissionsBridge(
|
RobloxPermissionsBridge::RobloxPermissionsBridge(
|
||||||
JniEnvironmentProvider environment,
|
JniEnvironmentProvider environment,
|
||||||
RobloxPermissionsMessageBusSymbols symbols,
|
RobloxPermissionsMessageBusSymbols symbols,
|
||||||
RobloxPermissionsMessageBusObjects objects, bool microphone_enabled)
|
RobloxPermissionsMessageBusObjects objects, bool microphone_enabled)
|
||||||
: environment_(environment),
|
: bridge_(std::make_unique<RobloxMessageBusRequestBridge>(
|
||||||
symbols_(symbols),
|
environment, symbols, objects, "PermissionsProtocol",
|
||||||
objects_(objects),
|
std::vector<std::string>{"SupportsPermissions", "HasPermissions",
|
||||||
microphone_enabled_(microphone_enabled) {}
|
"PermissionsRequest",
|
||||||
|
"ShouldShowPermissionUpsell",
|
||||||
|
"ShouldShowRequestPermissionRationale"},
|
||||||
|
[microphone_enabled](JNIEnv* env, jstring message,
|
||||||
|
std::size_t method) {
|
||||||
|
auto response = Answer(env, message, method, microphone_enabled);
|
||||||
|
return RobloxMessageBusReply{response.body.dump(), response.code,
|
||||||
|
response.outcome};
|
||||||
|
})) {}
|
||||||
|
|
||||||
RobloxPermissionsBridge::~RobloxPermissionsBridge() { (void)Shutdown(); }
|
RobloxPermissionsBridge::~RobloxPermissionsBridge() = default;
|
||||||
|
|
||||||
Status RobloxPermissionsBridge::Initialize() {
|
Status RobloxPermissionsBridge::Initialize() {
|
||||||
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
|
return bridge_->Initialize();
|
||||||
if (state_) {
|
|
||||||
return Status::Error(StatusCode::kFailedPrecondition,
|
|
||||||
"PermissionsProtocol is already initialized");
|
|
||||||
}
|
|
||||||
if (!environment_.valid() || !symbols_.complete() || !objects_.complete()) {
|
|
||||||
return Unavailable("PermissionsProtocol prerequisites are incomplete");
|
|
||||||
}
|
|
||||||
JNIEnv* env = nullptr;
|
|
||||||
Status status = environment_.Acquire(&env);
|
|
||||||
if (!status.ok()) return status;
|
|
||||||
state_ = std::make_shared<State>();
|
|
||||||
state_->symbols = symbols_;
|
|
||||||
state_->microphone_enabled = microphone_enabled_;
|
|
||||||
state_->bus = env->NewGlobalRef(objects_.message_bus);
|
|
||||||
LocalString protocol(env, kProtocol);
|
|
||||||
state_->protocol = static_cast<jstring>(env->NewGlobalRef(protocol.value));
|
|
||||||
auto fail = [this](Status failure) {
|
|
||||||
(void)ShutdownLocked();
|
|
||||||
return failure;
|
|
||||||
};
|
|
||||||
if (!state_->bus || !state_->protocol || !CheckJni(env).ok()) {
|
|
||||||
return fail(Unavailable("could not retain PermissionsProtocol roots"));
|
|
||||||
}
|
|
||||||
for (std::size_t i = 0; i < kMethodCount; ++i) {
|
|
||||||
auto& entry = state_->registrations[i];
|
|
||||||
auto endpoint =
|
|
||||||
std::make_shared<State::Endpoint>(State::Endpoint{state_, i});
|
|
||||||
LocalString method(env, kMethods[i]);
|
|
||||||
entry.method = static_cast<jstring>(env->NewGlobalRef(method.value));
|
|
||||||
jobject callback = objects_.create_raw_callback(objects_.context, endpoint,
|
|
||||||
&State::Legacy);
|
|
||||||
if (callback) {
|
|
||||||
entry.callback = env->NewGlobalRef(callback);
|
|
||||||
if (!entry.callback)
|
|
||||||
objects_.clear_raw_callback(objects_.context, callback);
|
|
||||||
env->DeleteLocalRef(callback);
|
|
||||||
}
|
|
||||||
jobject handler = objects_.create_async_handler(objects_.context, endpoint,
|
|
||||||
&State::Async);
|
|
||||||
if (handler) {
|
|
||||||
entry.handler = env->NewGlobalRef(handler);
|
|
||||||
if (!entry.handler)
|
|
||||||
objects_.clear_async_handler(objects_.context, handler);
|
|
||||||
env->DeleteLocalRef(handler);
|
|
||||||
}
|
|
||||||
if (!entry.method || !entry.callback || !entry.handler ||
|
|
||||||
!CheckJni(env).ok()) {
|
|
||||||
return fail(
|
|
||||||
Unavailable("could not create PermissionsProtocol callbacks"));
|
|
||||||
}
|
|
||||||
jobject connection =
|
|
||||||
symbols_.subscribe_request(env, state_->bus, state_->protocol,
|
|
||||||
entry.method, entry.callback, JNI_FALSE);
|
|
||||||
if (connection) {
|
|
||||||
jclass cls = env->GetObjectClass(connection);
|
|
||||||
jfieldID pointer = cls ? env->GetFieldID(cls, "a", "J") : nullptr;
|
|
||||||
if (pointer)
|
|
||||||
entry.connection_handle = env->GetLongField(connection, pointer);
|
|
||||||
if (cls) env->DeleteLocalRef(cls);
|
|
||||||
entry.connection = env->NewGlobalRef(connection);
|
|
||||||
if (!entry.connection && entry.connection_handle) {
|
|
||||||
symbols_.delete_connection(env, connection, entry.connection_handle);
|
|
||||||
entry.connection_handle = 0;
|
|
||||||
}
|
|
||||||
env->DeleteLocalRef(connection);
|
|
||||||
}
|
|
||||||
if (!entry.connection || !entry.connection_handle || !CheckJni(env).ok()) {
|
|
||||||
return fail(
|
|
||||||
Unavailable("could not subscribe PermissionsProtocol request"));
|
|
||||||
}
|
|
||||||
entry.handler_registered = true;
|
|
||||||
symbols_.set_async_handler(env, state_->bus, state_->protocol, entry.method,
|
|
||||||
entry.handler);
|
|
||||||
status = CheckJni(env);
|
|
||||||
if (!status.ok()) return fail(status);
|
|
||||||
}
|
|
||||||
std::fprintf(stderr,
|
|
||||||
" [permissions] PermissionsProtocol ready microphone=%s "
|
|
||||||
"legacy=5 async=5\n",
|
|
||||||
microphone_enabled_ ? "enabled" : "disabled");
|
|
||||||
return Status::Ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Status RobloxPermissionsBridge::Shutdown() {
|
Status RobloxPermissionsBridge::Shutdown() {
|
||||||
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
|
return bridge_->Shutdown();
|
||||||
return ShutdownLocked();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Status RobloxPermissionsBridge::ShutdownLocked() {
|
|
||||||
if (!state_) return Status::Ok();
|
|
||||||
{
|
|
||||||
std::unique_lock<std::mutex> lock(state_->mutex);
|
|
||||||
state_->accepting = false;
|
|
||||||
state_->drained.wait(lock, [this] { return state_->in_flight == 0; });
|
|
||||||
}
|
|
||||||
JNIEnv* env = nullptr;
|
|
||||||
Status status = environment_.Acquire(&env);
|
|
||||||
if (!status.ok()) return status; // Keep roots for a later shutdown retry.
|
|
||||||
// Clear any exception from partially failed initialization before cleanup.
|
|
||||||
const Status pending = CheckJni(env);
|
|
||||||
if (!pending.ok()) status = pending;
|
|
||||||
for (auto& entry : state_->registrations) {
|
|
||||||
if (entry.handler_registered) {
|
|
||||||
symbols_.clear_handler(env, state_->bus, state_->protocol, entry.method);
|
|
||||||
const Status cleared = CheckJni(env);
|
|
||||||
if (!cleared.ok()) status = cleared;
|
|
||||||
entry.handler_registered = false;
|
|
||||||
}
|
|
||||||
if (entry.connection && entry.connection_handle) {
|
|
||||||
symbols_.delete_connection(env, entry.connection,
|
|
||||||
entry.connection_handle);
|
|
||||||
const Status disconnected = CheckJni(env);
|
|
||||||
if (!disconnected.ok()) status = disconnected;
|
|
||||||
entry.connection_handle = 0;
|
|
||||||
}
|
|
||||||
if (entry.callback) {
|
|
||||||
objects_.clear_raw_callback(objects_.context, entry.callback);
|
|
||||||
env->DeleteGlobalRef(entry.callback);
|
|
||||||
}
|
|
||||||
if (entry.handler) {
|
|
||||||
objects_.clear_async_handler(objects_.context, entry.handler);
|
|
||||||
env->DeleteGlobalRef(entry.handler);
|
|
||||||
}
|
|
||||||
if (entry.connection) env->DeleteGlobalRef(entry.connection);
|
|
||||||
if (entry.method) env->DeleteGlobalRef(entry.method);
|
|
||||||
entry = {};
|
|
||||||
const Status cleared = CheckJni(env);
|
|
||||||
if (!cleared.ok()) status = cleared;
|
|
||||||
}
|
|
||||||
if (state_->protocol) env->DeleteGlobalRef(state_->protocol);
|
|
||||||
if (state_->bus) env->DeleteGlobalRef(state_->bus);
|
|
||||||
state_.reset();
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace runtime
|
} // namespace runtime
|
||||||
} // namespace mocktail
|
} // namespace mocktail
|
||||||
@@ -125,6 +125,16 @@ RobloxPlatformWebSymbols ResolveRobloxPlatformWebSymbols(
|
|||||||
roblox_library,
|
roblox_library,
|
||||||
"Java_com_roblox_universalapp_cookie_JNICookieProtocol_"
|
"Java_com_roblox_universalapp_cookie_JNICookieProtocol_"
|
||||||
"updateOnSetCookieHandler");
|
"updateOnSetCookieHandler");
|
||||||
|
symbols.web_view.send_app_event_on_game_loaded =
|
||||||
|
Resolve<SendRobloxAppEventOnGameLoadedFn>(
|
||||||
|
roblox_library,
|
||||||
|
"Java_com_roblox_engine_jni_NativeGLInterface_"
|
||||||
|
"nativeAppBridgeV2SendAppEventOnGameLoaded");
|
||||||
|
symbols.web_view.send_app_event_on_app_ready =
|
||||||
|
Resolve<SendRobloxAppEventOnAppReadyFn>(
|
||||||
|
roblox_library,
|
||||||
|
"Java_com_roblox_engine_jni_NativeGLInterface_"
|
||||||
|
"nativeAppBridgeV2SendAppEventOnAppReady");
|
||||||
|
|
||||||
symbols.browser_service.bind = Resolve<BindRobloxMemStorageFn>(
|
symbols.browser_service.bind = Resolve<BindRobloxMemStorageFn>(
|
||||||
roblox_library, "Java_com_roblox_engine_jni_memstorage_MemStorage_bind");
|
roblox_library, "Java_com_roblox_engine_jni_memstorage_MemStorage_bind");
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#include "runtime/roblox_presence_resolver.h"
|
||||||
|
|
||||||
|
#include <curl/curl.h>
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <fstream>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace mocktail {
|
||||||
|
namespace runtime {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
Status Invalid(std::string message) {
|
||||||
|
return Status::Error(StatusCode::kInvalidArgument, std::move(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t WriteToString(char* data, size_t size, size_t nmemb, void* userdata) {
|
||||||
|
static_cast<std::string*>(userdata)->append(data, size * nmemb);
|
||||||
|
return size * nmemb;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// Reads mocktail's own persisted session cookie from
|
||||||
|
// $XDG_DATA_HOME/mocktail/auth/roblox.cookie (default
|
||||||
|
// ~/.local/share/mocktail/auth/roblox.cookie), matching the format
|
||||||
|
// mocktail's own auth code already writes there: a single line of the
|
||||||
|
// form ".ROBLOSECURITY=<value>". Strips the prefix so callers get just
|
||||||
|
// the bare value. Returns an empty string if no session is stored yet.
|
||||||
|
std::string ReadStoredRoblosecurityCookie() {
|
||||||
|
const char* home = std::getenv("HOME");
|
||||||
|
if (home == nullptr || home[0] == '\0') {
|
||||||
|
return std::string();
|
||||||
|
}
|
||||||
|
const char* xdg_data_home = std::getenv("XDG_DATA_HOME");
|
||||||
|
const std::string base =
|
||||||
|
(xdg_data_home != nullptr && xdg_data_home[0] != '\0')
|
||||||
|
? std::string(xdg_data_home)
|
||||||
|
: std::string(home) + "/.local/share";
|
||||||
|
const std::string path = base + "/mocktail/auth/roblox.cookie";
|
||||||
|
|
||||||
|
std::ifstream file(path);
|
||||||
|
if (!file.is_open()) {
|
||||||
|
return std::string();
|
||||||
|
}
|
||||||
|
std::string line;
|
||||||
|
std::getline(file, line);
|
||||||
|
while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) {
|
||||||
|
line.pop_back();
|
||||||
|
}
|
||||||
|
constexpr std::string_view kPrefix = ".ROBLOSECURITY=";
|
||||||
|
if (line.size() > kPrefix.size() &&
|
||||||
|
line.compare(0, kPrefix.size(), kPrefix) == 0) {
|
||||||
|
return line.substr(kPrefix.size());
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
Status ResolveFollowUserPlace(int64_t user_id, const std::string& auth_cookie,
|
||||||
|
ResolvedFollowUserPlace* out) {
|
||||||
|
if (out == nullptr || user_id <= 0) {
|
||||||
|
return Invalid("Roblox presence lookup requires a positive userId");
|
||||||
|
}
|
||||||
|
if (auth_cookie.empty()) {
|
||||||
|
return Invalid("Roblox presence lookup requires an authenticated session");
|
||||||
|
}
|
||||||
|
|
||||||
|
CURL* curl = curl_easy_init();
|
||||||
|
if (curl == nullptr) {
|
||||||
|
return Invalid("Roblox presence lookup could not initialize a request");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nlohmann::json body = {{"userIds", {user_id}}};
|
||||||
|
const std::string payload = body.dump();
|
||||||
|
std::string response;
|
||||||
|
|
||||||
|
struct curl_slist* headers = nullptr;
|
||||||
|
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||||
|
const std::string cookie_header = "Cookie: .ROBLOSECURITY=" + auth_cookie;
|
||||||
|
headers = curl_slist_append(headers, cookie_header.c_str());
|
||||||
|
|
||||||
|
curl_easy_setopt(curl, CURLOPT_URL,
|
||||||
|
"https://presence.roblox.com/v1/presence/users");
|
||||||
|
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload.c_str());
|
||||||
|
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteToString);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
|
||||||
|
|
||||||
|
const CURLcode result = curl_easy_perform(curl);
|
||||||
|
long http_status = 0;
|
||||||
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_status);
|
||||||
|
curl_slist_free_all(headers);
|
||||||
|
curl_easy_cleanup(curl);
|
||||||
|
|
||||||
|
if (result != CURLE_OK) {
|
||||||
|
return Invalid("Roblox presence lookup failed to connect");
|
||||||
|
}
|
||||||
|
if (http_status != 200) {
|
||||||
|
return Invalid("Roblox presence lookup was rejected (status " +
|
||||||
|
std::to_string(http_status) + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nlohmann::json parsed = nlohmann::json::parse(
|
||||||
|
response, /*cb=*/nullptr, /*allow_exceptions=*/false);
|
||||||
|
if (parsed.is_discarded()) {
|
||||||
|
return Invalid("Roblox presence lookup returned malformed JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.contains("userPresences") ||
|
||||||
|
!parsed["userPresences"].is_array() ||
|
||||||
|
parsed["userPresences"].empty()) {
|
||||||
|
return Invalid("Roblox presence lookup returned no data for user");
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto& presence = parsed["userPresences"][0];
|
||||||
|
// userPresenceType: 0 offline, 1 online, 2 in-game, 3 in-studio.
|
||||||
|
const int presence_type = presence.value("userPresenceType", 0);
|
||||||
|
if (presence_type != 2) {
|
||||||
|
return Invalid("User is not currently in a joinable Roblox experience");
|
||||||
|
}
|
||||||
|
if (!presence.contains("placeId") || presence["placeId"].is_null()) {
|
||||||
|
return Invalid("Roblox presence lookup did not include a placeId");
|
||||||
|
}
|
||||||
|
|
||||||
|
out->place_id = presence.value("placeId", int64_t{0});
|
||||||
|
if (presence.contains("gameId") && !presence["gameId"].is_null()) {
|
||||||
|
out->game_instance_id = presence.value("gameId", std::string());
|
||||||
|
}
|
||||||
|
if (out->place_id <= 0) {
|
||||||
|
return Invalid("Roblox presence lookup returned an invalid placeId");
|
||||||
|
}
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace runtime
|
||||||
|
} // namespace mocktail
|
||||||
@@ -36,6 +36,8 @@ constexpr char kNativeGlInterfaceClass[] =
|
|||||||
"com/roblox/engine/jni/NativeGLInterface";
|
"com/roblox/engine/jni/NativeGLInterface";
|
||||||
constexpr char kConnectionPointerField[] = "a";
|
constexpr char kConnectionPointerField[] = "a";
|
||||||
constexpr std::size_t kMaximumPendingHostWindowEvents = 256;
|
constexpr std::size_t kMaximumPendingHostWindowEvents = 256;
|
||||||
|
// Digits of the largest signed 64-bit user id.
|
||||||
|
constexpr std::size_t kMaximumRobloxUserIdDigits = 19;
|
||||||
constexpr std::string_view kBrowserLoginUrl = "https://www.roblox.com/login";
|
constexpr std::string_view kBrowserLoginUrl = "https://www.roblox.com/login";
|
||||||
|
|
||||||
bool EqualsIgnoreCase(std::string_view a, std::string_view b) {
|
bool EqualsIgnoreCase(std::string_view a, std::string_view b) {
|
||||||
@@ -96,6 +98,21 @@ bool IsLoginChallengeUrl(std::string_view url) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A positive decimal that fits a signed 64-bit integer, without sign, spaces
|
||||||
|
// or leading zero.
|
||||||
|
bool IsRobloxUserId(std::string_view value) {
|
||||||
|
constexpr std::string_view kLargest = "9223372036854775807";
|
||||||
|
if (value.empty() || value.size() > kLargest.size() || value[0] == '0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const char digit : value) {
|
||||||
|
if (digit < '0' || digit > '9') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value.size() < kLargest.size() || value <= kLargest;
|
||||||
|
}
|
||||||
|
|
||||||
Status Invalid(std::string message) {
|
Status Invalid(std::string message) {
|
||||||
return Status::Error(StatusCode::kInvalidArgument, std::move(message));
|
return Status::Error(StatusCode::kInvalidArgument, std::move(message));
|
||||||
}
|
}
|
||||||
@@ -1355,7 +1372,20 @@ void RobloxWebViewBridge::QueueHostWindowEvent(HostWindowEventType type,
|
|||||||
" [webview] pending host event queue overflow; preserving "
|
" [webview] pending host event queue overflow; preserving "
|
||||||
"newest lifecycle state\n");
|
"newest lifecycle state\n");
|
||||||
}
|
}
|
||||||
pending_host_window_events_.push_back({type, generation});
|
pending_host_window_events_.push_back({type, generation, {}});
|
||||||
|
}
|
||||||
|
|
||||||
|
void RobloxWebViewBridge::QueueStartConversation(std::string user_id) {
|
||||||
|
std::lock_guard<std::mutex> lock(host_window_event_mutex_);
|
||||||
|
if (pending_host_window_events_.size() == kMaximumPendingHostWindowEvents) {
|
||||||
|
pending_host_window_events_.pop_front();
|
||||||
|
std::fprintf(stderr,
|
||||||
|
" [webview] pending host event queue overflow; preserving "
|
||||||
|
"newest lifecycle state\n");
|
||||||
|
}
|
||||||
|
pending_host_window_events_.push_back(
|
||||||
|
{HostWindowEventType::kStartConversationWithUser, 0,
|
||||||
|
std::move(user_id)});
|
||||||
}
|
}
|
||||||
|
|
||||||
Status RobloxWebViewBridge::DrainHostWindowEvents() {
|
Status RobloxWebViewBridge::DrainHostWindowEvents() {
|
||||||
@@ -1418,8 +1448,24 @@ Status RobloxWebViewBridge::DrainHostWindowEvents() {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case HostWindowEventType::kStartConversationWithUser: {
|
||||||
|
// A failed send is dropped, not retried: the click that asked for
|
||||||
|
// the conversation is stale by the next drain.
|
||||||
|
const Status sent = SendStartConversationEvents(env, event.user_id);
|
||||||
|
if (sent.ok()) {
|
||||||
|
std::fprintf(stderr,
|
||||||
|
" [app-shell] Chat opened with a conversation "
|
||||||
|
"request\n");
|
||||||
|
} else {
|
||||||
|
std::fprintf(stderr,
|
||||||
|
" [app-shell] conversation request failed: %s\n",
|
||||||
|
sent.message().c_str());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (status.ok() && event.type != HostWindowEventType::kDataModelUnfocused) {
|
if (status.ok() && event.type != HostWindowEventType::kDataModelUnfocused &&
|
||||||
|
event.type != HostWindowEventType::kStartConversationWithUser) {
|
||||||
const std::shared_ptr<HostWindowCloseTarget> target =
|
const std::shared_ptr<HostWindowCloseTarget> target =
|
||||||
host_window_close_target_;
|
host_window_close_target_;
|
||||||
if (target != nullptr) {
|
if (target != nullptr) {
|
||||||
@@ -1472,6 +1518,28 @@ Status RobloxWebViewBridge::HandleDataModelNotification(JNIEnv* env,
|
|||||||
OpenSource::kDataModelNotification);
|
OpenSource::kDataModelNotification);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (owned_type == kRobloxLaunchConversationNotification) {
|
||||||
|
std::string user_id;
|
||||||
|
status = CopyJniString(env, data, kMaximumRobloxUserIdDigits,
|
||||||
|
"conversation user id", &user_id);
|
||||||
|
if (!status.ok()) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
if (!IsRobloxUserId(user_id)) {
|
||||||
|
return Invalid("LAUNCH_CONVERSATION payload is not a user id");
|
||||||
|
}
|
||||||
|
if (symbols_.send_app_event_on_game_loaded == nullptr ||
|
||||||
|
symbols_.send_app_event_on_app_ready == nullptr) {
|
||||||
|
return Unavailable("Roblox app event entrypoints are unavailable");
|
||||||
|
}
|
||||||
|
std::fprintf(stderr,
|
||||||
|
" [app-shell] LAUNCH_CONVERSATION notification received\n");
|
||||||
|
// The engine is inside its notification callback. The reply is sent from
|
||||||
|
// the next host event drain, as Android posts it to its main thread.
|
||||||
|
QueueStartConversation(std::move(user_id));
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
RobloxWebViewOpenRequest request;
|
RobloxWebViewOpenRequest request;
|
||||||
if (owned_type == kRobloxViewProfileNotification) {
|
if (owned_type == kRobloxViewProfileNotification) {
|
||||||
std::string owned_url;
|
std::string owned_url;
|
||||||
@@ -1652,6 +1720,53 @@ Status RobloxWebViewBridge::BroadcastDataModelFocus(JNIEnv *env,
|
|||||||
return CheckJniException(env, "broadcast AppInput DataModel focus event");
|
return CheckJniException(env, "broadcast AppInput DataModel focus event");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors the Android app shell's reply to LAUNCH_CONVERSATION: switch the
|
||||||
|
// Lua app to its Chat destination, then ask Chat to start the conversation
|
||||||
|
// once that app reports ready.
|
||||||
|
Status RobloxWebViewBridge::SendStartConversationEvents(
|
||||||
|
JNIEnv* env, const std::string& user_id) {
|
||||||
|
if (env == nullptr || symbols_.send_app_event_on_game_loaded == nullptr ||
|
||||||
|
symbols_.send_app_event_on_app_ready == nullptr) {
|
||||||
|
return Unavailable("Roblox app event entrypoints are unavailable");
|
||||||
|
}
|
||||||
|
jclass native_gl_class = env->FindClass(kNativeGlInterfaceClass);
|
||||||
|
const std::array<jstring, 7> strings = {
|
||||||
|
env->NewStringUTF("Navigations"),
|
||||||
|
env->NewStringUTF(R"({"appName":"Chat"})"),
|
||||||
|
env->NewStringUTF("Destination"),
|
||||||
|
env->NewStringUTF("AppShellNotifications"),
|
||||||
|
env->NewStringUTF(user_id.c_str()),
|
||||||
|
env->NewStringUTF("StartConversationWithUserId"),
|
||||||
|
env->NewStringUTF("Chat"),
|
||||||
|
};
|
||||||
|
bool allocated = native_gl_class != nullptr;
|
||||||
|
for (jstring value : strings) {
|
||||||
|
allocated = allocated && value != nullptr;
|
||||||
|
}
|
||||||
|
Status status = allocated
|
||||||
|
? Status::Ok()
|
||||||
|
: Unavailable("could not allocate conversation events");
|
||||||
|
if (status.ok()) {
|
||||||
|
symbols_.send_app_event_on_game_loaded(env, native_gl_class, strings[0],
|
||||||
|
strings[1], strings[2]);
|
||||||
|
status = CheckJniException(env, "send Chat destination app event");
|
||||||
|
}
|
||||||
|
if (status.ok()) {
|
||||||
|
symbols_.send_app_event_on_app_ready(env, native_gl_class, strings[3],
|
||||||
|
strings[4], strings[5], strings[6]);
|
||||||
|
status = CheckJniException(env, "send start conversation app event");
|
||||||
|
}
|
||||||
|
for (jstring value : strings) {
|
||||||
|
if (value != nullptr) {
|
||||||
|
env->DeleteLocalRef(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (native_gl_class != nullptr) {
|
||||||
|
env->DeleteLocalRef(native_gl_class);
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
Status RobloxWebViewBridge::BeginDispatch() {
|
Status RobloxWebViewBridge::BeginDispatch() {
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
if ((!initializing_ && !initialized_) || !accepting_messages_) {
|
if ((!initializing_ && !initialized_) || !accepting_messages_) {
|
||||||
|
|||||||
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user