mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 18:02:19 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -44,6 +44,8 @@ jobs:
|
||||
if: needs.probe.outputs.build == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
UPINFO: gh-releases-zsync|${{ github.repository_owner }}|${{ github.event.repository.name }}|continuous|Nightcap-nightly-x86_64.AppImage.zsync
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -60,6 +62,7 @@ jobs:
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
--env GH_TOKEN \
|
||||
--env UPINFO \
|
||||
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
|
||||
pacman -Syu --noconfirm --needed \
|
||||
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
|
||||
@@ -123,11 +126,12 @@ jobs:
|
||||
export RUNTIME=/tmp/uruntime
|
||||
export DWARFS_CMD=/tmp/mkdwarfs
|
||||
make appimage BUILD_DIR=build-appimage JOBS=4 \
|
||||
APPIMAGE=dist/Mocktail-x86_64.AppImage \
|
||||
APPIMAGE=dist/Nightcap-nightly-x86_64.AppImage \
|
||||
PORTABLE_MODE=standalone APPIMAGE_FORMAT=anylinux \
|
||||
ANYLINUX_PACKAGER=/tmp/quick-sharun \
|
||||
ANYLINUX_APPIMAGETOOL=/tmp/appimagetool
|
||||
test -x dist/Mocktail-x86_64.AppImage
|
||||
test -x dist/Nightcap-nightly-x86_64.AppImage
|
||||
test -s dist/Nightcap-nightly-x86_64.AppImage.zsync
|
||||
'
|
||||
|
||||
- name: Smoke-test AppImage without FUSE
|
||||
@@ -140,14 +144,17 @@ jobs:
|
||||
export XDG_CACHE_HOME="$smoke_dir/cache"
|
||||
export XDG_STATE_HOME="$smoke_dir/state"
|
||||
mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME"
|
||||
timeout 60s dist/Mocktail-x86_64.AppImage --appimage-extract-and-run --help
|
||||
timeout 60s dist/Mocktail-x86_64.AppImage --appimage-extract-and-run mocktail_updater status
|
||||
test "$(dist/Nightcap-nightly-x86_64.AppImage --appimage-updateinformation)" = "$UPINFO"
|
||||
timeout 60s dist/Nightcap-nightly-x86_64.AppImage --appimage-extract-and-run --help
|
||||
timeout 60s dist/Nightcap-nightly-x86_64.AppImage --appimage-extract-and-run mocktail_updater status
|
||||
|
||||
- name: Upload AppImage
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Nightcap-AppImage
|
||||
path: dist/Mocktail-x86_64.AppImage
|
||||
path: |
|
||||
dist/Nightcap-nightly-x86_64.AppImage
|
||||
dist/Nightcap-nightly-x86_64.AppImage.zsync
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
@@ -173,13 +180,14 @@ jobs:
|
||||
name: Nightcap-AppImage
|
||||
path: release
|
||||
|
||||
- name: Name and checksum the nightly build
|
||||
- name: Checksum the nightly build
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
# The Makefile builds under upstream's artifact name; the published
|
||||
# asset carries this fork's.
|
||||
mv -- release/Mocktail-x86_64.AppImage release/Nightcap-nightly-x86_64.AppImage
|
||||
# The .zsync file names the AppImage it updates, so the build already
|
||||
# carries the published name.
|
||||
test -s release/Nightcap-nightly-x86_64.AppImage
|
||||
test -s release/Nightcap-nightly-x86_64.AppImage.zsync
|
||||
(cd release && sha256sum Nightcap-nightly-x86_64.AppImage \
|
||||
> Nightcap-nightly-x86_64.AppImage.sha256)
|
||||
find release -maxdepth 1 -type f -print
|
||||
@@ -206,7 +214,7 @@ jobs:
|
||||
# Drop assets left by earlier runs under other names.
|
||||
while IFS= read -r asset; do
|
||||
case "$asset" in
|
||||
Nightcap-nightly-x86_64.AppImage|Nightcap-nightly-x86_64.AppImage.sha256)
|
||||
Nightcap-nightly-x86_64.AppImage|Nightcap-nightly-x86_64.AppImage.zsync|Nightcap-nightly-x86_64.AppImage.sha256)
|
||||
;;
|
||||
*)
|
||||
gh release delete-asset continuous "$asset" --yes
|
||||
|
||||
+197
-205
@@ -1,248 +1,245 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Nightcap. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Release notes on
|
||||
GitHub are taken from the matching section here.
|
||||
All notable changes to Nightcap. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Release notes on GitHub are taken from the matching section here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.4] - 2026-10-11
|
||||
|
||||
### Added
|
||||
|
||||
- The AppImage build now makes a `.zsync` file, so tools like `appimageupdatetool` can update the AppImage.
|
||||
- The desktop side panel menu now shows by default on PC profiles. Set `FIntFlyoutTestVariantRollout12` in
|
||||
`fflags.json` to change it.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Flatpak build asks for the X11 socket only when there is no Wayland session, as before 0.3.3.
|
||||
- Nightcap now uses Wayland on a Wayland session with NVIDIA drivers too, however it is started. It used to
|
||||
fall back to X11 there when started from a terminal.
|
||||
- Building Nightcap no longer needs libexecinfo.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Roblox 2.742 being rejected as an update, which left the game on the older version. Nightcap now
|
||||
accepts Roblox versions that change the order of their start-up steps.
|
||||
- Fixed Nightcap reporting Mocktail's version number, 1.0.4, in session logs and support bundles. It now
|
||||
reports its own version.
|
||||
- Fixed the old Roblox home screen showing instead of the new one with the Me tab and the "For you" and
|
||||
"Charts" tabs. Nightcap now runs the Roblox start-up step that loads the new interface for your account.
|
||||
- Fixed anti-aliasing being forced off at every graphics quality level. Roblox now picks it, and a
|
||||
`FIntDebugForceMSAASamples` value in `fflags.json` still applies.
|
||||
- Fixed the voice chat microphone only working on Roblox 2.738. It no longer depends on one Roblox version.
|
||||
- Fixed other players not hearing you in voice chat after a Roblox update. The updater now rebuilds the
|
||||
audio device data when the previous version's profile has none.
|
||||
- Fixed frame hitches while textures load on graphics cards that need ETC2 textures decoded on the CPU.
|
||||
- Fixed problems on Chimera Linux and other musl systems, including error messages that differed from the
|
||||
ones Roblox expects.
|
||||
- Fixed the nightly AppImage build failing when a bundled GTK 4 was older than the one libadwaita needs.
|
||||
- Fixed a random crash on Wayland with NVIDIA drivers, where the game window closed after you let go of the
|
||||
camera or left mouse lock. The log showed `wp_linux_drm_syncobj_surface_v1` error 3.
|
||||
- Fixed some menu actions being ignored. The Chat button on a profile now opens the Party tab with that
|
||||
player, and clicking a search suggestion now runs the search instead of closing the search menu.
|
||||
- Fixed possible crashes when the game calls an Android function Nightcap did not provide, or releases the
|
||||
same reference twice.
|
||||
- Fixed a possible hang on close when a web view process stayed open. Nightcap now stops waiting for the log
|
||||
writer after 2 seconds.
|
||||
- Fixed a possible missing picture on graphics drivers that reject the low-latency present mode. Nightcap now
|
||||
retries with the game's own mode.
|
||||
- Fixed a possible Wayland crash when the window first appears on the OpenGL graphics path.
|
||||
- Fixed a missing picture on machines where the graphics card runs a different driver than the one Nightcap
|
||||
picked, such as an NVIDIA card on nouveau or a laptop with an Intel chip and an older second card. Nightcap
|
||||
now picks the Vulkan driver that matches the running kernel driver, and says so when no card can run Vulkan.
|
||||
|
||||
## [0.3.3] - 2026-10-04
|
||||
|
||||
### Added
|
||||
|
||||
- Joining a friend from a Roblox "follow user" link now works. Nightcap looks
|
||||
up which game they are in and joins it.
|
||||
- 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.
|
||||
- Roblox textures now decode about 3 times faster, which shortens the stalls when a place loads and when new
|
||||
parts of the map stream in. Thanks to @glook9001.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `make update-roblox` no longer deletes your previous Roblox install when an
|
||||
import fails partway through. If a step fails, the old files are put back,
|
||||
or kept in a folder whose path is printed. Thanks to @kimlherme.
|
||||
- A failed Roblox update now restores your previous version's records instead
|
||||
of leaving them half switched. A damaged version file is rejected instead of
|
||||
being half read, and a temporary failure to start the startup check no longer
|
||||
marks a working Roblox build as broken for good. Thanks to @kimlherme.
|
||||
- Fixed a possible crash when Roblox clears its audio queue while sound is
|
||||
still playing, and a memory leak in lists of objects passed to Roblox's Java
|
||||
code. Thanks to @kimlherme.
|
||||
- Fixed a possible crash when the text overlay shuts down, a freeze when Roblox
|
||||
releases the mouse lock while another release is already in progress, and
|
||||
network lookups that misread Roblox's options. Thanks to @kimlherme.
|
||||
- Fixed the sky sometimes rendering with the wrong textures in the Vulkan
|
||||
renderer.
|
||||
- Clicking while a Roblox text box has focus no longer closes it. Only Roblox
|
||||
decides when a click ends typing.
|
||||
- Going to another page in the web view, such as Friends from a profile, no
|
||||
longer closes the web view. It crashed on every page change on Wayland
|
||||
systems whose GL setup GTK could not use.
|
||||
- Fixed a crash when Roblox's saved dark or light theme setting was missing or
|
||||
damaged.
|
||||
- The Flatpak build now detects an NVIDIA driver from inside its sandbox, so it
|
||||
uses X11 instead of native Wayland where both are available. This avoids
|
||||
Wayland hangs and display disconnects.
|
||||
- `make update-roblox` no longer deletes your previous Roblox install when an import fails partway through. If
|
||||
a step fails, the old files are put back, or kept in a folder whose path is printed. Thanks to @kimlherme.
|
||||
- A failed Roblox update now restores your previous version's records instead of leaving them half switched. A
|
||||
damaged version file is rejected instead of being half read, and a temporary failure to start the startup
|
||||
check no longer marks a working Roblox build as broken for good. Thanks to @kimlherme.
|
||||
- Fixed a possible crash when Roblox clears its audio queue while sound is still playing, and a memory leak in
|
||||
lists of objects passed to Roblox's Java code. Thanks to @kimlherme.
|
||||
- Fixed a possible crash when the text overlay shuts down, a freeze when Roblox releases the mouse lock while
|
||||
another release is already in progress, and network lookups that misread Roblox's options. Thanks to
|
||||
@kimlherme.
|
||||
- Fixed the sky sometimes rendering with the wrong textures in the Vulkan renderer.
|
||||
- Clicking while a Roblox text box has focus no longer closes it. Only Roblox decides when a click ends
|
||||
typing.
|
||||
- Going to another page in the web view, such as Friends from a profile, no longer closes the web view. It
|
||||
crashed on every page change on Wayland systems whose GL setup GTK could not use.
|
||||
- Fixed a crash when Roblox's saved dark or light theme setting was missing or damaged.
|
||||
|
||||
## [0.3.2] - 2026-09-29
|
||||
|
||||
### Added
|
||||
|
||||
- Performance recordings made with `--profile` now show how busy the CPU was
|
||||
during each frame, which makes it easier to tell what caused a stutter.
|
||||
- Experimental support for ARM64 (`aarch64`) Linux, running Roblox's Android
|
||||
`arm64-v8a` client.
|
||||
- Performance recordings made with `--profile` now show how busy the CPU was during each frame, which makes it
|
||||
easier to tell what caused a stutter.
|
||||
- Experimental support for ARM64 (`aarch64`) Linux, running Roblox's Android `arm64-v8a` client.
|
||||
|
||||
### Fixed
|
||||
|
||||
- When Roblox asks for a Google Play device check during login, Nightcap now
|
||||
answers right away that it isn't supported. Before, the "Verifying you're
|
||||
not a bot" screen waited about a minute for an answer and then sent you
|
||||
back to the login screen.
|
||||
- Solving a login challenge now closes its window. Before, Roblox's
|
||||
"challenge completed" signal was ignored and the window stayed open.
|
||||
Thanks to @glook9001.
|
||||
- Login no longer stalls after you pass a verification check. Before, Roblox
|
||||
only saw the page's "challenge completed" signal after a one-minute
|
||||
timeout, so it gave up and the login failed. On a Roblox build Nightcap
|
||||
can't handle this way, the log says "WebViewProtocol getter not
|
||||
recognized" and web pages behave as before.
|
||||
- Login check windows and other web pages get 5 seconds to start instead of
|
||||
3, so a slow start no longer closes them. The log says how long each one
|
||||
took to open.
|
||||
- Less stutter while joining a game and while textures load in. In Blade
|
||||
Ball, loading hitches dropped by about half, and the worst one went from
|
||||
0.8 seconds to 0.55 seconds.
|
||||
- Clicking Play on a private server now joins right away. Before, nothing
|
||||
happened until you clicked a sidebar tab like Home.
|
||||
- When Roblox asks for a Google Play device check during login, Nightcap now answers right away that it isn't
|
||||
supported. Before, the "Verifying you're not a bot" screen waited about a minute for an answer and then sent
|
||||
you back to the login screen.
|
||||
- Solving a login challenge now closes its window. Before, Roblox's "challenge completed" signal was ignored
|
||||
and the window stayed open. Thanks to @glook9001.
|
||||
- Login no longer stalls after you pass a verification check. Before, Roblox only saw the page's "challenge
|
||||
completed" signal after a one-minute timeout, so it gave up and the login failed. On a Roblox build Nightcap
|
||||
can't handle this way, the log says "WebViewProtocol getter not recognized" and web pages behave as before.
|
||||
- Login check windows and other web pages get 5 seconds to start instead of 3, so a slow start no longer
|
||||
closes them. The log says how long each one took to open.
|
||||
- Less stutter while joining a game and while textures load in. In Blade Ball, loading hitches dropped by
|
||||
about half, and the worst one went from 0.8 seconds to 0.55 seconds.
|
||||
- Clicking Play on a private server now joins right away. Before, nothing happened until you clicked a sidebar
|
||||
tab like Home.
|
||||
- Middle click now reaches Roblox as the middle mouse button.
|
||||
- Scrolling follows your system's natural scrolling setting.
|
||||
- Fixed a crash on some x86_64 hosts when Roblox asked about a thread other
|
||||
than the one making the call.
|
||||
- Roblox updates no longer mix up the ARM64 and x86_64 compatibility profiles
|
||||
for the same version.
|
||||
- A partial or out-of-range user id in `MOCKTAIL_ROBLOX_USER_ID` or
|
||||
`MOCKTAIL_LOCAL_STORAGE_CURRENT_USER_ID` is now ignored instead of being
|
||||
partly read.
|
||||
- Fixed a crash on some x86_64 hosts when Roblox asked about a thread other than the one making the call.
|
||||
- Roblox updates no longer mix up the ARM64 and x86_64 compatibility profiles for the same version.
|
||||
- A partial or out-of-range user id in `MOCKTAIL_ROBLOX_USER_ID` or `MOCKTAIL_LOCAL_STORAGE_CURRENT_USER_ID`
|
||||
is now ignored instead of being partly read.
|
||||
|
||||
### Changed
|
||||
|
||||
- `make flatpak` and `scripts/build_flatpak.sh` now build with 2 jobs by
|
||||
default instead of 4, so a local Flatpak build is less likely to run out
|
||||
of memory. Set `FLATPAK_JOBS` or `MOCKTAIL_FLATPAK_JOBS` to use more.
|
||||
Thanks to @glook9001.
|
||||
- `make flatpak` and `scripts/build_flatpak.sh` now build with 2 jobs by default instead of 4, so a local
|
||||
Flatpak build is less likely to run out of memory. Set `FLATPAK_JOBS` or `MOCKTAIL_FLATPAK_JOBS` to use
|
||||
more. Thanks to @glook9001.
|
||||
|
||||
## [0.3.1] - 2026-09-20
|
||||
|
||||
### Added
|
||||
|
||||
- Each release carries a DEB, an RPM, and a pacman package beside the
|
||||
AppImage, with a `.sha256` for every file. They install `/usr/bin/mocktail`
|
||||
and conflict with each other and with upstream's `mocktail` package.
|
||||
Nightcap is in no distribution repository or the AUR; the recipes under
|
||||
`packaging/aur/nightcap{,-bin,-git}` build from this repository locally.
|
||||
- The AppStream metadata lists the Nightcap releases, so software centres
|
||||
show the release history instead of upstream's.
|
||||
- Each release carries a DEB, an RPM, and a pacman package beside the AppImage, with a `.sha256` for every
|
||||
file. They install `/usr/bin/mocktail` and conflict with each other and with upstream's `mocktail` package.
|
||||
Nightcap is in no distribution repository or the AUR; the recipes under `packaging/aur/nightcap{,-bin,-git}`
|
||||
build from this repository locally.
|
||||
- The AppStream metadata lists the Nightcap releases, so software centres show the release history instead of
|
||||
upstream's.
|
||||
|
||||
### Fixed
|
||||
|
||||
- ETC2 texture decoding no longer blocks the thread that submits the frame.
|
||||
Uploads decode on a background dispatcher and the submit carries a timeline
|
||||
semaphore wait, so the GPU waits for the texture instead of the render
|
||||
thread. Reading the application's compressed bytes moves to that dispatcher
|
||||
too whenever they are already mapped. In Blade Ball the worst
|
||||
`vkQueueSubmit` fell from 140ms to 1ms, its p99 from 4.9ms to 0.05ms, and
|
||||
the total time spent in submits from 2534ms to 309ms. Frame interval is
|
||||
unchanged: the remaining tail is the engine's own main-thread step. A device
|
||||
without the `timelineSemaphore` feature decodes inline as before, as does a
|
||||
submit whose `pNext` already sizes arrays by its semaphore counts.
|
||||
- The mouse pointer jumped when a right-click camera drag ended, and drifted
|
||||
to the viewport edge during one. The input router inferred pointer capture
|
||||
from SDL reporting zero absolute coordinates, which holds on X11 but not on
|
||||
Wayland, where the reported position accumulates the relative deltas and
|
||||
leaves the window. Motion, button, and wheel events now carry the window's
|
||||
relative mouse mode. The right-click camera fallback also only captures
|
||||
inside an experience, so the cursor stays free over the app surface.
|
||||
- Shutting the window down while an experience was starting or leaving could
|
||||
free the pointer capture owner underneath the thread using it. Callers off
|
||||
the SDL thread now hold a reference for the length of their call.
|
||||
- The TextBox caret did not move when a trailing space was typed in a
|
||||
single-line field. The final cluster rect SDL_ttf reports stops at the last
|
||||
inked glyph, so the laid-out width is used there instead. Wrapped layouts
|
||||
keep the clamp, because their width is the widest line rather than the last.
|
||||
- Every TextBox keystroke re-ran `TTF_Init`, a fontconfig sort, six
|
||||
`TTF_OpenFont` calls, and `TTF_Quit` on the render thread. The overlay holds
|
||||
its opened fonts and reopens them only when the Roblox font or the point
|
||||
size changes, keeping the current set when a reopen fails. Measured over 100
|
||||
rasters, 1.59ms to 0.12ms per keystroke.
|
||||
- Staged ETC2 copies now carry an explicit host-write barrier. A queue
|
||||
submission only makes host writes from before it visible to the device, and
|
||||
an asynchronous decode writes after the submit, so without the barrier those
|
||||
texels were not guaranteed to reach the copy that reads them.
|
||||
- ETC2 texture decoding no longer blocks the thread that submits the frame. Uploads decode on a background
|
||||
dispatcher and the submit carries a timeline semaphore wait, so the GPU waits for the texture instead of the
|
||||
render thread. Reading the application's compressed bytes moves to that dispatcher too whenever they are
|
||||
already mapped. In Blade Ball the worst `vkQueueSubmit` fell from 140ms to 1ms, its p99 from 4.9ms to
|
||||
0.05ms, and the total time spent in submits from 2534ms to 309ms. Frame interval is unchanged: the remaining
|
||||
tail is the engine's own main-thread step. A device without the `timelineSemaphore` feature decodes inline
|
||||
as before, as does a submit whose `pNext` already sizes arrays by its semaphore counts.
|
||||
- The mouse pointer jumped when a right-click camera drag ended, and drifted to the viewport edge during one.
|
||||
The input router inferred pointer capture from SDL reporting zero absolute coordinates, which holds on X11
|
||||
but not on Wayland, where the reported position accumulates the relative deltas and leaves the window.
|
||||
Motion, button, and wheel events now carry the window's relative mouse mode. The right-click camera fallback
|
||||
also only captures inside an experience, so the cursor stays free over the app surface.
|
||||
- Shutting the window down while an experience was starting or leaving could free the pointer capture owner
|
||||
underneath the thread using it. Callers off the SDL thread now hold a reference for the length of their
|
||||
call.
|
||||
- The TextBox caret did not move when a trailing space was typed in a single-line field. The final cluster
|
||||
rect SDL_ttf reports stops at the last inked glyph, so the laid-out width is used there instead. Wrapped
|
||||
layouts keep the clamp, because their width is the widest line rather than the last.
|
||||
- Every TextBox keystroke re-ran `TTF_Init`, a fontconfig sort, six `TTF_OpenFont` calls, and `TTF_Quit` on
|
||||
the render thread. The overlay holds its opened fonts and reopens them only when the Roblox font or the
|
||||
point size changes, keeping the current set when a reopen fails. Measured over 100 rasters, 1.59ms to 0.12ms
|
||||
per keystroke.
|
||||
- Staged ETC2 copies now carry an explicit host-write barrier. A queue submission only makes host writes from
|
||||
before it visible to the device, and an asynchronous decode writes after the submit, so without the barrier
|
||||
those texels were not guaranteed to reach the copy that reads them.
|
||||
|
||||
### Changed
|
||||
|
||||
- `docs/PERFORMANCE.md` documents the GameMode settings to run with. A
|
||||
`desiredgov=powersave` in `~/.config/gamemode.ini` overrides the packaged
|
||||
`performance` default and clocks the CPU down for the whole session while
|
||||
still reporting an active performance request.
|
||||
- `docs/PERFORMANCE.md` documents the GameMode settings to run with. A `desiredgov=powersave` in
|
||||
`~/.config/gamemode.ini` overrides the packaged `performance` default and clocks the CPU down for the whole
|
||||
session while still reporting an active performance request.
|
||||
|
||||
## [0.3.0] - 2026-09-18
|
||||
|
||||
### Added
|
||||
|
||||
- Custom Discord presence. `integrations.discord_rpc.text.title` replaces the
|
||||
name in "Playing Nightcap", and `integrations.discord_rpc.images` sets the
|
||||
large and small icons and their hover text. Text and image fields accept
|
||||
`{place_name}`, `{place_icon}`, and `{creator_name}`. A field that expands
|
||||
an empty placeholder is left out, and `text.state: ""` hides the state line.
|
||||
- The default Discord card now matches Sober: the title is "Roblox", the
|
||||
details line is "Playing <experience>", the state is "by <creator>", and
|
||||
the Roblox badge sits inside the experience icon. The creator comes from the
|
||||
same Roblox games request that already fetches the experience name. With
|
||||
`show_place_name: false` the creator is hidden too, so the card shows only
|
||||
the title and timer.
|
||||
- `--profile <file>` writes a Chrome-format trace of the Vulkan adapter's
|
||||
work: presents, GPU waits, submits, ETC2 texture decode, and pipeline and
|
||||
shader creation. Open it in ui.perfetto.dev. `docs/BENCHMARKING.md`
|
||||
describes the benchmark runs, and `scripts/summarize_profile_trace.py`
|
||||
turns traces into a before and after table.
|
||||
- Custom Discord presence. `integrations.discord_rpc.text.title` replaces the name in "Playing Nightcap", and
|
||||
`integrations.discord_rpc.images` sets the large and small icons and their hover text. Text and image fields
|
||||
accept `{place_name}`, `{place_icon}`, and `{creator_name}`. A field that expands an empty placeholder is
|
||||
left out, and `text.state: ""` hides the state line.
|
||||
- The default Discord card now matches Sober: the title is "Roblox", the details line is "Playing
|
||||
<experience>", the state is "by <creator>", and the Roblox badge sits inside the experience icon. The
|
||||
creator comes from the same Roblox games request that already fetches the experience name. With
|
||||
`show_place_name: false` the creator is hidden too, so the card shows only the title and timer.
|
||||
- `--profile <file>` writes a Chrome-format trace of the Vulkan adapter's work: presents, GPU waits, submits,
|
||||
ETC2 texture decode, and pipeline and shader creation. Open it in ui.perfetto.dev. `docs/BENCHMARKING.md`
|
||||
describes the benchmark runs, and `scripts/summarize_profile_trace.py` turns traces into a before and after
|
||||
table.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Texture uploads stalled the frame they were submitted on. ETC2 batches
|
||||
decoded on worker threads but were then resampled one upload at a time on
|
||||
the submitting thread, which at the default 4x upscale is sixteen times the
|
||||
decoded texels. Resampling now runs on the same worker pool, in two passes so
|
||||
mip level 0 still resolves before the mips built from it. In a 70k-part
|
||||
place the worst `vkQueueSubmit` fell from 167ms to 92ms.
|
||||
- Most ETC2 batches decoded on a single thread. Workers were created and
|
||||
joined per batch, so the parallel floor had to cover that cost and sat at a
|
||||
1024x1024 image; batches under it ran on the caller, which was 54% of all
|
||||
decode time in a 70k-part place. Decode workers are now a persistent pool,
|
||||
and the floor drops to a 128x128 image. Mean decode time in that place fell
|
||||
from 8.2ms to 4.7ms, and 11.8ms before both texture fixes.
|
||||
- On CPUs with a wide core-speed spread, Feral GameMode pinned the process to
|
||||
the few fastest cores, concentrating Roblox's worker pool onto them instead
|
||||
of spreading it. With `performance.gamemode: auto`, a request that narrows
|
||||
the CPU affinity is now released again, which releases the pin. Set
|
||||
`pin_cores=no` in `~/.config/gamemode.ini` to keep GameMode without the
|
||||
pinning, or `performance.gamemode: on` to accept it. See
|
||||
`docs/PERFORMANCE.md`.
|
||||
- With `show_place_name: false`, Discord no longer shows the experience
|
||||
thumbnail or its name on hover.
|
||||
- The host main thread no longer spins on the engine's message pump, which
|
||||
cost about half a core and 146,000 empty calls a second. It now rests 100 µs
|
||||
between polls: sleeping when the governor is `performance`, parking the
|
||||
core with TPAUSE on CPUs that have it, spinning otherwise. With GameMode
|
||||
setting the governor, process CPU in a 60k-part place fell from 216% to
|
||||
107% at the same 120 fps. `MOCKTAIL_ENGINE_PUMP_REST` forces a mode; see
|
||||
`docs/PERFORMANCE.md`.
|
||||
- ETC2 uploads with a `bufferRowLength` or `bufferImageHeight` larger than
|
||||
the image read the padded rows as packed data and decoded garbage.
|
||||
- ETC2 resampling created and joined worker threads on every batch. It now
|
||||
runs on the persistent decode pool.
|
||||
- A failed trace write left the profile writer thread waking every 250 ms
|
||||
for the rest of the session. It now exits.
|
||||
- The `--profile` trace gains a `pump` slice for the engine's main-thread
|
||||
step.
|
||||
- Texture uploads stalled the frame they were submitted on. ETC2 batches decoded on worker threads but were
|
||||
then resampled one upload at a time on the submitting thread, which at the default 4x upscale is sixteen
|
||||
times the decoded texels. Resampling now runs on the same worker pool, in two passes so mip level 0 still
|
||||
resolves before the mips built from it. In a 70k-part place the worst `vkQueueSubmit` fell from 167ms to
|
||||
92ms.
|
||||
- Most ETC2 batches decoded on a single thread. Workers were created and joined per batch, so the parallel
|
||||
floor had to cover that cost and sat at a 1024x1024 image; batches under it ran on the caller, which was 54%
|
||||
of all decode time in a 70k-part place. Decode workers are now a persistent pool, and the floor drops to a
|
||||
128x128 image. Mean decode time in that place fell from 8.2ms to 4.7ms, and 11.8ms before both texture
|
||||
fixes.
|
||||
- On CPUs with a wide core-speed spread, Feral GameMode pinned the process to the few fastest cores,
|
||||
concentrating Roblox's worker pool onto them instead of spreading it. With `performance.gamemode: auto`, a
|
||||
request that narrows the CPU affinity is now released again, which releases the pin. Set `pin_cores=no` in
|
||||
`~/.config/gamemode.ini` to keep GameMode without the pinning, or `performance.gamemode: on` to accept it.
|
||||
See `docs/PERFORMANCE.md`.
|
||||
- With `show_place_name: false`, Discord no longer shows the experience thumbnail or its name on hover.
|
||||
- The host main thread no longer spins on the engine's message pump, which cost about half a core and 146,000
|
||||
empty calls a second. It now rests 100 µs between polls: sleeping when the governor is `performance`,
|
||||
parking the core with TPAUSE on CPUs that have it, spinning otherwise. With GameMode setting the governor,
|
||||
process CPU in a 60k-part place fell from 216% to 107% at the same 120 fps. `MOCKTAIL_ENGINE_PUMP_REST`
|
||||
forces a mode; see `docs/PERFORMANCE.md`.
|
||||
- ETC2 uploads with a `bufferRowLength` or `bufferImageHeight` larger than the image read the padded rows as
|
||||
packed data and decoded garbage.
|
||||
- ETC2 resampling created and joined worker threads on every batch. It now runs on the persistent decode pool.
|
||||
- A failed trace write left the profile writer thread waking every 250 ms for the rest of the session. It now
|
||||
exits.
|
||||
- The `--profile` trace gains a `pump` slice for the engine's main-thread step.
|
||||
|
||||
## [0.2.0] - 2026-09-16
|
||||
|
||||
### Added
|
||||
|
||||
- FAQ covering Discord RPC, log locations, and VR, adapted from upstream.
|
||||
- `input.raw_mouse` in `config.yaml` sends mouse look deltas in the host's
|
||||
own units. Turn it on when first-person aiming feels too slow or too fast
|
||||
under fractional display scaling. Off by default.
|
||||
- Nightly AppImage builds, published as the `continuous` prerelease. They are
|
||||
untested; use a tagged release for normal play.
|
||||
- `input.raw_mouse` in `config.yaml` sends mouse look deltas in the host's own units. Turn it on when
|
||||
first-person aiming feels too slow or too fast under fractional display scaling. Off by default.
|
||||
- Nightly AppImage builds, published as the `continuous` prerelease. They are untested; use a tagged release
|
||||
for normal play.
|
||||
|
||||
### Changed
|
||||
|
||||
- Values in `fflags.json` now win over the performance preset instead of
|
||||
stopping the launch. Startup prints each key it kept.
|
||||
- Discord Rich Presence uses Nightcap's own Discord application, so it shows
|
||||
as "Playing Nightcap". Terms and privacy pages for it live on the
|
||||
project site.
|
||||
- Discord Rich Presence is on by default. Set
|
||||
`integrations.discord_rpc.enabled: false` to turn it off.
|
||||
- The AppImage build verifies its reduced dependency packages against digests
|
||||
taken at build time, rather than a digest list kept in the repository.
|
||||
- Values in `fflags.json` now win over the performance preset instead of stopping the launch. Startup prints
|
||||
each key it kept.
|
||||
- Discord Rich Presence uses Nightcap's own Discord application, so it shows as "Playing Nightcap". Terms and
|
||||
privacy pages for it live on the project site.
|
||||
- Discord Rich Presence is on by default. Set `integrations.discord_rpc.enabled: false` to turn it off.
|
||||
- The AppImage build verifies its reduced dependency packages against digests taken at build time, rather than
|
||||
a digest list kept in the repository.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mouse look keeps responding after a long turn. The tracked pointer position
|
||||
could drift past the edge of the view, which swallowed every reversal until
|
||||
the mouse travelled all the way back.
|
||||
- Nightcap no longer refuses to start with `cannot lock appStorage`. It
|
||||
created its own credential directory using the process umask, then rejected
|
||||
that directory for being group writable. Systems with a permissive umask,
|
||||
such as `002`, failed on first run.
|
||||
- Mouse look keeps responding after a long turn. The tracked pointer position could drift past the edge of the
|
||||
view, which swallowed every reversal until the mouse travelled all the way back.
|
||||
- Nightcap no longer refuses to start with `cannot lock appStorage`. It created its own credential directory
|
||||
using the process umask, then rejected that directory for being group writable. Systems with a permissive
|
||||
umask, such as `002`, failed on first run.
|
||||
|
||||
## [0.1.0] - 2026-09-15
|
||||
|
||||
@@ -250,37 +247,32 @@ First Nightcap release, forked from komaruworld/mocktail.
|
||||
|
||||
### Added
|
||||
|
||||
- ETC2 and EAC texture emulation on Vulkan, so games that use compressed
|
||||
mobile textures render correctly on desktop GPUs. Decoding runs on worker
|
||||
threads to avoid stutter.
|
||||
- PNG texture overrides in `~/.config/mocktail/textures`, keyed by upload
|
||||
hash, with a dump mode to find the texture you want to replace.
|
||||
- Small textures are upscaled 4x by default. Set
|
||||
`MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn it off.
|
||||
- AppImage and signed Flatpak builds on every release tag. The Flatpak repo
|
||||
is published at https://coderdayton.github.io/nightcap/.
|
||||
- ETC2 and EAC texture emulation on Vulkan, so games that use compressed mobile textures render correctly on
|
||||
desktop GPUs. Decoding runs on worker threads to avoid stutter.
|
||||
- PNG texture overrides in `~/.config/mocktail/textures`, keyed by upload hash, with a dump mode to find the
|
||||
texture you want to replace.
|
||||
- Small textures are upscaled 4x by default. Set `MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn it off.
|
||||
- AppImage and signed Flatpak builds on every release tag. The Flatpak repo is published at
|
||||
https://coderdayton.github.io/nightcap/.
|
||||
- Roadmap in `docs/ROADMAP.md`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Frames are presented as soon as they are ready instead of waiting in a
|
||||
queue, which lowers input latency.
|
||||
- Roblox now sees the real host RAM and display size instead of a low-end
|
||||
phone profile.
|
||||
- App ID is `io.github.CoderDayton.nightcap`, so Nightcap installs next to
|
||||
upstream Mocktail. The binary and config paths keep the `mocktail` name.
|
||||
- Frames are presented as soon as they are ready instead of waiting in a queue, which lowers input latency.
|
||||
- Roblox now sees the real host RAM and display size instead of a low-end phone profile.
|
||||
- App ID is `io.github.CoderDayton.nightcap`, so Nightcap installs next to upstream Mocktail. The binary and
|
||||
config paths keep the `mocktail` name.
|
||||
- New name, icon, and colors.
|
||||
- The desktop entry launches on Wayland and points at the installed binary.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Servers no longer kick with error 319 shortly after joining.
|
||||
- The FMOD output-device bridge is optional, so the updater no longer fails
|
||||
without it.
|
||||
- The AppImage build no longer fails on stale checksums for the slimmed
|
||||
Arch packages.
|
||||
- The FMOD output-device bridge is optional, so the updater no longer fails without it.
|
||||
- The AppImage build no longer fails on stale checksums for the slimmed Arch packages.
|
||||
|
||||
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.3...HEAD
|
||||
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.4...HEAD
|
||||
[0.3.4]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.4
|
||||
[0.3.3]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.3
|
||||
[0.3.2]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.2
|
||||
[0.3.1]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.1
|
||||
|
||||
+1
-46
@@ -9,7 +9,7 @@
|
||||
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(Mocktail
|
||||
VERSION 1.0.4
|
||||
VERSION 0.3.4
|
||||
DESCRIPTION "Android Roblox compatibility runtime for Linux"
|
||||
LANGUAGES CXX
|
||||
)
|
||||
@@ -561,50 +561,6 @@ target_link_libraries(mocktail_bionic_abi_exports PRIVATE
|
||||
Threads::Threads
|
||||
)
|
||||
|
||||
include(CheckCXXSourceCompiles)
|
||||
include(CheckIncludeFileCXX)
|
||||
include(CMakePushCheckState)
|
||||
|
||||
check_include_file_cxx(execinfo.h MOCKTAIL_HAVE_EXECINFO_H)
|
||||
if(NOT MOCKTAIL_HAVE_EXECINFO_H)
|
||||
message(FATAL_ERROR
|
||||
"execinfo.h is required for crash diagnostics; on musl install "
|
||||
"libexecinfo-devel"
|
||||
)
|
||||
endif()
|
||||
|
||||
set(MOCKTAIL_EXECINFO_LINK_LIBRARY "")
|
||||
set(_mocktail_execinfo_link_probe [=[
|
||||
#include <execinfo.h>
|
||||
int main() {
|
||||
void* frames[1] = {};
|
||||
const int count = backtrace(frames, 1);
|
||||
return backtrace_symbols(frames, count) == nullptr;
|
||||
}
|
||||
]=])
|
||||
check_cxx_source_compiles(
|
||||
"${_mocktail_execinfo_link_probe}"
|
||||
MOCKTAIL_EXECINFO_IN_HOST_LIBC
|
||||
)
|
||||
if(NOT MOCKTAIL_EXECINFO_IN_HOST_LIBC)
|
||||
find_library(MOCKTAIL_EXECINFO_LIBRARY NAMES execinfo REQUIRED)
|
||||
cmake_push_check_state(RESET)
|
||||
set(CMAKE_REQUIRED_LIBRARIES "${MOCKTAIL_EXECINFO_LIBRARY}")
|
||||
check_cxx_source_compiles(
|
||||
"${_mocktail_execinfo_link_probe}"
|
||||
MOCKTAIL_EXECINFO_IN_EXTERNAL_LIBRARY
|
||||
)
|
||||
cmake_pop_check_state()
|
||||
if(NOT MOCKTAIL_EXECINFO_IN_EXTERNAL_LIBRARY)
|
||||
message(FATAL_ERROR
|
||||
"execinfo.h is available, but backtrace and backtrace_symbols cannot "
|
||||
"be linked"
|
||||
)
|
||||
endif()
|
||||
set(MOCKTAIL_EXECINFO_LINK_LIBRARY "${MOCKTAIL_EXECINFO_LIBRARY}")
|
||||
endif()
|
||||
unset(_mocktail_execinfo_link_probe)
|
||||
|
||||
add_library(mocktail_legacy_runtime STATIC
|
||||
src/legacy/legacy_runtime.cc
|
||||
src/runtime/roblox_text_input_jni_bridge.cc
|
||||
@@ -629,7 +585,6 @@ target_link_libraries(mocktail_legacy_runtime PRIVATE
|
||||
libc_shim
|
||||
mocktail_window
|
||||
mocktail_graphics_foundation
|
||||
${MOCKTAIL_EXECINFO_LINK_LIBRARY}
|
||||
)
|
||||
target_compile_definitions(mocktail_legacy_runtime PRIVATE
|
||||
"MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=\"${MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST}\""
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<a href="https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml"><img src="https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://github.com/CoderDayton/nightcap/stargazers"><img src="https://img.shields.io/github/stars/CoderDayton/nightcap?style=flat&logo=github" alt="Stars"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License"></a>
|
||||
<a href="https://discord.gg/DbAUG9hBFU"><img alt="Discord" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Fv10%2Finvites%2FDbAUG9hBFU%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&label=Discord&logo=discord&logoColor=white&color=5865F2&suffix=%20members"></a>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -121,6 +122,9 @@ PC profiles use the legacy Charts page by default to avoid the blank screen
|
||||
caused by `Color3` errors in the newer SDUI page. An explicit
|
||||
`FFlagLuaAppChartsAppPage` override takes precedence over this default.
|
||||
|
||||
PC profiles also set `FIntFlyoutTestVariantRollout12` to `2`, which shows the
|
||||
desktop side panel menu. An explicit override takes precedence.
|
||||
|
||||
```json
|
||||
{
|
||||
"FFlagExample": "True",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"mode": "vendored-patched-snapshot",
|
||||
"url": "https://github.com/minecraft-linux/mcpelauncher-linker.git",
|
||||
"upstream_commit": "1ac3ea6c1cf4f73a84d73f0bff510fba94a7d0f2",
|
||||
"vendor_tree": "fca44d85e3773c135ccfc603b14abdf6aeb8efd1",
|
||||
"vendor_tree": "0637221e48f382ec8d0bc701385f4b10bcd07a65",
|
||||
"patch": "third_party/patches/mcpelauncher-linker.patch",
|
||||
"nested_sources": {
|
||||
"android_bionic": {
|
||||
|
||||
@@ -22,20 +22,6 @@ what you are playing and you can switch it all off in one click.
|
||||
Done when every option in `config/mocktail.example.yaml` that a player would
|
||||
touch can be set without a text editor.
|
||||
|
||||
## 2. Controller button glyphs
|
||||
|
||||
Upstream added Xbox and PlayStation gamepad support through SDL. Controllers
|
||||
already hot-plug and release their held inputs when unplugged. One rough edge
|
||||
is left.
|
||||
|
||||
- Correct button glyphs for the connected pad. `SDL_GetGamepadType` is only
|
||||
read for Xbox and PlayStation pads, so a Switch Pro, a GameCube pad, or any
|
||||
generic pad SDL reports as `SDL_GAMEPAD_TYPE_STANDARD` reaches Roblox as
|
||||
type 0 and draws generic glyphs. `SDL_GetRealGamepadType`, and the vendor
|
||||
and product IDs, are available and unused.
|
||||
|
||||
Done when a plugged-in pad shows its own buttons.
|
||||
|
||||
## Shipped
|
||||
|
||||
- Lower input latency: frames are presented as soon as they are ready.
|
||||
|
||||
@@ -11,19 +11,27 @@
|
||||
|
||||
namespace mocktail::compat {
|
||||
|
||||
// glibc's atoi is (int)strtol(), exactly like Bionic's, so the host symbol is
|
||||
// used as-is there. Other libcs (musl) differ on overflow and need the shim.
|
||||
#ifdef __GLIBC__
|
||||
inline constexpr bool kHostAtoiMatchesBionic = true;
|
||||
#else
|
||||
inline constexpr bool kHostAtoiMatchesBionic = false;
|
||||
#endif
|
||||
|
||||
// Android uses size_t fields here; glibc uses int and musl has no equivalent.
|
||||
// An all-zero fallback means unknown, not zero usage.
|
||||
struct BionicMallinfoSnapshot {
|
||||
size_t arena = 0;
|
||||
size_t ordblks = 0;
|
||||
size_t smblks = 0;
|
||||
size_t hblks = 0;
|
||||
size_t hblkhd = 0;
|
||||
size_t usmblks = 0;
|
||||
size_t fsmblks = 0;
|
||||
size_t uordblks = 0;
|
||||
size_t fordblks = 0;
|
||||
size_t keepcost = 0;
|
||||
size_t arena;
|
||||
size_t ordblks;
|
||||
size_t smblks;
|
||||
size_t hblks;
|
||||
size_t hblkhd;
|
||||
size_t usmblks;
|
||||
size_t fsmblks;
|
||||
size_t uordblks;
|
||||
size_t fordblks;
|
||||
size_t keepcost;
|
||||
};
|
||||
|
||||
struct BionicLocaleState;
|
||||
@@ -90,6 +98,7 @@ int mocktail_bionic_cxa_thread_atexit_impl(
|
||||
mocktail::compat::BionicThreadDestructor destructor, void* argument,
|
||||
void* dso_handle);
|
||||
void mocktail_bionic_arc4random_buf(void* buffer, size_t size);
|
||||
int mocktail_bionic_atoi(const char* text);
|
||||
mocktail::compat::BionicMallinfoSnapshot mocktail_bionic_mallinfo();
|
||||
int mocktail_bionic_sysinfo(struct sysinfo* info);
|
||||
int mocktail_bionic_uname(struct utsname* name);
|
||||
|
||||
@@ -36,6 +36,11 @@ struct FmodOutputDeviceBridgeProfile {
|
||||
? std::array<std::size_t, 4>{9, 10, 12, 18}
|
||||
: std::array<std::size_t, 4>{8, 9, 10, 16};
|
||||
}
|
||||
std::array<std::size_t, 6> capture_vtable_indexes() const {
|
||||
return vtable_layout_version == 2
|
||||
? std::array<std::size_t, 6>{4, 13, 14, 15, 16, 17}
|
||||
: std::array<std::size_t, 6>{4, 11, 12, 13, 14, 15};
|
||||
}
|
||||
|
||||
bool valid_vtable_layout() const {
|
||||
return vtable_layout_version == 1 || vtable_layout_version == 2;
|
||||
|
||||
@@ -109,6 +109,15 @@ struct HostAbiProfile {
|
||||
size_t NativeMimallocConstructorRangeEndExclusive() const noexcept;
|
||||
bool ShouldInitializeNativeMimallocThreadAfterConstructor(
|
||||
size_t index) const noexcept;
|
||||
// True when both replays have the one verified shape. The native replay is
|
||||
// one range that runs to the end of .init_array. Its first four
|
||||
// constructors are the process-load constructor at the thread-initializer
|
||||
// checkpoint, the JNI singleton initializer, and two wiring entries. The
|
||||
// host-bridge replay runs the same constructors without the two wiring
|
||||
// entries. `jni_singleton_constructor` is the .init_array index of the JNI
|
||||
// singleton initializer.
|
||||
bool HasVerifiedConstructorPolicy(
|
||||
size_t jni_singleton_constructor) const noexcept;
|
||||
bool HasValidNativePreJniBootstrap() const noexcept;
|
||||
HostAllocatorStrategy ResolveAllocatorStrategy(
|
||||
bool host_bridges_override_present,
|
||||
|
||||
@@ -61,8 +61,7 @@ class RobloxOutputDeviceBridge final {
|
||||
void SelectDevice(int index, bool input = false);
|
||||
void ConstructGuestString(void* destination, std::string_view value) const;
|
||||
|
||||
std::unique_ptr<NativeInputCapture> capture_;
|
||||
bool capture_profile_supported_ = false;
|
||||
std::shared_ptr<NativeInputCapture> capture_;
|
||||
std::array<std::uintptr_t, 6> original_capture_methods_{};
|
||||
mutable std::mutex mutex_;
|
||||
compat::FmodOutputDeviceBridgeProfile profile_;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#define MOCKTAIL_GRAPHICS_PRESENT_MODE_POLICY_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
@@ -29,6 +30,36 @@ FilterPresentModes(PresentModePolicy policy,
|
||||
const char *PresentModePolicyName(PresentModePolicy policy);
|
||||
const char *PresentModeKhrName(VkPresentModeKHR mode);
|
||||
|
||||
// Creates the swapchain with the mode in `info`. A driver can reject a mode
|
||||
// that the device did not enable, so a failed create is retried once with the
|
||||
// mode the game asked for. `create` takes the info and returns a VkResult.
|
||||
template <typename Create>
|
||||
VkResult CreateSwapchainWithPresentModeFallback(VkSwapchainCreateInfoKHR &info,
|
||||
VkPresentModeKHR requested,
|
||||
Create create) {
|
||||
const VkResult result = create(info);
|
||||
if (result == VK_SUCCESS || info.presentMode == requested) {
|
||||
return result;
|
||||
}
|
||||
switch (result) {
|
||||
case VK_ERROR_OUT_OF_HOST_MEMORY:
|
||||
case VK_ERROR_OUT_OF_DEVICE_MEMORY:
|
||||
case VK_ERROR_DEVICE_LOST:
|
||||
case VK_ERROR_SURFACE_LOST_KHR:
|
||||
case VK_ERROR_NATIVE_WINDOW_IN_USE_KHR:
|
||||
return result; // Another present mode cannot fix these.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
" [vulkan] swapchain presentMode %s failed (%d); retrying "
|
||||
"with %s\n",
|
||||
PresentModeKhrName(info.presentMode), static_cast<int>(result),
|
||||
PresentModeKhrName(requested));
|
||||
info.presentMode = requested;
|
||||
return create(info);
|
||||
}
|
||||
|
||||
// Extra images so acquire does not wait on the compositor-held buffer.
|
||||
std::uint32_t PreferSwapchainMinImageCount(PresentModePolicy policy,
|
||||
std::uint32_t requested,
|
||||
|
||||
@@ -18,6 +18,26 @@ std::string SelectVulkanIcdManifest(
|
||||
const std::vector<std::filesystem::path>& directories,
|
||||
std::string_view vendor);
|
||||
|
||||
// One GPU as the kernel reports it.
|
||||
struct HostGpu {
|
||||
unsigned int vendor = 0; // PCI vendor ID.
|
||||
std::string kernel_driver; // Bound kernel driver; empty when unknown.
|
||||
};
|
||||
|
||||
struct VulkanIcdSelection {
|
||||
std::string manifest; // Empty when no GPU has a usable driver.
|
||||
std::vector<std::string> skipped; // Why each passed-over GPU was skipped.
|
||||
};
|
||||
|
||||
// ICD manifest for the first GPU whose bound kernel driver has an installed
|
||||
// Vulkan driver. NVIDIA and AMD cards are tried before Intel when
|
||||
// `prefer_discrete` is set, and after it otherwise. A GPU with an unknown
|
||||
// kernel driver is matched by vendor alone.
|
||||
VulkanIcdSelection SelectHostVulkanIcd(
|
||||
const std::vector<HostGpu>& gpus,
|
||||
const std::vector<std::filesystem::path>& directories,
|
||||
bool prefer_discrete);
|
||||
|
||||
// Publishes the resolved graphics backend before the managed payload updater
|
||||
// starts. OpenGL is a strict system EGL/GLES path; it never silently retries
|
||||
// through ANGLE/Vulkan or accepts a window without a real graphics context.
|
||||
|
||||
@@ -32,6 +32,9 @@ inline constexpr char kRobloxOpenCaptchaViewNotification[] =
|
||||
"OPEN_CAPTCHA_VIEW";
|
||||
inline constexpr char kRobloxViewProfileNotification[] = "VIEW_PROFILE";
|
||||
inline constexpr char kRobloxPurchaseRobuxNotification[] = "PURCHASE_ROBUX";
|
||||
// Sent with a decimal user id when the app asks for a chat with that user.
|
||||
inline constexpr char kRobloxLaunchConversationNotification[] =
|
||||
"LAUNCH_CONVERSATION";
|
||||
|
||||
// Owned subset of WebViewProtocol's openWindow payload. The URL is copied
|
||||
// before the MessageBus RawCallback returns, so no JNI local reference escapes.
|
||||
@@ -115,6 +118,14 @@ using PublishWebViewRawFn = void (*)(JNIEnv *, jobject, jstring, jstring);
|
||||
using BroadcastWebViewDataModelFocusFn = void (*)(JNIEnv *, jclass, jstring,
|
||||
jstring, jstring);
|
||||
using SignalWebViewJavascriptCallbackFn = void (*)(JNIEnv*, jclass, jstring);
|
||||
// NativeGLInterface.nativeAppBridgeV2SendAppEventOnGameLoaded(namespace,
|
||||
// detail, detailType).
|
||||
using SendRobloxAppEventOnGameLoadedFn = void (*)(JNIEnv*, jclass, jstring,
|
||||
jstring, jstring);
|
||||
// NativeGLInterface.nativeAppBridgeV2SendAppEventOnAppReady(namespace, detail,
|
||||
// detailType, appName). The engine holds the event until appName is ready.
|
||||
using SendRobloxAppEventOnAppReadyFn = void (*)(JNIEnv*, jclass, jstring,
|
||||
jstring, jstring, jstring);
|
||||
using UpdateRobloxCookieSetHandlerFn =
|
||||
void (*)(JNIEnv*, jobject, jobject);
|
||||
// Roblox's counted handle to its Subsystem<IWebViewProtocol>. The protocol,
|
||||
@@ -167,6 +178,9 @@ struct RobloxWebViewMessageBusSymbols {
|
||||
UpdateRobloxCookieSetHandlerFn update_cookie_set_handler = nullptr;
|
||||
// Optional: found by code contract, not by symbol name.
|
||||
AcquireWebViewProtocolFn acquire_web_view_protocol = nullptr;
|
||||
// Optional: without them LAUNCH_CONVERSATION is reported as unavailable.
|
||||
SendRobloxAppEventOnGameLoadedFn send_app_event_on_game_loaded = nullptr;
|
||||
SendRobloxAppEventOnAppReadyFn send_app_event_on_app_ready = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return get_open_window_id != nullptr &&
|
||||
@@ -270,11 +284,14 @@ private:
|
||||
kDataModelUnfocused,
|
||||
kDataModelFocused,
|
||||
kMessageBusWindowClose,
|
||||
kStartConversationWithUser,
|
||||
};
|
||||
|
||||
struct PendingHostWindowEvent {
|
||||
HostWindowEventType type = HostWindowEventType::kDataModelUnfocused;
|
||||
uint64_t generation = 0;
|
||||
// Decimal user id of a kStartConversationWithUser event.
|
||||
std::string user_id;
|
||||
};
|
||||
|
||||
Status CheckJniException(JNIEnv *env, const char *operation) const;
|
||||
@@ -283,6 +300,8 @@ private:
|
||||
Status DispatchOpenRequest(RobloxWebViewOpenRequest request,
|
||||
OpenSource source);
|
||||
void QueueHostWindowEvent(HostWindowEventType type, uint64_t generation);
|
||||
void QueueStartConversation(std::string user_id);
|
||||
Status SendStartConversationEvents(JNIEnv* env, const std::string& user_id);
|
||||
Status BeginDispatch();
|
||||
void EndDispatch();
|
||||
static void RawMessageCallback(void *context, JNIEnv *env, jstring message);
|
||||
|
||||
@@ -11,6 +11,10 @@ struct HostAbiDerivationOptions {
|
||||
std::filesystem::path reference_library;
|
||||
std::filesystem::path reference_profile;
|
||||
std::vector<std::filesystem::path> reference_compatibility_manifests;
|
||||
// Optional libroblox.so of another build that the compatibility manifests
|
||||
// describe. It supplies the FMOD output-device anchors when the manifests
|
||||
// have none for the reference build.
|
||||
std::filesystem::path runtime_fallback_library;
|
||||
std::filesystem::path candidate_payload_directory;
|
||||
std::filesystem::path output_directory;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,11 @@ pre-commit:
|
||||
desktop-entry:
|
||||
glob: "*.desktop"
|
||||
run: desktop-file-validate {staged_files}
|
||||
changelog-width:
|
||||
glob: "CHANGELOG.md"
|
||||
run: >-
|
||||
git diff --cached -U0 -- CHANGELOG.md |
|
||||
awk '/^\+[^+]/ && length($0) > 111 { print "CHANGELOG.md: added line over 110 characters: " substr($0, 2); bad = 1 } END { exit bad }'
|
||||
|
||||
pre-push:
|
||||
commands:
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"--share=ipc",
|
||||
"--share=network",
|
||||
"--socket=wayland",
|
||||
"--socket=x11",
|
||||
"--socket=fallback-x11",
|
||||
"--socket=pulseaudio",
|
||||
"--device=dri",
|
||||
"--talk-name=com.feralinteractive.GameMode",
|
||||
|
||||
@@ -57,6 +57,19 @@
|
||||
<binary>mocktail</binary>
|
||||
</provides>
|
||||
<releases>
|
||||
<release version="0.3.4" date="2026-10-11">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Fixed Roblox 2.742 being rejected as an update.</li>
|
||||
<li>The new Roblox home screen and its side menu now show.</li>
|
||||
<li>Fixed the voice chat microphone after Roblox updates.</li>
|
||||
<li>Anti-aliasing is no longer forced off.</li>
|
||||
<li>Nightcap now uses Wayland on NVIDIA Wayland sessions.</li>
|
||||
<li>Fixed a missing picture on machines whose graphics card runs a different driver.</li>
|
||||
<li>Fewer frame hitches while textures load.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.3.3" date="2026-10-04">
|
||||
<description>
|
||||
<ul>
|
||||
@@ -67,7 +80,6 @@
|
||||
<li>Going to another page in the web view no longer closes it.</li>
|
||||
<li>Clicking while typing no longer closes the text box.</li>
|
||||
<li>Fixed the sky showing the wrong textures.</li>
|
||||
<li>The Flatpak build on NVIDIA now uses X11 to avoid Wayland hangs.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
|
||||
@@ -23,6 +23,7 @@ supported.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
@@ -161,6 +162,10 @@ class SignatureSpec:
|
||||
allow_registry_object_size_change: bool = False
|
||||
minimum_anchor_bytes: int = 6
|
||||
registry_size_indices: tuple[int, int] = (8, 12)
|
||||
# Instructions whose register operands the compiler may have chosen
|
||||
# differently. Each reference register must stand for one candidate
|
||||
# register across all of them, and the reverse.
|
||||
renamed_register_indices: tuple[int, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -1075,6 +1080,51 @@ def semantic_shape(
|
||||
)
|
||||
|
||||
|
||||
def register_family(name: str) -> str:
|
||||
"""Return the 64-bit register that name is all or part of."""
|
||||
if len(name) >= 2 and name[0] == "r" and name[1].isdigit():
|
||||
return name.rstrip("bwd")
|
||||
for family in ("ax", "bx", "cx", "dx", "si", "di", "bp", "sp"):
|
||||
if name in (family, "e" + family, "r" + family):
|
||||
return "r" + family
|
||||
return name
|
||||
|
||||
|
||||
def renamed_shapes_match(
|
||||
reference_shape: tuple[Any, ...],
|
||||
candidate_shape: tuple[Any, ...],
|
||||
forward: dict[str, str],
|
||||
backward: dict[str, str],
|
||||
) -> bool:
|
||||
"""Compare two semantic shapes, letting register operands be renamed.
|
||||
|
||||
forward and backward hold the renames accepted so far and gain new ones.
|
||||
"""
|
||||
reference_mnemonic, reference_operands = reference_shape
|
||||
candidate_mnemonic, candidate_operands = candidate_shape
|
||||
if reference_mnemonic != candidate_mnemonic or len(reference_operands) != len(
|
||||
candidate_operands
|
||||
):
|
||||
return False
|
||||
for left, right in zip(reference_operands, candidate_operands):
|
||||
if left[0] != "reg" or right[0] != "reg":
|
||||
if left != right:
|
||||
return False
|
||||
continue
|
||||
if left[2] != right[2]:
|
||||
return False
|
||||
reference_family = register_family(left[1])
|
||||
candidate_family = register_family(right[1])
|
||||
if (
|
||||
forward.setdefault(reference_family, candidate_family)
|
||||
!= candidate_family
|
||||
or backward.setdefault(candidate_family, reference_family)
|
||||
!= reference_family
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_semantic_match(
|
||||
reference: Sequence[Any],
|
||||
candidate: Sequence[Any],
|
||||
@@ -1082,6 +1132,8 @@ def validate_semantic_match(
|
||||
) -> None:
|
||||
if len(reference) != len(candidate):
|
||||
raise AnalyzerError("normalized signature instruction count changed")
|
||||
forward: dict[str, str] = {}
|
||||
backward: dict[str, str] = {}
|
||||
for index, (reference_instruction, candidate_instruction) in enumerate(
|
||||
zip(reference, candidate)
|
||||
):
|
||||
@@ -1089,8 +1141,14 @@ def validate_semantic_match(
|
||||
spec.allow_registry_object_size_change
|
||||
and index in spec.registry_size_indices
|
||||
)
|
||||
if semantic_shape(reference_instruction, wildcard_size) != semantic_shape(
|
||||
candidate_instruction, wildcard_size
|
||||
reference_shape = semantic_shape(reference_instruction, wildcard_size)
|
||||
candidate_shape = semantic_shape(candidate_instruction, wildcard_size)
|
||||
if (
|
||||
not renamed_shapes_match(
|
||||
reference_shape, candidate_shape, forward, backward
|
||||
)
|
||||
if index in spec.renamed_register_indices
|
||||
else reference_shape != candidate_shape
|
||||
):
|
||||
raise AnalyzerError(
|
||||
f"normalized instruction semantics changed at signature index {index}"
|
||||
@@ -1541,6 +1599,7 @@ def derive_runtime_compatibility(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
manifests: Sequence[Path],
|
||||
fallback: ElfImage | None = None,
|
||||
) -> dict[str, Any]:
|
||||
source = load_reference_runtime_profile(manifests, reference.build_id)
|
||||
result: dict[str, Any] = {}
|
||||
@@ -1563,9 +1622,38 @@ def derive_runtime_compatibility(
|
||||
result["fmod_output_device_bridge"] = derive_fmod_output_device_bridge(
|
||||
reference, candidate, fmod
|
||||
)
|
||||
elif fallback is not None:
|
||||
bridge = derive_fallback_fmod_output_device_bridge(
|
||||
fallback, candidate, manifests
|
||||
)
|
||||
if bridge is not None:
|
||||
result["fmod_output_device_bridge"] = bridge
|
||||
return result
|
||||
|
||||
|
||||
def derive_fallback_fmod_output_device_bridge(
|
||||
fallback: ElfImage,
|
||||
candidate: ElfImage,
|
||||
manifests: Sequence[Path],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Derive the optional FMOD bridge from another build in the manifests.
|
||||
|
||||
Any failure leaves the bridge out.
|
||||
"""
|
||||
if fallback.build_id == candidate.build_id:
|
||||
return None
|
||||
try:
|
||||
fallback.validate_toolchain()
|
||||
fmod = load_reference_runtime_profile(manifests, fallback.build_id).get(
|
||||
"fmod_output_device_bridge"
|
||||
)
|
||||
if fmod is None:
|
||||
return None
|
||||
return derive_fmod_output_device_bridge(fallback, candidate, fmod)
|
||||
except AnalyzerError:
|
||||
return None
|
||||
|
||||
|
||||
def find_registry_function_entry(
|
||||
candidate: ElfImage,
|
||||
body_rva: int,
|
||||
@@ -1613,6 +1701,73 @@ def find_registry_function_entry(
|
||||
return entries[0]
|
||||
|
||||
|
||||
def find_renamed_registry_body_match(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
reference_body: Sequence[Any],
|
||||
minimum_anchor_bytes: int,
|
||||
) -> SignatureMatch:
|
||||
"""Find the registry initializer body under other temporary registers.
|
||||
|
||||
The body allocates the registry, clears it, and starts filling it:
|
||||
|
||||
0 mov edi, SIZE 6 xor esi, esi
|
||||
1 call allocate 7 call memset
|
||||
2 mov rbx, rax 8 mov ONE, 0x3f800000
|
||||
3 xor ZERO, ZERO 9 mov dword ptr [rbx + 0x20], ONE
|
||||
4 mov edx, SIZE 10 lea FIELD, [rbx + 0x28]
|
||||
5 mov rdi, rax
|
||||
|
||||
ZERO, ONE and FIELD are the compiler's choice. Instructions 4 to 7 use
|
||||
registers the calling convention fixes, so they are searched for by bytes
|
||||
and every hit is then checked against all eleven instructions.
|
||||
"""
|
||||
clear_index = 4
|
||||
clear_matches = find_signature_matches(
|
||||
reference,
|
||||
candidate,
|
||||
SignatureSpec(
|
||||
"registry-initializer-clear",
|
||||
reference_body[clear_index].address,
|
||||
4,
|
||||
True,
|
||||
minimum_anchor_bytes,
|
||||
(0, 0),
|
||||
),
|
||||
allow_multiple_candidates=True,
|
||||
)
|
||||
body_spec = SignatureSpec(
|
||||
"registry-initializer-body",
|
||||
reference_body[0].address,
|
||||
len(reference_body),
|
||||
True,
|
||||
minimum_anchor_bytes,
|
||||
(0, 4),
|
||||
(3, 8, 9, 10),
|
||||
)
|
||||
# Instructions 0 to 3 keep their lengths as long as ZERO stays one of r8
|
||||
# to r15, so the body starts this far before the clear sequence.
|
||||
lead = reference_body[clear_index].address - reference_body[0].address
|
||||
found = []
|
||||
for clear in clear_matches:
|
||||
if clear.rva < lead:
|
||||
continue
|
||||
try:
|
||||
body = disassemble(candidate, clear.rva - lead, len(reference_body))
|
||||
if body[clear_index].address != clear.rva:
|
||||
continue
|
||||
validate_semantic_match(reference_body, body, body_spec)
|
||||
except AnalyzerError:
|
||||
continue
|
||||
found.append(SignatureMatch(clear.rva - lead, tuple(reference_body), body))
|
||||
if len(found) != 1:
|
||||
raise AnalyzerError(
|
||||
f"signature registry-initializer-body matched {len(found)} "
|
||||
"candidate locations with renamed registers"
|
||||
)
|
||||
return found[0]
|
||||
|
||||
|
||||
def find_registry_signature_match(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
@@ -1633,7 +1788,18 @@ def find_registry_signature_match(
|
||||
spec.minimum_anchor_bytes,
|
||||
(0, 4),
|
||||
)
|
||||
body_match = find_signature_match(reference, candidate, body_spec)
|
||||
try:
|
||||
body_match = find_signature_match(reference, candidate, body_spec)
|
||||
except AnalyzerError:
|
||||
body_match = find_renamed_registry_body_match(
|
||||
reference,
|
||||
candidate,
|
||||
reference_full[
|
||||
body_instruction_index : body_instruction_index
|
||||
+ body_instruction_count
|
||||
],
|
||||
spec.minimum_anchor_bytes,
|
||||
)
|
||||
candidate_entry = find_registry_function_entry(
|
||||
candidate,
|
||||
body_match.rva,
|
||||
@@ -1671,14 +1837,14 @@ def find_constructor_boundary_match(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
spec: SignatureSpec,
|
||||
reference_entry: int,
|
||||
candidate_entry: int,
|
||||
candidate_entries: Sequence[int],
|
||||
) -> SignatureMatch:
|
||||
if reference_entry != spec.reference_rva:
|
||||
raise AnalyzerError(
|
||||
"reference constructor boundary moved outside init-array index 4"
|
||||
)
|
||||
reference_wrapper = disassemble(reference, reference_entry, 1)
|
||||
"""Match a constructor that is one jump to its body.
|
||||
|
||||
The body is found by signature, and the candidate constructor is the one
|
||||
of candidate_entries that jumps to it.
|
||||
"""
|
||||
reference_wrapper = disassemble(reference, spec.reference_rva, 1)
|
||||
reference_target = direct_jump_target(
|
||||
reference_wrapper[0], "reference constructor boundary"
|
||||
)
|
||||
@@ -1686,20 +1852,29 @@ def find_constructor_boundary_match(
|
||||
reference,
|
||||
candidate,
|
||||
SignatureSpec(
|
||||
"constructor-4-target",
|
||||
f"{spec.name}-target",
|
||||
reference_target,
|
||||
spec.instruction_count,
|
||||
),
|
||||
)
|
||||
candidate_wrapper = disassemble(candidate, candidate_entry, 1)
|
||||
candidate_target = direct_jump_target(
|
||||
candidate_wrapper[0], "candidate constructor boundary"
|
||||
)
|
||||
if candidate_target != target_match.rva:
|
||||
found = []
|
||||
for candidate_entry in candidate_entries:
|
||||
try:
|
||||
candidate_wrapper = disassemble(candidate, candidate_entry, 1)
|
||||
candidate_target = direct_jump_target(
|
||||
candidate_wrapper[0], "candidate constructor boundary"
|
||||
)
|
||||
except AnalyzerError:
|
||||
continue
|
||||
if candidate_target == target_match.rva:
|
||||
found.append(
|
||||
SignatureMatch(candidate_entry, reference_wrapper, candidate_wrapper)
|
||||
)
|
||||
if len(found) != 1:
|
||||
raise AnalyzerError(
|
||||
"constructor index 4 no longer targets its verified boundary"
|
||||
f"{spec.name} no longer targets its verified boundary"
|
||||
)
|
||||
return SignatureMatch(candidate_entry, reference_wrapper, candidate_wrapper)
|
||||
return found[0]
|
||||
|
||||
|
||||
def rip_targets(instruction: Any) -> tuple[int, ...]:
|
||||
@@ -1713,6 +1888,161 @@ def rip_targets(instruction: Any) -> tuple[int, ...]:
|
||||
return tuple(targets)
|
||||
|
||||
|
||||
def is_register_operand(operand: Any, register: int) -> bool:
|
||||
return operand.type == capstone_x86.X86_OP_REG and operand.reg == register
|
||||
|
||||
|
||||
def jni_singleton_publication(image: ElfImage, entry: int) -> tuple[Any, ...]:
|
||||
"""Return the instructions that publish the JNI singleton.
|
||||
|
||||
They open its initializer at entry, after a stack frame if it has one:
|
||||
|
||||
lea rax, [rip + OBJECT]
|
||||
xchg qword ptr [rip + SLOT], rax
|
||||
mov qword ptr [rip + OBJECT], rax
|
||||
|
||||
OBJECT is the eight bytes after SLOT.
|
||||
"""
|
||||
for frame in (0, 2):
|
||||
try:
|
||||
decoded = disassemble(image, entry, frame + 3)
|
||||
except AnalyzerError:
|
||||
continue
|
||||
if frame:
|
||||
push, link = decoded[:2]
|
||||
if (
|
||||
push.mnemonic != "push"
|
||||
or len(push.operands) != 1
|
||||
or not is_register_operand(push.operands[0], capstone_x86.X86_REG_RBP)
|
||||
or link.mnemonic != "mov"
|
||||
or len(link.operands) != 2
|
||||
or not is_register_operand(link.operands[0], capstone_x86.X86_REG_RBP)
|
||||
or not is_register_operand(link.operands[1], capstone_x86.X86_REG_RSP)
|
||||
):
|
||||
continue
|
||||
load, exchange, store = decoded[frame:]
|
||||
object_targets = rip_targets(load)
|
||||
slot_targets = rip_targets(exchange)
|
||||
stored_targets = rip_targets(store)
|
||||
if (
|
||||
load.mnemonic == "lea"
|
||||
and len(load.operands) == 2
|
||||
and is_register_operand(load.operands[0], capstone_x86.X86_REG_RAX)
|
||||
and exchange.mnemonic == "xchg"
|
||||
and len(exchange.operands) == 2
|
||||
and exchange.operands[0].size == 8
|
||||
and is_register_operand(exchange.operands[1], capstone_x86.X86_REG_RAX)
|
||||
and store.mnemonic == "mov"
|
||||
and len(store.operands) == 2
|
||||
and store.operands[0].size == 8
|
||||
and is_register_operand(store.operands[1], capstone_x86.X86_REG_RAX)
|
||||
and len(object_targets) == 1
|
||||
and len(slot_targets) == 1
|
||||
and len(stored_targets) == 1
|
||||
and object_targets[0] == stored_targets[0]
|
||||
and object_targets[0] == slot_targets[0] + 8
|
||||
):
|
||||
return decoded[frame:]
|
||||
raise AnalyzerError(
|
||||
f"JNI singleton initializer at {format_rva(entry)} "
|
||||
"does not publish its singleton"
|
||||
)
|
||||
|
||||
|
||||
def find_jni_singleton_match(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
spec: SignatureSpec,
|
||||
candidate_entries: Sequence[int],
|
||||
) -> SignatureMatch:
|
||||
"""Find the JNI singleton initializer among candidate_entries.
|
||||
|
||||
The instructions after the publication follow the singleton's layout, so
|
||||
when a release changes that layout only the publication is matched. The
|
||||
initializer itself runs from the init-array either way.
|
||||
"""
|
||||
try:
|
||||
return find_signature_match(reference, candidate, spec)
|
||||
except AnalyzerError:
|
||||
pass
|
||||
reference_publication = jni_singleton_publication(reference, spec.reference_rva)
|
||||
found = find_signature_match(
|
||||
reference,
|
||||
candidate,
|
||||
SignatureSpec(
|
||||
"jni-singleton-publication",
|
||||
reference_publication[0].address,
|
||||
len(reference_publication),
|
||||
minimum_anchor_bytes=3,
|
||||
),
|
||||
)
|
||||
for entry in candidate_entries:
|
||||
try:
|
||||
publication = jni_singleton_publication(candidate, entry)
|
||||
except AnalyzerError:
|
||||
continue
|
||||
if publication[0].address == found.rva:
|
||||
return SignatureMatch(entry, reference_publication, publication)
|
||||
raise AnalyzerError("JNI singleton initializer is not a leading constructor")
|
||||
|
||||
|
||||
# The native replay starts at this init-array index on x86_64.
|
||||
LEADING_CONSTRUCTOR_BEGIN = 2
|
||||
# The process-load constructor, the JNI singleton initializer and two wiring
|
||||
# entries. A release may put them in any order.
|
||||
LEADING_CONSTRUCTOR_COUNT = 4
|
||||
|
||||
|
||||
def match_leading_constructors(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
constructor_anchors: dict[str, Any],
|
||||
jni_spec: SignatureSpec,
|
||||
reference_init: Sequence[int],
|
||||
candidate_init: Sequence[int],
|
||||
) -> tuple[tuple[int, ...], SignatureMatch]:
|
||||
"""Find each reference leading constructor in the candidate by its code.
|
||||
|
||||
Returns, for each reference leading constructor, the candidate init-array
|
||||
index of the same constructor, both counted from
|
||||
LEADING_CONSTRUCTOR_BEGIN, and the JNI singleton initializer match.
|
||||
"""
|
||||
moved = "constructor moved outside verified init-array boundary"
|
||||
end = LEADING_CONSTRUCTOR_BEGIN + LEADING_CONSTRUCTOR_COUNT
|
||||
if len(reference_init) < end or len(candidate_init) < end:
|
||||
raise AnalyzerError("init-array shape is not derivable")
|
||||
candidate_entries = tuple(candidate_init[LEADING_CONSTRUCTOR_BEGIN:end])
|
||||
candidate_offsets = []
|
||||
jni_match = None
|
||||
for index in range(LEADING_CONSTRUCTOR_BEGIN, end):
|
||||
reference_entry = reference_init[index]
|
||||
name = f"constructor-{index}"
|
||||
if parse_rva(constructor_anchors.get(str(index)), name) != reference_entry:
|
||||
raise AnalyzerError(moved)
|
||||
if reference_entry == jni_spec.reference_rva:
|
||||
match = find_jni_singleton_match(
|
||||
reference, candidate, jni_spec, candidate_entries
|
||||
)
|
||||
jni_match = match
|
||||
else:
|
||||
spec = SignatureSpec(name, reference_entry, 24)
|
||||
if disassemble(reference, reference_entry, 1)[0].mnemonic == "jmp":
|
||||
match = find_constructor_boundary_match(
|
||||
reference, candidate, spec, candidate_entries
|
||||
)
|
||||
else:
|
||||
match = find_signature_match(reference, candidate, spec)
|
||||
if match.rva not in candidate_entries:
|
||||
raise AnalyzerError(moved)
|
||||
candidate_offset = candidate_entries.index(match.rva)
|
||||
if candidate_offset in candidate_offsets:
|
||||
raise AnalyzerError(moved)
|
||||
candidate_offsets.append(candidate_offset)
|
||||
if jni_match is None:
|
||||
raise AnalyzerError("JNI singleton initializer is not a leading constructor")
|
||||
return tuple(candidate_offsets), jni_match
|
||||
|
||||
|
||||
def mapped_rip_target(
|
||||
match: SignatureMatch, reference_target: int, description: str
|
||||
) -> int:
|
||||
@@ -1805,7 +2135,6 @@ def create_signature_specs(
|
||||
bridges = bridge_map(profile)
|
||||
data_seeds = profile["data_seeds"]
|
||||
bootstrap = profile["native_pre_jni_bootstrap"]
|
||||
constructor_rvas = anchors["constructor_rvas"]
|
||||
return [
|
||||
SignatureSpec(
|
||||
"small-allocate",
|
||||
@@ -1870,36 +2199,51 @@ def create_signature_specs(
|
||||
parse_rva(anchors["jni_singleton_initializer_rva"], "JNI singleton anchor"),
|
||||
11,
|
||||
),
|
||||
SignatureSpec(
|
||||
"constructor-2", parse_rva(constructor_rvas["2"], "constructor 2"), 24
|
||||
),
|
||||
SignatureSpec(
|
||||
"constructor-3", parse_rva(constructor_rvas["3"], "constructor 3"), 24
|
||||
),
|
||||
SignatureSpec(
|
||||
"constructor-4",
|
||||
parse_rva(constructor_rvas["4"], "constructor 4"),
|
||||
24,
|
||||
minimum_anchor_bytes=3,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def adjusted_ranges(
|
||||
ranges: list[dict[str, int]], reference_count: int, candidate_count: int
|
||||
def remapped_constructor_ranges(
|
||||
ranges: list[dict[str, int]],
|
||||
candidate_offsets: Sequence[int],
|
||||
reference_count: int,
|
||||
candidate_count: int,
|
||||
) -> list[dict[str, int]]:
|
||||
result = []
|
||||
"""Rewrite the reference constructor ranges for the candidate init-array.
|
||||
|
||||
Each leading constructor keeps its place in or out of the replay under
|
||||
its candidate index, and every later constructor runs.
|
||||
"""
|
||||
cannot_fit = "reference constructor range cannot fit candidate init-array"
|
||||
leading_end = LEADING_CONSTRUCTOR_BEGIN + LEADING_CONSTRUCTOR_COUNT
|
||||
ranges = validated_ranges(ranges, "reference constructor ranges", reference_count)
|
||||
if (
|
||||
ranges[0]["begin"] < LEADING_CONSTRUCTOR_BEGIN
|
||||
or ranges[-1]["begin"] > leading_end
|
||||
or ranges[-1]["end_exclusive"] != reference_count
|
||||
or reference_count <= leading_end
|
||||
or candidate_count <= leading_end
|
||||
):
|
||||
raise AnalyzerError(cannot_fit)
|
||||
runs = [False] * LEADING_CONSTRUCTOR_COUNT
|
||||
for item in ranges:
|
||||
end = (
|
||||
candidate_count
|
||||
if item["end_exclusive"] == reference_count
|
||||
else item["end_exclusive"]
|
||||
)
|
||||
if end > candidate_count:
|
||||
raise AnalyzerError(
|
||||
"reference constructor range cannot fit candidate init-array"
|
||||
)
|
||||
result.append({"begin": item["begin"], "end_exclusive": end})
|
||||
for index in range(item["begin"], min(item["end_exclusive"], leading_end)):
|
||||
runs[candidate_offsets[index - LEADING_CONSTRUCTOR_BEGIN]] = True
|
||||
result = []
|
||||
run_begin = None
|
||||
for offset, run in enumerate(runs):
|
||||
index = LEADING_CONSTRUCTOR_BEGIN + offset
|
||||
if run:
|
||||
if run_begin is None:
|
||||
run_begin = index
|
||||
elif run_begin is not None:
|
||||
result.append({"begin": run_begin, "end_exclusive": index})
|
||||
run_begin = None
|
||||
result.append(
|
||||
{
|
||||
"begin": leading_end if run_begin is None else run_begin,
|
||||
"end_exclusive": candidate_count,
|
||||
}
|
||||
)
|
||||
return validated_ranges(result, "derived constructor ranges", candidate_count)
|
||||
|
||||
|
||||
@@ -1948,13 +2292,14 @@ def derive_profile(
|
||||
matches[spec.name] = find_registry_signature_match(
|
||||
reference, candidate, spec
|
||||
)
|
||||
elif spec.name == "constructor-4":
|
||||
matches[spec.name] = find_constructor_boundary_match(
|
||||
elif spec.name == "jni-singleton-initializer":
|
||||
leading_offsets, matches[spec.name] = match_leading_constructors(
|
||||
reference,
|
||||
candidate,
|
||||
anchors["constructor_rvas"],
|
||||
spec,
|
||||
reference_init[4],
|
||||
candidate_init[4],
|
||||
reference_init,
|
||||
candidate_init,
|
||||
)
|
||||
else:
|
||||
matches[spec.name] = find_signature_match(reference, candidate, spec)
|
||||
@@ -1990,21 +2335,7 @@ def derive_profile(
|
||||
"registry allocator call"
|
||||
)
|
||||
matches["allocate"] = selected_allocate[0]
|
||||
for index in (2, 3, 4):
|
||||
match = matches[f"constructor-{index}"]
|
||||
if (
|
||||
reference_init[index] != match.reference_instructions[0].address
|
||||
or candidate_init[index] != match.rva
|
||||
):
|
||||
raise AnalyzerError(
|
||||
f"constructor {index} moved outside its verified init-array boundary"
|
||||
)
|
||||
singleton_match = matches["jni-singleton-initializer"]
|
||||
if (
|
||||
reference_init[5] != singleton_match.reference_instructions[0].address
|
||||
or candidate_init[5] != singleton_match.rva
|
||||
):
|
||||
raise AnalyzerError("JNI singleton initializer is not constructor index 5")
|
||||
|
||||
candidate_bridge_entries = []
|
||||
for entry in reference_profile["bridge_entries"]:
|
||||
@@ -2075,28 +2406,34 @@ def derive_profile(
|
||||
init_section = candidate.sections[".init_array"]
|
||||
reference_count = reference_profile["init_array_count"]
|
||||
candidate_count = len(candidate_init)
|
||||
constructor_ranges = adjusted_ranges(
|
||||
validated_ranges(
|
||||
reference_profile["constructor_run_ranges"],
|
||||
"reference constructor ranges",
|
||||
reference_count,
|
||||
),
|
||||
constructor_ranges = remapped_constructor_ranges(
|
||||
reference_profile["constructor_run_ranges"],
|
||||
leading_offsets,
|
||||
reference_count,
|
||||
candidate_count,
|
||||
)
|
||||
native_ranges = adjusted_ranges(
|
||||
validated_ranges(
|
||||
reference_profile["native_mimalloc_constructor_run_ranges"],
|
||||
"reference native constructor ranges",
|
||||
reference_count,
|
||||
),
|
||||
native_ranges = remapped_constructor_ranges(
|
||||
reference_profile["native_mimalloc_constructor_run_ranges"],
|
||||
leading_offsets,
|
||||
reference_count,
|
||||
candidate_count,
|
||||
)
|
||||
thread_boundary = reference_profile[
|
||||
reference_thread_boundary = reference_profile[
|
||||
"native_mimalloc_thread_initializer_after_constructor"
|
||||
]
|
||||
if thread_boundary != 2 or not any(
|
||||
if not (
|
||||
LEADING_CONSTRUCTOR_BEGIN
|
||||
<= reference_thread_boundary
|
||||
< LEADING_CONSTRUCTOR_BEGIN + LEADING_CONSTRUCTOR_COUNT
|
||||
):
|
||||
raise AnalyzerError("native allocator TLS boundary is no longer verified")
|
||||
# The thread initializer follows the process-load constructor to its
|
||||
# candidate index.
|
||||
thread_boundary = (
|
||||
LEADING_CONSTRUCTOR_BEGIN
|
||||
+ leading_offsets[reference_thread_boundary - LEADING_CONSTRUCTOR_BEGIN]
|
||||
)
|
||||
if not any(
|
||||
item["begin"] <= thread_boundary < item["end_exclusive"]
|
||||
for item in native_ranges
|
||||
):
|
||||
@@ -2243,13 +2580,22 @@ def analyze(arguments: argparse.Namespace) -> tuple[dict[str, Any], dict[str, An
|
||||
metadata_path = Path(arguments.payload_metadata)
|
||||
sidecar_path = Path(arguments.reference_profile)
|
||||
reference_sidecar = validated_sidecar(sidecar_path)
|
||||
with ElfImage(reference_path) as reference, ElfImage(candidate_path) as candidate:
|
||||
with contextlib.ExitStack() as stack:
|
||||
reference = stack.enter_context(ElfImage(reference_path))
|
||||
candidate = stack.enter_context(ElfImage(candidate_path))
|
||||
fallback = None
|
||||
if arguments.runtime_fallback_lib is not None:
|
||||
with contextlib.suppress(AnalyzerError):
|
||||
fallback = stack.enter_context(
|
||||
ElfImage(Path(arguments.runtime_fallback_lib))
|
||||
)
|
||||
metadata = validated_payload_metadata(metadata_path, candidate_path, candidate)
|
||||
profile, anchors = derive_profile(reference, candidate, reference_sidecar)
|
||||
runtime_compatibility = derive_runtime_compatibility(
|
||||
reference,
|
||||
candidate,
|
||||
tuple(Path(path) for path in arguments.reference_compatibility),
|
||||
fallback,
|
||||
)
|
||||
return output_documents(
|
||||
candidate,
|
||||
@@ -2274,6 +2620,12 @@ def parse_arguments(argv: Sequence[str]) -> argparse.Namespace:
|
||||
metavar="MANIFEST",
|
||||
help="compatibility manifest containing exact reference runtime anchors",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--runtime-fallback-lib",
|
||||
metavar="LIB",
|
||||
help="libroblox.so of another build in the compatibility manifests; "
|
||||
"supplies the FMOD output-device anchors when the reference has none",
|
||||
)
|
||||
parser.add_argument("--candidate-lib", required=True, metavar="LIB")
|
||||
parser.add_argument("--payload-metadata", required=True, metavar="META")
|
||||
parser.add_argument("--output", required=True, metavar="PROFILE")
|
||||
|
||||
@@ -107,6 +107,28 @@ AnyLinuxDownloadDependencies() {
|
||||
AnyLinuxDependencyDie 'dependency downloads failed after 3 snapshots'
|
||||
}
|
||||
|
||||
# Prints the package name and path of each archive that is older than the
|
||||
# installed package.
|
||||
AnyLinuxDependencyDowngrades() {
|
||||
local package name version installed
|
||||
for package in "$@"; do
|
||||
read -r name version < <(pacman -Qp -- "${package}")
|
||||
installed="$(pacman -Q -- "${name}" 2>/dev/null | cut -d ' ' -f 2)" || continue
|
||||
[[ -n "${installed}" ]] || continue
|
||||
if (( $(vercmp "${version}" "${installed}") < 0 )); then
|
||||
printf '%s\t%s\n' "${name}" "${package}"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Prints each pkg-config module whose requirements are not met.
|
||||
AnyLinuxDependencyBrokenModules() {
|
||||
local module
|
||||
while read -r module; do
|
||||
pkg-config --exists "${module}" 2>/dev/null || printf '%s\n' "${module}"
|
||||
done < <(pkg-config --list-package-names 2>/dev/null)
|
||||
}
|
||||
|
||||
AnyLinuxDependenciesMain() (
|
||||
AnyLinuxDependencyRequireContainer
|
||||
(( $# == 0 )) || AnyLinuxDependencyDie 'no arguments are supported'
|
||||
@@ -126,7 +148,35 @@ AnyLinuxDependenciesMain() (
|
||||
while IFS=$'\t' read -r digest filename url; do
|
||||
packages+=("${DOWNLOADS}/${filename}")
|
||||
done <"${DOWNLOADS}/dependencies.tsv"
|
||||
pacman -U --noconfirm --needed "${packages[@]}"
|
||||
local broken name package
|
||||
local -a current=() older=() older_names=()
|
||||
local -A is_older=()
|
||||
while IFS=$'\t' read -r name package; do
|
||||
older_names+=("${name}")
|
||||
is_older["${package}"]=1
|
||||
done < <(AnyLinuxDependencyDowngrades "${packages[@]}")
|
||||
for package in "${packages[@]}"; do
|
||||
if [[ -n "${is_older[${package}]:-}" ]]; then
|
||||
older+=("${package}")
|
||||
else
|
||||
current+=("${package}")
|
||||
fi
|
||||
done
|
||||
(( ${#current[@]} == 0 )) || pacman -U --noconfirm --needed "${current[@]}"
|
||||
(( ${#older[@]} > 0 )) || return 0
|
||||
# A reduced package can lag the distribution. If the older version leaves
|
||||
# another package's pkg-config requirements unmet, build with the
|
||||
# distribution's package for that library. Modules that the reduced
|
||||
# packages of the current version already leave unmet do not count.
|
||||
broken="$(AnyLinuxDependencyBrokenModules)"
|
||||
pacman -U --noconfirm --needed "${older[@]}"
|
||||
if [[ "$(AnyLinuxDependencyBrokenModules)" != "${broken}" ]]; then
|
||||
printf '[anylinux-dependencies] reduced packages are too old; restoring %s\n' \
|
||||
"${older_names[*]}" >&2
|
||||
pacman -S --noconfirm "${older_names[@]}"
|
||||
[[ "$(AnyLinuxDependencyBrokenModules)" == "${broken}" ]] ||
|
||||
AnyLinuxDependencyDie 'reduced packages break pkg-config requirements'
|
||||
fi
|
||||
)
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
|
||||
@@ -207,7 +207,13 @@ EOF
|
||||
"${ANYLINUX_TOOL}" --make-appimage
|
||||
local result="${ANYLINUX_WORK}/image/$(basename -- "${ANYLINUX_OUTPUT}")"
|
||||
[[ -x "${result}" ]] || AnyLinuxDie "quick-sharun did not create the requested AppImage"
|
||||
if [[ -n "${UPINFO:-}" ]]; then
|
||||
[[ -s "${result}.zsync" ]] || AnyLinuxDie "update information was set but no .zsync was generated"
|
||||
fi
|
||||
mv -f -- "${result}" "${ANYLINUX_OUTPUT}"
|
||||
if [[ -f "${result}.zsync" ]]; then
|
||||
mv -fT -- "${result}.zsync" "${ANYLINUX_OUTPUT}.zsync"
|
||||
fi
|
||||
printf '[anylinux] AppImage ready: %s\n' "${ANYLINUX_OUTPUT}" >&2
|
||||
}
|
||||
|
||||
|
||||
+93
-42
@@ -6,51 +6,102 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Nightcap for Linux</title>
|
||||
<meta name="description" content="Play Roblox on Linux like it was made for your PC. Nightcap is Mocktail, tuned to feel fast and look sharp.">
|
||||
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body><main>
|
||||
<img src="nightcap.svg" alt="Nightcap logo">
|
||||
<h1>Nightcap</h1>
|
||||
<p>Play Roblox on Linux like it was made for your PC. Nightcap is Mocktail, tuned to feel fast and look sharp.</p>
|
||||
<a class="button" href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">Download AppImage</a>
|
||||
<a class="button secondary" href="nightcap.flatpakref">Install with Flatpak</a>
|
||||
<div class="commands">
|
||||
<p>AppImage</p>
|
||||
<code>chmod +x Nightcap-x86_64.AppImage
|
||||
./Nightcap-x86_64.AppImage</code>
|
||||
<p>Flatpak</p>
|
||||
<code>flatpak install --user https://coderdayton.github.io/nightcap/nightcap.flatpakref
|
||||
flatpak run io.github.CoderDayton.nightcap</code>
|
||||
<p>DEB, RPM and pacman</p>
|
||||
<code>sudo apt install ./nightcap_<version>-1_amd64.deb
|
||||
<body>
|
||||
<header class="hero">
|
||||
<div class="wrap">
|
||||
<p class="top">
|
||||
<a href="https://github.com/CoderDayton/nightcap" aria-label="Nightcap on GitHub" title="GitHub"><svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg></a>
|
||||
<a href="https://discord.gg/DbAUG9hBFU" aria-label="Nightcap on Discord" title="Discord"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg></a>
|
||||
</p>
|
||||
<h1><img src="nightcap.svg" alt=""><span>Nightcap</span></h1>
|
||||
<p class="lede">Play Roblox on Linux like it was made for your PC. Nightcap is Mocktail, tuned to feel fast and look sharp.</p>
|
||||
<p class="actions">
|
||||
<a class="button" href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">Download AppImage</a>
|
||||
<a class="button secondary" href="nightcap.flatpakref">Install with Flatpak</a>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="wrap">
|
||||
<section class="row stacked">
|
||||
<h2>Install</h2>
|
||||
<dl class="body install">
|
||||
<dt>AppImage</dt>
|
||||
<dd><pre><code>chmod +x Nightcap-x86_64.AppImage
|
||||
./Nightcap-x86_64.AppImage</code></pre></dd>
|
||||
<dt>Flatpak</dt>
|
||||
<dd><pre><code>flatpak install --user https://coderdayton.github.io/nightcap/nightcap.flatpakref
|
||||
flatpak run io.github.CoderDayton.nightcap</code></pre></dd>
|
||||
<dt>DEB, RPM, pacman</dt>
|
||||
<dd><pre><code>sudo apt install ./nightcap_<version>-1_amd64.deb
|
||||
sudo dnf install ./nightcap-<version>-1.x86_64.rpm
|
||||
sudo pacman -U nightcap-<version>-1-x86_64.pkg.tar.zst</code>
|
||||
<p>Build from source</p>
|
||||
<code>git clone --recurse-submodules https://github.com/CoderDayton/nightcap.git
|
||||
sudo pacman -U nightcap-<version>-1-x86_64.pkg.tar.zst</code></pre></dd>
|
||||
<dt>Source</dt>
|
||||
<dd><pre><code>git clone --recurse-submodules https://github.com/CoderDayton/nightcap.git
|
||||
cd nightcap && make build
|
||||
./build/mocktail</code>
|
||||
</div>
|
||||
<h2>What's different</h2>
|
||||
<ul>
|
||||
<li>Lower input lag. Frames are shown as soon as they are ready.</li>
|
||||
<li>Sharper textures, with your own PNG overrides if you want them.</li>
|
||||
<li>ETC2 textures render correctly on desktop GPUs.</li>
|
||||
<li>No more error 319 kicks after joining a server.</li>
|
||||
<li>Roblox sees your real RAM and screen size.</li>
|
||||
</ul>
|
||||
<h2>Direct downloads</h2>
|
||||
<p>Prebuilt builds for Linux x86_64.</p>
|
||||
<p class="links">
|
||||
<a href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">AppImage</a>
|
||||
<a href="Nightcap-x86_64.flatpak">Flatpak bundle</a>
|
||||
<a href="https://github.com/CoderDayton/nightcap/releases/latest">DEB, RPM, pacman</a>
|
||||
<a href="nightcap-flatpak.gpg">Signing key</a>
|
||||
<a href="terms.html">Terms</a>
|
||||
<a href="privacy.html">Privacy</a>
|
||||
</p>
|
||||
<p><a class="source" href="https://github.com/CoderDayton/nightcap">Source code</a> ·
|
||||
<a class="source" href="https://github.com/komaruworld/mocktail">Upstream Mocktail</a></p>
|
||||
<p>Nightcap is an independent community project and is not affiliated with Roblox Corporation.</p>
|
||||
</main></body>
|
||||
./build/mocktail</code></pre></dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="row">
|
||||
<h2>What's different</h2>
|
||||
<dl class="body facts">
|
||||
<dt>Input lag</dt>
|
||||
<dd>Lower. Frames are shown as soon as they are ready.</dd>
|
||||
<dt>Textures</dt>
|
||||
<dd>Sharper, with your own PNG overrides if you want them.</dd>
|
||||
<dt>ETC2</dt>
|
||||
<dd>Textures render correctly on desktop GPUs.</dd>
|
||||
<dt>Error 319</dt>
|
||||
<dd>No more kicks after joining a server.</dd>
|
||||
<dt>RAM and screen</dt>
|
||||
<dd>Roblox sees your real RAM and screen size.</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="row">
|
||||
<h2>Direct downloads</h2>
|
||||
<div class="body">
|
||||
<p>Prebuilt builds for Linux x86_64.</p>
|
||||
<ul class="links">
|
||||
<li><a href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage"><span class="name">AppImage</span><span class="file">Nightcap-x86_64.AppImage</span><span class="go" aria-hidden="true">↓</span></a></li>
|
||||
<li><a href="Nightcap-x86_64.flatpak"><span class="name">Flatpak bundle</span><span class="file">Nightcap-x86_64.flatpak</span><span class="go" aria-hidden="true">↓</span></a></li>
|
||||
<li><a href="https://github.com/CoderDayton/nightcap/releases/latest"><span class="name">DEB, RPM, pacman</span><span class="file">GitHub releases page</span><span class="go" aria-hidden="true">↗</span></a></li>
|
||||
<li><a href="nightcap-flatpak.gpg"><span class="name">Signing key</span><span class="file">nightcap-flatpak.gpg</span><span class="go" aria-hidden="true">↓</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="wrap foot">
|
||||
<p>
|
||||
<a href="https://github.com/CoderDayton/nightcap">Source code</a> ·
|
||||
<a href="https://github.com/komaruworld/mocktail">Upstream Mocktail</a> ·
|
||||
<a href="terms.html">Terms</a> ·
|
||||
<a href="privacy.html">Privacy</a>
|
||||
</p>
|
||||
<p>Nightcap is an independent community project and is not affiliated with Roblox Corporation.</p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Adds a copy button to each command block. The page works without it.
|
||||
document.querySelectorAll('.install dd').forEach(function (block) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = 'copy';
|
||||
button.addEventListener('click', function () {
|
||||
if (!navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(block.querySelector('code').textContent).then(function () {
|
||||
button.textContent = 'copied';
|
||||
setTimeout(function () { button.textContent = 'copy'; }, 1600);
|
||||
});
|
||||
});
|
||||
block.appendChild(button);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+36
-26
@@ -8,36 +8,46 @@
|
||||
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body><main>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p>Nightcap is a Linux runtime for the Roblox Android client. This policy covers Nightcap itself and its Discord application. Roblox and Discord have their own policies for the data they handle.</p>
|
||||
<body>
|
||||
<header class="wrap bar">
|
||||
<a class="brand" href="index.html"><img src="nightcap.svg" alt="" width="30" height="30"><span>Nightcap</span></a>
|
||||
<p class="top">
|
||||
<a href="https://github.com/CoderDayton/nightcap" aria-label="Nightcap on GitHub" title="GitHub"><svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg></a>
|
||||
<a href="https://discord.gg/DbAUG9hBFU" aria-label="Nightcap on Discord" title="Discord"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg></a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<h2>What Nightcap collects</h2>
|
||||
<p>Nothing. Nightcap has no server. It does not collect, store, or send telemetry, analytics, crash reports, or account data to the Nightcap project.</p>
|
||||
<main class="wrap prose">
|
||||
<h1>Privacy Policy</h1>
|
||||
<p>Nightcap is a Linux runtime for the Roblox Android client. This policy covers Nightcap itself and its Discord application. Roblox and Discord have their own policies for the data they handle.</p>
|
||||
|
||||
<h2>What stays on your machine</h2>
|
||||
<ul>
|
||||
<li>Configuration in <code>~/.config/mocktail/</code></li>
|
||||
<li>Session logs and support bundles in <code>~/.local/state/mocktail/</code></li>
|
||||
<li>Roblox login session under <code>~/.local/share/mocktail/</code></li>
|
||||
<li>Roblox cache, downloaded payloads, and shader cache under <code>~/.cache/mocktail/</code></li>
|
||||
</ul>
|
||||
<p>Flatpak installs keep the same layout under <code>~/.var/app/io.github.CoderDayton.nightcap/</code>.</p>
|
||||
<h2>What Nightcap collects</h2>
|
||||
<p>Nothing. Nightcap has no server. It does not collect, store, or send telemetry, analytics, crash reports, or account data to the Nightcap project.</p>
|
||||
|
||||
<h2>Discord Rich Presence</h2>
|
||||
<p>When Rich Presence is on, Nightcap sends the current experience name, its public icon URL, elapsed time, and an optional public server join code to the Discord desktop client on the same machine over a local socket. Discord displays this on your profile under its own privacy settings. Nightcap never signs in to Discord, never reads a Discord token, and never contacts Discord's servers.</p>
|
||||
<p>Set <code>integrations.discord_rpc.enabled: false</code> in <code>config.yaml</code> to turn it off.</p>
|
||||
<h2>What stays on your machine</h2>
|
||||
<ul>
|
||||
<li>Configuration in <code>~/.config/mocktail/</code></li>
|
||||
<li>Session logs and support bundles in <code>~/.local/state/mocktail/</code></li>
|
||||
<li>Roblox login session under <code>~/.local/share/mocktail/</code></li>
|
||||
<li>Roblox cache, downloaded payloads, and shader cache under <code>~/.cache/mocktail/</code></li>
|
||||
</ul>
|
||||
<p>Flatpak installs keep the same layout under <code>~/.var/app/io.github.CoderDayton.nightcap/</code>.</p>
|
||||
|
||||
<h2>Roblox</h2>
|
||||
<p>Nightcap talks to Roblox's services exactly as the Roblox client would. That traffic is covered by the Roblox Privacy Policy.</p>
|
||||
<h2>Discord Rich Presence</h2>
|
||||
<p>When Rich Presence is on, Nightcap sends the current experience name, its public icon URL, elapsed time, and an optional public server join code to the Discord desktop client on the same machine over a local socket. Discord displays this on your profile under its own privacy settings. Nightcap never signs in to Discord, never reads a Discord token, and never contacts Discord's servers.</p>
|
||||
<p>Set <code>integrations.discord_rpc.enabled: false</code> in <code>config.yaml</code> to turn it off.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>Open an issue at <a class="source" href="https://github.com/CoderDayton/nightcap/issues">github.com/CoderDayton/nightcap/issues</a>.</p>
|
||||
<h2>Roblox</h2>
|
||||
<p>Nightcap talks to Roblox's services exactly as the Roblox client would. That traffic is covered by the Roblox Privacy Policy.</p>
|
||||
|
||||
<p class="links">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="terms.html">Terms of Service</a>
|
||||
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
|
||||
</p>
|
||||
</main></body>
|
||||
<h2>Contact</h2>
|
||||
<p>Open an issue at <a href="https://github.com/CoderDayton/nightcap/issues">github.com/CoderDayton/nightcap/issues</a>.</p>
|
||||
|
||||
<p class="legal-nav">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="terms.html">Terms of Service</a>
|
||||
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+247
-49
@@ -1,94 +1,292 @@
|
||||
/* Modified by vii from komaruworld/mocktail. See README "About this fork". */
|
||||
:root {
|
||||
font: 17px/1.55 system-ui, sans-serif;
|
||||
--bg: #14122b;
|
||||
--ink: #ece8ff;
|
||||
--muted: #b3aad6;
|
||||
--accent: #6c4fd1;
|
||||
--link: #a78bfa;
|
||||
--line: #3a3470;
|
||||
--code-bg: #1d1a3d;
|
||||
font: 17px/1.6 system-ui, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #14122b;
|
||||
color: #ece8ff;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 680px;
|
||||
margin: 12vh auto;
|
||||
padding: 32px;
|
||||
a { color: var(--link); }
|
||||
a:focus-visible,
|
||||
button:focus-visible { outline: 2px solid var(--link); outline-offset: 3px; }
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
.hero { padding-bottom: clamp(48px, 10vh, 110px); }
|
||||
|
||||
.top {
|
||||
margin: 0;
|
||||
padding-top: 22px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: .2rem 0;
|
||||
font-size: clamp(2.5rem, 8vw, 4.5rem);
|
||||
.top a {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 32px 0 0;
|
||||
font-size: 1.15rem;
|
||||
.top a:hover { color: var(--ink); }
|
||||
|
||||
.hero h1 {
|
||||
margin: clamp(48px, 12vh, 130px) 0 0;
|
||||
font-size: clamp(3.6rem, 15vw, 9.5rem);
|
||||
font-weight: 800;
|
||||
line-height: .95;
|
||||
letter-spacing: -.045em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .16em;
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
color: #b3aad6;
|
||||
.hero h1 img {
|
||||
height: .78em;
|
||||
width: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 1.2rem;
|
||||
.lede {
|
||||
max-width: 30ch;
|
||||
margin: 22px 0 0;
|
||||
color: var(--ink);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.actions { margin: 28px 0 0; }
|
||||
|
||||
a.button {
|
||||
display: inline-block;
|
||||
margin: 16px 8px 16px 0;
|
||||
padding: 12px 18px;
|
||||
border-radius: 9px;
|
||||
background: #6c4fd1;
|
||||
color: white;
|
||||
margin: 0 10px 10px 0;
|
||||
padding: 12px 20px;
|
||||
border: 2px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.secondary {
|
||||
background: #2f2a6b;
|
||||
a.button:hover { background: #7d62dd; border-color: #7d62dd; }
|
||||
|
||||
a.button.secondary {
|
||||
background: var(--bg);
|
||||
border-color: var(--link);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.commands {
|
||||
margin-top: 10px;
|
||||
a.button.secondary:hover { background: var(--code-bg); }
|
||||
|
||||
/* Every section is a label on the left and its content on the right. */
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 8px 32px;
|
||||
padding: 36px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.commands p,
|
||||
.download-label {
|
||||
margin: 14px 0 5px;
|
||||
color: #ece8ff;
|
||||
font-size: .82rem;
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.row.stacked { grid-template-columns: 1fr; gap: 20px; }
|
||||
|
||||
.body { margin: 0; min-width: 0; }
|
||||
.body p { margin: 0 0 12px; color: var(--muted); }
|
||||
|
||||
dl.body {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0 28px;
|
||||
}
|
||||
|
||||
dt {
|
||||
padding: 9px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 9px 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.facts dt,
|
||||
.facts dd { border-bottom: 1px solid var(--line); }
|
||||
.facts dt:last-of-type,
|
||||
.facts dd:last-of-type { border-bottom: 0; }
|
||||
.facts dt { color: var(--ink); font-weight: 700; }
|
||||
|
||||
dl.body.install { display: block; }
|
||||
|
||||
.install dt {
|
||||
padding: 18px 0 6px;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.install dt:first-child { padding-top: 0; }
|
||||
.install dd { padding: 0; }
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
overflow-x: auto;
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
code {
|
||||
display: block;
|
||||
padding: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
border: 1px solid #3a3470;
|
||||
border-radius: 6px;
|
||||
background: #1d1a3d;
|
||||
font: .9rem/1.7 ui-monospace, "DejaVu Sans Mono", "JetBrains Mono", monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
dd button {
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
right: 0;
|
||||
padding: 2px 0 2px 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: .8rem ui-monospace, monospace;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
dd button:hover { color: var(--ink); text-decoration: underline; }
|
||||
|
||||
.links {
|
||||
margin: 10px 0 14px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.links li { border-bottom: 1px solid var(--line); }
|
||||
|
||||
.links a {
|
||||
display: inline-block;
|
||||
margin: 0 14px 6px 0;
|
||||
color: #a78bfa;
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr 24px;
|
||||
align-items: baseline;
|
||||
gap: 4px 16px;
|
||||
margin: 0 -8px;
|
||||
padding: 12px 24px 12px 8px;
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.source {
|
||||
color: #a78bfa;
|
||||
.links a:hover { background: var(--code-bg); }
|
||||
.links a:hover .name { text-decoration: underline; text-decoration-color: var(--link); text-underline-offset: 4px; }
|
||||
|
||||
.links .file {
|
||||
color: var(--muted);
|
||||
font: 400 .88rem ui-monospace, "DejaVu Sans Mono", monospace;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.links .go { color: var(--link); text-align: right; }
|
||||
|
||||
.foot {
|
||||
padding-bottom: 48px;
|
||||
color: var(--muted);
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.foot::before {
|
||||
content: "";
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.foot p { margin: 0 0 8px; }
|
||||
|
||||
/* Terms and privacy pages: a header bar and one readable column. */
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.bar .top { padding-top: 0; }
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--ink);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand img { width: 30px; height: 30px; }
|
||||
|
||||
.prose {
|
||||
padding-top: clamp(32px, 8vh, 80px);
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.prose > * { max-width: 700px; }
|
||||
|
||||
.prose h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: clamp(2rem, 6vw, 3rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -.03em;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
margin: 36px 0 6px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.prose p,
|
||||
.prose li { margin: 0 0 12px; }
|
||||
.prose ul { margin: 0 0 12px; padding-left: 1.2rem; }
|
||||
|
||||
.prose code {
|
||||
padding: 1px 6px;
|
||||
background: var(--code-bg);
|
||||
font-size: .85rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.legal-nav { display: flex; flex-wrap: wrap; gap: 4px 20px; margin-top: 28px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.row { grid-template-columns: 1fr; padding: 28px 0; }
|
||||
dl.body:not(.install) { grid-template-columns: 1fr; }
|
||||
.facts dt { padding-bottom: 0; border-bottom: 0; }
|
||||
.links a { grid-template-columns: 1fr 24px; }
|
||||
.links .file { grid-column: 1; grid-row: 2; }
|
||||
.links .go { grid-column: 2; grid-row: 1 / span 2; align-self: center; }
|
||||
}
|
||||
+29
-19
@@ -8,29 +8,39 @@
|
||||
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body><main>
|
||||
<h1>Terms of Service</h1>
|
||||
<p>Nightcap is free and open-source software licensed under the Apache License, Version 2.0. The license text shipped with the source governs your use of the software.</p>
|
||||
<body>
|
||||
<header class="wrap bar">
|
||||
<a class="brand" href="index.html"><img src="nightcap.svg" alt="" width="30" height="30"><span>Nightcap</span></a>
|
||||
<p class="top">
|
||||
<a href="https://github.com/CoderDayton/nightcap" aria-label="Nightcap on GitHub" title="GitHub"><svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg></a>
|
||||
<a href="https://discord.gg/DbAUG9hBFU" aria-label="Nightcap on Discord" title="Discord"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg></a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<h2>No warranty</h2>
|
||||
<p>Nightcap is provided "as is", without warranty of any kind. The authors are not liable for any loss, damage, or account action that results from using it. See sections 7 and 8 of the Apache License.</p>
|
||||
<main class="wrap prose">
|
||||
<h1>Terms of Service</h1>
|
||||
<p>Nightcap is free and open-source software licensed under the Apache License, Version 2.0. The license text shipped with the source governs your use of the software.</p>
|
||||
|
||||
<h2>Not affiliated</h2>
|
||||
<p>Nightcap is an independent community project. It is not affiliated with, endorsed by, or supported by Roblox Corporation or Discord Inc. Roblox and Discord are trademarks of their respective owners.</p>
|
||||
<h2>No warranty</h2>
|
||||
<p>Nightcap is provided "as is", without warranty of any kind. The authors are not liable for any loss, damage, or account action that results from using it. See sections 7 and 8 of the Apache License.</p>
|
||||
|
||||
<h2>Your responsibility</h2>
|
||||
<p>You are responsible for following the Roblox Terms of Use and the Discord Terms of Service while using Nightcap with those services. Nightcap does not modify Roblox game logic, does not bypass Roblox security, and is not a cheat.</p>
|
||||
<h2>Not affiliated</h2>
|
||||
<p>Nightcap is an independent community project. It is not affiliated with, endorsed by, or supported by Roblox Corporation or Discord Inc. Roblox and Discord are trademarks of their respective owners.</p>
|
||||
|
||||
<h2>Discord application</h2>
|
||||
<p>The Nightcap Discord application only provides Rich Presence for the Nightcap desktop runtime. It requests no permissions, has no bot, and does not access your Discord account.</p>
|
||||
<h2>Your responsibility</h2>
|
||||
<p>You are responsible for following the Roblox Terms of Use and the Discord Terms of Service while using Nightcap with those services. Nightcap does not modify Roblox game logic, does not bypass Roblox security, and is not a cheat.</p>
|
||||
|
||||
<h2>Changes</h2>
|
||||
<p>These terms may change with new releases. The current version always lives at this address.</p>
|
||||
<h2>Discord application</h2>
|
||||
<p>The Nightcap Discord application only provides Rich Presence for the Nightcap desktop runtime. It requests no permissions, has no bot, and does not access your Discord account.</p>
|
||||
|
||||
<p class="links">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="privacy.html">Privacy Policy</a>
|
||||
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
|
||||
</p>
|
||||
</main></body>
|
||||
<h2>Changes</h2>
|
||||
<p>These terms may change with new releases. The current version always lives at this address.</p>
|
||||
|
||||
<p class="legal-nav">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="privacy.html">Privacy Policy</a>
|
||||
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
@@ -31,18 +32,18 @@ struct NativeOptionalFormat {
|
||||
struct NativeSharedSink { void* object; void* owner; };
|
||||
static_assert(sizeof(NativeOptionalFormat) == 16);
|
||||
static_assert(sizeof(NativePcmFormat) == 8);
|
||||
inline std::atomic<NativeInputCapture*> g_native_capture{nullptr};
|
||||
inline std::shared_ptr<NativeInputCapture> g_native_capture;
|
||||
|
||||
class NativeInputCapture {
|
||||
public:
|
||||
using DeliverPcm = std::size_t (*)(void*, const void*, std::size_t,
|
||||
std::uint64_t, const NativePcmFormat*);
|
||||
using Destroy = void (*)(NativeSharedSink*);
|
||||
using OriginalLatency = void (*)(void*, std::uint32_t*, std::uint32_t*);
|
||||
using ImageRange = bool (*)(std::uintptr_t, std::uintptr_t, std::size_t);
|
||||
struct SinkAbi {
|
||||
std::uintptr_t vtable;
|
||||
DeliverPcm deliver;
|
||||
Destroy release;
|
||||
std::uintptr_t image_base;
|
||||
ImageRange is_image_relro;
|
||||
ImageRange is_image_code;
|
||||
OriginalLatency latency;
|
||||
};
|
||||
NativeInputCapture(SinkAbi abi, bool enabled)
|
||||
@@ -50,17 +51,22 @@ class NativeInputCapture {
|
||||
~NativeInputCapture() { Shutdown(); }
|
||||
|
||||
static NativeOptionalFormat Format(void*) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
return {{}, capture && capture->enabled_ ? 1ULL : 0ULL};
|
||||
auto capture =
|
||||
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||
if (!capture) return {};
|
||||
std::lock_guard lock(capture->mutex_);
|
||||
return {{}, capture->enabled_ && !capture->closed_ ? 1ULL : 0ULL};
|
||||
}
|
||||
static void Latency(void* self, std::uint32_t* playback,
|
||||
std::uint32_t* recording) {
|
||||
// Preserve the native playback latency and report the host capture queue.
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
auto capture =
|
||||
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||
if (!capture) return;
|
||||
std::lock_guard lock(capture->mutex_);
|
||||
if (capture->closed_) return;
|
||||
capture->abi_.latency(self, playback, recording);
|
||||
if (recording) {
|
||||
std::lock_guard lock(capture->mutex_);
|
||||
*recording = 10;
|
||||
auto it = capture->sessions_.find(self);
|
||||
if (it != capture->sessions_.end()) {
|
||||
@@ -70,33 +76,48 @@ class NativeInputCapture {
|
||||
}
|
||||
}
|
||||
static bool Start(void* self, NativeSharedSink* sink) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
auto capture =
|
||||
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||
return capture && capture->StartSink(self, sink);
|
||||
}
|
||||
static void Stop(void* self, NativeSharedSink* sink) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
auto capture =
|
||||
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||
if (capture) capture->StopSink(self, sink);
|
||||
}
|
||||
static bool Recording(void* self) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
auto capture =
|
||||
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||
if (!capture) return false;
|
||||
std::lock_guard lock(capture->mutex_);
|
||||
return capture->sessions_.find(self) != capture->sessions_.end();
|
||||
}
|
||||
static void Poll(void* self) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
auto capture =
|
||||
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||
if (capture) capture->Deliver(self);
|
||||
}
|
||||
void Shutdown() {
|
||||
std::lock_guard lock(mutex_);
|
||||
closed_ = true;
|
||||
for (auto& [self, session] : sessions_) Close(*session);
|
||||
sessions_.clear();
|
||||
}
|
||||
|
||||
private:
|
||||
struct Sink {
|
||||
NativeSharedSink shared;
|
||||
DeliverPcm deliver;
|
||||
};
|
||||
struct SharedCount {
|
||||
const std::uintptr_t* vtable;
|
||||
long shared_owners;
|
||||
long weak_owners;
|
||||
};
|
||||
using ControlBlockHook = void (*)(void*);
|
||||
struct Session {
|
||||
std::unique_ptr<AudioCapture> input;
|
||||
std::vector<NativeSharedSink> sinks;
|
||||
std::vector<Sink> sinks;
|
||||
std::mutex queue_mutex;
|
||||
// At most 200 ms; a stalled consumer must not accumulate stale speech.
|
||||
std::array<std::array<std::int16_t, 480>, 20> frames{};
|
||||
@@ -118,20 +139,33 @@ class NativeInputCapture {
|
||||
session.input->buffer_data(), bytes);
|
||||
++session.count;
|
||||
}
|
||||
bool IsGuestVtable(const void* object, std::size_t size,
|
||||
std::initializer_list<std::size_t> methods) const {
|
||||
const auto base = abi_.image_base;
|
||||
const auto* table = *static_cast<const std::uintptr_t* const*>(object);
|
||||
const auto address = reinterpret_cast<std::uintptr_t>(table);
|
||||
if (address < base ||
|
||||
!abi_.is_image_relro(base, address - base, size * sizeof(*table)))
|
||||
return false;
|
||||
for (const auto slot : methods)
|
||||
if (table[slot] < base ||
|
||||
!abi_.is_image_code(base, table[slot] - base, 1))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
bool StartSink(void* self, NativeSharedSink* sink) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!enabled_ || !self || !sink || !sink->object || !sink->owner)
|
||||
if (closed_ || !enabled_ || !self || !sink || !sink->object || !sink->owner)
|
||||
return false;
|
||||
auto* table = *reinterpret_cast<std::uintptr_t**>(sink->object);
|
||||
if (reinterpret_cast<std::uintptr_t>(table) != abi_.vtable ||
|
||||
table[2] != reinterpret_cast<std::uintptr_t>(abi_.deliver)) {
|
||||
if (!IsGuestVtable(sink->object, 3, {2}) ||
|
||||
!IsGuestVtable(sink->owner, 5, {2, 4})) {
|
||||
std::fprintf(stderr, " [audio-input] unsupported native PCM sink ABI\n");
|
||||
return false;
|
||||
}
|
||||
auto it = sessions_.find(self);
|
||||
if (it != sessions_.end()) {
|
||||
for (const auto& existing : it->second->sinks)
|
||||
if (existing.object == sink->object) return true;
|
||||
if (existing.shared.object == sink->object) return true;
|
||||
} else {
|
||||
auto session = std::make_unique<Session>();
|
||||
SdlAudioCaptureOptions options;
|
||||
@@ -151,13 +185,20 @@ class NativeInputCapture {
|
||||
}
|
||||
// The native parameter is a by-value shared_ptr. Retain its existing
|
||||
// ownership by moving it, leaving the caller's temporary empty.
|
||||
it->second->sinks.push_back(*sink);
|
||||
const auto* table = *static_cast<const std::uintptr_t* const*>(sink->object);
|
||||
it->second->sinks.push_back({*sink, reinterpret_cast<DeliverPcm>(table[2])});
|
||||
*sink = {};
|
||||
return true;
|
||||
}
|
||||
void Release(NativeSharedSink& sink) {
|
||||
abi_.release(&sink);
|
||||
static void Release(Sink& sink) {
|
||||
auto* count = static_cast<SharedCount*>(sink.shared.owner);
|
||||
sink = {};
|
||||
if (__atomic_fetch_sub(&count->shared_owners, 1, __ATOMIC_ACQ_REL) != 0)
|
||||
return;
|
||||
reinterpret_cast<ControlBlockHook>(count->vtable[2])(count);
|
||||
if (__atomic_load_n(&count->weak_owners, __ATOMIC_ACQUIRE) == 0 ||
|
||||
__atomic_fetch_sub(&count->weak_owners, 1, __ATOMIC_ACQ_REL) == 0)
|
||||
reinterpret_cast<ControlBlockHook>(count->vtable[4])(count);
|
||||
}
|
||||
void Close(Session& session) {
|
||||
session.input->Shutdown();
|
||||
@@ -174,7 +215,7 @@ class NativeInputCapture {
|
||||
if (it == sessions_.end() || !sink) return;
|
||||
auto& session = *it->second;
|
||||
auto existing = std::find_if(session.sinks.begin(), session.sinks.end(),
|
||||
[&](const auto& entry) { return entry.object == sink->object; });
|
||||
[&](const auto& entry) { return entry.shared.object == sink->object; });
|
||||
if (existing == session.sinks.end()) return;
|
||||
Release(*existing);
|
||||
session.sinks.erase(existing);
|
||||
@@ -203,8 +244,7 @@ class NativeInputCapture {
|
||||
}
|
||||
const NativePcmFormat format;
|
||||
for (const auto& sink : session.sinks)
|
||||
abi_.deliver(
|
||||
sink.object, pcm.data(), pcm.size(), delay, &format);
|
||||
sink.deliver(sink.shared.object, pcm.data(), pcm.size(), delay, &format);
|
||||
if (++session.delivered == 1)
|
||||
std::fprintf(stderr, " [audio-input] PCM delivered to native voice sink\n");
|
||||
if (!session.audible && std::any_of(pcm.begin(), pcm.end(),
|
||||
@@ -216,6 +256,7 @@ class NativeInputCapture {
|
||||
}
|
||||
SinkAbi abi_;
|
||||
bool enabled_;
|
||||
bool closed_ = false;
|
||||
std::mutex mutex_;
|
||||
std::map<void*, std::unique_ptr<Session>> sessions_;
|
||||
};
|
||||
|
||||
@@ -130,14 +130,6 @@ std::uintptr_t FunctionAddress(Function function) {
|
||||
|
||||
} // namespace
|
||||
|
||||
// ABI verified for Roblox 2.738.1397. Keep capture separate from the menu
|
||||
// profile: relocating query methods alone does not establish a recording ABI.
|
||||
namespace {
|
||||
constexpr std::array<std::size_t, 6> kCaptureSlots{4, 13, 14, 15, 16, 17};
|
||||
constexpr std::array<std::uintptr_t, 6> kCaptureRvas{
|
||||
0x320c630, 0x320bcf6, 0x320d4b4, 0x320dd94, 0x320b844, 0x320e49a};
|
||||
} // namespace
|
||||
|
||||
RobloxOutputDeviceBridge::RobloxOutputDeviceBridge() = default;
|
||||
|
||||
namespace internal {
|
||||
@@ -226,10 +218,6 @@ Status RobloxOutputDeviceBridge::Install(const compat::BuildProfile& profile) {
|
||||
"another Roblox output-device bridge owns the process");
|
||||
}
|
||||
profile_ = *profile.fmod_output_device_bridge;
|
||||
capture_profile_supported_ =
|
||||
profile.elf_build_id == "5f0704edd9064f566ee3d6df2bd2fabbcc709f03" &&
|
||||
profile_.vtable_rva == 0x6cd3ce0 &&
|
||||
profile_.vtable_layout_version == 2 && profile_.has_input_devices();
|
||||
if (!linker::SetAndroidLibraryLoadObserver(&ObserveAndroidLibrary, this)) {
|
||||
g_active_bridge.store(nullptr, std::memory_order_release);
|
||||
profile_ = {};
|
||||
@@ -255,7 +243,10 @@ void RobloxOutputDeviceBridge::Shutdown() {
|
||||
" [audio-menu] failed to restore FmodAudioDevice "
|
||||
"vtable protection\n");
|
||||
}
|
||||
g_native_capture.store(nullptr, std::memory_order_release);
|
||||
std::atomic_store_explicit(&g_native_capture,
|
||||
std::shared_ptr<NativeInputCapture>{},
|
||||
std::memory_order_release);
|
||||
if (capture_) capture_->Shutdown();
|
||||
capture_.reset();
|
||||
active_ = false;
|
||||
devices_.clear();
|
||||
@@ -419,6 +410,7 @@ Status RobloxOutputDeviceBridge::Activate(std::uintptr_t image_base) {
|
||||
Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||
const std::size_t kCurrentOutputVtableIndex = profile_.current_vtable_index();
|
||||
const std::size_t kSelectOutputVtableIndex = profile_.select_vtable_index();
|
||||
const auto capture_indexes = profile_.capture_vtable_indexes();
|
||||
if (profile_.vtable_rva >
|
||||
std::numeric_limits<std::uintptr_t>::max() -
|
||||
(kSelectOutputVtableIndex + 1) * sizeof(std::uintptr_t)) {
|
||||
@@ -461,26 +453,25 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||
"FmodAudioDevice string ABI contract validation failed");
|
||||
}
|
||||
|
||||
if (capture_profile_supported_) {
|
||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) {
|
||||
if (!IsExecutableImageRange(library_base_, kCaptureRvas[i], 1) ||
|
||||
vtable_[kCaptureSlots[i]] != library_base_ + kCaptureRvas[i])
|
||||
if (profile_.has_input_devices()) {
|
||||
for (std::size_t i = 0; i < capture_indexes.size(); ++i) {
|
||||
const auto method = vtable_[capture_indexes[i]];
|
||||
if (method < library_base_ ||
|
||||
!IsExecutableImageRange(library_base_, method - library_base_, 1) ||
|
||||
std::find(profile_.input_method_rvas.begin(),
|
||||
profile_.input_method_rvas.end(), method - library_base_) !=
|
||||
profile_.input_method_rvas.end() ||
|
||||
std::find(original_capture_methods_.begin(),
|
||||
original_capture_methods_.begin() + i, method) !=
|
||||
original_capture_methods_.begin() + i)
|
||||
return FailedPrecondition("native input capture vtable ABI mismatch");
|
||||
original_capture_methods_[i] = vtable_[kCaptureSlots[i]];
|
||||
original_capture_methods_[i] = method;
|
||||
}
|
||||
if (!IsExecutableImageRange(library_base_, 0x3213154, 1) ||
|
||||
!IsExecutableImageRange(library_base_, 0x1dc4cd0, 1) ||
|
||||
!IsRelroImageRange(library_base_, 0x6cd4550, 24))
|
||||
return FailedPrecondition("native input capture sink ABI outside image");
|
||||
capture_ = std::make_unique<NativeInputCapture>(
|
||||
capture_ = std::make_shared<NativeInputCapture>(
|
||||
NativeInputCapture::SinkAbi{
|
||||
library_base_ + 0x6cd4550,
|
||||
reinterpret_cast<NativeInputCapture::DeliverPcm>(
|
||||
library_base_ + 0x3213154),
|
||||
reinterpret_cast<NativeInputCapture::Destroy>(
|
||||
library_base_ + 0x1dc4cd0),
|
||||
library_base_, &IsRelroImageRange, &IsExecutableImageRange,
|
||||
reinterpret_cast<NativeInputCapture::OriginalLatency>(
|
||||
library_base_ + kCaptureRvas[1])},
|
||||
original_capture_methods_[1])},
|
||||
!input_devices_.empty());
|
||||
}
|
||||
|
||||
@@ -519,7 +510,8 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||
__atomic_store_n(&vtable_[indexes[i]], methods[i], __ATOMIC_RELEASE);
|
||||
}
|
||||
if (capture_) {
|
||||
g_native_capture.store(capture_.get(), std::memory_order_release);
|
||||
std::atomic_store_explicit(&g_native_capture, capture_,
|
||||
std::memory_order_release);
|
||||
const std::array<std::uintptr_t, 6> methods{
|
||||
FunctionAddress(&NativeInputCapture::Format),
|
||||
FunctionAddress(&NativeInputCapture::Latency),
|
||||
@@ -527,8 +519,8 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||
FunctionAddress(&NativeInputCapture::Stop),
|
||||
FunctionAddress(&NativeInputCapture::Poll),
|
||||
FunctionAddress(&NativeInputCapture::Recording)};
|
||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
|
||||
__atomic_store_n(&vtable_[kCaptureSlots[i]], methods[i], __ATOMIC_RELEASE);
|
||||
for (std::size_t i = 0; i < capture_indexes.size(); ++i)
|
||||
__atomic_store_n(&vtable_[capture_indexes[i]], methods[i], __ATOMIC_RELEASE);
|
||||
}
|
||||
if (!SetVtableWritable(vtable_, kSelectOutputVtableIndex, false)) {
|
||||
__atomic_store_n(&vtable_[kOutputCountVtableIndex], original_methods_[0],
|
||||
@@ -546,10 +538,13 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||
__ATOMIC_RELEASE);
|
||||
}
|
||||
if (capture_) {
|
||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
|
||||
__atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i],
|
||||
for (std::size_t i = 0; i < capture_indexes.size(); ++i)
|
||||
__atomic_store_n(&vtable_[capture_indexes[i]], original_capture_methods_[i],
|
||||
__ATOMIC_RELEASE);
|
||||
g_native_capture.store(nullptr, std::memory_order_release);
|
||||
std::atomic_store_explicit(&g_native_capture,
|
||||
std::shared_ptr<NativeInputCapture>{},
|
||||
std::memory_order_release);
|
||||
capture_->Shutdown();
|
||||
capture_.reset();
|
||||
}
|
||||
(void)SetVtableWritable(vtable_, kSelectOutputVtableIndex, false);
|
||||
@@ -581,8 +576,9 @@ bool RobloxOutputDeviceBridge::RestoreVtableLocked() {
|
||||
__ATOMIC_RELEASE);
|
||||
}
|
||||
if (capture_) {
|
||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
|
||||
__atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i],
|
||||
const auto capture_indexes = profile_.capture_vtable_indexes();
|
||||
for (std::size_t i = 0; i < capture_indexes.size(); ++i)
|
||||
__atomic_store_n(&vtable_[capture_indexes[i]], original_capture_methods_[i],
|
||||
__ATOMIC_RELEASE);
|
||||
}
|
||||
return SetVtableWritable(vtable_, kSelectOutputVtableIndex, false);
|
||||
|
||||
@@ -150,6 +150,15 @@ Result ParseInHostCLocale(Parser parser) noexcept {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Highest errno in Bionic's message table (EHWPOISON). Numbers 41 and 58 are
|
||||
// unassigned in the generic Linux errno list, so Bionic has no text for them.
|
||||
constexpr int kBionicMaxErrno = 133;
|
||||
|
||||
constexpr bool HasBionicErrnoMessage(int error_number) noexcept {
|
||||
return error_number >= 0 && error_number <= kBionicMaxErrno &&
|
||||
error_number != 41 && error_number != 58;
|
||||
}
|
||||
|
||||
template <typename Result>
|
||||
const char* HostStrErrorResult(Result result, char* buffer) noexcept {
|
||||
if constexpr (std::is_integral_v<Result>) {
|
||||
@@ -318,10 +327,16 @@ int BionicStrError(int error_number, char* buffer,
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Bionic's table ends at EHWPOISON. Unknown numbers get the same text on
|
||||
// every host instead of whatever the host libc prints (musl has no
|
||||
// "Unknown error N" form).
|
||||
std::array<char, 256> host_buffer{};
|
||||
auto host_result =
|
||||
::strerror_r(error_number, host_buffer.data(), host_buffer.size());
|
||||
const char* message = HostStrErrorResult(host_result, host_buffer.data());
|
||||
const char* message = nullptr;
|
||||
if (HasBionicErrnoMessage(error_number)) {
|
||||
auto host_result =
|
||||
::strerror_r(error_number, host_buffer.data(), host_buffer.size());
|
||||
message = HostStrErrorResult(host_result, host_buffer.data());
|
||||
}
|
||||
|
||||
std::array<char, 64> unknown_buffer{};
|
||||
if (message == nullptr) {
|
||||
@@ -373,6 +388,10 @@ extern "C" void mocktail_bionic_arc4random_buf(void* buffer, size_t size) {
|
||||
mocktail::compat::BionicArc4RandomBuffer(buffer, size);
|
||||
}
|
||||
|
||||
extern "C" int mocktail_bionic_atoi(const char* text) {
|
||||
return static_cast<int>(std::strtol(text, nullptr, 10));
|
||||
}
|
||||
|
||||
extern "C" mocktail::compat::BionicMallinfoSnapshot mocktail_bionic_mallinfo() {
|
||||
return mocktail::compat::BionicMallinfo();
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ int CopySupportedThreadAttributes(const MocktailBionicPthreadAttr& source,
|
||||
if (RejectRealtimeScheduling(source.sched_policy)) {
|
||||
return EPERM;
|
||||
}
|
||||
if (source.sched_policy != SCHED_NORMAL) {
|
||||
if (source.sched_policy != SCHED_OTHER) {
|
||||
result = pthread_attr_setschedpolicy(destination, source.sched_policy);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
|
||||
@@ -270,6 +270,42 @@ bool HostAbiProfile::ShouldInitializeNativeMimallocThreadAfterConstructor(
|
||||
index == native_mimalloc_thread_initializer_after_constructor;
|
||||
}
|
||||
|
||||
bool HostAbiProfile::HasVerifiedConstructorPolicy(
|
||||
size_t jni_singleton_constructor) const noexcept {
|
||||
constexpr size_t kLeadingConstructors = 4;
|
||||
constexpr size_t kWiringConstructors = 2;
|
||||
if (!HasValidConstructorRanges() ||
|
||||
!HasValidNativeMimallocConstructorRanges() ||
|
||||
native_mimalloc_constructor_run_range_count != 1) {
|
||||
return false;
|
||||
}
|
||||
const size_t begin = native_mimalloc_constructor_run_ranges[0].begin;
|
||||
const size_t checkpoint =
|
||||
native_mimalloc_thread_initializer_after_constructor;
|
||||
const ConstructorRange &tail =
|
||||
constructor_run_ranges[constructor_run_range_count - 1];
|
||||
if (init_array_count < begin + kLeadingConstructors ||
|
||||
native_mimalloc_constructor_run_ranges[0].end_exclusive !=
|
||||
init_array_count ||
|
||||
checkpoint < begin || checkpoint >= begin + kLeadingConstructors ||
|
||||
!AllowsConstructor(checkpoint) || jni_singleton_constructor < begin ||
|
||||
jni_singleton_constructor >= begin + kLeadingConstructors ||
|
||||
jni_singleton_constructor == checkpoint ||
|
||||
!AllowsConstructor(jni_singleton_constructor) ||
|
||||
ConstructorRangeBegin() < begin ||
|
||||
tail.begin > begin + kLeadingConstructors ||
|
||||
tail.end_exclusive != init_array_count) {
|
||||
return false;
|
||||
}
|
||||
size_t excluded = 0;
|
||||
for (size_t index = begin; index < begin + kLeadingConstructors; ++index) {
|
||||
if (!AllowsConstructor(index)) {
|
||||
++excluded;
|
||||
}
|
||||
}
|
||||
return excluded == kWiringConstructors;
|
||||
}
|
||||
|
||||
bool HostAbiProfile::HasValidNativePreJniBootstrap() const noexcept {
|
||||
return native_pre_jni_bootstrap.IsValid();
|
||||
}
|
||||
|
||||
@@ -612,26 +612,26 @@ std::unique_ptr<OwnedHostAbiProfile> ParseProfile(const Json& root,
|
||||
*error = "external host ABI profile has inconsistent constructor policy";
|
||||
return nullptr;
|
||||
}
|
||||
// The verified constructor layout is the same on both guest ABIs: a single
|
||||
// process-load constructor at the thread-initializer checkpoint, two wiring
|
||||
// entries excluded under host bridges, and one contiguous native-mimalloc
|
||||
// replay from the checkpoint to the end of .init_array. Only the checkpoint
|
||||
// index itself differs between payloads.
|
||||
const std::size_t checkpoint =
|
||||
owned->profile.native_mimalloc_thread_initializer_after_constructor;
|
||||
if (owned->profile.init_array_count <= checkpoint + 3 ||
|
||||
owned->profile.constructor_run_range_count != 2 ||
|
||||
owned->profile.constructor_run_ranges[0].begin != checkpoint ||
|
||||
owned->profile.constructor_run_ranges[0].end_exclusive != checkpoint + 1 ||
|
||||
owned->profile.constructor_run_ranges[1].begin != checkpoint + 3 ||
|
||||
owned->profile.constructor_run_ranges[1].end_exclusive !=
|
||||
owned->profile.init_array_count ||
|
||||
owned->profile.native_mimalloc_constructor_run_range_count != 1 ||
|
||||
owned->profile.native_mimalloc_constructor_run_ranges[0].begin !=
|
||||
checkpoint ||
|
||||
owned->profile.native_mimalloc_constructor_run_ranges[0].end_exclusive !=
|
||||
owned->profile.init_array_count ||
|
||||
(kGuestElfMachine == EM_X86_64 && checkpoint != 2)) {
|
||||
// The verified constructor layout is the same on both guest ABIs. Payloads
|
||||
// differ in the order of the four leading constructors, and the guest ABIs
|
||||
// differ in the index the native replay starts at.
|
||||
// derivation_code_rvas holds three initializer anchors, then the anchors of
|
||||
// the four leading constructors in .init_array order.
|
||||
constexpr size_t kJniSingletonAnchor = 2;
|
||||
constexpr size_t kFirstConstructorAnchor = 3;
|
||||
constexpr size_t kConstructorAnchors = 4;
|
||||
size_t jni_singleton_constructor = owned->profile.init_array_count;
|
||||
for (size_t anchor = 0; anchor < kConstructorAnchors; ++anchor) {
|
||||
if (owned->derivation_code_rvas[kFirstConstructorAnchor + anchor] ==
|
||||
owned->derivation_code_rvas[kJniSingletonAnchor]) {
|
||||
jni_singleton_constructor =
|
||||
owned->profile.NativeMimallocConstructorRangeBegin() + anchor;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!owned->profile.HasVerifiedConstructorPolicy(jni_singleton_constructor) ||
|
||||
(kGuestElfMachine == EM_X86_64 &&
|
||||
owned->profile.NativeMimallocConstructorRangeBegin() != 2)) {
|
||||
*error =
|
||||
"external host ABI profile changed the verified constructor policy";
|
||||
return nullptr;
|
||||
|
||||
@@ -2000,7 +2000,11 @@ VKAPI_ATTR VkResult VKAPI_CALL vkCreateSwapchainKHR(
|
||||
}
|
||||
}
|
||||
const VkResult result =
|
||||
host_create(device, &host_info, allocator, swapchain);
|
||||
mocktail::graphics::CreateSwapchainWithPresentModeFallback(
|
||||
host_info, create_info->presentMode,
|
||||
[&](const VkSwapchainCreateInfoKHR& attempt) {
|
||||
return host_create(device, &attempt, allocator, swapchain);
|
||||
});
|
||||
if (result != VK_SUCCESS) {
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -222,14 +222,17 @@ void ReleaseJniReference(jobject obj) {
|
||||
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()) {
|
||||
if (ref_it->second > 1) {
|
||||
--ref_it->second;
|
||||
return;
|
||||
}
|
||||
g_jni_ref_counts.erase(ref_it);
|
||||
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()) {
|
||||
|
||||
@@ -5575,7 +5575,81 @@ std::shared_ptr<Class> VM::FindClass(const std::string& class_name) const {
|
||||
return it->second;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
using AnyFunction = void (*)();
|
||||
using ZeroWord = std::uintptr_t;
|
||||
// Float and double results come back in a vector register, not rax.
|
||||
using ZeroReal = double;
|
||||
|
||||
void ReportUnimplementedJniFunction(const char* name,
|
||||
std::atomic<bool>& reported) {
|
||||
if (!reported.exchange(true)) {
|
||||
std::fprintf(stderr, " [JNI] %s is not implemented; returning zero\n",
|
||||
name);
|
||||
}
|
||||
}
|
||||
|
||||
// Fills a slot with a function that ignores its arguments and returns zero.
|
||||
// The caller owns its argument registers and stack, so ignoring them is safe.
|
||||
// A void result ignores the value left in rax.
|
||||
#define MOCKTAIL_JNI_STUB(name, result) \
|
||||
table.name = reinterpret_cast<decltype(table.name)>( \
|
||||
reinterpret_cast<AnyFunction>(+[]() -> result { \
|
||||
static std::atomic<bool> reported{false}; \
|
||||
ReportUnimplementedJniFunction(#name, reported); \
|
||||
return result{}; \
|
||||
}))
|
||||
|
||||
#define MOCKTAIL_JNI_STUB_NONVIRTUAL(type, result) \
|
||||
MOCKTAIL_JNI_STUB(CallNonvirtual##type##Method, result); \
|
||||
MOCKTAIL_JNI_STUB(CallNonvirtual##type##MethodV, result); \
|
||||
MOCKTAIL_JNI_STUB(CallNonvirtual##type##MethodA, result)
|
||||
|
||||
#define MOCKTAIL_JNI_STUB_ARRAY(type) \
|
||||
MOCKTAIL_JNI_STUB(New##type##Array, ZeroWord); \
|
||||
MOCKTAIL_JNI_STUB(Get##type##ArrayElements, ZeroWord); \
|
||||
MOCKTAIL_JNI_STUB(Release##type##ArrayElements, ZeroWord); \
|
||||
MOCKTAIL_JNI_STUB(Get##type##ArrayRegion, ZeroWord); \
|
||||
MOCKTAIL_JNI_STUB(Set##type##ArrayRegion, ZeroWord)
|
||||
|
||||
// Installed before the real functions, which replace their stubs. A slot left
|
||||
// null would send the guest to address 0 when it calls the function.
|
||||
void InstallUnimplementedJniFunctions(JNINativeInterface_& table) {
|
||||
MOCKTAIL_JNI_STUB(DefineClass, ZeroWord);
|
||||
MOCKTAIL_JNI_STUB(GetModule, ZeroWord);
|
||||
MOCKTAIL_JNI_STUB(GetStringCritical, ZeroWord);
|
||||
MOCKTAIL_JNI_STUB(ReleaseStringCritical, ZeroWord);
|
||||
|
||||
MOCKTAIL_JNI_STUB_NONVIRTUAL(Object, ZeroWord);
|
||||
MOCKTAIL_JNI_STUB_NONVIRTUAL(Boolean, ZeroWord);
|
||||
MOCKTAIL_JNI_STUB_NONVIRTUAL(Byte, ZeroWord);
|
||||
MOCKTAIL_JNI_STUB_NONVIRTUAL(Char, ZeroWord);
|
||||
MOCKTAIL_JNI_STUB_NONVIRTUAL(Short, ZeroWord);
|
||||
MOCKTAIL_JNI_STUB_NONVIRTUAL(Int, ZeroWord);
|
||||
MOCKTAIL_JNI_STUB_NONVIRTUAL(Long, ZeroWord);
|
||||
MOCKTAIL_JNI_STUB_NONVIRTUAL(Float, ZeroReal);
|
||||
MOCKTAIL_JNI_STUB_NONVIRTUAL(Double, ZeroReal);
|
||||
MOCKTAIL_JNI_STUB_NONVIRTUAL(Void, ZeroWord);
|
||||
|
||||
MOCKTAIL_JNI_STUB_ARRAY(Boolean);
|
||||
MOCKTAIL_JNI_STUB_ARRAY(Char);
|
||||
MOCKTAIL_JNI_STUB_ARRAY(Short);
|
||||
MOCKTAIL_JNI_STUB_ARRAY(Int);
|
||||
MOCKTAIL_JNI_STUB_ARRAY(Long);
|
||||
MOCKTAIL_JNI_STUB_ARRAY(Double);
|
||||
MOCKTAIL_JNI_STUB(GetFloatArrayRegion, ZeroWord);
|
||||
}
|
||||
|
||||
#undef MOCKTAIL_JNI_STUB_ARRAY
|
||||
#undef MOCKTAIL_JNI_STUB_NONVIRTUAL
|
||||
#undef MOCKTAIL_JNI_STUB
|
||||
|
||||
} // namespace
|
||||
|
||||
void VM::InitJNIFunctionTables() {
|
||||
InstallUnimplementedJniFunctions(native_interface_);
|
||||
|
||||
invoke_interface_.AttachCurrentThread =
|
||||
[](JavaVM* vm, void** env, void* args) -> jint {
|
||||
if (JniVmTraceEnabled()) {
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
#include <sys/socket.h>
|
||||
#include <sys/time.h>
|
||||
#include <time.h>
|
||||
#include <execinfo.h>
|
||||
#include <ucontext.h>
|
||||
#include <elf.h>
|
||||
#include <fcntl.h>
|
||||
@@ -651,10 +650,10 @@ void ApplyRuntimeDefaults() {
|
||||
SetEnvDefault("MOCKTAIL_INIT_CLIENT_SETTINGS", "0");
|
||||
SetEnvDefault("MOCKTAIL_POST_CLIENT_SETTINGS", "0");
|
||||
SetEnvDefault("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
|
||||
// Current Roblox Android builds drive app startup through NativeGLInterface's
|
||||
// V2 app bridge path. The legacy NativeAppBridgeInterface entry point stays
|
||||
// available by opt-in, but running it in parallel can block V2 init.
|
||||
SetEnvDefault("MOCKTAIL_APP_BRIDGE_APP_START", "0");
|
||||
// nativeAppBridgeAppStart runs Roblox's StartupController, which builds the
|
||||
// experimentation protocol that writes ClientSettings/IxpSettings.json. It
|
||||
// runs inline before V2 init; on its own thread it can block V2 init.
|
||||
SetEnvDefault("MOCKTAIL_APP_BRIDGE_APP_START", "1");
|
||||
SetEnvDefault("MOCKTAIL_APP_BRIDGE_APP_START_THREAD", "0");
|
||||
SetEnvDefault("MOCKTAIL_START_LUA_APP_DM", "1");
|
||||
SetEnvDefault("MOCKTAIL_START_LUA_APP_DM_INLINE", "0");
|
||||
|
||||
@@ -135,6 +135,7 @@ bool IsBionicLibcOnlySymbol(std::string_view symbol) {
|
||||
return symbol == "__cmsg_nxthdr" || symbol == "__cxa_thread_atexit_impl" ||
|
||||
symbol == "__readlink_chk" || symbol == "__register_atfork" ||
|
||||
symbol == "arc4random_buf" || symbol == "geteuid" ||
|
||||
(!mocktail::compat::kHostAtoiMatchesBionic && symbol == "atoi") ||
|
||||
symbol == "getuid" || symbol == "lseek64" ||
|
||||
symbol == "mallinfo" || symbol == "pread64" || symbol == "pwrite64" ||
|
||||
symbol == "sigaction" || symbol == "strerror_r" ||
|
||||
@@ -384,6 +385,10 @@ void RegisterBionicHostLibcRuntimeForLibc() {
|
||||
RegisterSyntheticSymbol("libc.so", "arc4random_buf",
|
||||
reinterpret_cast<void*>(
|
||||
mocktail_bionic_arc4random_buf));
|
||||
if (!mocktail::compat::kHostAtoiMatchesBionic) {
|
||||
RegisterSyntheticSymbol("libc.so", "atoi",
|
||||
reinterpret_cast<void*>(mocktail_bionic_atoi));
|
||||
}
|
||||
RegisterSyntheticSymbol("libc.so", "mallinfo",
|
||||
reinterpret_cast<void*>(mocktail_bionic_mallinfo));
|
||||
RegisterSyntheticSymbol("libc.so", "sysinfo",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <sys/eventfd.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/time.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <unistd.h>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail {
|
||||
@@ -45,11 +46,9 @@ constexpr const char* kForeignArchitectures[] = {
|
||||
};
|
||||
#endif
|
||||
|
||||
struct HostGpus {
|
||||
bool intel = false;
|
||||
bool nvidia = false;
|
||||
bool amd = false;
|
||||
};
|
||||
constexpr unsigned int kPciVendorAmd = 0x1002;
|
||||
constexpr unsigned int kPciVendorNvidia = 0x10de;
|
||||
constexpr unsigned int kPciVendorIntel = 0x8086;
|
||||
|
||||
bool ForeignArchitecture(const std::string& name) {
|
||||
for (const char* architecture : kForeignArchitectures) {
|
||||
@@ -110,75 +109,86 @@ bool ParsePciVendor(const std::string& raw, unsigned int* vendor) {
|
||||
return true;
|
||||
}
|
||||
|
||||
HostGpus DetectHostGpus() {
|
||||
HostGpus gpus;
|
||||
std::vector<HostGpu> DetectHostGpus() {
|
||||
std::vector<HostGpu> gpus;
|
||||
for (int index = 0; index < 16; ++index) {
|
||||
const std::string path = "/sys/class/drm/card" + std::to_string(index) +
|
||||
"/device/vendor";
|
||||
std::ifstream input(path);
|
||||
const std::string device =
|
||||
"/sys/class/drm/card" + std::to_string(index) + "/device";
|
||||
std::ifstream input(device + "/vendor");
|
||||
std::string raw;
|
||||
unsigned int vendor = 0;
|
||||
if (!(input >> raw) || !ParsePciVendor(raw, &vendor)) {
|
||||
HostGpu gpu;
|
||||
if (!(input >> raw) || !ParsePciVendor(raw, &gpu.vendor)) {
|
||||
continue;
|
||||
}
|
||||
if (vendor == 0x8086) {
|
||||
gpus.intel = true;
|
||||
} else if (vendor == 0x10de) {
|
||||
gpus.nvidia = true;
|
||||
} else if (vendor == 0x1002) {
|
||||
gpus.amd = true;
|
||||
std::error_code error;
|
||||
const std::filesystem::path driver =
|
||||
std::filesystem::read_symlink(device + "/driver", error);
|
||||
if (!error) {
|
||||
gpu.kernel_driver = driver.filename().string();
|
||||
}
|
||||
gpus.push_back(std::move(gpu));
|
||||
}
|
||||
return gpus;
|
||||
}
|
||||
|
||||
std::string FindIcdFile(const char* filename_needle) {
|
||||
if (filename_needle == nullptr || filename_needle[0] == '\0') {
|
||||
return {};
|
||||
}
|
||||
std::vector<std::filesystem::path> directories;
|
||||
for (const char* directory : kIcdDirectories) {
|
||||
directories.emplace_back(directory);
|
||||
}
|
||||
return SelectVulkanIcdManifest(directories, filename_needle);
|
||||
bool HasVendor(const std::vector<HostGpu>& gpus, unsigned int vendor) {
|
||||
return std::any_of(gpus.begin(), gpus.end(), [vendor](const HostGpu& gpu) {
|
||||
return gpu.vendor == vendor;
|
||||
});
|
||||
}
|
||||
|
||||
std::string SelectHardwareIcd(const HostGpus& gpus) {
|
||||
const bool prefer_discrete = !EnvIsOff("DRI_PRIME") &&
|
||||
!EnvIsOff("__NV_PRIME_RENDER_OFFLOAD");
|
||||
if (prefer_discrete && gpus.nvidia) {
|
||||
std::string nvidia = FindIcdFile("nvidia_icd");
|
||||
if (nvidia.empty()) {
|
||||
nvidia = FindIcdFile("nouveau_icd");
|
||||
}
|
||||
if (!nvidia.empty()) {
|
||||
return nvidia;
|
||||
}
|
||||
const char* VendorName(unsigned int vendor) {
|
||||
switch (vendor) {
|
||||
case kPciVendorAmd:
|
||||
return "AMD";
|
||||
case kPciVendorNvidia:
|
||||
return "NVIDIA";
|
||||
case kPciVendorIntel:
|
||||
return "Intel";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
if (prefer_discrete && gpus.amd) {
|
||||
const std::string amd = FindIcdFile("radeon_icd");
|
||||
if (!amd.empty()) {
|
||||
return amd;
|
||||
}
|
||||
}
|
||||
|
||||
// ICD manifest names that can drive `gpu`, best first. Empty when the bound
|
||||
// kernel driver has no Vulkan driver at all.
|
||||
std::vector<const char*> VulkanIcdsFor(const HostGpu& gpu) {
|
||||
switch (gpu.vendor) {
|
||||
case kPciVendorNvidia:
|
||||
if (gpu.kernel_driver == "nvidia") {
|
||||
return {"nvidia_icd"};
|
||||
}
|
||||
if (gpu.kernel_driver == "nouveau") {
|
||||
return {"nouveau_icd"};
|
||||
}
|
||||
return {"nvidia_icd", "nouveau_icd"};
|
||||
case kPciVendorAmd:
|
||||
// RADV needs the amdgpu kernel driver.
|
||||
if (gpu.kernel_driver == "radeon") {
|
||||
return {};
|
||||
}
|
||||
return {"radeon_icd"};
|
||||
case kPciVendorIntel:
|
||||
return {"intel_icd", "intel_hasvk_icd"};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
if (gpus.intel) {
|
||||
std::string intel = FindIcdFile("intel_icd");
|
||||
if (intel.empty()) {
|
||||
intel = FindIcdFile("intel_hasvk_icd");
|
||||
}
|
||||
return intel;
|
||||
}
|
||||
|
||||
std::string SkipReason(const HostGpu& gpu,
|
||||
const std::vector<const char*>& icds) {
|
||||
std::string reason = std::string(VendorName(gpu.vendor)) + " card";
|
||||
if (!gpu.kernel_driver.empty()) {
|
||||
reason += " on the \"" + gpu.kernel_driver + "\" kernel driver";
|
||||
}
|
||||
if (gpus.nvidia) {
|
||||
std::string nvidia = FindIcdFile("nvidia_icd");
|
||||
if (nvidia.empty()) {
|
||||
nvidia = FindIcdFile("nouveau_icd");
|
||||
}
|
||||
return nvidia;
|
||||
if (icds.empty()) {
|
||||
return reason + " has no Vulkan support";
|
||||
}
|
||||
if (gpus.amd) {
|
||||
return FindIcdFile("radeon_icd");
|
||||
reason += " has no installed Vulkan driver (looked for";
|
||||
for (const char* icd : icds) {
|
||||
reason += std::string(" ") + icd;
|
||||
}
|
||||
return {};
|
||||
return reason + ")";
|
||||
}
|
||||
|
||||
// Match Mesa ANV: 75% of RAM when the machine has more than 4GiB, else 50%.
|
||||
@@ -197,7 +207,8 @@ const char* AnvSysMemLimitPercent() {
|
||||
return "50";
|
||||
}
|
||||
|
||||
bool ApplyVulkanIcdPolicy(const HostGpus& gpus, std::string* error) {
|
||||
bool ApplyVulkanIcdPolicy(const std::vector<HostGpu>& gpus,
|
||||
std::string* error) {
|
||||
// Drop software/emulation ICDs even when the user already pinned a driver
|
||||
// list. Old loaders ignore this variable.
|
||||
if (!SetDefault("VK_LOADER_DRIVERS_DISABLE",
|
||||
@@ -210,17 +221,61 @@ bool ApplyVulkanIcdPolicy(const HostGpus& gpus, std::string* error) {
|
||||
(existing_icds != nullptr && existing_icds[0] != '\0')) {
|
||||
return true;
|
||||
}
|
||||
const std::string icd = SelectHardwareIcd(gpus);
|
||||
if (icd.empty()) {
|
||||
std::vector<std::filesystem::path> directories;
|
||||
for (const char* directory : kIcdDirectories) {
|
||||
directories.emplace_back(directory);
|
||||
}
|
||||
const bool prefer_discrete = !EnvIsOff("DRI_PRIME") &&
|
||||
!EnvIsOff("__NV_PRIME_RENDER_OFFLOAD");
|
||||
const VulkanIcdSelection selection =
|
||||
SelectHostVulkanIcd(gpus, directories, prefer_discrete);
|
||||
for (const std::string& reason : selection.skipped) {
|
||||
std::fprintf(stderr, " [runtime] vulkan: %s\n", reason.c_str());
|
||||
}
|
||||
if (selection.manifest.empty()) {
|
||||
if (!selection.skipped.empty()) {
|
||||
std::fprintf(stderr,
|
||||
" [runtime] vulkan: no GPU here can run Vulkan; set "
|
||||
"graphics.backend: opengl in the config\n");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
std::fprintf(stderr, " [runtime] vulkan ICD=%s\n", icd.c_str());
|
||||
return SetDefault("VK_DRIVER_FILES", icd, error) &&
|
||||
SetDefault("VK_ICD_FILENAMES", icd, error);
|
||||
std::fprintf(stderr, " [runtime] vulkan ICD=%s\n",
|
||||
selection.manifest.c_str());
|
||||
return SetDefault("VK_DRIVER_FILES", selection.manifest, error) &&
|
||||
SetDefault("VK_ICD_FILENAMES", selection.manifest, error);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
VulkanIcdSelection SelectHostVulkanIcd(
|
||||
const std::vector<HostGpu>& gpus,
|
||||
const std::vector<std::filesystem::path>& directories,
|
||||
bool prefer_discrete) {
|
||||
constexpr unsigned int kDiscreteFirst[] = {kPciVendorNvidia, kPciVendorAmd,
|
||||
kPciVendorIntel};
|
||||
constexpr unsigned int kIntegratedFirst[] = {
|
||||
kPciVendorIntel, kPciVendorNvidia, kPciVendorAmd};
|
||||
VulkanIcdSelection selection;
|
||||
for (const unsigned int vendor :
|
||||
prefer_discrete ? kDiscreteFirst : kIntegratedFirst) {
|
||||
for (const HostGpu& gpu : gpus) {
|
||||
if (gpu.vendor != vendor) {
|
||||
continue;
|
||||
}
|
||||
const std::vector<const char*> icds = VulkanIcdsFor(gpu);
|
||||
for (const char* icd : icds) {
|
||||
selection.manifest = SelectVulkanIcdManifest(directories, icd);
|
||||
if (!selection.manifest.empty()) {
|
||||
return selection;
|
||||
}
|
||||
}
|
||||
selection.skipped.push_back(SkipReason(gpu, icds));
|
||||
}
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
std::string SelectVulkanIcdManifest(
|
||||
const std::vector<std::filesystem::path>& directories,
|
||||
std::string_view vendor) {
|
||||
@@ -291,7 +346,7 @@ bool ApplyGraphicsLaunchPolicy(const RuntimeConfig& config,
|
||||
if (direct_vulkan) {
|
||||
const char* wsi_mode =
|
||||
UnthrottledPresentation(config) ? "immediate" : "mailbox";
|
||||
const HostGpus gpus = DetectHostGpus();
|
||||
const std::vector<HostGpu> gpus = DetectHostGpus();
|
||||
if (!SetDefault("MOCKTAIL_CLIENT_SETTINGS_OVERRIDES_JSON",
|
||||
kVulkanClientSettingsOverrides, error) ||
|
||||
!SetDefault("ANV_SYS_MEM_LIMIT", AnvSysMemLimitPercent(), error) ||
|
||||
@@ -304,7 +359,9 @@ bool ApplyGraphicsLaunchPolicy(const RuntimeConfig& config,
|
||||
}
|
||||
// Low FRM only on Intel-only machines. Hybrid NVIDIA/AMD laptops should
|
||||
// keep the desktop quality default on the discrete GPU.
|
||||
if (gpus.intel && !gpus.nvidia && !gpus.amd &&
|
||||
if (HasVendor(gpus, kPciVendorIntel) &&
|
||||
!HasVendor(gpus, kPciVendorNvidia) &&
|
||||
!HasVendor(gpus, kPciVendorAmd) &&
|
||||
!SetDefault("MOCKTAIL_GRAPHICS_QUALITY", "1", error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -243,7 +243,9 @@ bool MergePerformanceClientSettingsOverrides(
|
||||
|
||||
// ForceCacheSize settings are byte counts. Let Roblox size its mesh and
|
||||
// SLIM content caches; values like 256/128 would cap them to a few bytes.
|
||||
const std::array<ClientSetting, 68> rendering_settings = {{
|
||||
// Leave MSAA selection to Roblox or explicit client-settings overrides.
|
||||
// Forcing one sample disables anti-aliasing even at maximum quality.
|
||||
const std::array<ClientSetting, 67> rendering_settings = {{
|
||||
{"FIntSmoothClusterTaskQueueMaxParallelTasks", workers},
|
||||
{"FIntOcclusionWorkerThreadCount", occlusion_workers},
|
||||
{"FFlagMovePrerenderV2", "True"},
|
||||
@@ -300,7 +302,6 @@ bool MergePerformanceClientSettingsOverrides(
|
||||
{"FFlagRenderAllocateShadowMapResourcesOnDemand", "True"},
|
||||
{"FIntRenderShadowMapDepthCacheMemLimit", "16"},
|
||||
{"FIntTM2ShadowMapMaxMips", "1"},
|
||||
{"FIntDebugForceMSAASamples", "1"},
|
||||
{"FFlagLuauStartupGcSuppression", "False"},
|
||||
{"FIntLuauGcStepMul", "300"},
|
||||
{"FIntLuauGcGoalCore", "120"},
|
||||
|
||||
@@ -361,6 +361,10 @@ bool MergeDesktopAppPolicyClientSettingsOverride(std::string_view policy_json,
|
||||
// leave the whole LuaApp blank. Default to the working legacy page while
|
||||
// preserving explicit user overrides for testing newer UI patches.
|
||||
overrides.emplace("FFlagLuaAppChartsAppPage", "False");
|
||||
// Roblox enrolls desktop clients in the flyout side panel through a
|
||||
// server-side experiment that does not reach this client. Variant 2 is the
|
||||
// desktop side panel; an explicit user override is preserved.
|
||||
overrides.emplace("FIntFlyoutTestVariantRollout12", "2");
|
||||
*merged_json = overrides.dump();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -125,6 +125,16 @@ RobloxPlatformWebSymbols ResolveRobloxPlatformWebSymbols(
|
||||
roblox_library,
|
||||
"Java_com_roblox_universalapp_cookie_JNICookieProtocol_"
|
||||
"updateOnSetCookieHandler");
|
||||
symbols.web_view.send_app_event_on_game_loaded =
|
||||
Resolve<SendRobloxAppEventOnGameLoadedFn>(
|
||||
roblox_library,
|
||||
"Java_com_roblox_engine_jni_NativeGLInterface_"
|
||||
"nativeAppBridgeV2SendAppEventOnGameLoaded");
|
||||
symbols.web_view.send_app_event_on_app_ready =
|
||||
Resolve<SendRobloxAppEventOnAppReadyFn>(
|
||||
roblox_library,
|
||||
"Java_com_roblox_engine_jni_NativeGLInterface_"
|
||||
"nativeAppBridgeV2SendAppEventOnAppReady");
|
||||
|
||||
symbols.browser_service.bind = Resolve<BindRobloxMemStorageFn>(
|
||||
roblox_library, "Java_com_roblox_engine_jni_memstorage_MemStorage_bind");
|
||||
|
||||
@@ -36,6 +36,8 @@ constexpr char kNativeGlInterfaceClass[] =
|
||||
"com/roblox/engine/jni/NativeGLInterface";
|
||||
constexpr char kConnectionPointerField[] = "a";
|
||||
constexpr std::size_t kMaximumPendingHostWindowEvents = 256;
|
||||
// Digits of the largest signed 64-bit user id.
|
||||
constexpr std::size_t kMaximumRobloxUserIdDigits = 19;
|
||||
constexpr std::string_view kBrowserLoginUrl = "https://www.roblox.com/login";
|
||||
|
||||
bool EqualsIgnoreCase(std::string_view a, std::string_view b) {
|
||||
@@ -96,6 +98,21 @@ bool IsLoginChallengeUrl(std::string_view url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A positive decimal that fits a signed 64-bit integer, without sign, spaces
|
||||
// or leading zero.
|
||||
bool IsRobloxUserId(std::string_view value) {
|
||||
constexpr std::string_view kLargest = "9223372036854775807";
|
||||
if (value.empty() || value.size() > kLargest.size() || value[0] == '0') {
|
||||
return false;
|
||||
}
|
||||
for (const char digit : value) {
|
||||
if (digit < '0' || digit > '9') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return value.size() < kLargest.size() || value <= kLargest;
|
||||
}
|
||||
|
||||
Status Invalid(std::string message) {
|
||||
return Status::Error(StatusCode::kInvalidArgument, std::move(message));
|
||||
}
|
||||
@@ -1355,7 +1372,20 @@ void RobloxWebViewBridge::QueueHostWindowEvent(HostWindowEventType type,
|
||||
" [webview] pending host event queue overflow; preserving "
|
||||
"newest lifecycle state\n");
|
||||
}
|
||||
pending_host_window_events_.push_back({type, generation});
|
||||
pending_host_window_events_.push_back({type, generation, {}});
|
||||
}
|
||||
|
||||
void RobloxWebViewBridge::QueueStartConversation(std::string user_id) {
|
||||
std::lock_guard<std::mutex> lock(host_window_event_mutex_);
|
||||
if (pending_host_window_events_.size() == kMaximumPendingHostWindowEvents) {
|
||||
pending_host_window_events_.pop_front();
|
||||
std::fprintf(stderr,
|
||||
" [webview] pending host event queue overflow; preserving "
|
||||
"newest lifecycle state\n");
|
||||
}
|
||||
pending_host_window_events_.push_back(
|
||||
{HostWindowEventType::kStartConversationWithUser, 0,
|
||||
std::move(user_id)});
|
||||
}
|
||||
|
||||
Status RobloxWebViewBridge::DrainHostWindowEvents() {
|
||||
@@ -1418,8 +1448,24 @@ Status RobloxWebViewBridge::DrainHostWindowEvents() {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case HostWindowEventType::kStartConversationWithUser: {
|
||||
// A failed send is dropped, not retried: the click that asked for
|
||||
// the conversation is stale by the next drain.
|
||||
const Status sent = SendStartConversationEvents(env, event.user_id);
|
||||
if (sent.ok()) {
|
||||
std::fprintf(stderr,
|
||||
" [app-shell] Chat opened with a conversation "
|
||||
"request\n");
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
" [app-shell] conversation request failed: %s\n",
|
||||
sent.message().c_str());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (status.ok() && event.type != HostWindowEventType::kDataModelUnfocused) {
|
||||
if (status.ok() && event.type != HostWindowEventType::kDataModelUnfocused &&
|
||||
event.type != HostWindowEventType::kStartConversationWithUser) {
|
||||
const std::shared_ptr<HostWindowCloseTarget> target =
|
||||
host_window_close_target_;
|
||||
if (target != nullptr) {
|
||||
@@ -1472,6 +1518,28 @@ Status RobloxWebViewBridge::HandleDataModelNotification(JNIEnv* env,
|
||||
OpenSource::kDataModelNotification);
|
||||
}
|
||||
|
||||
if (owned_type == kRobloxLaunchConversationNotification) {
|
||||
std::string user_id;
|
||||
status = CopyJniString(env, data, kMaximumRobloxUserIdDigits,
|
||||
"conversation user id", &user_id);
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
}
|
||||
if (!IsRobloxUserId(user_id)) {
|
||||
return Invalid("LAUNCH_CONVERSATION payload is not a user id");
|
||||
}
|
||||
if (symbols_.send_app_event_on_game_loaded == nullptr ||
|
||||
symbols_.send_app_event_on_app_ready == nullptr) {
|
||||
return Unavailable("Roblox app event entrypoints are unavailable");
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
" [app-shell] LAUNCH_CONVERSATION notification received\n");
|
||||
// The engine is inside its notification callback. The reply is sent from
|
||||
// the next host event drain, as Android posts it to its main thread.
|
||||
QueueStartConversation(std::move(user_id));
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
RobloxWebViewOpenRequest request;
|
||||
if (owned_type == kRobloxViewProfileNotification) {
|
||||
std::string owned_url;
|
||||
@@ -1652,6 +1720,53 @@ Status RobloxWebViewBridge::BroadcastDataModelFocus(JNIEnv *env,
|
||||
return CheckJniException(env, "broadcast AppInput DataModel focus event");
|
||||
}
|
||||
|
||||
// Mirrors the Android app shell's reply to LAUNCH_CONVERSATION: switch the
|
||||
// Lua app to its Chat destination, then ask Chat to start the conversation
|
||||
// once that app reports ready.
|
||||
Status RobloxWebViewBridge::SendStartConversationEvents(
|
||||
JNIEnv* env, const std::string& user_id) {
|
||||
if (env == nullptr || symbols_.send_app_event_on_game_loaded == nullptr ||
|
||||
symbols_.send_app_event_on_app_ready == nullptr) {
|
||||
return Unavailable("Roblox app event entrypoints are unavailable");
|
||||
}
|
||||
jclass native_gl_class = env->FindClass(kNativeGlInterfaceClass);
|
||||
const std::array<jstring, 7> strings = {
|
||||
env->NewStringUTF("Navigations"),
|
||||
env->NewStringUTF(R"({"appName":"Chat"})"),
|
||||
env->NewStringUTF("Destination"),
|
||||
env->NewStringUTF("AppShellNotifications"),
|
||||
env->NewStringUTF(user_id.c_str()),
|
||||
env->NewStringUTF("StartConversationWithUserId"),
|
||||
env->NewStringUTF("Chat"),
|
||||
};
|
||||
bool allocated = native_gl_class != nullptr;
|
||||
for (jstring value : strings) {
|
||||
allocated = allocated && value != nullptr;
|
||||
}
|
||||
Status status = allocated
|
||||
? Status::Ok()
|
||||
: Unavailable("could not allocate conversation events");
|
||||
if (status.ok()) {
|
||||
symbols_.send_app_event_on_game_loaded(env, native_gl_class, strings[0],
|
||||
strings[1], strings[2]);
|
||||
status = CheckJniException(env, "send Chat destination app event");
|
||||
}
|
||||
if (status.ok()) {
|
||||
symbols_.send_app_event_on_app_ready(env, native_gl_class, strings[3],
|
||||
strings[4], strings[5], strings[6]);
|
||||
status = CheckJniException(env, "send start conversation app event");
|
||||
}
|
||||
for (jstring value : strings) {
|
||||
if (value != nullptr) {
|
||||
env->DeleteLocalRef(value);
|
||||
}
|
||||
}
|
||||
if (native_gl_class != nullptr) {
|
||||
env->DeleteLocalRef(native_gl_class);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
Status RobloxWebViewBridge::BeginDispatch() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if ((!initializing_ && !initialized_) || !accepting_messages_) {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -707,6 +708,28 @@ std::string SessionLog::Header(const Environment& environment,
|
||||
return output.str();
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::chrono::seconds kLoggerExitTimeout(2);
|
||||
|
||||
// The writer exits once every copy of the pipe write ends is closed, and a
|
||||
// child process can hold one open. Past the timeout it is killed so that
|
||||
// shutdown cannot hang.
|
||||
void ReapLogger(pid_t child) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + kLoggerExitTimeout;
|
||||
for (;;) {
|
||||
const pid_t reaped = waitpid(child, nullptr, WNOHANG);
|
||||
if (reaped == child || (reaped < 0 && errno != EINTR)) return;
|
||||
if (std::chrono::steady_clock::now() >= deadline) break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
(void)kill(child, SIGKILL);
|
||||
while (waitpid(child, nullptr, 0) < 0 && errno == EINTR) {
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void SessionLog::Stop() {
|
||||
if (!active_) return;
|
||||
std::cout.flush();
|
||||
@@ -718,9 +741,7 @@ void SessionLog::Stop() {
|
||||
close(original_stderr_);
|
||||
original_stdout_ = -1;
|
||||
original_stderr_ = -1;
|
||||
const pid_t child = static_cast<pid_t>(logger_process_);
|
||||
while (waitpid(child, nullptr, 0) < 0 && errno == EINTR) {
|
||||
}
|
||||
ReapLogger(static_cast<pid_t>(logger_process_));
|
||||
logger_process_ = -1;
|
||||
active_ = false;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,14 @@ bool ReadStorage(const std::filesystem::path& path, nlohmann::json* storage,
|
||||
*storage = nlohmann::json::object();
|
||||
return !filesystem_error;
|
||||
}
|
||||
// The storage directory may have been writable by other users, so a file
|
||||
// another user owns, a symlink, or a second hard link is not trusted.
|
||||
struct stat metadata = {};
|
||||
if (lstat(path.c_str(), &metadata) != 0 || !S_ISREG(metadata.st_mode) ||
|
||||
metadata.st_uid != geteuid() || metadata.st_nlink != 1) {
|
||||
*error = "appStorage is not a private regular file";
|
||||
return false;
|
||||
}
|
||||
const auto size = std::filesystem::file_size(path, filesystem_error);
|
||||
if (filesystem_error || size > kMaximumStorageBytes) {
|
||||
*error = "appStorage is unavailable or exceeds the size limit";
|
||||
@@ -193,6 +201,23 @@ bool IsPrivateDirectory(const struct stat& metadata) {
|
||||
(metadata.st_mode & (S_IWGRP | S_IWOTH)) == 0;
|
||||
}
|
||||
|
||||
// Removes group and world write access from a directory this user owns.
|
||||
// Roblox creates its storage directories with the process umask, which is 002
|
||||
// on many desktops. A directory owned by another user is left untouched.
|
||||
void TightenOwnedDirectory(const std::filesystem::path& directory) {
|
||||
const int descriptor = open(directory.c_str(),
|
||||
O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW);
|
||||
if (descriptor < 0) return;
|
||||
struct stat metadata = {};
|
||||
if (fstat(descriptor, &metadata) == 0 && S_ISDIR(metadata.st_mode) &&
|
||||
metadata.st_uid == geteuid() &&
|
||||
(metadata.st_mode & (S_IWGRP | S_IWOTH)) != 0) {
|
||||
(void)fchmod(descriptor,
|
||||
(metadata.st_mode & 07777) & ~(S_IWGRP | S_IWOTH));
|
||||
}
|
||||
close(descriptor);
|
||||
}
|
||||
|
||||
bool FindExistingDirectoryAncestor(const std::filesystem::path& directory,
|
||||
std::filesystem::path* ancestor) {
|
||||
if (directory.empty() || ancestor == nullptr) return false;
|
||||
@@ -491,6 +516,7 @@ BrowserTrackerResult BrowserTrackerService::EnsureInitialized(
|
||||
return result;
|
||||
}
|
||||
const bool lock_parent_existed = existing_lock_ancestor == lock_parent;
|
||||
if (lock_parent_existed) TightenOwnedDirectory(lock_parent);
|
||||
const bool lock_parent_created =
|
||||
CreatePrivateDirectories(lock_parent, existing_lock_ancestor);
|
||||
struct stat lock_parent_status = {};
|
||||
@@ -537,10 +563,14 @@ BrowserTrackerResult BrowserTrackerService::EnsureInitialized(
|
||||
|
||||
const auto existing = storage.find("BrowserTrackerId");
|
||||
std::string stored_id;
|
||||
// Roblox stores "0" when it starts without a tracker identity.
|
||||
const bool stored_unset =
|
||||
existing != storage.end() && existing->is_string() &&
|
||||
existing->get_ref<const std::string&>() == "0";
|
||||
if (existing != storage.end() && existing->is_string() &&
|
||||
IsValidId(existing->get_ref<const std::string&>())) {
|
||||
stored_id = existing->get<std::string>();
|
||||
} else if (existing != storage.end()) {
|
||||
} else if (existing != storage.end() && !stored_unset) {
|
||||
result.error = "appStorage BrowserTrackerId is invalid";
|
||||
close(lock);
|
||||
return result;
|
||||
|
||||
+440
-66
@@ -887,6 +887,10 @@ struct SignatureSpec {
|
||||
bool registry_size_may_change = false;
|
||||
std::size_t minimum_anchor_bytes = 6;
|
||||
std::array<std::size_t, 2> registry_size_indices = {8, 12};
|
||||
// Instructions whose register operands the compiler may have chosen
|
||||
// differently. Each reference register must stand for one candidate
|
||||
// register across all of them, and the reverse.
|
||||
std::vector<std::size_t> renamed_register_indices = {};
|
||||
};
|
||||
|
||||
struct SignatureMatch {
|
||||
@@ -895,10 +899,46 @@ struct SignatureMatch {
|
||||
std::vector<Instruction> candidate;
|
||||
};
|
||||
|
||||
// The 64-bit register that `name` is all or part of, such as rax for eax.
|
||||
std::string RegisterFamily(std::string_view name) {
|
||||
if (name.size() >= 2 && name[0] == 'r' && name[1] >= '0' && name[1] <= '9') {
|
||||
while (!name.empty() && (name.back() < '0' || name.back() > '9')) {
|
||||
name.remove_suffix(1);
|
||||
}
|
||||
return std::string(name);
|
||||
}
|
||||
for (const std::string_view family :
|
||||
{"ax", "bx", "cx", "dx", "si", "di", "bp", "sp"}) {
|
||||
if (name == family || (name.size() == 3 && name.substr(1) == family &&
|
||||
(name[0] == 'e' || name[0] == 'r'))) {
|
||||
return "r" + std::string(family);
|
||||
}
|
||||
}
|
||||
return std::string(name);
|
||||
}
|
||||
|
||||
// Reference register families and the candidate families that stand for them.
|
||||
struct RegisterRenames {
|
||||
std::map<std::string, std::string> forward;
|
||||
std::map<std::string, std::string> backward;
|
||||
|
||||
bool Accept(std::string_view reference, std::string_view candidate) {
|
||||
const std::string left = RegisterFamily(reference);
|
||||
const std::string right = RegisterFamily(candidate);
|
||||
const auto forward_found = forward.emplace(left, right).first;
|
||||
const auto backward_found = backward.emplace(right, left).first;
|
||||
return forward_found->second == right && backward_found->second == left;
|
||||
}
|
||||
};
|
||||
|
||||
bool SameSemanticOperand(const Operand& left, const Operand& right,
|
||||
bool control_flow, bool wildcard_size) {
|
||||
bool control_flow, bool wildcard_size,
|
||||
RegisterRenames* renames = nullptr) {
|
||||
if (left.type != right.type || left.size != right.size) return false;
|
||||
if (left.type == Operand::Type::kRegister) {
|
||||
if (renames != nullptr) {
|
||||
return renames->Accept(left.register_name, right.register_name);
|
||||
}
|
||||
return left.register_name == right.register_name;
|
||||
}
|
||||
if (left.type == Operand::Type::kImmediate) {
|
||||
@@ -918,6 +958,7 @@ bool ValidateSemantics(const std::vector<Instruction>& reference,
|
||||
*error = "normalized signature instruction count changed";
|
||||
return false;
|
||||
}
|
||||
RegisterRenames renames;
|
||||
for (std::size_t index = 0; index < reference.size(); ++index) {
|
||||
const Instruction& left = reference[index];
|
||||
const Instruction& right = candidate[index];
|
||||
@@ -925,6 +966,10 @@ bool ValidateSemantics(const std::vector<Instruction>& reference,
|
||||
spec.registry_size_may_change &&
|
||||
(index == spec.registry_size_indices[0] ||
|
||||
index == spec.registry_size_indices[1]);
|
||||
const bool renamed =
|
||||
std::find(spec.renamed_register_indices.begin(),
|
||||
spec.renamed_register_indices.end(),
|
||||
index) != spec.renamed_register_indices.end();
|
||||
if (left.mnemonic != right.mnemonic ||
|
||||
left.operands.size() != right.operands.size()) {
|
||||
*error = "normalized instruction semantics changed at index " +
|
||||
@@ -933,7 +978,8 @@ bool ValidateSemantics(const std::vector<Instruction>& reference,
|
||||
}
|
||||
for (std::size_t operand = 0; operand < left.operands.size(); ++operand) {
|
||||
if (!SameSemanticOperand(left.operands[operand], right.operands[operand],
|
||||
left.control_flow, wildcard)) {
|
||||
left.control_flow, wildcard,
|
||||
renamed ? &renames : nullptr)) {
|
||||
*error = "normalized instruction operand changed at index " +
|
||||
std::to_string(index);
|
||||
return false;
|
||||
@@ -1569,6 +1615,74 @@ std::optional<std::uint64_t> FindRegistryFunctionEntry(
|
||||
return entries.front();
|
||||
}
|
||||
|
||||
// Finds the registry initializer body when a rebuild holds its temporaries in
|
||||
// other registers, which changes the bytes the exact search needs.
|
||||
//
|
||||
// The body allocates the registry, clears it, and starts filling it:
|
||||
//
|
||||
// 0 mov edi, SIZE 6 xor esi, esi
|
||||
// 1 call allocate 7 call memset
|
||||
// 2 mov rbx, rax 8 mov ONE, 0x3f800000
|
||||
// 3 xor ZERO, ZERO 9 mov dword ptr [rbx + 0x20], ONE
|
||||
// 4 mov edx, SIZE 10 lea FIELD, [rbx + 0x28]
|
||||
// 5 mov rdi, rax
|
||||
//
|
||||
// ZERO, ONE and FIELD are the compiler's choice. Instructions 4 to 7 use
|
||||
// registers the calling convention fixes, so they are searched for by bytes
|
||||
// and every hit is then checked against all eleven instructions.
|
||||
std::optional<SignatureMatch> FindRenamedRegistryBodyMatch(
|
||||
const ElfImage& reference, const ElfImage& candidate,
|
||||
const Disassembler& disassembler,
|
||||
const std::vector<Instruction>& reference_body,
|
||||
std::size_t minimum_anchor_bytes, std::string* error) {
|
||||
constexpr std::size_t kClearIndex = 4;
|
||||
constexpr std::size_t kClearInstructionCount = 4;
|
||||
const SignatureSpec clear_spec{
|
||||
"registry-initializer-clear",
|
||||
reference_body[kClearIndex].address,
|
||||
kClearInstructionCount,
|
||||
true,
|
||||
minimum_anchor_bytes,
|
||||
{0, 0},
|
||||
};
|
||||
const auto clear_matches = FindSignatureMatches(
|
||||
reference, candidate, disassembler, clear_spec, true, error);
|
||||
if (!clear_matches.has_value()) return std::nullopt;
|
||||
SignatureSpec body_spec{
|
||||
"registry-initializer-body",
|
||||
reference_body.front().address,
|
||||
reference_body.size(),
|
||||
true,
|
||||
minimum_anchor_bytes,
|
||||
{0, 4},
|
||||
};
|
||||
body_spec.renamed_register_indices = {3, 8, 9, 10};
|
||||
// Instructions 0 to 3 keep their lengths as long as ZERO stays one of r8
|
||||
// to r15, so the body starts this far before the clear sequence.
|
||||
const std::uint64_t lead =
|
||||
reference_body[kClearIndex].address - reference_body.front().address;
|
||||
std::vector<SignatureMatch> found;
|
||||
for (const SignatureMatch& clear : *clear_matches) {
|
||||
if (clear.rva < lead) continue;
|
||||
std::string ignored;
|
||||
const auto body = disassembler.Decode(candidate, clear.rva - lead,
|
||||
reference_body.size(), 4096,
|
||||
&ignored);
|
||||
if (!body.has_value() || (*body)[kClearIndex].address != clear.rva ||
|
||||
!ValidateSemantics(reference_body, *body, body_spec, &ignored)) {
|
||||
continue;
|
||||
}
|
||||
found.push_back(SignatureMatch{clear.rva - lead, reference_body, *body});
|
||||
}
|
||||
if (found.size() != 1U) {
|
||||
*error = "signature registry-initializer-body matched " +
|
||||
std::to_string(found.size()) +
|
||||
" candidate locations with renamed registers";
|
||||
return std::nullopt;
|
||||
}
|
||||
return std::move(found.front());
|
||||
}
|
||||
|
||||
std::optional<SignatureMatch> FindRegistrySignatureMatch(
|
||||
const ElfImage& reference, const ElfImage& candidate,
|
||||
const Disassembler& disassembler, const SignatureSpec& spec,
|
||||
@@ -1590,8 +1704,18 @@ std::optional<SignatureMatch> FindRegistrySignatureMatch(
|
||||
spec.minimum_anchor_bytes,
|
||||
{0, 4},
|
||||
};
|
||||
const auto body_match =
|
||||
auto body_match =
|
||||
FindSignatureMatch(reference, candidate, disassembler, body_spec, error);
|
||||
if (!body_match.has_value()) {
|
||||
error->clear();
|
||||
body_match = FindRenamedRegistryBodyMatch(
|
||||
reference, candidate, disassembler,
|
||||
std::vector<Instruction>(
|
||||
std::next(reference_full->begin(), kBodyInstructionIndex),
|
||||
std::next(reference_full->begin(),
|
||||
kBodyInstructionIndex + kBodyInstructionCount)),
|
||||
spec.minimum_anchor_bytes, error);
|
||||
}
|
||||
if (!body_match.has_value()) return std::nullopt;
|
||||
const std::vector<Instruction> reference_prologue(
|
||||
reference_full->begin(),
|
||||
@@ -1633,39 +1757,48 @@ std::optional<std::uint64_t> DirectJumpTarget(
|
||||
return static_cast<std::uint64_t>(instruction.operands[0].value);
|
||||
}
|
||||
|
||||
// Matches a constructor that is one jump to its body. The body is found by
|
||||
// signature, and the candidate constructor is the one of `candidate_entries`
|
||||
// that jumps to it.
|
||||
std::optional<SignatureMatch> FindConstructorBoundaryMatch(
|
||||
const ElfImage& reference, const ElfImage& candidate,
|
||||
const Disassembler& disassembler, const SignatureSpec& spec,
|
||||
std::uint64_t reference_entry, std::uint64_t candidate_entry,
|
||||
std::string* error) {
|
||||
if (reference_entry != spec.reference_rva) {
|
||||
*error = "reference constructor boundary moved outside init-array index 4";
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::vector<std::uint64_t>& candidate_entries, std::string* error) {
|
||||
const auto reference_wrapper =
|
||||
disassembler.Decode(reference, reference_entry, 1, 16, error);
|
||||
disassembler.Decode(reference, spec.reference_rva, 1, 16, error);
|
||||
if (!reference_wrapper.has_value()) return std::nullopt;
|
||||
const auto reference_target = DirectJumpTarget(
|
||||
reference_wrapper->front(), "reference constructor boundary", error);
|
||||
if (!reference_target.has_value()) return std::nullopt;
|
||||
const SignatureSpec target_spec{"constructor-4-target", *reference_target,
|
||||
const SignatureSpec target_spec{spec.name + "-target", *reference_target,
|
||||
spec.instruction_count};
|
||||
const auto target_match = FindSignatureMatch(
|
||||
reference, candidate, disassembler, target_spec, error);
|
||||
if (!target_match.has_value()) return std::nullopt;
|
||||
const auto candidate_wrapper =
|
||||
disassembler.Decode(candidate, candidate_entry, 1, 16, error);
|
||||
if (!candidate_wrapper.has_value()) return std::nullopt;
|
||||
const auto candidate_target = DirectJumpTarget(
|
||||
candidate_wrapper->front(), "candidate constructor boundary", error);
|
||||
if (!candidate_target.has_value() || *candidate_target != target_match->rva) {
|
||||
if (error->empty()) {
|
||||
*error = "constructor index 4 no longer targets its verified boundary";
|
||||
std::optional<SignatureMatch> found;
|
||||
for (const std::uint64_t candidate_entry : candidate_entries) {
|
||||
std::string ignored;
|
||||
const auto candidate_wrapper =
|
||||
disassembler.Decode(candidate, candidate_entry, 1, 16, &ignored);
|
||||
if (!candidate_wrapper.has_value()) continue;
|
||||
const auto candidate_target = DirectJumpTarget(
|
||||
candidate_wrapper->front(), "candidate constructor boundary",
|
||||
&ignored);
|
||||
if (!candidate_target.has_value() ||
|
||||
*candidate_target != target_match->rva) {
|
||||
continue;
|
||||
}
|
||||
return std::nullopt;
|
||||
if (found.has_value()) {
|
||||
found.reset();
|
||||
break;
|
||||
}
|
||||
found = SignatureMatch{candidate_entry, *reference_wrapper,
|
||||
*candidate_wrapper};
|
||||
}
|
||||
return SignatureMatch{candidate_entry, *reference_wrapper,
|
||||
*candidate_wrapper};
|
||||
if (!found.has_value()) {
|
||||
*error = spec.name + " no longer targets its verified boundary";
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
std::vector<std::uint64_t> RipTargets(const Instruction& instruction) {
|
||||
@@ -1678,6 +1811,187 @@ std::vector<std::uint64_t> RipTargets(const Instruction& instruction) {
|
||||
return result;
|
||||
}
|
||||
|
||||
bool IsRegisterOperand(const Operand& operand, std::string_view name) {
|
||||
return operand.type == Operand::Type::kRegister &&
|
||||
operand.register_name == name;
|
||||
}
|
||||
|
||||
// The instructions that publish the JNI singleton, which open its
|
||||
// initializer at `entry`, after a stack frame if it has one:
|
||||
//
|
||||
// lea rax, [rip + OBJECT]
|
||||
// xchg qword ptr [rip + SLOT], rax
|
||||
// mov qword ptr [rip + OBJECT], rax
|
||||
//
|
||||
// OBJECT is the eight bytes after SLOT.
|
||||
std::optional<std::vector<Instruction>> JniSingletonPublication(
|
||||
const ElfImage& image, const Disassembler& disassembler,
|
||||
std::uint64_t entry, std::string* error) {
|
||||
for (const std::size_t frame : {std::size_t{0}, std::size_t{2}}) {
|
||||
std::string ignored;
|
||||
const auto decoded =
|
||||
disassembler.Decode(image, entry, frame + 3, 64, &ignored);
|
||||
if (!decoded.has_value()) continue;
|
||||
if (frame != 0) {
|
||||
const Instruction& push = (*decoded)[0];
|
||||
const Instruction& link = (*decoded)[1];
|
||||
if (push.mnemonic != "push" || push.operands.size() != 1 ||
|
||||
!IsRegisterOperand(push.operands[0], "rbp") ||
|
||||
link.mnemonic != "mov" || link.operands.size() != 2 ||
|
||||
!IsRegisterOperand(link.operands[0], "rbp") ||
|
||||
!IsRegisterOperand(link.operands[1], "rsp")) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const Instruction& load = (*decoded)[frame];
|
||||
const Instruction& exchange = (*decoded)[frame + 1];
|
||||
const Instruction& store = (*decoded)[frame + 2];
|
||||
const auto object = RipTargets(load);
|
||||
const auto slot = RipTargets(exchange);
|
||||
const auto stored = RipTargets(store);
|
||||
if (load.mnemonic != "lea" || load.operands.size() != 2 ||
|
||||
!IsRegisterOperand(load.operands[0], "rax") ||
|
||||
exchange.mnemonic != "xchg" || exchange.operands.size() != 2 ||
|
||||
exchange.operands[0].size != 8 ||
|
||||
!IsRegisterOperand(exchange.operands[1], "rax") ||
|
||||
store.mnemonic != "mov" || store.operands.size() != 2 ||
|
||||
store.operands[0].size != 8 ||
|
||||
!IsRegisterOperand(store.operands[1], "rax") || object.size() != 1 ||
|
||||
slot.size() != 1 || stored.size() != 1 || object[0] != stored[0] ||
|
||||
object[0] != slot[0] + 8) {
|
||||
continue;
|
||||
}
|
||||
return std::vector<Instruction>(std::next(decoded->begin(), frame),
|
||||
decoded->end());
|
||||
}
|
||||
*error = "JNI singleton initializer at " + FormatRva(entry) +
|
||||
" does not publish its singleton";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Finds the JNI singleton initializer among `candidate_entries`. The
|
||||
// instructions after the publication follow the singleton's layout, so when
|
||||
// a release changes that layout only the publication is matched. The
|
||||
// initializer itself runs from the init-array either way.
|
||||
std::optional<SignatureMatch> FindJniSingletonMatch(
|
||||
const ElfImage& reference, const ElfImage& candidate,
|
||||
const Disassembler& disassembler, const SignatureSpec& spec,
|
||||
const std::vector<std::uint64_t>& candidate_entries, std::string* error) {
|
||||
auto whole =
|
||||
FindSignatureMatch(reference, candidate, disassembler, spec, error);
|
||||
if (whole.has_value()) return whole;
|
||||
error->clear();
|
||||
const auto reference_publication = JniSingletonPublication(
|
||||
reference, disassembler, spec.reference_rva, error);
|
||||
if (!reference_publication.has_value()) return std::nullopt;
|
||||
const SignatureSpec publication_spec{
|
||||
"jni-singleton-publication", reference_publication->front().address,
|
||||
reference_publication->size(), false, 3};
|
||||
const auto found = FindSignatureMatch(reference, candidate, disassembler,
|
||||
publication_spec, error);
|
||||
if (!found.has_value()) return std::nullopt;
|
||||
for (const std::uint64_t entry : candidate_entries) {
|
||||
std::string ignored;
|
||||
const auto publication =
|
||||
JniSingletonPublication(candidate, disassembler, entry, &ignored);
|
||||
if (publication.has_value() &&
|
||||
publication->front().address == found->rva) {
|
||||
return SignatureMatch{entry, *reference_publication, *publication};
|
||||
}
|
||||
}
|
||||
*error = "JNI singleton initializer is not a leading constructor";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// The native replay starts at this init-array index on x86_64.
|
||||
constexpr std::size_t kLeadingConstructorBegin = 2;
|
||||
// The process-load constructor, the JNI singleton initializer and two wiring
|
||||
// entries. A release may put them in any order.
|
||||
constexpr std::size_t kLeadingConstructorCount = 4;
|
||||
|
||||
struct LeadingConstructors {
|
||||
// For each reference leading constructor, the candidate init-array index
|
||||
// of the same constructor. Both count from kLeadingConstructorBegin.
|
||||
std::array<std::size_t, kLeadingConstructorCount> candidate_offset{};
|
||||
SignatureMatch jni_singleton;
|
||||
};
|
||||
|
||||
// Finds each reference leading constructor among the candidate's leading
|
||||
// constructors by its code.
|
||||
std::optional<LeadingConstructors> MatchLeadingConstructors(
|
||||
const ElfImage& reference, const ElfImage& candidate,
|
||||
const Disassembler& disassembler, const Json& constructor_anchors,
|
||||
const SignatureSpec& jni_spec,
|
||||
const std::vector<std::uint64_t>& reference_init,
|
||||
const std::vector<std::uint64_t>& candidate_init, std::string* error) {
|
||||
constexpr std::size_t kConstructorInstructionCount = 24;
|
||||
constexpr char kMoved[] =
|
||||
"constructor moved outside verified init-array boundary";
|
||||
const std::size_t end = kLeadingConstructorBegin + kLeadingConstructorCount;
|
||||
if (reference_init.size() < end || candidate_init.size() < end) {
|
||||
*error = "init-array shape is not derivable";
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::vector<std::uint64_t> candidate_entries(
|
||||
std::next(candidate_init.begin(), kLeadingConstructorBegin),
|
||||
std::next(candidate_init.begin(), end));
|
||||
LeadingConstructors result;
|
||||
std::array<bool, kLeadingConstructorCount> taken{};
|
||||
bool found_jni = false;
|
||||
for (std::size_t offset = 0; offset < kLeadingConstructorCount; ++offset) {
|
||||
const std::size_t index = kLeadingConstructorBegin + offset;
|
||||
const std::uint64_t reference_entry = reference_init[index];
|
||||
const std::string name = "constructor-" + std::to_string(index);
|
||||
const auto anchor = ParseRva(
|
||||
constructor_anchors.value(std::to_string(index), Json()), name, error);
|
||||
if (!anchor.has_value()) return std::nullopt;
|
||||
if (*anchor != reference_entry) {
|
||||
*error = kMoved;
|
||||
return std::nullopt;
|
||||
}
|
||||
std::optional<SignatureMatch> match;
|
||||
if (reference_entry == jni_spec.reference_rva) {
|
||||
match = FindJniSingletonMatch(reference, candidate, disassembler,
|
||||
jni_spec, candidate_entries, error);
|
||||
if (!match.has_value()) return std::nullopt;
|
||||
result.jni_singleton = *match;
|
||||
found_jni = true;
|
||||
} else {
|
||||
const auto first =
|
||||
disassembler.Decode(reference, reference_entry, 1, 16, error);
|
||||
if (!first.has_value()) return std::nullopt;
|
||||
const SignatureSpec spec{name, reference_entry,
|
||||
kConstructorInstructionCount};
|
||||
match = first->front().mnemonic == "jmp"
|
||||
? FindConstructorBoundaryMatch(reference, candidate,
|
||||
disassembler, spec,
|
||||
candidate_entries, error)
|
||||
: FindSignatureMatch(reference, candidate, disassembler,
|
||||
spec, error);
|
||||
if (!match.has_value()) return std::nullopt;
|
||||
}
|
||||
const auto found = std::find(candidate_entries.begin(),
|
||||
candidate_entries.end(), match->rva);
|
||||
if (found == candidate_entries.end()) {
|
||||
*error = kMoved;
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::size_t candidate_offset =
|
||||
static_cast<std::size_t>(found - candidate_entries.begin());
|
||||
if (taken[candidate_offset]) {
|
||||
*error = kMoved;
|
||||
return std::nullopt;
|
||||
}
|
||||
taken[candidate_offset] = true;
|
||||
result.candidate_offset[offset] = candidate_offset;
|
||||
}
|
||||
if (!found_jni) {
|
||||
*error = "JNI singleton initializer is not a leading constructor";
|
||||
return std::nullopt;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<std::uint64_t> MappedRipTarget(const SignatureMatch& match,
|
||||
std::uint64_t reference_target,
|
||||
std::string_view description,
|
||||
@@ -1755,16 +2069,23 @@ std::optional<std::size_t> PositiveSize(const Json& value,
|
||||
return static_cast<std::size_t>(number);
|
||||
}
|
||||
|
||||
std::optional<Json> AdjustRanges(const Json& ranges,
|
||||
std::size_t reference_count,
|
||||
std::size_t candidate_count,
|
||||
std::string* error) {
|
||||
// Rewrites the reference constructor ranges for the candidate init-array.
|
||||
// Each leading constructor keeps its place in or out of the replay under its
|
||||
// candidate index, and every later constructor runs.
|
||||
std::optional<Json> RemapConstructorRanges(
|
||||
const Json& ranges, const LeadingConstructors& leading,
|
||||
std::size_t reference_count, std::size_t candidate_count,
|
||||
std::string* error) {
|
||||
if (!ranges.is_array() || ranges.empty()) {
|
||||
*error = "constructor ranges are missing";
|
||||
return std::nullopt;
|
||||
}
|
||||
Json result = Json::array();
|
||||
const std::size_t leading_end =
|
||||
kLeadingConstructorBegin + kLeadingConstructorCount;
|
||||
std::array<bool, kLeadingConstructorCount> runs{};
|
||||
std::size_t previous_end = 0;
|
||||
std::size_t tail_begin = SIZE_MAX;
|
||||
std::size_t tail_end = 0;
|
||||
for (const Json& range : ranges) {
|
||||
if (!range.is_object() || !range.contains("begin") ||
|
||||
!range.contains("end_exclusive") ||
|
||||
@@ -1774,15 +2095,39 @@ std::optional<Json> AdjustRanges(const Json& ranges,
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::size_t begin = range["begin"].get<std::size_t>();
|
||||
std::size_t end = range["end_exclusive"].get<std::size_t>();
|
||||
if (end == reference_count) end = candidate_count;
|
||||
if (begin >= end || begin < previous_end || end > candidate_count) {
|
||||
const std::size_t end = range["end_exclusive"].get<std::size_t>();
|
||||
if (begin >= end || begin < previous_end || end > reference_count ||
|
||||
begin < kLeadingConstructorBegin) {
|
||||
*error = "constructor range cannot fit candidate init-array";
|
||||
return std::nullopt;
|
||||
}
|
||||
result.push_back({{"begin", begin}, {"end_exclusive", end}});
|
||||
for (std::size_t index = begin; index < std::min(end, leading_end);
|
||||
++index) {
|
||||
runs[leading.candidate_offset[index - kLeadingConstructorBegin]] = true;
|
||||
}
|
||||
previous_end = end;
|
||||
tail_begin = begin;
|
||||
tail_end = end;
|
||||
}
|
||||
if (tail_begin > leading_end || tail_end != reference_count ||
|
||||
reference_count <= leading_end || candidate_count <= leading_end) {
|
||||
*error = "constructor range cannot fit candidate init-array";
|
||||
return std::nullopt;
|
||||
}
|
||||
Json result = Json::array();
|
||||
std::size_t run_begin = SIZE_MAX;
|
||||
for (std::size_t offset = 0; offset < kLeadingConstructorCount; ++offset) {
|
||||
const std::size_t index = kLeadingConstructorBegin + offset;
|
||||
if (runs[offset]) {
|
||||
if (run_begin == SIZE_MAX) run_begin = index;
|
||||
} else if (run_begin != SIZE_MAX) {
|
||||
result.push_back({{"begin", run_begin}, {"end_exclusive", index}});
|
||||
run_begin = SIZE_MAX;
|
||||
}
|
||||
}
|
||||
result.push_back(
|
||||
{{"begin", run_begin == SIZE_MAX ? leading_end : run_begin},
|
||||
{"end_exclusive", candidate_count}});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1826,7 +2171,6 @@ std::optional<std::vector<SignatureSpec>> SignatureSpecs(const Json& sidecar,
|
||||
};
|
||||
const Json& seeds = profile["data_seeds"];
|
||||
const Json& bootstrap = profile["native_pre_jni_bootstrap"];
|
||||
const Json& constructors = anchors["constructor_rvas"];
|
||||
std::vector<SignatureSpec> result;
|
||||
const auto add = [&](std::string name, const Json& encoded, std::size_t count,
|
||||
bool registry = false, std::size_t minimum = 6) -> bool {
|
||||
@@ -1859,10 +2203,7 @@ std::optional<std::vector<SignatureSpec>> SignatureSpecs(const Json& sidecar,
|
||||
!add("empty-string-initializer", anchors["empty_string_initializer_rva"],
|
||||
23) ||
|
||||
!add("jni-singleton-initializer",
|
||||
anchors["jni_singleton_initializer_rva"], 11) ||
|
||||
!add("constructor-2", constructors["2"], 24) ||
|
||||
!add("constructor-3", constructors["3"], 24) ||
|
||||
!add("constructor-4", constructors["4"], 24, false, 3)) {
|
||||
anchors["jni_singleton_initializer_rva"], 11)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return result;
|
||||
@@ -1966,7 +2307,17 @@ std::optional<std::pair<Json, Json>> DeriveDocuments(const ElfImage& reference,
|
||||
}
|
||||
std::map<std::string, SignatureMatch> matches;
|
||||
std::vector<SignatureMatch> allocate_matches;
|
||||
std::optional<LeadingConstructors> leading;
|
||||
for (const SignatureSpec& spec : *specs) {
|
||||
if (spec.name == "jni-singleton-initializer") {
|
||||
leading = MatchLeadingConstructors(
|
||||
reference, candidate, disassembler,
|
||||
sidecar["derivation_anchors"]["constructor_rvas"], spec,
|
||||
*reference_init, *candidate_init, error);
|
||||
if (!leading.has_value()) return std::nullopt;
|
||||
matches.emplace(spec.name, leading->jni_singleton);
|
||||
continue;
|
||||
}
|
||||
if (spec.name == "allocate") {
|
||||
auto candidates = FindSignatureMatches(reference, candidate,
|
||||
disassembler, spec, true, error);
|
||||
@@ -1979,11 +2330,6 @@ std::optional<std::pair<Json, Json>> DeriveDocuments(const ElfImage& reference,
|
||||
return FindRegistrySignatureMatch(reference, candidate, disassembler,
|
||||
spec, error);
|
||||
}
|
||||
if (spec.name == "constructor-4") {
|
||||
return FindConstructorBoundaryMatch(
|
||||
reference, candidate, disassembler, spec, (*reference_init)[4],
|
||||
(*candidate_init)[4], error);
|
||||
}
|
||||
return FindSignatureMatch(reference, candidate, disassembler, spec,
|
||||
error);
|
||||
}();
|
||||
@@ -2028,21 +2374,7 @@ std::optional<std::pair<Json, Json>> DeriveDocuments(const ElfImage& reference,
|
||||
return std::nullopt;
|
||||
}
|
||||
matches.emplace("allocate", std::move(*selected_allocate));
|
||||
for (const std::size_t index : {2U, 3U, 4U}) {
|
||||
const SignatureMatch& match =
|
||||
matches["constructor-" + std::to_string(index)];
|
||||
if ((*reference_init)[index] != match.reference[0].address ||
|
||||
(*candidate_init)[index] != match.rva) {
|
||||
*error = "constructor moved outside verified init-array boundary";
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
const SignatureMatch& singleton = matches["jni-singleton-initializer"];
|
||||
if ((*reference_init)[5] != singleton.reference[0].address ||
|
||||
(*candidate_init)[5] != singleton.rva) {
|
||||
*error = "JNI singleton initializer is not constructor index 5";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Json candidate_bridges = Json::array();
|
||||
std::map<std::string, std::uint64_t> bridge_rvas;
|
||||
@@ -2112,21 +2444,29 @@ std::optional<std::pair<Json, Json>> DeriveDocuments(const ElfImage& reference,
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const auto constructor_ranges =
|
||||
AdjustRanges(reference_profile["constructor_run_ranges"],
|
||||
*reference_count, candidate_init->size(), error);
|
||||
const auto native_ranges =
|
||||
AdjustRanges(reference_profile["native_mimalloc_constructor_run_ranges"],
|
||||
*reference_count, candidate_init->size(), error);
|
||||
const auto constructor_ranges = RemapConstructorRanges(
|
||||
reference_profile["constructor_run_ranges"], *leading, *reference_count,
|
||||
candidate_init->size(), error);
|
||||
const auto native_ranges = RemapConstructorRanges(
|
||||
reference_profile["native_mimalloc_constructor_run_ranges"], *leading,
|
||||
*reference_count, candidate_init->size(), error);
|
||||
if (!constructor_ranges.has_value() || !native_ranges.has_value()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::size_t thread_boundary = reference_profile.value(
|
||||
const std::size_t reference_thread_boundary = reference_profile.value(
|
||||
"native_mimalloc_thread_initializer_after_constructor", SIZE_MAX);
|
||||
if (thread_boundary != 2) {
|
||||
if (reference_thread_boundary < kLeadingConstructorBegin ||
|
||||
reference_thread_boundary >=
|
||||
kLeadingConstructorBegin + kLeadingConstructorCount) {
|
||||
*error = "native allocator TLS boundary is no longer verified";
|
||||
return std::nullopt;
|
||||
}
|
||||
// The thread initializer follows the process-load constructor to its
|
||||
// candidate index.
|
||||
const std::size_t thread_boundary =
|
||||
kLeadingConstructorBegin +
|
||||
leading->candidate_offset[reference_thread_boundary -
|
||||
kLeadingConstructorBegin];
|
||||
const auto singleton_bytes =
|
||||
PositiveSize(seeds["jni_singleton_bytes"], "JNI singleton size", error);
|
||||
const auto arena_slots = PositiveSize(seeds["arena_table_slot_count"],
|
||||
@@ -2223,6 +2563,35 @@ std::optional<std::pair<Json, Json>> DeriveDocuments(const ElfImage& reference,
|
||||
return std::pair<Json, Json>{std::move(derived), std::move(compatibility)};
|
||||
}
|
||||
|
||||
// Derives the optional FMOD output-device bridge from `library`, another build
|
||||
// the manifests describe. Any failure leaves the manifest without the bridge.
|
||||
void AddFallbackFmodBridge(const std::filesystem::path& library,
|
||||
const std::vector<std::filesystem::path>& manifests,
|
||||
const ElfImage& candidate, Json* compatibility) {
|
||||
std::string error;
|
||||
ElfImage fallback;
|
||||
if (!fallback.Open(library, &error) ||
|
||||
fallback.build_id() == candidate.build_id() ||
|
||||
!fallback.ValidateToolchain(&error)) {
|
||||
return;
|
||||
}
|
||||
const auto anchors =
|
||||
LoadRuntimeCompatibilityAnchors(manifests, fallback.build_id(), &error);
|
||||
if (!anchors.has_value() || !anchors->fmod.has_value()) return;
|
||||
Disassembler disassembler;
|
||||
if (!disassembler.Open(&error)) return;
|
||||
RuntimeCompatibilityAnchors source;
|
||||
source.fmod = anchors->fmod;
|
||||
const auto derived = DeriveRuntimeCompatibility(fallback, candidate,
|
||||
disassembler, source, &error);
|
||||
if (!derived.has_value() ||
|
||||
!derived->contains("fmod_output_device_bridge")) {
|
||||
return;
|
||||
}
|
||||
(*compatibility)["profiles"][0]["fmod_output_device_bridge"] =
|
||||
(*derived)["fmod_output_device_bridge"];
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
HostAbiSidecarIdentity ReadHostAbiSidecarIdentity(
|
||||
@@ -2280,10 +2649,15 @@ HostAbiDerivationResult DeriveHostAbiProfile(
|
||||
options.reference_compatibility_manifests, reference.build_id(),
|
||||
&result.error);
|
||||
if (!runtime_source.has_value()) return result;
|
||||
const auto documents = DeriveDocuments(reference, candidate, *sidecar,
|
||||
*metadata, *runtime_source,
|
||||
&result.error);
|
||||
auto documents = DeriveDocuments(reference, candidate, *sidecar, *metadata,
|
||||
*runtime_source, &result.error);
|
||||
if (!documents.has_value()) return result;
|
||||
if (!runtime_source->fmod.has_value() &&
|
||||
!options.runtime_fallback_library.empty()) {
|
||||
AddFallbackFmodBridge(options.runtime_fallback_library,
|
||||
options.reference_compatibility_manifests, candidate,
|
||||
&documents->second);
|
||||
}
|
||||
result.profile = options.output_directory / "host_abi_profile.json";
|
||||
result.compatibility_manifest =
|
||||
options.output_directory / "compatibility.json";
|
||||
|
||||
@@ -312,7 +312,10 @@ bool CloneOrCopyFile(const std::filesystem::path& source,
|
||||
}
|
||||
bool copied = false;
|
||||
#if defined(__linux__) && defined(FICLONE)
|
||||
copied = ioctl(output, FICLONE, input) == 0;
|
||||
struct stat cloned_metadata = {};
|
||||
copied = ioctl(output, FICLONE, input) == 0 &&
|
||||
fstat(output, &cloned_metadata) == 0 &&
|
||||
cloned_metadata.st_size == metadata.st_size;
|
||||
#endif
|
||||
#if defined(__linux__)
|
||||
if (!copied) {
|
||||
|
||||
@@ -320,6 +320,7 @@ CanaryResult RunReadinessCanary(const CanaryOptions& options) {
|
||||
"LOGNAME",
|
||||
"VK_DRIVER_FILES",
|
||||
"VK_ICD_FILENAMES",
|
||||
"vk_require_etc2",
|
||||
"MESA_LOADER_DRIVER_OVERRIDE",
|
||||
"DRI_PRIME",
|
||||
"__NV_PRIME_RENDER_OFFLOAD",
|
||||
@@ -472,7 +473,9 @@ CanaryResult RunReadinessCanary(const CanaryOptions& options) {
|
||||
}
|
||||
std::filesystem::remove_all(isolated, filesystem_error);
|
||||
if (!result.error.empty()) return result;
|
||||
result.exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 128;
|
||||
result.exit_code = WIFEXITED(status)
|
||||
? WEXITSTATUS(status)
|
||||
: 128 + (WIFSIGNALED(status) ? WTERMSIG(status) : 0);
|
||||
if (result.exit_code != 0) {
|
||||
result.error =
|
||||
"graphics canary exited with status " +
|
||||
|
||||
@@ -301,6 +301,9 @@ struct ReferenceProfile {
|
||||
std::filesystem::path library;
|
||||
std::filesystem::path profile;
|
||||
std::vector<std::filesystem::path> compatibility_manifests;
|
||||
// libroblox.so of the shipped reference payload when the reference is a
|
||||
// different, machine-derived payload. Empty when it is not staged.
|
||||
std::filesystem::path runtime_fallback_library;
|
||||
std::string error;
|
||||
|
||||
explicit operator bool() const {
|
||||
@@ -367,6 +370,23 @@ ReferenceProfile ResolveReference(
|
||||
paths.compatibility_manifest)) {
|
||||
result.compatibility_manifests.push_back(paths.compatibility_manifest);
|
||||
}
|
||||
// A machine-derived profile can lack the optional FMOD output-device
|
||||
// anchors. The shipped reference payload stays staged and has them.
|
||||
const std::optional<SupportedPayloadProfile> shipped =
|
||||
ResolveReferenceProfile(paths.host_abi_reference_profile, profiles,
|
||||
nullptr);
|
||||
if (shipped.has_value()) {
|
||||
const std::string shipped_id = std::to_string(shipped->version_code) +
|
||||
"-" + shipped->elf_build_id;
|
||||
const std::filesystem::path shipped_directory =
|
||||
paths.data_root / "payloads" / shipped_id;
|
||||
const PayloadIntegrityResult shipped_payload =
|
||||
InspectPreparedPayload(shipped_directory);
|
||||
if (shipped_id != installed.payload_id && shipped_payload &&
|
||||
shipped_payload.payload_id == shipped_id) {
|
||||
result.runtime_fallback_library = shipped_directory / "libroblox.so";
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -475,6 +495,7 @@ UpdateResult RunUnsafeLatest(
|
||||
derivation.reference_profile = reference.profile;
|
||||
derivation.reference_compatibility_manifests =
|
||||
reference.compatibility_manifests;
|
||||
derivation.runtime_fallback_library = reference.runtime_fallback_library;
|
||||
derivation.candidate_payload_directory = candidate.staged.payload_directory;
|
||||
derivation.output_directory = workspace / "derived";
|
||||
Progress(request.progress_fd, "Deriving latest Roblox compatibility...");
|
||||
@@ -853,6 +874,8 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
|
||||
derivation.reference_profile = reference.profile;
|
||||
derivation.reference_compatibility_manifests =
|
||||
reference.compatibility_manifests;
|
||||
derivation.runtime_fallback_library =
|
||||
reference.runtime_fallback_library;
|
||||
derivation.candidate_payload_directory =
|
||||
candidate.staged.payload_directory;
|
||||
derivation.output_directory =
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#ifndef MOCKTAIL_WINDOW_HOST_SURFACE_COMMIT_GATE_H_
|
||||
#define MOCKTAIL_WINDOW_HOST_SURFACE_COMMIT_GATE_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
|
||||
namespace mocktail {
|
||||
namespace window {
|
||||
|
||||
// Keeps main-thread wl_surface commits out of a host vkQueuePresentKHR call.
|
||||
// With explicit sync a Wayland driver sends its sync points, buffer attach
|
||||
// and commit as separate requests inside that call. A commit from another
|
||||
// thread between them carries sync points without a buffer, which is a
|
||||
// no_buffer protocol error that disconnects the client.
|
||||
class HostSurfaceCommitGate final {
|
||||
public:
|
||||
// A host present can block for as long as the compositor withholds a
|
||||
// frame, so the main thread proceeds unguarded after this wait.
|
||||
static constexpr std::chrono::milliseconds kMainThreadWait{50};
|
||||
|
||||
HostSurfaceCommitGate() = default;
|
||||
HostSurfaceCommitGate(const HostSurfaceCommitGate&) = delete;
|
||||
HostSurfaceCommitGate& operator=(const HostSurfaceCommitGate&) = delete;
|
||||
|
||||
void BeginHostPresent() {
|
||||
if (present_thread_.load(std::memory_order_acquire) ==
|
||||
std::this_thread::get_id()) {
|
||||
// The previous present on this thread never reported its end.
|
||||
return;
|
||||
}
|
||||
mutex_.lock();
|
||||
present_thread_.store(std::this_thread::get_id(),
|
||||
std::memory_order_release);
|
||||
}
|
||||
|
||||
void EndHostPresent() {
|
||||
if (present_thread_.load(std::memory_order_acquire) !=
|
||||
std::this_thread::get_id()) {
|
||||
return;
|
||||
}
|
||||
present_thread_.store(std::thread::id(), std::memory_order_release);
|
||||
mutex_.unlock();
|
||||
}
|
||||
|
||||
// Held around SDL calls that can commit the window surface.
|
||||
class MainThreadScope final {
|
||||
public:
|
||||
explicit MainThreadScope(
|
||||
HostSurfaceCommitGate& gate,
|
||||
std::chrono::nanoseconds wait = kMainThreadWait)
|
||||
: lock_(gate.mutex_, wait) {}
|
||||
MainThreadScope(const MainThreadScope&) = delete;
|
||||
MainThreadScope& operator=(const MainThreadScope&) = delete;
|
||||
|
||||
bool owns_lock() const { return lock_.owns_lock(); }
|
||||
|
||||
private:
|
||||
std::unique_lock<std::timed_mutex> lock_;
|
||||
};
|
||||
|
||||
private:
|
||||
std::timed_mutex mutex_;
|
||||
std::atomic<std::thread::id> present_thread_{};
|
||||
};
|
||||
|
||||
inline HostSurfaceCommitGate& ProcessHostSurfaceCommitGate() {
|
||||
static HostSurfaceCommitGate gate;
|
||||
return gate;
|
||||
}
|
||||
|
||||
} // namespace window
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_WINDOW_HOST_SURFACE_COMMIT_GATE_H_
|
||||
@@ -1,8 +1,6 @@
|
||||
#include "window/video_driver_policy.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <system_error>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace mocktail {
|
||||
namespace window {
|
||||
@@ -18,10 +16,6 @@ VideoDriverChoice ResolveVideoDriverChoice(
|
||||
if (input.force_x11 && input.has_x11_display) {
|
||||
return VideoDriverChoice::kX11;
|
||||
}
|
||||
if (input.uses_direct_vulkan && input.has_nvidia_kernel_driver &&
|
||||
input.has_wayland_session && input.has_x11_display) {
|
||||
return VideoDriverChoice::kNvidiaDirectVulkanX11;
|
||||
}
|
||||
if (input.prefer_wayland && input.has_wayland_session) {
|
||||
return VideoDriverChoice::kWayland;
|
||||
}
|
||||
@@ -33,7 +27,6 @@ const char* VideoDriverChoiceName(VideoDriverChoice choice) {
|
||||
case VideoDriverChoice::kWayland:
|
||||
return "wayland";
|
||||
case VideoDriverChoice::kX11:
|
||||
case VideoDriverChoice::kNvidiaDirectVulkanX11:
|
||||
return "x11";
|
||||
case VideoDriverChoice::kSdlDefault:
|
||||
return nullptr;
|
||||
@@ -41,16 +34,6 @@ const char* VideoDriverChoiceName(VideoDriverChoice choice) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool HasNvidiaKernelDriver(const std::filesystem::path& proc_version,
|
||||
const std::filesystem::path& pci_driver) {
|
||||
if (access(proc_version.c_str(), R_OK) == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::error_code error;
|
||||
return std::filesystem::is_directory(pci_driver, error);
|
||||
}
|
||||
|
||||
bool HasAvailableVideoDriverCandidate(
|
||||
std::string_view requested,
|
||||
const std::vector<std::string_view>& available_drivers) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#ifndef MOCKTAIL_WINDOW_VIDEO_DRIVER_POLICY_H_
|
||||
#define MOCKTAIL_WINDOW_VIDEO_DRIVER_POLICY_H_
|
||||
|
||||
#include <filesystem>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
@@ -12,7 +11,6 @@ enum class VideoDriverChoice {
|
||||
kSdlDefault,
|
||||
kWayland,
|
||||
kX11,
|
||||
kNvidiaDirectVulkanX11,
|
||||
};
|
||||
|
||||
struct VideoDriverPolicyInput {
|
||||
@@ -22,23 +20,15 @@ struct VideoDriverPolicyInput {
|
||||
bool prefer_wayland = true;
|
||||
bool has_wayland_session = false;
|
||||
bool has_x11_display = false;
|
||||
bool uses_direct_vulkan = false;
|
||||
bool has_nvidia_kernel_driver = false;
|
||||
};
|
||||
|
||||
// Resolves the SDL video backend before SDL_Init. An explicit SDL driver is
|
||||
// always authoritative. NVIDIA's direct Vulkan WSI uses X11/XWayland by
|
||||
// default when both display transports are available to avoid native Wayland
|
||||
// WSI hangs and explicit-sync protocol errors that disconnect the display.
|
||||
// always authoritative. A Wayland session uses native Wayland unless X11 is
|
||||
// forced or the Wayland preference is disabled.
|
||||
VideoDriverChoice ResolveVideoDriverChoice(const VideoDriverPolicyInput& input);
|
||||
|
||||
const char* VideoDriverChoiceName(VideoDriverChoice choice);
|
||||
|
||||
// Also detects sandboxed drivers through sysfs when procfs is unavailable.
|
||||
bool HasNvidiaKernelDriver(
|
||||
const std::filesystem::path& proc_version = "/proc/driver/nvidia/version",
|
||||
const std::filesystem::path& pci_driver = "/sys/bus/pci/drivers/nvidia");
|
||||
|
||||
// SDL accepts a comma-separated priority list. An inherited override is only
|
||||
// useful when at least one requested driver exists in the linked SDL build.
|
||||
bool HasAvailableVideoDriverCandidate(
|
||||
|
||||
+27
-8
@@ -24,6 +24,7 @@
|
||||
#include "mocktail/platform/sdl_application_metadata.h"
|
||||
#include "mocktail/platform/sdl_event_converter.h"
|
||||
#include "mocktail/platform/sdl_window_icon.h"
|
||||
#include "window/host_surface_commit_gate.h"
|
||||
#include "window/input_pump_pacer.h"
|
||||
#include "window/main_thread_command_gate.h"
|
||||
#include "window/roblox_fullscreen_menu_request_gate.h"
|
||||
@@ -125,7 +126,9 @@ struct WindowState {
|
||||
int height = 720;
|
||||
bool initialised = false;
|
||||
bool quit_requested = false;
|
||||
bool visible = false;
|
||||
// Written on the main thread. The render thread reads it to decide whether
|
||||
// to ask for the window to be shown.
|
||||
std::atomic<bool> visible{false};
|
||||
bool software_window = false;
|
||||
bool direct_vulkan = false;
|
||||
bool input_test_sequence_queued = false;
|
||||
@@ -144,6 +147,9 @@ struct WindowState {
|
||||
|
||||
static WindowState g_state;
|
||||
static std::atomic<int> g_real_swap_count{0};
|
||||
// Set by the render thread at its first frame; the main thread shows the
|
||||
// window, because SDL window calls belong to the main thread.
|
||||
static std::atomic<bool> g_show_window_requested{false};
|
||||
static std::atomic<uint64_t> g_first_present_ticks_ns{0};
|
||||
static InputPumpPacer g_input_pump_pacer;
|
||||
static PresentLifecycleGate g_present_lifecycle;
|
||||
@@ -475,8 +481,6 @@ VideoDriverChoice ResolveConfiguredVideoDriverChoice() {
|
||||
!StringEquals(GetEnvNonEmpty("MOCKTAIL_FORCE_WAYLAND"), "0");
|
||||
input.has_wayland_session = HasWaylandSession();
|
||||
input.has_x11_display = GetEnvNonEmpty("DISPLAY") != nullptr;
|
||||
input.uses_direct_vulkan = ShouldUseNativeVulkanBackend();
|
||||
input.has_nvidia_kernel_driver = HasNvidiaKernelDriver();
|
||||
return ResolveVideoDriverChoice(input);
|
||||
}
|
||||
|
||||
@@ -665,11 +669,6 @@ bool ConfigureGraphicsBackendBeforeSDL() {
|
||||
setenv("SDL_VIDEODRIVER", video_driver, 1);
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, video_driver);
|
||||
}
|
||||
if (video_driver_choice == VideoDriverChoice::kNvidiaDirectVulkanX11) {
|
||||
fprintf(stderr,
|
||||
" [window] NVIDIA direct Vulkan on Wayland session: using "
|
||||
"X11/XWayland WSI; set SDL_VIDEODRIVER=wayland to override\n");
|
||||
}
|
||||
|
||||
const GraphicsLibraries libraries = ResolveGraphicsLibraries();
|
||||
const char* egl_library =
|
||||
@@ -1642,6 +1641,21 @@ void ShowIfHidden() {
|
||||
IsDisabledEnv("MOCKTAIL_SHOW_WINDOW_ON_FIRST_SWAP")) {
|
||||
return;
|
||||
}
|
||||
g_show_window_requested.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
// Main thread only. Shows the window once a frame has asked for it.
|
||||
void ApplyShowWindowRequest() {
|
||||
if (!g_show_window_requested.load(std::memory_order_acquire) ||
|
||||
g_state.sdl_window == nullptr) {
|
||||
return; // Without a window the request stays for the next pump.
|
||||
}
|
||||
g_show_window_requested.store(false, std::memory_order_relaxed);
|
||||
if (g_state.visible) {
|
||||
return;
|
||||
}
|
||||
HostSurfaceCommitGate::MainThreadScope commit_scope(
|
||||
ProcessHostSurfaceCommitGate());
|
||||
SDL_ShowWindow(g_state.sdl_window);
|
||||
SDL_RaiseWindow(g_state.sdl_window);
|
||||
g_state.visible = true;
|
||||
@@ -1846,6 +1860,7 @@ extern "C" bool mocktail_window_uses_direct_vulkan() {
|
||||
extern "C" void mocktail_window_note_vulkan_present() { NoteVulkanPresent(); }
|
||||
|
||||
extern "C" void mocktail_window_note_vulkan_host_present_begin() {
|
||||
ProcessHostSurfaceCommitGate().BeginHostPresent();
|
||||
g_vulkan_host_present_sequence =
|
||||
g_vulkan_present_progress_gate.NotifyHostPresentBegin(
|
||||
SDL_GetTicksNS(), SDL_GetCurrentThreadID());
|
||||
@@ -1856,6 +1871,7 @@ extern "C" void mocktail_window_note_vulkan_host_present_end(
|
||||
g_vulkan_present_progress_gate.NotifyHostPresentEnd(
|
||||
g_vulkan_host_present_sequence);
|
||||
g_vulkan_host_present_sequence = 0;
|
||||
ProcessHostSurfaceCommitGate().EndHostPresent();
|
||||
}
|
||||
|
||||
extern "C" std::uint64_t mocktail_window_note_vulkan_call_begin(
|
||||
@@ -1995,6 +2011,8 @@ bool RequestFullscreenState(bool fullscreen, const char* reason) {
|
||||
if (current_fullscreen != fullscreen) {
|
||||
// Save the restore rectangle before SDL replaces it with monitor bounds.
|
||||
CaptureWindowState();
|
||||
HostSurfaceCommitGate::MainThreadScope commit_scope(
|
||||
ProcessHostSurfaceCommitGate());
|
||||
if (!SDL_SetWindowFullscreen(g_state.sdl_window, fullscreen)) {
|
||||
fprintf(stderr, " [fullscreen] SDL request failed: %s\n",
|
||||
SDL_GetError());
|
||||
@@ -2192,6 +2210,7 @@ void MaybeReportVulkanPresentStall() {
|
||||
}
|
||||
|
||||
bool PumpEvents() {
|
||||
ApplyShowWindowRequest();
|
||||
MaybeSynchronizeRestoredFullscreenState();
|
||||
MaybeApplyRobloxFullscreenMenuRequest();
|
||||
MaybeApplyAndroidFullscreenRequest();
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include "window/host_surface_commit_gate.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace window {
|
||||
|
||||
@@ -203,6 +205,10 @@ bool SdlPointerCaptureBackend::Apply(bool relative_mode, bool cursor_visible) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Leaving relative mode makes SDL warp the pointer, which commits the
|
||||
// window surface on Wayland compositors without wp_pointer_warp_v1.
|
||||
HostSurfaceCommitGate::MainThreadScope commit_scope(
|
||||
ProcessHostSurfaceCommitGate());
|
||||
if (relative_mode && !relative_mode_) {
|
||||
SDL_GetMouseState(&capture_anchor_x_, &capture_anchor_y_);
|
||||
capture_anchor_valid_ = true;
|
||||
|
||||
@@ -510,6 +510,16 @@ target_link_libraries(vulkan_surface_recovery_gate_test PRIVATE
|
||||
)
|
||||
mocktail_apply_compile_options(vulkan_surface_recovery_gate_test)
|
||||
|
||||
add_executable(host_surface_commit_gate_test
|
||||
host_surface_commit_gate_test.cc
|
||||
)
|
||||
target_link_libraries(host_surface_commit_gate_test PRIVATE
|
||||
mocktail_window
|
||||
GTest::gtest_main
|
||||
Threads::Threads
|
||||
)
|
||||
mocktail_apply_compile_options(host_surface_commit_gate_test)
|
||||
|
||||
add_executable(vulkan_present_progress_gate_test
|
||||
vulkan_present_progress_gate_test.cc
|
||||
)
|
||||
@@ -559,6 +569,17 @@ target_link_libraries(texture_override_test PRIVATE GTest::gtest_main
|
||||
PkgConfig::LIBPNG)
|
||||
mocktail_apply_compile_options(texture_override_test)
|
||||
|
||||
add_executable(texture_resample_test
|
||||
texture_resample_test.cc
|
||||
${PROJECT_SOURCE_DIR}/src/graphics/texture_override.cc
|
||||
)
|
||||
target_include_directories(texture_resample_test PRIVATE
|
||||
${PROJECT_SOURCE_DIR}/include
|
||||
)
|
||||
target_link_libraries(texture_resample_test PRIVATE GTest::gtest_main
|
||||
PkgConfig::LIBPNG)
|
||||
mocktail_apply_compile_options(texture_resample_test)
|
||||
|
||||
add_executable(chrome_trace_writer_test
|
||||
chrome_trace_writer_test.cc
|
||||
${PROJECT_SOURCE_DIR}/src/graphics/chrome_trace_writer.cc
|
||||
@@ -1058,9 +1079,12 @@ gtest_discover_tests(vulkan_present_observer_test)
|
||||
gtest_discover_tests(input_pump_pacer_test)
|
||||
gtest_discover_tests(input_pump_pacing_runtime_test)
|
||||
gtest_discover_tests(vulkan_surface_recovery_gate_test)
|
||||
gtest_discover_tests(host_surface_commit_gate_test)
|
||||
gtest_discover_tests(vulkan_present_progress_gate_test)
|
||||
gtest_discover_tests(vulkan_text_overlay_compositor_test)
|
||||
gtest_discover_tests(etc2_decoder_test)
|
||||
gtest_discover_tests(texture_override_test)
|
||||
gtest_discover_tests(texture_resample_test)
|
||||
gtest_discover_tests(chrome_trace_writer_test)
|
||||
gtest_discover_tests(vulkan_etc2_emulation_test)
|
||||
gtest_discover_tests(video_driver_policy_test)
|
||||
|
||||
@@ -102,21 +102,69 @@ curl() {
|
||||
}
|
||||
|
||||
pacman() {
|
||||
[[ "$#" == 5 && "$1" == -U && "$2" == --noconfirm && "$3" == --needed ]]
|
||||
if [[ "${SCENARIO}" == replaced ]]; then
|
||||
cmp "${TEMP_DIR}/updated-ffmpeg" "$4"
|
||||
else
|
||||
cmp "${TEMP_DIR}/ffmpeg" "$4"
|
||||
fi
|
||||
cmp "${TEMP_DIR}/opus" "$5"
|
||||
local downloads="${4%/*}"
|
||||
local name
|
||||
case "$1" in
|
||||
-Qp)
|
||||
# The stale scenario offers an ffmpeg older than the installed one.
|
||||
name="${3##*/}"
|
||||
name="${name%%-mini-*}"
|
||||
if [[ "${SCENARIO}" == stale && "${name}" == ffmpeg ]]; then
|
||||
printf '%s 1-1\n' "${name}"
|
||||
else
|
||||
printf '%s 2-1\n' "${name}"
|
||||
fi
|
||||
return 0 ;;
|
||||
-Q)
|
||||
printf '%s 2-1\n' "$3"
|
||||
return 0 ;;
|
||||
-S)
|
||||
[[ "$2" == --noconfirm ]]
|
||||
printf '%s\n' "${@:3}" >"${TEMP_DIR}/restored"
|
||||
return 0 ;;
|
||||
esac
|
||||
[[ "$#" -ge 4 && "$1" == -U && "$2" == --noconfirm && "$3" == --needed ]]
|
||||
local archive downloads="${4%/*}"
|
||||
(cd "${downloads}" && sha256sum --check --strict SHA256SUMS)
|
||||
cp "$4" "$5" "${downloads}/SHA256SUMS" "${downloads}/dependencies.tsv" \
|
||||
for archive in "${@:4}"; do
|
||||
case "${archive##*/}" in
|
||||
"${FFMPEG}")
|
||||
if [[ "${SCENARIO}" == replaced ]]; then
|
||||
cmp "${TEMP_DIR}/updated-ffmpeg" "${archive}"
|
||||
else
|
||||
cmp "${TEMP_DIR}/ffmpeg" "${archive}"
|
||||
fi ;;
|
||||
"${OPUS}") cmp "${TEMP_DIR}/opus" "${archive}" ;;
|
||||
*) Fail "unexpected archive: ${archive}" ;;
|
||||
esac
|
||||
cp "${archive}" "${TEMP_DIR}/${SCENARIO}/"
|
||||
done
|
||||
cp "${downloads}/SHA256SUMS" "${downloads}/dependencies.tsv" \
|
||||
"${TEMP_DIR}/${SCENARIO}/"
|
||||
touch "${TEMP_DIR}/installed"
|
||||
}
|
||||
|
||||
export -f curl pacman Fail
|
||||
vercmp() {
|
||||
if [[ "$1" == "$2" ]]; then printf '0\n'; else printf -- '-1\n'; fi
|
||||
}
|
||||
|
||||
# opus never resolves once its reduced package is installed. libadwaita-1
|
||||
# stops resolving while the stale ffmpeg is installed.
|
||||
pkg-config() {
|
||||
case "$1" in
|
||||
--list-package-names) printf 'ffmpeg\nlibadwaita-1\nopus\n' ;;
|
||||
--exists)
|
||||
if [[ "$2" == opus ]]; then
|
||||
[[ ! -e "${TEMP_DIR}/${SCENARIO}/${OPUS}" ]]
|
||||
return
|
||||
fi
|
||||
[[ "${SCENARIO}" != stale || "$2" != libadwaita-1 ||
|
||||
! -e "${TEMP_DIR}/${SCENARIO}/${FFMPEG}" ||
|
||||
-e "${TEMP_DIR}/restored" ]] ;;
|
||||
*) Fail "unexpected pkg-config option: $1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
export -f curl pacman vercmp pkg-config Fail
|
||||
export TEMP_DIR API FFMPEG OPUS SCENARIO TEST_RELEASE
|
||||
|
||||
ExpectRejectedSnapshot() {
|
||||
@@ -190,7 +238,7 @@ for invalid_manifest in '../ffmpeg-mini-x86_64.pkg.tar.zst' \
|
||||
fi
|
||||
done
|
||||
|
||||
for SCENARIO in success replaced corrupted download-failure metadata-failure invalid-metadata; do
|
||||
for SCENARIO in success replaced stale corrupted download-failure metadata-failure invalid-metadata; do
|
||||
TEST_RELEASE="${TEMP_DIR}/release.json"
|
||||
if [[ "${SCENARIO}" == invalid-metadata ]]; then
|
||||
# A valid first row must not allow downloads when a later row is invalid.
|
||||
@@ -206,13 +254,15 @@ for SCENARIO in success replaced corrupted download-failure metadata-failure inv
|
||||
AnyLinuxDependenciesMain
|
||||
' _ "${TEMP_DIR}" \
|
||||
>"${TEMP_DIR}/${SCENARIO}.log" 2>&1; then
|
||||
[[ "${SCENARIO}" == success || "${SCENARIO}" == replaced ]] ||
|
||||
[[ "${SCENARIO}" == success || "${SCENARIO}" == replaced ||
|
||||
"${SCENARIO}" == stale ]] ||
|
||||
Fail "installed packages after ${SCENARIO}"
|
||||
[[ -f "${TEMP_DIR}/installed" ]]
|
||||
(cd "${TEMP_DIR}/${SCENARIO}" && sha256sum --check --strict SHA256SUMS)
|
||||
rm -- "${TEMP_DIR}/installed"
|
||||
else
|
||||
if [[ "${SCENARIO}" == success || "${SCENARIO}" == replaced ]]; then
|
||||
if [[ "${SCENARIO}" == success || "${SCENARIO}" == replaced ||
|
||||
"${SCENARIO}" == stale ]]; then
|
||||
cat -- "${TEMP_DIR}/${SCENARIO}.log" >&2
|
||||
Fail "valid download failed: ${SCENARIO}"
|
||||
fi
|
||||
@@ -220,6 +270,8 @@ for SCENARIO in success replaced corrupted download-failure metadata-failure inv
|
||||
fi
|
||||
case "${SCENARIO}" in
|
||||
success)
|
||||
[[ ! -e "${TEMP_DIR}/restored" ]] ||
|
||||
Fail 'restored a package that broke nothing'
|
||||
[[ "$(wc -l <"${TEMP_DIR}/calls")" == 3 ]]
|
||||
grep -Fq "${API}/assets/10" "${TEMP_DIR}/calls" ;;
|
||||
replaced)
|
||||
@@ -227,6 +279,10 @@ for SCENARIO in success replaced corrupted download-failure metadata-failure inv
|
||||
grep -Fq "${API}/assets/11" "${TEMP_DIR}/calls"
|
||||
grep -Fq "${API}/assets/11" "${TEMP_DIR}/replaced/dependencies.tsv"
|
||||
grep -Fq "${API}/assets/21" "${TEMP_DIR}/replaced/dependencies.tsv" ;;
|
||||
stale)
|
||||
[[ "$(cat -- "${TEMP_DIR}/restored" 2>/dev/null)" == ffmpeg ]] ||
|
||||
Fail 'kept a stale package that broke another package'
|
||||
rm -- "${TEMP_DIR}/restored" ;;
|
||||
corrupted)
|
||||
grep -Fq 'dependency checksum verification failed' "${TEMP_DIR}/${SCENARIO}.log" ;;
|
||||
download-failure)
|
||||
|
||||
@@ -11,6 +11,7 @@ readonly TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mocktail-anylinux-test.XXXXXX")"
|
||||
trap 'rm -rf -- "${TEMP_DIR}"' EXIT
|
||||
|
||||
source "${ROOT}/scripts/package_anylinux.sh"
|
||||
export UPINFO=""
|
||||
mkdir -p -- "${TEMP_DIR}/fixtures"
|
||||
|
||||
# Compile the real adapter loader and updater canary. The fake runtime
|
||||
@@ -79,6 +80,18 @@ if [[ "$1" == --make-appimage ]]; then
|
||||
printf 'make-appimage\n' >>"${MOCKTAIL_PACKAGING_FIXTURES}/calls"
|
||||
mkdir -p -- "${OUTPATH}"
|
||||
cp -- "$(type -P true)" "${OUTPATH}/${OUTNAME}"
|
||||
if [[ -n "${UPINFO:-}" ]]; then
|
||||
printf '%s\n' "${UPINFO}" >"${OUTPATH}/update-info"
|
||||
case "${MOCKTAIL_TEST_ZSYNC:-present}" in
|
||||
present)
|
||||
printf 'Filename: %s\nURL: %s\n\n\0checksum fixture' "${OUTNAME}" "${OUTNAME}" \
|
||||
>"${OUTPATH}/${OUTNAME}.zsync"
|
||||
cp -- "${OUTPATH}/${OUTNAME}.zsync" "${OUTPATH}/expected.zsync"
|
||||
;;
|
||||
empty) touch "${OUTPATH}/${OUTNAME}.zsync" ;;
|
||||
missing) : ;;
|
||||
esac
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
[[ "$#" == 7 && "$1" == /usr/bin/mocktail &&
|
||||
@@ -145,8 +158,41 @@ mkdir -p -- "${ANYLINUX_WORK}"
|
||||
AnyLinuxVerifyInstalled() { :; }
|
||||
AnyLinuxDeploy
|
||||
[[ -x "${ANYLINUX_OUTPUT}" ]]
|
||||
[[ ! -e "${ANYLINUX_OUTPUT}.zsync" ]]
|
||||
[[ "$(<"${TEMP_DIR}/fixtures/calls")" == $'deploy-usr\nmake-appimage' ]]
|
||||
|
||||
for filename in mocktail-nightly.AppImage mocktail-nightly-aarch64.AppImage \
|
||||
Mocktail-1.0.5-x86_64.AppImage; do
|
||||
(
|
||||
ANYLINUX_WORK="${TEMP_DIR}/updates/${filename}"
|
||||
ANYLINUX_OUTPUT="${TEMP_DIR}/${filename}"
|
||||
export UPINFO="gh-releases-zsync|owner|repository|continuous|${filename}.zsync"
|
||||
if [[ "${filename}" == Mocktail-* ]]; then
|
||||
UPINFO='gh-releases-zsync|owner|repository|latest|Mocktail-*-x86_64.AppImage.zsync'
|
||||
fi
|
||||
AnyLinuxDeploy
|
||||
[[ "$(<"${ANYLINUX_WORK}/image/update-info")" == "${UPINFO}" ]]
|
||||
cmp -- "${ANYLINUX_WORK}/image/expected.zsync" "${ANYLINUX_OUTPUT}.zsync"
|
||||
grep -aFxq "URL: ${filename}" "${ANYLINUX_OUTPUT}.zsync"
|
||||
[[ ! -e "${ANYLINUX_WORK}/image/${filename}.zsync" ]]
|
||||
)
|
||||
done
|
||||
|
||||
for sidecar in missing empty; do
|
||||
if (
|
||||
ANYLINUX_WORK="${TEMP_DIR}/updates/${sidecar}"
|
||||
ANYLINUX_OUTPUT="${TEMP_DIR}/${sidecar}.AppImage"
|
||||
export UPINFO="gh-releases-zsync|owner|repository|continuous|${sidecar}.AppImage.zsync"
|
||||
export MOCKTAIL_TEST_ZSYNC="${sidecar}"
|
||||
AnyLinuxDeploy
|
||||
) >"${TEMP_DIR}/${sidecar}.log" 2>&1; then
|
||||
printf 'packager accepted a %s .zsync file\n' "${sidecar}" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq 'no .zsync was generated' "${TEMP_DIR}/${sidecar}.log"
|
||||
[[ ! -e "${TEMP_DIR}/${sidecar}.AppImage" ]]
|
||||
done
|
||||
|
||||
mv -- "${ANYLINUX_WORK}/AppDir" "${TEMP_DIR}/relocated AppDir"
|
||||
app_dir="${TEMP_DIR}/relocated AppDir"
|
||||
AnyLinuxVerifyAppDir "${app_dir}"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "libc_shim/libc_shim.h"
|
||||
@@ -205,6 +206,21 @@ TEST(BionicHostLibcRuntimeTest, HidesPrivilegedHostIdentityFromAndroidGuest) {
|
||||
mocktail::compat::NormalizeBionicApplicationUid(::geteuid()));
|
||||
}
|
||||
|
||||
TEST(BionicHostLibcRuntimeTest, ParsesAtoiWithBionicOverflowBehavior) {
|
||||
EXPECT_EQ(mocktail_bionic_atoi(""), 0);
|
||||
EXPECT_EQ(mocktail_bionic_atoi("invalid"), 0);
|
||||
EXPECT_EQ(mocktail_bionic_atoi(" -42suffix"), -42);
|
||||
EXPECT_EQ(mocktail_bionic_atoi("2147483647"), INT_MAX);
|
||||
EXPECT_EQ(mocktail_bionic_atoi("-2147483648"), INT_MIN);
|
||||
EXPECT_EQ(mocktail_bionic_atoi("2147483648"), INT_MIN);
|
||||
EXPECT_EQ(mocktail_bionic_atoi("4294967295"), -1);
|
||||
EXPECT_EQ(mocktail_bionic_atoi("9999999999"), 1410065407);
|
||||
|
||||
errno = 0;
|
||||
EXPECT_EQ(mocktail_bionic_atoi("9223372036854775808"), -1);
|
||||
EXPECT_EQ(errno, ERANGE);
|
||||
}
|
||||
|
||||
TEST(BionicHostLibcRuntimeTest, UsesBionicPosixStrErrorContract) {
|
||||
std::array<char, 128> buffer{};
|
||||
|
||||
@@ -214,9 +230,18 @@ TEST(BionicHostLibcRuntimeTest, UsesBionicPosixStrErrorContract) {
|
||||
EXPECT_NE(std::strstr(buffer.data(), "Invalid"), nullptr);
|
||||
EXPECT_EQ(errno, EDOM);
|
||||
|
||||
buffer.fill('\0');
|
||||
EXPECT_EQ(mocktail_bionic_strerror_r(4567, buffer.data(), buffer.size()), 0);
|
||||
EXPECT_NE(std::strstr(buffer.data(), "4567"), nullptr);
|
||||
EXPECT_EQ(mocktail_bionic_strerror_r(0, buffer.data(), buffer.size()), 0);
|
||||
EXPECT_STREQ(buffer.data(), std::strerror(0));
|
||||
EXPECT_EQ(errno, EDOM);
|
||||
|
||||
for (const int error_number : {-1, 41, 58, 134, 4567, INT_MIN, INT_MAX}) {
|
||||
errno = EDOM;
|
||||
ASSERT_EQ(mocktail_bionic_strerror_r(error_number, buffer.data(),
|
||||
buffer.size()), 0);
|
||||
EXPECT_EQ(std::string(buffer.data()),
|
||||
"Unknown error " + std::to_string(error_number));
|
||||
EXPECT_EQ(errno, EDOM);
|
||||
}
|
||||
|
||||
std::array<char, 2> truncated{};
|
||||
errno = 0;
|
||||
|
||||
@@ -231,9 +231,79 @@ TEST_F(BrowserTrackerServiceTest,
|
||||
EXPECT_EQ(storage_parent.st_mode & 0777, 0700U);
|
||||
}
|
||||
|
||||
TEST_F(BrowserTrackerServiceTest, TightensOwnedGroupWritableStorageDirectory) {
|
||||
std::filesystem::create_directories(Storage().parent_path());
|
||||
ASSERT_EQ(chmod(Storage().parent_path().c_str(), 0775), 0);
|
||||
std::ofstream(Cookies()) << ".ROBLOSECURITY=secret";
|
||||
FakeHttpClient http;
|
||||
http.response = {true,
|
||||
200,
|
||||
R"({"browserTrackerId":987654321})",
|
||||
"",
|
||||
{"Set-Cookie: RBXEventTrackerV2="
|
||||
"CreateDate=1&rbxid=2&browserid=987654321; Path=/"}};
|
||||
|
||||
const BrowserTrackerResult result =
|
||||
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
|
||||
|
||||
ASSERT_TRUE(result) << result.error;
|
||||
EXPECT_EQ(result.browser_tracker_id, "987654321");
|
||||
struct stat storage_parent = {};
|
||||
ASSERT_EQ(stat(Storage().parent_path().c_str(), &storage_parent), 0);
|
||||
EXPECT_EQ(storage_parent.st_mode & 0777, 0755U);
|
||||
}
|
||||
|
||||
TEST_F(BrowserTrackerServiceTest, RejectsStorageFileItCannotVouchFor) {
|
||||
std::filesystem::create_directories(Storage().parent_path());
|
||||
ASSERT_EQ(chmod(Storage().parent_path().c_str(), 0775), 0);
|
||||
const std::filesystem::path planted = root_ / "planted.json";
|
||||
std::ofstream(planted) << R"({"BrowserTrackerId":"123456"})";
|
||||
std::ofstream(Cookies()) << ".ROBLOSECURITY=secret";
|
||||
FakeHttpClient http;
|
||||
|
||||
// A second hard link means another name can still change the contents.
|
||||
std::filesystem::create_hard_link(planted, Storage());
|
||||
BrowserTrackerResult result =
|
||||
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
|
||||
EXPECT_FALSE(result);
|
||||
EXPECT_EQ(result.error, "appStorage is not a private regular file");
|
||||
|
||||
std::filesystem::remove(Storage());
|
||||
std::filesystem::create_symlink(planted, Storage());
|
||||
result =
|
||||
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
|
||||
EXPECT_FALSE(result);
|
||||
EXPECT_EQ(result.error, "appStorage is not a private regular file");
|
||||
EXPECT_EQ(http.post_count, 0);
|
||||
EXPECT_EQ(ReadFile(planted), R"({"BrowserTrackerId":"123456"})");
|
||||
}
|
||||
|
||||
TEST_F(BrowserTrackerServiceTest, ReplacesRobloxUnsetStoredIdentity) {
|
||||
std::filesystem::create_directories(Storage().parent_path());
|
||||
std::ofstream(Storage()) << R"({"BrowserTrackerId":"0","keep":true})";
|
||||
std::ofstream(Cookies()) << ".ROBLOSECURITY=secret";
|
||||
FakeHttpClient http;
|
||||
http.response = {true,
|
||||
200,
|
||||
R"({"browserTrackerId":987654321})",
|
||||
"",
|
||||
{"Set-Cookie: RBXEventTrackerV2="
|
||||
"CreateDate=1&rbxid=2&browserid=987654321; Path=/"}};
|
||||
|
||||
const BrowserTrackerResult result =
|
||||
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
|
||||
|
||||
ASSERT_TRUE(result) << result.error;
|
||||
EXPECT_EQ(result.status, BrowserTrackerStatus::kCreated);
|
||||
std::ifstream input(Storage());
|
||||
const nlohmann::json saved = nlohmann::json::parse(input);
|
||||
EXPECT_EQ(saved.at("BrowserTrackerId"), "987654321");
|
||||
EXPECT_TRUE(saved.at("keep"));
|
||||
}
|
||||
|
||||
TEST_F(BrowserTrackerServiceTest, RejectsCorruptStoredIdentityFailClosed) {
|
||||
std::filesystem::create_directories(Storage().parent_path());
|
||||
std::ofstream(Storage()) << R"({"BrowserTrackerId":"0"})";
|
||||
std::ofstream(Storage()) << R"({"BrowserTrackerId":"12x"})";
|
||||
std::ofstream(Cookies()) << ".ROBLOSECURITY=secret";
|
||||
FakeHttpClient http;
|
||||
|
||||
@@ -364,7 +434,7 @@ TEST_F(BrowserTrackerServiceTest,
|
||||
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
|
||||
|
||||
EXPECT_FALSE(result);
|
||||
EXPECT_EQ(result.error, "cannot atomically replace appStorage");
|
||||
EXPECT_EQ(result.error, "appStorage is not a private regular file");
|
||||
EXPECT_EQ(http.post_count, 0);
|
||||
EXPECT_EQ(ReadFile(protected_storage), storage_contents);
|
||||
EXPECT_EQ(ReadFile(Storage()), storage_contents);
|
||||
|
||||
@@ -50,6 +50,10 @@ LATEST_CANDIDATE_METADATA = (
|
||||
LATEST_REFERENCE_PROFILE = REFERENCE_PROFILE_PATH
|
||||
DEVICE_LISTS_PAYLOAD_ID = "3092-5f0704edd9064f566ee3d6df2bd2fabbcc709f03"
|
||||
DEVICE_LISTS_LIBRARY = PAYLOAD_ROOT / DEVICE_LISTS_PAYLOAD_ID / "libroblox.so"
|
||||
FALLBACK_CANDIDATE_PAYLOAD_ID = "3222-a67a91ef96c700c0730acd36a250c2f93cfafbc1"
|
||||
FALLBACK_CANDIDATE_LIBRARY = (
|
||||
PAYLOAD_ROOT / FALLBACK_CANDIDATE_PAYLOAD_ID / "libroblox.so"
|
||||
)
|
||||
|
||||
|
||||
def load_analyzer():
|
||||
@@ -356,6 +360,61 @@ class SignatureSemanticsTest(unittest.TestCase):
|
||||
|
||||
ANALYZER.validate_semantic_match(reference, candidate, spec)
|
||||
|
||||
REGISTRY_BODY = (
|
||||
"bf38030000 e800000000 4889c3 4531f6 ba38030000 "
|
||||
"4889c7 31f6 e800000000 b80000803f 894320 488d4b28"
|
||||
)
|
||||
|
||||
def renamed_registry_body_spec(self, renamed: tuple[int, ...]):
|
||||
return ANALYZER.SignatureSpec(
|
||||
"registry-body", 0x1000, 11, True, 6, (0, 4), renamed
|
||||
)
|
||||
|
||||
def test_registry_body_registers_may_be_renamed(self) -> None:
|
||||
reference = decode_x86(bytes.fromhex(self.REGISTRY_BODY))
|
||||
# r15d for r14d, r13d for eax, r12 for rcx, and a smaller object.
|
||||
candidate = decode_x86(
|
||||
bytes.fromhex(
|
||||
"bff8020000 e800000000 4889c3 4531ff baf8020000 "
|
||||
"4889c7 31f6 e800000000 41bd0000803f 44896b20 4c8d6328"
|
||||
),
|
||||
0x2000,
|
||||
)
|
||||
|
||||
ANALYZER.validate_semantic_match(
|
||||
reference, candidate, self.renamed_registry_body_spec((3, 8, 9, 10))
|
||||
)
|
||||
with self.assertRaisesRegex(ANALYZER.AnalyzerError, "semantics changed"):
|
||||
ANALYZER.validate_semantic_match(
|
||||
reference, candidate, self.renamed_registry_body_spec(())
|
||||
)
|
||||
|
||||
def test_renamed_registers_must_stay_consistent(self) -> None:
|
||||
reference = decode_x86(bytes.fromhex(self.REGISTRY_BODY))
|
||||
# The constant goes to r13d but r14d is stored.
|
||||
two_for_one = decode_x86(
|
||||
bytes.fromhex(
|
||||
"bff8020000 e800000000 4889c3 4531ff baf8020000 "
|
||||
"4889c7 31f6 e800000000 41bd0000803f 44897320 4c8d6328"
|
||||
),
|
||||
0x2000,
|
||||
)
|
||||
# r13 stands for both r14 and rax.
|
||||
one_for_two = decode_x86(
|
||||
bytes.fromhex(
|
||||
"bff8020000 e800000000 4889c3 4531ed baf8020000 "
|
||||
"4889c7 31f6 e800000000 41bd0000803f 44896b20 4c8d6328"
|
||||
),
|
||||
0x2000,
|
||||
)
|
||||
spec = self.renamed_registry_body_spec((3, 8, 9, 10))
|
||||
|
||||
for candidate in (two_for_one, one_for_two):
|
||||
with self.assertRaisesRegex(
|
||||
ANALYZER.AnalyzerError, "semantics changed"
|
||||
):
|
||||
ANALYZER.validate_semantic_match(reference, candidate, spec)
|
||||
|
||||
def test_short_anchor_is_opt_in(self) -> None:
|
||||
encoded = b"abc\0def"
|
||||
mask = b"\x01\x01\x01\0\x01\x01\x01"
|
||||
@@ -364,6 +423,116 @@ class SignatureSemanticsTest(unittest.TestCase):
|
||||
self.assertEqual(ANALYZER.longest_fixed_anchor(encoded, mask, 3), (0, b"abc"))
|
||||
|
||||
|
||||
class ConstructorRangeRemapTest(unittest.TestCase):
|
||||
HOST_BRIDGE = [
|
||||
{"begin": 2, "end_exclusive": 3},
|
||||
{"begin": 5, "end_exclusive": 100},
|
||||
]
|
||||
NATIVE = [{"begin": 2, "end_exclusive": 100}]
|
||||
# The 2.742 order: reference constructors 2, 3, 4, 5 are candidate
|
||||
# constructors 5, 2, 4, 3.
|
||||
REORDERED = (3, 0, 2, 1)
|
||||
|
||||
def test_same_order_only_moves_the_end(self) -> None:
|
||||
self.assertEqual(
|
||||
ANALYZER.remapped_constructor_ranges(
|
||||
self.HOST_BRIDGE, (0, 1, 2, 3), 100, 120
|
||||
),
|
||||
[
|
||||
{"begin": 2, "end_exclusive": 3},
|
||||
{"begin": 5, "end_exclusive": 120},
|
||||
],
|
||||
)
|
||||
|
||||
def test_leading_constructors_follow_their_new_index(self) -> None:
|
||||
self.assertEqual(
|
||||
ANALYZER.remapped_constructor_ranges(
|
||||
self.HOST_BRIDGE, self.REORDERED, 100, 120
|
||||
),
|
||||
[
|
||||
{"begin": 3, "end_exclusive": 4},
|
||||
{"begin": 5, "end_exclusive": 120},
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
ANALYZER.remapped_constructor_ranges(
|
||||
self.NATIVE, self.REORDERED, 100, 120
|
||||
),
|
||||
[{"begin": 2, "end_exclusive": 120}],
|
||||
)
|
||||
|
||||
def test_rejects_ranges_outside_the_verified_shape(self) -> None:
|
||||
for ranges in (
|
||||
# Runs a constructor before the leading four.
|
||||
[{"begin": 1, "end_exclusive": 100}],
|
||||
# Stops before the end of the init-array.
|
||||
[{"begin": 2, "end_exclusive": 99}],
|
||||
# Skips a constructor after the leading four.
|
||||
[{"begin": 2, "end_exclusive": 6}, {"begin": 7, "end_exclusive": 100}],
|
||||
):
|
||||
with self.assertRaisesRegex(ANALYZER.AnalyzerError, "cannot fit"):
|
||||
ANALYZER.remapped_constructor_ranges(
|
||||
ranges, self.REORDERED, 100, 120
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipIf(ANALYZER.capstone is None, "Python capstone is unavailable")
|
||||
class JniSingletonPublicationTest(unittest.TestCase):
|
||||
class Image:
|
||||
def __init__(self, encoded: bytes):
|
||||
self.data = encoded
|
||||
self.file_size = len(encoded)
|
||||
|
||||
def require_code_rva(self, rva: int, size: int = 1) -> None:
|
||||
if rva != 0x1000 or size > len(self.data):
|
||||
raise ANALYZER.AnalyzerError("unexpected code RVA")
|
||||
|
||||
def rva_to_offset(self, rva: int, size: int = 1) -> int:
|
||||
return rva - 0x1000
|
||||
|
||||
def bytes_at(self, offset: int, size: int, _description: str) -> bytes:
|
||||
return self.data[offset : offset + size]
|
||||
|
||||
@staticmethod
|
||||
def publication(rva: int, slot: int, stored: int) -> bytes:
|
||||
def rip(opcode: str, instruction_rva: int, target: int) -> bytes:
|
||||
return bytes.fromhex(opcode) + struct.pack(
|
||||
"<i", target - (instruction_rva + 7)
|
||||
)
|
||||
|
||||
return (
|
||||
rip("488d05", rva, slot + 8)
|
||||
+ rip("488705", rva + 7, slot)
|
||||
+ rip("488905", rva + 14, stored)
|
||||
)
|
||||
|
||||
def test_finds_the_publication_with_and_without_a_frame(self) -> None:
|
||||
bare = self.Image(self.publication(0x1000, 0x2000, 0x2008) + b"\xc3")
|
||||
framed = self.Image(
|
||||
bytes.fromhex("554889e5")
|
||||
+ self.publication(0x1004, 0x2000, 0x2008)
|
||||
+ b"\x5d\xc3"
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
ANALYZER.jni_singleton_publication(bare, 0x1000)[0].address, 0x1000
|
||||
)
|
||||
publication = ANALYZER.jni_singleton_publication(framed, 0x1000)
|
||||
self.assertEqual(len(publication), 3)
|
||||
self.assertEqual(publication[0].address, 0x1004)
|
||||
self.assertEqual(ANALYZER.rip_targets(publication[1]), (0x2000,))
|
||||
|
||||
def test_rejects_code_that_publishes_nothing(self) -> None:
|
||||
other_object = self.Image(
|
||||
self.publication(0x1000, 0x2000, 0x2010) + b"\xc3"
|
||||
)
|
||||
unrelated = self.Image(bytes.fromhex("554889e5 4157 4156 4155 c3"))
|
||||
|
||||
for image in (other_object, unrelated):
|
||||
with self.assertRaisesRegex(ANALYZER.AnalyzerError, "does not publish"):
|
||||
ANALYZER.jni_singleton_publication(image, 0x1000)
|
||||
|
||||
|
||||
@unittest.skipIf(ANALYZER.capstone is None, "Python capstone is unavailable")
|
||||
class RegistrySlotDerivationTest(unittest.TestCase):
|
||||
class Image:
|
||||
@@ -634,5 +803,38 @@ class DeviceListsRuntimeCompatibilityAcceptanceTest(unittest.TestCase):
|
||||
self.assertEqual(derived["fmod_output_device_bridge"], self.EXPECTED_BRIDGE)
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
ANALYZER.capstone is not None
|
||||
and CANDIDATE_LIBRARY.is_file()
|
||||
and DEVICE_LISTS_LIBRARY.is_file()
|
||||
and FALLBACK_CANDIDATE_LIBRARY.is_file(),
|
||||
"local exact 2998, 3092 and 3222 payloads are unavailable",
|
||||
)
|
||||
class FallbackRuntimeCompatibilityAcceptanceTest(unittest.TestCase):
|
||||
MANIFESTS = (PROJECT_ROOT / "config" / "roblox_compatibility.json",)
|
||||
|
||||
def test_fallback_library_supplies_missing_fmod_bridge(self):
|
||||
# The shipped manifest describes 2998 but not 3092.
|
||||
with ANALYZER.ElfImage(DEVICE_LISTS_LIBRARY) as reference, ANALYZER.ElfImage(
|
||||
FALLBACK_CANDIDATE_LIBRARY
|
||||
) as candidate, ANALYZER.ElfImage(CANDIDATE_LIBRARY) as fallback:
|
||||
without = ANALYZER.derive_runtime_compatibility(
|
||||
reference, candidate, self.MANIFESTS)
|
||||
derived = ANALYZER.derive_runtime_compatibility(
|
||||
reference, candidate, self.MANIFESTS, fallback)
|
||||
self.assertNotIn("fmod_output_device_bridge", without)
|
||||
bridge = derived["fmod_output_device_bridge"]
|
||||
self.assertEqual(bridge["vtable_layout_version"], 2)
|
||||
self.assertIn("input_count_method_rva", bridge)
|
||||
|
||||
def test_fallback_without_anchors_leaves_bridge_out(self):
|
||||
with ANALYZER.ElfImage(DEVICE_LISTS_LIBRARY) as reference, ANALYZER.ElfImage(
|
||||
FALLBACK_CANDIDATE_LIBRARY
|
||||
) as candidate:
|
||||
derived = ANALYZER.derive_runtime_compatibility(
|
||||
reference, candidate, self.MANIFESTS, reference)
|
||||
self.assertNotIn("fmod_output_device_bridge", derived)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -37,14 +37,14 @@ finish_args = set(manifest["finish-args"])
|
||||
required_permissions = {
|
||||
"--share=network",
|
||||
"--socket=wayland",
|
||||
"--socket=x11",
|
||||
"--socket=fallback-x11",
|
||||
"--socket=pulseaudio",
|
||||
"--device=dri",
|
||||
"--filesystem=xdg-run/discord-ipc-0:rw",
|
||||
}
|
||||
assert required_permissions <= finish_args
|
||||
# Keep NVIDIA's XWayland fallback available even when Wayland is present.
|
||||
assert "--socket=fallback-x11" not in finish_args
|
||||
# X11 is only a fallback; Wayland sessions get no X11 socket.
|
||||
assert "--socket=x11" not in finish_args
|
||||
assert not any(argument.startswith("--filesystem=host") for argument in finish_args)
|
||||
assert not any(argument == "--filesystem=home" for argument in finish_args)
|
||||
|
||||
@@ -193,9 +193,14 @@ cat >"${TEMP_DIR}/bin/flatpak" <<'EOF'
|
||||
exit 1
|
||||
EOF
|
||||
chmod 0755 "${TEMP_DIR}/bin/uname" "${TEMP_DIR}/bin/flatpak"
|
||||
# The helper sees only these tools, so a flatpak-builder on the host stays
|
||||
# out of its PATH.
|
||||
for tool in bash dirname realpath; do
|
||||
ln -s -- "$(command -v "${tool}")" "${TEMP_DIR}/bin/${tool}"
|
||||
done
|
||||
|
||||
set +e
|
||||
PATH="${TEMP_DIR}/bin:/usr/bin:/bin" \
|
||||
PATH="${TEMP_DIR}/bin" \
|
||||
"${BUILD_HELPER}" --build-dir "${ROOT}/build-flatpak-test" \
|
||||
>"${TEMP_DIR}/stdout" 2>"${TEMP_DIR}/stderr"
|
||||
helper_status=$?
|
||||
|
||||
@@ -154,6 +154,84 @@ TEST(HostAbiProfileTest, ResearchedBaselineKeepsHostBridgeDefault) {
|
||||
HostAllocatorStrategy::kNativeMimalloc);
|
||||
}
|
||||
|
||||
// A profile of 100 constructors whose native replay starts at index 2.
|
||||
HostAbiProfile ConstructorPolicyProfile(
|
||||
size_t checkpoint, std::initializer_list<ConstructorRange> host_ranges) {
|
||||
HostAbiProfile profile;
|
||||
profile.init_array_count = 100;
|
||||
for (const ConstructorRange& range : host_ranges) {
|
||||
profile.constructor_run_ranges[profile.constructor_run_range_count++] =
|
||||
range;
|
||||
}
|
||||
profile.native_mimalloc_constructor_run_ranges[0] = {2, 100};
|
||||
profile.native_mimalloc_constructor_run_range_count = 1;
|
||||
profile.native_mimalloc_thread_initializer_after_constructor = checkpoint;
|
||||
return profile;
|
||||
}
|
||||
|
||||
TEST(HostAbiProfileTest, ConstructorPolicyAcceptsProcessLoadConstructorFirst) {
|
||||
EXPECT_TRUE(ConstructorPolicyProfile(2, {{2, 3}, {5, 100}})
|
||||
.HasVerifiedConstructorPolicy(5));
|
||||
}
|
||||
|
||||
TEST(HostAbiProfileTest, ConstructorPolicyAcceptsProcessLoadConstructorLast) {
|
||||
// The 2.742 order: wiring, JNI singleton, wiring, process-load constructor.
|
||||
EXPECT_TRUE(ConstructorPolicyProfile(5, {{3, 4}, {5, 100}})
|
||||
.HasVerifiedConstructorPolicy(3));
|
||||
}
|
||||
|
||||
TEST(HostAbiProfileTest, ConstructorPolicyRequiresJniSingletonConstructor) {
|
||||
// Host bridges run a wiring constructor and skip the JNI singleton.
|
||||
EXPECT_FALSE(ConstructorPolicyProfile(2, {{2, 4}, {6, 100}})
|
||||
.HasVerifiedConstructorPolicy(5));
|
||||
EXPECT_FALSE(ConstructorPolicyProfile(5, {{2, 3}, {5, 100}})
|
||||
.HasVerifiedConstructorPolicy(3));
|
||||
// The JNI singleton constructor is the checkpoint constructor.
|
||||
EXPECT_FALSE(ConstructorPolicyProfile(2, {{2, 3}, {5, 100}})
|
||||
.HasVerifiedConstructorPolicy(2));
|
||||
// The JNI singleton constructor is outside the four leading constructors.
|
||||
EXPECT_FALSE(ConstructorPolicyProfile(2, {{2, 3}, {5, 100}})
|
||||
.HasVerifiedConstructorPolicy(6));
|
||||
}
|
||||
|
||||
TEST(HostAbiProfileTest, ConstructorPolicyRejectsOtherShapes) {
|
||||
// The checkpoint is outside the four leading constructors.
|
||||
EXPECT_FALSE(ConstructorPolicyProfile(6, {{2, 3}, {5, 100}})
|
||||
.HasVerifiedConstructorPolicy(5));
|
||||
// The checkpoint constructor does not run under host bridges.
|
||||
EXPECT_FALSE(ConstructorPolicyProfile(3, {{2, 3}, {5, 100}})
|
||||
.HasVerifiedConstructorPolicy(5));
|
||||
// Only one wiring constructor is excluded.
|
||||
EXPECT_FALSE(ConstructorPolicyProfile(2, {{2, 4}, {5, 100}})
|
||||
.HasVerifiedConstructorPolicy(5));
|
||||
// A constructor after the leading four is excluded.
|
||||
EXPECT_FALSE(ConstructorPolicyProfile(2, {{2, 3}, {5, 6}, {7, 100}})
|
||||
.HasVerifiedConstructorPolicy(5));
|
||||
// Host bridges run a constructor the native replay skips.
|
||||
EXPECT_FALSE(ConstructorPolicyProfile(2, {{1, 3}, {5, 100}})
|
||||
.HasVerifiedConstructorPolicy(5));
|
||||
// The replay stops before the end of the init-array.
|
||||
EXPECT_FALSE(ConstructorPolicyProfile(2, {{2, 3}, {5, 99}})
|
||||
.HasVerifiedConstructorPolicy(5));
|
||||
|
||||
HostAbiProfile split_native = ConstructorPolicyProfile(2, {{2, 3}, {5, 100}});
|
||||
split_native.native_mimalloc_constructor_run_ranges[0] = {2, 50};
|
||||
split_native.native_mimalloc_constructor_run_ranges[1] = {50, 100};
|
||||
split_native.native_mimalloc_constructor_run_range_count = 2;
|
||||
EXPECT_FALSE(split_native.HasVerifiedConstructorPolicy(5));
|
||||
|
||||
// The init-array ends inside the four leading constructors.
|
||||
HostAbiProfile too_short = ConstructorPolicyProfile(2, {{2, 3}});
|
||||
too_short.init_array_count = 5;
|
||||
too_short.native_mimalloc_constructor_run_ranges[0] = {2, 5};
|
||||
EXPECT_FALSE(too_short.HasVerifiedConstructorPolicy(4));
|
||||
|
||||
HostAbiProfile shortest = ConstructorPolicyProfile(2, {{2, 3}, {5, 6}});
|
||||
shortest.init_array_count = 6;
|
||||
shortest.native_mimalloc_constructor_run_ranges[0] = {2, 6};
|
||||
EXPECT_TRUE(shortest.HasVerifiedConstructorPolicy(5));
|
||||
}
|
||||
|
||||
TEST(HostAbiProfileTest, UnknownBuildHasNoNativeOffsets) {
|
||||
EXPECT_EQ(FindHostAbiProfile("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
|
||||
nullptr);
|
||||
@@ -428,7 +506,7 @@ class ExternalHostAbiProfileLoaderTest : public ::testing::Test {
|
||||
{"empty_string_initializer_rva",
|
||||
Hex(SymbolRva("MocktailFixtureThreadInitialize"))},
|
||||
{"jni_singleton_initializer_rva",
|
||||
Hex(SymbolRva("MocktailFixtureRegistryInitialize"))},
|
||||
Hex(SymbolRva("MocktailFixtureConstructorFive"))},
|
||||
{"constructor_rvas",
|
||||
{{"2", Hex(SymbolRva("MocktailFixtureConstructorTwo"))},
|
||||
{"3", Hex(SymbolRva("MocktailFixtureConstructorThree"))},
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#include "window/host_surface_commit_gate.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
namespace mocktail {
|
||||
namespace window {
|
||||
namespace {
|
||||
|
||||
using std::chrono::milliseconds;
|
||||
|
||||
TEST(HostSurfaceCommitGateTest, MainThreadWaitsForHostPresentToFinish) {
|
||||
HostSurfaceCommitGate gate;
|
||||
std::atomic<bool> present_done{false};
|
||||
std::atomic<bool> saw_present_done{false};
|
||||
std::atomic<bool> owned{false};
|
||||
|
||||
gate.BeginHostPresent();
|
||||
std::thread main_thread([&] {
|
||||
HostSurfaceCommitGate::MainThreadScope scope(gate, milliseconds(5000));
|
||||
owned.store(scope.owns_lock());
|
||||
saw_present_done.store(present_done.load());
|
||||
});
|
||||
std::this_thread::sleep_for(milliseconds(50));
|
||||
present_done.store(true);
|
||||
gate.EndHostPresent();
|
||||
main_thread.join();
|
||||
|
||||
EXPECT_TRUE(owned.load());
|
||||
EXPECT_TRUE(saw_present_done.load());
|
||||
}
|
||||
|
||||
TEST(HostSurfaceCommitGateTest, HostPresentWaitsForMainThreadScope) {
|
||||
HostSurfaceCommitGate gate;
|
||||
std::atomic<bool> scope_released{false};
|
||||
std::atomic<bool> saw_scope_released{false};
|
||||
std::thread render_thread;
|
||||
|
||||
{
|
||||
HostSurfaceCommitGate::MainThreadScope scope(gate, milliseconds(5000));
|
||||
ASSERT_TRUE(scope.owns_lock());
|
||||
render_thread = std::thread([&] {
|
||||
gate.BeginHostPresent();
|
||||
saw_scope_released.store(scope_released.load());
|
||||
gate.EndHostPresent();
|
||||
});
|
||||
std::this_thread::sleep_for(milliseconds(50));
|
||||
scope_released.store(true);
|
||||
}
|
||||
render_thread.join();
|
||||
|
||||
EXPECT_TRUE(saw_scope_released.load());
|
||||
}
|
||||
|
||||
TEST(HostSurfaceCommitGateTest, MainThreadGivesUpWhenHostPresentIsStuck) {
|
||||
HostSurfaceCommitGate gate;
|
||||
std::atomic<bool> owned{true};
|
||||
|
||||
gate.BeginHostPresent();
|
||||
std::thread main_thread([&] {
|
||||
HostSurfaceCommitGate::MainThreadScope scope(gate, milliseconds(1));
|
||||
owned.store(scope.owns_lock());
|
||||
});
|
||||
main_thread.join();
|
||||
gate.EndHostPresent();
|
||||
|
||||
EXPECT_FALSE(owned.load());
|
||||
}
|
||||
|
||||
TEST(HostSurfaceCommitGateTest, EndWithoutBeginIsIgnored) {
|
||||
HostSurfaceCommitGate gate;
|
||||
|
||||
gate.EndHostPresent();
|
||||
HostSurfaceCommitGate::MainThreadScope scope(gate, milliseconds(1));
|
||||
|
||||
EXPECT_TRUE(scope.owns_lock());
|
||||
}
|
||||
|
||||
TEST(HostSurfaceCommitGateTest, BeginWithoutEndDoesNotBlockTheNextPresent) {
|
||||
HostSurfaceCommitGate gate;
|
||||
|
||||
gate.BeginHostPresent();
|
||||
gate.BeginHostPresent();
|
||||
gate.EndHostPresent();
|
||||
HostSurfaceCommitGate::MainThreadScope scope(gate, milliseconds(1));
|
||||
|
||||
EXPECT_TRUE(scope.owns_lock());
|
||||
}
|
||||
|
||||
TEST(HostSurfaceCommitGateTest, PresentsFromOneThreadCanRepeat) {
|
||||
HostSurfaceCommitGate gate;
|
||||
|
||||
for (int frame = 0; frame < 3; ++frame) {
|
||||
gate.BeginHostPresent();
|
||||
gate.EndHostPresent();
|
||||
}
|
||||
HostSurfaceCommitGate::MainThreadScope scope(gate, milliseconds(1));
|
||||
|
||||
EXPECT_TRUE(scope.owns_lock());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace window
|
||||
} // namespace mocktail
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdarg>
|
||||
@@ -382,6 +383,31 @@ TEST_F(JniVmTest, JniEnvPointerIsNotNull) {
|
||||
EXPECT_NE(vm_->GetJNIEnv(), nullptr);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, EveryJniFunctionSlotHoldsAFunction) {
|
||||
JNIEnv* env = vm_->GetJNIEnv();
|
||||
ASSERT_NE(env, nullptr);
|
||||
// The first four slots are reserved and null by definition.
|
||||
constexpr std::size_t kReservedSlots = 4;
|
||||
std::array<void*, sizeof(JNINativeInterface_) / sizeof(void*)> slots;
|
||||
std::memcpy(slots.data(), env->functions, sizeof(JNINativeInterface_));
|
||||
std::string null_slots;
|
||||
for (std::size_t slot = kReservedSlots; slot < slots.size(); ++slot) {
|
||||
if (slots[slot] == nullptr) {
|
||||
null_slots += " " + std::to_string(slot);
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(null_slots.empty()) << "null JNI slots:" << null_slots;
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, UnimplementedJniFunctionsReturnZeroInsteadOfCrashing) {
|
||||
JNIEnv* env = vm_->GetJNIEnv();
|
||||
ASSERT_NE(env, nullptr);
|
||||
EXPECT_EQ(env->NewIntArray(4), nullptr);
|
||||
EXPECT_EQ(env->CallNonvirtualIntMethod(nullptr, nullptr, nullptr), 0);
|
||||
EXPECT_EQ(env->CallNonvirtualDoubleMethod(nullptr, nullptr, nullptr), 0.0);
|
||||
env->SetIntArrayRegion(nullptr, 0, 0, nullptr);
|
||||
}
|
||||
|
||||
#if defined(__x86_64__)
|
||||
constexpr unsigned int kAudioFpMode = (1U << 15) | (1U << 6);
|
||||
|
||||
@@ -681,6 +707,21 @@ TEST_F(JniVmTest, TraceAllNeverPrintsJStringContentOrPrefix) {
|
||||
EXPECT_EQ(trace_output.find("SECRET_CANARY_PREFIX"), std::string::npos);
|
||||
}
|
||||
|
||||
// Creates strings, which fixes the process-wide trace setting, so it stays
|
||||
// below the test that sets MOCKTAIL_TRACE_ALL.
|
||||
TEST_F(JniVmTest, ReleasingAReferenceTwiceDoesNotGiveOneHandleToTwoObjects) {
|
||||
JNIEnv* env = vm_->GetJNIEnv();
|
||||
ASSERT_NE(env, nullptr);
|
||||
jstring stale = env->NewStringUTF("stale");
|
||||
env->DeleteLocalRef(stale);
|
||||
env->DeleteLocalRef(stale);
|
||||
jstring first = env->NewStringUTF("first");
|
||||
jstring second = env->NewStringUTF("second");
|
||||
EXPECT_NE(first, second);
|
||||
EXPECT_EQ(ReadJavaString(env, first), "first");
|
||||
EXPECT_EQ(ReadJavaString(env, second), "second");
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, InitialClassCountIsZero) {
|
||||
EXPECT_EQ(vm_->GetClassCount(), 0u);
|
||||
}
|
||||
|
||||
@@ -130,6 +130,12 @@ TEST(LinkerRegistryTest, KeepsHostIndependentBionicAbiOwnedByLibc) {
|
||||
reinterpret_cast<void *>(mocktail___readlink_chk));
|
||||
EXPECT_EQ(symbols.at("arc4random_buf"),
|
||||
reinterpret_cast<void *>(mocktail_bionic_arc4random_buf));
|
||||
if (mocktail::compat::kHostAtoiMatchesBionic) {
|
||||
EXPECT_EQ(symbols.count("atoi"), 0u);
|
||||
} else {
|
||||
EXPECT_EQ(symbols.at("atoi"),
|
||||
reinterpret_cast<void *>(mocktail_bionic_atoi));
|
||||
}
|
||||
EXPECT_EQ(symbols.at("mallinfo"),
|
||||
reinterpret_cast<void *>(mocktail_bionic_mallinfo));
|
||||
EXPECT_EQ(symbols.at("sysinfo"),
|
||||
|
||||
@@ -121,8 +121,12 @@ for expected in \
|
||||
'--env GH_TOKEN' \
|
||||
'MOCKTAIL_ANYLINUX_SYSTEM_INSTALL=1' \
|
||||
'--appimage-extract-and-run mocktail_updater status' \
|
||||
'Mocktail-x86_64.AppImage' \
|
||||
'Nightcap-nightly-x86_64.AppImage' \
|
||||
'APPIMAGE=dist/Nightcap-nightly-x86_64.AppImage' \
|
||||
'|continuous|Nightcap-nightly-x86_64.AppImage.zsync' \
|
||||
'--env UPINFO' \
|
||||
'test -s dist/Nightcap-nightly-x86_64.AppImage.zsync' \
|
||||
'--appimage-updateinformation' \
|
||||
'Nightcap-nightly-x86_64.AppImage.zsync|' \
|
||||
'github-actions[bot]' \
|
||||
'gh release upload continuous'; do
|
||||
grep -Fq -- "${expected}" "${WORKFLOW}" ||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <minizip/zip.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <csignal>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
@@ -242,6 +244,53 @@ TEST(HttpDownloadPolicyTest, RejectsCredentialsAndHostSuffixTricks) {
|
||||
EXPECT_FALSE(IsTrustedHttpsUrl("https://user@pureapk.com/a", hosts));
|
||||
}
|
||||
|
||||
TEST(ReadinessCanaryTest, PreservesEtc2DriverOverride) {
|
||||
TemporaryDirectory temporary;
|
||||
CanaryOptions options;
|
||||
options.runtime_binary = temporary.root() / "runtime";
|
||||
options.cache_root = temporary.root() / "cache";
|
||||
options.state_root = temporary.root() / "state";
|
||||
options.payload_directory = temporary.root() / "payload";
|
||||
options.timeout_seconds = 5;
|
||||
Write(options.runtime_binary,
|
||||
"#!/bin/sh\nprintf 'etc2=%s\\n' \"$vk_require_etc2\"\n");
|
||||
ASSERT_EQ(chmod(options.runtime_binary.c_str(), 0700), 0);
|
||||
for (const char* value : {"true", "false"}) {
|
||||
const pid_t child = fork();
|
||||
ASSERT_GE(child, 0);
|
||||
if (child == 0) {
|
||||
if (setenv("vk_require_etc2", value, 1) != 0) std::_Exit(1);
|
||||
const auto result = RunReadinessCanary(options);
|
||||
std::_Exit(result.exit_code == 0 &&
|
||||
ReadFile(result.log_path) ==
|
||||
std::string("etc2=") + value + "\n"
|
||||
? 0 : 2);
|
||||
}
|
||||
int status = 0;
|
||||
ASSERT_EQ(waitpid(child, &status, 0), child);
|
||||
ASSERT_TRUE(WIFEXITED(status));
|
||||
EXPECT_EQ(WEXITSTATUS(status), 0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ReadinessCanaryTest, ReportsTerminatingSignal) {
|
||||
TemporaryDirectory temporary;
|
||||
CanaryOptions options;
|
||||
options.runtime_binary = temporary.root() / "runtime";
|
||||
options.cache_root = temporary.root() / "cache";
|
||||
options.state_root = temporary.root() / "state";
|
||||
options.payload_directory = temporary.root() / "payload";
|
||||
options.timeout_seconds = 5;
|
||||
Write(options.runtime_binary, "#!/bin/sh\nkill -TERM $$\n");
|
||||
ASSERT_EQ(chmod(options.runtime_binary.c_str(), 0700), 0);
|
||||
|
||||
const auto result = RunReadinessCanary(options);
|
||||
EXPECT_FALSE(result);
|
||||
EXPECT_EQ(result.exit_code, 128 + SIGTERM);
|
||||
EXPECT_EQ(result.error, "graphics canary exited with status " +
|
||||
std::to_string(128 + SIGTERM));
|
||||
}
|
||||
|
||||
TEST(ReadinessCanaryTest, AcceptsRealPresentWithoutShaderPackSummary) {
|
||||
const std::string valid =
|
||||
"[compat] legacy binary patches: disabled\n"
|
||||
@@ -1721,6 +1770,7 @@ TEST(PayloadStoreTest, StagesAndPromotesVerifiedExactPayload) {
|
||||
temporary.root() / "compatibility.json";
|
||||
Write(compatibility,
|
||||
"{\"schema_version\":1,\"profiles\":[{"
|
||||
"\"abi\":\"" + std::string(compat::kGuestAbi) + "\","
|
||||
"\"version_name\":\"2.727.1199\",\"version_code\":2628,"
|
||||
"\"elf_build_id\":\"1686400865ae0e408cd7bd67de7a439625c6fd13\","
|
||||
"\"status\":\"supported\",\"default_allowed\":true,"
|
||||
@@ -2062,5 +2112,71 @@ TEST(PayloadStoreTest, RequiresTwoRuntimeBoundCanariesForLatestCandidate) {
|
||||
metadata["sha256"]["libroblox"]);
|
||||
}
|
||||
|
||||
// Needs real Roblox payloads. MOCKTAIL_TEST_UPDATE_DATA_ROOT is an updater data
|
||||
// root whose derived 3092 profile has no FMOD output-device bridge.
|
||||
TEST(HostAbiDeriverRealPayloadTest, FallbackLibrarySuppliesMissingFmodBridge) {
|
||||
const char* data_root = std::getenv("MOCKTAIL_TEST_UPDATE_DATA_ROOT");
|
||||
if (data_root == nullptr) {
|
||||
GTEST_SKIP() << "real Roblox payloads were not explicitly provided";
|
||||
}
|
||||
const std::filesystem::path root = data_root;
|
||||
const std::string reference_id =
|
||||
"3092-5f0704edd9064f566ee3d6df2bd2fabbcc709f03";
|
||||
// Derived profiles are named <payload id>-<runtime hash>.json.
|
||||
std::string installed_profile;
|
||||
std::error_code scan_error;
|
||||
for (const auto& entry : std::filesystem::directory_iterator(
|
||||
root / "host_abi_profiles", scan_error)) {
|
||||
const std::string name = entry.path().filename().string();
|
||||
if (name.rfind(reference_id + "-", 0) == 0 &&
|
||||
std::filesystem::exists(root / "compatibility_profiles" / name)) {
|
||||
installed_profile = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
ASSERT_FALSE(installed_profile.empty());
|
||||
char pattern[] = "/tmp/mocktail_fmod_fallback_XXXXXX";
|
||||
const char* created = mkdtemp(pattern);
|
||||
ASSERT_NE(created, nullptr);
|
||||
const std::filesystem::path output = created;
|
||||
|
||||
const auto derive = [&](const std::filesystem::path& directory,
|
||||
const std::filesystem::path& fallback) {
|
||||
HostAbiDerivationOptions options;
|
||||
options.reference_library =
|
||||
root / "payloads" / reference_id / "libroblox.so";
|
||||
options.reference_profile = root / "host_abi_profiles" / installed_profile;
|
||||
options.reference_compatibility_manifests = {
|
||||
root / "compatibility_profiles" / installed_profile,
|
||||
std::filesystem::path(MOCKTAIL_TEST_SOURCE_DIR) /
|
||||
"config/roblox_compatibility.json"};
|
||||
options.runtime_fallback_library = fallback;
|
||||
options.candidate_payload_directory =
|
||||
root / "payloads/3222-a67a91ef96c700c0730acd36a250c2f93cfafbc1";
|
||||
options.output_directory = directory;
|
||||
const HostAbiDerivationResult derived = DeriveHostAbiProfile(options);
|
||||
EXPECT_TRUE(derived) << derived.error;
|
||||
std::ifstream input(derived.compatibility_manifest);
|
||||
const nlohmann::json manifest =
|
||||
nlohmann::json::parse(input, nullptr, false);
|
||||
return manifest.is_object() ? manifest["profiles"][0] : nlohmann::json();
|
||||
};
|
||||
|
||||
const nlohmann::json without = derive(output / "without", {});
|
||||
EXPECT_FALSE(without.contains("fmod_output_device_bridge"));
|
||||
|
||||
const nlohmann::json with = derive(
|
||||
output / "with",
|
||||
root / "payloads/2998-ade08266c67aee88ec9c1d00902150e1684dad3a/"
|
||||
"libroblox.so");
|
||||
ASSERT_TRUE(with.contains("fmod_output_device_bridge"));
|
||||
EXPECT_TRUE(
|
||||
with["fmod_output_device_bridge"].contains("input_count_method_rva"));
|
||||
EXPECT_EQ(with["fmod_output_device_bridge"]["vtable_layout_version"], 2);
|
||||
|
||||
std::error_code filesystem_error;
|
||||
std::filesystem::remove_all(output, filesystem_error);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mocktail::update
|
||||
@@ -155,6 +155,34 @@ TEST(PerformancePolicyTest, WorkerModesRetainRobloxManagedAssetCacheSizes) {
|
||||
}
|
||||
}
|
||||
|
||||
TEST(PerformancePolicyTest, WorkerModesPreserveRobloxAndExplicitMsaaSelection) {
|
||||
for (const bool throughput : {false, true}) {
|
||||
PerformancePolicy policy;
|
||||
policy.multithreaded_rendering = !throughput;
|
||||
policy.physical_core_count = 14;
|
||||
policy.physics_worker_mode = throughput ? PhysicsWorkerMode::kThroughput
|
||||
: PhysicsWorkerMode::kAuto;
|
||||
for (const std::string samples : {"", "1", "4", "8"}) {
|
||||
nlohmann::json requested = nlohmann::json::object();
|
||||
if (!samples.empty()) {
|
||||
requested["FIntDebugForceMSAASamples"] = samples;
|
||||
}
|
||||
std::string merged;
|
||||
std::string error;
|
||||
ASSERT_TRUE(MergeRuntimeClientSettingsOverrides(
|
||||
ParseFrameRatePolicy("144"), policy, requested.dump(), &merged,
|
||||
&error)) << error;
|
||||
const auto parsed = nlohmann::json::parse(merged);
|
||||
EXPECT_FALSE(parsed.contains(""));
|
||||
if (samples.empty()) {
|
||||
EXPECT_FALSE(parsed.contains("FIntDebugForceMSAASamples"));
|
||||
} else {
|
||||
EXPECT_EQ(parsed.at("FIntDebugForceMSAASamples"), samples);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(PerformancePolicyTest, PreservesExplicitAssetCacheByteBudgets) {
|
||||
const nlohmann::json requested = {
|
||||
{"FIntMeshContentProviderForceCacheSize", "268435456"},
|
||||
@@ -245,9 +273,10 @@ TEST(PerformancePolicyTest, KeepsUserOverridesThatConflictWithPreset) {
|
||||
EXPECT_EQ(parsed.at("FFlagMovePrerenderV2"), true);
|
||||
EXPECT_EQ(parsed.at("FIntDebugForceMSAASamples"), 4);
|
||||
EXPECT_EQ(parsed.at("FFlagFastGPULightGrid"), "True");
|
||||
// The preset does not set MSAA, so that value passes through without
|
||||
// counting as a kept conflict.
|
||||
EXPECT_EQ(kept, (std::vector<std::string>{"FIntOcclusionWorkerThreadCount",
|
||||
"FFlagMovePrerenderV2",
|
||||
"FIntDebugForceMSAASamples"}));
|
||||
"FFlagMovePrerenderV2"}));
|
||||
|
||||
PerformancePolicy latency =
|
||||
ParsePerformancePolicy("true", "0", "auto", "latency");
|
||||
|
||||
@@ -73,6 +73,83 @@ TEST(PresentModePolicyTest, RequestsExtraSwapchainImagesWhenUnthrottled) {
|
||||
5U);
|
||||
}
|
||||
|
||||
TEST(PresentModePolicyTest, RetriesWithTheGamesModeWhenTheDriverRejectsOurs) {
|
||||
VkSwapchainCreateInfoKHR info{};
|
||||
info.presentMode = VK_PRESENT_MODE_FIFO_LATEST_READY_KHR;
|
||||
std::vector<VkPresentModeKHR> attempts;
|
||||
const VkResult result = CreateSwapchainWithPresentModeFallback(
|
||||
info, VK_PRESENT_MODE_FIFO_KHR,
|
||||
[&](const VkSwapchainCreateInfoKHR &attempt) {
|
||||
attempts.push_back(attempt.presentMode);
|
||||
return attempt.presentMode == VK_PRESENT_MODE_FIFO_LATEST_READY_KHR
|
||||
? VK_ERROR_INITIALIZATION_FAILED
|
||||
: VK_SUCCESS;
|
||||
});
|
||||
EXPECT_EQ(result, VK_SUCCESS);
|
||||
EXPECT_EQ(attempts, (std::vector<VkPresentModeKHR>{
|
||||
VK_PRESENT_MODE_FIFO_LATEST_READY_KHR,
|
||||
VK_PRESENT_MODE_FIFO_KHR}));
|
||||
EXPECT_EQ(info.presentMode, VK_PRESENT_MODE_FIFO_KHR);
|
||||
}
|
||||
|
||||
TEST(PresentModePolicyTest, DoesNotRetryWhenThereIsNothingToFallBackTo) {
|
||||
VkSwapchainCreateInfoKHR info{};
|
||||
info.presentMode = VK_PRESENT_MODE_MAILBOX_KHR;
|
||||
int calls = 0;
|
||||
EXPECT_EQ(CreateSwapchainWithPresentModeFallback(
|
||||
info, VK_PRESENT_MODE_FIFO_KHR,
|
||||
[&](const VkSwapchainCreateInfoKHR &) {
|
||||
++calls;
|
||||
return VK_SUCCESS;
|
||||
}),
|
||||
VK_SUCCESS);
|
||||
EXPECT_EQ(calls, 1);
|
||||
|
||||
info.presentMode = VK_PRESENT_MODE_FIFO_KHR;
|
||||
calls = 0;
|
||||
EXPECT_EQ(CreateSwapchainWithPresentModeFallback(
|
||||
info, VK_PRESENT_MODE_FIFO_KHR,
|
||||
[&](const VkSwapchainCreateInfoKHR &) {
|
||||
++calls;
|
||||
return VK_ERROR_SURFACE_LOST_KHR;
|
||||
}),
|
||||
VK_ERROR_SURFACE_LOST_KHR);
|
||||
EXPECT_EQ(calls, 1);
|
||||
}
|
||||
|
||||
TEST(PresentModePolicyTest, DoesNotRetryAfterErrorsAnotherModeCannotFix) {
|
||||
for (const VkResult error :
|
||||
{VK_ERROR_OUT_OF_HOST_MEMORY, VK_ERROR_OUT_OF_DEVICE_MEMORY,
|
||||
VK_ERROR_DEVICE_LOST, VK_ERROR_SURFACE_LOST_KHR,
|
||||
VK_ERROR_NATIVE_WINDOW_IN_USE_KHR}) {
|
||||
VkSwapchainCreateInfoKHR info{};
|
||||
info.presentMode = VK_PRESENT_MODE_FIFO_LATEST_READY_KHR;
|
||||
int calls = 0;
|
||||
EXPECT_EQ(CreateSwapchainWithPresentModeFallback(
|
||||
info, VK_PRESENT_MODE_FIFO_KHR,
|
||||
[&](const VkSwapchainCreateInfoKHR &) {
|
||||
++calls;
|
||||
return error;
|
||||
}),
|
||||
error);
|
||||
EXPECT_EQ(calls, 1) << "error " << static_cast<int>(error);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(PresentModePolicyTest, ReturnsTheRetrysResultWhenItAlsoFails) {
|
||||
VkSwapchainCreateInfoKHR info{};
|
||||
info.presentMode = VK_PRESENT_MODE_FIFO_LATEST_READY_KHR;
|
||||
int calls = 0;
|
||||
EXPECT_EQ(CreateSwapchainWithPresentModeFallback(
|
||||
info, VK_PRESENT_MODE_FIFO_KHR,
|
||||
[&](const VkSwapchainCreateInfoKHR &) {
|
||||
return ++calls == 1 ? VK_ERROR_INITIALIZATION_FAILED
|
||||
: VK_ERROR_OUT_OF_HOST_MEMORY;
|
||||
}),
|
||||
VK_ERROR_OUT_OF_HOST_MEMORY);
|
||||
EXPECT_EQ(calls, 2);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace graphics
|
||||
} // namespace mocktail
|
||||
@@ -218,6 +218,24 @@ TEST(RobloxDesktopAppPolicyTest,
|
||||
&merged, &error));
|
||||
}
|
||||
|
||||
TEST(RobloxDesktopAppPolicyTest, DefaultsDesktopFlyoutSidePanelUnlessOverridden) {
|
||||
const std::string policy =
|
||||
nlohmann::json({{"PlatformGroup", "Desktop"}}).dump();
|
||||
std::string merged;
|
||||
std::string error;
|
||||
ASSERT_TRUE(MergeDesktopAppPolicyClientSettingsOverride(policy, "{}", &merged,
|
||||
&error))
|
||||
<< error;
|
||||
EXPECT_EQ(nlohmann::json::parse(merged)["FIntFlyoutTestVariantRollout12"],
|
||||
"2");
|
||||
|
||||
ASSERT_TRUE(MergeDesktopAppPolicyClientSettingsOverride(
|
||||
policy, R"({"FIntFlyoutTestVariantRollout12":"0"})", &merged, &error))
|
||||
<< error;
|
||||
EXPECT_EQ(nlohmann::json::parse(merged)["FIntFlyoutTestVariantRollout12"],
|
||||
"0");
|
||||
}
|
||||
|
||||
TEST(RobloxDesktopAppPolicyTest, RejectsMalformedOrSymlinkedStorage) {
|
||||
TemporaryDirectory temporary;
|
||||
const std::filesystem::path default_path = temporary.path() / "default.json";
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
#include "mocktail/audio/roblox_output_device_bridge.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <future>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "audio/native_input_capture.h"
|
||||
#include "audio/roblox_output_device_bridge_internal.h"
|
||||
#include "compat/fmod_output_device_contract.h"
|
||||
#include "mocktail/audio/sdl_audio_sink.h"
|
||||
|
||||
namespace mocktail::audio {
|
||||
namespace {
|
||||
@@ -143,5 +152,317 @@ TEST(RobloxOutputDeviceBridgeTest, EnforcesSingleProcessOwner) {
|
||||
second.Shutdown();
|
||||
}
|
||||
|
||||
class NativeInputCaptureTest : public testing::Test {
|
||||
protected:
|
||||
struct PcmSink {
|
||||
const std::uintptr_t* vtable = sink_table.data();
|
||||
std::size_t frames = 0;
|
||||
int peak = 0;
|
||||
};
|
||||
struct Owner {
|
||||
const std::uintptr_t* vtable = owner_table.data();
|
||||
long shared = 0;
|
||||
long weak = 0;
|
||||
int released = 0;
|
||||
int freed = 0;
|
||||
int* freed_counter = nullptr;
|
||||
};
|
||||
static std::size_t Deliver(void* object, const void* data, std::size_t frames,
|
||||
std::uint64_t delay, const NativePcmFormat* format) {
|
||||
EXPECT_EQ(frames, 480U);
|
||||
EXPECT_EQ(format->rate, 48000U);
|
||||
EXPECT_EQ(format->channels, 1);
|
||||
EXPECT_EQ(format->bytes_per_sample, 2);
|
||||
EXPECT_GE(delay, 10000000U);
|
||||
EXPECT_LE(delay, 200000000U);
|
||||
auto& sink = *static_cast<PcmSink*>(object);
|
||||
sink.frames += frames;
|
||||
const auto* pcm = static_cast<const std::int16_t*>(data);
|
||||
for (std::size_t i = 0; i < frames; ++i)
|
||||
sink.peak = std::max(sink.peak, std::abs(static_cast<int>(pcm[i])));
|
||||
return frames;
|
||||
}
|
||||
static void Release(void* owner) { ++static_cast<Owner*>(owner)->released; }
|
||||
static void Free(void* opaque) {
|
||||
auto* owner = static_cast<Owner*>(opaque);
|
||||
++owner->freed;
|
||||
if (owner->freed_counter) {
|
||||
++*owner->freed_counter;
|
||||
delete owner;
|
||||
}
|
||||
}
|
||||
static void Latency(void*, std::uint32_t* playback, std::uint32_t* recording) {
|
||||
if (playback) *playback = 25;
|
||||
if (recording) *recording = 0;
|
||||
}
|
||||
bool PollUntil(PcmSink& sink, std::size_t frames) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (sink.frames < frames && std::chrono::steady_clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
NativeInputCapture::Poll(this);
|
||||
}
|
||||
return sink.frames >= frames;
|
||||
}
|
||||
static bool Relro(std::uintptr_t base, std::uintptr_t rva, std::size_t size) {
|
||||
return (base + rva == reinterpret_cast<std::uintptr_t>(sink_table.data()) &&
|
||||
size <= sizeof(sink_table)) ||
|
||||
(base + rva == reinterpret_cast<std::uintptr_t>(owner_table.data()) &&
|
||||
size <= sizeof(owner_table));
|
||||
}
|
||||
static bool Code(std::uintptr_t base, std::uintptr_t rva, std::size_t) {
|
||||
return base + rva == reinterpret_cast<std::uintptr_t>(&Deliver) ||
|
||||
base + rva == reinterpret_cast<std::uintptr_t>(&Release) ||
|
||||
base + rva == reinterpret_cast<std::uintptr_t>(&Free);
|
||||
}
|
||||
void SetUp() override {
|
||||
ASSERT_TRUE(SDL_SetHintWithPriority(SDL_HINT_AUDIO_DRIVER, "dummy",
|
||||
SDL_HINT_OVERRIDE));
|
||||
ASSERT_TRUE(InitializeSdlAudioSubsystem().ok());
|
||||
capture = std::make_shared<NativeInputCapture>(
|
||||
NativeInputCapture::SinkAbi{1, &Relro, &Code, &Latency}, true);
|
||||
std::atomic_store(&g_native_capture, capture);
|
||||
}
|
||||
void TearDown() override {
|
||||
std::atomic_store(&g_native_capture, std::shared_ptr<NativeInputCapture>{});
|
||||
capture.reset();
|
||||
EXPECT_TRUE(ShutdownSdlAudioSubsystem().ok());
|
||||
SDL_ResetHint(SDL_HINT_AUDIO_DRIVER);
|
||||
SDL_ResetHint(SDL_HINT_AUDIO_DISK_INPUT_FILE);
|
||||
SDL_ResetHint(SDL_HINT_AUDIO_DISK_OUTPUT_FILE);
|
||||
if (!pcm_path.empty()) std::remove(pcm_path.c_str());
|
||||
}
|
||||
inline static const std::array<std::uintptr_t, 3> sink_table{
|
||||
0, 0, reinterpret_cast<std::uintptr_t>(&Deliver)};
|
||||
inline static const std::array<std::uintptr_t, 5> owner_table{
|
||||
0, 0, reinterpret_cast<std::uintptr_t>(&Release), 0,
|
||||
reinterpret_cast<std::uintptr_t>(&Free)};
|
||||
PcmSink first, second;
|
||||
Owner first_owner, second_owner;
|
||||
std::shared_ptr<NativeInputCapture> capture;
|
||||
std::string pcm_path;
|
||||
};
|
||||
|
||||
TEST_F(NativeInputCaptureTest, DeliversPcmAndReleasesEachGuestSink) {
|
||||
NativeSharedSink a{&first, &first_owner}, b{&second, &second_owner};
|
||||
ASSERT_TRUE(NativeInputCapture::Start(this, &a));
|
||||
ASSERT_TRUE(NativeInputCapture::Start(this, &b));
|
||||
EXPECT_EQ(a.object, nullptr);
|
||||
EXPECT_EQ(a.owner, nullptr);
|
||||
EXPECT_EQ(b.object, nullptr);
|
||||
EXPECT_EQ(b.owner, nullptr);
|
||||
EXPECT_TRUE(PollUntil(first, 480));
|
||||
EXPECT_EQ(first.frames, second.frames);
|
||||
a = {&first, &first_owner};
|
||||
NativeInputCapture::Stop(this, &a);
|
||||
EXPECT_TRUE(NativeInputCapture::Recording(this));
|
||||
EXPECT_EQ(first_owner.released, 1);
|
||||
EXPECT_EQ(first_owner.freed, 1);
|
||||
b = {&second, &second_owner};
|
||||
NativeInputCapture::Stop(this, &b);
|
||||
EXPECT_FALSE(NativeInputCapture::Recording(this));
|
||||
EXPECT_EQ(second_owner.released, 1);
|
||||
EXPECT_EQ(second_owner.freed, 1);
|
||||
}
|
||||
|
||||
TEST_F(NativeInputCaptureTest, PreservesOtherSharedAndWeakOwners) {
|
||||
first_owner.shared = 1;
|
||||
second_owner.weak = 1;
|
||||
NativeSharedSink a{&first, &first_owner}, b{&second, &second_owner};
|
||||
ASSERT_TRUE(NativeInputCapture::Start(this, &a));
|
||||
ASSERT_TRUE(NativeInputCapture::Start(this, &b));
|
||||
capture->Shutdown();
|
||||
EXPECT_EQ(first_owner.shared, 0);
|
||||
EXPECT_EQ(first_owner.released, 0);
|
||||
EXPECT_EQ(first_owner.freed, 0);
|
||||
EXPECT_EQ(second_owner.weak, 0);
|
||||
EXPECT_EQ(second_owner.released, 1);
|
||||
EXPECT_EQ(second_owner.freed, 0);
|
||||
}
|
||||
|
||||
TEST_F(NativeInputCaptureTest, RejectsUnverifiedSinkAndOwnerTables) {
|
||||
auto unverified = sink_table;
|
||||
first.vtable = unverified.data();
|
||||
NativeSharedSink sink{&first, &first_owner};
|
||||
EXPECT_FALSE(NativeInputCapture::Start(this, &sink));
|
||||
first.vtable = sink_table.data();
|
||||
first_owner.vtable = sink_table.data();
|
||||
EXPECT_FALSE(NativeInputCapture::Start(this, &sink));
|
||||
EXPECT_FALSE(NativeInputCapture::Recording(this));
|
||||
EXPECT_EQ(sink.object, &first);
|
||||
EXPECT_EQ(first_owner.released, 0);
|
||||
}
|
||||
|
||||
TEST_F(NativeInputCaptureTest, DisabledCaptureDoesNotTakeOwnership) {
|
||||
capture = std::make_shared<NativeInputCapture>(
|
||||
NativeInputCapture::SinkAbi{1, &Relro, &Code, nullptr}, false);
|
||||
std::atomic_store(&g_native_capture, capture);
|
||||
NativeSharedSink sink{&first, &first_owner};
|
||||
EXPECT_EQ(NativeInputCapture::Format(this).present, 0U);
|
||||
EXPECT_FALSE(NativeInputCapture::Start(this, &sink));
|
||||
EXPECT_EQ(sink.object, &first);
|
||||
EXPECT_EQ(first_owner.released, 0);
|
||||
}
|
||||
|
||||
TEST_F(NativeInputCaptureTest, ShutdownRejectsLateRecordingRequests) {
|
||||
capture->Shutdown();
|
||||
NativeSharedSink sink{&first, &first_owner};
|
||||
EXPECT_EQ(NativeInputCapture::Format(this).present, 0U);
|
||||
EXPECT_FALSE(NativeInputCapture::Start(this, &sink));
|
||||
EXPECT_FALSE(NativeInputCapture::Recording(this));
|
||||
EXPECT_EQ(sink.object, &first);
|
||||
EXPECT_EQ(sink.owner, &first_owner);
|
||||
}
|
||||
|
||||
TEST_F(NativeInputCaptureTest, DeliversNonzeroPcmFromSyntheticRecording) {
|
||||
bool have_disk = false;
|
||||
for (int i = 0; i < SDL_GetNumAudioDrivers(); ++i)
|
||||
have_disk |= std::strcmp(SDL_GetAudioDriver(i), "disk") == 0;
|
||||
if (!have_disk) GTEST_SKIP() << "SDL disk audio driver is unavailable";
|
||||
ASSERT_TRUE(ShutdownSdlAudioSubsystem().ok());
|
||||
char path[] = "/tmp/mocktail-native-pcm-XXXXXX";
|
||||
const int fd = mkstemp(path);
|
||||
ASSERT_GE(fd, 0);
|
||||
pcm_path = path;
|
||||
FILE* file = fdopen(fd, "wb");
|
||||
if (!file) close(fd);
|
||||
ASSERT_NE(file, nullptr);
|
||||
const std::vector<unsigned char> pcm(1024 * 1024, 0x3f);
|
||||
const auto written = std::fwrite(pcm.data(), 1, pcm.size(), file);
|
||||
const int closed = std::fclose(file);
|
||||
ASSERT_EQ(written, pcm.size());
|
||||
ASSERT_EQ(closed, 0);
|
||||
ASSERT_TRUE(SDL_SetHintWithPriority(SDL_HINT_AUDIO_DRIVER, "disk",
|
||||
SDL_HINT_OVERRIDE));
|
||||
ASSERT_TRUE(SDL_SetHint(SDL_HINT_AUDIO_DISK_INPUT_FILE, path));
|
||||
ASSERT_TRUE(SDL_SetHint(SDL_HINT_AUDIO_DISK_OUTPUT_FILE, "/dev/null"));
|
||||
ASSERT_TRUE(InitializeSdlAudioSubsystem().ok());
|
||||
NativeSharedSink sink{&first, &first_owner};
|
||||
ASSERT_TRUE(NativeInputCapture::Start(this, &sink));
|
||||
ASSERT_TRUE(PollUntil(first, 480 * 3));
|
||||
EXPECT_GT(first.peak, 0);
|
||||
}
|
||||
|
||||
TEST_F(NativeInputCaptureTest, CanStopRestartAndMigrateAnActiveRecording) {
|
||||
for (int run = 0; run < 3; ++run) {
|
||||
first_owner = {};
|
||||
NativeSharedSink sink{&first, &first_owner};
|
||||
ASSERT_TRUE(NativeInputCapture::Start(this, &sink));
|
||||
ASSERT_TRUE(PollUntil(first, first.frames + 480));
|
||||
std::string name;
|
||||
ASSERT_TRUE(SwitchSdlRecordingDevice(0, &name).ok());
|
||||
ASSERT_TRUE(PollUntil(first, first.frames + 480));
|
||||
EXPECT_FALSE(SwitchSdlRecordingDevice(0xffffffffU, &name).ok());
|
||||
ASSERT_TRUE(PollUntil(first, first.frames + 480));
|
||||
sink = {&first, &first_owner};
|
||||
NativeInputCapture::Stop(this, &sink);
|
||||
const auto frames = first.frames;
|
||||
NativeInputCapture::Poll(this);
|
||||
NativeInputCapture::Stop(this, &sink);
|
||||
EXPECT_EQ(first.frames, frames);
|
||||
EXPECT_EQ(first_owner.released, 1);
|
||||
EXPECT_EQ(first_owner.freed, 1);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(NativeInputCaptureTest, BoundsQueuedAudioWhenTheConsumerStalls) {
|
||||
NativeSharedSink sink{&first, &first_owner};
|
||||
ASSERT_TRUE(NativeInputCapture::Start(this, &sink));
|
||||
std::uint32_t playback = 0, recording = 0;
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (recording < 210 && std::chrono::steady_clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
NativeInputCapture::Latency(this, &playback, &recording);
|
||||
}
|
||||
EXPECT_EQ(playback, 25U);
|
||||
ASSERT_EQ(recording, 210U);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
NativeInputCapture::Latency(this, nullptr, &recording);
|
||||
EXPECT_EQ(recording, 210U);
|
||||
NativeInputCapture::Poll(this);
|
||||
EXPECT_GT(first.frames, 0U);
|
||||
EXPECT_LE(first.frames, 20U * 480);
|
||||
}
|
||||
|
||||
TEST_F(NativeInputCaptureTest, FreesAHeapControlBlockExactlyOnce) {
|
||||
int freed = 0;
|
||||
auto* owner = new Owner;
|
||||
owner->freed_counter = &freed;
|
||||
NativeSharedSink sink{&first, owner};
|
||||
ASSERT_TRUE(NativeInputCapture::Start(this, &sink));
|
||||
sink = {&first, owner};
|
||||
NativeInputCapture::Stop(this, &sink);
|
||||
NativeInputCapture::Stop(this, &sink);
|
||||
capture->Shutdown();
|
||||
EXPECT_EQ(freed, 1);
|
||||
}
|
||||
|
||||
TEST_F(NativeInputCaptureTest, ConcurrentPollingStopsBeforeShutdownReturns) {
|
||||
NativeSharedSink sink{&first, &first_owner};
|
||||
ASSERT_TRUE(NativeInputCapture::Start(this, &sink));
|
||||
std::atomic<bool> done{false};
|
||||
std::promise<void> started;
|
||||
std::thread poller([&] {
|
||||
NativeInputCapture::Poll(this);
|
||||
started.set_value();
|
||||
while (!done.load()) {
|
||||
NativeInputCapture::Poll(this);
|
||||
NativeInputCapture::Format(this);
|
||||
NativeInputCapture::Recording(this);
|
||||
std::this_thread::yield();
|
||||
}
|
||||
});
|
||||
started.get_future().wait();
|
||||
capture->Shutdown();
|
||||
const auto frames = first.frames;
|
||||
std::atomic_store(&g_native_capture, std::shared_ptr<NativeInputCapture>{});
|
||||
capture.reset();
|
||||
done.store(true);
|
||||
poller.join();
|
||||
EXPECT_EQ(first.frames, frames);
|
||||
EXPECT_EQ(first_owner.released, 1);
|
||||
EXPECT_EQ(first_owner.freed, 1);
|
||||
}
|
||||
|
||||
TEST_F(NativeInputCaptureTest, ShutdownWaitsForAnInFlightNativeCall) {
|
||||
struct Gate {
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
bool entered = false;
|
||||
bool released = false;
|
||||
} gate;
|
||||
auto latency = +[](void* opaque, std::uint32_t*, std::uint32_t*) {
|
||||
auto& g = *static_cast<Gate*>(opaque);
|
||||
std::unique_lock lock(g.mutex);
|
||||
g.entered = true;
|
||||
g.cv.notify_all();
|
||||
g.cv.wait(lock, [&] { return g.released; });
|
||||
};
|
||||
capture = std::make_shared<NativeInputCapture>(
|
||||
NativeInputCapture::SinkAbi{1, &Relro, &Code, latency}, true);
|
||||
std::weak_ptr<NativeInputCapture> lifetime = capture;
|
||||
std::atomic_store(&g_native_capture, capture);
|
||||
std::thread caller([&] { NativeInputCapture::Latency(&gate, nullptr, nullptr); });
|
||||
{
|
||||
std::unique_lock lock(gate.mutex);
|
||||
gate.cv.wait(lock, [&] { return gate.entered; });
|
||||
}
|
||||
std::atomic_store(&g_native_capture, std::shared_ptr<NativeInputCapture>{});
|
||||
auto shutdown = std::async(std::launch::async, [owned = std::move(capture)]() mutable {
|
||||
owned->Shutdown();
|
||||
owned.reset();
|
||||
});
|
||||
EXPECT_EQ(shutdown.wait_for(std::chrono::milliseconds(30)),
|
||||
std::future_status::timeout);
|
||||
EXPECT_FALSE(lifetime.expired());
|
||||
{
|
||||
std::lock_guard lock(gate.mutex);
|
||||
gate.released = true;
|
||||
}
|
||||
gate.cv.notify_all();
|
||||
caller.join();
|
||||
shutdown.get();
|
||||
EXPECT_TRUE(lifetime.expired());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mocktail::audio
|
||||
@@ -60,6 +60,7 @@ struct WebViewBridgeProbe {
|
||||
std::string hybrid_callback_payload;
|
||||
std::string cookie_header;
|
||||
std::vector<std::string> data_model_focus_states;
|
||||
std::vector<std::string> app_events;
|
||||
std::vector<std::string> initialization_order;
|
||||
int native_initializations = 0;
|
||||
int close_publications = 0;
|
||||
@@ -215,6 +216,22 @@ void BroadcastDataModelFocus(JNIEnv *env, jclass, jstring event_namespace,
|
||||
ReadJavaString(env, state));
|
||||
}
|
||||
|
||||
void SendAppEventOnGameLoaded(JNIEnv* env, jclass, jstring event_namespace,
|
||||
jstring detail, jstring detail_type) {
|
||||
g_web_view_probe->app_events.push_back(
|
||||
"loaded|" + ReadJavaString(env, event_namespace) + "|" +
|
||||
ReadJavaString(env, detail) + "|" + ReadJavaString(env, detail_type));
|
||||
}
|
||||
|
||||
void SendAppEventOnAppReady(JNIEnv* env, jclass, jstring event_namespace,
|
||||
jstring detail, jstring detail_type,
|
||||
jstring app_name) {
|
||||
g_web_view_probe->app_events.push_back(
|
||||
"ready|" + ReadJavaString(env, event_namespace) + "|" +
|
||||
ReadJavaString(env, detail) + "|" + ReadJavaString(env, detail_type) +
|
||||
"|" + ReadJavaString(env, app_name));
|
||||
}
|
||||
|
||||
jobject SubscribeRaw(JNIEnv *env, jobject, jstring subscription_id,
|
||||
jobject callback, jboolean) {
|
||||
const int subscribe_index =
|
||||
@@ -343,15 +360,21 @@ Status DispatchCookie(void* context, std::string_view header) {
|
||||
}
|
||||
|
||||
RobloxWebViewBridge MakeBridge(jnivm::VM* vm, jobject bus,
|
||||
WebViewBridgeProbe* probe) {
|
||||
WebViewBridgeProbe* probe,
|
||||
bool app_event_symbols = true) {
|
||||
RobloxWebViewMessageBusSymbols symbols{
|
||||
&GetOpenWindowId, &GetHandleWindowCloseId, &GetProtocolName,
|
||||
&GetIsAvailableId, &GetMessageId, &InitializeAndroidWebViewProtocol,
|
||||
&SubscribeRaw, &DeleteConnection, &SetRequestHandler,
|
||||
&ClearRequestHandler, &PublishRaw, &BroadcastDataModelFocus,
|
||||
&GetMutateWindowId, &GetCloseWindowId, &SignalJavascriptCallback,
|
||||
&UpdateCookieSetHandler};
|
||||
if (app_event_symbols) {
|
||||
symbols.send_app_event_on_game_loaded = &SendAppEventOnGameLoaded;
|
||||
symbols.send_app_event_on_app_ready = &SendAppEventOnAppReady;
|
||||
}
|
||||
return RobloxWebViewBridge(
|
||||
{vm->GetJavaVM(), nullptr, nullptr},
|
||||
{&GetOpenWindowId, &GetHandleWindowCloseId, &GetProtocolName,
|
||||
&GetIsAvailableId, &GetMessageId, &InitializeAndroidWebViewProtocol,
|
||||
&SubscribeRaw, &DeleteConnection, &SetRequestHandler,
|
||||
&ClearRequestHandler, &PublishRaw, &BroadcastDataModelFocus,
|
||||
&GetMutateWindowId, &GetCloseWindowId, &SignalJavascriptCallback,
|
||||
&UpdateCookieSetHandler},
|
||||
{vm->GetJavaVM(), nullptr, nullptr}, symbols,
|
||||
{bus, vm, &CreateRawCallback, &ClearRawCallback, &CreateRequestHandler,
|
||||
&ClearRequestHandlerObject, &SetPlatformWebCallbacks,
|
||||
&ClearPlatformWebCallbacks},
|
||||
@@ -1093,6 +1116,95 @@ TEST(RobloxWebViewBridgeTest,
|
||||
g_web_view_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST(RobloxWebViewBridgeTest,
|
||||
LaunchConversationOpensChatAndStartsTheConversationWithThatUser) {
|
||||
jnivm::VM vm;
|
||||
JNIEnv* env = vm.GetJNIEnv();
|
||||
jclass bus_class =
|
||||
env->FindClass("com/roblox/universalapp/messagebus/MessageBus");
|
||||
jobject bus = env->AllocObject(bus_class);
|
||||
WebViewBridgeProbe probe;
|
||||
g_web_view_probe = &probe;
|
||||
RobloxWebViewBridge bridge = MakeBridge(&vm, bus, &probe);
|
||||
ASSERT_TRUE(bridge.Initialize().ok());
|
||||
|
||||
EXPECT_TRUE(vm.DispatchRobloxDataModelNotification(
|
||||
env, env->NewStringUTF(kRobloxLaunchConversationNotification),
|
||||
env->NewStringUTF("8406613906")));
|
||||
// The engine is still inside its notification callback here.
|
||||
EXPECT_TRUE(probe.app_events.empty());
|
||||
EXPECT_EQ(probe.dispatches, 0);
|
||||
|
||||
ASSERT_TRUE(bridge.DrainHostWindowEvents().ok());
|
||||
EXPECT_EQ(probe.app_events,
|
||||
(std::vector<std::string>{
|
||||
R"(loaded|Navigations|{"appName":"Chat"}|Destination)",
|
||||
"ready|AppShellNotifications|8406613906|"
|
||||
"StartConversationWithUserId|Chat"}));
|
||||
EXPECT_TRUE(probe.data_model_focus_states.empty());
|
||||
|
||||
ASSERT_TRUE(bridge.DrainHostWindowEvents().ok());
|
||||
EXPECT_EQ(probe.app_events.size(), 2U);
|
||||
|
||||
ASSERT_TRUE(bridge.Shutdown().ok());
|
||||
g_web_view_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST(RobloxWebViewBridgeTest,
|
||||
LaunchConversationRejectsPayloadsThatAreNotUserIds) {
|
||||
jnivm::VM vm;
|
||||
JNIEnv* env = vm.GetJNIEnv();
|
||||
jclass bus_class =
|
||||
env->FindClass("com/roblox/universalapp/messagebus/MessageBus");
|
||||
jobject bus = env->AllocObject(bus_class);
|
||||
WebViewBridgeProbe probe;
|
||||
g_web_view_probe = &probe;
|
||||
RobloxWebViewBridge bridge = MakeBridge(&vm, bus, &probe);
|
||||
ASSERT_TRUE(bridge.Initialize().ok());
|
||||
|
||||
for (const char* data :
|
||||
{"", "0", "-5", "abc", "12 34", "84066139060000000000",
|
||||
R"({"userId":1})"}) {
|
||||
SCOPED_TRACE(data);
|
||||
EXPECT_FALSE(
|
||||
bridge
|
||||
.HandleDataModelNotification(
|
||||
env, env->NewStringUTF(kRobloxLaunchConversationNotification),
|
||||
env->NewStringUTF(data))
|
||||
.ok());
|
||||
}
|
||||
ASSERT_TRUE(bridge.DrainHostWindowEvents().ok());
|
||||
EXPECT_TRUE(probe.app_events.empty());
|
||||
|
||||
ASSERT_TRUE(bridge.Shutdown().ok());
|
||||
g_web_view_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST(RobloxWebViewBridgeTest,
|
||||
LaunchConversationIsUnavailableWithoutTheAppEventEntrypoints) {
|
||||
jnivm::VM vm;
|
||||
JNIEnv* env = vm.GetJNIEnv();
|
||||
jclass bus_class =
|
||||
env->FindClass("com/roblox/universalapp/messagebus/MessageBus");
|
||||
jobject bus = env->AllocObject(bus_class);
|
||||
WebViewBridgeProbe probe;
|
||||
g_web_view_probe = &probe;
|
||||
RobloxWebViewBridge bridge = MakeBridge(&vm, bus, &probe, false);
|
||||
ASSERT_TRUE(bridge.Initialize().ok());
|
||||
|
||||
EXPECT_FALSE(
|
||||
bridge
|
||||
.HandleDataModelNotification(
|
||||
env, env->NewStringUTF(kRobloxLaunchConversationNotification),
|
||||
env->NewStringUTF("8406613906"))
|
||||
.ok());
|
||||
ASSERT_TRUE(bridge.DrainHostWindowEvents().ok());
|
||||
EXPECT_TRUE(probe.app_events.empty());
|
||||
|
||||
ASSERT_TRUE(bridge.Shutdown().ok());
|
||||
g_web_view_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST(RobloxWebViewBridgeTest,
|
||||
ReopenSupersedesThePreviousWindowAndIgnoresItsStaleExit) {
|
||||
jnivm::VM vm;
|
||||
|
||||
@@ -156,6 +156,33 @@ TEST(SessionLogTest, SkipsIsolatedCanary) {
|
||||
EXPECT_FALSE(std::filesystem::exists(paths.logs_root()));
|
||||
}
|
||||
|
||||
TEST(SessionLogTest, StopDoesNotWaitForAProcessThatKeepsTheLogOpen) {
|
||||
TemporaryDirectory temporary;
|
||||
const MapEnvironment environment({
|
||||
{"HOME", (temporary.root() / "home").string()},
|
||||
{"MOCKTAIL_STATE_ROOT", (temporary.root() / "state").string()},
|
||||
});
|
||||
const RuntimePaths paths = RuntimePaths::FromEnvironment(environment);
|
||||
pid_t holder = -1;
|
||||
std::chrono::steady_clock::time_point begin;
|
||||
{
|
||||
SessionLog log = SessionLog::Start(environment, paths);
|
||||
ASSERT_TRUE(log) << log.error();
|
||||
// The child inherits the log pipes and outlives the log.
|
||||
holder = fork();
|
||||
ASSERT_GE(holder, 0);
|
||||
if (holder == 0) {
|
||||
alarm(30);
|
||||
for (;;) pause();
|
||||
}
|
||||
begin = std::chrono::steady_clock::now();
|
||||
} // The destructor stops the log.
|
||||
const auto stop_time = std::chrono::steady_clock::now() - begin;
|
||||
kill(holder, SIGKILL);
|
||||
waitpid(holder, nullptr, 0);
|
||||
EXPECT_LT(stop_time, std::chrono::seconds(10));
|
||||
}
|
||||
|
||||
TEST(SessionLogTest, CpuLimitCrashReachesConsoleAndLogAndRemainsFatal) {
|
||||
TemporaryDirectory temporary;
|
||||
const MapEnvironment environment({
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#include "mocktail/graphics/texture_override.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail::graphics {
|
||||
namespace {
|
||||
|
||||
TEST(TextureResampleTest, IntegerUpscalingPreservesEveryChannel) {
|
||||
for (const auto& [width, height] :
|
||||
std::array<std::pair<std::uint32_t, std::uint32_t>, 5>{
|
||||
{{1, 1}, {4, 4}, {37, 37}, {60, 33}, {64, 64}}}) {
|
||||
RgbaImage source{width, height,
|
||||
std::vector<std::uint8_t>(width * height * 4)};
|
||||
for (std::size_t i = 0; i < source.pixels.size(); ++i) {
|
||||
source.pixels[i] = static_cast<std::uint8_t>(i * 37 + 19);
|
||||
}
|
||||
for (std::uint32_t scale_x : {1U, 2U, 3U, 4U, 8U}) {
|
||||
for (std::uint32_t scale_y : {1U, 2U, 3U, 4U, 8U}) {
|
||||
SCOPED_TRACE(::testing::Message()
|
||||
<< width << 'x' << height << " scale=" << scale_x << 'x'
|
||||
<< scale_y);
|
||||
const std::uint32_t target_width = width * scale_x;
|
||||
const std::uint32_t target_height = height * scale_y;
|
||||
std::vector<std::uint8_t> expected(target_width * target_height * 4);
|
||||
for (std::uint32_t y = 0; y < target_height; ++y) {
|
||||
for (std::uint32_t x = 0; x < target_width; ++x) {
|
||||
for (unsigned channel = 0; channel < 4; ++channel) {
|
||||
expected[(y * target_width + x) * 4 + channel] =
|
||||
source.pixels[((y / scale_y) * width + x / scale_x) * 4 +
|
||||
channel];
|
||||
}
|
||||
}
|
||||
}
|
||||
std::vector<std::uint8_t> output(expected.size() + 2, 0xa5);
|
||||
ResampleRgba(source, target_width, target_height, output.data() + 1);
|
||||
EXPECT_EQ(output.front(), 0xa5);
|
||||
EXPECT_EQ(output.back(), 0xa5);
|
||||
EXPECT_EQ(std::vector<std::uint8_t>(output.begin() + 1, output.end() - 1),
|
||||
expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(TextureResampleTest, SameSizeSupportsInPlaceResampling) {
|
||||
RgbaImage image{2, 1, {0, 127, 255, 0, 255, 128, 0, 255}};
|
||||
const auto expected = image.pixels;
|
||||
ResampleRgba(image, image.width, image.height, image.pixels.data());
|
||||
EXPECT_EQ(image.pixels, expected);
|
||||
}
|
||||
|
||||
TEST(TextureResampleTest, FractionalUpscalingKeepsOriginalSampling) {
|
||||
const RgbaImage source{2, 1, {10, 20, 30, 0, 40, 50, 60, 255}};
|
||||
std::vector<std::uint8_t> output(12);
|
||||
ResampleRgba(source, 3, 1, output.data());
|
||||
EXPECT_EQ(output, (std::vector<std::uint8_t>{10, 20, 30, 0, 10, 20, 30, 0,
|
||||
40, 50, 60, 255}));
|
||||
}
|
||||
|
||||
TEST(TextureResampleTest, DownscalingPreservesAreaAveragesAndAlphaRounding) {
|
||||
const RgbaImage source{3, 2,
|
||||
{0, 10, 20, 0, 10, 20, 30, 100, 20, 30, 40, 200,
|
||||
30, 40, 50, 255, 40, 50, 60, 0, 50, 60, 70, 255}};
|
||||
std::vector<std::uint8_t> output(8);
|
||||
ResampleRgba(source, 2, 1, output.data());
|
||||
EXPECT_EQ(output, (std::vector<std::uint8_t>{15, 25, 35, 128,
|
||||
30, 40, 50, 139}));
|
||||
}
|
||||
|
||||
TEST(TextureResampleTest, MixedScalingPreservesAreaAverages) {
|
||||
const RgbaImage source{2, 2, {0, 10, 20, 0, 10, 20, 30, 100,
|
||||
30, 40, 50, 255, 40, 50, 60, 0}};
|
||||
std::vector<std::uint8_t> output(16);
|
||||
ResampleRgba(source, 4, 1, output.data());
|
||||
EXPECT_EQ(output, (std::vector<std::uint8_t>{15, 25, 35, 128, 15, 25, 35, 128,
|
||||
25, 35, 45, 50, 25, 35, 45, 50}));
|
||||
}
|
||||
|
||||
TEST(TextureResampleTest, InvalidInputDoesNotWriteDestination) {
|
||||
const std::array<std::uint8_t, 4> source{10, 20, 30, 40};
|
||||
std::array<std::uint8_t, 16> output{};
|
||||
const auto expected = output;
|
||||
ResampleRgba(nullptr, 1, 1, 2, 2, output.data());
|
||||
ResampleRgba(source.data(), 0, 1, 2, 2, output.data());
|
||||
ResampleRgba(source.data(), 1, 0, 2, 2, output.data());
|
||||
ResampleRgba(source.data(), 1, 1, 0, 2, output.data());
|
||||
ResampleRgba(source.data(), 1, 1, 2, 0, output.data());
|
||||
ResampleRgba(source.data(), 1, 1, 2, 2, nullptr);
|
||||
EXPECT_EQ(output, expected);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -21,30 +21,29 @@ namespace mocktail {
|
||||
namespace window {
|
||||
namespace {
|
||||
|
||||
VideoDriverPolicyInput NvidiaWaylandDirectVulkan() {
|
||||
VideoDriverPolicyInput WaylandSessionWithXwayland() {
|
||||
VideoDriverPolicyInput input;
|
||||
input.prefer_wayland = true;
|
||||
input.has_wayland_session = true;
|
||||
input.has_x11_display = true;
|
||||
input.uses_direct_vulkan = true;
|
||||
input.has_nvidia_kernel_driver = true;
|
||||
return input;
|
||||
}
|
||||
|
||||
TEST(VideoDriverPolicyTest, UsesXwaylandForNvidiaDirectVulkanByDefault) {
|
||||
EXPECT_EQ(ResolveVideoDriverChoice(NvidiaWaylandDirectVulkan()),
|
||||
VideoDriverChoice::kNvidiaDirectVulkanX11);
|
||||
TEST(VideoDriverPolicyTest, UsesWaylandOnWaylandSessionWithXwayland) {
|
||||
EXPECT_EQ(ResolveVideoDriverChoice(WaylandSessionWithXwayland()),
|
||||
VideoDriverChoice::kWayland);
|
||||
}
|
||||
|
||||
TEST(VideoDriverPolicyTest, ExplicitSdlDriverRemainsAuthoritative) {
|
||||
VideoDriverPolicyInput input = NvidiaWaylandDirectVulkan();
|
||||
VideoDriverPolicyInput input = WaylandSessionWithXwayland();
|
||||
input.has_explicit_sdl_driver = true;
|
||||
|
||||
EXPECT_EQ(ResolveVideoDriverChoice(input), VideoDriverChoice::kSdlDefault);
|
||||
}
|
||||
|
||||
TEST(VideoDriverPolicyTest, ForceWaylandOverridesNvidiaFallback) {
|
||||
VideoDriverPolicyInput input = NvidiaWaylandDirectVulkan();
|
||||
TEST(VideoDriverPolicyTest, ForceWaylandSelectsWaylandWithoutPreference) {
|
||||
VideoDriverPolicyInput input = WaylandSessionWithXwayland();
|
||||
input.prefer_wayland = false;
|
||||
input.force_wayland = true;
|
||||
|
||||
EXPECT_EQ(ResolveVideoDriverChoice(input), VideoDriverChoice::kWayland);
|
||||
@@ -60,19 +59,8 @@ TEST(VideoDriverPolicyTest, ForceX11WinsForAvailableDisplay) {
|
||||
EXPECT_EQ(ResolveVideoDriverChoice(input), VideoDriverChoice::kX11);
|
||||
}
|
||||
|
||||
TEST(VideoDriverPolicyTest, KeepsWaylandForNonNvidiaAndNonDirectBackends) {
|
||||
VideoDriverPolicyInput input = NvidiaWaylandDirectVulkan();
|
||||
input.uses_direct_vulkan = false;
|
||||
|
||||
EXPECT_EQ(ResolveVideoDriverChoice(input), VideoDriverChoice::kWayland);
|
||||
|
||||
input.uses_direct_vulkan = true;
|
||||
input.has_nvidia_kernel_driver = false;
|
||||
EXPECT_EQ(ResolveVideoDriverChoice(input), VideoDriverChoice::kWayland);
|
||||
}
|
||||
|
||||
TEST(VideoDriverPolicyTest, KeepsWaylandWhenXwaylandIsUnavailable) {
|
||||
VideoDriverPolicyInput input = NvidiaWaylandDirectVulkan();
|
||||
VideoDriverPolicyInput input = WaylandSessionWithXwayland();
|
||||
input.has_x11_display = false;
|
||||
|
||||
EXPECT_EQ(ResolveVideoDriverChoice(input), VideoDriverChoice::kWayland);
|
||||
@@ -90,8 +78,6 @@ TEST(VideoDriverPolicyTest, UsesSdlDefaultWhenWaylandPreferenceIsDisabled) {
|
||||
TEST(VideoDriverPolicyTest, NamesOnlySelectedDrivers) {
|
||||
EXPECT_STREQ(VideoDriverChoiceName(VideoDriverChoice::kWayland), "wayland");
|
||||
EXPECT_STREQ(VideoDriverChoiceName(VideoDriverChoice::kX11), "x11");
|
||||
EXPECT_STREQ(VideoDriverChoiceName(VideoDriverChoice::kNvidiaDirectVulkanX11),
|
||||
"x11");
|
||||
EXPECT_EQ(VideoDriverChoiceName(VideoDriverChoice::kSdlDefault), nullptr);
|
||||
}
|
||||
|
||||
@@ -112,68 +98,6 @@ TEST(VideoDriverPolicyTest, RejectsUnavailableOrEmptyDriverList) {
|
||||
EXPECT_FALSE(HasAvailableVideoDriverCandidate("", available));
|
||||
}
|
||||
|
||||
class NvidiaDriverDetectionTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
char pattern[] = "/tmp/mocktail_video_driver_XXXXXX";
|
||||
const char* created = mkdtemp(pattern);
|
||||
ASSERT_NE(created, nullptr);
|
||||
root_ = created;
|
||||
proc_version_ = root_ / "proc/driver/nvidia/version";
|
||||
pci_drivers_ = root_ / "sys/bus/pci/drivers";
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
std::error_code error;
|
||||
std::filesystem::remove_all(root_, error);
|
||||
}
|
||||
|
||||
bool Detect() const {
|
||||
return HasNvidiaKernelDriver(proc_version_, pci_drivers_ / "nvidia");
|
||||
}
|
||||
|
||||
std::filesystem::path root_;
|
||||
std::filesystem::path proc_version_;
|
||||
std::filesystem::path pci_drivers_;
|
||||
};
|
||||
|
||||
TEST_F(NvidiaDriverDetectionTest, DetectsProcDriverWithoutSysfs) {
|
||||
std::filesystem::create_directories(proc_version_.parent_path());
|
||||
std::ofstream(proc_version_) << "NVRM version: NVIDIA UNIX Kernel Module\n";
|
||||
|
||||
EXPECT_TRUE(Detect());
|
||||
}
|
||||
|
||||
TEST_F(NvidiaDriverDetectionTest, HybridGpuUsesXwaylandWhenProcIsHidden) {
|
||||
std::filesystem::create_directories(pci_drivers_ / "i915");
|
||||
std::filesystem::create_directories(pci_drivers_ / "nvidia");
|
||||
VideoDriverPolicyInput input = NvidiaWaylandDirectVulkan();
|
||||
input.has_nvidia_kernel_driver = Detect();
|
||||
|
||||
EXPECT_TRUE(input.has_nvidia_kernel_driver);
|
||||
EXPECT_EQ(ResolveVideoDriverChoice(input),
|
||||
VideoDriverChoice::kNvidiaDirectVulkanX11);
|
||||
input.force_wayland = true;
|
||||
EXPECT_EQ(ResolveVideoDriverChoice(input), VideoDriverChoice::kWayland);
|
||||
input.has_explicit_sdl_driver = true;
|
||||
EXPECT_EQ(ResolveVideoDriverChoice(input), VideoDriverChoice::kSdlDefault);
|
||||
}
|
||||
|
||||
TEST_F(NvidiaDriverDetectionTest, DoesNotTreatNouveauAsTheNvidiaDriver) {
|
||||
std::filesystem::create_directories(pci_drivers_ / "nouveau");
|
||||
std::filesystem::create_directories(pci_drivers_ / "i915");
|
||||
std::filesystem::create_directories(pci_drivers_ / "amdgpu");
|
||||
|
||||
EXPECT_FALSE(Detect());
|
||||
}
|
||||
|
||||
TEST_F(NvidiaDriverDetectionTest, IgnoresMissingAndUnrelatedDeviceInformation) {
|
||||
EXPECT_FALSE(Detect());
|
||||
std::filesystem::create_directories(pci_drivers_ / "nvidia_drm");
|
||||
std::ofstream(pci_drivers_ / "nvidia") << "not a registered driver\n";
|
||||
EXPECT_FALSE(Detect());
|
||||
}
|
||||
|
||||
TEST(VideoDriverPolicyIntegrationTest,
|
||||
DropsUnavailableLegacyOverrideBeforeSdlInit) {
|
||||
bool has_dummy_driver = false;
|
||||
|
||||
@@ -108,6 +108,112 @@ TEST(VulkanIcdSelectionTest, IgnoresAMissingDirectory) {
|
||||
EXPECT_TRUE(SelectVulkanIcdManifest({temporary.root()}, "").empty());
|
||||
}
|
||||
|
||||
constexpr unsigned int kAmd = 0x1002;
|
||||
constexpr unsigned int kNvidia = 0x10de;
|
||||
constexpr unsigned int kIntel = 0x8086;
|
||||
|
||||
TEST(HostVulkanIcdTest, NvidiaKernelDriverSelectsTheNvidiaManifest) {
|
||||
TemporaryDirectory temporary;
|
||||
WriteManifest(temporary.root() / "nvidia_icd.json");
|
||||
WriteManifest(temporary.root() / "nouveau_icd.json");
|
||||
|
||||
const VulkanIcdSelection selection =
|
||||
SelectHostVulkanIcd({{kNvidia, "nvidia"}}, {temporary.root()}, true);
|
||||
EXPECT_EQ(selection.manifest,
|
||||
(temporary.root() / "nvidia_icd.json").string());
|
||||
EXPECT_TRUE(selection.skipped.empty());
|
||||
}
|
||||
|
||||
TEST(HostVulkanIcdTest, NouveauKernelDriverIgnoresAnInstalledNvidiaManifest) {
|
||||
TemporaryDirectory temporary;
|
||||
WriteManifest(temporary.root() / "nvidia_icd.json");
|
||||
WriteManifest(temporary.root() / "nouveau_icd.json");
|
||||
|
||||
const VulkanIcdSelection selection =
|
||||
SelectHostVulkanIcd({{kNvidia, "nouveau"}}, {temporary.root()}, true);
|
||||
EXPECT_EQ(selection.manifest,
|
||||
(temporary.root() / "nouveau_icd.json").string());
|
||||
}
|
||||
|
||||
TEST(HostVulkanIcdTest, UnknownKernelDriverTriesNvidiaThenNouveau) {
|
||||
TemporaryDirectory temporary;
|
||||
WriteManifest(temporary.root() / "nouveau_icd.json");
|
||||
|
||||
EXPECT_EQ(
|
||||
SelectHostVulkanIcd({{kNvidia, ""}}, {temporary.root()}, true).manifest,
|
||||
(temporary.root() / "nouveau_icd.json").string());
|
||||
|
||||
WriteManifest(temporary.root() / "nvidia_icd.json");
|
||||
EXPECT_EQ(
|
||||
SelectHostVulkanIcd({{kNvidia, ""}}, {temporary.root()}, true).manifest,
|
||||
(temporary.root() / "nvidia_icd.json").string());
|
||||
}
|
||||
|
||||
TEST(HostVulkanIcdTest, CardWithoutItsVulkanDriverFallsBackToTheIntelChip) {
|
||||
TemporaryDirectory temporary;
|
||||
WriteManifest(temporary.root() / "nvidia_icd.json");
|
||||
WriteManifest(temporary.root() / "intel_icd.json");
|
||||
|
||||
const VulkanIcdSelection selection = SelectHostVulkanIcd(
|
||||
{{kIntel, "i915"}, {kNvidia, "nouveau"}}, {temporary.root()}, true);
|
||||
EXPECT_EQ(selection.manifest,
|
||||
(temporary.root() / "intel_icd.json").string());
|
||||
ASSERT_EQ(selection.skipped.size(), 1u);
|
||||
EXPECT_NE(selection.skipped[0].find("nouveau"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(HostVulkanIcdTest, RadeonKernelDriverFallsBackToTheIntelChip) {
|
||||
TemporaryDirectory temporary;
|
||||
WriteManifest(temporary.root() / "radeon_icd.json");
|
||||
WriteManifest(temporary.root() / "intel_icd.json");
|
||||
|
||||
const VulkanIcdSelection selection = SelectHostVulkanIcd(
|
||||
{{kIntel, "i915"}, {kAmd, "radeon"}}, {temporary.root()}, true);
|
||||
EXPECT_EQ(selection.manifest,
|
||||
(temporary.root() / "intel_icd.json").string());
|
||||
}
|
||||
|
||||
TEST(HostVulkanIcdTest, RadeonKernelDriverAloneSelectsNothingAndSaysWhy) {
|
||||
TemporaryDirectory temporary;
|
||||
WriteManifest(temporary.root() / "radeon_icd.json");
|
||||
|
||||
const VulkanIcdSelection selection =
|
||||
SelectHostVulkanIcd({{kAmd, "radeon"}}, {temporary.root()}, true);
|
||||
EXPECT_TRUE(selection.manifest.empty());
|
||||
ASSERT_EQ(selection.skipped.size(), 1u);
|
||||
EXPECT_NE(selection.skipped[0].find("radeon"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(HostVulkanIcdTest, AmdgpuKernelDriverSelectsTheRadeonManifest) {
|
||||
TemporaryDirectory temporary;
|
||||
WriteManifest(temporary.root() / "radeon_icd.json");
|
||||
|
||||
EXPECT_EQ(SelectHostVulkanIcd({{kAmd, "amdgpu"}}, {temporary.root()}, true)
|
||||
.manifest,
|
||||
(temporary.root() / "radeon_icd.json").string());
|
||||
}
|
||||
|
||||
TEST(HostVulkanIcdTest, PrefersTheIntelChipWhenTheDiscreteCardIsNotWanted) {
|
||||
TemporaryDirectory temporary;
|
||||
WriteManifest(temporary.root() / "nvidia_icd.json");
|
||||
WriteManifest(temporary.root() / "intel_icd.json");
|
||||
|
||||
EXPECT_EQ(SelectHostVulkanIcd({{kIntel, "i915"}, {kNvidia, "nvidia"}},
|
||||
{temporary.root()}, false)
|
||||
.manifest,
|
||||
(temporary.root() / "intel_icd.json").string());
|
||||
}
|
||||
|
||||
TEST(HostVulkanIcdTest, NoKnownCardSelectsNothingAndSkipsNothing) {
|
||||
TemporaryDirectory temporary;
|
||||
WriteManifest(temporary.root() / "nvidia_icd.json");
|
||||
|
||||
const VulkanIcdSelection selection =
|
||||
SelectHostVulkanIcd({}, {temporary.root()}, true);
|
||||
EXPECT_TRUE(selection.manifest.empty());
|
||||
EXPECT_TRUE(selection.skipped.empty());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -312,10 +312,13 @@ TEST(WebViewHelperLauncherTest, SendsNormalizedInternalRouteToHelper) {
|
||||
ASSERT_TRUE(
|
||||
WriteExecutable(helper, "#!/bin/sh\ncat > '" + output.string() + "'\n"));
|
||||
|
||||
auto exit_probe = std::make_shared<ExitProbe>();
|
||||
const WebViewHelperLaunchResult result =
|
||||
LaunchWebViewHelper(helper, "www:games/servers-section/987654321");
|
||||
LaunchWebViewHelper(helper, "www:games/servers-section/987654321",
|
||||
WebViewHelperExitObserver{exit_probe, &RecordExit});
|
||||
ASSERT_TRUE(result) << result.error;
|
||||
ASSERT_TRUE(WaitForFile(output, std::chrono::seconds(2)));
|
||||
// Redirection creates the file before cat writes it; wait for completion.
|
||||
ASSERT_TRUE(WaitForExit(exit_probe, std::chrono::seconds(2)));
|
||||
|
||||
std::ifstream input(output);
|
||||
const std::string request((std::istreambuf_iterator<char>(input)),
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
#include <sys/cdefs.h>
|
||||
#include "../bionic/libc/include/assert.h"
|
||||
#ifdef __BIONIC__
|
||||
#undef __BIONIC__
|
||||
#endif
|
||||
#endif
|
||||
@@ -15,8 +15,12 @@
|
||||
typedef std::atomic_int atomic_int;
|
||||
typedef std::atomic_uint atomic_uint;
|
||||
#endif
|
||||
#ifndef PAGE_SIZE
|
||||
#define PAGE_SIZE 4096
|
||||
#endif
|
||||
#ifndef PAGE_MASK
|
||||
#define PAGE_MASK (~(PAGE_SIZE - 1))
|
||||
#endif
|
||||
#if !defined(__APPLE__) && !defined(__FreeBSD__)
|
||||
#define DEF_WEAK(a)
|
||||
#define __BIONIC_ALIGN(__value, __alignment) (((__value) + (__alignment)-1) & ~((__alignment)-1))
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
#pragma once
|
||||
#ifndef __APPLE__
|
||||
#pragma push_macro("_FORTIFY_SOURCE")
|
||||
#pragma push_macro("__bos")
|
||||
#undef _FORTIFY_SOURCE
|
||||
#undef __bos
|
||||
#include "../../bionic/libc/include/sys/cdefs.h"
|
||||
#pragma pop_macro("__bos")
|
||||
#pragma pop_macro("_FORTIFY_SOURCE")
|
||||
#endif
|
||||
#if defined(__has_include_next)
|
||||
#if __has_include_next(<sys/cdefs.h>)
|
||||
|
||||
Reference in new issue
Block a user