mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 21:00:47 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d982cd9506 | ||
|
|
65adb3bddc | ||
|
|
47e23df2e0 | ||
|
|
c0ff1842ee | ||
|
|
e073947b91 | ||
|
|
66de662e5b | ||
|
|
6c3631058c | ||
|
|
6829fd00ed | ||
|
|
8be3618cd5 | ||
|
|
265b476440 | ||
|
|
ff905fe5b7 | ||
|
|
02108a9bb1 | ||
|
|
7dd32335ca | ||
|
|
5ed6ed5e84 | ||
|
|
cef1b3ec4a | ||
|
|
8bde1a1c0d | ||
|
|
60e9c5c3ef | ||
|
|
d76e1b6f54 | ||
|
|
33c06c063e | ||
|
|
9e8afb233a | ||
|
|
7fda1eb239 | ||
|
|
f128e28b83 | ||
|
|
718e67f9e1 | ||
|
|
c6c354fdb1 | ||
|
|
25930d14ff | ||
|
|
854969457f | ||
|
|
65030d8df1 | ||
|
|
0be5e808c5 | ||
|
|
1b030a57eb | ||
|
|
2f5527b8b5 | ||
|
|
579cedbf1d | ||
|
|
a7b90e5919 | ||
|
|
3bba795ea3 | ||
|
|
5ce4487d4f | ||
|
|
c544389dd0 | ||
|
|
bdb84142d7 |
No files matched your search
+16
-74
@@ -1,8 +1,6 @@
|
||||
name: Publish AUR packages
|
||||
name: Check AUR packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -13,8 +11,8 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish AUR packages
|
||||
metadata:
|
||||
name: Refresh AUR metadata
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -27,10 +25,9 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
package_dir="$GITHUB_WORKSPACE/packaging/aur/mocktail-git"
|
||||
version="$(sed -n \
|
||||
's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' \
|
||||
CMakeLists.txt | head -n 1)"
|
||||
package_dir="$GITHUB_WORKSPACE/packaging/aur/nightcap-git"
|
||||
version="$(git describe --tags --abbrev=0 --match='v[0-9]*')"
|
||||
version="${version#v}"
|
||||
[[ "$version" =~ ^[0-9]+(\.[0-9]+)*$ ]]
|
||||
pkgver="${version}.r$(git rev-list --count HEAD).g$(git rev-parse --short=7 HEAD)"
|
||||
sed -i "s/^pkgver=.*/pkgver=${pkgver}/" "$package_dir/PKGBUILD"
|
||||
@@ -39,76 +36,21 @@ jobs:
|
||||
--volume "$package_dir:/pkg" \
|
||||
archlinux:base-devel bash -Eeuo pipefail -c '
|
||||
useradd --create-home builder
|
||||
install -d -o builder -g builder /tmp/mocktail-git
|
||||
install -d -o builder -g builder /tmp/nightcap-git
|
||||
install -o builder -g builder -m 0644 \
|
||||
/pkg/PKGBUILD /tmp/mocktail-git/PKGBUILD
|
||||
/pkg/PKGBUILD /tmp/nightcap-git/PKGBUILD
|
||||
runuser -u builder -- bash -c \
|
||||
"cd /tmp/mocktail-git && makepkg --printsrcinfo" \
|
||||
"cd /tmp/nightcap-git && makepkg --printsrcinfo" \
|
||||
> /pkg/.SRCINFO
|
||||
'
|
||||
|
||||
grep -Fqx "pkgver=${pkgver}" "$package_dir/PKGBUILD"
|
||||
grep -Fqx $'\tpkgver = '"${pkgver}" "$package_dir/.SRCINFO"
|
||||
printf 'AUR_PKGVER=%s\n' "$pkgver" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Publish to the AUR
|
||||
shell: bash
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
AUR_SSH_KEY_PASSPHRASE: ${{ secrets.AUR_SSH_KEY_PASSPHRASE }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
[[ -n "$AUR_SSH_PRIVATE_KEY" ]]
|
||||
[[ -n "$AUR_SSH_KEY_PASSPHRASE" ]]
|
||||
|
||||
private_key="$RUNNER_TEMP/aur_ed25519"
|
||||
askpass="$RUNNER_TEMP/aur_askpass"
|
||||
known_hosts="$RUNNER_TEMP/aur_known_hosts"
|
||||
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$private_key"
|
||||
unset AUR_SSH_PRIVATE_KEY
|
||||
chmod 0600 "$private_key"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'printf '\''%s\n'\'' "$AUR_SSH_KEY_PASSPHRASE"' \
|
||||
> "$askpass"
|
||||
chmod 0700 "$askpass"
|
||||
eval "$(ssh-agent -s)"
|
||||
trap 'ssh-agent -k >/dev/null; rm -f -- "$private_key" "$askpass"' EXIT
|
||||
DISPLAY=:0 SSH_ASKPASS="$askpass" SSH_ASKPASS_REQUIRE=force \
|
||||
ssh-add "$private_key" </dev/null
|
||||
unset AUR_SSH_KEY_PASSPHRASE
|
||||
|
||||
ssh-keyscan -T 10 -t ed25519 aur.archlinux.org > "$known_hosts"
|
||||
fingerprint="$(ssh-keygen -lf "$known_hosts" -E sha256 | awk '{print $2}')"
|
||||
[[ "$fingerprint" == \
|
||||
'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4' ]]
|
||||
|
||||
export GIT_SSH_COMMAND="ssh -F /dev/null -i $private_key \
|
||||
-o BatchMode=yes -o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile=$known_hosts"
|
||||
publish_aur_package() {
|
||||
local package_name="$1"
|
||||
local commit_message="$2"
|
||||
local source_dir="packaging/aur/$package_name"
|
||||
local aur_dir="$RUNNER_TEMP/${package_name}-aur"
|
||||
|
||||
git clone --branch master --single-branch \
|
||||
"ssh://aur@aur.archlinux.org/${package_name}.git" "$aur_dir"
|
||||
install -m 0644 "$source_dir/PKGBUILD" "$aur_dir/PKGBUILD"
|
||||
install -m 0644 "$source_dir/.SRCINFO" "$aur_dir/.SRCINFO"
|
||||
|
||||
git -C "$aur_dir" config user.name komaruworld
|
||||
git -C "$aur_dir" config user.email rtysus228@gmail.com
|
||||
git -C "$aur_dir" add PKGBUILD .SRCINFO
|
||||
if git -C "$aur_dir" diff --cached --quiet; then
|
||||
printf '%s is already published\n' "$package_name"
|
||||
return
|
||||
fi
|
||||
git -C "$aur_dir" commit -m "$commit_message"
|
||||
git -C "$aur_dir" push origin HEAD:master
|
||||
}
|
||||
|
||||
publish_aur_package mocktail-git "Update to ${AUR_PKGVER}"
|
||||
publish_aur_package mocktail 'make builds stable'
|
||||
publish_aur_package mocktail-bin 'make builds stable'
|
||||
- name: Upload AUR metadata
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Nightcap-AUR
|
||||
path: packaging/aur/nightcap-git/.SRCINFO
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -126,10 +126,153 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
deb:
|
||||
name: DEB (Ubuntu 26.04)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Build DEB
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
docker run --rm \
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
--env MOCKTAIL_PACKAGE_VERSION="$(./scripts/package_version.sh)" \
|
||||
ubuntu:26.04 bash -Eeuo pipefail -c '
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install --yes --no-install-recommends \
|
||||
build-essential cmake git ninja-build pkg-config lld fakeroot \
|
||||
dpkg-dev file libsdl3-dev libsdl3-ttf-dev \
|
||||
libcurl4-openssl-dev libssl-dev nlohmann-json3-dev \
|
||||
libyaml-dev libelf-dev libminizip-dev libcapstone-dev \
|
||||
libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
|
||||
libutf8proc-dev libfontconfig1-dev libegl-dev \
|
||||
libvulkan-dev libplacebo-dev zlib1g-dev libpng-dev
|
||||
cmake -S . -B build-deb -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DMOCKTAIL_PACKAGE_NAME=nightcap \
|
||||
-DMOCKTAIL_PACKAGE_VERSION="$MOCKTAIL_PACKAGE_VERSION" \
|
||||
-DBUILD_TESTING=OFF
|
||||
cmake --build build-deb --parallel 4
|
||||
cpack --config build-deb/CPackConfig.cmake \
|
||||
-G DEB -B dist/deb
|
||||
dpkg-deb --info dist/deb/*.deb >/dev/null
|
||||
'
|
||||
|
||||
- name: Upload DEB
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Nightcap-DEB
|
||||
path: dist/deb/*.deb
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
rpm:
|
||||
name: RPM (Fedora 44)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Build RPM
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
docker run --rm \
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
--env MOCKTAIL_PACKAGE_VERSION="$(./scripts/package_version.sh)" \
|
||||
fedora:44 bash -Eeuo pipefail -c '
|
||||
dnf install --assumeyes \
|
||||
gcc-c++ cmake git ninja-build pkgconf-pkg-config lld rpm-build \
|
||||
SDL3-devel SDL3_ttf-devel libcurl-devel openssl-devel \
|
||||
nlohmann-json-devel libyaml-devel elfutils-libelf-devel \
|
||||
minizip-ng-compat-devel capstone-devel gtk4-devel libadwaita-devel \
|
||||
webkitgtk6.0-devel utf8proc-devel fontconfig-devel \
|
||||
libglvnd-devel vulkan-headers vulkan-loader-devel \
|
||||
libplacebo-devel zlib-ng-compat-devel libpng-devel
|
||||
cmake -S . -B build-rpm -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DCMAKE_INSTALL_LIBDIR=lib \
|
||||
-DMOCKTAIL_PACKAGE_NAME=nightcap \
|
||||
-DMOCKTAIL_PACKAGE_VERSION="$MOCKTAIL_PACKAGE_VERSION" \
|
||||
-DBUILD_TESTING=OFF
|
||||
cmake --build build-rpm --parallel 4
|
||||
cpack --config build-rpm/CPackConfig.cmake \
|
||||
-G RPM -B dist/rpm
|
||||
rpm --query --package --info dist/rpm/*.rpm >/dev/null
|
||||
'
|
||||
|
||||
- name: Upload RPM
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Nightcap-RPM
|
||||
path: dist/rpm/*.rpm
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
arch:
|
||||
name: Pacman package (Arch Linux)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Build pacman package
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
version="$(./scripts/package_version.sh)"
|
||||
sed -i "s/^pkgver=.*/pkgver=${version}/" packaging/arch/PKGBUILD
|
||||
docker run --rm \
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
|
||||
pacman -Syu --needed --noconfirm \
|
||||
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
|
||||
nlohmann-json libyaml libelf minizip capstone gtk4 \
|
||||
libadwaita webkitgtk-6.0 libutf8proc fontconfig libpng \
|
||||
libplacebo vulkan-headers vulkan-icd-loader libglvnd zlib
|
||||
useradd --create-home builder
|
||||
chown -R builder:builder /workspace
|
||||
runuser -u builder -- env MOCKTAIL_SOURCE_DIR=/workspace \
|
||||
makepkg --dir /workspace/packaging/arch \
|
||||
--noconfirm --cleanbuild --force
|
||||
install -d dist/arch
|
||||
cp packaging/arch/*.pkg.tar.zst dist/arch/
|
||||
pacman -Qp dist/arch/*.pkg.tar.zst >/dev/null
|
||||
'
|
||||
|
||||
- name: Upload pacman package
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Nightcap-Arch
|
||||
path: dist/arch/*.pkg.tar.zst
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
publish:
|
||||
name: Publish GitHub release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [appimage]
|
||||
needs: [appimage, deb, rpm, arch]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
@@ -138,6 +281,13 @@ jobs:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Download packages
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: 'Nightcap-*'
|
||||
merge-multiple: true
|
||||
path: release
|
||||
|
||||
- name: Download AppImage
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
@@ -152,7 +302,28 @@ jobs:
|
||||
set -Eeuo pipefail
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
mv release/Mocktail-x86_64.AppImage release/Nightcap-x86_64.AppImage
|
||||
(cd release && sha256sum Nightcap-x86_64.AppImage > Nightcap-x86_64.AppImage.sha256)
|
||||
# An unmatched glob would otherwise reach sha256sum and gh as its own
|
||||
# literal text, so every package pattern has to match before upload.
|
||||
shopt -s nullglob
|
||||
assets=(Nightcap-x86_64.AppImage)
|
||||
for glob in '*.deb' '*.rpm' '*.pkg.tar.zst'; do
|
||||
matched=(release/$glob)
|
||||
if [[ ${#matched[@]} -eq 0 ]]; then
|
||||
echo "::error::no ${glob} package among the release artifacts"
|
||||
exit 1
|
||||
fi
|
||||
assets+=("${matched[@]#release/}")
|
||||
done
|
||||
uploads=()
|
||||
for asset in "${assets[@]}"; do
|
||||
uploads+=("release/$asset" "release/$asset.sha256")
|
||||
done
|
||||
(
|
||||
cd release
|
||||
for asset in "${assets[@]}"; do
|
||||
sha256sum "$asset" > "$asset.sha256"
|
||||
done
|
||||
)
|
||||
./scripts/release_notes.sh "$tag" > release/notes.md
|
||||
# The Flatpak workflow creates the same release; whichever job
|
||||
# arrives second sees "already exists" and continues.
|
||||
@@ -164,5 +335,4 @@ jobs:
|
||||
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--title "Nightcap $tag" --notes-file release/notes.md
|
||||
gh release upload "$tag" --repo "$GITHUB_REPOSITORY" --clobber \
|
||||
release/Nightcap-x86_64.AppImage \
|
||||
release/Nightcap-x86_64.AppImage.sha256
|
||||
"${uploads[@]}"
|
||||
@@ -177,3 +177,6 @@ lsan.log*
|
||||
# Test fixtures
|
||||
!/third_party/mcpelauncher-linker/core/libziparchive/**/*.zip
|
||||
!/third_party/mcpelauncher-linker/core/libziparchive/**/*.apk
|
||||
|
||||
# Working plans, not part of the shipped documentation
|
||||
/docs/plans/
|
||||
+118
-1
@@ -6,6 +6,121 @@ GitHub are taken from the matching section here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.1] - 2026-09-20
|
||||
|
||||
### Added
|
||||
|
||||
- Each release carries a DEB, an RPM, and a pacman package beside the
|
||||
AppImage, with a `.sha256` for every file. They install `/usr/bin/mocktail`
|
||||
and conflict with each other and with upstream's `mocktail` package.
|
||||
Nightcap is in no distribution repository or the AUR; the recipes under
|
||||
`packaging/aur/nightcap{,-bin,-git}` build from this repository locally.
|
||||
- The AppStream metadata lists the Nightcap releases, so software centres
|
||||
show the release history instead of upstream's.
|
||||
|
||||
### Fixed
|
||||
|
||||
- ETC2 texture decoding no longer blocks the thread that submits the frame.
|
||||
Uploads decode on a background dispatcher and the submit carries a timeline
|
||||
semaphore wait, so the GPU waits for the texture instead of the render
|
||||
thread. Reading the application's compressed bytes moves to that dispatcher
|
||||
too whenever they are already mapped. In Blade Ball the worst
|
||||
`vkQueueSubmit` fell from 140ms to 1ms, its p99 from 4.9ms to 0.05ms, and
|
||||
the total time spent in submits from 2534ms to 309ms. Frame interval is
|
||||
unchanged: the remaining tail is the engine's own main-thread step. A device
|
||||
without the `timelineSemaphore` feature decodes inline as before, as does a
|
||||
submit whose `pNext` already sizes arrays by its semaphore counts.
|
||||
- The mouse pointer jumped when a right-click camera drag ended, and drifted
|
||||
to the viewport edge during one. The input router inferred pointer capture
|
||||
from SDL reporting zero absolute coordinates, which holds on X11 but not on
|
||||
Wayland, where the reported position accumulates the relative deltas and
|
||||
leaves the window. Motion, button, and wheel events now carry the window's
|
||||
relative mouse mode. The right-click camera fallback also only captures
|
||||
inside an experience, so the cursor stays free over the app surface.
|
||||
- Shutting the window down while an experience was starting or leaving could
|
||||
free the pointer capture owner underneath the thread using it. Callers off
|
||||
the SDL thread now hold a reference for the length of their call.
|
||||
- The TextBox caret did not move when a trailing space was typed in a
|
||||
single-line field. The final cluster rect SDL_ttf reports stops at the last
|
||||
inked glyph, so the laid-out width is used there instead. Wrapped layouts
|
||||
keep the clamp, because their width is the widest line rather than the last.
|
||||
- Every TextBox keystroke re-ran `TTF_Init`, a fontconfig sort, six
|
||||
`TTF_OpenFont` calls, and `TTF_Quit` on the render thread. The overlay holds
|
||||
its opened fonts and reopens them only when the Roblox font or the point
|
||||
size changes, keeping the current set when a reopen fails. Measured over 100
|
||||
rasters, 1.59ms to 0.12ms per keystroke.
|
||||
- Staged ETC2 copies now carry an explicit host-write barrier. A queue
|
||||
submission only makes host writes from before it visible to the device, and
|
||||
an asynchronous decode writes after the submit, so without the barrier those
|
||||
texels were not guaranteed to reach the copy that reads them.
|
||||
|
||||
### Changed
|
||||
|
||||
- `docs/PERFORMANCE.md` documents the GameMode settings to run with. A
|
||||
`desiredgov=powersave` in `~/.config/gamemode.ini` overrides the packaged
|
||||
`performance` default and clocks the CPU down for the whole session while
|
||||
still reporting an active performance request.
|
||||
|
||||
## [0.3.0] - 2026-09-18
|
||||
|
||||
### Added
|
||||
|
||||
- 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.
|
||||
|
||||
## [0.2.0] - 2026-09-16
|
||||
|
||||
### Added
|
||||
@@ -75,6 +190,8 @@ First Nightcap release, forked from komaruworld/mocktail.
|
||||
- The AppImage build no longer fails on stale checksums for the slimmed
|
||||
Arch packages.
|
||||
|
||||
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.2.0...HEAD
|
||||
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.1...HEAD
|
||||
[0.3.1]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.1
|
||||
[0.3.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.0
|
||||
[0.2.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.2.0
|
||||
[0.1.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.1.0
|
||||
+23
-17
@@ -266,6 +266,7 @@ add_library(mocktail_runtime STATIC
|
||||
src/runtime/crash_report_policy.cc
|
||||
src/runtime/discord_rpc.cc
|
||||
src/runtime/device_profile.cc
|
||||
src/runtime/engine_pump_rest.cc
|
||||
src/runtime/environment.cc
|
||||
src/runtime/external_launch_broker.cc
|
||||
src/runtime/frame_rate_policy.cc
|
||||
@@ -785,23 +786,29 @@ install(CODE "
|
||||
endif()
|
||||
")
|
||||
|
||||
set(MOCKTAIL_PACKAGE_NAMES mocktail mocktail-nightly nightcap nightcap-nightly)
|
||||
set(MOCKTAIL_PACKAGE_NAME "mocktail" CACHE STRING
|
||||
"Native package name"
|
||||
)
|
||||
set(MOCKTAIL_PACKAGE_VERSION "${PROJECT_VERSION}" CACHE STRING
|
||||
"Native package version"
|
||||
)
|
||||
set(MOCKTAIL_PACKAGE_VERSION_SUFFIX "" CACHE STRING
|
||||
"Native package version suffix"
|
||||
)
|
||||
if(NOT MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail" AND
|
||||
NOT MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail-nightly")
|
||||
if(NOT MOCKTAIL_PACKAGE_NAME IN_LIST MOCKTAIL_PACKAGE_NAMES)
|
||||
string(JOIN ", " MOCKTAIL_PACKAGE_NAMES_TEXT ${MOCKTAIL_PACKAGE_NAMES})
|
||||
message(FATAL_ERROR
|
||||
"MOCKTAIL_PACKAGE_NAME must be mocktail or mocktail-nightly")
|
||||
"MOCKTAIL_PACKAGE_NAME must be one of ${MOCKTAIL_PACKAGE_NAMES_TEXT}")
|
||||
endif()
|
||||
|
||||
set(CPACK_PACKAGE_NAME "${MOCKTAIL_PACKAGE_NAME}")
|
||||
set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}${MOCKTAIL_PACKAGE_VERSION_SUFFIX}")
|
||||
set(CPACK_PACKAGE_VENDOR "Mocktail Project")
|
||||
set(CPACK_PACKAGE_CONTACT "komaruworld <rtysus228@gmail.com>")
|
||||
set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/komaruworld/mocktail")
|
||||
set(CPACK_PACKAGE_VERSION
|
||||
"${MOCKTAIL_PACKAGE_VERSION}${MOCKTAIL_PACKAGE_VERSION_SUFFIX}"
|
||||
)
|
||||
set(CPACK_PACKAGE_VENDOR "Nightcap")
|
||||
set(CPACK_PACKAGE_CONTACT "vii <coderdayton14@gmail.com>")
|
||||
set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/CoderDayton/nightcap")
|
||||
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}")
|
||||
set(CPACK_PACKAGE_DESCRIPTION
|
||||
"Runs the Android x86-64 Roblox client on Linux."
|
||||
@@ -811,7 +818,7 @@ set(CPACK_PACKAGING_INSTALL_PREFIX "/usr")
|
||||
set(CPACK_STRIP_FILES TRUE)
|
||||
|
||||
set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT)
|
||||
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "komaruworld <rtysus228@gmail.com>")
|
||||
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "vii <coderdayton14@gmail.com>")
|
||||
set(CPACK_DEBIAN_PACKAGE_SECTION "games")
|
||||
set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional")
|
||||
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
|
||||
@@ -821,15 +828,14 @@ set(CPACK_RPM_PACKAGE_LICENSE "Apache-2.0")
|
||||
set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games")
|
||||
set(CPACK_RPM_PACKAGE_AUTOREQPROV ON)
|
||||
|
||||
if(MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail-nightly")
|
||||
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "mocktail")
|
||||
set(CPACK_DEBIAN_PACKAGE_REPLACES "mocktail")
|
||||
set(CPACK_RPM_PACKAGE_CONFLICTS "mocktail")
|
||||
else()
|
||||
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "mocktail-nightly")
|
||||
set(CPACK_DEBIAN_PACKAGE_REPLACES "mocktail-nightly")
|
||||
set(CPACK_RPM_PACKAGE_CONFLICTS "mocktail-nightly")
|
||||
endif()
|
||||
# Every variant installs the same /usr/bin/mocktail and /usr/share/mocktail,
|
||||
# so only one of them can be present at a time.
|
||||
set(MOCKTAIL_PACKAGE_SIBLINGS ${MOCKTAIL_PACKAGE_NAMES})
|
||||
list(REMOVE_ITEM MOCKTAIL_PACKAGE_SIBLINGS "${MOCKTAIL_PACKAGE_NAME}")
|
||||
string(JOIN ", " MOCKTAIL_PACKAGE_CONFLICTS ${MOCKTAIL_PACKAGE_SIBLINGS})
|
||||
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "${MOCKTAIL_PACKAGE_CONFLICTS}")
|
||||
set(CPACK_DEBIAN_PACKAGE_REPLACES "${MOCKTAIL_PACKAGE_CONFLICTS}")
|
||||
set(CPACK_RPM_PACKAGE_CONFLICTS "${MOCKTAIL_PACKAGE_CONFLICTS}")
|
||||
include(CPack)
|
||||
|
||||
add_dependencies(mocktail all_stubs)
|
||||
|
||||
@@ -25,7 +25,8 @@ side by side.
|
||||
waiting in a queue.
|
||||
- **Sharper textures.** Small textures are upscaled 4x by default, and you
|
||||
can replace any texture with your own PNG in `~/.config/mocktail/textures`.
|
||||
Set `MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn the upscale off.
|
||||
A ready-made crosshair is in [`textures/`](textures/README.md). Set
|
||||
`MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn the upscale off.
|
||||
- **ETC2 textures on any GPU.** Games that use compressed mobile textures now
|
||||
render correctly on desktop GPUs, without stutter.
|
||||
- **No more error 319 kicks.** Servers no longer drop you shortly after
|
||||
@@ -36,8 +37,9 @@ side by side.
|
||||
|
||||
Everything else works the same as upstream. See the
|
||||
[roadmap](docs/ROADMAP.md) for what is coming next, the
|
||||
[changelog](CHANGELOG.md) for what each release changed, and the
|
||||
[FAQ](FAQ.md) for Discord RPC, logs, and VR.
|
||||
[changelog](CHANGELOG.md) for what each release changed,
|
||||
[performance](docs/PERFORMANCE.md) for CPU load, heat and the knobs that change
|
||||
them, and the [FAQ](FAQ.md) for Discord RPC, logs, and VR.
|
||||
|
||||
## Install with Flatpak
|
||||
|
||||
@@ -59,10 +61,24 @@ chmod +x Nightcap-x86_64.AppImage
|
||||
./Nightcap-x86_64.AppImage
|
||||
```
|
||||
|
||||
Or build and install it from source using the steps under
|
||||
[Building](#building). There are no AUR, DEB, or RPM packages of Nightcap.
|
||||
Upstream ships those for unmodified Mocktail, see
|
||||
[komaruworld/mocktail](https://github.com/komaruworld/mocktail#readme).
|
||||
## Distribution packages
|
||||
|
||||
Each release also carries a DEB, an RPM, and a pacman package, with a
|
||||
`.sha256` beside every file on the
|
||||
[releases page](https://github.com/CoderDayton/nightcap/releases/latest):
|
||||
|
||||
```bash
|
||||
sudo apt install ./nightcap_<version>-1_amd64.deb
|
||||
sudo dnf install ./nightcap-<version>-1.x86_64.rpm
|
||||
sudo pacman -U nightcap-<version>-1-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
They install `/usr/bin/mocktail`, so they conflict with upstream's `mocktail`
|
||||
package. Nightcap is not in any distribution repository or the AUR; the
|
||||
recipes under `packaging/aur` are kept for local builds only.
|
||||
|
||||
Or build and install from source using the steps under
|
||||
[Building](#building).
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -114,8 +130,9 @@ Linux `x86_64` is supported. Experimental FreeBSD 15.1 Linuxulator support has
|
||||
been tested with an `x86_64` Fedora 44 userspace. On FreeBSD, Nightcap runs
|
||||
inside Linuxulator; it is not a native FreeBSD binary. Building requires CMake
|
||||
3.20+, Git, pkg-config, LLD, binutils, a C++17 compiler, SDL 3.4+, SDL3_ttf,
|
||||
Vulkan, EGL, libplacebo, fontconfig, libcurl, OpenSSL, libelf, libyaml, minizip,
|
||||
Capstone 5, utf8proc, nlohmann/json, GTK4, libadwaita 1.6+, and WebKitGTK 6.0.
|
||||
Vulkan, EGL, libplacebo, fontconfig, libcurl, OpenSSL, libelf, libyaml, libpng,
|
||||
minizip, Capstone 5, utf8proc, nlohmann/json, GTK4, libadwaita 1.6+, and
|
||||
WebKitGTK 6.0.
|
||||
|
||||
See the [FreeBSD Guide](packaging/freebsd/README.md) for setup and launch instructions.
|
||||
|
||||
@@ -126,7 +143,7 @@ See the [FreeBSD Guide](packaging/freebsd/README.md) for setup and launch instru
|
||||
sudo apt update
|
||||
sudo apt install build-essential cmake git ninja-build pkg-config lld \
|
||||
libsdl3-dev libsdl3-ttf-dev libcurl4-openssl-dev libssl-dev \
|
||||
nlohmann-json3-dev libyaml-dev libelf-dev libminizip-dev \
|
||||
nlohmann-json3-dev libyaml-dev libpng-dev libelf-dev libminizip-dev \
|
||||
libcapstone-dev libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
|
||||
libutf8proc-dev libfontconfig1-dev libegl-dev libvulkan-dev \
|
||||
libplacebo-dev zlib1g-dev
|
||||
@@ -138,7 +155,7 @@ sudo apt install build-essential cmake git ninja-build pkg-config lld \
|
||||
|
||||
```bash
|
||||
sudo pacman -S --needed base-devel cmake git ninja pkgconf lld sdl3 sdl3_ttf \
|
||||
curl openssl nlohmann-json libyaml libelf minizip capstone gtk4 \
|
||||
curl openssl nlohmann-json libyaml libpng libelf minizip capstone gtk4 \
|
||||
libadwaita webkitgtk-6.0 libutf8proc fontconfig libglvnd \
|
||||
libplacebo vulkan-headers vulkan-icd-loader zlib
|
||||
```
|
||||
@@ -150,7 +167,8 @@ sudo pacman -S --needed base-devel cmake git ninja pkgconf lld sdl3 sdl3_ttf \
|
||||
```bash
|
||||
sudo dnf install gcc-c++ cmake git ninja-build pkgconf-pkg-config lld \
|
||||
SDL3-devel SDL3_ttf-devel libcurl-devel openssl-devel \
|
||||
nlohmann-json-devel libyaml-devel elfutils-libelf-devel minizip-ng-compat-devel \
|
||||
nlohmann-json-devel libyaml-devel libpng-devel elfutils-libelf-devel \
|
||||
minizip-ng-compat-devel \
|
||||
capstone-devel gtk4-devel libadwaita-devel webkitgtk6.0-devel \
|
||||
utf8proc-devel fontconfig-devel libglvnd-devel vulkan-headers \
|
||||
vulkan-loader-devel libplacebo-devel zlib-ng-compat-devel
|
||||
|
||||
@@ -112,12 +112,24 @@ integrations:
|
||||
public_servers_only: true
|
||||
# button_label: Join Server
|
||||
# English defaults. Uncomment only the lines you want to customize.
|
||||
# Text and image fields accept {place_name}, {place_icon}, and
|
||||
# {creator_name}. A field that expands an empty placeholder is left out,
|
||||
# and a "" title shows the app name.
|
||||
# title, state, and the images fields may be "" to hide them.
|
||||
# text:
|
||||
# browsing: Browsing experiences
|
||||
# joining: Joining an experience
|
||||
# playing: "{place_name}"
|
||||
# state: Playing Roblox
|
||||
# playing: "Playing {place_name}"
|
||||
# state: "by {creator_name}"
|
||||
# unknown_place: Unknown experience
|
||||
# title: Roblox
|
||||
# Images take an https:// URL, or an asset key uploaded to application_id.
|
||||
# large: "" hides both images; the small image needs a large one.
|
||||
# images:
|
||||
# large: "{place_icon}"
|
||||
# large_text: "{place_name}"
|
||||
# small: roblox_small
|
||||
# small_text: Roblox
|
||||
# Advanced: overrides the application bundled by the package maintainer.
|
||||
# application_id: 123456789012345678
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Benchmarking
|
||||
|
||||
A pull request that claims a speedup shows before and after numbers taken
|
||||
the way this guide describes. Numbers from different machines are not
|
||||
compared with each other. Each run is compared with a run on the same
|
||||
machine.
|
||||
|
||||
## Two layers, two tools
|
||||
|
||||
Roblox measures its own work. Nightcap measures the layer between Roblox and
|
||||
your driver.
|
||||
|
||||
| Layer | What it covers | Tool |
|
||||
| --- | --- | --- |
|
||||
| Roblox | Scripts, physics, rendering jobs, network | Roblox's stats overlays, Shift+F4 and Shift+F5 in game |
|
||||
| Nightcap | Vulkan adapter: present, swapchain and GPU waits, submits, ETC2 texture decode, pipeline and shader creation | `--profile` |
|
||||
|
||||
A Nightcap change should move the `--profile` numbers. If it only moves the
|
||||
Roblox overlay numbers, say so in the pull request, since the cause is then
|
||||
less direct.
|
||||
|
||||
## Recording a trace
|
||||
|
||||
```sh
|
||||
mocktail --profile ~/nightcap-before.json
|
||||
```
|
||||
|
||||
The Vulkan adapter writes a Chrome-format trace to that file, a few times a
|
||||
second. The file is valid JSON after every write, so a session that crashes
|
||||
or is killed still leaves a readable trace. Exit from the Roblox menu when you are done. Open the file
|
||||
in [ui.perfetto.dev](https://ui.perfetto.dev) or `chrome://tracing`.
|
||||
|
||||
Expect about 130 bytes per event, around 15 MB per minute at 240 FPS.
|
||||
Profiling is off unless `--profile` is given, and `MOCKTAIL_PROFILE_TRACE`
|
||||
does the same thing as the flag.
|
||||
|
||||
### What the trace contains
|
||||
|
||||
Each slice is named after the call it times, on the thread that made it.
|
||||
|
||||
| Category | Slices | Arguments |
|
||||
| --- | --- | --- |
|
||||
| `present` | `vkQueuePresentKHR`: the whole adapter present, including the text overlay | `result` |
|
||||
| `present` | `host present`: the host driver's present, nested inside the slice above | |
|
||||
| `wait` | `vkAcquireNextImageKHR`, `vkAcquireNextImage2KHR`, `vkWaitForFences`, `vkWaitSemaphores`, `vkWaitSemaphoresKHR`, `vkQueueWaitIdle`, `vkDeviceWaitIdle` | |
|
||||
| `submit` | `vkQueueSubmit`, `vkQueueSubmit2`, `vkQueueSubmit2KHR` | `submits` |
|
||||
| `texture` | `etc2 decode`: CPU decode of ETC2 textures. On a device with a timeline semaphore this runs on the decode dispatcher, not the submitting thread | `uploads`, `compressed_bytes`, `decoded_bytes` |
|
||||
| `texture` | `etc2 gather`: the part of an asynchronous upload that stays on the submitting thread. Absent when the decode ran inline | `uploads`, `compressed_bytes`, `decoded_bytes`, `ticket` |
|
||||
| `pipeline` | `vkCreateGraphicsPipelines`, `vkCreateComputePipelines` | `count`, `cache` (1 when a pipeline cache was passed), `result` |
|
||||
| `pipeline` | `vkCreateShaderModule` | `bytes`, `result` |
|
||||
| `pipeline` | `vkCreatePipelineCache` | `initial_bytes`, `result` |
|
||||
| `pump` | `nativeCallMessagesFromMainThread`: the engine's main-thread step, on the host main thread. Only calls of 20 µs or more are recorded; the empty polls between them are not. | |
|
||||
|
||||
The `frame interval (ms)` counter is the time between successive adapter
|
||||
presents. `vkQueuePresentKHR` time minus `host present` time is the adapter's
|
||||
own cost per frame.
|
||||
|
||||
## The benchmark runs
|
||||
|
||||
Run every scenario before and after the change, on the same build type
|
||||
(`make build`), with the same `config.yaml`. Close other GPU-heavy apps.
|
||||
Record `graphics.frame_rate_limit` and `graphics.vsync` in the pull request,
|
||||
because a frame cap hides frame time gains.
|
||||
|
||||
Record `~/.config/gamemode.ini` too, and do not change it between the before
|
||||
and after runs. `desiredgov` there decides the CPU governor for the whole
|
||||
session and therefore which engine pump mode is chosen, which moves process
|
||||
CPU by more than most changes under test. See
|
||||
[PERFORMANCE.md](PERFORMANCE.md).
|
||||
|
||||
Frame interval p99 varies by 2 ms or more between identical runs, so a
|
||||
difference that size is not a result. Three runs per scenario is the minimum
|
||||
for any frame-time claim; slice totals for a specific subsystem are far less
|
||||
noisy and can be read from fewer.
|
||||
|
||||
| Scenario | How to start | What to do | What it shows |
|
||||
| --- | --- | --- | --- |
|
||||
| Home screen, cold | `mocktail --profile home-cold.json` with an empty shader cache (below) | Wait 30 s on the home screen, exit | First-load stutter: `pipeline` and `texture` slices |
|
||||
| Tower of Hell | `mocktail --profile toh.json --launch-uri "roblox://experiences/start?placeId=1962086868"` | Stand still at spawn for 60 s, exit | Steady-state frame time on a light scene |
|
||||
| Murder Mystery 2 | `mocktail --profile mm2.json --launch-uri "roblox://experiences/start?placeId=142823291"` | Stay in the lobby for 60 s, exit | Frame time with many players and a busier scene |
|
||||
|
||||
For the cold run, point the driver's shader cache at an empty directory, so
|
||||
your normal cache is left alone:
|
||||
|
||||
```sh
|
||||
cache=$(mktemp -d)
|
||||
MESA_SHADER_CACHE_DIR="$cache" __GL_SHADER_DISK_CACHE_PATH="$cache" \
|
||||
mocktail --profile home-cold.json
|
||||
```
|
||||
|
||||
`MESA_SHADER_CACHE_DIR` applies to Mesa drivers (AMD, Intel) and
|
||||
`__GL_SHADER_DISK_CACHE_PATH` to the NVIDIA driver.
|
||||
|
||||
Run each scenario three times and keep the median run, since one run can
|
||||
catch a background stall.
|
||||
|
||||
## Comparing runs
|
||||
|
||||
```sh
|
||||
scripts/summarize_profile_trace.py toh-before.json toh-after.json
|
||||
```
|
||||
|
||||
This prints a Markdown table with one column per trace: frame count, frame
|
||||
interval mean, p50, p95, p99 and max, then count, total, mean, p99 and max
|
||||
for every slice name, largest total first. Paste the table into the pull
|
||||
request and attach both traces.
|
||||
|
||||
For frame time, compare p95 and p99 as well as the mean. Stutter shows up in
|
||||
the tail first. For first-load work, compare the `total (ms)` rows of the
|
||||
`pipeline` and `texture` slices from the cold run.
|
||||
@@ -0,0 +1,193 @@
|
||||
# Performance and CPU load
|
||||
|
||||
What Nightcap does to your CPU and GPU, which knobs change it, and how to
|
||||
measure rather than guess.
|
||||
|
||||
## GameMode core pinning
|
||||
|
||||
Nightcap asks Feral GameMode for a performance session at startup
|
||||
(`performance.gamemode`, default `auto`). GameMode raises the CPU governor and
|
||||
the process priority, which helps.
|
||||
|
||||
On a CPU with a wide spread of per-core maximum frequencies — Intel P-core and
|
||||
E-core parts, AMD parts with preferred cores — GameMode also pins the process
|
||||
to the fastest cores. On a 32-thread host that can mean 4 usable threads out of
|
||||
32. Roblox spreads its work across a worker pool, so the pin concentrates that
|
||||
pool onto a couple of physical cores instead of spreading it. Those cores sit
|
||||
near their boost ceiling and run hot while the rest of the chip idles.
|
||||
|
||||
GameMode re-applies the pin roughly every 5 seconds, so widening the mask from
|
||||
inside the process does not hold. Releasing the request does release the pin.
|
||||
|
||||
Under `auto`, Nightcap therefore checks its CPU affinity immediately after the
|
||||
request. If GameMode narrowed it, Nightcap ends the request and prints:
|
||||
|
||||
```
|
||||
[gamemode] released: it pinned this process to a subset of the CPUs, ...
|
||||
```
|
||||
|
||||
The trade is that GameMode's governor and priority boost go with it. To keep
|
||||
those and lose only the pinning, turn pinning off for every game on the host.
|
||||
|
||||
These are the settings to run with:
|
||||
|
||||
```ini
|
||||
# ~/.config/gamemode.ini
|
||||
[general]
|
||||
desiredgov=performance
|
||||
|
||||
[cpu]
|
||||
pin_cores=no
|
||||
park_cores=no
|
||||
```
|
||||
|
||||
`desiredgov` is the one to check first. The packaged default in
|
||||
`/usr/share/gamemode/gamemode.ini` is already `performance`, but a value in
|
||||
your own file replaces it, and `desiredgov=powersave` there means GameMode
|
||||
clocks the CPU **down** for the whole session while still reporting
|
||||
`[gamemode] performance request active`. The symptoms are a low CPU clock
|
||||
under load, a GPU that never leaves its lower SM clocks, and the engine pump
|
||||
falling back to `tpause` — which costs about 70% of a core instead of 3%,
|
||||
because `sleep` is only chosen when the governor reads `performance`. On one
|
||||
13900K host, correcting `powersave` to `performance` took the process from
|
||||
177% CPU to 42% and the package from 70°C to 53-63°C, at the same frame rate.
|
||||
|
||||
GameMode reads `$XDG_CONFIG_HOME`, `/usr/share/gamemode` and `/etc`, in that
|
||||
order. It does not read anything under Nightcap's own config directory, so this
|
||||
file has to live in one of those three places. The daemon reads it when it
|
||||
starts, so restart it after editing: `systemctl --user restart gamemoded`.
|
||||
|
||||
Restarting the daemon drops the session of any game already running; relaunch
|
||||
the game for new settings to apply to it.
|
||||
|
||||
`gamemoded` is D-Bus activated through
|
||||
`com.feralinteractive.GameMode.service`, so `systemctl --user stop gamemoded`
|
||||
does not hold — the next client request starts it again within seconds. To
|
||||
measure with GameMode genuinely off, mask it:
|
||||
|
||||
```bash
|
||||
systemctl --user mask gamemoded # and --unmask afterwards
|
||||
```
|
||||
|
||||
The governor switch runs through polkit. Debian and Ubuntu only allow it for
|
||||
members of the `gamemode` group; without that, the daemon's journal shows
|
||||
`cpugovctl set performance: Not authorized` and the governor stays put while
|
||||
the session is otherwise active. Add yourself and log in again:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG gamemode "$USER"
|
||||
```
|
||||
|
||||
Fedora and Arch allow any logged-in local user.
|
||||
|
||||
Setting `performance.gamemode: on` accepts the pinning instead: `on` is an
|
||||
explicit request for GameMode, and Nightcap keeps the session whatever it does
|
||||
to the affinity. `off` never contacts the daemon.
|
||||
|
||||
To see which CPUs the process may use:
|
||||
|
||||
```bash
|
||||
pid=$(pgrep -x Main)
|
||||
for t in /proc/$pid/task/*; do taskset -pc "${t##*/}"; done
|
||||
```
|
||||
|
||||
Two traps here. The engine process reports its name as `Main`, not
|
||||
`mocktail`; `pgrep -x mocktail` finds only the launcher, which sits near 0%
|
||||
CPU and tells you nothing. And `taskset -p` reads one thread, not the whole
|
||||
process, so a single call reports the main thread's mask and says nothing
|
||||
about the workers — which is where the game's load actually is.
|
||||
|
||||
For the same reason, `ps -L -o psr=` shows which core each thread is running
|
||||
on right now, not which cores it is allowed on. On a hybrid CPU the scheduler
|
||||
preferring P-cores for runnable work is normal and is not a mask.
|
||||
|
||||
## The main-thread pump
|
||||
|
||||
Roblox posts its per-frame main-thread step at a time of its own choosing,
|
||||
and nothing signals the host when it does. The host main thread therefore
|
||||
polls `nativeCallMessagesFromMainThread`, and a post that waits more than a
|
||||
fraction of a millisecond costs a whole frame. Between polls the thread rests
|
||||
for 100 µs, and how it rests decides the CPU clock:
|
||||
|
||||
| Mode | When | What it costs |
|
||||
| --- | --- | --- |
|
||||
| `sleep` | The governor is `performance`, which GameMode sets | The thread idles at about 3% of a core. The governor holds the clock. |
|
||||
| `tpause` | Otherwise, on a CPU with WAITPKG (Intel 12th gen and newer) | The core stays awake in a light C0 state. Frame rate holds; the OS books the time as busy, so the thread shows around 70% of a core, at lower power than a spin. |
|
||||
| `spin` | Otherwise | The core stays awake at full power. |
|
||||
|
||||
Under a power-saving governor the CPU clocks a bursty core down unless some
|
||||
core stays awake, and Roblox's render thread then runs slower and drops
|
||||
frames. That is why `sleep` is not the default there. Startup prints the
|
||||
chosen mode:
|
||||
|
||||
```
|
||||
[main] engine pump rest: tpause (MOCKTAIL_ENGINE_PUMP_REST=sleep|tpause|spin)
|
||||
```
|
||||
|
||||
`MOCKTAIL_ENGINE_PUMP_REST` forces a mode. `sleep` without the governor's help
|
||||
runs cooler for a few frames per second less; measure before keeping it.
|
||||
|
||||
## Small texture upscaling
|
||||
|
||||
`MOCKTAIL_SMALL_TEXTURE_UPSCALE` (default `4`) redraws ETC2 textures of 64px or
|
||||
smaller at N times their width and height. At the default that is 16 times the
|
||||
pixels per affected texture.
|
||||
|
||||
The cost lands in two places. Decoding and resampling is CPU work on up to 8
|
||||
threads while textures load, which shows up as a burst rather than a steady
|
||||
load. The enlarged textures then stay in GPU memory for the session and cost
|
||||
extra sampling bandwidth on every frame that uses them.
|
||||
|
||||
That decode does not run on the thread that submits the frame. On a device
|
||||
with a timeline semaphore, uploads decode on a background dispatcher and the
|
||||
submit carries a semaphore wait, so the GPU waits for the texture instead of
|
||||
the render thread. In the trace this is the `etc2 decode` slice on its own
|
||||
thread, with only a negligible `etc2 gather` left on the submitting one. A
|
||||
device without that feature decodes inline as before.
|
||||
|
||||
Set it to `1` to turn upscaling off:
|
||||
|
||||
```bash
|
||||
MOCKTAIL_SMALL_TEXTURE_UPSCALE=1 mocktail
|
||||
```
|
||||
|
||||
## Frame rate and present mode
|
||||
|
||||
`graphics.frame_rate_limit` (default `-1`) leaves Roblox's own in-game
|
||||
framerate cap in charge; Nightcap sets no `DFIntTaskSchedulerTargetFps`
|
||||
override. A fixed value forwards that number to the scheduler.
|
||||
|
||||
`graphics.vsync` (default `auto`) selects the presentation mode. `auto` and
|
||||
`on` both pick the lowest-latency synchronized mode the driver offers, in
|
||||
order: `FIFO_LATEST_READY`, `MAILBOX`, `FIFO_RELAXED`, `FIFO`. The first two do
|
||||
not block the render thread on the display refresh, so the frame rate is
|
||||
governed by the in-game cap rather than by presentation. `off` selects
|
||||
`IMMEDIATE` and does not synchronize at all.
|
||||
|
||||
If the frame rate is higher than your refresh rate, the extra frames are work
|
||||
you cannot see. Lower the in-game cap to your refresh rate first.
|
||||
|
||||
## Measuring
|
||||
|
||||
Shift+F4 in game shows Roblox's own scheduler jobs, with a millisecond cost and
|
||||
a percentage for each. It does not show Nightcap's threads — the Vulkan
|
||||
adapter, the ETC2 decode workers, the libc shim — so it is a reading on the
|
||||
game, not on the process.
|
||||
|
||||
For the whole process:
|
||||
|
||||
```bash
|
||||
top -H -p $(pgrep -x mocktail) # per-thread CPU, sorted
|
||||
ps -L -o psr=,pcpu=,comm= -p $(pgrep -x mocktail) # which core each thread is on
|
||||
nvidia-smi --query-gpu=utilization.gpu,temperature.gpu,power.draw --format=csv
|
||||
```
|
||||
|
||||
A thread pinned near 100% while the frame rate is steady is a busy-wait. The
|
||||
one exception is the host main thread in `tpause` mode, which the OS reports
|
||||
as busy while the core is parked (see above). Load spread across many
|
||||
`RBX Worker` threads is the game's own scheduler, and its cost tracks the
|
||||
place you are in — part count, moving parts and joints all show in the
|
||||
Shift+F4 World section.
|
||||
|
||||
`--profile <file>` writes a Chrome trace of the Vulkan adapter's work for
|
||||
ui.perfetto.dev. See [BENCHMARKING.md](BENCHMARKING.md).
|
||||
+24
-29
@@ -15,7 +15,8 @@ Goal: a small GTK4 settings window, reachable from the app menu and from a
|
||||
- Graphics tab: backend, frame rate limit, vsync, small texture upscale.
|
||||
- Input tab: the `input.raw_mouse` toggle and controller options.
|
||||
- Advanced tab: FFlag overrides with a plain text editor for `fflags.json`.
|
||||
- Discord tab: the `discord_rpc` block (see item 3).
|
||||
- Discord tab: every field in the `discord_rpc` block, so a friend can see
|
||||
what you are playing and you can switch it all off in one click.
|
||||
- Changes apply on next launch. A restart button restarts Nightcap.
|
||||
|
||||
Done when every option in `config/mocktail.example.yaml` that a player would
|
||||
@@ -35,36 +36,16 @@ is left.
|
||||
|
||||
Done when a plugged-in pad shows its own buttons.
|
||||
|
||||
## 3. Discord Rich Presence
|
||||
## 3. First-load stutter
|
||||
|
||||
Upstream ships Discord RPC. Nightcap turns it on by default; see the
|
||||
[FAQ](../FAQ.md) to switch it off. It shows the experience name, elapsed
|
||||
time, the experience icon, and a join button when the server is public.
|
||||
The `--profile` trace now shows shader compiles and pipeline creation, so
|
||||
the stutter on first entering an experience can be measured.
|
||||
|
||||
- Custom presence: set the title, details line, state line, and large and
|
||||
small icons from `config.yaml`, with placeholders like the experience name
|
||||
and elapsed time.
|
||||
- A Discord tab in the settings window to toggle each field.
|
||||
- Cut the stutter the trace shows, and prove it with a before and after
|
||||
trace per [BENCHMARKING.md](BENCHMARKING.md).
|
||||
|
||||
Done when a friend can see what you are playing and join in one click, and
|
||||
you can switch it all off in one click.
|
||||
|
||||
## 4. Performance profiling
|
||||
|
||||
Every performance change should come with numbers. Today they come with a
|
||||
feeling.
|
||||
|
||||
- A frame time overlay, toggled by a key and a config flag, showing CPU time,
|
||||
GPU time, and present latency.
|
||||
- Shader compile and pipeline cache tracing, so first-load stutter can be
|
||||
measured and cut.
|
||||
- A `--profile` flag that writes a Chrome-format trace file, readable in
|
||||
`ui.perfetto.dev` and `chrome://tracing`.
|
||||
- A short benchmark guide in `docs/` with a fixed set of experiences, so
|
||||
before and after numbers can be compared across machines.
|
||||
|
||||
Done when a pull request that claims a speedup can show the trace that
|
||||
proves it.
|
||||
Done when entering a fresh experience no longer drops frames on shader
|
||||
compiles.
|
||||
|
||||
## Shipped
|
||||
|
||||
@@ -82,7 +63,21 @@ proves it.
|
||||
- No more error 319 kicks after joining a server.
|
||||
- Roblox sees the real host RAM and screen size.
|
||||
- Desktop launcher that works on Wayland and finds the installed binary.
|
||||
- Discord Rich Presence on by default.
|
||||
- Discord Rich Presence on by default, with the experience name, creator,
|
||||
elapsed time, the experience icon with a Roblox badge, and a join button
|
||||
when the server is public. See the [FAQ](../FAQ.md) to switch it off.
|
||||
- Custom Discord presence: title, details, state, and large and small icons
|
||||
from `config.yaml`, with `{place_name}`, `{place_icon}`, and
|
||||
`{creator_name}` placeholders.
|
||||
- `--profile` writes a Chrome-format trace of the Vulkan adapter's work,
|
||||
including shader and pipeline creation, readable in `ui.perfetto.dev`.
|
||||
[BENCHMARKING.md](BENCHMARKING.md) fixes the experiences to measure and
|
||||
`scripts/summarize_profile_trace.py` builds the before and after table.
|
||||
- Texture uploads no longer stall the frame they land on: ETC2 decode and
|
||||
resampling run on a persistent worker pool.
|
||||
- The host main thread rests between engine polls instead of spinning, and
|
||||
GameMode's core pinning is released, so Roblox's workers spread across
|
||||
every core. See [PERFORMANCE.md](PERFORMANCE.md).
|
||||
- Own app ID `io.github.CoderDayton.nightcap`, so Nightcap installs next to
|
||||
upstream.
|
||||
- AppImage and signed Flatpak releases on every `v*` tag, with the Flatpak
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
#ifndef MOCKTAIL_GRAPHICS_CHROME_TRACE_WRITER_H_
|
||||
#define MOCKTAIL_GRAPHICS_CHROME_TRACE_WRITER_H_
|
||||
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <initializer_list>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace mocktail {
|
||||
namespace graphics {
|
||||
|
||||
// Environment variable naming the trace file the Vulkan adapter writes.
|
||||
inline constexpr char kProfileTraceEnvironment[] = "MOCKTAIL_PROFILE_TRACE";
|
||||
|
||||
struct TraceArg {
|
||||
const char* key;
|
||||
std::int64_t value;
|
||||
};
|
||||
|
||||
// CLOCK_MONOTONIC in nanoseconds.
|
||||
std::uint64_t TraceClockNanos();
|
||||
|
||||
// Writes the Trace Event Format JSON array read by chrome://tracing and
|
||||
// ui.perfetto.dev. Recording threads only format into a shared buffer; a
|
||||
// background thread owns file writes, so disk stalls do not land inside the
|
||||
// measured calls. Events are written in arrival order. Each write ends the
|
||||
// file with the closing bracket, so a killed session leaves a loadable trace
|
||||
// holding everything written before it died.
|
||||
// Names, categories, and keys must outlive the call.
|
||||
class ChromeTraceWriter final {
|
||||
public:
|
||||
static std::unique_ptr<ChromeTraceWriter> Open(
|
||||
const std::filesystem::path& path, std::string* error);
|
||||
|
||||
~ChromeTraceWriter();
|
||||
ChromeTraceWriter(const ChromeTraceWriter&) = delete;
|
||||
ChromeTraceWriter& operator=(const ChromeTraceWriter&) = delete;
|
||||
|
||||
// A complete ("X") slice on the calling thread. Slices on one thread must
|
||||
// nest, which scoped timing gives.
|
||||
void Slice(const char* name, const char* category, std::uint64_t start_ns,
|
||||
std::uint64_t end_ns, std::initializer_list<TraceArg> args = {});
|
||||
void Slice(const char* name, const char* category, std::uint64_t start_ns,
|
||||
std::uint64_t end_ns, const TraceArg* args,
|
||||
std::size_t arg_count);
|
||||
// A counter ("C") sample; each name becomes one counter track.
|
||||
void Counter(const char* name, std::uint64_t timestamp_ns, double value);
|
||||
|
||||
// Returns once every event recorded before the call is on disk.
|
||||
void Flush();
|
||||
// Flushes, closes the file, and drops later events. Idempotent.
|
||||
void Close();
|
||||
|
||||
private:
|
||||
explicit ChromeTraceWriter(std::FILE* file);
|
||||
void Append(const char* event, std::size_t size);
|
||||
void WriterLoop();
|
||||
void WritePending();
|
||||
|
||||
std::FILE* file_;
|
||||
const int owner_pid_;
|
||||
std::mutex file_mutex_; // Guards file_ and spare_.
|
||||
std::string spare_;
|
||||
std::mutex mutex_;
|
||||
std::condition_variable wake_;
|
||||
std::string pending_;
|
||||
bool first_event_ = true;
|
||||
bool closed_ = false;
|
||||
bool stopping_ = false;
|
||||
std::thread writer_;
|
||||
};
|
||||
|
||||
// The process trace named by MOCKTAIL_PROFILE_TRACE, opened on first use.
|
||||
// Null when profiling is off or the file could not be opened. The writer is
|
||||
// never destroyed; it is closed when the process exits normally.
|
||||
ChromeTraceWriter* ActiveProfileTrace();
|
||||
|
||||
// Times its own lifetime as one slice. A null writer records nothing.
|
||||
class TraceScope final {
|
||||
public:
|
||||
TraceScope(ChromeTraceWriter* writer, const char* name,
|
||||
const char* category)
|
||||
: writer_(writer),
|
||||
name_(name),
|
||||
category_(category),
|
||||
start_ns_(writer != nullptr ? TraceClockNanos() : 0) {}
|
||||
~TraceScope() {
|
||||
if (writer_ != nullptr) {
|
||||
writer_->Slice(name_, category_, start_ns_, TraceClockNanos(), args_,
|
||||
arg_count_);
|
||||
}
|
||||
}
|
||||
TraceScope(const TraceScope&) = delete;
|
||||
TraceScope& operator=(const TraceScope&) = delete;
|
||||
|
||||
// Keeps the first kMaxArgs arguments.
|
||||
void Arg(const char* key, std::int64_t value) {
|
||||
if (writer_ != nullptr && arg_count_ < kMaxArgs) {
|
||||
args_[arg_count_++] = {key, value};
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr std::size_t kMaxArgs = 4;
|
||||
ChromeTraceWriter* writer_;
|
||||
const char* name_;
|
||||
const char* category_;
|
||||
std::uint64_t start_ns_;
|
||||
TraceArg args_[kMaxArgs]{};
|
||||
std::size_t arg_count_ = 0;
|
||||
};
|
||||
|
||||
} // namespace graphics
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_GRAPHICS_CHROME_TRACE_WRITER_H_
|
||||
@@ -66,6 +66,14 @@ struct EtcDecodeJob {
|
||||
void DecodeEtcJobs(EtcDecodeJob* jobs, std::size_t count,
|
||||
unsigned worker_count);
|
||||
|
||||
// Calls run(context) worker_count times, once on the caller and the rest on
|
||||
// pooled decode workers that run alongside it, and returns when every call
|
||||
// has returned. `run` must be safe to call from several threads at once, so
|
||||
// it should drain a shared queue. When the pool is busy with another batch,
|
||||
// or no worker could be started, run is called on the caller alone.
|
||||
void RunOnDecodeWorkers(void (*run)(void*), void* context,
|
||||
unsigned worker_count);
|
||||
|
||||
} // namespace mocktail::graphics
|
||||
|
||||
#endif // MOCKTAIL_GRAPHICS_ETC2_DECODER_H_
|
||||
@@ -47,6 +47,14 @@ inline constexpr std::uint32_t kSmallTextureMaxExtent = 64;
|
||||
inline constexpr std::uint32_t kDefaultSmallTextureUpscale = 4;
|
||||
std::uint32_t SmallTextureUpscale(const char* value);
|
||||
|
||||
// A timeline semaphore and the value an asynchronous decode will signal.
|
||||
// `semaphore` is VK_NULL_HANDLE when the decode already finished on the
|
||||
// calling thread and the submit needs no extra wait.
|
||||
struct Etc2SubmitWait {
|
||||
VkSemaphore semaphore = VK_NULL_HANDLE;
|
||||
std::uint64_t value = 0;
|
||||
};
|
||||
|
||||
// Reports ETC2/EAC support on hosts without native ETC2 (desktop GPUs) and
|
||||
// stores those textures decoded. Images are created with the host format;
|
||||
// each staged upload into one is redirected to a host-visible staging buffer
|
||||
@@ -66,10 +74,14 @@ class VulkanEtc2Emulation final {
|
||||
bool PhysicalDeviceNeedsEmulation(VkPhysicalDevice physical_device,
|
||||
PFN_vkGetPhysicalDeviceFeatures host);
|
||||
|
||||
// `timeline_semaphore` states whether the device was created with the
|
||||
// timelineSemaphore feature enabled. Without it every decode runs on the
|
||||
// calling thread.
|
||||
void RegisterDevice(VkDevice device, VkPhysicalDevice physical_device,
|
||||
bool emulated,
|
||||
const VkPhysicalDeviceMemoryProperties& memory,
|
||||
PFN_vkGetDeviceProcAddr get_device_proc_addr);
|
||||
PFN_vkGetDeviceProcAddr get_device_proc_addr,
|
||||
bool timeline_semaphore = false);
|
||||
void DestroyDevice(VkDevice device);
|
||||
|
||||
VkResult CreateImage(VkDevice device, const VkImageCreateInfo* create_info,
|
||||
@@ -112,12 +124,27 @@ class VulkanEtc2Emulation final {
|
||||
const VkCommandBuffer* secondaries);
|
||||
|
||||
// Decodes every upload recorded into these command buffers and the
|
||||
// secondary command buffers they execute. Large batches decode on worker
|
||||
// threads; this returns once every upload is decoded.
|
||||
void PrepareSubmit(const VkCommandBuffer* command_buffers,
|
||||
std::uint32_t count);
|
||||
// secondary command buffers they execute.
|
||||
//
|
||||
// With `allow_async` on a device that has a timeline semaphore, the decode
|
||||
// runs on worker threads, and so does the gather of the compressed bytes
|
||||
// whenever the application already has them mapped. The returned semaphore
|
||||
// and value must then be added to the submit's waits, so the GPU copy
|
||||
// cannot execute before the decode finishes. Values are signalled in the
|
||||
// order they are handed out.
|
||||
//
|
||||
// Those threads read the application's source bytes after this returns,
|
||||
// which Vulkan allows: the application may not write them between the
|
||||
// submit and the copy's completion. Unmapping or freeing that memory waits
|
||||
// for the decodes still reading it.
|
||||
//
|
||||
// Returns `{VK_NULL_HANDLE, 0}` when there was nothing to decode, when
|
||||
// `allow_async` is false, or when the device has no timeline semaphore. In
|
||||
// those cases every upload is already decoded when this returns.
|
||||
Etc2SubmitWait PrepareSubmit(const VkCommandBuffer* command_buffers,
|
||||
std::uint32_t count, bool allow_async = false);
|
||||
// Destroys the staging buffers owned by a command buffer that is no longer
|
||||
// pending.
|
||||
// pending, after waiting for any decode still writing into them.
|
||||
void ReleaseCommandBuffer(VkCommandBuffer command_buffer);
|
||||
|
||||
struct State;
|
||||
|
||||
@@ -152,6 +152,10 @@ struct MouseMotionEvent {
|
||||
float delta_x = 0.0f;
|
||||
float delta_y = 0.0f;
|
||||
std::uint32_t buttons = 0;
|
||||
// True while the window holds relative mouse mode. The absolute coordinates
|
||||
// are meaningless then: X11 reports zeroes, Wayland reports a position that
|
||||
// accumulates the deltas and can leave the window entirely.
|
||||
bool relative = false;
|
||||
};
|
||||
|
||||
struct MouseButtonEvent {
|
||||
@@ -160,6 +164,8 @@ struct MouseButtonEvent {
|
||||
std::uint8_t clicks = 0;
|
||||
float x = 0.0f;
|
||||
float y = 0.0f;
|
||||
// See MouseMotionEvent::relative.
|
||||
bool relative = false;
|
||||
};
|
||||
|
||||
struct MouseWheelEvent {
|
||||
@@ -167,6 +173,8 @@ struct MouseWheelEvent {
|
||||
float delta_y = 0.0f;
|
||||
float mouse_x = 0.0f;
|
||||
float mouse_y = 0.0f;
|
||||
// See MouseMotionEvent::relative.
|
||||
bool relative = false;
|
||||
};
|
||||
|
||||
struct TouchEvent {
|
||||
|
||||
@@ -33,6 +33,8 @@ struct CommandLineOptions {
|
||||
// Requests one explicit launch of the provider latest without promoting it
|
||||
// into the managed payload state.
|
||||
bool force_run_latest = false;
|
||||
// Chrome trace file the Vulkan adapter writes; empty when not profiling.
|
||||
std::string profile_trace_path;
|
||||
};
|
||||
|
||||
struct CommandLineParseResult {
|
||||
|
||||
@@ -13,11 +13,14 @@ namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
struct DiscordRpcActivity {
|
||||
std::string name;
|
||||
std::string details;
|
||||
std::string state;
|
||||
std::optional<int64_t> start_timestamp;
|
||||
std::string large_image;
|
||||
std::string large_text;
|
||||
std::string small_image;
|
||||
std::string small_text;
|
||||
std::string button_label;
|
||||
std::string button_url;
|
||||
};
|
||||
@@ -27,7 +30,8 @@ std::string BuildDiscordJoinUrl(const RobloxExperienceLaunchRequest& request);
|
||||
DiscordRpcActivity BuildDiscordRpcActivity(
|
||||
const DiscordRpcConfig& config, RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request, std::string place_name,
|
||||
int64_t session_started_at, std::string place_icon_url = {});
|
||||
int64_t session_started_at, std::string place_icon_url = {},
|
||||
std::string creator_name = {});
|
||||
|
||||
// Maintains a direct, unauthenticated Discord Desktop RPC connection. The
|
||||
// worker owns all network and IPC work; lifecycle notifications only replace
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ENGINE_PUMP_REST_H_
|
||||
#define MOCKTAIL_RUNTIME_ENGINE_PUMP_REST_H_
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace mocktail::runtime {
|
||||
|
||||
// The engine posts its per-frame main-thread step at a time of its own
|
||||
// choosing, with no signal the host can block on, so the host main thread
|
||||
// polls nativeCallMessagesFromMainThread. An empty poll costs a few
|
||||
// microseconds; polled back to back it burns a core.
|
||||
//
|
||||
// Between polls the thread rests for kEnginePumpRestNs. A post is then picked
|
||||
// up at most kEnginePumpRestNs + timer slack + wake latency late, far under
|
||||
// the engine's sub-millisecond slack.
|
||||
//
|
||||
// How it rests decides the clock. Under a power-saving governor the CPU clocks
|
||||
// a bursty core down unless some core stays awake, and the engine's render
|
||||
// thread then runs slower and drops frames. So:
|
||||
// kSleep nanosleep. The core idles. Correct only when the governor holds
|
||||
// the clock up on its own (performance, which GameMode sets).
|
||||
// kTpause TPAUSE in C0.2. The core stays awake at reduced power, the clock
|
||||
// holds, and the OS books the time as busy. Needs WAITPKG.
|
||||
// kSpin No rest. The core stays awake at full power.
|
||||
inline constexpr uint64_t kEnginePumpRestNs = 100'000;
|
||||
// Timer slack the calling thread is set to; the kernel default of 50 us would
|
||||
// otherwise stretch every sleep.
|
||||
inline constexpr uint64_t kEnginePumpTimerSlackNs = 10'000;
|
||||
// Shallow C-state exit plus scheduler latency on the host, for the bound.
|
||||
inline constexpr uint64_t kShallowWakeNs = 50'000;
|
||||
|
||||
enum class EnginePumpRestMode { kSleep, kTpause, kSpin };
|
||||
|
||||
const char* EnginePumpRestModeName(EnginePumpRestMode mode);
|
||||
|
||||
struct EnginePumpRestInputs {
|
||||
bool governor_performance = false;
|
||||
bool cpu_has_waitpkg = false;
|
||||
// MOCKTAIL_ENGINE_PUMP_REST: "sleep", "tpause" or "spin". Null, empty or
|
||||
// unknown defers to the policy; "tpause" without WAITPKG does too.
|
||||
const char* override = nullptr;
|
||||
};
|
||||
|
||||
EnginePumpRestMode ChooseEnginePumpRestMode(const EnginePumpRestInputs& inputs);
|
||||
|
||||
// True when the file holds "performance". `path` is a cpufreq
|
||||
// scaling_governor file; cpu0's stands for the machine.
|
||||
bool CpuGovernorIsPerformance(const char* path);
|
||||
|
||||
bool CpuHasWaitPkg();
|
||||
|
||||
// The mode chosen from the host on first use.
|
||||
EnginePumpRestMode ActiveEnginePumpRestMode();
|
||||
|
||||
// Rests the calling thread in the given mode and returns the rest length, 0
|
||||
// for kSpin. The first call lowers the thread's timer slack to
|
||||
// kEnginePumpTimerSlackNs.
|
||||
uint64_t RestAfterEnginePump(EnginePumpRestMode mode);
|
||||
inline uint64_t RestAfterEnginePump() {
|
||||
return RestAfterEnginePump(ActiveEnginePumpRestMode());
|
||||
}
|
||||
|
||||
} // namespace mocktail::runtime
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_ENGINE_PUMP_REST_H_
|
||||
@@ -1,6 +1,8 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_GAME_MODE_H_
|
||||
#define MOCKTAIL_RUNTIME_GAME_MODE_H_
|
||||
|
||||
#include <sched.h>
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
@@ -22,6 +24,7 @@ enum class GameModeSessionState {
|
||||
kDisabled,
|
||||
kUnavailable,
|
||||
kRequestFailed,
|
||||
kDeclinedCorePinning,
|
||||
kAlreadyActive,
|
||||
kActive,
|
||||
kStopped,
|
||||
@@ -43,9 +46,37 @@ struct GameModeClientApi {
|
||||
ErrorFn error_string = nullptr;
|
||||
};
|
||||
|
||||
// The CPU affinity calls, injectable so tests exercise the restore without
|
||||
// moving the threads they run on.
|
||||
struct CpuAffinityApi {
|
||||
// Reads the calling thread's permitted CPUs. GameMode pins every thread
|
||||
// alike, so one thread's mask is enough to detect the pin.
|
||||
using GetFn = int (*)(cpu_set_t* mask);
|
||||
// Applies `mask` to every thread of this process, not only the caller.
|
||||
// Returns 0 when each live thread took it.
|
||||
using SetFn = int (*)(const cpu_set_t* mask);
|
||||
|
||||
GetFn get = nullptr;
|
||||
SetFn set = nullptr;
|
||||
};
|
||||
|
||||
CpuAffinityApi HostCpuAffinityApi();
|
||||
|
||||
// True when `after` permits a strict subset of the CPUs `before` permits.
|
||||
// An equal, wider or merely relocated set is not a narrowing.
|
||||
bool CpuAffinityNarrowed(const cpu_set_t& before, const cpu_set_t& after);
|
||||
|
||||
// Owns at most one ref-counted GameMode request. Missing host support and
|
||||
// daemon rejection remain nonfatal because GameMode is an optimization, not a
|
||||
// correctness dependency.
|
||||
//
|
||||
// GameMode pins the calling process to the host's fastest cores when it sees a
|
||||
// wide core-speed spread. Roblox spreads its own work over a worker pool, so
|
||||
// that pin concentrates the load onto a couple of cores instead of helping,
|
||||
// and the daemon re-applies it every few seconds, so correcting the mask in
|
||||
// place does not hold. Under `auto`, a request that narrows the affinity is
|
||||
// released again: ending the request is what releases the pin. `on` is an
|
||||
// explicit choice and keeps the pinned session.
|
||||
class GameModeSession final {
|
||||
public:
|
||||
GameModeSession() = default;
|
||||
@@ -59,6 +90,8 @@ class GameModeSession final {
|
||||
static GameModeSession Start(GameModePolicy policy);
|
||||
static GameModeSession StartWithClientForTesting(
|
||||
GameModePolicy policy, GameModeClientApi api);
|
||||
static GameModeSession StartWithClientForTesting(
|
||||
GameModePolicy policy, GameModeClientApi api, CpuAffinityApi affinity);
|
||||
|
||||
GameModeSessionState state() const { return state_; }
|
||||
const std::string& detail() const { return detail_; }
|
||||
@@ -73,6 +106,7 @@ class GameModeSession final {
|
||||
private:
|
||||
static GameModeSession StartBound(GameModePolicy policy,
|
||||
GameModeClientApi api,
|
||||
CpuAffinityApi affinity,
|
||||
void* library_handle);
|
||||
void CloseLibrary();
|
||||
|
||||
|
||||
@@ -223,6 +223,10 @@ class RobloxInputRouter final {
|
||||
Status DisconnectGamepadLocked(ActiveGamepad& gamepad);
|
||||
Status DisconnectGamepadsLocked();
|
||||
Status ReleasePressedInputsLocked();
|
||||
// Keeps mouse_x_/mouse_y_ where they are for an event that carries no
|
||||
// usable position, clamped to the current viewport. Before any absolute
|
||||
// position has been seen, the held position is the viewport center.
|
||||
void HoldPointerLocked(const platform::SurfaceCoordinateTransform& transform);
|
||||
int32_t AllocatePointerIdLocked() const;
|
||||
std::vector<ActiveTouch>::iterator FindTouchIteratorLocked(int64_t touch_id,
|
||||
int64_t finger_id);
|
||||
@@ -238,8 +242,13 @@ class RobloxInputRouter final {
|
||||
std::vector<ActiveTouch> active_touches_;
|
||||
std::vector<ActiveKey> active_keys_;
|
||||
std::vector<ActiveGamepad> gamepads_;
|
||||
// Last known pointer position in guest units. Relative-mode motion, and
|
||||
// wheel or button events with zero coordinates, do not move it: the pointer
|
||||
// stays where it was captured while the deltas pass through.
|
||||
float mouse_x_ = 0.0f;
|
||||
float mouse_y_ = 0.0f;
|
||||
// True once an absolute position has been received since Activate.
|
||||
bool pointer_anchored_ = false;
|
||||
bool raw_mouse_ = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_text_display_state.h"
|
||||
|
||||
struct TTF_Font;
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
@@ -39,6 +41,8 @@ class RobloxTextSurfaceOverlay final {
|
||||
const RobloxTextDisplayUpdate& update);
|
||||
void ApplyUpdate(const RobloxTextDisplayUpdate& update);
|
||||
Status RasterizeLocked();
|
||||
Status OpenFontsLocked(std::int32_t font, float point_size);
|
||||
void CloseFontsLocked();
|
||||
void ClearFrameLocked();
|
||||
void RecordFailureLocked(Status status);
|
||||
|
||||
@@ -46,10 +50,14 @@ class RobloxTextSurfaceOverlay final {
|
||||
RobloxTextDisplayState state_;
|
||||
RobloxTextOverlayViewport viewport_;
|
||||
std::vector<std::uint8_t> rgba_;
|
||||
std::vector<TTF_Font*> fonts_;
|
||||
std::int32_t fonts_font_ = 0;
|
||||
float fonts_point_size_ = 0.0F;
|
||||
std::uint64_t state_revision_ = 0;
|
||||
std::uint64_t raster_revision_ = 0;
|
||||
Status failure_;
|
||||
bool initialized_ = false;
|
||||
bool ttf_initialized_ = false;
|
||||
bool failure_logged_ = false;
|
||||
bool ready_logged_ = false;
|
||||
};
|
||||
|
||||
@@ -51,14 +51,27 @@ struct NetworkProxyConfig {
|
||||
int port = 0;
|
||||
};
|
||||
|
||||
// Text and image fields expand {place_name}, {place_icon}, and
|
||||
// {creator_name}. A field that expands an empty placeholder, or renders
|
||||
// empty, is left out of the activity.
|
||||
struct DiscordRpcTextConfig {
|
||||
std::string title = "Roblox";
|
||||
std::string browsing = "Browsing experiences";
|
||||
std::string joining = "Joining an experience";
|
||||
std::string playing = "{place_name}";
|
||||
std::string state = "Playing Roblox";
|
||||
std::string playing = "Playing {place_name}";
|
||||
std::string state = "by {creator_name}";
|
||||
std::string unknown_place = "Unknown experience";
|
||||
};
|
||||
|
||||
// Images are an https URL or a Discord application asset key. Discord shows
|
||||
// the small image only next to a large one.
|
||||
struct DiscordRpcImagesConfig {
|
||||
std::string large = "{place_icon}";
|
||||
std::string large_text = "{place_name}";
|
||||
std::string small = "roblox_small";
|
||||
std::string small_text = "Roblox";
|
||||
};
|
||||
|
||||
struct DiscordRpcConfig {
|
||||
bool enabled = true;
|
||||
bool show_place_name = true;
|
||||
@@ -68,6 +81,7 @@ struct DiscordRpcConfig {
|
||||
std::string join_button_label = "Join Server";
|
||||
std::string application_id;
|
||||
DiscordRpcTextConfig text;
|
||||
DiscordRpcImagesConfig images;
|
||||
};
|
||||
|
||||
std::optional<NetworkProxyConfig> ParseNetworkProxyConfig(
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# Maintainer: komaruworld <rtysus228@gmail.com>
|
||||
# Maintainer: vii <coderdayton14@gmail.com>
|
||||
|
||||
pkgname=mocktail
|
||||
pkgver=1.0.3
|
||||
pkgrel=2
|
||||
pkgname=nightcap
|
||||
pkgver=0.3.1
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/komaruworld/mocktail'
|
||||
url='https://github.com/CoderDayton/nightcap'
|
||||
license=('Apache-2.0')
|
||||
provides=('mocktail')
|
||||
conflicts=('mocktail')
|
||||
depends=(
|
||||
'capstone'
|
||||
'curl'
|
||||
@@ -16,6 +18,7 @@ depends=(
|
||||
'libelf'
|
||||
'libglvnd'
|
||||
'libplacebo'
|
||||
'libpng'
|
||||
'libutf8proc'
|
||||
'libyaml'
|
||||
'minizip'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
pkgbase = mocktail-bin
|
||||
pkgbase = nightcap-bin
|
||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux (prebuilt)
|
||||
pkgver = 1.0.4
|
||||
pkgver = 0.3.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/komaruworld/mocktail
|
||||
url = https://github.com/CoderDayton/nightcap
|
||||
arch = x86_64
|
||||
license = Apache-2.0
|
||||
depends = capstone
|
||||
@@ -17,6 +17,7 @@ pkgbase = mocktail-bin
|
||||
depends = libelf
|
||||
depends = libglvnd
|
||||
depends = libplacebo
|
||||
depends = libpng
|
||||
depends = libsoup3
|
||||
depends = libutf8proc
|
||||
depends = libyaml
|
||||
@@ -29,12 +30,14 @@ pkgbase = mocktail-bin
|
||||
depends = zlib
|
||||
optdepends = gamemode: Feral GameMode integration
|
||||
provides = mocktail
|
||||
provides = nightcap
|
||||
conflicts = mocktail
|
||||
conflicts = mocktail-git
|
||||
noextract = mocktail-1.0.4-x86_64.pkg.tar.zst
|
||||
conflicts = nightcap
|
||||
conflicts = nightcap-git
|
||||
noextract = nightcap-0.3.1-1-x86_64.pkg.tar.zst
|
||||
options = !debug
|
||||
options = !strip
|
||||
source = mocktail-1.0.4-x86_64.pkg.tar.zst::https://github.com/komaruworld/mocktail/releases/download/1.0.4/mocktail-1.0.4-x86_64.pkg.tar.zst
|
||||
sha256sums = d5cc7216e88fd2889369e8eb7fba4276b2746c2b084dce566aaaa7505bc2ffb7
|
||||
source = nightcap-0.3.1-1-x86_64.pkg.tar.zst::https://github.com/CoderDayton/nightcap/releases/download/v0.3.1/nightcap-0.3.1-1-x86_64.pkg.tar.zst
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = mocktail-bin
|
||||
pkgname = nightcap-bin
|
||||
@@ -1,11 +1,11 @@
|
||||
# Maintainer: komaruworld <rtysus228 at gmail dot com>
|
||||
# Maintainer: vii <coderdayton14 at gmail dot com>
|
||||
|
||||
pkgname=mocktail-bin
|
||||
pkgver=1.0.4
|
||||
pkgname=nightcap-bin
|
||||
pkgver=0.3.1
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux (prebuilt)'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/komaruworld/mocktail'
|
||||
url='https://github.com/CoderDayton/nightcap'
|
||||
license=('Apache-2.0')
|
||||
depends=(
|
||||
'capstone'
|
||||
@@ -20,6 +20,7 @@ depends=(
|
||||
'libelf'
|
||||
'libglvnd'
|
||||
'libplacebo'
|
||||
'libpng'
|
||||
'libsoup3'
|
||||
'libutf8proc'
|
||||
'libyaml'
|
||||
@@ -32,17 +33,18 @@ depends=(
|
||||
'zlib'
|
||||
)
|
||||
optdepends=('gamemode: Feral GameMode integration')
|
||||
provides=('mocktail')
|
||||
conflicts=('mocktail' 'mocktail-git')
|
||||
provides=('mocktail' 'nightcap')
|
||||
conflicts=('mocktail' 'nightcap' 'nightcap-git')
|
||||
options=('!debug' '!strip')
|
||||
# Release asset names do not include the AUR package release.
|
||||
_archive="mocktail-${pkgver}-${CARCH}.pkg.tar.zst"
|
||||
source=("${_archive}::https://github.com/komaruworld/mocktail/releases/download/${pkgver}/${_archive}")
|
||||
# The release asset carries the pacman package release built by release.yml.
|
||||
_archive="nightcap-${pkgver}-1-${CARCH}.pkg.tar.zst"
|
||||
source=("${_archive}::https://github.com/CoderDayton/nightcap/releases/download/v${pkgver}/${_archive}")
|
||||
noextract=("${_archive}")
|
||||
sha256sums=('d5cc7216e88fd2889369e8eb7fba4276b2746c2b084dce566aaaa7505bc2ffb7')
|
||||
# Replace with the checksum published beside the release asset.
|
||||
sha256sums=('SKIP')
|
||||
|
||||
package() {
|
||||
bsdtar -xf "${srcdir}/${_archive}" -C "${pkgdir}" usr
|
||||
install -Dm644 "${pkgdir}/usr/share/licenses/mocktail/LICENSE" \
|
||||
install -Dm644 "${pkgdir}/usr/share/licenses/nightcap/LICENSE" \
|
||||
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
pkgbase = mocktail
|
||||
pkgbase = nightcap-git
|
||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
|
||||
pkgver = 1.0.4
|
||||
pkgver = 0.3.1.r0.g0000000
|
||||
pkgrel = 1
|
||||
url = https://github.com/komaruworld/mocktail
|
||||
url = https://github.com/CoderDayton/nightcap
|
||||
arch = x86_64
|
||||
license = Apache-2.0
|
||||
makedepends = cmake>=3.20
|
||||
@@ -24,6 +24,7 @@ pkgbase = mocktail
|
||||
depends = libelf
|
||||
depends = libglvnd
|
||||
depends = libplacebo
|
||||
depends = libpng
|
||||
depends = libsoup3
|
||||
depends = libutf8proc
|
||||
depends = libyaml
|
||||
@@ -35,11 +36,13 @@ pkgbase = mocktail
|
||||
depends = webkitgtk-6.0
|
||||
depends = zlib
|
||||
optdepends = gamemode: Feral GameMode integration
|
||||
conflicts = mocktail-bin
|
||||
conflicts = mocktail-git
|
||||
provides = mocktail
|
||||
provides = nightcap
|
||||
conflicts = mocktail
|
||||
conflicts = nightcap
|
||||
conflicts = nightcap-bin
|
||||
options = !debug
|
||||
options = !lto
|
||||
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=1.0.4
|
||||
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#branch=main
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = mocktail
|
||||
pkgname = nightcap-git
|
||||
@@ -1,11 +1,11 @@
|
||||
# Maintainer: komaruworld <rtysus228 at gmail dot com>
|
||||
# Maintainer: vii <coderdayton14 at gmail dot com>
|
||||
|
||||
pkgname=mocktail-git
|
||||
pkgver=1.0.2.r24.g9bdd737
|
||||
pkgname=nightcap-git
|
||||
pkgver=0.3.1.r0.g0000000
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/komaruworld/mocktail'
|
||||
url='https://github.com/CoderDayton/nightcap'
|
||||
license=('Apache-2.0')
|
||||
depends=(
|
||||
'capstone'
|
||||
@@ -20,6 +20,7 @@ depends=(
|
||||
'libelf'
|
||||
'libglvnd'
|
||||
'libplacebo'
|
||||
'libpng'
|
||||
'libsoup3'
|
||||
'libutf8proc'
|
||||
'libyaml'
|
||||
@@ -41,31 +42,30 @@ makedepends=(
|
||||
'vulkan-headers'
|
||||
)
|
||||
optdepends=('gamemode: Feral GameMode integration')
|
||||
provides=('mocktail')
|
||||
conflicts=('mocktail' 'mocktail-bin')
|
||||
provides=('mocktail' 'nightcap')
|
||||
conflicts=('mocktail' 'nightcap' 'nightcap-bin')
|
||||
options=('!debug')
|
||||
source=(
|
||||
'mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main'
|
||||
'nightcap::git+https://github.com/CoderDayton/nightcap.git#branch=main'
|
||||
)
|
||||
sha256sums=(
|
||||
'SKIP'
|
||||
)
|
||||
|
||||
pkgver() {
|
||||
cd mocktail
|
||||
cd nightcap
|
||||
|
||||
local version
|
||||
version="$(sed -n \
|
||||
's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' \
|
||||
CMakeLists.txt | head -n 1)"
|
||||
version="$(git describe --tags --abbrev=0 --match='v[0-9]*' 2>/dev/null)"
|
||||
version="${version#v}"
|
||||
printf '%s.r%s.g%s' \
|
||||
"${version}" \
|
||||
"${version:-0.0.0}" \
|
||||
"$(git rev-list --count HEAD)" \
|
||||
"$(git rev-parse --short=7 HEAD)"
|
||||
}
|
||||
|
||||
prepare() {
|
||||
cd mocktail
|
||||
cd nightcap
|
||||
|
||||
sed -i \
|
||||
's|add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)|find_package(VulkanHeaders CONFIG REQUIRED)|' \
|
||||
@@ -73,7 +73,7 @@ prepare() {
|
||||
}
|
||||
|
||||
build() {
|
||||
cmake -S mocktail -B build -G Ninja \
|
||||
cmake -S nightcap -B build -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DCMAKE_INSTALL_LIBDIR=lib \
|
||||
@@ -86,6 +86,6 @@ build() {
|
||||
|
||||
package() {
|
||||
DESTDIR="${pkgdir}" cmake --install build
|
||||
install -Dm644 mocktail/LICENSE \
|
||||
install -Dm644 nightcap/LICENSE \
|
||||
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
pkgbase = mocktail-git
|
||||
pkgbase = nightcap
|
||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
|
||||
pkgver = 1.0.2.r24.g9bdd737
|
||||
pkgver = 0.3.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/komaruworld/mocktail
|
||||
url = https://github.com/CoderDayton/nightcap
|
||||
arch = x86_64
|
||||
license = Apache-2.0
|
||||
makedepends = cmake>=3.20
|
||||
@@ -24,6 +24,7 @@ pkgbase = mocktail-git
|
||||
depends = libelf
|
||||
depends = libglvnd
|
||||
depends = libplacebo
|
||||
depends = libpng
|
||||
depends = libsoup3
|
||||
depends = libutf8proc
|
||||
depends = libyaml
|
||||
@@ -37,9 +38,11 @@ pkgbase = mocktail-git
|
||||
optdepends = gamemode: Feral GameMode integration
|
||||
provides = mocktail
|
||||
conflicts = mocktail
|
||||
conflicts = mocktail-bin
|
||||
conflicts = nightcap-bin
|
||||
conflicts = nightcap-git
|
||||
options = !debug
|
||||
source = mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main
|
||||
options = !lto
|
||||
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#tag=v0.3.1
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = mocktail-git
|
||||
pkgname = nightcap
|
||||
@@ -1,11 +1,11 @@
|
||||
# Maintainer: komaruworld <rtysus228 at gmail dot com>
|
||||
# Maintainer: vii <coderdayton14 at gmail dot com>
|
||||
|
||||
pkgname=mocktail
|
||||
pkgver=1.0.4
|
||||
pkgname=nightcap
|
||||
pkgver=0.3.1
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/komaruworld/mocktail'
|
||||
url='https://github.com/CoderDayton/nightcap'
|
||||
license=('Apache-2.0')
|
||||
depends=(
|
||||
'capstone'
|
||||
@@ -20,6 +20,7 @@ depends=(
|
||||
'libelf'
|
||||
'libglvnd'
|
||||
'libplacebo'
|
||||
'libpng'
|
||||
'libsoup3'
|
||||
'libutf8proc'
|
||||
'libyaml'
|
||||
@@ -41,17 +42,18 @@ makedepends=(
|
||||
'vulkan-headers'
|
||||
)
|
||||
optdepends=('gamemode: Feral GameMode integration')
|
||||
conflicts=('mocktail-bin' 'mocktail-git')
|
||||
provides=('mocktail')
|
||||
conflicts=('mocktail' 'nightcap-bin' 'nightcap-git')
|
||||
options=('!debug' '!lto')
|
||||
source=(
|
||||
"mocktail::git+https://github.com/komaruworld/mocktail.git#tag=${pkgver}"
|
||||
"nightcap::git+https://github.com/CoderDayton/nightcap.git#tag=v${pkgver}"
|
||||
)
|
||||
sha256sums=(
|
||||
'SKIP'
|
||||
)
|
||||
|
||||
prepare() {
|
||||
cd mocktail
|
||||
cd nightcap
|
||||
|
||||
sed -i \
|
||||
's|add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)|find_package(VulkanHeaders CONFIG REQUIRED)|' \
|
||||
@@ -59,7 +61,7 @@ prepare() {
|
||||
}
|
||||
|
||||
build() {
|
||||
cmake -S mocktail -B build -G Ninja \
|
||||
cmake -S nightcap -B build -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DCMAKE_INSTALL_LIBDIR=lib \
|
||||
@@ -72,6 +74,6 @@ build() {
|
||||
|
||||
package() {
|
||||
DESTDIR="${pkgdir}" cmake --install build
|
||||
install -Dm644 mocktail/LICENSE \
|
||||
install -Dm644 nightcap/LICENSE \
|
||||
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
@@ -21,8 +21,9 @@
|
||||
Sign in to Roblox, browse experiences, and play them on Linux.
|
||||
</p>
|
||||
<p>
|
||||
Mocktail is an independent community project. It is not affiliated with
|
||||
Roblox Corporation or VinegarHQ, and the Roblox client is not included.
|
||||
Nightcap is an independent community project, forked from Mocktail. It
|
||||
is not affiliated with Roblox Corporation or VinegarHQ, and the Roblox
|
||||
client is not included.
|
||||
</p>
|
||||
</description>
|
||||
<screenshots>
|
||||
@@ -56,45 +57,49 @@
|
||||
<binary>mocktail</binary>
|
||||
</provides>
|
||||
<releases>
|
||||
<release version="1.0.4" date="2026-09-11">
|
||||
<release version="0.3.1" date="2026-09-20">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Fixed login issues</li>
|
||||
<li>Fixed low-resolution textures, skybox rendering and HiDPI scaling.</li>
|
||||
<li>Added vc support.</li>
|
||||
<li>Added Xbox and PlayStation gamepad support.</li>
|
||||
<li>Added Nix support</li>
|
||||
<li>Improved text input, fullscreen mode, window sizing, and mouse controls.</li>
|
||||
<li>Updated support for Roblox 2.736.1408.</li>
|
||||
<li>Added DEB, RPM, and pacman packages to every release.</li>
|
||||
<li>Smoother frames: textures no longer decode on the thread that submits the frame.</li>
|
||||
<li>Fixed the mouse pointer jumping when a right-click camera drag ended.</li>
|
||||
<li>Fixed the text caret not moving when a trailing space was typed.</li>
|
||||
<li>Faster typing in text boxes.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.0.3" date="2026-08-16">
|
||||
<release version="0.3.0" date="2026-09-18">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Fixed issues with downloading Roblox on first launch</li>
|
||||
<li>Added fflags.json for custom FFlags</li>
|
||||
<li>Added a message if your cookies have expired</li>
|
||||
<li>Added a custom Discord presence: set the title, state, and icons.</li>
|
||||
<li>Added --profile to record a trace of the renderer's work.</li>
|
||||
<li>Faster texture loading: compressed textures decode and resample on worker threads.</li>
|
||||
<li>Lower CPU use while playing.</li>
|
||||
<li>Fixed GameMode pinning the game to a few CPU cores.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.0.2" date="2026-08-14">
|
||||
<release version="0.2.0" date="2026-09-16">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Fixed the “Roblox is out of date” bug</li>
|
||||
<li>Improved logging</li>
|
||||
<li>Fixed issues with Discord RPC where the game you were playing wasn’t displayed</li>
|
||||
<li>Discord Rich Presence is on by default.</li>
|
||||
<li>Added input.raw_mouse for mouse look in the host's own units.</li>
|
||||
<li>Fixed mouse look after a long turn.</li>
|
||||
<li>Fixed a first-run failure with "cannot lock appStorage".</li>
|
||||
<li>Values in fflags.json now win over the performance preset.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.0.1" date="2026-08-13">
|
||||
<release version="0.1.0" date="2026-09-15">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Fix issues with GameMode</li>
|
||||
<li>Add Discord RPC support</li>
|
||||
<li>First Nightcap release, forked from Mocktail.</li>
|
||||
<li>Added ETC2 and EAC texture emulation on Vulkan.</li>
|
||||
<li>Added PNG texture overrides.</li>
|
||||
<li>Lower input latency.</li>
|
||||
<li>Roblox now sees the real host RAM and display size.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.1.0" date="2026-08-12"/>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -23,6 +23,7 @@
|
||||
elfutils,
|
||||
libglvnd,
|
||||
libplacebo,
|
||||
libpng,
|
||||
libsoup_3,
|
||||
libutf8proc,
|
||||
libyaml,
|
||||
@@ -92,6 +93,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
elfutils
|
||||
libglvnd
|
||||
libplacebo
|
||||
libpng
|
||||
libsoup_3
|
||||
libutf8proc
|
||||
libyaml
|
||||
|
||||
@@ -119,6 +119,7 @@ libyaml
|
||||
fontconfig
|
||||
minizip
|
||||
libplacebo
|
||||
libpng
|
||||
util-linux'
|
||||
INSTALL_ARGS='-S --needed --noconfirm'
|
||||
;;
|
||||
@@ -144,6 +145,7 @@ libyaml
|
||||
fontconfig
|
||||
minizip
|
||||
libplacebo
|
||||
libpng
|
||||
util-linux'
|
||||
if HostUsesMusl; then
|
||||
PACKAGES="$PACKAGES gcompat"
|
||||
@@ -172,6 +174,7 @@ libyaml
|
||||
fontconfig
|
||||
minizip
|
||||
libplacebo
|
||||
libpng
|
||||
coreutils
|
||||
findutils
|
||||
util-linux'
|
||||
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
# Prints the native package version for the current ref. A release tag gives
|
||||
# its own version; anything else gives 0.0.0, which sorts below every release.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
ref="${1:-${GITHUB_REF_NAME:-}}"
|
||||
version="${ref#v}"
|
||||
|
||||
if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
printf '%s\n' "${version}"
|
||||
else
|
||||
printf '0.0.0\n'
|
||||
fi
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
# Licensed under the Apache License, Version 2.0.
|
||||
"""Summarizes `--profile` traces as a Markdown table for pull requests.
|
||||
|
||||
Usage: summarize_profile_trace.py TRACE [TRACE ...]
|
||||
|
||||
Each trace gets its own column, so `before.json after.json` compares them.
|
||||
Frame times come from the "frame interval (ms)" counter; every other row is
|
||||
a slice name recorded by the Vulkan adapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Iterable
|
||||
|
||||
FRAME_COUNTER = "frame interval (ms)"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Distribution:
|
||||
count: int
|
||||
total: float
|
||||
mean: float
|
||||
p50: float
|
||||
p95: float
|
||||
p99: float
|
||||
max: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Summary:
|
||||
frames: Distribution | None
|
||||
slices: dict[str, Distribution]
|
||||
|
||||
|
||||
def _nearest_rank(ordered: list[float], percent: float) -> float:
|
||||
rank = max(1, math.ceil(percent / 100 * len(ordered)))
|
||||
return ordered[rank - 1]
|
||||
|
||||
|
||||
def _distribution(values: list[float]) -> Distribution:
|
||||
ordered = sorted(values)
|
||||
total = sum(ordered)
|
||||
return Distribution(count=len(ordered), total=total,
|
||||
mean=total / len(ordered),
|
||||
p50=_nearest_rank(ordered, 50),
|
||||
p95=_nearest_rank(ordered, 95),
|
||||
p99=_nearest_rank(ordered, 99), max=ordered[-1])
|
||||
|
||||
|
||||
def summarize(events: Iterable[dict]) -> Summary:
|
||||
frames: list[float] = []
|
||||
slices: dict[str, list[float]] = {}
|
||||
for event in events:
|
||||
phase = event.get("ph")
|
||||
if phase == "C" and event.get("name") == FRAME_COUNTER:
|
||||
frames.append(float(event["args"]["value"]))
|
||||
elif phase == "X":
|
||||
slices.setdefault(event["name"], []).append(event["dur"] / 1000)
|
||||
return Summary(
|
||||
frames=_distribution(frames) if frames else None,
|
||||
slices={name: _distribution(values)
|
||||
for name, values in slices.items()})
|
||||
|
||||
|
||||
def _row(label: str, cells: list[str]) -> str:
|
||||
return "| " + " | ".join([label, *cells]) + " |"
|
||||
|
||||
|
||||
def _cell(distribution: Distribution | None, field: str) -> str:
|
||||
if distribution is None:
|
||||
return "-"
|
||||
if field == "count":
|
||||
return str(distribution.count)
|
||||
return f"{getattr(distribution, field):.2f}"
|
||||
|
||||
|
||||
def _metric_row(label: str, distributions: list[Distribution | None],
|
||||
field: str) -> str:
|
||||
return _row(label, [_cell(item, field) for item in distributions])
|
||||
|
||||
|
||||
def to_markdown(traces: list[tuple[str, Summary]]) -> str:
|
||||
summaries = [summary for _, summary in traces]
|
||||
lines = [_row("metric", [name for name, _ in traces]),
|
||||
_row("---", ["---:"] * len(traces))]
|
||||
|
||||
frames = [summary.frames for summary in summaries]
|
||||
lines.append(_metric_row("frames", frames, "count"))
|
||||
if any(frames):
|
||||
lines += [_metric_row(f"frame interval {field} (ms)", frames, field)
|
||||
for field in ("mean", "p50", "p95", "p99", "max")]
|
||||
|
||||
# Largest total time first, taken from whichever trace spent the most.
|
||||
totals: dict[str, float] = {}
|
||||
for summary in summaries:
|
||||
for name, distribution in summary.slices.items():
|
||||
totals[name] = max(totals.get(name, 0.0), distribution.total)
|
||||
for name in sorted(totals, key=lambda name: -totals[name]):
|
||||
column = [summary.slices.get(name) for summary in summaries]
|
||||
lines.append(_metric_row(f"{name} count", column, "count"))
|
||||
lines += [_metric_row(f"{name} {field} (ms)", column, field)
|
||||
for field in ("total", "mean", "p99", "max")]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
if not arguments:
|
||||
print(__doc__.strip(), file=sys.stderr)
|
||||
return 2
|
||||
traces = []
|
||||
for argument in arguments:
|
||||
path = Path(argument)
|
||||
traces.append((path.name, summarize(json.loads(path.read_text()))))
|
||||
print(to_markdown(traces))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -22,6 +22,10 @@
|
||||
<p>Flatpak</p>
|
||||
<code>flatpak install --user https://coderdayton.github.io/nightcap/nightcap.flatpakref
|
||||
flatpak run io.github.CoderDayton.nightcap</code>
|
||||
<p>DEB, RPM and pacman</p>
|
||||
<code>sudo apt install ./nightcap_<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
|
||||
cd nightcap && make build
|
||||
@@ -40,6 +44,7 @@ cd nightcap && make build
|
||||
<p class="links">
|
||||
<a href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">AppImage</a>
|
||||
<a href="Nightcap-x86_64.flatpak">Flatpak bundle</a>
|
||||
<a href="https://github.com/CoderDayton/nightcap/releases/latest">DEB, RPM, pacman</a>
|
||||
<a href="nightcap-flatpak.gpg">Signing key</a>
|
||||
<a href="terms.html">Terms</a>
|
||||
<a href="privacy.html">Privacy</a>
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "mocktail/graphics/android_vulkan_wsi_adapter.h"
|
||||
#include "mocktail/graphics/chrome_trace_writer.h"
|
||||
#include "mocktail/graphics/present_mode_policy.h"
|
||||
#include "mocktail/graphics/vulkan_etc2_emulation.h"
|
||||
#include "mocktail/graphics/vulkan_text_overlay_compositor.h"
|
||||
@@ -1011,7 +1013,8 @@ VkResult WaitForSemaphoresObserved(const char* call_name,
|
||||
}
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
static_cast<void>(call_name);
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
call_name, "wait");
|
||||
const VkResult result = host_wait(device, wait_info, timeout);
|
||||
if (observation != nullptr) {
|
||||
observation->SetResult(result);
|
||||
@@ -1130,7 +1133,12 @@ ObservedHostQueuePresent(VkQueue queue, const VkPresentInfoKHR* present_info) {
|
||||
}
|
||||
const bool fps_trace = FpsTraceEnabled();
|
||||
const std::uint64_t present_start_ns = fps_trace ? MonotonicNanos() : 0;
|
||||
const VkResult result = host_present(queue, present_info);
|
||||
VkResult result = VK_SUCCESS;
|
||||
{
|
||||
mocktail::graphics::TraceScope scope(
|
||||
mocktail::graphics::ActiveProfileTrace(), "host present", "present");
|
||||
result = host_present(queue, present_info);
|
||||
}
|
||||
if (fps_trace) {
|
||||
PresentWaitTrace().Record(present_start_ns);
|
||||
}
|
||||
@@ -1142,6 +1150,119 @@ ObservedHostQueuePresent(VkQueue queue, const VkPresentInfoKHR* present_info) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Samples the time between successive adapter present calls.
|
||||
void RecordProfiledFrame(mocktail::graphics::ChromeTraceWriter* trace,
|
||||
std::uint64_t present_start_ns) {
|
||||
static std::atomic<std::uint64_t> previous_start_ns{0};
|
||||
const std::uint64_t previous =
|
||||
previous_start_ns.exchange(present_start_ns, std::memory_order_relaxed);
|
||||
if (previous != 0 && present_start_ns > previous) {
|
||||
trace->Counter("frame interval (ms)", present_start_ns,
|
||||
static_cast<double>(present_start_ns - previous) / 1e6);
|
||||
}
|
||||
}
|
||||
|
||||
// Pipeline and shader creation is wrapped only while profiling, so a normal
|
||||
// session calls the host driver directly.
|
||||
VkResult VKAPI_CALL ProfiledCreateGraphicsPipelines(
|
||||
VkDevice device, VkPipelineCache cache, std::uint32_t count,
|
||||
const VkGraphicsPipelineCreateInfo* infos,
|
||||
const VkAllocationCallbacks* allocator, VkPipeline* pipelines) {
|
||||
const auto host = reinterpret_cast<PFN_vkCreateGraphicsPipelines>(
|
||||
HostDeviceProc(device, "vkCreateGraphicsPipelines"));
|
||||
if (host == nullptr) {
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkCreateGraphicsPipelines",
|
||||
"pipeline");
|
||||
const VkResult result =
|
||||
host(device, cache, count, infos, allocator, pipelines);
|
||||
scope.Arg("count", count);
|
||||
scope.Arg("cache", cache != VK_NULL_HANDLE);
|
||||
scope.Arg("result", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
VkResult VKAPI_CALL ProfiledCreateComputePipelines(
|
||||
VkDevice device, VkPipelineCache cache, std::uint32_t count,
|
||||
const VkComputePipelineCreateInfo* infos,
|
||||
const VkAllocationCallbacks* allocator, VkPipeline* pipelines) {
|
||||
const auto host = reinterpret_cast<PFN_vkCreateComputePipelines>(
|
||||
HostDeviceProc(device, "vkCreateComputePipelines"));
|
||||
if (host == nullptr) {
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkCreateComputePipelines",
|
||||
"pipeline");
|
||||
const VkResult result =
|
||||
host(device, cache, count, infos, allocator, pipelines);
|
||||
scope.Arg("count", count);
|
||||
scope.Arg("cache", cache != VK_NULL_HANDLE);
|
||||
scope.Arg("result", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
VkResult VKAPI_CALL ProfiledCreateShaderModule(
|
||||
VkDevice device, const VkShaderModuleCreateInfo* create_info,
|
||||
const VkAllocationCallbacks* allocator, VkShaderModule* shader_module) {
|
||||
const auto host = reinterpret_cast<PFN_vkCreateShaderModule>(
|
||||
HostDeviceProc(device, "vkCreateShaderModule"));
|
||||
if (host == nullptr) {
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkCreateShaderModule", "pipeline");
|
||||
const VkResult result = host(device, create_info, allocator, shader_module);
|
||||
scope.Arg("bytes", create_info != nullptr
|
||||
? static_cast<std::int64_t>(create_info->codeSize)
|
||||
: 0);
|
||||
scope.Arg("result", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
VkResult VKAPI_CALL ProfiledCreatePipelineCache(
|
||||
VkDevice device, const VkPipelineCacheCreateInfo* create_info,
|
||||
const VkAllocationCallbacks* allocator, VkPipelineCache* cache) {
|
||||
const auto host = reinterpret_cast<PFN_vkCreatePipelineCache>(
|
||||
HostDeviceProc(device, "vkCreatePipelineCache"));
|
||||
if (host == nullptr) {
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkCreatePipelineCache", "pipeline");
|
||||
const VkResult result = host(device, create_info, allocator, cache);
|
||||
scope.Arg("initial_bytes",
|
||||
create_info != nullptr
|
||||
? static_cast<std::int64_t>(create_info->initialDataSize)
|
||||
: 0);
|
||||
scope.Arg("result", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Null unless profiling is on and `name` is a profiled device command.
|
||||
PFN_vkVoidFunction ProfileAdapterProc(const char* name) {
|
||||
if (name == nullptr || mocktail::graphics::ActiveProfileTrace() == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
if (std::strcmp(name, "vkCreateGraphicsPipelines") == 0) {
|
||||
return reinterpret_cast<PFN_vkVoidFunction>(
|
||||
ProfiledCreateGraphicsPipelines);
|
||||
}
|
||||
if (std::strcmp(name, "vkCreateComputePipelines") == 0) {
|
||||
return reinterpret_cast<PFN_vkVoidFunction>(
|
||||
ProfiledCreateComputePipelines);
|
||||
}
|
||||
if (std::strcmp(name, "vkCreateShaderModule") == 0) {
|
||||
return reinterpret_cast<PFN_vkVoidFunction>(ProfiledCreateShaderModule);
|
||||
}
|
||||
if (std::strcmp(name, "vkCreatePipelineCache") == 0) {
|
||||
return reinterpret_cast<PFN_vkVoidFunction>(ProfiledCreatePipelineCache);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
template <typename Function>
|
||||
Function HostPhysicalDeviceProc(const char* name, const char* alias) {
|
||||
VkInstance instance = VK_NULL_HANDLE;
|
||||
@@ -1180,18 +1301,134 @@ void ReleaseEtc2PoolCommandBuffers(VkDevice device,
|
||||
}
|
||||
}
|
||||
|
||||
void PrepareEtc2Submit2(std::uint32_t submit_count,
|
||||
const VkSubmitInfo2* submits) {
|
||||
if (submits == nullptr) {
|
||||
return;
|
||||
// The stage the decoded staging bytes are read at. Every emulated upload is
|
||||
// a buffer-to-image copy, so the transfer stage is the earliest point the GPU
|
||||
// may touch them.
|
||||
constexpr VkPipelineStageFlags kEtc2WaitStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
|
||||
|
||||
// Holds the arrays a rewritten submit points at, so they outlive the call.
|
||||
// Deques keep earlier elements addressable as later ones are appended.
|
||||
struct Etc2SubmitRewrite {
|
||||
std::deque<std::vector<VkSemaphore>> wait_semaphores;
|
||||
std::deque<std::vector<VkPipelineStageFlags>> wait_stages;
|
||||
std::deque<std::vector<std::uint64_t>> wait_values;
|
||||
std::deque<std::vector<std::uint64_t>> signal_values;
|
||||
std::deque<VkTimelineSemaphoreSubmitInfo> timelines;
|
||||
std::deque<std::vector<VkSemaphoreSubmitInfo>> wait_infos;
|
||||
std::vector<VkSubmitInfo> rewritten;
|
||||
std::vector<VkSubmitInfo2> rewritten2;
|
||||
};
|
||||
|
||||
// Adds each submit's decode wait to its wait list. A submit whose pNext chain
|
||||
// already sizes arrays by its own semaphore counts is decoded on this thread
|
||||
// instead: a VkTimelineSemaphoreSubmitInfo would have to be rebuilt, and a
|
||||
// VkDeviceGroupSubmitInfo's pWaitSemaphoreDeviceIndices would be left one
|
||||
// entry short of the grown wait list.
|
||||
const VkSubmitInfo* PrepareEtc2Submit(std::uint32_t submit_count,
|
||||
const VkSubmitInfo* submits,
|
||||
Etc2SubmitRewrite* rewrite) {
|
||||
if (submits == nullptr || submit_count == 0) {
|
||||
return submits;
|
||||
}
|
||||
bool rewritten_any = false;
|
||||
for (std::uint32_t index = 0; index < submit_count; ++index) {
|
||||
for (std::uint32_t info = 0; info < submits[index].commandBufferInfoCount;
|
||||
++info) {
|
||||
State().etc2.PrepareSubmit(
|
||||
&submits[index].pCommandBufferInfos[info].commandBuffer, 1);
|
||||
const VkSubmitInfo& original = submits[index];
|
||||
const bool fixed_wait_list =
|
||||
FindFeature(original.pNext,
|
||||
VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO) !=
|
||||
nullptr ||
|
||||
FindFeature(original.pNext,
|
||||
VK_STRUCTURE_TYPE_DEVICE_GROUP_SUBMIT_INFO) != nullptr;
|
||||
const mocktail::graphics::Etc2SubmitWait wait = State().etc2.PrepareSubmit(
|
||||
original.pCommandBuffers, original.commandBufferCount,
|
||||
!fixed_wait_list);
|
||||
if (wait.semaphore == VK_NULL_HANDLE) {
|
||||
continue;
|
||||
}
|
||||
if (!rewritten_any) {
|
||||
rewrite->rewritten.assign(submits, submits + submit_count);
|
||||
rewritten_any = true;
|
||||
}
|
||||
std::vector<VkSemaphore>& semaphores = rewrite->wait_semaphores.emplace_back(
|
||||
original.pWaitSemaphores,
|
||||
original.pWaitSemaphores + original.waitSemaphoreCount);
|
||||
semaphores.push_back(wait.semaphore);
|
||||
std::vector<VkPipelineStageFlags>& stages =
|
||||
rewrite->wait_stages.emplace_back(
|
||||
original.pWaitDstStageMask,
|
||||
original.pWaitDstStageMask + original.waitSemaphoreCount);
|
||||
stages.push_back(kEtc2WaitStage);
|
||||
// Binary semaphores ignore their value; only the appended entry is read.
|
||||
std::vector<std::uint64_t>& values = rewrite->wait_values.emplace_back(
|
||||
original.waitSemaphoreCount, 0);
|
||||
values.push_back(wait.value);
|
||||
|
||||
VkTimelineSemaphoreSubmitInfo& timeline =
|
||||
rewrite->timelines.emplace_back();
|
||||
timeline.sType = VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO;
|
||||
timeline.pNext = original.pNext;
|
||||
timeline.waitSemaphoreValueCount = static_cast<std::uint32_t>(values.size());
|
||||
timeline.pWaitSemaphoreValues = values.data();
|
||||
if (original.signalSemaphoreCount != 0) {
|
||||
std::vector<std::uint64_t>& signals =
|
||||
rewrite->signal_values.emplace_back(original.signalSemaphoreCount, 0);
|
||||
timeline.signalSemaphoreValueCount =
|
||||
static_cast<std::uint32_t>(signals.size());
|
||||
timeline.pSignalSemaphoreValues = signals.data();
|
||||
}
|
||||
|
||||
VkSubmitInfo& target = rewrite->rewritten[index];
|
||||
target.pNext = &timeline;
|
||||
target.waitSemaphoreCount = static_cast<std::uint32_t>(semaphores.size());
|
||||
target.pWaitSemaphores = semaphores.data();
|
||||
target.pWaitDstStageMask = stages.data();
|
||||
}
|
||||
return rewritten_any ? rewrite->rewritten.data() : submits;
|
||||
}
|
||||
|
||||
const VkSubmitInfo2* PrepareEtc2Submit2(std::uint32_t submit_count,
|
||||
const VkSubmitInfo2* submits,
|
||||
Etc2SubmitRewrite* rewrite) {
|
||||
if (submits == nullptr || submit_count == 0) {
|
||||
return submits;
|
||||
}
|
||||
bool rewritten_any = false;
|
||||
for (std::uint32_t index = 0; index < submit_count; ++index) {
|
||||
const VkSubmitInfo2& original = submits[index];
|
||||
std::vector<VkCommandBuffer> command_buffers;
|
||||
command_buffers.reserve(original.commandBufferInfoCount);
|
||||
for (std::uint32_t info = 0; info < original.commandBufferInfoCount;
|
||||
++info) {
|
||||
command_buffers.push_back(
|
||||
original.pCommandBufferInfos[info].commandBuffer);
|
||||
}
|
||||
const mocktail::graphics::Etc2SubmitWait wait = State().etc2.PrepareSubmit(
|
||||
command_buffers.data(),
|
||||
static_cast<std::uint32_t>(command_buffers.size()), true);
|
||||
if (wait.semaphore == VK_NULL_HANDLE) {
|
||||
continue;
|
||||
}
|
||||
if (!rewritten_any) {
|
||||
rewrite->rewritten2.assign(submits, submits + submit_count);
|
||||
rewritten_any = true;
|
||||
}
|
||||
std::vector<VkSemaphoreSubmitInfo>& waits = rewrite->wait_infos.emplace_back(
|
||||
original.pWaitSemaphoreInfos,
|
||||
original.pWaitSemaphoreInfos + original.waitSemaphoreInfoCount);
|
||||
VkSemaphoreSubmitInfo decoded{};
|
||||
decoded.sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO;
|
||||
decoded.semaphore = wait.semaphore;
|
||||
decoded.value = wait.value;
|
||||
// Matches kEtc2WaitStage: the staging bytes are read by transfer
|
||||
// commands, which includes the blits an upscaled upload records.
|
||||
decoded.stageMask = VK_PIPELINE_STAGE_2_ALL_TRANSFER_BIT;
|
||||
waits.push_back(decoded);
|
||||
|
||||
VkSubmitInfo2& target = rewrite->rewritten2[index];
|
||||
target.waitSemaphoreInfoCount = static_cast<std::uint32_t>(waits.size());
|
||||
target.pWaitSemaphoreInfos = waits.data();
|
||||
}
|
||||
return rewritten_any ? rewrite->rewritten2.data() : submits;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -1397,6 +1634,10 @@ vkGetInstanceProcAddr(VkInstance instance, const char* name) {
|
||||
vkGetPhysicalDeviceSurfacePresentModesKHR)
|
||||
: nullptr;
|
||||
}
|
||||
if (const PFN_vkVoidFunction profiled = ProfileAdapterProc(name);
|
||||
profiled != nullptr) {
|
||||
return HostInstanceProc(instance, name) != nullptr ? profiled : nullptr;
|
||||
}
|
||||
if (const PFN_vkVoidFunction adapter = AdapterProc(name);
|
||||
adapter != nullptr) {
|
||||
if ((IsDeviceAdapterProc(name) || IsEtc2PhysicalDeviceProc(name)) &&
|
||||
@@ -1418,6 +1659,10 @@ VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL vkGetDeviceProcAddr(VkDevice device,
|
||||
const PFN_vkVoidFunction host = host_get_device_proc_addr != nullptr
|
||||
? host_get_device_proc_addr(device, name)
|
||||
: nullptr;
|
||||
if (const PFN_vkVoidFunction profiled = ProfileAdapterProc(name);
|
||||
profiled != nullptr) {
|
||||
return host != nullptr ? profiled : nullptr;
|
||||
}
|
||||
if (!IsDeviceAdapterProc(name)) {
|
||||
return host;
|
||||
}
|
||||
@@ -1536,7 +1781,8 @@ VKAPI_ATTR VkResult VKAPI_CALL vkCreateDevice(
|
||||
host_memory_properties(physical_device, &memory_properties);
|
||||
}
|
||||
State().etc2.RegisterDevice(*device, physical_device, etc2_emulated,
|
||||
memory_properties, host_get_device_proc_addr);
|
||||
memory_properties, host_get_device_proc_addr,
|
||||
enabled.vulkan12.timelineSemaphore == VK_TRUE);
|
||||
|
||||
std::uint32_t queue_family_count = 0;
|
||||
const auto host_get_queue_families =
|
||||
@@ -1599,6 +1845,10 @@ vkDestroyDevice(VkDevice device, const VkAllocationCallbacks* allocator) {
|
||||
host_destroy(device, allocator);
|
||||
}
|
||||
RemoveHostDeviceDispatch(device);
|
||||
if (auto* trace = mocktail::graphics::ActiveProfileTrace();
|
||||
trace != nullptr) {
|
||||
trace->Flush();
|
||||
}
|
||||
}
|
||||
|
||||
VKAPI_ATTR void VKAPI_CALL vkGetDeviceQueue(VkDevice device,
|
||||
@@ -1754,6 +2004,8 @@ VKAPI_ATTR VkResult VKAPI_CALL vkAcquireNextImageKHR(
|
||||
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkAcquireNextImageKHR", "wait");
|
||||
const bool fps_trace = FpsTraceEnabled();
|
||||
const std::uint64_t acquire_start_ns = fps_trace ? MonotonicNanos() : 0;
|
||||
const VkResult host_result = host_acquire(
|
||||
@@ -1783,6 +2035,8 @@ VKAPI_ATTR VkResult VKAPI_CALL vkAcquireNextImage2KHR(
|
||||
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkAcquireNextImage2KHR", "wait");
|
||||
const VkResult host_result =
|
||||
host_acquire(device, acquire_info, image_index);
|
||||
const VkResult result = NormalizeSwapchainResult(host_result);
|
||||
@@ -1807,6 +2061,8 @@ VKAPI_ATTR VkResult VKAPI_CALL vkWaitForFences(
|
||||
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkWaitForFences", "wait");
|
||||
const bool fps_trace = FpsTraceEnabled();
|
||||
const std::uint64_t fence_start_ns = fps_trace ? MonotonicNanos() : 0;
|
||||
const VkResult result =
|
||||
@@ -2002,14 +2258,14 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit(
|
||||
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
if (submits != nullptr) {
|
||||
for (std::uint32_t index = 0; index < submit_count; ++index) {
|
||||
State().etc2.PrepareSubmit(submits[index].pCommandBuffers,
|
||||
submits[index].commandBufferCount);
|
||||
}
|
||||
}
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkQueueSubmit", "submit");
|
||||
scope.Arg("submits", submit_count);
|
||||
Etc2SubmitRewrite rewrite;
|
||||
const VkSubmitInfo* prepared =
|
||||
PrepareEtc2Submit(submit_count, submits, &rewrite);
|
||||
const VkResult result = State().text_overlay.QueueSubmit(
|
||||
queue, submit_count, submits, fence, host_submit);
|
||||
queue, submit_count, prepared, fence, host_submit);
|
||||
observation.SetResult(result);
|
||||
return result;
|
||||
}
|
||||
@@ -2024,9 +2280,14 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit2(
|
||||
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
PrepareEtc2Submit2(submit_count, submits);
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkQueueSubmit2", "submit");
|
||||
scope.Arg("submits", submit_count);
|
||||
Etc2SubmitRewrite rewrite;
|
||||
const VkSubmitInfo2* prepared =
|
||||
PrepareEtc2Submit2(submit_count, submits, &rewrite);
|
||||
const VkResult result = State().text_overlay.QueueSubmit2(
|
||||
queue, submit_count, submits, fence, host_submit);
|
||||
queue, submit_count, prepared, fence, host_submit);
|
||||
observation.SetResult(result);
|
||||
return result;
|
||||
}
|
||||
@@ -2041,9 +2302,14 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit2KHR(
|
||||
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
PrepareEtc2Submit2(submit_count, submits);
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkQueueSubmit2KHR", "submit");
|
||||
scope.Arg("submits", submit_count);
|
||||
Etc2SubmitRewrite rewrite;
|
||||
const VkSubmitInfo2* prepared =
|
||||
PrepareEtc2Submit2(submit_count, submits, &rewrite);
|
||||
const VkResult result = State().text_overlay.QueueSubmit2(
|
||||
queue, submit_count, submits, fence,
|
||||
queue, submit_count, prepared, fence,
|
||||
reinterpret_cast<PFN_vkQueueSubmit2>(host_submit));
|
||||
observation.SetResult(result);
|
||||
return result;
|
||||
@@ -2073,6 +2339,8 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueWaitIdle(VkQueue queue) {
|
||||
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkQueueWaitIdle", "wait");
|
||||
const bool fps_trace = FpsTraceEnabled();
|
||||
const std::uint64_t idle_start_ns = fps_trace ? MonotonicNanos() : 0;
|
||||
const VkResult result =
|
||||
@@ -2092,6 +2360,8 @@ VKAPI_ATTR VkResult VKAPI_CALL vkDeviceWaitIdle(VkDevice device) {
|
||||
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkDeviceWaitIdle", "wait");
|
||||
const VkResult result =
|
||||
State().text_overlay.DeviceWaitIdle(device, host_wait);
|
||||
observation.SetResult(result);
|
||||
@@ -2210,8 +2480,15 @@ vkQueuePresentKHR(VkQueue queue, const VkPresentInfoKHR* present_info) {
|
||||
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
mocktail::graphics::ChromeTraceWriter* const trace =
|
||||
mocktail::graphics::ActiveProfileTrace();
|
||||
mocktail::graphics::TraceScope scope(trace, "vkQueuePresentKHR", "present");
|
||||
if (trace != nullptr) {
|
||||
RecordProfiledFrame(trace, mocktail::graphics::TraceClockNanos());
|
||||
}
|
||||
const VkResult result = state.text_overlay.QueuePresent(
|
||||
queue, present_info, ObservedHostQueuePresent);
|
||||
scope.Arg("result", result);
|
||||
const VkResult normalized_result = NormalizeSwapchainResult(result);
|
||||
if (normalized_result == VK_ERROR_OUT_OF_DATE_KHR) {
|
||||
const NoteSurfaceOutOfDateFn note_surface_out_of_date =
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
#include "mocktail/graphics/chrome_trace_writer.h"
|
||||
|
||||
#include <sys/types.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
namespace mocktail {
|
||||
namespace graphics {
|
||||
namespace {
|
||||
|
||||
// Buffered bytes that wake the writer before its periodic tick.
|
||||
constexpr std::size_t kWakeBytes = 256 * 1024;
|
||||
constexpr std::chrono::milliseconds kWriteInterval{250};
|
||||
// Every write ends the file with this, and the next write starts over it.
|
||||
constexpr char kArrayEnd[] = "\n]\n";
|
||||
constexpr long kArrayEndBytes = sizeof(kArrayEnd) - 1;
|
||||
|
||||
void AppendInteger(std::string* out, std::int64_t value) {
|
||||
char digits[24];
|
||||
const auto [end, error] =
|
||||
std::to_chars(digits, digits + sizeof(digits), value);
|
||||
if (error == std::errc()) {
|
||||
out->append(digits, end);
|
||||
} else {
|
||||
// Appending nothing would leave "ts": with no value, which costs the
|
||||
// whole trace rather than the one field.
|
||||
out->push_back('0');
|
||||
}
|
||||
}
|
||||
|
||||
void AppendDouble(std::string* out, double value) {
|
||||
// to_chars spells inf and nan without quotes, which no JSON reader accepts.
|
||||
if (!std::isfinite(value)) {
|
||||
out->push_back('0');
|
||||
return;
|
||||
}
|
||||
char digits[32];
|
||||
const auto [end, error] =
|
||||
std::to_chars(digits, digits + sizeof(digits), value);
|
||||
if (error == std::errc()) {
|
||||
out->append(digits, end);
|
||||
} else {
|
||||
out->push_back('0');
|
||||
}
|
||||
}
|
||||
|
||||
void AppendString(std::string* out, const char* value) {
|
||||
out->push_back('"');
|
||||
for (const char* cursor = value != nullptr ? value : ""; *cursor != '\0';
|
||||
++cursor) {
|
||||
const unsigned char byte = static_cast<unsigned char>(*cursor);
|
||||
if (byte == '"' || byte == '\\') {
|
||||
out->push_back('\\');
|
||||
out->push_back(static_cast<char>(byte));
|
||||
} else if (byte < 0x20) {
|
||||
static constexpr char kHex[] = "0123456789abcdef";
|
||||
out->append("\\u00");
|
||||
out->push_back(kHex[byte >> 4]);
|
||||
out->push_back(kHex[byte & 0xf]);
|
||||
} else {
|
||||
out->push_back(static_cast<char>(byte));
|
||||
}
|
||||
}
|
||||
out->push_back('"');
|
||||
}
|
||||
|
||||
void AppendHeader(std::string* out, const char* name, const char* category,
|
||||
char phase, std::uint64_t timestamp_ns) {
|
||||
static const std::int64_t pid = getpid();
|
||||
thread_local const std::int64_t tid = gettid();
|
||||
out->append("{\"name\":");
|
||||
AppendString(out, name);
|
||||
if (category != nullptr) {
|
||||
out->append(",\"cat\":");
|
||||
AppendString(out, category);
|
||||
}
|
||||
out->append(",\"ph\":\"");
|
||||
out->push_back(phase);
|
||||
out->append("\",\"ts\":");
|
||||
AppendInteger(out, static_cast<std::int64_t>(timestamp_ns / 1000));
|
||||
out->append(",\"pid\":");
|
||||
AppendInteger(out, pid);
|
||||
out->append(",\"tid\":");
|
||||
AppendInteger(out, tid);
|
||||
}
|
||||
|
||||
std::string& ThreadScratch() {
|
||||
thread_local std::string scratch;
|
||||
scratch.clear();
|
||||
return scratch;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::uint64_t TraceClockNanos() {
|
||||
timespec now{};
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
return static_cast<std::uint64_t>(now.tv_sec) * 1'000'000'000ULL +
|
||||
static_cast<std::uint64_t>(now.tv_nsec);
|
||||
}
|
||||
|
||||
std::unique_ptr<ChromeTraceWriter> ChromeTraceWriter::Open(
|
||||
const std::filesystem::path& path, std::string* error) {
|
||||
std::FILE* file = std::fopen(path.c_str(), "wb");
|
||||
if (file == nullptr || std::fputs("[", file) == EOF ||
|
||||
std::fputs(kArrayEnd, file) == EOF || std::fflush(file) != 0) {
|
||||
const int saved = errno;
|
||||
if (file != nullptr) {
|
||||
std::fclose(file);
|
||||
}
|
||||
if (error != nullptr) {
|
||||
*error = "could not open profile trace " + path.string() + ": " +
|
||||
std::strerror(saved);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
return std::unique_ptr<ChromeTraceWriter>(new ChromeTraceWriter(file));
|
||||
}
|
||||
|
||||
ChromeTraceWriter::ChromeTraceWriter(std::FILE* file)
|
||||
: file_(file), owner_pid_(getpid()) {
|
||||
writer_ = std::thread([this] { WriterLoop(); });
|
||||
}
|
||||
|
||||
ChromeTraceWriter::~ChromeTraceWriter() { Close(); }
|
||||
|
||||
void ChromeTraceWriter::Slice(const char* name, const char* category,
|
||||
std::uint64_t start_ns, std::uint64_t end_ns,
|
||||
std::initializer_list<TraceArg> args) {
|
||||
Slice(name, category, start_ns, end_ns, args.begin(), args.size());
|
||||
}
|
||||
|
||||
void ChromeTraceWriter::Slice(const char* name, const char* category,
|
||||
std::uint64_t start_ns, std::uint64_t end_ns,
|
||||
const TraceArg* args, std::size_t arg_count) {
|
||||
std::string& event = ThreadScratch();
|
||||
AppendHeader(&event, name, category, 'X', start_ns);
|
||||
event.append(",\"dur\":");
|
||||
const std::uint64_t start_us = start_ns / 1000;
|
||||
const std::uint64_t end_us = end_ns / 1000;
|
||||
AppendInteger(&event, static_cast<std::int64_t>(
|
||||
end_us > start_us ? end_us - start_us : 0));
|
||||
if (args != nullptr && arg_count != 0) {
|
||||
event.append(",\"args\":{");
|
||||
for (std::size_t index = 0; index < arg_count; ++index) {
|
||||
if (index != 0) {
|
||||
event.push_back(',');
|
||||
}
|
||||
AppendString(&event, args[index].key);
|
||||
event.push_back(':');
|
||||
AppendInteger(&event, args[index].value);
|
||||
}
|
||||
event.push_back('}');
|
||||
}
|
||||
event.push_back('}');
|
||||
Append(event.data(), event.size());
|
||||
}
|
||||
|
||||
void ChromeTraceWriter::Counter(const char* name, std::uint64_t timestamp_ns,
|
||||
double value) {
|
||||
std::string& event = ThreadScratch();
|
||||
AppendHeader(&event, name, nullptr, 'C', timestamp_ns);
|
||||
event.append(",\"args\":{\"value\":");
|
||||
AppendDouble(&event, value);
|
||||
event.append("}}");
|
||||
Append(event.data(), event.size());
|
||||
}
|
||||
|
||||
void ChromeTraceWriter::Append(const char* event, std::size_t size) {
|
||||
bool wake = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (closed_) {
|
||||
return;
|
||||
}
|
||||
pending_.append(first_event_ ? "\n" : ",\n");
|
||||
first_event_ = false;
|
||||
pending_.append(event, size);
|
||||
wake = pending_.size() >= kWakeBytes;
|
||||
}
|
||||
if (wake) {
|
||||
wake_.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
void ChromeTraceWriter::WriterLoop() {
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
while (!stopping_) {
|
||||
wake_.wait_for(lock, kWriteInterval, [this] {
|
||||
return stopping_ || pending_.size() >= kWakeBytes;
|
||||
});
|
||||
if (stopping_) {
|
||||
break;
|
||||
}
|
||||
lock.unlock();
|
||||
WritePending();
|
||||
lock.lock();
|
||||
}
|
||||
}
|
||||
|
||||
// The file lock is taken before the buffer lock, so chunks reach the file in
|
||||
// the order they were buffered.
|
||||
void ChromeTraceWriter::WritePending() {
|
||||
std::lock_guard<std::mutex> file_lock(file_mutex_);
|
||||
spare_.clear();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
pending_.swap(spare_);
|
||||
}
|
||||
if (file_ == nullptr || spare_.empty()) {
|
||||
return;
|
||||
}
|
||||
if (std::fseek(file_, -kArrayEndBytes, SEEK_END) != 0 ||
|
||||
std::fwrite(spare_.data(), 1, spare_.size(), file_) != spare_.size() ||
|
||||
std::fputs(kArrayEnd, file_) == EOF || std::fflush(file_) != 0) {
|
||||
std::fprintf(stderr, " [profile] trace write failed: %s\n",
|
||||
std::strerror(errno));
|
||||
std::fclose(file_);
|
||||
file_ = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
closed_ = true;
|
||||
// Nothing can reach the file now, so the writer thread exits instead of
|
||||
// ticking for the rest of the process.
|
||||
stopping_ = true;
|
||||
}
|
||||
wake_.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
void ChromeTraceWriter::Flush() { WritePending(); }
|
||||
|
||||
void ChromeTraceWriter::Close() {
|
||||
// A forked child shares the file but not the writer thread.
|
||||
if (getpid() != owner_pid_) {
|
||||
return;
|
||||
}
|
||||
// A write failure sets stopping_ without joining the thread, so the join
|
||||
// below runs even when stopping_ is already set.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
stopping_ = true;
|
||||
}
|
||||
wake_.notify_all();
|
||||
if (writer_.joinable() && writer_.get_id() != std::this_thread::get_id()) {
|
||||
writer_.join();
|
||||
}
|
||||
WritePending();
|
||||
std::lock_guard<std::mutex> file_lock(file_mutex_);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
closed_ = true;
|
||||
}
|
||||
if (file_ != nullptr) {
|
||||
std::fclose(file_);
|
||||
file_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
ChromeTraceWriter* ActiveProfileTrace() {
|
||||
static ChromeTraceWriter* const trace = []() -> ChromeTraceWriter* {
|
||||
const char* path = std::getenv(kProfileTraceEnvironment);
|
||||
if (path == nullptr || path[0] == '\0') {
|
||||
return nullptr;
|
||||
}
|
||||
std::string error;
|
||||
std::unique_ptr<ChromeTraceWriter> writer =
|
||||
ChromeTraceWriter::Open(path, &error);
|
||||
if (writer == nullptr) {
|
||||
std::fprintf(stderr, " [profile] %s\n", error.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
std::fprintf(stderr, " [profile] writing Chrome trace to %s\n", path);
|
||||
// Leaked so threads still recording during exit never touch a destroyed
|
||||
// writer; they find it closed instead.
|
||||
static struct CloseAtExit {
|
||||
ChromeTraceWriter* writer;
|
||||
~CloseAtExit() { writer->Close(); }
|
||||
} close_at_exit{writer.get()};
|
||||
return writer.release();
|
||||
}();
|
||||
return trace;
|
||||
}
|
||||
|
||||
} // namespace graphics
|
||||
} // namespace mocktail
|
||||
+111
-20
@@ -5,7 +5,9 @@
|
||||
#include <pthread.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail::graphics {
|
||||
@@ -213,10 +215,11 @@ std::size_t EtcDecodedTexelBytes(EtcFormat format) {
|
||||
|
||||
namespace {
|
||||
|
||||
// Blocks per band handed to one worker: about a 256x256 texel image.
|
||||
constexpr std::uint64_t kBlocksPerBand = 16384;
|
||||
// Batches smaller than a 1024x1024 texel image decode on the caller.
|
||||
constexpr std::uint64_t kParallelMinimumBlocks = 65536;
|
||||
// Blocks per band handed to one worker: about a 128x128 texel image.
|
||||
constexpr std::uint64_t kBlocksPerBand = 4096;
|
||||
// Batches smaller than a 128x128 texel image decode on the caller. Waking a
|
||||
// pooled worker costs microseconds, so the floor only has to cover that.
|
||||
constexpr std::uint64_t kParallelMinimumBlocks = 4096;
|
||||
|
||||
bool ImageFits(EtcFormat format, const std::uint8_t* source,
|
||||
std::size_t source_bytes, std::uint32_t width,
|
||||
@@ -274,10 +277,97 @@ struct DecodeBandQueue {
|
||||
}
|
||||
};
|
||||
|
||||
void* RunDecodeBandQueue(void* queue) {
|
||||
static_cast<DecodeBandQueue*>(queue)->Run();
|
||||
return nullptr;
|
||||
}
|
||||
// Decode workers outlive the batches that use them: creating and joining a
|
||||
// thread per batch costs more than decoding a small one. The threads park on
|
||||
// a condition variable and are never joined, so the pool is leaked at exit.
|
||||
class DecodePool {
|
||||
public:
|
||||
static DecodePool& Instance() {
|
||||
static DecodePool* pool = new DecodePool();
|
||||
return *pool;
|
||||
}
|
||||
|
||||
// Calls run(context) on the calling thread and on pooled workers, `workers`
|
||||
// calls in all. Returns false without calling it when another batch
|
||||
// already holds the pool or no worker could be started, leaving the whole
|
||||
// batch to the caller.
|
||||
bool Run(void (*run)(void*), void* context, std::size_t workers) {
|
||||
if (workers < 2) {
|
||||
return false;
|
||||
}
|
||||
std::unique_lock<std::mutex> batch(batch_mutex_, std::try_to_lock);
|
||||
if (!batch.owns_lock()) {
|
||||
return false;
|
||||
}
|
||||
const std::size_t helpers = Reserve(workers - 1);
|
||||
if (helpers == 0) {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
run_ = run;
|
||||
context_ = context;
|
||||
unclaimed_ = helpers;
|
||||
running_ = helpers;
|
||||
}
|
||||
ready_.notify_all();
|
||||
run(context);
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
done_.wait(lock, [this] { return running_ == 0; });
|
||||
run_ = nullptr;
|
||||
context_ = nullptr;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
DecodePool() = default;
|
||||
|
||||
// Grows the pool towards `wanted` and reports how many workers exist, so a
|
||||
// pthread_create failure degrades instead of deadlocking the wait below.
|
||||
std::size_t Reserve(std::size_t wanted) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
while (threads_ < wanted) {
|
||||
pthread_t worker;
|
||||
if (pthread_create(&worker, nullptr, &DecodePool::RunWorker, this) != 0) {
|
||||
break;
|
||||
}
|
||||
pthread_detach(worker);
|
||||
++threads_;
|
||||
}
|
||||
return std::min(threads_, wanted);
|
||||
}
|
||||
|
||||
static void* RunWorker(void* pool) {
|
||||
static_cast<DecodePool*>(pool)->WorkerLoop();
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void WorkerLoop() {
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
for (;;) {
|
||||
ready_.wait(lock, [this] { return unclaimed_ > 0; });
|
||||
--unclaimed_;
|
||||
void (*run)(void*) = run_;
|
||||
void* context = context_;
|
||||
lock.unlock();
|
||||
run(context);
|
||||
lock.lock();
|
||||
if (--running_ == 0) {
|
||||
done_.notify_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::mutex batch_mutex_;
|
||||
std::mutex mutex_;
|
||||
std::condition_variable ready_;
|
||||
std::condition_variable done_;
|
||||
void (*run_)(void*) = nullptr;
|
||||
void* context_ = nullptr;
|
||||
std::size_t unclaimed_ = 0;
|
||||
std::size_t running_ = 0;
|
||||
std::size_t threads_ = 0;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -311,19 +401,20 @@ void DecodeEtcJobs(EtcDecodeJob* jobs, std::size_t count,
|
||||
total_blocks < kParallelMinimumBlocks
|
||||
? 1
|
||||
: std::min<std::size_t>(std::max(worker_count, 1U), bands.size());
|
||||
std::vector<pthread_t> workers;
|
||||
workers.reserve(threads > 0 ? threads - 1 : 0);
|
||||
for (std::size_t index = 1; index < threads; ++index) {
|
||||
pthread_t worker;
|
||||
// The caller decodes whatever workers that failed to start leave behind.
|
||||
if (pthread_create(&worker, nullptr, RunDecodeBandQueue, &queue) != 0) {
|
||||
break;
|
||||
}
|
||||
workers.push_back(worker);
|
||||
RunOnDecodeWorkers(
|
||||
[](void* context) { static_cast<DecodeBandQueue*>(context)->Run(); },
|
||||
&queue, static_cast<unsigned>(threads));
|
||||
}
|
||||
|
||||
void RunOnDecodeWorkers(void (*run)(void*), void* context,
|
||||
unsigned worker_count) {
|
||||
if (run == nullptr) {
|
||||
return;
|
||||
}
|
||||
queue.Run();
|
||||
for (pthread_t worker : workers) {
|
||||
pthread_join(worker, nullptr);
|
||||
// The pool declines when it is already busy; the caller drains the whole
|
||||
// batch itself then, exactly as a single-threaded batch does.
|
||||
if (!DecodePool::Instance().Run(run, context, worker_count)) {
|
||||
run(context);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <png.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
@@ -82,6 +83,23 @@ void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
|
||||
height == 0 || source_width == 0 || source_height == 0) {
|
||||
return;
|
||||
}
|
||||
// Source box of destination column x is [columns[2x], columns[2x + 1]).
|
||||
thread_local std::vector<std::uint32_t> columns;
|
||||
columns.resize(static_cast<std::size_t>(width) * 2);
|
||||
bool one_texel_columns = true;
|
||||
for (std::uint32_t x = 0; x < width; ++x) {
|
||||
const std::uint32_t x0 = static_cast<std::uint32_t>(
|
||||
static_cast<std::uint64_t>(x) * source_width / width);
|
||||
const std::uint32_t x1 = std::max<std::uint32_t>(
|
||||
x0 + 1, static_cast<std::uint32_t>(
|
||||
(static_cast<std::uint64_t>(x) + 1) * source_width / width));
|
||||
columns[2 * static_cast<std::size_t>(x)] = x0;
|
||||
columns[2 * static_cast<std::size_t>(x) + 1] = x1;
|
||||
one_texel_columns = one_texel_columns && x1 == x0 + 1;
|
||||
}
|
||||
const std::size_t row_bytes = static_cast<std::size_t>(width) * 4;
|
||||
std::uint64_t previous_y0 = 0;
|
||||
std::uint64_t previous_y1 = 0;
|
||||
for (std::uint32_t y = 0; y < height; ++y) {
|
||||
const std::uint64_t y0 = static_cast<std::uint64_t>(y) * source_height / height;
|
||||
std::uint64_t y1 =
|
||||
@@ -89,13 +107,25 @@ void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
|
||||
if (y1 <= y0) {
|
||||
y1 = y0 + 1;
|
||||
}
|
||||
for (std::uint32_t x = 0; x < width; ++x) {
|
||||
const std::uint64_t x0 = static_cast<std::uint64_t>(x) * source_width / width;
|
||||
std::uint64_t x1 =
|
||||
(static_cast<std::uint64_t>(x) + 1) * source_width / width;
|
||||
if (x1 <= x0) {
|
||||
x1 = x0 + 1;
|
||||
std::uint8_t* out_row = destination + y * row_bytes;
|
||||
if (y > 0 && y0 == previous_y0 && y1 == previous_y1) {
|
||||
std::memcpy(out_row, out_row - row_bytes, row_bytes);
|
||||
continue;
|
||||
}
|
||||
previous_y0 = y0;
|
||||
previous_y1 = y1;
|
||||
if (one_texel_columns && y1 == y0 + 1) {
|
||||
const std::uint8_t* source_row = source + y0 * source_width * 4;
|
||||
for (std::uint32_t x = 0; x < width; ++x) {
|
||||
std::memcpy(out_row + static_cast<std::size_t>(x) * 4,
|
||||
source_row + columns[2 * static_cast<std::size_t>(x)] * 4,
|
||||
4);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (std::uint32_t x = 0; x < width; ++x) {
|
||||
const std::uint64_t x0 = columns[2 * static_cast<std::size_t>(x)];
|
||||
const std::uint64_t x1 = columns[2 * static_cast<std::size_t>(x) + 1];
|
||||
std::uint64_t sum[4] = {0, 0, 0, 0};
|
||||
for (std::uint64_t sy = y0; sy < y1; ++sy) {
|
||||
for (std::uint64_t sx = x0; sx < x1; ++sx) {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
#include "mocktail/graphics/vulkan_etc2_emulation.h"
|
||||
|
||||
#include "mocktail/graphics/chrome_trace_writer.h"
|
||||
#include "mocktail/graphics/texture_override.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
@@ -155,6 +159,13 @@ struct HostDevice {
|
||||
PFN_vkCreateBuffer create_buffer = nullptr;
|
||||
PFN_vkAllocateMemory allocate_memory = nullptr;
|
||||
PFN_vkGetBufferMemoryRequirements get_buffer_memory_requirements = nullptr;
|
||||
PFN_vkCmdPipelineBarrier pipeline_barrier = nullptr;
|
||||
PFN_vkSignalSemaphore signal_semaphore = nullptr;
|
||||
PFN_vkDestroySemaphore destroy_semaphore = nullptr;
|
||||
// Signalled by the decode dispatcher so a submit can wait for its uploads
|
||||
// on the GPU instead of on the submitting thread. Null when the device was
|
||||
// created without the timelineSemaphore feature.
|
||||
VkSemaphore timeline = VK_NULL_HANDLE;
|
||||
};
|
||||
|
||||
template <typename Function>
|
||||
@@ -199,18 +210,59 @@ bool IsColorFormat(EtcFormat format) {
|
||||
format == EtcFormat::kEtc2Rgba8;
|
||||
}
|
||||
|
||||
// A host-visible transfer buffer that stays mapped for its whole life.
|
||||
struct Staging {
|
||||
VkDevice device = VK_NULL_HANDLE;
|
||||
VkBuffer buffer = VK_NULL_HANDLE;
|
||||
VkDeviceMemory memory = VK_NULL_HANDLE;
|
||||
VkDeviceSize capacity = 0;
|
||||
std::uint8_t* mapped = nullptr;
|
||||
};
|
||||
|
||||
// Idle staging buffers kept for reuse. Creating, allocating, binding and
|
||||
// mapping one costs hundreds of microseconds in the driver, so a burst of
|
||||
// texture uploads would otherwise stall the frame that records it.
|
||||
constexpr VkDeviceSize kMaxIdleStagingBytes = 64 * 1024 * 1024;
|
||||
constexpr std::size_t kMaxIdleStagingBuffers = 64;
|
||||
// Decode batches kept for their buffers once they complete.
|
||||
constexpr std::size_t kMaxSpareBatches = 8;
|
||||
|
||||
// A growable byte buffer that leaves new bytes uninitialized. The gather
|
||||
// writes every byte it later reads, and zeroing a multi-megabyte buffer is
|
||||
// itself frame-visible work.
|
||||
class RawBuffer {
|
||||
public:
|
||||
void EnsureSize(std::size_t bytes) {
|
||||
if (bytes <= size_) {
|
||||
return;
|
||||
}
|
||||
// new[] on a trivial type default-initializes, so nothing is zeroed.
|
||||
data_.reset(new std::uint8_t[bytes]);
|
||||
size_ = bytes;
|
||||
}
|
||||
std::uint8_t* data() { return data_.get(); }
|
||||
std::size_t size() const { return size_; }
|
||||
|
||||
private:
|
||||
std::unique_ptr<std::uint8_t[]> data_;
|
||||
std::size_t size_ = 0;
|
||||
};
|
||||
// A request may take an idle buffer up to this size, or up to 4x its own
|
||||
// size when larger, so small uploads share buffers without pinning big ones.
|
||||
constexpr VkDeviceSize kStagingReuseSlack = 64 * 1024;
|
||||
|
||||
struct PendingUpload {
|
||||
VkDevice device = VK_NULL_HANDLE;
|
||||
VkBuffer source = VK_NULL_HANDLE;
|
||||
VkDeviceSize source_offset = 0;
|
||||
VkDeviceSize compressed = 0;
|
||||
VkDeviceSize decoded = 0;
|
||||
// Source pitch in bytes from bufferRowLength/bufferImageHeight, and the
|
||||
// bytes the region spans from `source_offset` including that padding.
|
||||
// `compressed` stays the packed size the decoder consumes.
|
||||
VkDeviceSize source_row_stride = 0;
|
||||
VkDeviceSize source_layer_stride = 0;
|
||||
VkDeviceSize source_span = 0;
|
||||
VkDeviceSize target_offset = 0;
|
||||
EtcFormat format = EtcFormat::kEtc2Rgb8;
|
||||
VkImage image = VK_NULL_HANDLE;
|
||||
@@ -237,6 +289,32 @@ struct CommandRecord {
|
||||
std::vector<PendingUpload> uploads;
|
||||
std::vector<Staging> staging;
|
||||
std::vector<VkCommandBuffer> secondaries;
|
||||
// Highest decode ticket dispatched for this command buffer. Its staging
|
||||
// must not be released before that ticket completes.
|
||||
std::uint64_t last_ticket = 0;
|
||||
};
|
||||
|
||||
// One submit's worth of uploads, decoded off the submitting thread. The
|
||||
// buffers and the upload list are owned so nothing in them depends on the
|
||||
// caller's stack.
|
||||
struct DecodeBatch {
|
||||
VkDevice device = VK_NULL_HANDLE;
|
||||
std::uint64_t ticket = 0;
|
||||
RawBuffer source;
|
||||
std::vector<std::uint8_t> decoded;
|
||||
// Bytes each buffer must hold. Both are grown on the dispatcher, since
|
||||
// sizing them on the submitting thread puts that cost back on the frame.
|
||||
// `decoded` stays a zeroed vector so a failed decode leaves defined bytes
|
||||
// rather than whatever the allocator handed back.
|
||||
VkDeviceSize source_bytes = 0;
|
||||
VkDeviceSize decoded_bytes = 0;
|
||||
std::vector<PendingUpload> uploads;
|
||||
// The application's mapped pointer for each upload, parallel to `uploads`.
|
||||
// Non-empty when the gather was deferred to the dispatcher, which Vulkan
|
||||
// allows: the application may not write these bytes between the submit and
|
||||
// the copy's completion. Unmapping or freeing that memory drains pending
|
||||
// decodes first, so the pointers stay dereferenceable.
|
||||
std::vector<const std::uint8_t*> deferred_sources;
|
||||
};
|
||||
|
||||
std::atomic<unsigned> g_image_logs{0};
|
||||
@@ -251,6 +329,7 @@ void LogFailure(const char* message) {
|
||||
|
||||
bool CreateStaging(const HostDevice& dev, VkDeviceSize size, Staging* staging,
|
||||
std::uint8_t** mapped) {
|
||||
*mapped = nullptr;
|
||||
if (dev.create_buffer == nullptr || dev.allocate_memory == nullptr ||
|
||||
dev.get_buffer_memory_requirements == nullptr ||
|
||||
dev.bind_buffer_memory == nullptr || dev.map_memory == nullptr ||
|
||||
@@ -300,12 +379,61 @@ bool CreateStaging(const HostDevice& dev, VkDeviceSize size, Staging* staging,
|
||||
dev.free_memory(dev.device, memory, nullptr);
|
||||
return false;
|
||||
}
|
||||
*staging = {dev.device, buffer, memory};
|
||||
*mapped = static_cast<std::uint8_t*>(data);
|
||||
*staging = {dev.device, buffer, memory, size, *mapped};
|
||||
return true;
|
||||
}
|
||||
|
||||
// Freeing mapped memory unmaps it implicitly.
|
||||
// Packs each upload's compressed bytes into `destination`, dropping the row
|
||||
// and layer padding the application may have asked for. Sources are parallel
|
||||
// to `uploads`.
|
||||
void GatherCompressed(const std::vector<PendingUpload>& uploads,
|
||||
const std::vector<const std::uint8_t*>& sources,
|
||||
std::uint8_t* destination) {
|
||||
VkDeviceSize offset = 0;
|
||||
for (std::size_t index = 0; index < uploads.size(); ++index) {
|
||||
const PendingUpload& upload = uploads[index];
|
||||
const std::uint8_t* source = sources[index];
|
||||
std::uint8_t* target = destination + offset;
|
||||
const VkDeviceSize compressed_layer = upload.compressed / upload.layers;
|
||||
if (upload.source_span == upload.compressed) {
|
||||
std::memcpy(target, source, upload.compressed);
|
||||
} else {
|
||||
const VkDeviceSize block_rows =
|
||||
(static_cast<VkDeviceSize>(upload.height) + 3) / 4;
|
||||
const VkDeviceSize packed_row = compressed_layer / block_rows;
|
||||
for (std::uint32_t layer = 0; layer < upload.layers; ++layer) {
|
||||
for (VkDeviceSize row = 0; row < block_rows; ++row) {
|
||||
std::memcpy(target + layer * compressed_layer + row * packed_row,
|
||||
source + layer * upload.source_layer_stride +
|
||||
row * upload.source_row_stride,
|
||||
packed_row);
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += upload.compressed;
|
||||
}
|
||||
}
|
||||
|
||||
// Makes the decode's host writes to staging visible to the transfer that
|
||||
// reads them. A queue submission performs the host-to-device domain operation
|
||||
// only for writes that happened before it, and an asynchronous decode writes
|
||||
// after the submit, so the dependency has to be recorded explicitly.
|
||||
void RecordHostWriteBarrier(const HostDevice& dev,
|
||||
VkCommandBuffer command_buffer) {
|
||||
if (dev.pipeline_barrier == nullptr) {
|
||||
return;
|
||||
}
|
||||
VkMemoryBarrier barrier{};
|
||||
barrier.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER;
|
||||
barrier.srcAccessMask = VK_ACCESS_HOST_WRITE_BIT;
|
||||
barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
|
||||
dev.pipeline_barrier(command_buffer, VK_PIPELINE_STAGE_HOST_BIT,
|
||||
VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &barrier, 0,
|
||||
nullptr, 0, nullptr);
|
||||
}
|
||||
|
||||
void DestroyStaging(const HostDevice& dev, const Staging& staging) {
|
||||
if (dev.destroy_buffer != nullptr) {
|
||||
dev.destroy_buffer(dev.device, staging.buffer, nullptr);
|
||||
@@ -408,6 +536,25 @@ bool PlanRegions(const ImageRecord& record, VkDevice device, VkBuffer source,
|
||||
upload.target_width = upload.width;
|
||||
upload.target_height = upload.height;
|
||||
upload.target_bytes = upload.decoded;
|
||||
const VkDeviceSize row_texels =
|
||||
region.bufferRowLength != 0 ? region.bufferRowLength : upload.width;
|
||||
const VkDeviceSize layer_rows =
|
||||
region.bufferImageHeight != 0 ? region.bufferImageHeight
|
||||
: upload.height;
|
||||
if (row_texels < upload.width || layer_rows < upload.height) {
|
||||
return false;
|
||||
}
|
||||
const VkDeviceSize block_bytes = EtcBlockBytes(record.format.etc_format);
|
||||
const VkDeviceSize block_rows =
|
||||
(static_cast<VkDeviceSize>(upload.height) + 3) / 4;
|
||||
const VkDeviceSize packed_row =
|
||||
((static_cast<VkDeviceSize>(upload.width) + 3) / 4) * block_bytes;
|
||||
upload.source_row_stride = ((row_texels + 3) / 4) * block_bytes;
|
||||
upload.source_layer_stride =
|
||||
upload.source_row_stride * ((layer_rows + 3) / 4);
|
||||
upload.source_span = (upload.layers - 1) * upload.source_layer_stride +
|
||||
(block_rows - 1) * upload.source_row_stride +
|
||||
packed_row;
|
||||
Region copy = region;
|
||||
copy.bufferOffset = *total;
|
||||
copy.bufferRowLength = 0;
|
||||
@@ -465,9 +612,11 @@ struct VulkanEtc2Emulation::State {
|
||||
std::unordered_map<VkDeviceMemory, Mapping> mappings;
|
||||
std::unordered_map<VkCommandBuffer, CommandRecord> commands;
|
||||
std::atomic<bool> has_commands{false};
|
||||
std::vector<Staging> idle_staging;
|
||||
VkDeviceSize idle_staging_bytes = 0;
|
||||
// Heap buffers reused across submits; they only grow.
|
||||
std::mutex scratch_mutex;
|
||||
std::vector<std::uint8_t> scratch_source;
|
||||
RawBuffer scratch_source;
|
||||
std::vector<std::uint8_t> scratch_decoded;
|
||||
TextureOverrides overrides = TextureOverrides::FromEnvironment();
|
||||
const std::uint32_t upscale =
|
||||
@@ -562,6 +711,109 @@ struct VulkanEtc2Emulation::State {
|
||||
}
|
||||
}
|
||||
|
||||
struct EmitJob {
|
||||
const PendingUpload* upload = nullptr;
|
||||
const std::uint8_t* compressed = nullptr;
|
||||
const std::uint8_t* decoded = nullptr;
|
||||
};
|
||||
|
||||
struct EmitQueue {
|
||||
State* state = nullptr;
|
||||
std::vector<EmitJob> jobs;
|
||||
std::atomic<std::size_t> next{0};
|
||||
|
||||
void Run() {
|
||||
for (;;) {
|
||||
const std::size_t index = next.fetch_add(1, std::memory_order_relaxed);
|
||||
if (index >= jobs.size()) {
|
||||
return;
|
||||
}
|
||||
const EmitJob& job = jobs[index];
|
||||
state->EmitUpload(*job.upload, job.compressed, job.decoded);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Resampling a batch costs sixteen times the decoded texels at the default
|
||||
// upscale, so uploads are emitted across the decode worker pool. They write
|
||||
// disjoint staging ranges, and level 0 is emitted before the rest because it
|
||||
// publishes the texels the other mips resample from.
|
||||
void EmitBatch(std::vector<EmitJob> jobs, unsigned worker_count) {
|
||||
if (jobs.empty()) {
|
||||
return;
|
||||
}
|
||||
std::vector<EmitJob> level_zero;
|
||||
std::vector<EmitJob> rest;
|
||||
for (const EmitJob& job : jobs) {
|
||||
(job.upload->mip_level == 0 ? level_zero : rest).push_back(job);
|
||||
}
|
||||
EmitPass(std::move(level_zero), worker_count);
|
||||
EmitPass(std::move(rest), worker_count);
|
||||
}
|
||||
|
||||
void EmitPass(std::vector<EmitJob> jobs, unsigned worker_count) {
|
||||
if (jobs.empty()) {
|
||||
return;
|
||||
}
|
||||
EmitQueue queue;
|
||||
queue.state = this;
|
||||
queue.jobs = std::move(jobs);
|
||||
const std::size_t threads =
|
||||
std::min<std::size_t>(std::max(worker_count, 1U), queue.jobs.size());
|
||||
RunOnDecodeWorkers(
|
||||
[](void* context) { static_cast<EmitQueue*>(context)->Run(); }, &queue,
|
||||
static_cast<unsigned>(threads));
|
||||
}
|
||||
|
||||
// Takes the smallest idle buffer that fits, or creates one.
|
||||
bool AcquireStaging(const HostDevice& dev, VkDeviceSize size,
|
||||
Staging* staging) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
const VkDeviceSize limit = std::max(size * 4, kStagingReuseSlack);
|
||||
auto best = idle_staging.end();
|
||||
for (auto it = idle_staging.begin(); it != idle_staging.end(); ++it) {
|
||||
if (it->device == dev.device && it->capacity >= size &&
|
||||
it->capacity <= limit &&
|
||||
(best == idle_staging.end() || it->capacity < best->capacity)) {
|
||||
best = it;
|
||||
}
|
||||
}
|
||||
if (best != idle_staging.end()) {
|
||||
*staging = *best;
|
||||
idle_staging_bytes -= best->capacity;
|
||||
*best = idle_staging.back();
|
||||
idle_staging.pop_back();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
std::uint8_t* mapped = nullptr;
|
||||
return CreateStaging(dev, size, staging, &mapped);
|
||||
}
|
||||
|
||||
// Keeps released buffers for reuse up to the idle limits and destroys
|
||||
// the rest.
|
||||
void ReleaseStaging(std::vector<Staging> released) {
|
||||
std::vector<Staging> doomed;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
for (const Staging& staging : released) {
|
||||
if (idle_staging.size() < kMaxIdleStagingBuffers &&
|
||||
idle_staging_bytes + staging.capacity <= kMaxIdleStagingBytes) {
|
||||
idle_staging.push_back(staging);
|
||||
idle_staging_bytes += staging.capacity;
|
||||
} else {
|
||||
doomed.push_back(staging);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const Staging& staging : doomed) {
|
||||
if (const HostDevice* dev = Find(staging.device); dev != nullptr) {
|
||||
DestroyStaging(*dev, staging);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HostDevice* Find(VkDevice device) {
|
||||
std::lock_guard<std::mutex> lock(devices_mutex);
|
||||
for (const auto& candidate : devices) {
|
||||
@@ -600,11 +852,203 @@ struct VulkanEtc2Emulation::State {
|
||||
record.staging.push_back(staging);
|
||||
has_commands.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
// Asynchronous decode. One dispatcher runs batches in the order tickets
|
||||
// were handed out, so the timeline semaphore is always signalled with an
|
||||
// increasing value and a waiter never observes a later batch's signal.
|
||||
std::mutex queue_mutex;
|
||||
std::condition_variable queue_ready;
|
||||
std::condition_variable queue_done;
|
||||
std::deque<std::unique_ptr<DecodeBatch>> queue;
|
||||
std::vector<std::unique_ptr<DecodeBatch>> spare_batches;
|
||||
std::uint64_t issued_ticket = 0;
|
||||
std::uint64_t completed_ticket = 0;
|
||||
bool stopping = false;
|
||||
std::thread dispatcher;
|
||||
|
||||
// Recycles a batch's buffers; they only grow, as the scratch buffers do.
|
||||
std::unique_ptr<DecodeBatch> AcquireBatch() {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex);
|
||||
if (spare_batches.empty()) {
|
||||
return std::make_unique<DecodeBatch>();
|
||||
}
|
||||
std::unique_ptr<DecodeBatch> batch = std::move(spare_batches.back());
|
||||
spare_batches.pop_back();
|
||||
batch->uploads.clear();
|
||||
return batch;
|
||||
}
|
||||
|
||||
void StartDispatcher() {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex);
|
||||
if (dispatcher.joinable() || stopping) {
|
||||
return;
|
||||
}
|
||||
dispatcher = std::thread([this] { RunDispatcher(); });
|
||||
}
|
||||
|
||||
// Hands out the next ticket and queues the batch behind it.
|
||||
std::uint64_t Enqueue(std::unique_ptr<DecodeBatch> batch) {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex);
|
||||
batch->ticket = ++issued_ticket;
|
||||
const std::uint64_t ticket = batch->ticket;
|
||||
queue.push_back(std::move(batch));
|
||||
queue_ready.notify_one();
|
||||
return ticket;
|
||||
}
|
||||
|
||||
void RunDispatcher() {
|
||||
for (;;) {
|
||||
std::unique_ptr<DecodeBatch> batch;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(queue_mutex);
|
||||
queue_ready.wait(lock, [this] { return stopping || !queue.empty(); });
|
||||
if (queue.empty()) {
|
||||
if (stopping) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
batch = std::move(queue.front());
|
||||
queue.pop_front();
|
||||
}
|
||||
RunBatch(*batch);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex);
|
||||
completed_ticket = batch->ticket;
|
||||
if (spare_batches.size() < kMaxSpareBatches) {
|
||||
spare_batches.push_back(std::move(batch));
|
||||
}
|
||||
queue_done.notify_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RunBatch(DecodeBatch& batch) {
|
||||
TraceScope trace_scope(ActiveProfileTrace(), "etc2 decode", "texture");
|
||||
trace_scope.Arg("uploads", static_cast<std::int64_t>(batch.uploads.size()));
|
||||
trace_scope.Arg("compressed_bytes",
|
||||
static_cast<std::int64_t>(batch.source_bytes));
|
||||
trace_scope.Arg("decoded_bytes",
|
||||
static_cast<std::int64_t>(batch.decoded_bytes));
|
||||
batch.source.EnsureSize(batch.source_bytes);
|
||||
if (batch.decoded.size() < batch.decoded_bytes) {
|
||||
batch.decoded.resize(batch.decoded_bytes);
|
||||
}
|
||||
if (!batch.deferred_sources.empty()) {
|
||||
GatherCompressed(batch.uploads, batch.deferred_sources,
|
||||
batch.source.data());
|
||||
batch.deferred_sources.clear();
|
||||
}
|
||||
DecodeAndEmit(batch.uploads, batch.source.data(), batch.decoded.data());
|
||||
SignalDecoded(batch.device, batch.ticket);
|
||||
}
|
||||
|
||||
void SignalDecoded(VkDevice device, std::uint64_t value) {
|
||||
const HostDevice* dev = Find(device);
|
||||
if (dev == nullptr || dev->timeline == VK_NULL_HANDLE ||
|
||||
dev->signal_semaphore == nullptr) {
|
||||
return;
|
||||
}
|
||||
VkSemaphoreSignalInfo info{};
|
||||
info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_SIGNAL_INFO;
|
||||
info.semaphore = dev->timeline;
|
||||
info.value = value;
|
||||
if (dev->signal_semaphore(dev->device, &info) != VK_SUCCESS) {
|
||||
LogFailure("could not signal the decode timeline semaphore");
|
||||
}
|
||||
}
|
||||
|
||||
// Decodes every layer of every upload and writes the host texels. Shared by
|
||||
// the dispatcher and by the synchronous path.
|
||||
void DecodeAndEmit(const std::vector<PendingUpload>& uploads,
|
||||
std::uint8_t* source_base, std::uint8_t* decoded_base) {
|
||||
std::vector<EtcDecodeJob> jobs;
|
||||
VkDeviceSize compressed_offset = 0;
|
||||
VkDeviceSize decoded_offset = 0;
|
||||
for (const PendingUpload& upload : uploads) {
|
||||
std::uint8_t* source = source_base + compressed_offset;
|
||||
std::uint8_t* decoded = decoded_base + decoded_offset;
|
||||
const VkDeviceSize compressed_layer = upload.compressed / upload.layers;
|
||||
const VkDeviceSize decoded_layer = upload.decoded / upload.layers;
|
||||
for (std::uint32_t layer = 0; layer < upload.layers; ++layer) {
|
||||
EtcDecodeJob job;
|
||||
job.format = upload.format;
|
||||
job.source = source + layer * compressed_layer;
|
||||
job.source_bytes = compressed_layer;
|
||||
job.width = upload.width;
|
||||
job.height = upload.height;
|
||||
job.destination = decoded + layer * decoded_layer;
|
||||
job.destination_bytes = decoded_layer;
|
||||
jobs.push_back(job);
|
||||
}
|
||||
compressed_offset += upload.compressed;
|
||||
decoded_offset += upload.decoded;
|
||||
if (ShouldLog(&g_decode_logs, 4)) {
|
||||
std::fprintf(stderr, " [vulkan] ETC2 upload decoded %ux%u layers=%u\n",
|
||||
upload.width, upload.height, upload.layers);
|
||||
}
|
||||
}
|
||||
DecodeEtcJobs(jobs.data(), jobs.size(), DecodeWorkerCount());
|
||||
for (const EtcDecodeJob& job : jobs) {
|
||||
if (!job.ok) {
|
||||
LogFailure("could not decode an upload layer");
|
||||
}
|
||||
}
|
||||
compressed_offset = 0;
|
||||
decoded_offset = 0;
|
||||
std::vector<EmitJob> emit_jobs;
|
||||
emit_jobs.reserve(uploads.size());
|
||||
for (const PendingUpload& upload : uploads) {
|
||||
emit_jobs.push_back({&upload, source_base + compressed_offset,
|
||||
decoded_base + decoded_offset});
|
||||
compressed_offset += upload.compressed;
|
||||
decoded_offset += upload.decoded;
|
||||
}
|
||||
EmitBatch(std::move(emit_jobs), DecodeWorkerCount());
|
||||
}
|
||||
|
||||
// Blocks until every batch up to `ticket` has been decoded and emitted.
|
||||
void WaitForTicket(std::uint64_t ticket) {
|
||||
if (ticket == 0) {
|
||||
return;
|
||||
}
|
||||
std::unique_lock<std::mutex> lock(queue_mutex);
|
||||
queue_done.wait(lock, [this, ticket] {
|
||||
return completed_ticket >= ticket || stopping;
|
||||
});
|
||||
}
|
||||
|
||||
// Blocks until nothing is queued or in flight.
|
||||
void DrainDecodes() {
|
||||
std::uint64_t ticket = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex);
|
||||
ticket = issued_ticket;
|
||||
}
|
||||
WaitForTicket(ticket);
|
||||
}
|
||||
|
||||
void StopDispatcher() {
|
||||
std::thread worker;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex);
|
||||
stopping = true;
|
||||
queue_ready.notify_all();
|
||||
queue_done.notify_all();
|
||||
worker = std::move(dispatcher);
|
||||
}
|
||||
if (worker.joinable()) {
|
||||
worker.join();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
VulkanEtc2Emulation::VulkanEtc2Emulation() : state_(new State) {}
|
||||
|
||||
VulkanEtc2Emulation::~VulkanEtc2Emulation() { delete state_; }
|
||||
VulkanEtc2Emulation::~VulkanEtc2Emulation() {
|
||||
state_->StopDispatcher();
|
||||
delete state_;
|
||||
}
|
||||
|
||||
bool VulkanEtc2Emulation::PhysicalDeviceNeedsEmulation(
|
||||
VkPhysicalDevice physical_device, PFN_vkGetPhysicalDeviceFeatures host) {
|
||||
@@ -630,7 +1074,7 @@ bool VulkanEtc2Emulation::PhysicalDeviceNeedsEmulation(
|
||||
void VulkanEtc2Emulation::RegisterDevice(
|
||||
VkDevice device, VkPhysicalDevice physical_device, bool emulated,
|
||||
const VkPhysicalDeviceMemoryProperties& memory,
|
||||
PFN_vkGetDeviceProcAddr get_device_proc_addr) {
|
||||
PFN_vkGetDeviceProcAddr get_device_proc_addr, bool timeline_semaphore) {
|
||||
static_cast<void>(physical_device);
|
||||
if (device == VK_NULL_HANDLE || get_device_proc_addr == nullptr) {
|
||||
return;
|
||||
@@ -676,6 +1120,31 @@ void VulkanEtc2Emulation::RegisterDevice(
|
||||
dev->get_buffer_memory_requirements =
|
||||
DeviceProc<PFN_vkGetBufferMemoryRequirements>(
|
||||
get, device, "vkGetBufferMemoryRequirements");
|
||||
dev->pipeline_barrier =
|
||||
DeviceProc<PFN_vkCmdPipelineBarrier>(get, device, "vkCmdPipelineBarrier");
|
||||
dev->signal_semaphore = DeviceProc<PFN_vkSignalSemaphore>(
|
||||
get, device, "vkSignalSemaphore", "vkSignalSemaphoreKHR");
|
||||
dev->destroy_semaphore =
|
||||
DeviceProc<PFN_vkDestroySemaphore>(get, device, "vkDestroySemaphore");
|
||||
if (emulated && timeline_semaphore && dev->signal_semaphore != nullptr) {
|
||||
const auto create_semaphore =
|
||||
DeviceProc<PFN_vkCreateSemaphore>(get, device, "vkCreateSemaphore");
|
||||
VkSemaphoreTypeCreateInfo type{};
|
||||
type.sType = VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO;
|
||||
type.semaphoreType = VK_SEMAPHORE_TYPE_TIMELINE;
|
||||
type.initialValue = 0;
|
||||
VkSemaphoreCreateInfo info{};
|
||||
info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
|
||||
info.pNext = &type;
|
||||
if (create_semaphore != nullptr &&
|
||||
create_semaphore(device, &info, nullptr, &dev->timeline) !=
|
||||
VK_SUCCESS) {
|
||||
dev->timeline = VK_NULL_HANDLE;
|
||||
}
|
||||
if (dev->timeline != VK_NULL_HANDLE) {
|
||||
state_->StartDispatcher();
|
||||
}
|
||||
}
|
||||
if (emulated) {
|
||||
std::fprintf(stderr,
|
||||
" [vulkan] ETC2/EAC emulation enabled: compressed uploads "
|
||||
@@ -692,6 +1161,8 @@ void VulkanEtc2Emulation::RegisterDevice(
|
||||
}
|
||||
|
||||
void VulkanEtc2Emulation::DestroyDevice(VkDevice device) {
|
||||
// Nothing may free staging or the semaphore while a decode still writes it.
|
||||
state_->DrainDecodes();
|
||||
std::vector<Staging> doomed;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
@@ -707,6 +1178,16 @@ void VulkanEtc2Emulation::DestroyDevice(VkDevice device) {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
for (auto it = state_->idle_staging.begin();
|
||||
it != state_->idle_staging.end();) {
|
||||
if (it->device == device) {
|
||||
state_->idle_staging_bytes -= it->capacity;
|
||||
doomed.push_back(*it);
|
||||
it = state_->idle_staging.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
for (auto it = state_->images.begin(); it != state_->images.end();) {
|
||||
it = it->second.device == device ? state_->images.erase(it) : std::next(it);
|
||||
}
|
||||
@@ -717,6 +1198,9 @@ void VulkanEtc2Emulation::DestroyDevice(VkDevice device) {
|
||||
for (const Staging& staging : doomed) {
|
||||
DestroyStaging(*dev, staging);
|
||||
}
|
||||
if (dev->timeline != VK_NULL_HANDLE && dev->destroy_semaphore != nullptr) {
|
||||
dev->destroy_semaphore(device, dev->timeline, nullptr);
|
||||
}
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(state_->devices_mutex);
|
||||
state_->devices.erase(
|
||||
@@ -880,6 +1364,9 @@ void VulkanEtc2Emulation::UnmapMemory(VkDevice device, VkDeviceMemory memory) {
|
||||
return;
|
||||
}
|
||||
if (dev->emulated) {
|
||||
// A deferred gather reads this mapping on a worker, so it must finish
|
||||
// before the pointer stops being valid.
|
||||
state_->DrainDecodes();
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
state_->mappings.erase(memory);
|
||||
}
|
||||
@@ -893,6 +1380,7 @@ VkResult VulkanEtc2Emulation::UnmapMemory2(VkDevice device,
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
if (dev->emulated && info != nullptr) {
|
||||
state_->DrainDecodes();
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
state_->mappings.erase(info->memory);
|
||||
}
|
||||
@@ -906,6 +1394,7 @@ void VulkanEtc2Emulation::FreeMemory(VkDevice device, VkDeviceMemory memory,
|
||||
return;
|
||||
}
|
||||
if (dev->emulated) {
|
||||
state_->DrainDecodes();
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
state_->mappings.erase(memory);
|
||||
}
|
||||
@@ -943,20 +1432,20 @@ void VulkanEtc2Emulation::CmdCopyBufferToImage(
|
||||
std::vector<PendingUpload> uploads;
|
||||
VkDeviceSize total = 0;
|
||||
Staging staging;
|
||||
std::uint8_t* mapped = nullptr;
|
||||
if (!PlanRegions(record, device, source, destination, region_count, regions,
|
||||
&rewritten, &uploads, &total)) {
|
||||
LogFailure("skipped an upload with an unsupported region");
|
||||
return;
|
||||
}
|
||||
if (!CreateStaging(*dev, total, &staging, &mapped)) {
|
||||
if (!state_->AcquireStaging(*dev, total, &staging)) {
|
||||
LogFailure("could not allocate a host-visible staging buffer");
|
||||
return;
|
||||
}
|
||||
for (PendingUpload& upload : uploads) {
|
||||
upload.target = mapped + upload.target_offset;
|
||||
upload.target = staging.mapped + upload.target_offset;
|
||||
}
|
||||
state_->Record(command_buffer, std::move(uploads), staging);
|
||||
RecordHostWriteBarrier(*dev, command_buffer);
|
||||
dev->copy_buffer_to_image(command_buffer, staging.buffer, destination,
|
||||
layout, region_count, rewritten.data());
|
||||
}
|
||||
@@ -978,21 +1467,21 @@ void VulkanEtc2Emulation::CmdCopyBufferToImage2(
|
||||
std::vector<PendingUpload> uploads;
|
||||
VkDeviceSize total = 0;
|
||||
Staging staging;
|
||||
std::uint8_t* mapped = nullptr;
|
||||
if (!PlanRegions(record, device, info->srcBuffer, info->dstImage,
|
||||
info->regionCount, info->pRegions, &rewritten, &uploads,
|
||||
&total)) {
|
||||
LogFailure("skipped an upload with an unsupported region");
|
||||
return;
|
||||
}
|
||||
if (!CreateStaging(*dev, total, &staging, &mapped)) {
|
||||
if (!state_->AcquireStaging(*dev, total, &staging)) {
|
||||
LogFailure("could not allocate a host-visible staging buffer");
|
||||
return;
|
||||
}
|
||||
for (PendingUpload& upload : uploads) {
|
||||
upload.target = mapped + upload.target_offset;
|
||||
upload.target = staging.mapped + upload.target_offset;
|
||||
}
|
||||
state_->Record(command_buffer, std::move(uploads), staging);
|
||||
RecordHostWriteBarrier(*dev, command_buffer);
|
||||
VkCopyBufferToImageInfo2 host_info = *info;
|
||||
host_info.srcBuffer = staging.buffer;
|
||||
host_info.pRegions = rewritten.data();
|
||||
@@ -1108,11 +1597,12 @@ void VulkanEtc2Emulation::CmdExecuteCommands(
|
||||
dev->execute_commands(command_buffer, count, secondaries);
|
||||
}
|
||||
|
||||
void VulkanEtc2Emulation::PrepareSubmit(const VkCommandBuffer* command_buffers,
|
||||
std::uint32_t count) {
|
||||
Etc2SubmitWait VulkanEtc2Emulation::PrepareSubmit(
|
||||
const VkCommandBuffer* command_buffers, std::uint32_t count,
|
||||
bool allow_async) {
|
||||
if (command_buffers == nullptr || count == 0 ||
|
||||
!state_->has_commands.load(std::memory_order_acquire)) {
|
||||
return;
|
||||
return {};
|
||||
}
|
||||
struct Work {
|
||||
PendingUpload upload;
|
||||
@@ -1146,9 +1636,9 @@ void VulkanEtc2Emulation::PrepareSubmit(const VkCommandBuffer* command_buffers,
|
||||
(mapping->second.size == VK_WHOLE_SIZE ||
|
||||
(absolute - mapping->second.offset <=
|
||||
mapping->second.size &&
|
||||
upload.compressed <= mapping->second.size -
|
||||
(absolute -
|
||||
mapping->second.offset)))) {
|
||||
upload.source_span <= mapping->second.size -
|
||||
(absolute -
|
||||
mapping->second.offset)))) {
|
||||
item.source = static_cast<std::uint8_t*>(mapping->second.data) +
|
||||
(absolute - mapping->second.offset);
|
||||
} else {
|
||||
@@ -1170,6 +1660,26 @@ void VulkanEtc2Emulation::PrepareSubmit(const VkCommandBuffer* command_buffers,
|
||||
}
|
||||
}
|
||||
}
|
||||
if (work.empty()) {
|
||||
return {};
|
||||
}
|
||||
// A submit carries one device, but a batch that somehow spans two cannot be
|
||||
// signalled by a single timeline, so it decodes on this thread instead.
|
||||
VkDevice batch_device = work.front().upload.device;
|
||||
bool single_device = true;
|
||||
for (const Work& item : work) {
|
||||
if (item.upload.device != batch_device) {
|
||||
single_device = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const HostDevice* batch_dev = state_->Find(batch_device);
|
||||
const bool async = allow_async && single_device && batch_dev != nullptr &&
|
||||
batch_dev->timeline != VK_NULL_HANDLE &&
|
||||
batch_dev->signal_semaphore != nullptr;
|
||||
|
||||
TraceScope trace_scope(ActiveProfileTrace(),
|
||||
async ? "etc2 gather" : "etc2 decode", "texture");
|
||||
// Mapped Vulkan memory is uncached or write-combined, which makes the
|
||||
// decoder's scattered byte reads and writes tens of times slower than on
|
||||
// heap memory. Compressed bytes are streamed into a heap scratch buffer,
|
||||
@@ -1217,71 +1727,115 @@ void VulkanEtc2Emulation::PrepareSubmit(const VkCommandBuffer* command_buffers,
|
||||
compressed_total += upload.compressed;
|
||||
decoded_total += upload.decoded;
|
||||
}
|
||||
trace_scope.Arg("uploads", static_cast<std::int64_t>(copies.size()));
|
||||
trace_scope.Arg("compressed_bytes",
|
||||
static_cast<std::int64_t>(compressed_total));
|
||||
trace_scope.Arg("decoded_bytes", static_cast<std::int64_t>(decoded_total));
|
||||
|
||||
std::lock_guard<std::mutex> scratch_lock(state_->scratch_mutex);
|
||||
std::vector<std::uint8_t>& scratch_source = state_->scratch_source;
|
||||
std::vector<std::uint8_t>& scratch_decoded = state_->scratch_decoded;
|
||||
if (scratch_source.size() < compressed_total) {
|
||||
scratch_source.resize(compressed_total);
|
||||
std::unique_ptr<DecodeBatch> batch;
|
||||
if (async) {
|
||||
batch = state_->AcquireBatch();
|
||||
batch->device = batch_device;
|
||||
}
|
||||
if (scratch_decoded.size() < decoded_total) {
|
||||
scratch_decoded.resize(decoded_total);
|
||||
}
|
||||
std::vector<EtcDecodeJob> jobs;
|
||||
VkDeviceSize compressed_offset = 0;
|
||||
VkDeviceSize decoded_offset = 0;
|
||||
for (std::size_t index = 0; index < copies.size(); ++index) {
|
||||
const Copy& copy = copies[index];
|
||||
const PendingUpload& upload = *copied_uploads[index];
|
||||
std::uint8_t* source = scratch_source.data() + compressed_offset;
|
||||
std::uint8_t* decoded = scratch_decoded.data() + decoded_offset;
|
||||
std::memcpy(source, copy.source, copy.compressed);
|
||||
const VkDeviceSize compressed_layer = upload.compressed / upload.layers;
|
||||
const VkDeviceSize decoded_layer = upload.decoded / upload.layers;
|
||||
for (std::uint32_t layer = 0; layer < upload.layers; ++layer) {
|
||||
EtcDecodeJob job;
|
||||
job.format = upload.format;
|
||||
job.source = source + layer * compressed_layer;
|
||||
job.source_bytes = compressed_layer;
|
||||
job.width = upload.width;
|
||||
job.height = upload.height;
|
||||
job.destination = decoded + layer * decoded_layer;
|
||||
job.destination_bytes = decoded_layer;
|
||||
jobs.push_back(job);
|
||||
}
|
||||
compressed_offset += copy.compressed;
|
||||
decoded_offset += copy.decoded;
|
||||
if (ShouldLog(&g_decode_logs, 4)) {
|
||||
std::fprintf(stderr,
|
||||
" [vulkan] ETC2 upload decoded %ux%u layers=%u\n",
|
||||
upload.width, upload.height, upload.layers);
|
||||
}
|
||||
}
|
||||
for (const auto& [memory, mapping] : temporary) {
|
||||
if (mapping.dev->unmap_memory != nullptr) {
|
||||
mapping.dev->unmap_memory(mapping.dev->device, memory);
|
||||
}
|
||||
}
|
||||
DecodeEtcJobs(jobs.data(), jobs.size(), DecodeWorkerCount());
|
||||
for (const EtcDecodeJob& job : jobs) {
|
||||
if (!job.ok) {
|
||||
LogFailure("could not decode an upload layer");
|
||||
}
|
||||
}
|
||||
compressed_offset = 0;
|
||||
decoded_offset = 0;
|
||||
// Owned copies, so the decode never reads the caller's stack.
|
||||
std::vector<PendingUpload> uploads;
|
||||
uploads.reserve(copied_uploads.size());
|
||||
for (const PendingUpload* upload : copied_uploads) {
|
||||
state_->EmitUpload(*upload, scratch_source.data() + compressed_offset,
|
||||
scratch_decoded.data() + decoded_offset);
|
||||
compressed_offset += upload->compressed;
|
||||
decoded_offset += upload->decoded;
|
||||
uploads.push_back(*upload);
|
||||
}
|
||||
std::vector<const std::uint8_t*> sources;
|
||||
sources.reserve(copies.size());
|
||||
for (const Copy& copy : copies) {
|
||||
sources.push_back(copy.source);
|
||||
}
|
||||
|
||||
// Reading the application's staging means reading write-combined memory,
|
||||
// which is slow enough to show in a frame. When nothing had to be mapped
|
||||
// for this batch, the application's own mapping stays valid until the copy
|
||||
// completes, so the gather moves to the dispatcher with the decode and the
|
||||
// submitting thread keeps only the bookkeeping.
|
||||
const bool defer_gather = async && temporary.empty();
|
||||
|
||||
if (!async) {
|
||||
std::lock_guard<std::mutex> scratch_lock(state_->scratch_mutex);
|
||||
state_->scratch_source.EnsureSize(compressed_total);
|
||||
if (state_->scratch_decoded.size() < decoded_total) {
|
||||
state_->scratch_decoded.resize(decoded_total);
|
||||
}
|
||||
GatherCompressed(uploads, sources, state_->scratch_source.data());
|
||||
for (const auto& [memory, mapping] : temporary) {
|
||||
if (mapping.dev->unmap_memory != nullptr) {
|
||||
mapping.dev->unmap_memory(mapping.dev->device, memory);
|
||||
}
|
||||
}
|
||||
state_->DecodeAndEmit(uploads, state_->scratch_source.data(),
|
||||
state_->scratch_decoded.data());
|
||||
return {};
|
||||
}
|
||||
|
||||
batch->source_bytes = compressed_total;
|
||||
batch->decoded_bytes = decoded_total;
|
||||
if (defer_gather) {
|
||||
batch->deferred_sources = std::move(sources);
|
||||
} else {
|
||||
batch->source.EnsureSize(compressed_total);
|
||||
GatherCompressed(uploads, sources, batch->source.data());
|
||||
for (const auto& [memory, mapping] : temporary) {
|
||||
if (mapping.dev->unmap_memory != nullptr) {
|
||||
mapping.dev->unmap_memory(mapping.dev->device, memory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
batch->uploads = std::move(uploads);
|
||||
// The staging these uploads write into must outlive the decode, so the
|
||||
// command buffers that own it remember the ticket. The batch is queued
|
||||
// while the state lock is held, so a concurrent ReleaseCommandBuffer cannot
|
||||
// read a stale ticket and recycle staging the dispatcher is still writing.
|
||||
// Tickets only ever go up, so a record keeps the highest one it has seen.
|
||||
std::uint64_t ticket = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
ticket = state_->Enqueue(std::move(batch));
|
||||
const auto remember = [this, ticket](VkCommandBuffer command_buffer) {
|
||||
const auto record = state_->commands.find(command_buffer);
|
||||
if (record != state_->commands.end()) {
|
||||
record->second.last_ticket =
|
||||
std::max(record->second.last_ticket, ticket);
|
||||
}
|
||||
};
|
||||
for (std::uint32_t index = 0; index < count; ++index) {
|
||||
const auto record = state_->commands.find(command_buffers[index]);
|
||||
if (record == state_->commands.end()) {
|
||||
continue;
|
||||
}
|
||||
record->second.last_ticket =
|
||||
std::max(record->second.last_ticket, ticket);
|
||||
for (VkCommandBuffer secondary : record->second.secondaries) {
|
||||
remember(secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
trace_scope.Arg("ticket", static_cast<std::int64_t>(ticket));
|
||||
return {batch_dev->timeline, ticket};
|
||||
}
|
||||
|
||||
void VulkanEtc2Emulation::ReleaseCommandBuffer(VkCommandBuffer command_buffer) {
|
||||
if (!state_->has_commands.load(std::memory_order_acquire)) {
|
||||
return;
|
||||
}
|
||||
std::uint64_t ticket = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
const auto record = state_->commands.find(command_buffer);
|
||||
if (record == state_->commands.end()) {
|
||||
return;
|
||||
}
|
||||
ticket = record->second.last_ticket;
|
||||
}
|
||||
// Waited on without holding the state lock, which the decode itself takes.
|
||||
state_->WaitForTicket(ticket);
|
||||
|
||||
std::vector<Staging> doomed;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
@@ -1294,11 +1848,7 @@ void VulkanEtc2Emulation::ReleaseCommandBuffer(VkCommandBuffer command_buffer) {
|
||||
state_->has_commands.store(!state_->commands.empty(),
|
||||
std::memory_order_release);
|
||||
}
|
||||
for (const Staging& staging : doomed) {
|
||||
if (const HostDevice* dev = state_->Find(staging.device); dev != nullptr) {
|
||||
DestroyStaging(*dev, staging);
|
||||
}
|
||||
}
|
||||
state_->ReleaseStaging(std::move(doomed));
|
||||
}
|
||||
|
||||
} // namespace mocktail::graphics
|
||||
+114
-36
@@ -62,7 +62,10 @@ thread_local JNIEnv* g_thread_local_env = nullptr;
|
||||
thread_local JNIEnv g_thread_env_storage = {};
|
||||
thread_local VM* g_thread_vm_instance = nullptr;
|
||||
thread_local mocktail::audio::FmodThreadFloatingPointMode g_thread_audio_fp_mode;
|
||||
thread_local std::vector<std::vector<jobject>> g_local_frames;
|
||||
// Each frame counts how many local references it holds per handle, so
|
||||
// DeleteLocalRef costs one hash lookup per open frame.
|
||||
using LocalFrame = std::unordered_map<jobject, uint32_t>;
|
||||
thread_local std::vector<LocalFrame> g_local_frames;
|
||||
|
||||
std::recursive_mutex g_jni_state_mutex;
|
||||
// Authentication preflight can briefly own a second VM. Keep every live
|
||||
@@ -289,6 +292,10 @@ constexpr uint32_t kJniHandleShift = 16;
|
||||
std::shared_ptr<void> g_segment_owners[kJniSegmentCapacity];
|
||||
std::unordered_set<jclass> g_known_classes;
|
||||
std::unordered_map<std::string, jobject> g_singleton_objects;
|
||||
std::unordered_set<jobject> g_singleton_handles;
|
||||
// One handle per Class. Class slots are never freed, so the table holds at
|
||||
// most one slot per distinct class.
|
||||
std::unordered_map<const Class*, jclass> g_class_handles;
|
||||
std::unordered_map<std::string, std::shared_ptr<Class>> g_fallback_classes;
|
||||
std::unordered_set<jstring> g_known_strings;
|
||||
std::list<std::string> g_method_name_storage;
|
||||
@@ -339,7 +346,7 @@ void RegisterLocalRef(jobject obj) {
|
||||
return;
|
||||
}
|
||||
EnsureLocalFrame();
|
||||
g_local_frames.back().push_back(obj);
|
||||
++g_local_frames.back()[obj];
|
||||
}
|
||||
|
||||
void UnregisterLocalRef(jobject obj) {
|
||||
@@ -348,10 +355,27 @@ void UnregisterLocalRef(jobject obj) {
|
||||
}
|
||||
for (auto frame = g_local_frames.rbegin(); frame != g_local_frames.rend();
|
||||
++frame) {
|
||||
for (auto it = frame->rbegin(); it != frame->rend(); ++it) {
|
||||
if (*it == obj) {
|
||||
frame->erase(std::next(it).base());
|
||||
return;
|
||||
auto it = frame->find(obj);
|
||||
if (it != frame->end()) {
|
||||
if (--it->second == 0) {
|
||||
frame->erase(it);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReleaseJniReference(jobject obj);
|
||||
|
||||
// A detached thread can no longer use its local references, so they go the
|
||||
// way ART drops them on detach.
|
||||
void ReleaseThreadLocalRefs() {
|
||||
std::vector<LocalFrame> frames = std::move(g_local_frames);
|
||||
g_local_frames.clear();
|
||||
for (const LocalFrame& frame : frames) {
|
||||
for (const auto& [obj, count] : frame) {
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
ReleaseJniReference(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,6 +394,8 @@ void RetainJniReference(jobject obj) {
|
||||
}
|
||||
}
|
||||
|
||||
void ReportHandleTableFull();
|
||||
|
||||
int AllocateSegmentSlot(void* value, std::shared_ptr<void> owner = nullptr,
|
||||
SegmentType type = SegmentType::kObject) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
@@ -381,6 +407,7 @@ int AllocateSegmentSlot(void* value, std::shared_ptr<void> owner = nullptr,
|
||||
static_cast<uint32_t>(g_jni_ref_index) < kJniSegmentCapacity) {
|
||||
index = g_jni_ref_index++;
|
||||
} else {
|
||||
ReportHandleTableFull();
|
||||
return 0;
|
||||
}
|
||||
my_segment[index] = value;
|
||||
@@ -739,6 +766,13 @@ std::shared_ptr<Class> ClassFromJClass(jclass clazz) {
|
||||
static jclass StoreClass(std::shared_ptr<Class> cls) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
Class* raw_ptr = cls.get();
|
||||
if (raw_ptr == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
auto cached = g_class_handles.find(raw_ptr);
|
||||
if (cached != g_class_handles.end()) {
|
||||
return cached->second;
|
||||
}
|
||||
int index = AllocateSegmentSlot(raw_ptr, cls, SegmentType::kClass);
|
||||
if (index <= 0) {
|
||||
return nullptr;
|
||||
@@ -746,6 +780,7 @@ static jclass StoreClass(std::shared_ptr<Class> cls) {
|
||||
jclass handle =
|
||||
reinterpret_cast<jclass>(JniHandleFromIndex(static_cast<uint32_t>(index)));
|
||||
g_known_classes.insert(handle);
|
||||
g_class_handles.emplace(raw_ptr, handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
@@ -790,10 +825,8 @@ void ReleaseJniReference(jobject obj) {
|
||||
g_known_classes.end()) {
|
||||
return;
|
||||
}
|
||||
for (const auto& pair : g_singleton_objects) {
|
||||
if (pair.second == obj) {
|
||||
return;
|
||||
}
|
||||
if (g_singleton_handles.count(obj) != 0) {
|
||||
return;
|
||||
}
|
||||
const uint32_t index = JniIndexFromHandle(obj);
|
||||
if (index > 0 && index < kJniSegmentCapacity) {
|
||||
@@ -850,6 +883,7 @@ jobject SingletonObject(const std::string& class_name) {
|
||||
}
|
||||
jobject object = MakeObjectForClass(class_name);
|
||||
g_singleton_objects[class_name] = object;
|
||||
g_singleton_handles.insert(object);
|
||||
return object;
|
||||
}
|
||||
|
||||
@@ -861,6 +895,38 @@ std::string_view ObjectClassName(jobject obj) {
|
||||
return pseudo_object->GetClass()->GetName();
|
||||
}
|
||||
|
||||
// Logs once per process which classes hold the live handles, so the leak
|
||||
// behind a later null FindClass or NewStringUTF can be named.
|
||||
void ReportHandleTableFull() {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
static bool reported = false;
|
||||
if (reported) {
|
||||
return;
|
||||
}
|
||||
reported = true;
|
||||
std::unordered_map<std::string_view, std::size_t> live_by_class;
|
||||
for (const auto& entry : g_object_storage) {
|
||||
std::string_view name = ObjectClassName(entry.first);
|
||||
++live_by_class[name.empty() ? std::string_view("<unknown>") : name];
|
||||
}
|
||||
std::vector<std::pair<std::string_view, std::size_t>> ranked(
|
||||
live_by_class.begin(), live_by_class.end());
|
||||
const std::size_t shown = std::min<std::size_t>(ranked.size(), 10);
|
||||
std::partial_sort(ranked.begin(), ranked.begin() + shown, ranked.end(),
|
||||
[](const auto& a, const auto& b) {
|
||||
return a.second > b.second;
|
||||
});
|
||||
fprintf(stderr,
|
||||
"[JNI] handle table full (%u slots): %zu objects, %zu classes, "
|
||||
"%zu arrays live; new objects return null\n",
|
||||
kJniSegmentCapacity, g_object_storage.size(),
|
||||
g_class_handles.size(), g_array_storage.size());
|
||||
for (std::size_t i = 0; i < shown; ++i) {
|
||||
fprintf(stderr, "[JNI] %zu x %.*s\n", ranked[i].second,
|
||||
static_cast<int>(ranked[i].first.size()), ranked[i].first.data());
|
||||
}
|
||||
}
|
||||
|
||||
jobject ExactMessageBusStaticObject(jclass clazz, jmethodID method_id) {
|
||||
const std::shared_ptr<Class> object_class = ClassFromJClass(clazz);
|
||||
if (object_class == nullptr ||
|
||||
@@ -881,6 +947,8 @@ jobject EngineJavaCallbackObject() {
|
||||
}
|
||||
g_engine_java_callback =
|
||||
MakeObjectForClass("com/roblox/engine/jni/EngineJavaCallback2");
|
||||
// VM-owned like a singleton, so detaching the creating thread keeps it.
|
||||
g_singleton_handles.insert(g_engine_java_callback);
|
||||
g_static_object_fields["sImplementation"] = g_engine_java_callback;
|
||||
return g_engine_java_callback;
|
||||
}
|
||||
@@ -1149,7 +1217,7 @@ static inline bool IsRawStringPointer(jstring str) {
|
||||
if (val < 0x10000) {
|
||||
return false;
|
||||
}
|
||||
if (val < 0x10000000ULL && (val & 0xffff) == 0) {
|
||||
if ((val & 0xffff) == 0 && (val >> kJniHandleShift) < kJniSegmentCapacity) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -2230,10 +2298,9 @@ bool HandleRobloxCookieSetVoidMethodA(jobject obj, jmethodID method_id,
|
||||
|
||||
jobject MakeFileObject(const char* path) {
|
||||
jobject file = MakeObjectForClass("java/io/File");
|
||||
auto* pseudo_object = PseudoObjectFromRef(file);
|
||||
if (pseudo_object) {
|
||||
pseudo_object->object_fields["path"] = MakeString(path);
|
||||
}
|
||||
// The field owns a reference, so the calling thread's local ref can be
|
||||
// released on detach without freeing the value.
|
||||
SetStringFieldRaw(file, "path", path);
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -2270,25 +2337,26 @@ jobject MakeDeviceStaticParamsObject() {
|
||||
auto* pseudo_object = PseudoObjectFromRef(object);
|
||||
if (pseudo_object) {
|
||||
const PlatformIdentity identity = CurrentPlatformIdentity();
|
||||
pseudo_object->object_fields["osVersion"] = MakeString("Android 13");
|
||||
pseudo_object->object_fields["deviceName"] =
|
||||
MakeString(identity.device_name.c_str());
|
||||
// The field owns a reference, so the calling thread's local ref can be
|
||||
// released on detach without freeing the value.
|
||||
const auto set_string = [pseudo_object](const char* field,
|
||||
const char* value) {
|
||||
jobject text = MakeString(value);
|
||||
RetainJniReference(text);
|
||||
pseudo_object->object_fields[field] = text;
|
||||
};
|
||||
set_string("osVersion", "Android 13");
|
||||
set_string("deviceName", identity.device_name.c_str());
|
||||
const char* app_version = std::getenv("MOCKTAIL_ROBLOX_VERSION");
|
||||
pseudo_object->object_fields["appVersion"] =
|
||||
MakeString(app_version != nullptr ? app_version : "unknown");
|
||||
pseudo_object->object_fields["manufacturer"] =
|
||||
MakeString(identity.manufacturer.c_str());
|
||||
pseudo_object->object_fields["model"] = MakeString(identity.model.c_str());
|
||||
pseudo_object->object_fields["brand"] = MakeString(identity.brand.c_str());
|
||||
pseudo_object->object_fields["device"] =
|
||||
MakeString(identity.device_code.c_str());
|
||||
pseudo_object->object_fields["deviceSku"] =
|
||||
MakeString(identity.device_sku.c_str());
|
||||
pseudo_object->object_fields["appBuildVariant"] = MakeString("headless");
|
||||
pseudo_object->object_fields["socModel"] =
|
||||
MakeString(identity.soc_model.c_str());
|
||||
pseudo_object->object_fields["soc_model"] =
|
||||
MakeString(identity.soc_model.c_str());
|
||||
set_string("appVersion", app_version != nullptr ? app_version : "unknown");
|
||||
set_string("manufacturer", identity.manufacturer.c_str());
|
||||
set_string("model", identity.model.c_str());
|
||||
set_string("brand", identity.brand.c_str());
|
||||
set_string("device", identity.device_code.c_str());
|
||||
set_string("deviceSku", identity.device_sku.c_str());
|
||||
set_string("appBuildVariant", "headless");
|
||||
set_string("socModel", identity.soc_model.c_str());
|
||||
set_string("soc_model", identity.soc_model.c_str());
|
||||
pseudo_object->boolean_fields["cpu64Bit"] = JNI_TRUE;
|
||||
const mocktail::runtime::DisplaySize host_display =
|
||||
mocktail::runtime::ParseDisplaySize(
|
||||
@@ -6486,6 +6554,7 @@ void VM::InitJNIFunctionTables() {
|
||||
if (g_thread_vm_instance != owner || !IsThreadLocalEnvValid()) {
|
||||
return JNI_EDETACHED;
|
||||
}
|
||||
ReleaseThreadLocalRefs();
|
||||
g_thread_audio_fp_mode.Restore();
|
||||
g_thread_local_env = nullptr;
|
||||
g_thread_vm_instance = nullptr;
|
||||
@@ -6615,17 +6684,26 @@ void VM::InitJNIFunctionTables() {
|
||||
native_interface_.PopLocalFrame =
|
||||
[](JNIEnv* /*env*/, jobject result) -> jobject {
|
||||
EnsureLocalFrame();
|
||||
std::vector<jobject> frame = std::move(g_local_frames.back());
|
||||
LocalFrame frame = std::move(g_local_frames.back());
|
||||
if (g_local_frames.size() > 1) {
|
||||
g_local_frames.pop_back();
|
||||
} else {
|
||||
g_local_frames.back().clear();
|
||||
}
|
||||
for (jobject obj : frame) {
|
||||
if (obj != result) {
|
||||
bool result_kept = false;
|
||||
for (const auto& [obj, count] : frame) {
|
||||
uint32_t releases = count;
|
||||
if (obj == result) {
|
||||
--releases;
|
||||
result_kept = true;
|
||||
}
|
||||
for (uint32_t i = 0; i < releases; ++i) {
|
||||
ReleaseJniReference(obj);
|
||||
}
|
||||
}
|
||||
if (result != nullptr && !result_kept) {
|
||||
RetainJniReference(result);
|
||||
}
|
||||
RegisterLocalRef(result);
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
#include "libc_shim/libc_shim.h"
|
||||
#include "linker/linker.h"
|
||||
#include "mocktail/graphics/bionic_egl_bridge.h"
|
||||
#include "mocktail/graphics/chrome_trace_writer.h"
|
||||
#include "runtime/engine_pump_rest.h"
|
||||
#include "runtime/device_memory_profile.h"
|
||||
#include "runtime/display_size.h"
|
||||
#include "runtime/environment.h"
|
||||
@@ -762,7 +764,12 @@ uint64_t MonotonicNanos() {
|
||||
static_cast<uint64_t>(ts.tv_nsec);
|
||||
}
|
||||
|
||||
JNIEnv* AttachMainThreadJniEnv() {
|
||||
// `attached` reports whether AttachCurrentThread itself succeeded, so a caller
|
||||
// that caches the env can tell a real attach from the GetJNIEnv fallback.
|
||||
JNIEnv* AttachMainThreadJniEnv(bool* attached = nullptr) {
|
||||
if (attached != nullptr) {
|
||||
*attached = false;
|
||||
}
|
||||
if (g_vm_for_main_thread_pump == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
@@ -773,6 +780,9 @@ JNIEnv* AttachMainThreadJniEnv() {
|
||||
jint attach_result = java_vm->AttachCurrentThread(&raw_env, nullptr);
|
||||
if (attach_result == JNI_OK && raw_env != nullptr) {
|
||||
env = static_cast<JNIEnv*>(raw_env);
|
||||
if (attached != nullptr) {
|
||||
*attached = true;
|
||||
}
|
||||
} else if (IsEnabled("MOCKTAIL_TRACE_MAIN_THREAD_PUMP")) {
|
||||
std::cerr << " [main] AttachCurrentThread failed in pump: "
|
||||
<< attach_result << '\n'
|
||||
@@ -787,6 +797,35 @@ JNIEnv* AttachMainThreadJniEnv() {
|
||||
return env;
|
||||
}
|
||||
|
||||
// The pseudo-VM attach is idempotent: once this thread holds a live env for
|
||||
// the VM, re-attaching hands back the same thread-local pointer. Only the
|
||||
// main thread calls this, so the cache needs no synchronization. The game
|
||||
// session installs RestoreGameSessionJniEnvironment, which restores the JNI
|
||||
// function tables when a guest JNI_OnLoad replaces them.
|
||||
JNIEnv* MainThreadPumpJniEnv() {
|
||||
static JNIEnv* cached_env = nullptr;
|
||||
static const jnivm::VM* cached_vm = nullptr;
|
||||
if (cached_env != nullptr && cached_vm == g_vm_for_main_thread_pump) {
|
||||
// Only the attach is cached. The table restore and the env publish run on
|
||||
// every tick: a guest JNI_OnLoad can replace the function tables, and any
|
||||
// other thread that publishes its own env overwrites the single global
|
||||
// slot this one shares.
|
||||
g_vm_for_main_thread_pump->RestoreFunctions();
|
||||
PublishCurrentJniEnv(cached_env);
|
||||
return cached_env;
|
||||
}
|
||||
// Only a real attach is worth caching. The GetJNIEnv fallback means this
|
||||
// thread is not attached to the VM, and the next tick has to retry: a VM
|
||||
// swap can make AttachCurrentThread fail for one tick and succeed after.
|
||||
bool attached = false;
|
||||
JNIEnv* env = AttachMainThreadJniEnv(&attached);
|
||||
if (env != nullptr && attached) {
|
||||
cached_env = env;
|
||||
cached_vm = g_vm_for_main_thread_pump;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
bool InvokeTaskSchedulerForeground(
|
||||
JNIEnv* env, jclass native_gl_class,
|
||||
NativeSetTaskSchedulerBackgroundModeFn native_set_task_scheduler_background_mode,
|
||||
@@ -905,6 +944,43 @@ bool RunTaskSchedulerForegroundOnMainThread(
|
||||
}
|
||||
}
|
||||
|
||||
// The profile trace writer is compiled into the Vulkan adapter, which the host
|
||||
// dlopens RTLD_GLOBAL, so the pump slice goes through symbols resolved from it.
|
||||
// Until the adapter is loaded there is no writer and nothing is recorded.
|
||||
struct PumpTraceHooks {
|
||||
using ActiveFn = mocktail::graphics::ChromeTraceWriter* (*)();
|
||||
using ClockFn = std::uint64_t (*)();
|
||||
using SliceFn = void (*)(mocktail::graphics::ChromeTraceWriter*, const char*,
|
||||
const char*, std::uint64_t, std::uint64_t,
|
||||
const mocktail::graphics::TraceArg*, std::size_t);
|
||||
ActiveFn active = nullptr;
|
||||
ClockFn clock = nullptr;
|
||||
SliceFn slice = nullptr;
|
||||
};
|
||||
|
||||
const PumpTraceHooks& PumpTrace() {
|
||||
static PumpTraceHooks hooks;
|
||||
// The adapter loads during startup, so a few hundred ticks cover it. After
|
||||
// that the lookups stop and the pump stays untraced.
|
||||
static int attempts = 0;
|
||||
if (hooks.active == nullptr && attempts++ < 1000) {
|
||||
PumpTraceHooks resolved;
|
||||
resolved.active = reinterpret_cast<PumpTraceHooks::ActiveFn>(
|
||||
::dlsym(RTLD_DEFAULT, "_ZN8mocktail8graphics18ActiveProfileTraceEv"));
|
||||
resolved.clock = reinterpret_cast<PumpTraceHooks::ClockFn>(
|
||||
::dlsym(RTLD_DEFAULT, "_ZN8mocktail8graphics15TraceClockNanosEv"));
|
||||
resolved.slice = reinterpret_cast<PumpTraceHooks::SliceFn>(::dlsym(
|
||||
RTLD_DEFAULT,
|
||||
"_ZN8mocktail8graphics17ChromeTraceWriter5SliceEPKcS3_mmPKNS0_"
|
||||
"8TraceArgEm"));
|
||||
if (resolved.active != nullptr && resolved.clock != nullptr &&
|
||||
resolved.slice != nullptr) {
|
||||
hooks = resolved;
|
||||
}
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
void PumpRobloxMainThreadMessagesOnce() {
|
||||
static const bool force_early =
|
||||
IsEnabled("MOCKTAIL_FORCE_EARLY_MAIN_THREAD_MESSAGE_PUMP");
|
||||
@@ -944,7 +1020,7 @@ void PumpRobloxMainThreadMessagesOnce() {
|
||||
return;
|
||||
}
|
||||
|
||||
JNIEnv* env = AttachMainThreadJniEnv();
|
||||
JNIEnv* env = MainThreadPumpJniEnv();
|
||||
if (env == nullptr) {
|
||||
static bool logged_missing_env = false;
|
||||
if (!logged_missing_env && trace_pump) {
|
||||
@@ -966,8 +1042,21 @@ void PumpRobloxMainThreadMessagesOnce() {
|
||||
<< std::flush;
|
||||
}
|
||||
}
|
||||
const PumpTraceHooks& hooks = PumpTrace();
|
||||
mocktail::graphics::ChromeTraceWriter* trace =
|
||||
hooks.active != nullptr ? hooks.active() : nullptr;
|
||||
const std::uint64_t pump_start_ns = trace != nullptr ? hooks.clock() : 0;
|
||||
g_native_call_messages_from_main_thread(
|
||||
env, g_native_gl_class_for_main_thread);
|
||||
if (trace != nullptr) {
|
||||
// Empty polls run at kilohertz rates and would swamp the trace, so only
|
||||
// calls that ran a message are recorded.
|
||||
const std::uint64_t pump_end_ns = hooks.clock();
|
||||
if (pump_end_ns - pump_start_ns >= 20'000) {
|
||||
hooks.slice(trace, "nativeCallMessagesFromMainThread", "pump",
|
||||
pump_start_ns, pump_end_ns, nullptr, 0);
|
||||
}
|
||||
}
|
||||
if (__builtin_expect(trace_pump, 0)) {
|
||||
if (pump_count <= 10 || pump_count % 100 == 0) {
|
||||
std::cerr << " [main] nativeCallMessagesFromMainThread returned #"
|
||||
@@ -1414,8 +1503,11 @@ void JniOnLoadTimeoutAlarm(int, siginfo_t* info, void* context) {
|
||||
|
||||
void PublishCurrentJniEnv(JNIEnv* env) {
|
||||
using SetCurrentJniEnvFn = void (*)(void*);
|
||||
auto* set_current_jni_env = reinterpret_cast<SetCurrentJniEnvFn>(
|
||||
::dlsym(RTLD_DEFAULT, "mocktail_set_current_jni_env"));
|
||||
// RTLD_DEFAULT resolves this to the runtime's own exported setter, so the
|
||||
// address is fixed for the life of the process.
|
||||
static SetCurrentJniEnvFn set_current_jni_env =
|
||||
reinterpret_cast<SetCurrentJniEnvFn>(
|
||||
::dlsym(RTLD_DEFAULT, "mocktail_set_current_jni_env"));
|
||||
if (set_current_jni_env) {
|
||||
set_current_jni_env(env);
|
||||
}
|
||||
@@ -6831,6 +6923,10 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
bool input_shutdown_completed = window_input_runtime == nullptr;
|
||||
bool resize_readiness_completed = true;
|
||||
if (mocktail::window::IsInitialised()) {
|
||||
std::cout << " [main] engine pump rest: "
|
||||
<< mocktail::runtime::EnginePumpRestModeName(
|
||||
mocktail::runtime::ActiveEnginePumpRestMode())
|
||||
<< " (MOCKTAIL_ENGINE_PUMP_REST=sleep|tpause|spin)\n";
|
||||
std::cout << " [main] entering SDL event loop (close the window to quit)\n"
|
||||
<< std::flush;
|
||||
std::unique_ptr<mocktail::window::WindowGameSurfaceBridge> surface_bridge;
|
||||
@@ -6946,7 +7042,14 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
}
|
||||
}
|
||||
const uint64_t fps_after_launch_ns = fps_trace ? MonotonicNanos() : 0;
|
||||
const uint64_t fps_pace_ns = mocktail::window::PaceInputPump();
|
||||
// The pacer sleeps only under throttled presentation. Unthrottled, the
|
||||
// engine pump above is a poll, and the rest between polls is what keeps
|
||||
// it from spinning.
|
||||
uint64_t fps_pace_ns = mocktail::window::PaceInputPump();
|
||||
if (fps_pace_ns == 0 &&
|
||||
mocktail::window::UnthrottledPresentationRequested()) {
|
||||
fps_pace_ns = mocktail::runtime::RestAfterEnginePump();
|
||||
}
|
||||
if (fps_trace) {
|
||||
const uint64_t fps_tick_end_ns = MonotonicNanos();
|
||||
++fps_samples;
|
||||
|
||||
+40
-23
@@ -11,6 +11,8 @@
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <limits.h>
|
||||
#include <algorithm>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
@@ -20,8 +22,19 @@ namespace libc_shim {
|
||||
namespace {
|
||||
|
||||
std::unordered_map<std::string, std::string> g_path_mappings;
|
||||
// g_path_mappings ordered longest Android prefix first, so the first match
|
||||
// is the most specific one. Rebuilt whenever the mappings change.
|
||||
std::vector<std::pair<std::string, std::string>> g_ordered_mappings;
|
||||
std::vector<std::string> g_ca_bundle_aliases;
|
||||
|
||||
void RebuildOrderedMappings() {
|
||||
g_ordered_mappings.assign(g_path_mappings.begin(), g_path_mappings.end());
|
||||
std::stable_sort(g_ordered_mappings.begin(), g_ordered_mappings.end(),
|
||||
[](const auto& a, const auto& b) {
|
||||
return a.first.size() > b.first.size();
|
||||
});
|
||||
}
|
||||
|
||||
bool g_installed = false;
|
||||
std::atomic<GuestAllocator> g_guest_allocator{nullptr};
|
||||
|
||||
@@ -72,11 +85,9 @@ std::string DefaultDataRoot() {
|
||||
return RuntimeRoot() + "/data";
|
||||
}
|
||||
|
||||
bool PrefixMatchesPath(const std::string& path, const std::string& prefix) {
|
||||
if (path.size() < prefix.size()) {
|
||||
return false;
|
||||
}
|
||||
if (path.compare(0, prefix.size(), prefix) != 0) {
|
||||
bool PrefixMatchesPath(std::string_view path, std::string_view prefix) {
|
||||
if (path.size() < prefix.size() ||
|
||||
std::memcmp(path.data(), prefix.data(), prefix.size()) != 0) {
|
||||
return false;
|
||||
}
|
||||
return path.size() == prefix.size() || path[prefix.size()] == '/';
|
||||
@@ -202,6 +213,7 @@ HostCaBundleResolution ResolveHostCaBundle() {
|
||||
|
||||
HostCaBundleResolution ConfigureHostCaBundlePathMappings() {
|
||||
for (const auto& alias : g_ca_bundle_aliases) g_path_mappings.erase(alias);
|
||||
RebuildOrderedMappings();
|
||||
g_ca_bundle_aliases.assign(kAndroidCaBundlePaths.begin(), kAndroidCaBundlePaths.end());
|
||||
if (const char* content = GetEnvNonEmpty("MOCKTAIL_ASSET_PATH")) {
|
||||
auto root = std::filesystem::path(content).lexically_normal();
|
||||
@@ -297,31 +309,33 @@ void Install() {
|
||||
<< g_path_mappings.size() << " prefix mappings)\n";
|
||||
}
|
||||
|
||||
std::string TranslatePath(const std::string& android_path) {
|
||||
const std::string* best_android_prefix = nullptr;
|
||||
const std::string* best_host_prefix = nullptr;
|
||||
for (const auto& [android_prefix, host_prefix] : g_path_mappings) {
|
||||
if (PrefixMatchesPath(android_path, android_prefix) &&
|
||||
(best_android_prefix == nullptr ||
|
||||
android_prefix.size() > best_android_prefix->size())) {
|
||||
best_android_prefix = &android_prefix;
|
||||
best_host_prefix = &host_prefix;
|
||||
bool TranslatePathInto(std::string_view android_path, std::string* host_path) {
|
||||
for (const auto& [android_prefix, host_prefix] : g_ordered_mappings) {
|
||||
if (PrefixMatchesPath(android_path, android_prefix)) {
|
||||
const std::string_view rest = android_path.substr(android_prefix.size());
|
||||
host_path->reserve(host_prefix.size() + rest.size());
|
||||
host_path->assign(host_prefix);
|
||||
host_path->append(rest);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (best_android_prefix != nullptr && best_host_prefix != nullptr) {
|
||||
return *best_host_prefix +
|
||||
android_path.substr(best_android_prefix->size());
|
||||
}
|
||||
return android_path;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string TranslatePath(const std::string& android_path) {
|
||||
std::string host_path;
|
||||
return TranslatePathInto(android_path, &host_path) ? host_path : android_path;
|
||||
}
|
||||
|
||||
void RegisterPathMapping(const std::string& android_prefix,
|
||||
const std::string& host_prefix) {
|
||||
g_path_mappings[android_prefix] = host_prefix;
|
||||
RebuildOrderedMappings();
|
||||
}
|
||||
|
||||
void ClearPathMappings() {
|
||||
g_path_mappings.clear();
|
||||
g_ordered_mappings.clear();
|
||||
g_ca_bundle_aliases.clear();
|
||||
}
|
||||
|
||||
@@ -333,13 +347,16 @@ const char* HostPath(const char* path, std::string* storage) {
|
||||
if (path == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
*storage = libc_shim::TranslatePath(path);
|
||||
return storage->c_str();
|
||||
return libc_shim::TranslatePathInto(path, storage) ? storage->c_str() : path;
|
||||
}
|
||||
|
||||
// Read once: the guest calls these shims on every file operation.
|
||||
bool PathTraceEnabled() {
|
||||
const char* value = std::getenv("MOCKTAIL_PATH_TRACE");
|
||||
return value != nullptr && value[0] != '\0' && value[0] != '0';
|
||||
static const bool enabled = [] {
|
||||
const char* value = std::getenv("MOCKTAIL_PATH_TRACE");
|
||||
return value != nullptr && value[0] != '\0' && value[0] != '0';
|
||||
}();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
void TracePathCall(const char* name, const char* path, const char* host_path,
|
||||
|
||||
@@ -552,6 +552,12 @@ int main(int argc, char* argv[]) {
|
||||
case mocktail::runtime::GameModeSessionState::kActive:
|
||||
std::cout << " [gamemode] performance request active\n";
|
||||
break;
|
||||
case mocktail::runtime::GameModeSessionState::kDeclinedCorePinning:
|
||||
std::cout << " [gamemode] released: it pinned this process to a subset "
|
||||
"of the CPUs, which concentrates the worker pool. Set "
|
||||
"pin_cores=no in ~/.config/gamemode.ini to keep GameMode, "
|
||||
"or performance.gamemode: on to accept the pinning\n";
|
||||
break;
|
||||
case mocktail::runtime::GameModeSessionState::kAlreadyActive:
|
||||
std::cout << " [gamemode] already active for this process\n";
|
||||
break;
|
||||
|
||||
@@ -61,14 +61,17 @@ bool ConvertSdlEvent(SDL_Window* window, const SDL_Event& source,
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_MOTION:
|
||||
destination->payload = MouseMotionEvent{
|
||||
source.motion.x, source.motion.y, source.motion.xrel,
|
||||
source.motion.yrel, static_cast<std::uint32_t>(source.motion.state)};
|
||||
source.motion.x, source.motion.y,
|
||||
source.motion.xrel, source.motion.yrel,
|
||||
static_cast<std::uint32_t>(source.motion.state),
|
||||
SDL_GetWindowRelativeMouseMode(window)};
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
case SDL_EVENT_MOUSE_BUTTON_UP:
|
||||
destination->payload = MouseButtonEvent{
|
||||
source.button.down, source.button.button, source.button.clicks,
|
||||
source.button.x, source.button.y};
|
||||
source.button.down, source.button.button, source.button.clicks,
|
||||
source.button.x, source.button.y,
|
||||
SDL_GetWindowRelativeMouseMode(window)};
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_WHEEL: {
|
||||
float delta_x = source.wheel.x;
|
||||
@@ -77,8 +80,10 @@ bool ConvertSdlEvent(SDL_Window* window, const SDL_Event& source,
|
||||
delta_x = -delta_x;
|
||||
delta_y = -delta_y;
|
||||
}
|
||||
destination->payload = MouseWheelEvent{
|
||||
delta_x, delta_y, source.wheel.mouse_x, source.wheel.mouse_y};
|
||||
destination->payload =
|
||||
MouseWheelEvent{delta_x, delta_y, source.wheel.mouse_x,
|
||||
source.wheel.mouse_y,
|
||||
SDL_GetWindowRelativeMouseMode(window)};
|
||||
return true;
|
||||
}
|
||||
case SDL_EVENT_FINGER_DOWN:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "runtime/command_line.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
@@ -78,6 +79,12 @@ CommandLineParseResult ParseCommandLine(int argc, const char* const argv[]) {
|
||||
result.options.allow_unverified_build = true;
|
||||
} else if (argument == "--force-run-latest") {
|
||||
result.options.force_run_latest = true;
|
||||
} else if (argument == "--profile") {
|
||||
if (!ReadOptionValue(argc, argv, &index, argument,
|
||||
&result.options.profile_trace_path,
|
||||
&result.error)) {
|
||||
return result;
|
||||
}
|
||||
} else if (argument == "--launch-uri") {
|
||||
if (!result.options.launch_request_json.empty()) {
|
||||
result.error = "duplicate option: --launch-uri";
|
||||
@@ -227,6 +234,20 @@ bool ApplyCommandLineEnvironment(const CommandLineOptions& options,
|
||||
error)) {
|
||||
return false;
|
||||
}
|
||||
if (!options.profile_trace_path.empty()) {
|
||||
// Absolute, so a later working-directory change cannot move the trace.
|
||||
std::error_code absolute_error;
|
||||
const std::filesystem::path trace_path =
|
||||
std::filesystem::absolute(options.profile_trace_path, absolute_error);
|
||||
if (absolute_error ||
|
||||
!SetEnvironment("MOCKTAIL_PROFILE_TRACE", trace_path.string(),
|
||||
error)) {
|
||||
if (error != nullptr && absolute_error) {
|
||||
*error = "could not resolve --profile path";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (options.window_mode == WindowMode::kHeadless) {
|
||||
return SetEnvironment("MOCKTAIL_HEADLESS", "1", error);
|
||||
}
|
||||
@@ -256,6 +277,8 @@ std::string CommandLineUsage(const std::string& program_name) {
|
||||
"without approval; it is not activated\n"
|
||||
<< " --launch-uri <uri> Join from a roblox: or roblox-player: "
|
||||
"website link\n"
|
||||
<< " --profile <file> Write a Chrome trace of Vulkan adapter "
|
||||
"work for ui.perfetto.dev\n"
|
||||
<< " --help, -h Show this help\n\n"
|
||||
<< "Auth:\n"
|
||||
<< " When no saved Roblox session is found, Roblox's welcome screen "
|
||||
|
||||
+139
-47
@@ -84,14 +84,50 @@ std::string DumpJson(const Json& value) {
|
||||
return value.dump(-1, ' ', false, Json::error_handler_t::replace);
|
||||
}
|
||||
|
||||
std::string RenderPlaceTemplate(std::string text, std::string_view place_name) {
|
||||
constexpr std::string_view placeholder = "{place_name}";
|
||||
// Expands in one pass, so placeholder text inside a place name stays literal.
|
||||
// A template that expands an empty placeholder renders empty, so "by
|
||||
// {creator_name}" never shows a dangling "by".
|
||||
std::string RenderPresenceTemplate(std::string_view text,
|
||||
std::string_view place_name,
|
||||
std::string_view place_icon,
|
||||
std::string_view creator_name) {
|
||||
constexpr std::string_view kPlaceName = "{place_name}";
|
||||
constexpr std::string_view kPlaceIcon = "{place_icon}";
|
||||
constexpr std::string_view kCreatorName = "{creator_name}";
|
||||
std::string rendered;
|
||||
rendered.reserve(text.size());
|
||||
std::size_t offset = 0;
|
||||
while ((offset = text.find(placeholder, offset)) != std::string::npos) {
|
||||
text.replace(offset, placeholder.size(), place_name);
|
||||
offset += place_name.size();
|
||||
const auto expand = [&](std::string_view token, std::string_view value) {
|
||||
if (value.empty()) {
|
||||
return false;
|
||||
}
|
||||
rendered += value;
|
||||
offset += token.size();
|
||||
return true;
|
||||
};
|
||||
while (offset < text.size()) {
|
||||
const std::string_view rest = text.substr(offset);
|
||||
if (rest.substr(0, kPlaceName.size()) == kPlaceName) {
|
||||
if (!expand(kPlaceName, place_name)) return {};
|
||||
} else if (rest.substr(0, kPlaceIcon.size()) == kPlaceIcon) {
|
||||
if (!expand(kPlaceIcon, place_icon)) return {};
|
||||
} else if (rest.substr(0, kCreatorName.size()) == kCreatorName) {
|
||||
if (!expand(kCreatorName, creator_name)) return {};
|
||||
} else {
|
||||
rendered.push_back(text[offset]);
|
||||
++offset;
|
||||
}
|
||||
}
|
||||
return TruncateUtf8(std::move(text), 128);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
bool IsDiscordAssetKey(std::string_view key) {
|
||||
return !key.empty() && key.size() <= 32 &&
|
||||
std::all_of(key.begin(), key.end(), [](unsigned char byte) {
|
||||
return (byte >= 'a' && byte <= 'z') ||
|
||||
(byte >= 'A' && byte <= 'Z') ||
|
||||
(byte >= '0' && byte <= '9') || byte == '_' || byte == '-';
|
||||
});
|
||||
}
|
||||
|
||||
std::string PercentEncode(std::string_view value) {
|
||||
@@ -329,8 +365,14 @@ bool HandshakeDiscord(int descriptor, std::string_view application_id) {
|
||||
}
|
||||
|
||||
Json ActivityJson(const DiscordRpcActivity& activity) {
|
||||
Json result = {
|
||||
{"type", 0}, {"details", activity.details}, {"instance", true}};
|
||||
// Discord rejects the whole activity when a text field is an empty string.
|
||||
Json result = {{"type", 0}, {"instance", true}};
|
||||
if (!activity.details.empty()) {
|
||||
result["details"] = activity.details;
|
||||
}
|
||||
if (!activity.name.empty()) {
|
||||
result["name"] = activity.name;
|
||||
}
|
||||
if (!activity.state.empty()) {
|
||||
result["state"] = activity.state;
|
||||
}
|
||||
@@ -342,6 +384,12 @@ Json ActivityJson(const DiscordRpcActivity& activity) {
|
||||
if (!activity.large_text.empty()) {
|
||||
result["assets"]["large_text"] = activity.large_text;
|
||||
}
|
||||
if (!activity.small_image.empty()) {
|
||||
result["assets"]["small_image"] = activity.small_image;
|
||||
if (!activity.small_text.empty()) {
|
||||
result["assets"]["small_text"] = activity.small_text;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!activity.button_label.empty() && !activity.button_url.empty()) {
|
||||
result["buttons"] = Json::array(
|
||||
@@ -446,6 +494,7 @@ std::optional<Json> GetRobloxJson(std::string url) {
|
||||
|
||||
struct ResolvedPlaceMetadata {
|
||||
std::string name;
|
||||
std::string creator_name;
|
||||
std::string icon_url;
|
||||
};
|
||||
|
||||
@@ -494,6 +543,13 @@ ResolvedPlaceMetadata ResolvePlaceMetadata(int64_t place_id) {
|
||||
metadata.name =
|
||||
TruncateUtf8(first["name"].get<std::string>(), 128);
|
||||
}
|
||||
if (first.contains("creator") && first["creator"].is_object()) {
|
||||
const Json& creator = first["creator"];
|
||||
if (creator.contains("name") && creator["name"].is_string()) {
|
||||
metadata.creator_name =
|
||||
TruncateUtf8(creator["name"].get<std::string>(), 128);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const std::optional<Json> thumbnail = GetRobloxJson(
|
||||
@@ -542,46 +598,74 @@ std::string BuildDiscordJoinUrl(const RobloxExperienceLaunchRequest& request) {
|
||||
DiscordRpcActivity BuildDiscordRpcActivity(
|
||||
const DiscordRpcConfig& config, RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request, std::string place_name,
|
||||
int64_t session_started_at, std::string place_icon_url) {
|
||||
int64_t session_started_at, std::string place_icon_url,
|
||||
std::string creator_name) {
|
||||
const bool browsing = phase == RobloxExperiencePresencePhase::kBrowsing;
|
||||
const bool joining_without_place =
|
||||
phase == RobloxExperiencePresencePhase::kJoining && place_name.empty();
|
||||
if (!browsing && place_name.empty()) {
|
||||
place_name = config.text.unknown_place;
|
||||
}
|
||||
// Hidden or absent places expose nothing to any template, icons included.
|
||||
const bool show_place =
|
||||
!browsing && !joining_without_place && config.show_place_name;
|
||||
const std::string_view shown_name =
|
||||
show_place ? std::string_view(place_name) : std::string_view();
|
||||
const std::string_view shown_icon =
|
||||
show_place && IsSafeExternalImageUrl(place_icon_url)
|
||||
? std::string_view(place_icon_url)
|
||||
: std::string_view();
|
||||
const std::string_view shown_creator =
|
||||
show_place ? std::string_view(creator_name) : std::string_view();
|
||||
const auto render = [&](std::string_view text) {
|
||||
return RenderPresenceTemplate(text, shown_name, shown_icon,
|
||||
shown_creator);
|
||||
};
|
||||
const auto render_text = [&](std::string_view text) {
|
||||
return TruncateUtf8(render(text), 128);
|
||||
};
|
||||
const auto is_image = [](std::string_view image) {
|
||||
return IsSafeExternalImageUrl(image) || IsDiscordAssetKey(image);
|
||||
};
|
||||
|
||||
DiscordRpcActivity activity;
|
||||
activity.state = TruncateUtf8(config.text.state, 128);
|
||||
switch (phase) {
|
||||
case RobloxExperiencePresencePhase::kBrowsing:
|
||||
activity.details = TruncateUtf8(config.text.browsing, 128);
|
||||
break;
|
||||
case RobloxExperiencePresencePhase::kJoining:
|
||||
case RobloxExperiencePresencePhase::kPlaying: {
|
||||
if (place_name.empty()) {
|
||||
if (phase == RobloxExperiencePresencePhase::kJoining) {
|
||||
activity.details = TruncateUtf8(config.text.joining, 128);
|
||||
break;
|
||||
}
|
||||
place_name = config.text.unknown_place;
|
||||
}
|
||||
if (config.show_place_name) {
|
||||
activity.details = RenderPlaceTemplate(config.text.playing, place_name);
|
||||
} else {
|
||||
activity.details = activity.state;
|
||||
activity.state.clear();
|
||||
}
|
||||
if (phase == RobloxExperiencePresencePhase::kPlaying &&
|
||||
config.show_elapsed_time && session_started_at > 0) {
|
||||
activity.start_timestamp = session_started_at;
|
||||
}
|
||||
if (IsSafeExternalImageUrl(place_icon_url)) {
|
||||
activity.large_image = std::move(place_icon_url);
|
||||
activity.large_text = TruncateUtf8(place_name, 128);
|
||||
}
|
||||
if (phase == RobloxExperiencePresencePhase::kPlaying &&
|
||||
config.join_enabled && request != nullptr &&
|
||||
(!config.public_servers_only || IsPublicDiscordJoin(*request))) {
|
||||
activity.button_url = BuildDiscordJoinUrl(*request);
|
||||
if (!activity.button_url.empty()) {
|
||||
activity.button_label = config.join_button_label;
|
||||
}
|
||||
}
|
||||
break;
|
||||
activity.name = render_text(config.text.title);
|
||||
activity.state = render_text(config.text.state);
|
||||
if (browsing) {
|
||||
activity.details = render_text(config.text.browsing);
|
||||
} else if (joining_without_place) {
|
||||
activity.details = render_text(config.text.joining);
|
||||
} else {
|
||||
if (config.show_place_name) {
|
||||
activity.details = render_text(config.text.playing);
|
||||
} else {
|
||||
activity.details = std::move(activity.state);
|
||||
activity.state.clear();
|
||||
}
|
||||
if (phase == RobloxExperiencePresencePhase::kPlaying &&
|
||||
config.show_elapsed_time && session_started_at > 0) {
|
||||
activity.start_timestamp = session_started_at;
|
||||
}
|
||||
if (phase == RobloxExperiencePresencePhase::kPlaying &&
|
||||
config.join_enabled && request != nullptr &&
|
||||
(!config.public_servers_only || IsPublicDiscordJoin(*request))) {
|
||||
activity.button_url = BuildDiscordJoinUrl(*request);
|
||||
if (!activity.button_url.empty()) {
|
||||
activity.button_label = config.join_button_label;
|
||||
}
|
||||
}
|
||||
}
|
||||
activity.large_image = render(config.images.large);
|
||||
if (!is_image(activity.large_image)) {
|
||||
activity.large_image.clear();
|
||||
return activity;
|
||||
}
|
||||
activity.large_text = render_text(config.images.large_text);
|
||||
activity.small_image = render(config.images.small);
|
||||
if (is_image(activity.small_image)) {
|
||||
activity.small_text = render_text(config.images.small_text);
|
||||
} else {
|
||||
activity.small_image.clear();
|
||||
}
|
||||
return activity;
|
||||
}
|
||||
@@ -697,6 +781,7 @@ class DiscordRpcSession::Impl final {
|
||||
|
||||
struct CachedPlaceMetadata {
|
||||
std::string name;
|
||||
std::string creator_name;
|
||||
std::string icon_url;
|
||||
std::chrono::steady_clock::time_point next_attempt{};
|
||||
unsigned failures = 0;
|
||||
@@ -734,6 +819,7 @@ class DiscordRpcSession::Impl final {
|
||||
Desired desired = Snapshot();
|
||||
std::string place_name;
|
||||
std::string place_icon_url;
|
||||
std::string creator_name;
|
||||
bool resolve_metadata_after_publish = false;
|
||||
if ((desired.phase == RobloxExperiencePresencePhase::kJoining ||
|
||||
desired.phase == RobloxExperiencePresencePhase::kPlaying) &&
|
||||
@@ -742,6 +828,7 @@ class DiscordRpcSession::Impl final {
|
||||
place_metadata_[desired.request.place_id];
|
||||
place_name = cached.name;
|
||||
place_icon_url = cached.icon_url;
|
||||
creator_name = cached.creator_name;
|
||||
if ((cached.name.empty() || cached.icon_url.empty()) &&
|
||||
std::chrono::steady_clock::now() >= cached.next_attempt) {
|
||||
resolve_metadata_after_publish = true;
|
||||
@@ -787,7 +874,7 @@ class DiscordRpcSession::Impl final {
|
||||
? nullptr
|
||||
: &desired.request,
|
||||
std::move(place_name), desired.started_at,
|
||||
std::move(place_icon_url));
|
||||
std::move(place_icon_url), std::move(creator_name));
|
||||
if (!SetDiscordActivity(descriptor, &activity, nonce++)) {
|
||||
(void)close(descriptor);
|
||||
descriptor = -1;
|
||||
@@ -811,6 +898,11 @@ class DiscordRpcSession::Impl final {
|
||||
cached.name = resolved.name;
|
||||
changed = true;
|
||||
}
|
||||
if (!resolved.creator_name.empty() &&
|
||||
resolved.creator_name != cached.creator_name) {
|
||||
cached.creator_name = resolved.creator_name;
|
||||
changed = true;
|
||||
}
|
||||
if (!resolved.icon_url.empty() &&
|
||||
resolved.icon_url != cached.icon_url) {
|
||||
cached.icon_url = resolved.icon_url;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
#include "runtime/engine_pump_rest.h"
|
||||
|
||||
#include <cpuid.h>
|
||||
#include <immintrin.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <time.h>
|
||||
#include <x86intrin.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
namespace mocktail::runtime {
|
||||
namespace {
|
||||
|
||||
uint64_t NowNs() {
|
||||
timespec now{};
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
return static_cast<uint64_t>(now.tv_sec) * 1'000'000'000ULL +
|
||||
static_cast<uint64_t>(now.tv_nsec);
|
||||
}
|
||||
|
||||
// The kernel caps one TPAUSE at umwait_control/max_time TSC cycles (100000 by
|
||||
// default), so the wait is a loop of short pauses up to the deadline. Control
|
||||
// bit 0 clear selects C0.2, the deeper of the two light states.
|
||||
__attribute__((target("waitpkg"))) void TpauseUntil(uint64_t deadline_ns) {
|
||||
while (NowNs() < deadline_ns) {
|
||||
_tpause(0, __rdtsc() + 100000ULL);
|
||||
}
|
||||
}
|
||||
|
||||
void LowerTimerSlackOnce() {
|
||||
// Only the main thread rests, so the slack is set once per process.
|
||||
static const bool slack_set = [] {
|
||||
return prctl(PR_SET_TIMERSLACK, kEnginePumpTimerSlackNs) == 0;
|
||||
}();
|
||||
(void)slack_set;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* EnginePumpRestModeName(EnginePumpRestMode mode) {
|
||||
switch (mode) {
|
||||
case EnginePumpRestMode::kSleep:
|
||||
return "sleep";
|
||||
case EnginePumpRestMode::kTpause:
|
||||
return "tpause";
|
||||
case EnginePumpRestMode::kSpin:
|
||||
return "spin";
|
||||
}
|
||||
return "spin";
|
||||
}
|
||||
|
||||
EnginePumpRestMode ChooseEnginePumpRestMode(
|
||||
const EnginePumpRestInputs& inputs) {
|
||||
if (inputs.override != nullptr) {
|
||||
if (std::strcmp(inputs.override, "sleep") == 0) {
|
||||
return EnginePumpRestMode::kSleep;
|
||||
}
|
||||
if (std::strcmp(inputs.override, "spin") == 0) {
|
||||
return EnginePumpRestMode::kSpin;
|
||||
}
|
||||
if (std::strcmp(inputs.override, "tpause") == 0 &&
|
||||
inputs.cpu_has_waitpkg) {
|
||||
return EnginePumpRestMode::kTpause;
|
||||
}
|
||||
}
|
||||
if (inputs.governor_performance) {
|
||||
return EnginePumpRestMode::kSleep;
|
||||
}
|
||||
return inputs.cpu_has_waitpkg ? EnginePumpRestMode::kTpause
|
||||
: EnginePumpRestMode::kSpin;
|
||||
}
|
||||
|
||||
bool CpuGovernorIsPerformance(const char* path) {
|
||||
std::FILE* file = std::fopen(path, "r");
|
||||
if (file == nullptr) {
|
||||
return false;
|
||||
}
|
||||
char governor[32] = {};
|
||||
const bool read = std::fgets(governor, sizeof(governor), file) != nullptr;
|
||||
std::fclose(file);
|
||||
if (!read) {
|
||||
return false;
|
||||
}
|
||||
governor[std::strcspn(governor, "\r\n")] = '\0';
|
||||
return std::strcmp(governor, "performance") == 0;
|
||||
}
|
||||
|
||||
bool CpuHasWaitPkg() {
|
||||
unsigned eax = 0, ebx = 0, ecx = 0, edx = 0;
|
||||
if (__get_cpuid_count(7, 0, &eax, &ebx, &ecx, &edx) == 0) {
|
||||
return false;
|
||||
}
|
||||
return (ecx & (1u << 5)) != 0;
|
||||
}
|
||||
|
||||
EnginePumpRestMode ActiveEnginePumpRestMode() {
|
||||
static const EnginePumpRestMode mode = [] {
|
||||
EnginePumpRestInputs inputs;
|
||||
inputs.governor_performance = CpuGovernorIsPerformance(
|
||||
"/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor");
|
||||
inputs.cpu_has_waitpkg = CpuHasWaitPkg();
|
||||
inputs.override = std::getenv("MOCKTAIL_ENGINE_PUMP_REST");
|
||||
return ChooseEnginePumpRestMode(inputs);
|
||||
}();
|
||||
return mode;
|
||||
}
|
||||
|
||||
uint64_t RestAfterEnginePump(EnginePumpRestMode mode) {
|
||||
LowerTimerSlackOnce();
|
||||
switch (mode) {
|
||||
case EnginePumpRestMode::kSpin:
|
||||
return 0;
|
||||
case EnginePumpRestMode::kTpause:
|
||||
TpauseUntil(NowNs() + kEnginePumpRestNs);
|
||||
return kEnginePumpRestNs;
|
||||
case EnginePumpRestMode::kSleep:
|
||||
break;
|
||||
}
|
||||
timespec rest{};
|
||||
rest.tv_sec = 0;
|
||||
rest.tv_nsec = static_cast<long>(kEnginePumpRestNs);
|
||||
// A signal may cut the rest short. The remainder is not slept again: an
|
||||
// early poll only costs a few microseconds.
|
||||
(void)clock_nanosleep(CLOCK_MONOTONIC, 0, &rest, nullptr);
|
||||
return kEnginePumpRestNs;
|
||||
}
|
||||
|
||||
} // namespace mocktail::runtime
|
||||
@@ -349,6 +349,7 @@ class ExternalLaunchBroker::Impl final {
|
||||
Status initial_status = NormalizeRequest(*initial_request, &normalized);
|
||||
if (!initial_status.ok()) return initial_status;
|
||||
pending_.push_back(std::move(normalized));
|
||||
PublishPendingCount();
|
||||
}
|
||||
socket_path_ = options_.socket_path;
|
||||
Status status = socket_path_.empty()
|
||||
@@ -426,6 +427,14 @@ class ExternalLaunchBroker::Impl final {
|
||||
if (!sink.valid()) {
|
||||
return Invalid("external-launch sink is incomplete");
|
||||
}
|
||||
// The host main loop drains on every tick and the queue is almost always
|
||||
// empty. Observe that without either lock, so an idle tick never waits
|
||||
// behind a dispatch already in flight. A request queued just after this
|
||||
// load is delivered on the next tick.
|
||||
if (pending_count_.load(std::memory_order_acquire) == 0 &&
|
||||
!stopping_.load(std::memory_order_acquire)) {
|
||||
return Status::Ok();
|
||||
}
|
||||
std::lock_guard<std::mutex> drain_lock(drain_mutex_);
|
||||
if (stopping_.load(std::memory_order_acquire)) {
|
||||
return Failed("external-launch broker is stopping");
|
||||
@@ -437,11 +446,13 @@ class ExternalLaunchBroker::Impl final {
|
||||
if (pending_.empty()) break;
|
||||
request = std::move(pending_.front());
|
||||
pending_.pop_front();
|
||||
PublishPendingCount();
|
||||
}
|
||||
Status status = sink.dispatch(sink.context, request);
|
||||
if (!status.ok()) {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
pending_.push_front(std::move(request));
|
||||
PublishPendingCount();
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -482,6 +493,7 @@ class ExternalLaunchBroker::Impl final {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex_);
|
||||
pending_.clear();
|
||||
PublishPendingCount();
|
||||
}
|
||||
started_ = false;
|
||||
return Status::Ok();
|
||||
@@ -577,9 +589,16 @@ class ExternalLaunchBroker::Impl final {
|
||||
return Unavailable("external-launch queue is full");
|
||||
}
|
||||
pending_.push_back(std::move(request));
|
||||
PublishPendingCount();
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
// Mirrors pending_.size() for Drain's lock-free idle check. Every caller
|
||||
// holds queue_mutex_, except Start, which runs before the worker exists.
|
||||
void PublishPendingCount() {
|
||||
pending_count_.store(pending_.size(), std::memory_order_release);
|
||||
}
|
||||
|
||||
void CleanupEndpoint() {
|
||||
CloseDescriptor(&stop_event_);
|
||||
CloseDescriptor(&listener_);
|
||||
@@ -607,6 +626,7 @@ class ExternalLaunchBroker::Impl final {
|
||||
std::mutex drain_mutex_;
|
||||
std::mutex lifecycle_mutex_;
|
||||
std::deque<RobloxExperienceLaunchRequest> pending_;
|
||||
std::atomic<std::size_t> pending_count_{0};
|
||||
OwnedPthread worker_;
|
||||
std::atomic<bool> stopping_{true};
|
||||
int listener_ = -1;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#include "runtime/game_mode.h"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <dlfcn.h>
|
||||
#if defined(__GLIBC__)
|
||||
#include <link.h>
|
||||
#endif
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
@@ -56,8 +59,63 @@ void* OpenClientLibrary(const char* name) {
|
||||
#endif
|
||||
}
|
||||
|
||||
int GetThreadAffinity(cpu_set_t* mask) {
|
||||
return sched_getaffinity(0, sizeof(*mask), mask);
|
||||
}
|
||||
|
||||
// sched_setaffinity binds one thread, and GameMode pins every thread it finds,
|
||||
// so restoring the mask means walking the task list. Threads that exit while
|
||||
// the directory is being read are skipped, not treated as failures.
|
||||
int SetAffinityOfEveryThread(const cpu_set_t* mask) {
|
||||
DIR* tasks = opendir("/proc/self/task");
|
||||
if (tasks == nullptr) {
|
||||
return sched_setaffinity(0, sizeof(*mask), mask);
|
||||
}
|
||||
int result = 0;
|
||||
for (;;) {
|
||||
// readdir() reports both end-of-directory and failure with a null return,
|
||||
// so a read that dies partway must not pass for a completed walk.
|
||||
errno = 0;
|
||||
const dirent* entry = readdir(tasks);
|
||||
if (entry == nullptr) {
|
||||
if (errno != 0) {
|
||||
result = -1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const long tid = std::strtol(entry->d_name, nullptr, 10);
|
||||
if (tid <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (sched_setaffinity(static_cast<pid_t>(tid), sizeof(*mask), mask) != 0 &&
|
||||
errno != ESRCH) {
|
||||
result = -1;
|
||||
}
|
||||
}
|
||||
closedir(tasks);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
CpuAffinityApi HostCpuAffinityApi() {
|
||||
return CpuAffinityApi{&GetThreadAffinity, &SetAffinityOfEveryThread};
|
||||
}
|
||||
|
||||
bool CpuAffinityNarrowed(const cpu_set_t& before, const cpu_set_t& after) {
|
||||
if (CPU_COUNT(&after) >= CPU_COUNT(&before)) {
|
||||
return false;
|
||||
}
|
||||
// A smaller set that escapes `before` is a move, not a narrowing, and the
|
||||
// daemon's choice of CPUs is then the only one known to be permitted.
|
||||
for (int cpu = 0; cpu < CPU_SETSIZE; ++cpu) {
|
||||
if (CPU_ISSET(cpu, &after) && !CPU_ISSET(cpu, &before)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ParseGameModePolicy(std::string_view value, GameModePolicy* policy) {
|
||||
if (policy == nullptr) {
|
||||
return false;
|
||||
@@ -97,6 +155,8 @@ const char* GameModeSessionStateName(GameModeSessionState state) {
|
||||
return "unavailable";
|
||||
case GameModeSessionState::kRequestFailed:
|
||||
return "request-failed";
|
||||
case GameModeSessionState::kDeclinedCorePinning:
|
||||
return "declined-core-pinning";
|
||||
case GameModeSessionState::kAlreadyActive:
|
||||
return "already-active";
|
||||
case GameModeSessionState::kActive:
|
||||
@@ -123,7 +183,7 @@ GameModeSession::GameModeSession(GameModeSession&& other) noexcept
|
||||
|
||||
GameModeSession GameModeSession::Start(GameModePolicy policy) {
|
||||
if (policy == GameModePolicy::kOff) {
|
||||
return StartBound(policy, {}, nullptr);
|
||||
return StartBound(policy, {}, {}, nullptr);
|
||||
}
|
||||
|
||||
void* library = OpenClientLibrary("libgamemode.so.0");
|
||||
@@ -155,7 +215,8 @@ GameModeSession GameModeSession::Start(GameModePolicy policy) {
|
||||
(void)ResolveFunction(library, "real_gamemode_query_status",
|
||||
&api.query_status, nullptr);
|
||||
|
||||
GameModeSession session = StartBound(policy, api, library);
|
||||
GameModeSession session =
|
||||
StartBound(policy, api, HostCpuAffinityApi(), library);
|
||||
if (!session.owns_request_) {
|
||||
session.CloseLibrary();
|
||||
}
|
||||
@@ -164,11 +225,17 @@ GameModeSession GameModeSession::Start(GameModePolicy policy) {
|
||||
|
||||
GameModeSession GameModeSession::StartWithClientForTesting(
|
||||
GameModePolicy policy, GameModeClientApi api) {
|
||||
return StartBound(policy, api, nullptr);
|
||||
return StartBound(policy, api, {}, nullptr);
|
||||
}
|
||||
|
||||
GameModeSession GameModeSession::StartWithClientForTesting(
|
||||
GameModePolicy policy, GameModeClientApi api, CpuAffinityApi affinity) {
|
||||
return StartBound(policy, api, affinity, nullptr);
|
||||
}
|
||||
|
||||
GameModeSession GameModeSession::StartBound(GameModePolicy policy,
|
||||
GameModeClientApi api,
|
||||
CpuAffinityApi affinity,
|
||||
void* library_handle) {
|
||||
GameModeSession session;
|
||||
if (policy == GameModePolicy::kOff) {
|
||||
@@ -185,6 +252,11 @@ GameModeSession GameModeSession::StartBound(GameModePolicy policy,
|
||||
session.state_ = GameModeSessionState::kAlreadyActive;
|
||||
return session;
|
||||
}
|
||||
cpu_set_t before;
|
||||
CPU_ZERO(&before);
|
||||
const bool affinity_readable =
|
||||
policy != GameModePolicy::kOn && affinity.get != nullptr &&
|
||||
affinity.set != nullptr && affinity.get(&before) == 0;
|
||||
if (api.request_start() != 0) {
|
||||
session.state_ = GameModeSessionState::kRequestFailed;
|
||||
session.detail_ = ClientError(api, "GameMode request was rejected");
|
||||
@@ -192,6 +264,21 @@ GameModeSession GameModeSession::StartBound(GameModePolicy policy,
|
||||
}
|
||||
session.state_ = GameModeSessionState::kActive;
|
||||
session.owns_request_ = true;
|
||||
// The daemon pins before the request call returns, and re-pins every few
|
||||
// seconds afterwards. Ending the request is what releases the pin.
|
||||
cpu_set_t after;
|
||||
CPU_ZERO(&after);
|
||||
if (affinity_readable && affinity.get(&after) == 0 &&
|
||||
CpuAffinityNarrowed(before, after)) {
|
||||
session.state_ = GameModeSessionState::kDeclinedCorePinning;
|
||||
session.detail_ = "GameMode pinned the process to a subset of its CPUs";
|
||||
// A release that fails leaves the daemon holding the request, so keep
|
||||
// owning it and let Stop() retry rather than reporting it released.
|
||||
if (api.request_end() == 0) {
|
||||
session.owns_request_ = false;
|
||||
}
|
||||
(void)affinity.set(&before);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
|
||||
@@ -1242,6 +1242,7 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
if (active_launch_ != nullptr) {
|
||||
completed_result = active_launch_->result;
|
||||
game_active_ = completed_result.ok();
|
||||
window::SetGameSessionActive(game_active_);
|
||||
active_game_canonical_json_ =
|
||||
completed_result.ok() ? active_launch_->request.canonical_json
|
||||
: std::string();
|
||||
@@ -1279,6 +1280,7 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
return observed;
|
||||
}
|
||||
game_active_ = false;
|
||||
window::SetGameSessionActive(false);
|
||||
active_game_canonical_json_.clear();
|
||||
presence_request_.reset();
|
||||
playing_presence_published_ = false;
|
||||
@@ -1454,6 +1456,7 @@ Status RobloxExperienceComposition::LeaveGame() {
|
||||
if (status.ok()) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
game_active_ = false;
|
||||
window::SetGameSessionActive(false);
|
||||
active_game_canonical_json_.clear();
|
||||
}
|
||||
return status;
|
||||
@@ -1552,6 +1555,7 @@ Status RobloxExperienceComposition::Shutdown() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
subscribed_ = false;
|
||||
game_active_ = false;
|
||||
window::SetGameSessionActive(false);
|
||||
active_game_canonical_json_.clear();
|
||||
presence_request_.reset();
|
||||
playing_presence_published_ = false;
|
||||
|
||||
@@ -316,6 +316,7 @@ Status RobloxInputRouter::Activate(RobloxInputViewport viewport,
|
||||
gamepads_.clear();
|
||||
mouse_x_ = 0.0f;
|
||||
mouse_y_ = 0.0f;
|
||||
pointer_anchored_ = false;
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
@@ -549,20 +550,21 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseMotionLocked(
|
||||
const float delta_x = transform.HostLogicalToGuestX(event.delta_x);
|
||||
const float delta_y = transform.HostLogicalToGuestY(event.delta_y);
|
||||
|
||||
const float max_x = std::max(0.0F, transform.guest_width() - 1.0F);
|
||||
const float max_y = std::max(0.0F, transform.guest_height() - 1.0F);
|
||||
|
||||
// mouse_x_/mouse_y_ stay inside the viewport. Relative-mode motion reports
|
||||
// zero absolute coordinates, so the accumulated position is the only
|
||||
// position there; letting it drift past the edge would swallow every
|
||||
// reversal until the pointer walked all the way back.
|
||||
if (event.x == 0.0f && event.y == 0.0f &&
|
||||
(delta_x != 0.0f || delta_y != 0.0f)) {
|
||||
mouse_x_ = std::clamp(mouse_x_ + delta_x, 0.0f, max_x);
|
||||
mouse_y_ = std::clamp(mouse_y_ + delta_y, 0.0f, max_y);
|
||||
// The pointer holds its captured position while the delta passes through, so
|
||||
// a long camera turn neither parks the reported position at the viewport edge
|
||||
// nor swallows the reversal. Roblox draws its cursor at the held position
|
||||
// when the capture ends, which matches where the host pointer is warped.
|
||||
// Capture is read from the event, not inferred: only X11 zeroes the absolute
|
||||
// coordinates, while Wayland reports a position that accumulates the deltas.
|
||||
if (event.relative || (event.x == 0.0f && event.y == 0.0f &&
|
||||
(delta_x != 0.0f || delta_y != 0.0f))) {
|
||||
HoldPointerLocked(transform);
|
||||
} else {
|
||||
const float max_x = std::max(0.0F, transform.guest_width() - 1.0F);
|
||||
const float max_y = std::max(0.0F, transform.guest_height() - 1.0F);
|
||||
mouse_x_ = std::clamp(transform.HostLogicalToGuestX(event.x), 0.0f, max_x);
|
||||
mouse_y_ = std::clamp(transform.HostLogicalToGuestY(event.y), 0.0f, max_y);
|
||||
pointer_anchored_ = true;
|
||||
}
|
||||
|
||||
// Raw mouse reports the host's own delta, skipping the guest-surface scale
|
||||
@@ -590,19 +592,22 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseButtonLocked(
|
||||
RobloxInputEventKind::kMouseButton,
|
||||
Unsupported("SDL mouse button has no Android mapping"));
|
||||
}
|
||||
if (event.x > 0.0f || event.y > 0.0f ||
|
||||
(mouse_x_ == 0.0f && mouse_y_ == 0.0f)) {
|
||||
const platform::SurfaceCoordinateTransform transform =
|
||||
CoordinateTransform(snapshot_.viewport);
|
||||
mouse_x_ = transform.HostLogicalToGuestX(event.x);
|
||||
mouse_y_ = transform.HostLogicalToGuestY(event.y);
|
||||
}
|
||||
const platform::SurfaceCoordinateTransform transform =
|
||||
CoordinateTransform(snapshot_.viewport);
|
||||
const float max_x = std::max(0.0F, transform.guest_width() - 1.0F);
|
||||
const float max_y = std::max(0.0F, transform.guest_height() - 1.0F);
|
||||
const float clamped_x = std::clamp(mouse_x_, 0.0f, max_x);
|
||||
const float clamped_y = std::clamp(mouse_y_, 0.0f, max_y);
|
||||
// Button events during pointer capture must not move the held position.
|
||||
// Their coordinates are zeroed on X11 and drifted on Wayland, so the capture
|
||||
// flag decides.
|
||||
if (!event.relative && (event.x > 0.0f || event.y > 0.0f)) {
|
||||
mouse_x_ = std::clamp(transform.HostLogicalToGuestX(event.x), 0.0f, max_x);
|
||||
mouse_y_ = std::clamp(transform.HostLogicalToGuestY(event.y), 0.0f, max_y);
|
||||
pointer_anchored_ = true;
|
||||
} else {
|
||||
HoldPointerLocked(transform);
|
||||
}
|
||||
const float clamped_x = mouse_x_;
|
||||
const float clamped_y = mouse_y_;
|
||||
if (event.pressed) {
|
||||
const RobloxTextEditorSnapshot text = text_editor_.Snapshot();
|
||||
if (text.focused) {
|
||||
@@ -635,13 +640,39 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseWheelLocked(
|
||||
}
|
||||
const platform::SurfaceCoordinateTransform transform =
|
||||
CoordinateTransform(snapshot_.viewport);
|
||||
mouse_x_ = transform.HostLogicalToGuestX(std::max(0.0f, event.mouse_x));
|
||||
mouse_y_ = transform.HostLogicalToGuestY(std::max(0.0f, event.mouse_y));
|
||||
// Wheel events during pointer capture must not move the held position.
|
||||
// Their coordinates are zeroed on X11 and drifted on Wayland, so the capture
|
||||
// flag decides.
|
||||
if (!event.relative && (event.mouse_x > 0.0f || event.mouse_y > 0.0f)) {
|
||||
const float max_x = std::max(0.0F, transform.guest_width() - 1.0F);
|
||||
const float max_y = std::max(0.0F, transform.guest_height() - 1.0F);
|
||||
mouse_x_ = std::clamp(transform.HostLogicalToGuestX(event.mouse_x), 0.0f,
|
||||
max_x);
|
||||
mouse_y_ = std::clamp(transform.HostLogicalToGuestY(event.mouse_y), 0.0f,
|
||||
max_y);
|
||||
pointer_anchored_ = true;
|
||||
} else {
|
||||
HoldPointerLocked(transform);
|
||||
}
|
||||
return NativeResultLocked(
|
||||
sink_.mouse_wheel(sink_.context, mouse_x_, mouse_y_, event.delta_y),
|
||||
RobloxInputEventKind::kMouseWheel);
|
||||
}
|
||||
|
||||
void RobloxInputRouter::HoldPointerLocked(
|
||||
const platform::SurfaceCoordinateTransform& transform) {
|
||||
const float max_x = std::max(0.0F, transform.guest_width() - 1.0F);
|
||||
const float max_y = std::max(0.0F, transform.guest_height() - 1.0F);
|
||||
if (!pointer_anchored_) {
|
||||
mouse_x_ = max_x / 2.0f;
|
||||
mouse_y_ = max_y / 2.0f;
|
||||
pointer_anchored_ = true;
|
||||
return;
|
||||
}
|
||||
mouse_x_ = std::clamp(mouse_x_, 0.0f, max_x);
|
||||
mouse_y_ = std::clamp(mouse_y_, 0.0f, max_y);
|
||||
}
|
||||
|
||||
RobloxInputDispatchResult RobloxInputRouter::HandleTouchLocked(
|
||||
const platform::TouchEvent& event) {
|
||||
if (sink_.touch == nullptr) {
|
||||
|
||||
@@ -244,7 +244,7 @@ struct TextBytePosition {
|
||||
};
|
||||
|
||||
TextBytePosition TextPosition(TTF_Text* layout, std::size_t byte_offset,
|
||||
int text_width) {
|
||||
int text_width, bool single_line) {
|
||||
if (layout == nullptr) {
|
||||
return {};
|
||||
}
|
||||
@@ -259,7 +259,11 @@ TextBytePosition TextPosition(TTF_Text* layout, std::size_t byte_offset,
|
||||
position.y = substring.rect.y;
|
||||
position.height = substring.rect.h;
|
||||
if ((substring.flags & TTF_SUBSTRING_TEXT_END) != 0) {
|
||||
position.x = std::min(text_width, substring.rect.x + substring.rect.w);
|
||||
// Trailing whitespace has no ink, so the final cluster rect stops short of
|
||||
// the laid-out width that a caret at the end of a single line sits behind.
|
||||
position.x = single_line ? text_width
|
||||
: std::min(text_width,
|
||||
substring.rect.x + substring.rect.w);
|
||||
}
|
||||
return position;
|
||||
}
|
||||
@@ -297,6 +301,7 @@ Status RobloxTextSurfaceOverlay::Shutdown() {
|
||||
}
|
||||
state_.Reset();
|
||||
ClearFrameLocked();
|
||||
CloseFontsLocked();
|
||||
viewport_ = {};
|
||||
initialized_ = false;
|
||||
if (owned_active_overlay) {
|
||||
@@ -441,10 +446,6 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
const bool wrapped_layout =
|
||||
presentation.multiline || presentation.text_wrapped;
|
||||
|
||||
if (!TTF_Init()) {
|
||||
SecureClear(&candidate);
|
||||
return RasterizationFailure("unable to initialize SDL3_ttf");
|
||||
}
|
||||
const RobloxTextFontSelection font_selection =
|
||||
ResolveRobloxTextFont(presentation.font);
|
||||
const float native_point_size =
|
||||
@@ -454,23 +455,12 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
? std::clamp(native_point_size * font_selection.size_scale, 1.0F,
|
||||
512.0F)
|
||||
: std::clamp(static_cast<float>(height) * 0.38F, 12.0F, 28.0F);
|
||||
const std::vector<std::string> font_files = ResolveFontFiles(font_selection);
|
||||
std::vector<TTF_Font*> fonts;
|
||||
fonts.reserve(font_files.size());
|
||||
for (const std::string& file : font_files) {
|
||||
TTF_Font* font = TTF_OpenFont(file.c_str(), point_size);
|
||||
if (font != nullptr) {
|
||||
fonts.push_back(font);
|
||||
}
|
||||
}
|
||||
if (fonts.empty()) {
|
||||
TTF_Quit();
|
||||
if (Status status = OpenFontsLocked(presentation.font, point_size);
|
||||
!status.ok()) {
|
||||
SecureClear(&candidate);
|
||||
return RasterizationFailure("unable to open a Roblox or fallback font");
|
||||
}
|
||||
for (std::size_t index = 1; index < fonts.size(); ++index) {
|
||||
(void)TTF_AddFallbackFont(fonts.front(), fonts[index]);
|
||||
return status;
|
||||
}
|
||||
const std::vector<TTF_Font*>& fonts = fonts_;
|
||||
if (wrapped_layout) {
|
||||
TTF_HorizontalAlignment alignment = TTF_HORIZONTAL_ALIGN_LEFT;
|
||||
if (presentation.x_alignment == 1) {
|
||||
@@ -501,11 +491,12 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
TextBytePosition selection_end_position;
|
||||
if (layout != nullptr) {
|
||||
(void)TTF_GetTextSize(layout, &text_width, &text_height);
|
||||
caret_position = TextPosition(layout, caret_byte, text_width);
|
||||
selection_begin_position =
|
||||
TextPosition(layout, selection_begin_byte, text_width);
|
||||
caret_position =
|
||||
TextPosition(layout, caret_byte, text_width, !wrapped_layout);
|
||||
selection_begin_position = TextPosition(layout, selection_begin_byte,
|
||||
text_width, !wrapped_layout);
|
||||
selection_end_position =
|
||||
TextPosition(layout, selection_end_byte, text_width);
|
||||
TextPosition(layout, selection_end_byte, text_width, !wrapped_layout);
|
||||
}
|
||||
if (!text.value.empty()) {
|
||||
// SDL_ttf implementations have historically differed on whether fg.a is
|
||||
@@ -624,11 +615,6 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
if (layout != nullptr) {
|
||||
TTF_DestroyText(layout);
|
||||
}
|
||||
TTF_ClearFallbackFonts(fonts.front());
|
||||
for (auto iterator = fonts.rbegin(); iterator != fonts.rend(); ++iterator) {
|
||||
TTF_CloseFont(*iterator);
|
||||
}
|
||||
TTF_Quit();
|
||||
|
||||
if (!status.ok()) {
|
||||
SecureClear(&candidate);
|
||||
@@ -646,6 +632,58 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status RobloxTextSurfaceOverlay::OpenFontsLocked(std::int32_t font,
|
||||
float point_size) {
|
||||
if (!fonts_.empty() && fonts_font_ == font &&
|
||||
fonts_point_size_ == point_size) {
|
||||
return Status::Ok();
|
||||
}
|
||||
if (!TTF_Init()) {
|
||||
return RasterizationFailure("unable to initialize SDL3_ttf");
|
||||
}
|
||||
const std::vector<std::string> files =
|
||||
ResolveFontFiles(ResolveRobloxTextFont(font));
|
||||
std::vector<TTF_Font*> opened;
|
||||
opened.reserve(files.size());
|
||||
for (const std::string& file : files) {
|
||||
TTF_Font* candidate = TTF_OpenFont(file.c_str(), point_size);
|
||||
if (candidate != nullptr) {
|
||||
opened.push_back(candidate);
|
||||
}
|
||||
}
|
||||
// A failed reopen leaves the fonts already rendering in place.
|
||||
if (opened.empty()) {
|
||||
TTF_Quit();
|
||||
return RasterizationFailure("unable to open a Roblox or fallback font");
|
||||
}
|
||||
for (std::size_t index = 1; index < opened.size(); ++index) {
|
||||
(void)TTF_AddFallbackFont(opened.front(), opened[index]);
|
||||
}
|
||||
CloseFontsLocked();
|
||||
fonts_ = std::move(opened);
|
||||
ttf_initialized_ = true;
|
||||
fonts_font_ = font;
|
||||
fonts_point_size_ = point_size;
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
void RobloxTextSurfaceOverlay::CloseFontsLocked() {
|
||||
if (!fonts_.empty()) {
|
||||
TTF_ClearFallbackFonts(fonts_.front());
|
||||
for (auto iterator = fonts_.rbegin(); iterator != fonts_.rend();
|
||||
++iterator) {
|
||||
TTF_CloseFont(*iterator);
|
||||
}
|
||||
fonts_.clear();
|
||||
}
|
||||
fonts_font_ = 0;
|
||||
fonts_point_size_ = 0.0F;
|
||||
if (ttf_initialized_) {
|
||||
TTF_Quit();
|
||||
ttf_initialized_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
void RobloxTextSurfaceOverlay::ClearFrameLocked() {
|
||||
SecureClear(&rgba_);
|
||||
raster_revision_ = 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <climits>
|
||||
#include <cstdlib>
|
||||
#include <optional>
|
||||
#include <tuple>
|
||||
|
||||
#ifndef MOCKTAIL_DISCORD_APPLICATION_ID
|
||||
#define MOCKTAIL_DISCORD_APPLICATION_ID ""
|
||||
@@ -40,13 +41,17 @@ bool IsDiscordApplicationId(std::string_view value) {
|
||||
}));
|
||||
}
|
||||
|
||||
bool IsDiscordText(std::string_view value, std::size_t maximum) {
|
||||
return !value.empty() && value.size() <= maximum &&
|
||||
bool IsDiscordOptionalText(std::string_view value, std::size_t maximum) {
|
||||
return value.size() <= maximum &&
|
||||
std::none_of(value.begin(), value.end(), [](unsigned char byte) {
|
||||
return byte < 0x20 || byte == 0x7f;
|
||||
});
|
||||
}
|
||||
|
||||
bool IsDiscordText(std::string_view value, std::size_t maximum) {
|
||||
return !value.empty() && IsDiscordOptionalText(value, maximum);
|
||||
}
|
||||
|
||||
bool ReadBoolean(const Environment& environment, std::string_view name,
|
||||
bool default_value, bool* valid) {
|
||||
const std::optional<std::string> value = environment.Get(name);
|
||||
@@ -357,18 +362,40 @@ RuntimeConfig RuntimeConfig::FromEnvironment(const Environment& environment) {
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_JOINING", config.discord_rpc_.text.joining);
|
||||
config.discord_rpc_.text.playing = environment.GetOr(
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_PLAYING", config.discord_rpc_.text.playing);
|
||||
config.discord_rpc_.text.state = environment.GetOr(
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_STATE", config.discord_rpc_.text.state);
|
||||
config.discord_rpc_.text.unknown_place = environment.GetOr(
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_UNKNOWN_PLACE",
|
||||
config.discord_rpc_.text.unknown_place);
|
||||
config.discord_rpc_valid_ = discord_booleans_valid &&
|
||||
// An empty presence field hides it, so only an unset variable keeps the
|
||||
// default.
|
||||
bool discord_presence_valid = true;
|
||||
for (const auto& [variable, field, maximum] : {
|
||||
std::tuple<std::string_view, std::string*, std::size_t>(
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_TITLE",
|
||||
&config.discord_rpc_.text.title, 128),
|
||||
{"MOCKTAIL_DISCORD_RPC_TEXT_STATE",
|
||||
&config.discord_rpc_.text.state, 128},
|
||||
{"MOCKTAIL_DISCORD_RPC_IMAGE_LARGE",
|
||||
&config.discord_rpc_.images.large, 512},
|
||||
{"MOCKTAIL_DISCORD_RPC_IMAGE_LARGE_TEXT",
|
||||
&config.discord_rpc_.images.large_text, 128},
|
||||
{"MOCKTAIL_DISCORD_RPC_IMAGE_SMALL",
|
||||
&config.discord_rpc_.images.small, 512},
|
||||
{"MOCKTAIL_DISCORD_RPC_IMAGE_SMALL_TEXT",
|
||||
&config.discord_rpc_.images.small_text, 128},
|
||||
}) {
|
||||
if (std::optional<std::string> value = environment.Get(variable)) {
|
||||
*field = std::move(*value);
|
||||
}
|
||||
discord_presence_valid =
|
||||
discord_presence_valid && IsDiscordOptionalText(*field, maximum);
|
||||
}
|
||||
config.discord_rpc_valid_ =
|
||||
discord_booleans_valid && discord_presence_valid &&
|
||||
IsDiscordApplicationId(config.discord_rpc_.application_id) &&
|
||||
IsDiscordText(config.discord_rpc_.join_button_label, 32) &&
|
||||
IsDiscordText(config.discord_rpc_.text.browsing, 128) &&
|
||||
IsDiscordText(config.discord_rpc_.text.joining, 128) &&
|
||||
IsDiscordText(config.discord_rpc_.text.playing, 128) &&
|
||||
IsDiscordText(config.discord_rpc_.text.state, 128) &&
|
||||
IsDiscordText(config.discord_rpc_.text.unknown_place, 128);
|
||||
for (const std::string_view name : kUnsafeDetachedThreadOverrides) {
|
||||
if (LegacyEnabled(environment, name)) {
|
||||
|
||||
@@ -131,12 +131,24 @@ integrations:
|
||||
public_servers_only: true
|
||||
# button_label: Join Server
|
||||
# English defaults. Uncomment only the lines you want to customize.
|
||||
# Text and image fields accept {place_name}, {place_icon}, and
|
||||
# {creator_name}. A field that expands an empty placeholder is left out,
|
||||
# and a "" title shows the app name.
|
||||
# title, state, and the images fields may be "" to hide them.
|
||||
# text:
|
||||
# browsing: Browsing experiences
|
||||
# joining: Joining an experience
|
||||
# playing: "{place_name}"
|
||||
# state: Playing Roblox
|
||||
# playing: "Playing {place_name}"
|
||||
# state: "by {creator_name}"
|
||||
# unknown_place: Unknown experience
|
||||
# title: Roblox
|
||||
# Images take an https:// URL, or an asset key uploaded to application_id.
|
||||
# large: "" hides both images; the small image needs a large one.
|
||||
# images:
|
||||
# large: "{place_icon}"
|
||||
# large_text: "{place_name}"
|
||||
# small: roblox_small
|
||||
# small_text: Roblox
|
||||
# Advanced: overrides the application bundled by the package maintainer.
|
||||
# application_id: 123456789012345678
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment,
|
||||
"integrations.discord_rpc.text.playing",
|
||||
"integrations.discord_rpc.text.state",
|
||||
"integrations.discord_rpc.text.unknown_place",
|
||||
"integrations.discord_rpc.text.title",
|
||||
"integrations.discord_rpc.images.large",
|
||||
"integrations.discord_rpc.images.large_text",
|
||||
"integrations.discord_rpc.images.small",
|
||||
"integrations.discord_rpc.images.small_text",
|
||||
};
|
||||
for (const auto& [key, ignored] : yaml) {
|
||||
if (supported.find(key) == supported.end()) {
|
||||
@@ -554,8 +559,6 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment,
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_JOINING", 128},
|
||||
{"integrations.discord_rpc.text.playing",
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_PLAYING", 128},
|
||||
{"integrations.discord_rpc.text.state",
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_STATE", 128},
|
||||
{"integrations.discord_rpc.text.unknown_place",
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_UNKNOWN_PLACE", 128},
|
||||
}) {
|
||||
@@ -574,6 +577,36 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment,
|
||||
}
|
||||
(*environment)[std::string(field.variable)] = *configured;
|
||||
}
|
||||
// Presence fields may be empty: an empty value hides that field.
|
||||
for (const DiscordStringField& field : {
|
||||
DiscordStringField{"integrations.discord_rpc.text.title",
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_TITLE", 128},
|
||||
{"integrations.discord_rpc.text.state",
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_STATE", 128},
|
||||
{"integrations.discord_rpc.images.large",
|
||||
"MOCKTAIL_DISCORD_RPC_IMAGE_LARGE", 512},
|
||||
{"integrations.discord_rpc.images.large_text",
|
||||
"MOCKTAIL_DISCORD_RPC_IMAGE_LARGE_TEXT", 128},
|
||||
{"integrations.discord_rpc.images.small",
|
||||
"MOCKTAIL_DISCORD_RPC_IMAGE_SMALL", 512},
|
||||
{"integrations.discord_rpc.images.small_text",
|
||||
"MOCKTAIL_DISCORD_RPC_IMAGE_SMALL_TEXT", 128},
|
||||
}) {
|
||||
const std::optional<std::string> configured = value(field.yaml);
|
||||
if (!configured.has_value()) {
|
||||
continue;
|
||||
}
|
||||
if (configured->size() > field.maximum ||
|
||||
std::any_of(configured->begin(), configured->end(),
|
||||
[](unsigned char byte) {
|
||||
return byte < 0x20 || byte == 0x7f;
|
||||
})) {
|
||||
*error = std::string(field.yaml) +
|
||||
" must be bounded and contain no control bytes";
|
||||
return false;
|
||||
}
|
||||
(*environment)[std::string(field.variable)] = *configured;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -940,7 +973,17 @@ bool ExportRuntimeConfigEnvironment(const RuntimeConfig& config,
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_TEXT_STATE",
|
||||
config.discord_rpc().text.state, error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_TEXT_UNKNOWN_PLACE",
|
||||
config.discord_rpc().text.unknown_place, error);
|
||||
config.discord_rpc().text.unknown_place, error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_TEXT_TITLE",
|
||||
config.discord_rpc().text.title, error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_IMAGE_LARGE",
|
||||
config.discord_rpc().images.large, error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_IMAGE_LARGE_TEXT",
|
||||
config.discord_rpc().images.large_text, error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_IMAGE_SMALL",
|
||||
config.discord_rpc().images.small, error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_IMAGE_SMALL_TEXT",
|
||||
config.discord_rpc().images.small_text, error);
|
||||
if (!base_exported) {
|
||||
return false;
|
||||
}
|
||||
|
||||
+43
-9
@@ -151,13 +151,33 @@ static std::unique_ptr<WindowResizeReadinessGate> g_resize_readiness_gate;
|
||||
static std::unique_ptr<SdlTextInputBackend> g_text_input_backend;
|
||||
static std::unique_ptr<WindowTextInputOwner> g_text_input_owner;
|
||||
static std::unique_ptr<SdlPointerCaptureBackend> g_pointer_capture_backend;
|
||||
static std::unique_ptr<WindowPointerCaptureOwner> g_pointer_capture_owner;
|
||||
// The owner is created and destroyed on the SDL thread, but the experience
|
||||
// composition reaches it from its own thread. The mutex guards the pointer
|
||||
// only; a caller off the SDL thread holds a strong reference for its call.
|
||||
static std::mutex g_pointer_capture_owner_mutex;
|
||||
static std::shared_ptr<WindowPointerCaptureOwner> g_pointer_capture_owner;
|
||||
static std::unique_ptr<graphics::GlesTextOverlayCompositor> g_gles_text_overlay;
|
||||
static char g_preferred_egl_library[4096];
|
||||
static char g_preferred_gles_library[4096];
|
||||
static bool g_auto_angle_retry_attempted = false;
|
||||
static std::filesystem::path g_window_state_path;
|
||||
|
||||
// Keeps the owner alive past a concurrent Shutdown on the SDL thread.
|
||||
static std::shared_ptr<WindowPointerCaptureOwner> SharedPointerCaptureOwner() {
|
||||
std::lock_guard<std::mutex> lock(g_pointer_capture_owner_mutex);
|
||||
return g_pointer_capture_owner;
|
||||
}
|
||||
|
||||
// Drops the reference under the lock and destroys the owner outside it, so a
|
||||
// concurrent caller finishing its call cannot block the lock.
|
||||
static void ResetPointerCaptureOwner() {
|
||||
std::shared_ptr<WindowPointerCaptureOwner> owner;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_pointer_capture_owner_mutex);
|
||||
owner.swap(g_pointer_capture_owner);
|
||||
}
|
||||
}
|
||||
|
||||
WindowStartupPresentationPlan RestoredWindowPresentationPlan() {
|
||||
if (!g_state.state_persistence_active) {
|
||||
return {};
|
||||
@@ -865,8 +885,11 @@ bool ActivateWindowEventLifecycles() {
|
||||
std::make_unique<WindowTextInputOwner>(g_text_input_backend.get());
|
||||
g_pointer_capture_backend =
|
||||
std::make_unique<SdlPointerCaptureBackend>(g_state.sdl_window);
|
||||
g_pointer_capture_owner = std::make_unique<WindowPointerCaptureOwner>(
|
||||
g_pointer_capture_backend.get());
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_pointer_capture_owner_mutex);
|
||||
g_pointer_capture_owner = std::make_shared<WindowPointerCaptureOwner>(
|
||||
g_pointer_capture_backend.get());
|
||||
}
|
||||
fprintf(stderr,
|
||||
" [input] SDL event pump target=%llu Hz raw-motion, no smoothing\n",
|
||||
static_cast<unsigned long long>(kProductionInputPumpHz));
|
||||
@@ -1484,13 +1507,24 @@ bool SetPlatformEventObserver(PlatformEventObserver observer, void* context) {
|
||||
void ClearPlatformEventObserver() { g_platform_event_observer.Clear(); }
|
||||
|
||||
bool SetMouseLockQueryCallback(MouseLockQueryCallback callback, void* context) {
|
||||
return g_pointer_capture_owner != nullptr &&
|
||||
g_pointer_capture_owner->RegisterQuery(callback, context);
|
||||
const std::shared_ptr<WindowPointerCaptureOwner> owner =
|
||||
SharedPointerCaptureOwner();
|
||||
return owner != nullptr && owner->RegisterQuery(callback, context);
|
||||
}
|
||||
|
||||
void SetGameSessionActive(bool active) {
|
||||
const std::shared_ptr<WindowPointerCaptureOwner> owner =
|
||||
SharedPointerCaptureOwner();
|
||||
if (owner != nullptr) {
|
||||
owner->SetGameSessionActive(active);
|
||||
}
|
||||
}
|
||||
|
||||
void ClearMouseLockQueryCallback() {
|
||||
if (g_pointer_capture_owner != nullptr) {
|
||||
g_pointer_capture_owner->ClearQuery();
|
||||
const std::shared_ptr<WindowPointerCaptureOwner> owner =
|
||||
SharedPointerCaptureOwner();
|
||||
if (owner != nullptr) {
|
||||
owner->ClearQuery();
|
||||
}
|
||||
}
|
||||
bool SetPreTextInputPumpCallback(PreTextInputPumpCallback callback,
|
||||
@@ -2336,7 +2370,7 @@ void Shutdown() {
|
||||
if (!g_state.initialised) {
|
||||
g_fullscreen_request_gate.Reset();
|
||||
g_fullscreen_menu_request_gate.Reset();
|
||||
g_pointer_capture_owner.reset();
|
||||
ResetPointerCaptureOwner();
|
||||
g_pointer_capture_backend.reset();
|
||||
g_text_input_owner.reset();
|
||||
g_text_input_backend.reset();
|
||||
@@ -2355,7 +2389,7 @@ void Shutdown() {
|
||||
!g_pointer_capture_owner->Shutdown()) {
|
||||
fprintf(stderr, " [input] SDL pointer capture shutdown failed\n");
|
||||
}
|
||||
g_pointer_capture_owner.reset();
|
||||
ResetPointerCaptureOwner();
|
||||
g_pointer_capture_backend.reset();
|
||||
g_text_input_owner.reset();
|
||||
g_text_input_backend.reset();
|
||||
|
||||
@@ -83,6 +83,10 @@ void ClearPlatformEventObserver();
|
||||
bool SetMouseLockQueryCallback(MouseLockQueryCallback callback, void* context);
|
||||
void ClearMouseLockQueryCallback();
|
||||
|
||||
// Gates the RMB camera-drag fallback, which only stands in for a camera and
|
||||
// must not capture over the app surface's own UI.
|
||||
void SetGameSessionActive(bool active);
|
||||
|
||||
// Clear waits for a pre-input callback already in flight.
|
||||
bool SetPreTextInputPumpCallback(PreTextInputPumpCallback callback,
|
||||
void* context);
|
||||
|
||||
@@ -95,7 +95,7 @@ bool WindowPointerCaptureOwner::Pump(bool text_input_active) {
|
||||
}
|
||||
const bool client_active = query_ok;
|
||||
const bool native_capture_active = client_active && native_lock_active;
|
||||
const bool right_drag_active = right_button_held_;
|
||||
const bool right_drag_active = right_button_held_ && game_session_active_;
|
||||
// Roblox can keep Shift Lock active while chat has text focus. Honor that
|
||||
// native lock so camera motion stays relative and cannot leave the window.
|
||||
// Text editing only suppresses the host's fallback RMB camera capture.
|
||||
@@ -116,6 +116,10 @@ bool WindowPointerCaptureOwner::OnRightButton(bool pressed,
|
||||
return Pump(text_input_active);
|
||||
}
|
||||
|
||||
void WindowPointerCaptureOwner::SetGameSessionActive(bool active) {
|
||||
game_session_active_ = active;
|
||||
}
|
||||
|
||||
bool WindowPointerCaptureOwner::OnShiftKeyPressed(bool text_input_active) {
|
||||
focused_ = true;
|
||||
return Pump(text_input_active);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef MOCKTAIL_WINDOW_WINDOW_POINTER_CAPTURE_OWNER_H_
|
||||
#define MOCKTAIL_WINDOW_WINDOW_POINTER_CAPTURE_OWNER_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <mutex>
|
||||
@@ -31,6 +32,10 @@ class WindowPointerCaptureOwner final {
|
||||
// Android's lock-center query does not represent desktop RMB camera drag,
|
||||
// so the SDL owner tracks that transient capture source independently.
|
||||
bool OnRightButton(bool pressed, bool text_input_active);
|
||||
// The RMB fallback only stands in for a camera. Outside an experience there
|
||||
// is none, and capturing there freezes the cursor over Roblox's own UI.
|
||||
// Starts false: the app surface is what the client shows first.
|
||||
void SetGameSessionActive(bool active);
|
||||
// Shift can enable Roblox's persistent mouse lock while a transient RMB
|
||||
// camera drag is active. Refresh the native state after dispatching that
|
||||
// key so the subsequent RMB release preserves the intentional lock.
|
||||
@@ -62,6 +67,8 @@ class WindowPointerCaptureOwner final {
|
||||
bool clearing_ = false;
|
||||
bool focused_ = true;
|
||||
bool right_button_held_ = false;
|
||||
// Written by the experience composition, read on the SDL thread.
|
||||
std::atomic<bool> game_session_active_{false};
|
||||
bool native_lock_observed_ = false;
|
||||
bool native_lock_was_active_before_right_drag_ = false;
|
||||
bool shift_key_pressed_during_right_drag_ = false;
|
||||
|
||||
@@ -106,6 +106,7 @@ target_link_libraries(stub_glesv2 PRIVATE dl)
|
||||
# The host Vulkan loader owns device commands; this target adapts Android WSI.
|
||||
add_library(stub_vulkan SHARED
|
||||
${CMAKE_SOURCE_DIR}/src/graphics/bionic_vulkan_loader_adapter.cc
|
||||
${CMAKE_SOURCE_DIR}/src/graphics/chrome_trace_writer.cc
|
||||
${CMAKE_SOURCE_DIR}/src/graphics/vulkan_text_overlay_compositor.cc
|
||||
${CMAKE_SOURCE_DIR}/src/graphics/vulkan_etc2_emulation.cc
|
||||
${CMAKE_SOURCE_DIR}/src/graphics/etc2_decoder.cc
|
||||
|
||||
@@ -208,6 +208,13 @@ target_link_libraries(external_launch_broker_test PRIVATE
|
||||
)
|
||||
mocktail_apply_compile_options(external_launch_broker_test)
|
||||
|
||||
add_executable(engine_pump_rest_test engine_pump_rest_test.cc)
|
||||
target_link_libraries(engine_pump_rest_test PRIVATE
|
||||
Mocktail::Runtime
|
||||
GTest::gtest_main
|
||||
)
|
||||
mocktail_apply_compile_options(engine_pump_rest_test)
|
||||
|
||||
add_executable(supported_launch_policy_test supported_launch_policy_test.cc)
|
||||
target_link_libraries(supported_launch_policy_test PRIVATE
|
||||
Mocktail::Runtime
|
||||
@@ -454,6 +461,16 @@ target_link_libraries(input_pump_pacer_test PRIVATE
|
||||
)
|
||||
mocktail_apply_compile_options(input_pump_pacer_test)
|
||||
|
||||
add_executable(input_pump_pacing_runtime_test
|
||||
input_pump_pacing_runtime_test.cc
|
||||
)
|
||||
target_link_libraries(input_pump_pacing_runtime_test PRIVATE
|
||||
mocktail_window
|
||||
GTest::gtest_main
|
||||
Threads::Threads
|
||||
)
|
||||
mocktail_apply_compile_options(input_pump_pacing_runtime_test)
|
||||
|
||||
add_executable(vulkan_surface_recovery_gate_test
|
||||
vulkan_surface_recovery_gate_test.cc
|
||||
)
|
||||
@@ -513,9 +530,21 @@ target_link_libraries(texture_override_test PRIVATE GTest::gtest_main
|
||||
PkgConfig::LIBPNG)
|
||||
mocktail_apply_compile_options(texture_override_test)
|
||||
|
||||
add_executable(chrome_trace_writer_test
|
||||
chrome_trace_writer_test.cc
|
||||
${PROJECT_SOURCE_DIR}/src/graphics/chrome_trace_writer.cc
|
||||
)
|
||||
target_include_directories(chrome_trace_writer_test PRIVATE
|
||||
${PROJECT_SOURCE_DIR}/include
|
||||
)
|
||||
target_link_libraries(chrome_trace_writer_test PRIVATE GTest::gtest_main
|
||||
Threads::Threads nlohmann_json::nlohmann_json)
|
||||
mocktail_apply_compile_options(chrome_trace_writer_test)
|
||||
|
||||
add_executable(vulkan_etc2_emulation_test
|
||||
vulkan_etc2_emulation_test.cc
|
||||
${PROJECT_SOURCE_DIR}/src/graphics/vulkan_etc2_emulation.cc
|
||||
${PROJECT_SOURCE_DIR}/src/graphics/chrome_trace_writer.cc
|
||||
${PROJECT_SOURCE_DIR}/src/graphics/etc2_decoder.cc
|
||||
${PROJECT_SOURCE_DIR}/src/graphics/texture_override.cc
|
||||
)
|
||||
@@ -964,6 +993,7 @@ gtest_discover_tests(browser_tracker_service_test)
|
||||
gtest_discover_tests(command_line_test)
|
||||
gtest_discover_tests(roblox_launch_uri_test)
|
||||
gtest_discover_tests(external_launch_broker_test)
|
||||
gtest_discover_tests(engine_pump_rest_test)
|
||||
gtest_discover_tests(webview_helper_launcher_test)
|
||||
gtest_discover_tests(webview_helper_policy_test)
|
||||
gtest_discover_tests(webview_roblox_cookie_test)
|
||||
@@ -985,10 +1015,12 @@ gtest_discover_tests(jnivm_native_text_box_info_test)
|
||||
gtest_discover_tests(jnivm_webrtc_audio_manager_test)
|
||||
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(vulkan_present_progress_gate_test)
|
||||
gtest_discover_tests(vulkan_text_overlay_compositor_test)
|
||||
gtest_discover_tests(etc2_decoder_test)
|
||||
gtest_discover_tests(chrome_trace_writer_test)
|
||||
gtest_discover_tests(vulkan_etc2_emulation_test)
|
||||
gtest_discover_tests(video_driver_policy_test)
|
||||
gtest_discover_tests(window_surface_lifecycle_test)
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
#include "mocktail/graphics/chrome_trace_writer.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <signal.h>
|
||||
#include <sys/resource.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail {
|
||||
namespace graphics {
|
||||
namespace {
|
||||
|
||||
class TemporaryDirectory final {
|
||||
public:
|
||||
TemporaryDirectory() {
|
||||
char pattern[] = "/tmp/mocktail_chrome_trace_XXXXXX";
|
||||
const char* created = mkdtemp(pattern);
|
||||
if (created != nullptr) root_ = created;
|
||||
}
|
||||
|
||||
~TemporaryDirectory() {
|
||||
std::error_code error;
|
||||
std::filesystem::remove_all(root_, error);
|
||||
}
|
||||
|
||||
const std::filesystem::path& root() const { return root_; }
|
||||
|
||||
private:
|
||||
std::filesystem::path root_;
|
||||
};
|
||||
|
||||
std::string ReadFile(const std::filesystem::path& path) {
|
||||
std::ifstream input(path, std::ios::binary);
|
||||
std::ostringstream content;
|
||||
content << input.rdbuf();
|
||||
return content.str();
|
||||
}
|
||||
|
||||
nlohmann::json ParseTrace(const std::filesystem::path& path) {
|
||||
return nlohmann::json::parse(ReadFile(path));
|
||||
}
|
||||
|
||||
std::unique_ptr<ChromeTraceWriter> OpenOrFail(
|
||||
const std::filesystem::path& path) {
|
||||
std::string error;
|
||||
auto writer = ChromeTraceWriter::Open(path, &error);
|
||||
EXPECT_NE(writer, nullptr) << error;
|
||||
return writer;
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, WritesSliceAsCompleteEventInMicroseconds) {
|
||||
TemporaryDirectory directory;
|
||||
const auto path = directory.root() / "trace.json";
|
||||
auto writer = OpenOrFail(path);
|
||||
ASSERT_NE(writer, nullptr);
|
||||
|
||||
writer->Slice("vkQueuePresentKHR", "present", 5'000'123'456ULL,
|
||||
5'002'623'456ULL, {{"frame", 7}, {"result", -4}});
|
||||
writer->Close();
|
||||
|
||||
const nlohmann::json trace = ParseTrace(path);
|
||||
ASSERT_TRUE(trace.is_array());
|
||||
ASSERT_EQ(trace.size(), 1u);
|
||||
const nlohmann::json& event = trace[0];
|
||||
EXPECT_EQ(event["name"], "vkQueuePresentKHR");
|
||||
EXPECT_EQ(event["cat"], "present");
|
||||
EXPECT_EQ(event["ph"], "X");
|
||||
EXPECT_EQ(event["ts"], 5'000'123);
|
||||
EXPECT_EQ(event["dur"], 2'500);
|
||||
EXPECT_EQ(event["pid"], static_cast<int>(getpid()));
|
||||
EXPECT_EQ(event["tid"], static_cast<int>(gettid()));
|
||||
EXPECT_EQ(event["args"]["frame"], 7);
|
||||
EXPECT_EQ(event["args"]["result"], -4);
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, WritesCounterSample) {
|
||||
TemporaryDirectory directory;
|
||||
const auto path = directory.root() / "trace.json";
|
||||
auto writer = OpenOrFail(path);
|
||||
ASSERT_NE(writer, nullptr);
|
||||
|
||||
writer->Counter("frame interval (ms)", 9'000'000ULL, 16.5);
|
||||
writer->Close();
|
||||
|
||||
const nlohmann::json trace = ParseTrace(path);
|
||||
ASSERT_EQ(trace.size(), 1u);
|
||||
EXPECT_EQ(trace[0]["name"], "frame interval (ms)");
|
||||
EXPECT_EQ(trace[0]["ph"], "C");
|
||||
EXPECT_EQ(trace[0]["ts"], 9'000);
|
||||
EXPECT_DOUBLE_EQ(trace[0]["args"]["value"].get<double>(), 16.5);
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, EmptyTraceIsAnEmptyArray) {
|
||||
TemporaryDirectory directory;
|
||||
const auto path = directory.root() / "trace.json";
|
||||
auto writer = OpenOrFail(path);
|
||||
ASSERT_NE(writer, nullptr);
|
||||
|
||||
writer->Close();
|
||||
|
||||
EXPECT_EQ(ParseTrace(path), nlohmann::json::array());
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, EscapesNames) {
|
||||
TemporaryDirectory directory;
|
||||
const auto path = directory.root() / "trace.json";
|
||||
auto writer = OpenOrFail(path);
|
||||
ASSERT_NE(writer, nullptr);
|
||||
|
||||
writer->Slice("say \"hi\"\\now", "c\ta", 0, 1000);
|
||||
writer->Close();
|
||||
|
||||
const nlohmann::json trace = ParseTrace(path);
|
||||
ASSERT_EQ(trace.size(), 1u);
|
||||
EXPECT_EQ(trace[0]["name"], "say \"hi\"\\now");
|
||||
EXPECT_EQ(trace[0]["cat"], "c\ta");
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, FileIsValidJsonBetweenWrites) {
|
||||
TemporaryDirectory directory;
|
||||
const auto path = directory.root() / "trace.json";
|
||||
auto writer = OpenOrFail(path);
|
||||
ASSERT_NE(writer, nullptr);
|
||||
|
||||
// A session killed at any of these points leaves a loadable trace.
|
||||
EXPECT_EQ(ParseTrace(path), nlohmann::json::array());
|
||||
writer->Slice("first", "test", 1000, 2000);
|
||||
writer->Flush();
|
||||
EXPECT_EQ(ParseTrace(path).size(), 1u);
|
||||
writer->Slice("second", "test", 3000, 4000);
|
||||
writer->Flush();
|
||||
|
||||
const nlohmann::json trace = ParseTrace(path);
|
||||
ASSERT_EQ(trace.size(), 2u);
|
||||
EXPECT_EQ(trace[0]["name"], "first");
|
||||
EXPECT_EQ(trace[1]["name"], "second");
|
||||
writer->Close();
|
||||
EXPECT_EQ(ParseTrace(path), trace);
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, DropsEventsAfterClose) {
|
||||
TemporaryDirectory directory;
|
||||
const auto path = directory.root() / "trace.json";
|
||||
auto writer = OpenOrFail(path);
|
||||
ASSERT_NE(writer, nullptr);
|
||||
|
||||
writer->Slice("kept", "test", 0, 1);
|
||||
writer->Close();
|
||||
writer->Slice("dropped", "test", 2, 3);
|
||||
writer->Counter("dropped", 4, 1.0);
|
||||
writer->Flush();
|
||||
writer->Close();
|
||||
|
||||
const nlohmann::json trace = ParseTrace(path);
|
||||
ASSERT_EQ(trace.size(), 1u);
|
||||
EXPECT_EQ(trace[0]["name"], "kept");
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, KeepsEveryEventFromConcurrentThreads) {
|
||||
TemporaryDirectory directory;
|
||||
const auto path = directory.root() / "trace.json";
|
||||
auto writer = OpenOrFail(path);
|
||||
ASSERT_NE(writer, nullptr);
|
||||
|
||||
constexpr int kThreads = 8;
|
||||
constexpr int kEventsPerThread = 20'000;
|
||||
std::vector<std::thread> threads;
|
||||
for (int thread = 0; thread < kThreads; ++thread) {
|
||||
threads.emplace_back([&writer, thread] {
|
||||
for (int event = 0; event < kEventsPerThread; ++event) {
|
||||
writer->Slice("work", "test", 1000, 2000,
|
||||
{{"thread", thread}, {"event", event}});
|
||||
}
|
||||
});
|
||||
}
|
||||
for (std::thread& thread : threads) {
|
||||
thread.join();
|
||||
}
|
||||
writer->Close();
|
||||
|
||||
const nlohmann::json trace = ParseTrace(path);
|
||||
ASSERT_EQ(trace.size(), static_cast<std::size_t>(kThreads) *
|
||||
kEventsPerThread);
|
||||
std::set<std::pair<int, int>> seen;
|
||||
std::set<int> tids;
|
||||
for (const nlohmann::json& event : trace) {
|
||||
seen.emplace(event["args"]["thread"].get<int>(),
|
||||
event["args"]["event"].get<int>());
|
||||
tids.insert(event["tid"].get<int>());
|
||||
}
|
||||
EXPECT_EQ(seen.size(), trace.size());
|
||||
EXPECT_EQ(tids.size(), static_cast<std::size_t>(kThreads));
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, DestructorClosesTheArray) {
|
||||
TemporaryDirectory directory;
|
||||
const auto path = directory.root() / "trace.json";
|
||||
{
|
||||
auto writer = OpenOrFail(path);
|
||||
ASSERT_NE(writer, nullptr);
|
||||
writer->Slice("only", "test", 0, 1000);
|
||||
}
|
||||
EXPECT_EQ(ParseTrace(path).size(), 1u);
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, OpenReportsUnwritablePath) {
|
||||
TemporaryDirectory directory;
|
||||
std::string error;
|
||||
|
||||
const auto writer = ChromeTraceWriter::Open(
|
||||
directory.root() / "missing" / "trace.json", &error);
|
||||
|
||||
EXPECT_EQ(writer, nullptr);
|
||||
EXPECT_NE(error.find("missing"), std::string::npos) << error;
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, OpenCreatesOrTruncatesTheFile) {
|
||||
TemporaryDirectory directory;
|
||||
const auto path = directory.root() / "trace.json";
|
||||
std::ofstream(path) << "stale content that is not json";
|
||||
|
||||
auto writer = OpenOrFail(path);
|
||||
ASSERT_NE(writer, nullptr);
|
||||
writer->Close();
|
||||
|
||||
EXPECT_EQ(ParseTrace(path), nlohmann::json::array());
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, ScopeRecordsItsLifetimeAndArgs) {
|
||||
TemporaryDirectory directory;
|
||||
const auto path = directory.root() / "trace.json";
|
||||
auto writer = OpenOrFail(path);
|
||||
ASSERT_NE(writer, nullptr);
|
||||
|
||||
const std::uint64_t before = TraceClockNanos();
|
||||
{
|
||||
TraceScope scope(writer.get(), "scoped", "test");
|
||||
scope.Arg("count", 3);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||
}
|
||||
const std::uint64_t after = TraceClockNanos();
|
||||
writer->Close();
|
||||
|
||||
const nlohmann::json trace = ParseTrace(path);
|
||||
ASSERT_EQ(trace.size(), 1u);
|
||||
const auto ts = trace[0]["ts"].get<std::uint64_t>();
|
||||
const auto dur = trace[0]["dur"].get<std::uint64_t>();
|
||||
EXPECT_GE(ts, before / 1000);
|
||||
EXPECT_GE(dur, 2'000u);
|
||||
EXPECT_LE(ts + dur, after / 1000 + 1);
|
||||
EXPECT_EQ(trace[0]["args"]["count"], 3);
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, ScopeWithoutWriterRecordsNothing) {
|
||||
TraceScope scope(nullptr, "unused", "test");
|
||||
scope.Arg("ignored", 1);
|
||||
}
|
||||
|
||||
std::size_t ThreadCount() {
|
||||
return static_cast<std::size_t>(
|
||||
std::distance(std::filesystem::directory_iterator("/proc/self/task"),
|
||||
std::filesystem::directory_iterator()));
|
||||
}
|
||||
|
||||
// A failed write closes the trace, so the writer thread has nothing left to
|
||||
// do and exits instead of ticking for the rest of the process.
|
||||
TEST(ChromeTraceWriterTest, WriterThreadExitsAfterAWriteFailure) {
|
||||
TemporaryDirectory directory;
|
||||
const auto path = directory.root() / "trace.json";
|
||||
const std::size_t idle_threads = ThreadCount();
|
||||
auto writer = OpenOrFail(path);
|
||||
ASSERT_NE(writer, nullptr);
|
||||
ASSERT_EQ(ThreadCount(), idle_threads + 1);
|
||||
|
||||
// With SIGXFSZ ignored, a write past RLIMIT_FSIZE fails with EFBIG.
|
||||
struct sigaction ignore {};
|
||||
ignore.sa_handler = SIG_IGN;
|
||||
struct sigaction saved_action {};
|
||||
ASSERT_EQ(sigaction(SIGXFSZ, &ignore, &saved_action), 0);
|
||||
rlimit saved_limit{};
|
||||
ASSERT_EQ(getrlimit(RLIMIT_FSIZE, &saved_limit), 0);
|
||||
rlimit no_growth = saved_limit;
|
||||
no_growth.rlim_cur = 0;
|
||||
ASSERT_EQ(setrlimit(RLIMIT_FSIZE, &no_growth), 0);
|
||||
writer->Slice("lost", "test", 0, 1);
|
||||
writer->Flush();
|
||||
EXPECT_EQ(setrlimit(RLIMIT_FSIZE, &saved_limit), 0);
|
||||
EXPECT_EQ(sigaction(SIGXFSZ, &saved_action, nullptr), 0);
|
||||
|
||||
const auto deadline =
|
||||
std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (ThreadCount() != idle_threads &&
|
||||
std::chrono::steady_clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
EXPECT_EQ(ThreadCount(), idle_threads);
|
||||
writer->Close();
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, ActiveProfileTraceIsOffWithoutEnvironment) {
|
||||
ASSERT_EQ(std::getenv(kProfileTraceEnvironment), nullptr);
|
||||
EXPECT_EQ(ActiveProfileTrace(), nullptr);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace graphics
|
||||
} // namespace mocktail
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -160,6 +162,42 @@ TEST(CommandLineTest, ScrubsInternalLaunchDocumentFromOwnedAndProcessArgv) {
|
||||
[](char byte) { return byte == '\0'; }));
|
||||
}
|
||||
|
||||
TEST(CommandLineTest, ParsesProfileTracePath) {
|
||||
const std::array<const char*, 3> arguments = {"mocktail", "--profile",
|
||||
"/traces/session.json"};
|
||||
|
||||
const CommandLineParseResult result =
|
||||
ParseCommandLine(arguments.size(), arguments.data());
|
||||
|
||||
ASSERT_TRUE(result) << result.error;
|
||||
EXPECT_EQ(result.options.profile_trace_path, "/traces/session.json");
|
||||
}
|
||||
|
||||
TEST(CommandLineTest, AppliesProfileTraceAsAbsolutePath) {
|
||||
unsetenv("MOCKTAIL_PROFILE_TRACE");
|
||||
CommandLineOptions options;
|
||||
options.profile_trace_path = "session.json";
|
||||
std::string error;
|
||||
|
||||
ASSERT_TRUE(ApplyCommandLineEnvironment(options, &error)) << error;
|
||||
|
||||
const char* applied = std::getenv("MOCKTAIL_PROFILE_TRACE");
|
||||
ASSERT_NE(applied, nullptr);
|
||||
EXPECT_EQ(std::string(applied),
|
||||
(std::filesystem::current_path() / "session.json").string());
|
||||
unsetenv("MOCKTAIL_PROFILE_TRACE");
|
||||
}
|
||||
|
||||
TEST(CommandLineTest, LeavesProfileTraceUnsetWithoutOption) {
|
||||
unsetenv("MOCKTAIL_PROFILE_TRACE");
|
||||
std::string error;
|
||||
|
||||
ASSERT_TRUE(ApplyCommandLineEnvironment(CommandLineOptions{}, &error))
|
||||
<< error;
|
||||
|
||||
EXPECT_EQ(std::getenv("MOCKTAIL_PROFILE_TRACE"), nullptr);
|
||||
}
|
||||
|
||||
TEST(CommandLineTest, RejectsUnsupportedPositionalArgument) {
|
||||
const std::array<const char*, 2> arguments = {"mocktail", "/tmp/payload"};
|
||||
|
||||
@@ -182,7 +220,7 @@ TEST(CommandLineTest, RejectsUnknownOptionBeforeRuntimeWork) {
|
||||
|
||||
TEST(CommandLineTest, RejectsEveryMissingValue) {
|
||||
for (const char* option :
|
||||
{"--roblox-lib", "--graphics", "--launch-uri"}) {
|
||||
{"--roblox-lib", "--graphics", "--launch-uri", "--profile"}) {
|
||||
const std::array<const char*, 2> arguments = {"mocktail", option};
|
||||
const CommandLineParseResult result =
|
||||
ParseCommandLine(arguments.size(), arguments.data());
|
||||
@@ -268,7 +306,7 @@ TEST(CommandLineTest, UsageContainsEverySupportedOption) {
|
||||
for (const char* option :
|
||||
{"--roblox-lib", "--headless", "--windowed", "--graphics",
|
||||
"--allow-unverified-build", "--force-run-latest", "--launch-uri",
|
||||
"--help"}) {
|
||||
"--profile", "--help"}) {
|
||||
EXPECT_NE(usage.find(option), std::string::npos) << option;
|
||||
}
|
||||
EXPECT_EQ(usage.find("--login"), std::string::npos);
|
||||
|
||||
+153
-10
@@ -79,8 +79,9 @@ TEST(DiscordRpcTest, BuildsEnglishBrowsingAndJoiningActivities) {
|
||||
|
||||
const DiscordRpcActivity browsing = BuildDiscordRpcActivity(
|
||||
config, RobloxExperiencePresencePhase::kBrowsing, nullptr, {}, 0);
|
||||
EXPECT_EQ(browsing.name, "Roblox");
|
||||
EXPECT_EQ(browsing.details, "Browsing experiences");
|
||||
EXPECT_EQ(browsing.state, "Playing Roblox");
|
||||
EXPECT_TRUE(browsing.state.empty());
|
||||
EXPECT_FALSE(browsing.start_timestamp.has_value());
|
||||
EXPECT_TRUE(browsing.button_url.empty());
|
||||
|
||||
@@ -88,7 +89,7 @@ TEST(DiscordRpcTest, BuildsEnglishBrowsingAndJoiningActivities) {
|
||||
const DiscordRpcActivity joining = BuildDiscordRpcActivity(
|
||||
config, RobloxExperiencePresencePhase::kJoining, &request, {}, 0);
|
||||
EXPECT_EQ(joining.details, "Joining an experience");
|
||||
EXPECT_EQ(joining.state, "Playing Roblox");
|
||||
EXPECT_TRUE(joining.state.empty());
|
||||
EXPECT_TRUE(joining.button_url.empty());
|
||||
}
|
||||
|
||||
@@ -107,10 +108,11 @@ TEST(DiscordRpcTest, ShowsPlaceTimerAndPublicJoinWhenPlaying) {
|
||||
|
||||
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
|
||||
config, RobloxExperiencePresencePhase::kPlaying, &request,
|
||||
"Natural Disaster Survival", 1770000000);
|
||||
"Natural Disaster Survival", 1770000000, {}, "Stickmasterluke");
|
||||
|
||||
EXPECT_EQ(activity.details, "Natural Disaster Survival");
|
||||
EXPECT_EQ(activity.state, "Playing Roblox");
|
||||
EXPECT_EQ(activity.name, "Roblox");
|
||||
EXPECT_EQ(activity.details, "Playing Natural Disaster Survival");
|
||||
EXPECT_EQ(activity.state, "by Stickmasterluke");
|
||||
ASSERT_TRUE(activity.start_timestamp.has_value());
|
||||
EXPECT_EQ(*activity.start_timestamp, 1770000000);
|
||||
EXPECT_EQ(activity.button_label, "Join Server");
|
||||
@@ -119,6 +121,30 @@ TEST(DiscordRpcTest, ShowsPlaceTimerAndPublicJoinWhenPlaying) {
|
||||
"189707&gameInstanceId=job%2Fa%2Bb");
|
||||
}
|
||||
|
||||
TEST(DiscordRpcTest, LeavesOutTextThatExpandsAnEmptyCreator) {
|
||||
const RobloxExperienceLaunchRequest request = PublicServer();
|
||||
|
||||
const DiscordRpcActivity unknown_creator = BuildDiscordRpcActivity(
|
||||
DiscordRpcConfig(), RobloxExperiencePresencePhase::kPlaying, &request,
|
||||
"Natural Disaster Survival", 1);
|
||||
EXPECT_EQ(unknown_creator.details, "Playing Natural Disaster Survival");
|
||||
EXPECT_TRUE(unknown_creator.state.empty());
|
||||
|
||||
DiscordRpcConfig custom;
|
||||
custom.text.state = "by {creator_name} on Linux";
|
||||
EXPECT_TRUE(BuildDiscordRpcActivity(custom,
|
||||
RobloxExperiencePresencePhase::kPlaying,
|
||||
&request, "Natural Disaster Survival",
|
||||
1)
|
||||
.state.empty());
|
||||
EXPECT_EQ(BuildDiscordRpcActivity(custom,
|
||||
RobloxExperiencePresencePhase::kPlaying,
|
||||
&request, "Natural Disaster Survival", 1,
|
||||
{}, "Stickmasterluke")
|
||||
.state,
|
||||
"by Stickmasterluke on Linux");
|
||||
}
|
||||
|
||||
TEST(DiscordRpcTest, ShowsExternalPlaceThumbnailWhenAvailable) {
|
||||
const DiscordRpcConfig config;
|
||||
const RobloxExperienceLaunchRequest request = PublicServer();
|
||||
@@ -130,6 +156,8 @@ TEST(DiscordRpcTest, ShowsExternalPlaceThumbnailWhenAvailable) {
|
||||
EXPECT_EQ(activity.large_image,
|
||||
"https://tr.rbxcdn.com/example/512/512/Image/Png");
|
||||
EXPECT_EQ(activity.large_text, "Natural Disaster Survival");
|
||||
EXPECT_EQ(activity.small_image, "roblox_small");
|
||||
EXPECT_EQ(activity.small_text, "Roblox");
|
||||
}
|
||||
|
||||
TEST(DiscordRpcTest, ShowsPlaceJoinWhenServerIdIsUnavailable) {
|
||||
@@ -191,12 +219,20 @@ TEST(DiscordRpcTest, HidesPlaceNameAndElapsedTimeByPolicy) {
|
||||
config.join_enabled = false;
|
||||
|
||||
const RobloxExperienceLaunchRequest request = PublicServer();
|
||||
const DiscordRpcActivity activity =
|
||||
BuildDiscordRpcActivity(config, RobloxExperiencePresencePhase::kPlaying,
|
||||
&request, "Secret place name", 1770000000);
|
||||
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
|
||||
config, RobloxExperiencePresencePhase::kPlaying, &request,
|
||||
"Secret place name", 1770000000, {}, "Secret creator");
|
||||
|
||||
EXPECT_EQ(activity.details, "Playing Roblox");
|
||||
// The creator hides with the place, so the default state renders empty.
|
||||
EXPECT_TRUE(activity.details.empty());
|
||||
EXPECT_TRUE(activity.state.empty());
|
||||
|
||||
config.text.state = "Playing Roblox";
|
||||
const DiscordRpcActivity fixed_state = BuildDiscordRpcActivity(
|
||||
config, RobloxExperiencePresencePhase::kPlaying, &request,
|
||||
"Secret place name", 1770000000, {}, "Secret creator");
|
||||
EXPECT_EQ(fixed_state.details, "Playing Roblox");
|
||||
EXPECT_TRUE(fixed_state.state.empty());
|
||||
EXPECT_FALSE(activity.start_timestamp.has_value());
|
||||
EXPECT_TRUE(activity.button_url.empty());
|
||||
}
|
||||
@@ -219,6 +255,99 @@ TEST(DiscordRpcTest, ExpandsCustomPlaceTemplateAndUsesFallback) {
|
||||
"Playing Unknown world with Mocktail");
|
||||
}
|
||||
|
||||
TEST(DiscordRpcTest, RendersCustomTitleAndStateTemplates) {
|
||||
DiscordRpcConfig config;
|
||||
config.text.title = "{place_name}";
|
||||
config.text.state = "In {place_name}";
|
||||
const RobloxExperienceLaunchRequest request = PublicServer();
|
||||
|
||||
const DiscordRpcActivity playing =
|
||||
BuildDiscordRpcActivity(config, RobloxExperiencePresencePhase::kPlaying,
|
||||
&request, "Natural Disaster Survival", 1);
|
||||
EXPECT_EQ(playing.name, "Natural Disaster Survival");
|
||||
EXPECT_EQ(playing.state, "In Natural Disaster Survival");
|
||||
|
||||
const DiscordRpcActivity browsing = BuildDiscordRpcActivity(
|
||||
config, RobloxExperiencePresencePhase::kBrowsing, nullptr, {}, 0);
|
||||
EXPECT_TRUE(browsing.name.empty());
|
||||
}
|
||||
|
||||
TEST(DiscordRpcTest, RendersCustomImagesWithPlaceholders) {
|
||||
DiscordRpcConfig config;
|
||||
config.images.large = "nightcap_logo";
|
||||
config.images.large_text = "Nightcap";
|
||||
config.images.small = "{place_icon}";
|
||||
config.images.small_text = "{place_name}";
|
||||
const RobloxExperienceLaunchRequest request = PublicServer();
|
||||
|
||||
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
|
||||
config, RobloxExperiencePresencePhase::kPlaying, &request,
|
||||
"Natural Disaster Survival", 1,
|
||||
"https://tr.rbxcdn.com/example/512/512/Image/Png");
|
||||
|
||||
EXPECT_EQ(activity.large_image, "nightcap_logo");
|
||||
EXPECT_EQ(activity.large_text, "Nightcap");
|
||||
EXPECT_EQ(activity.small_image,
|
||||
"https://tr.rbxcdn.com/example/512/512/Image/Png");
|
||||
EXPECT_EQ(activity.small_text, "Natural Disaster Survival");
|
||||
}
|
||||
|
||||
TEST(DiscordRpcTest, EmptyLargeImageHidesBothImages) {
|
||||
DiscordRpcConfig config;
|
||||
config.images.large = "";
|
||||
config.images.small = "nightcap_logo";
|
||||
config.images.small_text = "Nightcap";
|
||||
const RobloxExperienceLaunchRequest request = PublicServer();
|
||||
|
||||
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
|
||||
config, RobloxExperiencePresencePhase::kPlaying, &request,
|
||||
"Natural Disaster Survival", 1,
|
||||
"https://tr.rbxcdn.com/example/512/512/Image/Png");
|
||||
|
||||
EXPECT_TRUE(activity.large_image.empty());
|
||||
EXPECT_TRUE(activity.large_text.empty());
|
||||
EXPECT_TRUE(activity.small_image.empty());
|
||||
EXPECT_TRUE(activity.small_text.empty());
|
||||
}
|
||||
|
||||
TEST(DiscordRpcTest, DropsUnsafeCustomImages) {
|
||||
const RobloxExperienceLaunchRequest request = PublicServer();
|
||||
for (const char* unsafe :
|
||||
{"file:///etc/passwd", "http://example.test/a.png", "not a key",
|
||||
"{place_name}"}) {
|
||||
DiscordRpcConfig config;
|
||||
config.images.large = "nightcap_logo";
|
||||
config.images.small = unsafe;
|
||||
const DiscordRpcActivity small = BuildDiscordRpcActivity(
|
||||
config, RobloxExperiencePresencePhase::kPlaying, &request,
|
||||
"Natural Disaster Survival", 1);
|
||||
EXPECT_TRUE(small.small_image.empty()) << unsafe;
|
||||
|
||||
config.images.large = unsafe;
|
||||
const DiscordRpcActivity large = BuildDiscordRpcActivity(
|
||||
config, RobloxExperiencePresencePhase::kPlaying, &request,
|
||||
"Natural Disaster Survival", 1);
|
||||
EXPECT_TRUE(large.large_image.empty()) << unsafe;
|
||||
EXPECT_TRUE(large.large_text.empty()) << unsafe;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(DiscordRpcTest, HiddenPlaceNameAlsoHidesPlaceImagesAndTitle) {
|
||||
DiscordRpcConfig config;
|
||||
config.show_place_name = false;
|
||||
config.text.title = "{place_name}";
|
||||
const RobloxExperienceLaunchRequest request = PublicServer();
|
||||
|
||||
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
|
||||
config, RobloxExperiencePresencePhase::kPlaying, &request,
|
||||
"Secret place name", 1,
|
||||
"https://tr.rbxcdn.com/example/512/512/Image/Png");
|
||||
|
||||
EXPECT_TRUE(activity.name.empty());
|
||||
EXPECT_TRUE(activity.large_image.empty());
|
||||
EXPECT_TRUE(activity.large_text.empty());
|
||||
}
|
||||
|
||||
TEST(DiscordRpcTest, PublishesLifecycleActivitiesOverDiscordIpc) {
|
||||
char directory_pattern[] = "/tmp/mocktail_discord_rpc_XXXXXX";
|
||||
char* directory = mkdtemp(directory_pattern);
|
||||
@@ -280,6 +409,13 @@ TEST(DiscordRpcTest, PublishesLifecycleActivitiesOverDiscordIpc) {
|
||||
config.application_id = "123456789012345678";
|
||||
config.show_place_name = false;
|
||||
config.join_enabled = false;
|
||||
config.text.title = "Nightcap Test";
|
||||
config.text.state = "Playing Roblox";
|
||||
// Joining has no place yet, so this renders empty and must be left out.
|
||||
config.text.joining = "{place_name}";
|
||||
config.images.large = "nightcap_logo";
|
||||
config.images.small = "linux";
|
||||
config.images.small_text = "On Linux";
|
||||
DiscordRpcSession session(std::move(config));
|
||||
std::string detail;
|
||||
ASSERT_TRUE(session.Start(&detail)) << detail;
|
||||
@@ -320,8 +456,15 @@ TEST(DiscordRpcTest, PublishesLifecycleActivitiesOverDiscordIpc) {
|
||||
EXPECT_NE(activity_payloads[0].find("SET_ACTIVITY"), std::string::npos);
|
||||
EXPECT_NE(activity_payloads[0].find("Browsing experiences"),
|
||||
std::string::npos);
|
||||
EXPECT_NE(activity_payloads[1].find("Joining an experience"),
|
||||
EXPECT_NE(activity_payloads[0].find("\"name\":\"Nightcap Test\""),
|
||||
std::string::npos);
|
||||
EXPECT_NE(activity_payloads[0].find("\"large_image\":\"nightcap_logo\""),
|
||||
std::string::npos);
|
||||
EXPECT_NE(activity_payloads[0].find("\"small_image\":\"linux\""),
|
||||
std::string::npos);
|
||||
EXPECT_NE(activity_payloads[0].find("\"small_text\":\"On Linux\""),
|
||||
std::string::npos);
|
||||
EXPECT_EQ(activity_payloads[1].find("\"details\""), std::string::npos);
|
||||
EXPECT_NE(activity_payloads[2].find("Playing Roblox"), std::string::npos);
|
||||
EXPECT_NE(activity_payloads[2].find("timestamps"), std::string::npos);
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
#include "runtime/engine_pump_rest.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
namespace mocktail::runtime {
|
||||
namespace {
|
||||
|
||||
uint64_t NowNs() {
|
||||
timespec now{};
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
return static_cast<uint64_t>(now.tv_sec) * 1'000'000'000ULL +
|
||||
static_cast<uint64_t>(now.tv_nsec);
|
||||
}
|
||||
|
||||
class GovernorFile final {
|
||||
public:
|
||||
explicit GovernorFile(const char* content) {
|
||||
char pattern[] = "/tmp/mocktail_governor_XXXXXX";
|
||||
const int fd = mkstemp(pattern);
|
||||
if (fd >= 0) {
|
||||
close(fd);
|
||||
path_ = pattern;
|
||||
std::ofstream(path_) << content;
|
||||
}
|
||||
}
|
||||
~GovernorFile() {
|
||||
std::error_code error;
|
||||
std::filesystem::remove(path_, error);
|
||||
}
|
||||
const char* path() const { return path_.c_str(); }
|
||||
|
||||
private:
|
||||
std::string path_;
|
||||
};
|
||||
|
||||
// A performance governor holds the clock up on its own, so the thread can
|
||||
// sleep. Without it, only a core that stays awake keeps the clock up: tpause
|
||||
// where the CPU has it, a spin otherwise.
|
||||
TEST(EnginePumpRestTest, PolicyFollowsGovernorThenCpuFeature) {
|
||||
EXPECT_EQ(ChooseEnginePumpRestMode({true, true, nullptr}),
|
||||
EnginePumpRestMode::kSleep);
|
||||
EXPECT_EQ(ChooseEnginePumpRestMode({true, false, nullptr}),
|
||||
EnginePumpRestMode::kSleep);
|
||||
EXPECT_EQ(ChooseEnginePumpRestMode({false, true, nullptr}),
|
||||
EnginePumpRestMode::kTpause);
|
||||
EXPECT_EQ(ChooseEnginePumpRestMode({false, false, nullptr}),
|
||||
EnginePumpRestMode::kSpin);
|
||||
}
|
||||
|
||||
TEST(EnginePumpRestTest, OverrideWinsWhenTheCpuCanHonorIt) {
|
||||
EXPECT_EQ(ChooseEnginePumpRestMode({false, true, "sleep"}),
|
||||
EnginePumpRestMode::kSleep);
|
||||
EXPECT_EQ(ChooseEnginePumpRestMode({true, true, "spin"}),
|
||||
EnginePumpRestMode::kSpin);
|
||||
EXPECT_EQ(ChooseEnginePumpRestMode({true, true, "tpause"}),
|
||||
EnginePumpRestMode::kTpause);
|
||||
// tpause needs the instruction; without it the policy decides.
|
||||
EXPECT_EQ(ChooseEnginePumpRestMode({false, false, "tpause"}),
|
||||
EnginePumpRestMode::kSpin);
|
||||
EXPECT_EQ(ChooseEnginePumpRestMode({false, true, "bogus"}),
|
||||
EnginePumpRestMode::kTpause);
|
||||
EXPECT_EQ(ChooseEnginePumpRestMode({false, true, ""}),
|
||||
EnginePumpRestMode::kTpause);
|
||||
}
|
||||
|
||||
TEST(EnginePumpRestTest, ReadsThePerformanceGovernorFromSysfs) {
|
||||
GovernorFile performance("performance\n");
|
||||
GovernorFile powersave("powersave\n");
|
||||
EXPECT_TRUE(CpuGovernorIsPerformance(performance.path()));
|
||||
EXPECT_FALSE(CpuGovernorIsPerformance(powersave.path()));
|
||||
EXPECT_FALSE(CpuGovernorIsPerformance("/nonexistent/scaling_governor"));
|
||||
}
|
||||
|
||||
TEST(EnginePumpRestTest, ModeNamesAreStable) {
|
||||
EXPECT_STREQ(EnginePumpRestModeName(EnginePumpRestMode::kSleep), "sleep");
|
||||
EXPECT_STREQ(EnginePumpRestModeName(EnginePumpRestMode::kTpause), "tpause");
|
||||
EXPECT_STREQ(EnginePumpRestModeName(EnginePumpRestMode::kSpin), "spin");
|
||||
}
|
||||
|
||||
// The rest bounds how late an engine post is picked up. With the thread's
|
||||
// timer slack lowered and a shallow C-state exit, the bound sits far under
|
||||
// the engine's sub-millisecond slack.
|
||||
TEST(EnginePumpRestTest, BoundStaysUnderTheEngineSlack) {
|
||||
EXPECT_LE(kEnginePumpRestNs + kEnginePumpTimerSlackNs + kShallowWakeNs,
|
||||
300'000ULL);
|
||||
}
|
||||
|
||||
TEST(EnginePumpRestTest, LowersTheCallingThreadTimerSlackOnce) {
|
||||
(void)RestAfterEnginePump(EnginePumpRestMode::kSleep);
|
||||
EXPECT_EQ(prctl(PR_GET_TIMERSLACK), static_cast<int>(kEnginePumpTimerSlackNs));
|
||||
}
|
||||
|
||||
// A real sleep, not a spin: the call takes at least the rest, and on an idle
|
||||
// core not much more. The upper bound is loose so a loaded box does not flake.
|
||||
TEST(EnginePumpRestTest, SleepModeSleepsForTheRestInterval) {
|
||||
const uint64_t start = NowNs();
|
||||
const uint64_t reported = RestAfterEnginePump(EnginePumpRestMode::kSleep);
|
||||
const uint64_t elapsed = NowNs() - start;
|
||||
EXPECT_EQ(reported, kEnginePumpRestNs);
|
||||
EXPECT_GE(elapsed, kEnginePumpRestNs);
|
||||
EXPECT_LT(elapsed, 2'000'000ULL) << "rest overshot by " << elapsed << " ns";
|
||||
}
|
||||
|
||||
TEST(EnginePumpRestTest, TpauseModeWaitsForTheRestInterval) {
|
||||
if (!CpuHasWaitPkg()) {
|
||||
GTEST_SKIP() << "CPU has no WAITPKG";
|
||||
}
|
||||
const uint64_t start = NowNs();
|
||||
const uint64_t reported = RestAfterEnginePump(EnginePumpRestMode::kTpause);
|
||||
const uint64_t elapsed = NowNs() - start;
|
||||
EXPECT_EQ(reported, kEnginePumpRestNs);
|
||||
EXPECT_GE(elapsed, kEnginePumpRestNs);
|
||||
EXPECT_LT(elapsed, 2'000'000ULL) << "rest overshot by " << elapsed << " ns";
|
||||
}
|
||||
|
||||
TEST(EnginePumpRestTest, SpinModeDoesNotRest) {
|
||||
const uint64_t start = NowNs();
|
||||
EXPECT_EQ(RestAfterEnginePump(EnginePumpRestMode::kSpin), 0U);
|
||||
EXPECT_LT(NowNs() - start, kEnginePumpRestNs);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mocktail::runtime
|
||||
@@ -3,9 +3,14 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <iterator>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <set>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail::graphics {
|
||||
@@ -252,6 +257,94 @@ TEST(Etc2DecoderTest, DecodesJobsOnWorkersLikeSerialDecode) {
|
||||
EXPECT_EQ(short_output, std::vector<std::uint8_t>(4 * 4 * 4 - 1, 0xAB));
|
||||
}
|
||||
|
||||
// Batches of small textures stay under the old parallel floor; the pool has
|
||||
// to decode them exactly as the caller would.
|
||||
TEST(Etc2DecoderTest, DecodesManySmallJobsLikeSerialDecode) {
|
||||
constexpr std::size_t kJobs = 24;
|
||||
constexpr std::uint32_t kWidth = 64;
|
||||
constexpr std::uint32_t kHeight = 64;
|
||||
std::vector<std::vector<std::uint8_t>> sources;
|
||||
std::vector<std::vector<std::uint8_t>> expected;
|
||||
std::vector<std::vector<std::uint8_t>> outputs;
|
||||
for (std::size_t index = 0; index < kJobs; ++index) {
|
||||
sources.push_back(RandomBlocks(EtcFormat::kEtc2Rgb8, kWidth, kHeight,
|
||||
static_cast<std::uint32_t>(index + 11)));
|
||||
expected.push_back(
|
||||
Decode(EtcFormat::kEtc2Rgb8, sources.back(), kWidth, kHeight));
|
||||
outputs.emplace_back(expected.back().size(), 0xAB);
|
||||
}
|
||||
std::vector<EtcDecodeJob> jobs;
|
||||
for (std::size_t index = 0; index < kJobs; ++index) {
|
||||
jobs.push_back({EtcFormat::kEtc2Rgb8, sources[index].data(),
|
||||
sources[index].size(), kWidth, kHeight,
|
||||
outputs[index].data(), outputs[index].size()});
|
||||
}
|
||||
|
||||
DecodeEtcJobs(jobs.data(), jobs.size(), 8);
|
||||
|
||||
for (std::size_t index = 0; index < kJobs; ++index) {
|
||||
EXPECT_TRUE(jobs[index].ok) << index;
|
||||
EXPECT_EQ(outputs[index], expected[index]) << index;
|
||||
}
|
||||
}
|
||||
|
||||
// The workers outlive each batch, so a later batch must find them idle.
|
||||
TEST(Etc2DecoderTest, DecodesCorrectlyAcrossRepeatedBatches) {
|
||||
constexpr std::uint32_t kWidth = 260;
|
||||
constexpr std::uint32_t kHeight = 132;
|
||||
const std::vector<std::uint8_t> source =
|
||||
RandomBlocks(EtcFormat::kEtc2Rgba8, kWidth, kHeight, 7);
|
||||
const std::vector<std::uint8_t> expected =
|
||||
Decode(EtcFormat::kEtc2Rgba8, source, kWidth, kHeight);
|
||||
|
||||
for (int round = 0; round < 40; ++round) {
|
||||
std::vector<std::uint8_t> output(expected.size(), 0xAB);
|
||||
EtcDecodeJob job{EtcFormat::kEtc2Rgba8, source.data(), source.size(),
|
||||
kWidth, kHeight, output.data(),
|
||||
output.size()};
|
||||
DecodeEtcJobs(&job, 1, 8);
|
||||
ASSERT_TRUE(job.ok) << round;
|
||||
ASSERT_EQ(output, expected) << round;
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t ThreadCount() {
|
||||
return static_cast<std::size_t>(
|
||||
std::distance(std::filesystem::directory_iterator("/proc/self/task"),
|
||||
std::filesystem::directory_iterator()));
|
||||
}
|
||||
|
||||
// Other batch work (ETC2 emit) drains its own queue across the decode
|
||||
// workers. The function runs worker_count times, once of them on the caller,
|
||||
// and a second batch reuses the threads the first one started.
|
||||
TEST(Etc2DecoderTest, RunsAFunctionAcrossPooledWorkersWithoutNewThreads) {
|
||||
struct Probe {
|
||||
std::mutex mutex;
|
||||
std::set<std::thread::id> threads;
|
||||
std::atomic<int> calls{0};
|
||||
};
|
||||
const auto run = [](void* context) {
|
||||
auto* probe = static_cast<Probe*>(context);
|
||||
probe->calls.fetch_add(1, std::memory_order_relaxed);
|
||||
std::lock_guard<std::mutex> lock(probe->mutex);
|
||||
probe->threads.insert(std::this_thread::get_id());
|
||||
};
|
||||
|
||||
Probe first;
|
||||
RunOnDecodeWorkers(run, &first, 3);
|
||||
EXPECT_EQ(first.calls.load(), 3);
|
||||
// An idle worker may claim two of the slots.
|
||||
EXPECT_GE(first.threads.size(), 2u);
|
||||
EXPECT_LE(first.threads.size(), 3u);
|
||||
EXPECT_EQ(first.threads.count(std::this_thread::get_id()), 1u);
|
||||
|
||||
const std::size_t pooled = ThreadCount();
|
||||
Probe second;
|
||||
RunOnDecodeWorkers(run, &second, 3);
|
||||
EXPECT_EQ(second.calls.load(), 3);
|
||||
EXPECT_EQ(ThreadCount(), pooled);
|
||||
}
|
||||
|
||||
TEST(Etc2DecoderTest, RejectsShortBuffers) {
|
||||
const std::vector<std::uint8_t> source(8, 0);
|
||||
std::vector<std::uint8_t> output(4 * 4 * 4, 0);
|
||||
|
||||
@@ -9,13 +9,16 @@
|
||||
#include <unistd.h>
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
@@ -288,6 +291,121 @@ TEST_F(ExternalLaunchBrokerTest, GlobalAttachmentDrainsAndRequiresOneOwner) {
|
||||
EXPECT_EQ(GetActiveExternalLaunchBroker(), nullptr);
|
||||
}
|
||||
|
||||
// The host main loop drains the broker on every tick and is almost always
|
||||
// idle. An idle drain must observe the empty queue without waiting for a
|
||||
// dispatch already in flight, or one slow launch stalls the whole loop.
|
||||
TEST_F(ExternalLaunchBrokerTest, IdleDrainDoesNotWaitForAnInFlightDispatch) {
|
||||
Start();
|
||||
ASSERT_TRUE(broker_->QueueInitialRequest(Request(7001)).ok());
|
||||
|
||||
std::mutex gate_mutex;
|
||||
std::condition_variable gate;
|
||||
bool released = false;
|
||||
std::atomic<bool> dispatching{false};
|
||||
|
||||
struct BlockingProbe {
|
||||
std::mutex* gate_mutex;
|
||||
std::condition_variable* gate;
|
||||
bool* released;
|
||||
std::atomic<bool>* dispatching;
|
||||
} blocking{&gate_mutex, &gate, &released, &dispatching};
|
||||
|
||||
const ExternalLaunchSink blocking_sink{
|
||||
&blocking,
|
||||
[](void* context, const RobloxExperienceLaunchRequest&) -> Status {
|
||||
auto* probe = static_cast<BlockingProbe*>(context);
|
||||
probe->dispatching->store(true, std::memory_order_release);
|
||||
std::unique_lock<std::mutex> lock(*probe->gate_mutex);
|
||||
probe->gate->wait(lock, [probe] { return *probe->released; });
|
||||
return Status::Ok();
|
||||
}};
|
||||
|
||||
std::thread holder([&] {
|
||||
EXPECT_TRUE(broker_->Drain(blocking_sink, 1).ok());
|
||||
});
|
||||
while (!dispatching.load(std::memory_order_acquire)) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
|
||||
// Release on a timer, so a drain that does block behind the dispatch
|
||||
// reports a slow elapsed time instead of deadlocking the test.
|
||||
std::thread releaser([&] {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(2));
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gate_mutex);
|
||||
released = true;
|
||||
}
|
||||
gate.notify_all();
|
||||
});
|
||||
|
||||
CaptureProbe probe;
|
||||
const auto started = std::chrono::steady_clock::now();
|
||||
const Status idle = broker_->Drain({&probe, Capture}, 4);
|
||||
const auto elapsed = std::chrono::steady_clock::now() - started;
|
||||
|
||||
releaser.join();
|
||||
holder.join();
|
||||
|
||||
EXPECT_TRUE(idle.ok()) << idle.message();
|
||||
EXPECT_TRUE(probe.requests.empty());
|
||||
EXPECT_LT(std::chrono::duration_cast<std::chrono::milliseconds>(elapsed)
|
||||
.count(),
|
||||
250)
|
||||
<< "idle drain blocked behind the in-flight dispatch";
|
||||
}
|
||||
|
||||
// The lock-free idle path must not drop a request that arrives while a
|
||||
// dispatch is running.
|
||||
TEST_F(ExternalLaunchBrokerTest, DeliversARequestQueuedDuringADispatch) {
|
||||
Start();
|
||||
ASSERT_TRUE(broker_->QueueInitialRequest(Request(7101)).ok());
|
||||
|
||||
std::mutex gate_mutex;
|
||||
std::condition_variable gate;
|
||||
bool released = false;
|
||||
std::atomic<bool> dispatching{false};
|
||||
|
||||
struct BlockingProbe {
|
||||
std::mutex* gate_mutex;
|
||||
std::condition_variable* gate;
|
||||
bool* released;
|
||||
std::atomic<bool>* dispatching;
|
||||
} blocking{&gate_mutex, &gate, &released, &dispatching};
|
||||
|
||||
const ExternalLaunchSink blocking_sink{
|
||||
&blocking,
|
||||
[](void* context, const RobloxExperienceLaunchRequest&) -> Status {
|
||||
auto* probe = static_cast<BlockingProbe*>(context);
|
||||
probe->dispatching->store(true, std::memory_order_release);
|
||||
std::unique_lock<std::mutex> lock(*probe->gate_mutex);
|
||||
probe->gate->wait(lock, [probe] { return *probe->released; });
|
||||
return Status::Ok();
|
||||
}};
|
||||
|
||||
std::thread holder([&] {
|
||||
EXPECT_TRUE(broker_->Drain(blocking_sink, 1).ok());
|
||||
});
|
||||
while (!dispatching.load(std::memory_order_acquire)) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
|
||||
ASSERT_TRUE(broker_->QueueInitialRequest(Request(7102)).ok());
|
||||
EXPECT_EQ(broker_->pending_launch_count(), 1u);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gate_mutex);
|
||||
released = true;
|
||||
}
|
||||
gate.notify_all();
|
||||
holder.join();
|
||||
|
||||
CaptureProbe probe;
|
||||
ASSERT_TRUE(broker_->Drain({&probe, Capture}, 4).ok());
|
||||
ASSERT_EQ(probe.requests.size(), 1u);
|
||||
EXPECT_EQ(probe.requests[0].place_id, 7102);
|
||||
EXPECT_EQ(broker_->pending_launch_count(), 0u);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -2,12 +2,61 @@
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <sched.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <initializer_list>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
|
||||
cpu_set_t MaskOf(std::initializer_list<int> cpus) {
|
||||
cpu_set_t mask;
|
||||
CPU_ZERO(&mask);
|
||||
for (const int cpu : cpus) {
|
||||
CPU_SET(cpu, &mask);
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
// Each get() consumes the next entry of `reads`, repeating the last one once
|
||||
// the list runs out.
|
||||
struct AffinityProbe {
|
||||
std::vector<cpu_set_t> reads;
|
||||
std::size_t get_calls = 0;
|
||||
int set_calls = 0;
|
||||
int get_result = 0;
|
||||
int set_result = 0;
|
||||
cpu_set_t last_set{};
|
||||
};
|
||||
|
||||
AffinityProbe* g_affinity = nullptr;
|
||||
|
||||
int AffinityGet(cpu_set_t* mask) {
|
||||
const std::size_t index = g_affinity->get_calls++;
|
||||
if (g_affinity->get_result != 0 || g_affinity->reads.empty()) {
|
||||
return g_affinity->get_result != 0 ? g_affinity->get_result : -1;
|
||||
}
|
||||
const std::size_t last = g_affinity->reads.size() - 1;
|
||||
*mask = g_affinity->reads[index < last ? index : last];
|
||||
return 0;
|
||||
}
|
||||
|
||||
int AffinitySet(const cpu_set_t* mask) {
|
||||
++g_affinity->set_calls;
|
||||
g_affinity->last_set = *mask;
|
||||
return g_affinity->set_result;
|
||||
}
|
||||
|
||||
CpuAffinityApi AffinityApi() { return CpuAffinityApi{&AffinityGet, &AffinitySet}; }
|
||||
|
||||
struct ClientProbe {
|
||||
int query_result = 0;
|
||||
int start_result = 0;
|
||||
@@ -173,6 +222,237 @@ TEST(GameModeSessionTest, StopFailureIsReportedOnlyOnce) {
|
||||
EXPECT_EQ(probe.end_calls, 1);
|
||||
}
|
||||
|
||||
TEST(CpuAffinityNarrowedTest, ComparesPermittedCpuSets) {
|
||||
const cpu_set_t all = MaskOf({0, 1, 2, 3});
|
||||
EXPECT_TRUE(CpuAffinityNarrowed(all, MaskOf({1, 2})));
|
||||
EXPECT_FALSE(CpuAffinityNarrowed(all, all));
|
||||
EXPECT_FALSE(CpuAffinityNarrowed(all, MaskOf({0, 1, 2, 3, 4})));
|
||||
// Same count, different CPUs: the daemon moved us without narrowing.
|
||||
EXPECT_FALSE(CpuAffinityNarrowed(MaskOf({0, 1}), MaskOf({2, 3})));
|
||||
}
|
||||
|
||||
// The daemon re-applies its pin every few seconds, so a session that pins is
|
||||
// dropped rather than corrected.
|
||||
TEST(HostCpuAffinityTest, RestoreReachesEveryThread) {
|
||||
const CpuAffinityApi api = HostCpuAffinityApi();
|
||||
ASSERT_NE(api.get, nullptr);
|
||||
ASSERT_NE(api.set, nullptr);
|
||||
cpu_set_t original;
|
||||
CPU_ZERO(&original);
|
||||
ASSERT_EQ(api.get(&original), 0);
|
||||
if (CPU_COUNT(&original) < 2) {
|
||||
GTEST_SKIP() << "needs at least two permitted CPUs";
|
||||
}
|
||||
int first = 0;
|
||||
while (!CPU_ISSET(first, &original)) {
|
||||
++first;
|
||||
}
|
||||
|
||||
std::atomic<pid_t> helper_tid{0};
|
||||
std::atomic<int> helper_pin_result{0};
|
||||
std::atomic<bool> release{false};
|
||||
std::thread helper([&] {
|
||||
const cpu_set_t pinned = MaskOf({first});
|
||||
const pid_t tid = static_cast<pid_t>(::syscall(SYS_gettid));
|
||||
// Published whatever the pin does: the waiter below would otherwise spin
|
||||
// forever on a failure instead of reporting one.
|
||||
helper_pin_result.store(sched_setaffinity(tid, sizeof(pinned), &pinned));
|
||||
helper_tid.store(tid);
|
||||
while (!release.load()) {
|
||||
sched_yield();
|
||||
}
|
||||
});
|
||||
while (helper_tid.load() == 0) {
|
||||
sched_yield();
|
||||
}
|
||||
if (helper_pin_result.load() != 0) {
|
||||
release.store(true);
|
||||
helper.join();
|
||||
FAIL() << "could not pin the helper thread to CPU " << first;
|
||||
}
|
||||
|
||||
EXPECT_EQ(api.set(&original), 0);
|
||||
cpu_set_t restored;
|
||||
CPU_ZERO(&restored);
|
||||
EXPECT_EQ(
|
||||
sched_getaffinity(helper_tid.load(), sizeof(restored), &restored), 0);
|
||||
release.store(true);
|
||||
helper.join();
|
||||
|
||||
EXPECT_TRUE(CPU_EQUAL(&restored, &original))
|
||||
<< "restore left the helper thread pinned to CPU " << first;
|
||||
}
|
||||
|
||||
TEST(GameModeSessionTest, DropsTheRequestWhenTheDaemonPinsCores) {
|
||||
ClientProbe probe;
|
||||
g_probe = &probe;
|
||||
AffinityProbe affinity;
|
||||
affinity.reads = {MaskOf({0, 1, 2, 3}), MaskOf({2, 3})};
|
||||
g_affinity = &affinity;
|
||||
|
||||
GameModeSession session = GameModeSession::StartWithClientForTesting(
|
||||
GameModePolicy::kAuto, Api(), AffinityApi());
|
||||
|
||||
EXPECT_EQ(session.state(), GameModeSessionState::kDeclinedCorePinning);
|
||||
EXPECT_FALSE(session.active());
|
||||
EXPECT_FALSE(session.owns_request());
|
||||
EXPECT_EQ(probe.end_calls, 1);
|
||||
// request_end releases the pin; the explicit restore only covers a daemon
|
||||
// that leaves the mask behind.
|
||||
EXPECT_EQ(affinity.set_calls, 1);
|
||||
EXPECT_TRUE(CPU_EQUAL(&affinity.last_set, &affinity.reads[0]));
|
||||
}
|
||||
|
||||
TEST(GameModeSessionTest, DroppedRequestIsNotEndedTwice) {
|
||||
ClientProbe probe;
|
||||
g_probe = &probe;
|
||||
AffinityProbe affinity;
|
||||
affinity.reads = {MaskOf({0, 1, 2, 3}), MaskOf({2, 3})};
|
||||
g_affinity = &affinity;
|
||||
|
||||
GameModeSession session = GameModeSession::StartWithClientForTesting(
|
||||
GameModePolicy::kAuto, Api(), AffinityApi());
|
||||
|
||||
ASSERT_EQ(probe.end_calls, 1);
|
||||
EXPECT_TRUE(session.Stop().ok());
|
||||
EXPECT_EQ(probe.end_calls, 1);
|
||||
}
|
||||
|
||||
// A failed release leaves the daemon holding the request, so the session has
|
||||
// to keep owning it and retry at shutdown instead of reporting it released.
|
||||
TEST(GameModeSessionTest, KeepsAFailedReleaseForShutdown) {
|
||||
ClientProbe probe;
|
||||
probe.end_result = -1;
|
||||
probe.error = "release failed";
|
||||
g_probe = &probe;
|
||||
AffinityProbe affinity;
|
||||
affinity.reads = {MaskOf({0, 1, 2, 3}), MaskOf({2, 3})};
|
||||
g_affinity = &affinity;
|
||||
|
||||
GameModeSession session = GameModeSession::StartWithClientForTesting(
|
||||
GameModePolicy::kAuto, Api(), AffinityApi());
|
||||
|
||||
EXPECT_EQ(session.state(), GameModeSessionState::kDeclinedCorePinning);
|
||||
EXPECT_TRUE(session.owns_request());
|
||||
ASSERT_EQ(probe.end_calls, 1);
|
||||
EXPECT_FALSE(session.Stop().ok());
|
||||
EXPECT_EQ(probe.end_calls, 2);
|
||||
}
|
||||
|
||||
TEST(GameModeSessionTest, KeepsTheRequestWhenAffinityIsUnchanged) {
|
||||
ClientProbe probe;
|
||||
g_probe = &probe;
|
||||
AffinityProbe affinity;
|
||||
affinity.reads = {MaskOf({0, 1, 2, 3})};
|
||||
g_affinity = &affinity;
|
||||
|
||||
GameModeSession session = GameModeSession::StartWithClientForTesting(
|
||||
GameModePolicy::kAuto, Api(), AffinityApi());
|
||||
|
||||
EXPECT_EQ(session.state(), GameModeSessionState::kActive);
|
||||
EXPECT_TRUE(session.owns_request());
|
||||
EXPECT_EQ(probe.end_calls, 0);
|
||||
EXPECT_EQ(affinity.set_calls, 0);
|
||||
}
|
||||
|
||||
// `on` is an explicit request for GameMode, so its pinning is accepted.
|
||||
TEST(GameModeSessionTest, ForcedPolicyKeepsAPinnedSession) {
|
||||
ClientProbe probe;
|
||||
g_probe = &probe;
|
||||
AffinityProbe affinity;
|
||||
affinity.reads = {MaskOf({0, 1, 2, 3}), MaskOf({2, 3})};
|
||||
g_affinity = &affinity;
|
||||
|
||||
GameModeSession session = GameModeSession::StartWithClientForTesting(
|
||||
GameModePolicy::kOn, Api(), AffinityApi());
|
||||
|
||||
EXPECT_EQ(session.state(), GameModeSessionState::kActive);
|
||||
EXPECT_TRUE(session.owns_request());
|
||||
EXPECT_EQ(probe.end_calls, 0);
|
||||
EXPECT_EQ(affinity.set_calls, 0);
|
||||
}
|
||||
|
||||
// A user's own taskset is in place before the request, so a daemon pin inside
|
||||
// it still counts as a narrowing and still drops the session.
|
||||
TEST(GameModeSessionTest, NarrowingADeliberateMaskAlsoDropsTheRequest) {
|
||||
ClientProbe probe;
|
||||
g_probe = &probe;
|
||||
AffinityProbe affinity;
|
||||
affinity.reads = {MaskOf({0, 1}), MaskOf({1})};
|
||||
g_affinity = &affinity;
|
||||
|
||||
GameModeSession session = GameModeSession::StartWithClientForTesting(
|
||||
GameModePolicy::kAuto, Api(), AffinityApi());
|
||||
|
||||
EXPECT_EQ(session.state(), GameModeSessionState::kDeclinedCorePinning);
|
||||
EXPECT_TRUE(CPU_EQUAL(&affinity.last_set, &affinity.reads[0]));
|
||||
}
|
||||
|
||||
TEST(GameModeSessionTest, RejectedRequestIsNotEvaluatedForPinning) {
|
||||
ClientProbe probe;
|
||||
probe.start_result = -1;
|
||||
g_probe = &probe;
|
||||
AffinityProbe affinity;
|
||||
affinity.reads = {MaskOf({0, 1, 2, 3}), MaskOf({2, 3})};
|
||||
g_affinity = &affinity;
|
||||
|
||||
GameModeSession session = GameModeSession::StartWithClientForTesting(
|
||||
GameModePolicy::kOn, Api(), AffinityApi());
|
||||
|
||||
EXPECT_EQ(session.state(), GameModeSessionState::kRequestFailed);
|
||||
EXPECT_EQ(probe.end_calls, 0);
|
||||
EXPECT_EQ(affinity.set_calls, 0);
|
||||
}
|
||||
|
||||
// Another component owns the request, so dropping it is not ours to do.
|
||||
TEST(GameModeSessionTest, AlreadyActiveProcessIsNotEvaluatedForPinning) {
|
||||
ClientProbe probe;
|
||||
probe.query_result = 2;
|
||||
g_probe = &probe;
|
||||
AffinityProbe affinity;
|
||||
affinity.reads = {MaskOf({0, 1, 2, 3}), MaskOf({2, 3})};
|
||||
g_affinity = &affinity;
|
||||
|
||||
GameModeSession session = GameModeSession::StartWithClientForTesting(
|
||||
GameModePolicy::kAuto, Api(), AffinityApi());
|
||||
|
||||
EXPECT_EQ(session.state(), GameModeSessionState::kAlreadyActive);
|
||||
EXPECT_EQ(probe.end_calls, 0);
|
||||
EXPECT_EQ(affinity.set_calls, 0);
|
||||
}
|
||||
|
||||
TEST(GameModeSessionTest, UnreadableAffinityKeepsTheRequest) {
|
||||
ClientProbe probe;
|
||||
g_probe = &probe;
|
||||
AffinityProbe affinity;
|
||||
affinity.reads = {MaskOf({0, 1})};
|
||||
affinity.get_result = -1;
|
||||
g_affinity = &affinity;
|
||||
|
||||
GameModeSession session = GameModeSession::StartWithClientForTesting(
|
||||
GameModePolicy::kAuto, Api(), AffinityApi());
|
||||
|
||||
EXPECT_EQ(session.state(), GameModeSessionState::kActive);
|
||||
EXPECT_EQ(probe.end_calls, 0);
|
||||
}
|
||||
|
||||
TEST(GameModeSessionTest, MissingAffinityApiKeepsTheRequest) {
|
||||
ClientProbe probe;
|
||||
g_probe = &probe;
|
||||
|
||||
GameModeSession session = GameModeSession::StartWithClientForTesting(
|
||||
GameModePolicy::kAuto, Api(), CpuAffinityApi{});
|
||||
|
||||
EXPECT_EQ(session.state(), GameModeSessionState::kActive);
|
||||
EXPECT_EQ(probe.end_calls, 0);
|
||||
}
|
||||
|
||||
TEST(GameModeSessionTest, NamesTheDeclinedState) {
|
||||
EXPECT_STREQ(
|
||||
GameModeSessionStateName(GameModeSessionState::kDeclinedCorePinning),
|
||||
"declined-core-pinning");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -0,0 +1,60 @@
|
||||
// Drives the real PaceInputPump() against an SDL window, so the pacing
|
||||
// decision is exercised end to end rather than only as pacer arithmetic.
|
||||
// Gated like the other tests that need a real SDL video driver.
|
||||
#include <SDL3/SDL.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
#include "window/input_pump_pacer.h"
|
||||
#include "window/window.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace window {
|
||||
namespace {
|
||||
|
||||
bool RealSdlWindowTestsEnabled() {
|
||||
const char* enabled = std::getenv("MOCKTAIL_TEST_SDL_WINDOW");
|
||||
return enabled != nullptr && std::string(enabled) == "1";
|
||||
}
|
||||
|
||||
// Unthrottled presentation must not pace the main-thread pump.
|
||||
//
|
||||
// Roblox posts its per-frame main-thread step at a time of its own choosing
|
||||
// and nothing signals the host, so the pump is a poll. A frame that misses
|
||||
// its slot slips a whole period, and the engine's slack is under a
|
||||
// millisecond, so the pacer's 240 Hz cadence is far too coarse here. The rest
|
||||
// between polls is runtime/engine_pump_rest, bounded at 100 us.
|
||||
//
|
||||
// A throttled present blocks on vsync, so the pacer still runs there.
|
||||
TEST(InputPumpPacingRuntimeTest, UnthrottledPresentationDoesNotParkThePump) {
|
||||
if (!RealSdlWindowTestsEnabled()) {
|
||||
GTEST_SKIP() << "real SDL window test was not explicitly enabled";
|
||||
}
|
||||
ASSERT_EQ(setenv("MOCKTAIL_VSYNC", "off", 1), 0);
|
||||
ASSERT_EQ(setenv("MOCKTAIL_ENABLE_TEST_GRAPHICS_STUBS", "1", 1), 0);
|
||||
ASSERT_TRUE(Init(320, 180, "input pump pacing test"));
|
||||
ASSERT_TRUE(UnthrottledPresentationRequested());
|
||||
|
||||
constexpr int kTicks = 240;
|
||||
uint64_t slept_ns = 0;
|
||||
for (int tick = 0; tick < kTicks; ++tick) {
|
||||
PumpEvents();
|
||||
slept_ns += PaceInputPump();
|
||||
}
|
||||
Shutdown();
|
||||
|
||||
EXPECT_EQ(slept_ns, 0U)
|
||||
<< "unthrottled ticks parked the main-thread pump for " << slept_ns
|
||||
<< " ns, which delays the engine's main-thread replies";
|
||||
}
|
||||
|
||||
// The present mode policy is cached process-wide on first use, so a second
|
||||
// Init in this binary reports the mode selected above. The throttled cadence
|
||||
// is covered by the pacer's own arithmetic tests.
|
||||
|
||||
} // namespace
|
||||
} // namespace window
|
||||
} // namespace mocktail
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdarg>
|
||||
#include <cstdint>
|
||||
@@ -1087,6 +1088,99 @@ TEST_F(JniVmTest, FloatArrayElementsAreMutable) {
|
||||
env->ReleaseFloatArrayElements(array, elements, 0);
|
||||
}
|
||||
|
||||
constexpr int kMoreThanHandleCapacity = 150000;
|
||||
|
||||
TEST_F(JniVmTest, RepeatedFindClassDoesNotExhaustHandles) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
for (int i = 0; i < kMoreThanHandleCapacity; ++i) {
|
||||
jclass cls = env->FindClass("android/view/Surface");
|
||||
ASSERT_NE(cls, nullptr) << "iteration " << i;
|
||||
env->DeleteLocalRef(cls);
|
||||
}
|
||||
jstring text = env->NewStringUTF("still allocatable");
|
||||
ASSERT_NE(text, nullptr);
|
||||
env->DeleteLocalRef(text);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, RepeatedGetObjectClassDoesNotExhaustHandles) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
jclass surface_class = env->FindClass("android/view/Surface");
|
||||
jobject surface = env->AllocObject(surface_class);
|
||||
ASSERT_NE(surface, nullptr);
|
||||
for (int i = 0; i < kMoreThanHandleCapacity; ++i) {
|
||||
jclass cls = env->GetObjectClass(surface);
|
||||
ASSERT_NE(cls, nullptr) << "iteration " << i;
|
||||
env->DeleteLocalRef(cls);
|
||||
}
|
||||
env->DeleteLocalRef(surface);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, ClassHandlesStayValidAfterDeleteLocalRef) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
jclass first = env->FindClass("android/view/Surface");
|
||||
env->DeleteLocalRef(first);
|
||||
jclass second = env->FindClass("android/view/Surface");
|
||||
ASSERT_NE(second, nullptr);
|
||||
EXPECT_NE(env->GetMethodID(second, "isValid", "()Z"), nullptr);
|
||||
jobject surface = env->AllocObject(second);
|
||||
ASSERT_NE(surface, nullptr);
|
||||
EXPECT_TRUE(env->IsInstanceOf(surface, second));
|
||||
env->DeleteLocalRef(surface);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, LocalRefsDeletedInCreationOrderFreeTheirSlots) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
for (int round = 0; round < 3; ++round) {
|
||||
std::vector<jstring> strings;
|
||||
strings.reserve(60000);
|
||||
for (int i = 0; i < 60000; ++i) {
|
||||
jstring text = env->NewStringUTF("x");
|
||||
ASSERT_NE(text, nullptr) << "round " << round << " string " << i;
|
||||
strings.push_back(text);
|
||||
}
|
||||
for (jstring text : strings) {
|
||||
env->DeleteLocalRef(text);
|
||||
}
|
||||
}
|
||||
EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds(5));
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, FullHandleTableReportsLiveObjectsByClassOnce) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
std::vector<jstring> strings;
|
||||
::testing::internal::CaptureStderr();
|
||||
for (int i = 0; i < kMoreThanHandleCapacity; ++i) {
|
||||
jstring text = env->NewStringUTF("fill");
|
||||
if (text == nullptr) {
|
||||
break;
|
||||
}
|
||||
strings.push_back(text);
|
||||
}
|
||||
EXPECT_EQ(env->NewStringUTF("still full"), nullptr);
|
||||
const std::string output = ::testing::internal::GetCapturedStderr();
|
||||
for (jstring text : strings) {
|
||||
env->DeleteLocalRef(text);
|
||||
}
|
||||
EXPECT_LT(strings.size(), static_cast<std::size_t>(kMoreThanHandleCapacity));
|
||||
const std::size_t report = output.find("[JNI] handle table full");
|
||||
ASSERT_NE(report, std::string::npos) << output;
|
||||
EXPECT_NE(output.find("java/lang/String", report), std::string::npos)
|
||||
<< output;
|
||||
EXPECT_EQ(output.find("[JNI] handle table full", report + 1),
|
||||
std::string::npos);
|
||||
EXPECT_NE(env->NewStringUTF("free again"), nullptr);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, PopLocalFrameFreesLocalsCreatedInsideIt) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
for (int i = 0; i < kMoreThanHandleCapacity; ++i) {
|
||||
ASSERT_EQ(env->PushLocalFrame(4), JNI_OK);
|
||||
ASSERT_NE(env->NewStringUTF("frame local"), nullptr) << "iteration " << i;
|
||||
env->PopLocalFrame(nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, ObjectMethodCanReturnMockClassLoader) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
jclass cls = env->FindClass("java/lang/Object");
|
||||
@@ -2701,5 +2795,86 @@ TEST_F(JniVmTest, NativeQuoteInterfaceReportsKeystoreUnavailable) {
|
||||
EXPECT_NE(message.find("AndroidKeyStore"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, DetachCurrentThreadReleasesThatThreadsLocalRefs) {
|
||||
JavaVM *java_vm = vm_->GetJavaVM();
|
||||
for (int round = 0; round < 4; ++round) {
|
||||
bool all_allocated = true;
|
||||
std::thread worker([java_vm, &all_allocated]() {
|
||||
JNIEnv *env = nullptr;
|
||||
if (java_vm->AttachCurrentThread(reinterpret_cast<void **>(&env), nullptr) !=
|
||||
JNI_OK) {
|
||||
all_allocated = false;
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < 40000; ++i) {
|
||||
if (env->NewStringUTF("thread local") == nullptr) {
|
||||
all_allocated = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
java_vm->DetachCurrentThread();
|
||||
});
|
||||
worker.join();
|
||||
ASSERT_TRUE(all_allocated) << "round " << round;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, DetachCurrentThreadKeepsVmOwnedObjectsAlive) {
|
||||
JavaVM *java_vm = vm_->GetJavaVM();
|
||||
jobject implementation = nullptr;
|
||||
jobject params = nullptr;
|
||||
std::thread worker([java_vm, &implementation, ¶ms]() {
|
||||
JNIEnv *env = nullptr;
|
||||
if (java_vm->AttachCurrentThread(reinterpret_cast<void **>(&env), nullptr) !=
|
||||
JNI_OK) {
|
||||
return;
|
||||
}
|
||||
jclass gl_class =
|
||||
env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
|
||||
implementation = env->CallStaticObjectMethod(
|
||||
gl_class,
|
||||
env->GetStaticMethodID(gl_class, "getImplementation",
|
||||
"()Lcom/roblox/engine/jni/EngineJavaCallback2;"));
|
||||
params = env->CallStaticObjectMethod(
|
||||
gl_class,
|
||||
env->GetStaticMethodID(
|
||||
gl_class, "getDeviceStaticParams",
|
||||
"()Lcom/roblox/engine/jni/model/DeviceStaticParams;"));
|
||||
java_vm->DetachCurrentThread();
|
||||
});
|
||||
worker.join();
|
||||
ASSERT_NE(implementation, nullptr);
|
||||
ASSERT_NE(params, nullptr);
|
||||
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
EXPECT_NE(reinterpret_cast<jobject>(env->NewStringUTF("reused slot")),
|
||||
implementation);
|
||||
}
|
||||
jclass params_class =
|
||||
env->FindClass("com/roblox/engine/jni/model/DeviceStaticParams");
|
||||
jfieldID os_version =
|
||||
env->GetFieldID(params_class, "osVersion", "Ljava/lang/String;");
|
||||
EXPECT_EQ(ReadJavaString(env, static_cast<jstring>(
|
||||
env->GetObjectField(params, os_version))),
|
||||
"Android 13");
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, DeletedHighIndexStringHandleReadsAsEmpty) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
std::vector<jstring> strings;
|
||||
for (int i = 0; i < 8000; ++i) {
|
||||
strings.push_back(env->NewStringUTF("filler"));
|
||||
}
|
||||
jstring high = strings.back();
|
||||
ASSERT_GT(reinterpret_cast<uintptr_t>(high), uintptr_t{0x10000000});
|
||||
for (jstring text : strings) {
|
||||
env->DeleteLocalRef(text);
|
||||
}
|
||||
EXPECT_EQ(env->GetStringUTFLength(high), 0);
|
||||
EXPECT_STREQ(env->GetStringUTFChars(high, nullptr), "");
|
||||
EXPECT_EQ(env->GetStringLength(high), 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace jnivm
|
||||
@@ -8,13 +8,15 @@ set -Eeuo pipefail
|
||||
readonly ROOT="${1:?source root is required}"
|
||||
readonly CMAKE_FILE="${ROOT}/CMakeLists.txt"
|
||||
readonly PKGBUILD="${ROOT}/packaging/arch/PKGBUILD"
|
||||
readonly AUR_STABLE_PKGBUILD="${ROOT}/packaging/aur/mocktail/PKGBUILD"
|
||||
readonly AUR_STABLE_SRCINFO="${ROOT}/packaging/aur/mocktail/.SRCINFO"
|
||||
readonly AUR_BIN_PKGBUILD="${ROOT}/packaging/aur/mocktail-bin/PKGBUILD"
|
||||
readonly AUR_BIN_SRCINFO="${ROOT}/packaging/aur/mocktail-bin/.SRCINFO"
|
||||
readonly AUR_PKGBUILD="${ROOT}/packaging/aur/mocktail-git/PKGBUILD"
|
||||
readonly AUR_SRCINFO="${ROOT}/packaging/aur/mocktail-git/.SRCINFO"
|
||||
readonly AUR_STABLE_PKGBUILD="${ROOT}/packaging/aur/nightcap/PKGBUILD"
|
||||
readonly AUR_STABLE_SRCINFO="${ROOT}/packaging/aur/nightcap/.SRCINFO"
|
||||
readonly AUR_BIN_PKGBUILD="${ROOT}/packaging/aur/nightcap-bin/PKGBUILD"
|
||||
readonly AUR_BIN_SRCINFO="${ROOT}/packaging/aur/nightcap-bin/.SRCINFO"
|
||||
readonly AUR_PKGBUILD="${ROOT}/packaging/aur/nightcap-git/PKGBUILD"
|
||||
readonly AUR_SRCINFO="${ROOT}/packaging/aur/nightcap-git/.SRCINFO"
|
||||
readonly AUR_WORKFLOW="${ROOT}/.github/workflows/aur.yml"
|
||||
readonly WORKFLOW="${ROOT}/.github/workflows/packages.yml"
|
||||
readonly RELEASE_WORKFLOW="${ROOT}/.github/workflows/release.yml"
|
||||
readonly README="${ROOT}/README.md"
|
||||
readonly STUB_CMAKE="${ROOT}/stubs/CMakeLists.txt"
|
||||
readonly ANDROID_STUB="${ROOT}/stubs/libandroid_stub.cc"
|
||||
@@ -35,31 +37,39 @@ grep -Fq 'CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON' "${CMAKE_FILE}" ||
|
||||
Fail 'DEB packages do not derive shared-library dependencies'
|
||||
grep -Fq 'CPACK_RPM_PACKAGE_AUTOREQPROV ON' "${CMAKE_FILE}" ||
|
||||
Fail 'RPM packages do not derive runtime requirements'
|
||||
grep -Fq 'pkgname=mocktail' "${PKGBUILD}" ||
|
||||
grep -Fq 'set(MOCKTAIL_PACKAGE_VERSION' "${CMAKE_FILE}" ||
|
||||
Fail 'native package version cannot be set from the release tag'
|
||||
grep -Fq 'https://github.com/CoderDayton/nightcap' "${CMAKE_FILE}" ||
|
||||
Fail 'native packages point at the upstream project page'
|
||||
grep -Fq 'pkgname=nightcap' "${PKGBUILD}" ||
|
||||
Fail 'Arch package name is not stable'
|
||||
grep -Fq "provides=('mocktail')" "${PKGBUILD}" ||
|
||||
Fail 'Arch package does not provide the upstream package name'
|
||||
grep -Fq "conflicts=('mocktail')" "${PKGBUILD}" ||
|
||||
Fail 'Arch package can be installed beside upstream Mocktail'
|
||||
grep -Fq "'sdl3>=3.4'" "${PKGBUILD}" ||
|
||||
Fail 'Arch package does not enforce the SDL minimum'
|
||||
grep -Fq "'libplacebo'" "${PKGBUILD}" ||
|
||||
Fail 'Arch package does not declare the graphics composition dependency'
|
||||
grep -Fq 'pkgname=mocktail-git' "${AUR_PKGBUILD}" ||
|
||||
grep -Fq 'pkgname=nightcap-git' "${AUR_PKGBUILD}" ||
|
||||
Fail 'AUR VCS package name is not stable'
|
||||
grep -Fq 'pkgname=mocktail' "${AUR_STABLE_PKGBUILD}" ||
|
||||
grep -Fq 'pkgname=nightcap' "${AUR_STABLE_PKGBUILD}" ||
|
||||
Fail 'AUR source package name is not stable'
|
||||
grep -Fq '#tag=${pkgver}' "${AUR_STABLE_PKGBUILD}" ||
|
||||
grep -Fq '#tag=v${pkgver}' "${AUR_STABLE_PKGBUILD}" ||
|
||||
Fail 'AUR source package does not pin its Git tag'
|
||||
grep -Fq "'vulkan-headers'" "${AUR_STABLE_PKGBUILD}" ||
|
||||
Fail 'AUR source package does not use the packaged Vulkan headers'
|
||||
grep -Fq 'pkgbase = mocktail' "${AUR_STABLE_SRCINFO}" ||
|
||||
grep -Fq 'pkgbase = nightcap' "${AUR_STABLE_SRCINFO}" ||
|
||||
Fail 'AUR source package has no generated .SRCINFO metadata'
|
||||
grep -Fq 'pkgname=mocktail-bin' "${AUR_BIN_PKGBUILD}" ||
|
||||
grep -Fq 'pkgname=nightcap-bin' "${AUR_BIN_PKGBUILD}" ||
|
||||
Fail 'AUR binary package name is not stable'
|
||||
grep -Fq 'pkgbase = mocktail-bin' "${AUR_BIN_SRCINFO}" ||
|
||||
grep -Fq 'pkgbase = nightcap-bin' "${AUR_BIN_SRCINFO}" ||
|
||||
Fail 'AUR binary package has no generated .SRCINFO metadata'
|
||||
grep -Fq -- \
|
||||
"'mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main'" \
|
||||
"'nightcap::git+https://github.com/CoderDayton/nightcap.git#branch=main'" \
|
||||
"${AUR_PKGBUILD}" ||
|
||||
Fail 'AUR package does not build from the upstream Git repository'
|
||||
grep -Fq "provides=('mocktail')" "${AUR_PKGBUILD}" ||
|
||||
Fail 'AUR package does not build from this project Git repository'
|
||||
grep -Fq "provides=('mocktail' 'nightcap')" "${AUR_PKGBUILD}" ||
|
||||
Fail 'AUR VCS package does not provide the stable package name'
|
||||
grep -Fq "'vulkan-headers'" "${AUR_PKGBUILD}" ||
|
||||
Fail 'AUR VCS package does not use the packaged Vulkan headers'
|
||||
@@ -76,8 +86,26 @@ grep -Fq -- \
|
||||
'-DMOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=/usr/share/mocktail/metadata/' \
|
||||
"${AUR_PKGBUILD}" ||
|
||||
Fail 'AUR package embeds a build-tree compatibility manifest path'
|
||||
grep -Fq 'pkgbase = mocktail-git' "${AUR_SRCINFO}" ||
|
||||
grep -Fq 'pkgbase = nightcap-git' "${AUR_SRCINFO}" ||
|
||||
Fail 'AUR package has no generated .SRCINFO metadata'
|
||||
grep -Fq 'git describe' "${AUR_PKGBUILD}" ||
|
||||
Fail 'AUR VCS package does not version from the release tag'
|
||||
grep -Fq 'git describe' "${AUR_WORKFLOW}" ||
|
||||
Fail 'AUR workflow does not version from the release tag'
|
||||
for pkgbuild in "${PKGBUILD}" "${AUR_STABLE_PKGBUILD}" "${AUR_BIN_PKGBUILD}"; do
|
||||
grep -Fqx 'pkgver=0.3.1' "${pkgbuild}" ||
|
||||
Fail "package version does not match the release being prepared: ${pkgbuild}"
|
||||
done
|
||||
|
||||
# Nightcap keeps its AUR recipes in the tree. Nothing publishes them.
|
||||
for unexpected in \
|
||||
'aur.archlinux.org' \
|
||||
'AUR_SSH_PRIVATE_KEY' \
|
||||
'push origin'; do
|
||||
if grep -Fq -- "${unexpected}" "${AUR_WORKFLOW}"; then
|
||||
Fail "AUR workflow should not publish: ${unexpected}"
|
||||
fi
|
||||
done
|
||||
grep -Fq 'LINKER:--no-as-needed' "${STUB_CMAKE}" ||
|
||||
Fail 'system shims can lose their host libc dependencies'
|
||||
grep -Fq 'MOCKTAIL_MINIZIP_HAS_STREAM_TELL' "${STUB_CMAKE}" ||
|
||||
@@ -116,6 +144,29 @@ for unexpected in \
|
||||
fi
|
||||
done
|
||||
|
||||
for expected in \
|
||||
'-G DEB' \
|
||||
'-G RPM' \
|
||||
'makepkg --dir' \
|
||||
'-DMOCKTAIL_PACKAGE_NAME=nightcap' \
|
||||
'-DMOCKTAIL_PACKAGE_VERSION=' \
|
||||
'Nightcap-x86_64.AppImage' \
|
||||
'gh release upload'; do
|
||||
grep -Fq -- "${expected}" "${RELEASE_WORKFLOW}" ||
|
||||
Fail "release workflow is missing: ${expected}"
|
||||
done
|
||||
|
||||
# Nightcap publishes its packages on the release page only. Pushing them to
|
||||
# APT, RPM or AUR repositories is out of scope for this workflow.
|
||||
for unexpected in \
|
||||
'createrepo' \
|
||||
'reprepro' \
|
||||
'aur.archlinux.org'; do
|
||||
if grep -Fq -- "${unexpected}" "${RELEASE_WORKFLOW}"; then
|
||||
Fail "release workflow should not publish to a repository: ${unexpected}"
|
||||
fi
|
||||
done
|
||||
|
||||
grep -Fq 'Ubuntu 26.04+' "${README}" ||
|
||||
Fail 'README has no Ubuntu source dependency guide'
|
||||
grep -Fq 'Arch Linux' "${README}" ||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "mocktail/graphics/android_vulkan_wsi_adapter.h"
|
||||
@@ -118,6 +119,24 @@ TEST(SdlWindowIconTest, AppliesEmbeddedPngToRealWindow) {
|
||||
SDL_CreateWindow("Mocktail icon test", 64, 64, SDL_WINDOW_HIDDEN);
|
||||
ASSERT_NE(window, nullptr) << SDL_GetError();
|
||||
|
||||
// Setting a window icon needs compositor support: on Wayland that is the
|
||||
// xdg_toplevel_icon_v1 protocol, which many compositors do not implement.
|
||||
// Probe with a throwaway surface so this covers the embedded PNG wherever
|
||||
// icons work, instead of failing on a platform limitation the callers of
|
||||
// ApplySdlWindowIcon already treat as non-fatal.
|
||||
SDL_Surface* probe = SDL_CreateSurface(1, 1, SDL_PIXELFORMAT_RGBA32);
|
||||
ASSERT_NE(probe, nullptr) << SDL_GetError();
|
||||
SDL_ClearError();
|
||||
const bool icons_supported = SDL_SetWindowIcon(window, probe);
|
||||
const std::string probe_error = icons_supported ? std::string() : SDL_GetError();
|
||||
SDL_DestroySurface(probe);
|
||||
if (!icons_supported) {
|
||||
SDL_DestroyWindow(window);
|
||||
SDL_Quit();
|
||||
GTEST_SKIP() << "this video driver cannot set a window icon: "
|
||||
<< probe_error;
|
||||
}
|
||||
|
||||
const Status status = platform::ApplySdlWindowIcon(window);
|
||||
EXPECT_TRUE(status.ok()) << status.message();
|
||||
|
||||
|
||||
@@ -233,16 +233,17 @@ TEST_F(RobloxInputRouterTest, RoutesMouseMotionButtonAndVerticalWheel) {
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].y, 80.0f);
|
||||
EXPECT_EQ(probe_.mouse_buttons[0].button, 1);
|
||||
ASSERT_EQ(probe_.mouse_wheels.size(), 1U);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].x, 0.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].y, 0.0f);
|
||||
// A wheel event without a usable position keeps the pointer where it was.
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].x, 100.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].y, 80.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].delta_y, -2.0f);
|
||||
}
|
||||
|
||||
TEST_F(RobloxInputRouterTest,
|
||||
KeepsRelativeMotionResponsiveAfterOvershootingViewportEdge) {
|
||||
// Relative-mode motion reports zero absolute coordinates, so the router
|
||||
// accumulates deltas. The accumulator stays inside the viewport: a reversal
|
||||
// moves the pointer on the next event, however far the overshoot ran.
|
||||
TEST_F(RobloxInputRouterTest, HoldsPointerPositionDuringRelativeMotion) {
|
||||
// Relative-mode motion reports zero absolute coordinates. The pointer stays
|
||||
// where it was captured while the deltas pass through, so a long camera
|
||||
// turn neither walks the reported position to the viewport edge nor
|
||||
// swallows the reversal. The next absolute event moves the pointer again.
|
||||
ASSERT_TRUE(
|
||||
router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{640.0f, 360.0f, 0.0f,
|
||||
@@ -256,13 +257,172 @@ TEST_F(RobloxInputRouterTest,
|
||||
ASSERT_TRUE(
|
||||
router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, -100.0f,
|
||||
40.0f, 0}))
|
||||
.dispatched());
|
||||
ASSERT_TRUE(
|
||||
router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{200.0f, 100.0f, 3.0f,
|
||||
3.0f, 0}))
|
||||
.dispatched());
|
||||
|
||||
ASSERT_EQ(probe_.mouse_moves.size(), 4U);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].x, 640.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].y, 360.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].delta_x, 2000.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].x, 640.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].y, 360.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].delta_x, -100.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].delta_y, 40.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[3].x, 200.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[3].y, 100.0f);
|
||||
}
|
||||
|
||||
TEST_F(RobloxInputRouterTest, HoldsPointerWhenRelativeMotionCarriesAPosition) {
|
||||
// Wayland reports a drifting absolute position during relative mode instead
|
||||
// of zeroes, and it can leave the window. The capture flag decides, not the
|
||||
// coordinates, so the held position survives the drift and the reversal.
|
||||
ASSERT_TRUE(router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{
|
||||
640.0f, 360.0f, 0.0f, 0.0f, 0, false}))
|
||||
.dispatched());
|
||||
ASSERT_TRUE(router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{
|
||||
1840.0f, 470.0f, 1200.0f, 110.0f, 0, true}))
|
||||
.dispatched());
|
||||
ASSERT_TRUE(router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{
|
||||
-510.0f, 430.0f, -2350.0f, -40.0f, 0, true}))
|
||||
.dispatched());
|
||||
|
||||
ASSERT_EQ(probe_.mouse_moves.size(), 3U);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].x, 640.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].y, 360.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].delta_x, 1200.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].x, 640.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].y, 360.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].delta_x, -2350.0f);
|
||||
}
|
||||
|
||||
TEST_F(RobloxInputRouterTest, HoldsPointerWhenRelativeButtonCarriesAPosition) {
|
||||
// The release that ends a Wayland camera drag carries the drifted position
|
||||
// too. It must not move the pointer Roblox draws.
|
||||
ASSERT_TRUE(router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{
|
||||
640.0f, 360.0f, 0.0f, 0.0f, 0, false}))
|
||||
.dispatched());
|
||||
ASSERT_TRUE(router_
|
||||
.HandleEvent(Event(platform::MouseButtonEvent{
|
||||
false, SDL_BUTTON_RIGHT, 1, 1840.0f, 470.0f, true}))
|
||||
.dispatched());
|
||||
|
||||
ASSERT_FALSE(probe_.mouse_buttons.empty());
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_buttons.back().x, 640.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_buttons.back().y, 360.0f);
|
||||
}
|
||||
|
||||
TEST_F(RobloxInputRouterTest, HoldsPointerWhenRelativeWheelCarriesAPosition) {
|
||||
// Zooming the camera mid-drag scrolls while capture is held. Wayland stamps
|
||||
// the drifted position on the wheel event too, so the capture flag decides
|
||||
// there as well.
|
||||
ASSERT_TRUE(router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{
|
||||
640.0f, 360.0f, 0.0f, 0.0f, 0, false}))
|
||||
.dispatched());
|
||||
ASSERT_TRUE(router_
|
||||
.HandleEvent(Event(platform::MouseWheelEvent{
|
||||
0.0f, -1.0f, 1840.0f, 470.0f, true}))
|
||||
.dispatched());
|
||||
|
||||
ASSERT_FALSE(probe_.mouse_wheels.empty());
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_wheels.back().x, 640.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_wheels.back().y, 360.0f);
|
||||
}
|
||||
|
||||
TEST_F(RobloxInputRouterTest,
|
||||
AnchorsUnknownPointerAtViewportCenterForRelativeMotion) {
|
||||
// No absolute position has been seen yet, so the held position is the
|
||||
// viewport center rather than the top-left corner.
|
||||
ASSERT_TRUE(
|
||||
router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, 25.0f,
|
||||
-10.0f, 0}))
|
||||
.dispatched());
|
||||
ASSERT_TRUE(
|
||||
router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, 300.0f,
|
||||
0.0f, 0}))
|
||||
.dispatched());
|
||||
|
||||
ASSERT_EQ(probe_.mouse_moves.size(), 3U);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].x, 1279.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].x, 1179.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].delta_x, -100.0f);
|
||||
ASSERT_EQ(probe_.mouse_moves.size(), 2U);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].x, 639.5f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].y, 359.5f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_x, 25.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_y, -10.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].x, 639.5f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].y, 359.5f);
|
||||
}
|
||||
|
||||
TEST_F(RobloxInputRouterTest, ClampsHeldPointerToViewportAfterResize) {
|
||||
ASSERT_TRUE(
|
||||
router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{1200.0f, 700.0f, 0.0f,
|
||||
0.0f, 0}))
|
||||
.dispatched());
|
||||
ASSERT_EQ(router_
|
||||
.HandleEvent(Event(
|
||||
platform::WindowResizedEvent{800, 600, 800, 600, 1.0F}))
|
||||
.state,
|
||||
RobloxInputDispatchState::kStateUpdated);
|
||||
ASSERT_TRUE(
|
||||
router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, 50.0f,
|
||||
0.0f, 0}))
|
||||
.dispatched());
|
||||
|
||||
ASSERT_EQ(probe_.mouse_moves.size(), 2U);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].x, 799.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].y, 599.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].delta_x, 50.0f);
|
||||
}
|
||||
|
||||
TEST_F(RobloxInputRouterTest,
|
||||
KeepsHeldPointerAcrossZeroCoordinateWheelAndButtonEvents) {
|
||||
// While the pointer is captured, wheel and button events also arrive with
|
||||
// zero coordinates. None of them may move the held position.
|
||||
ASSERT_TRUE(
|
||||
router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{640.0f, 360.0f, 0.0f,
|
||||
0.0f, 0}))
|
||||
.dispatched());
|
||||
ASSERT_TRUE(router_
|
||||
.HandleEvent(Event(
|
||||
platform::MouseWheelEvent{0.0f, -1.0f, 0.0f, 0.0f}))
|
||||
.dispatched());
|
||||
ASSERT_TRUE(router_
|
||||
.HandleEvent(Event(platform::MouseButtonEvent{
|
||||
true, SDL_BUTTON_LEFT, 1, 0.0f, 0.0f}))
|
||||
.dispatched());
|
||||
ASSERT_TRUE(router_
|
||||
.HandleEvent(Event(platform::MouseButtonEvent{
|
||||
false, SDL_BUTTON_LEFT, 1, 0.0f, 0.0f}))
|
||||
.dispatched());
|
||||
ASSERT_TRUE(
|
||||
router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, -500.0f,
|
||||
0.0f, 0}))
|
||||
.dispatched());
|
||||
|
||||
ASSERT_EQ(probe_.mouse_wheels.size(), 1U);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].x, 640.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].y, 360.0f);
|
||||
ASSERT_EQ(probe_.mouse_buttons.size(), 2U);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].x, 640.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].y, 360.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_buttons[1].x, 640.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_buttons[1].y, 360.0f);
|
||||
ASSERT_EQ(probe_.mouse_moves.size(), 2U);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].x, 640.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].y, 360.0f);
|
||||
}
|
||||
|
||||
// A fractionally scaled X11 desktop: the buffer is 1:1 with logical units
|
||||
@@ -298,9 +458,14 @@ TEST_F(RobloxInputRouterTest, RawMouseSendsLookDeltasUnscaled) {
|
||||
ASSERT_EQ(probe_.mouse_moves.size(), 1U);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_x, 10.0f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_y, -6.0f);
|
||||
// The reported position stays in guest units whatever the delta carries.
|
||||
EXPECT_NEAR(probe_.mouse_moves[0].x, 8.727f, 0.01f);
|
||||
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].y, 0.0f);
|
||||
// The reported position stays in guest units whatever the delta carries:
|
||||
// with no absolute position seen, it is the guest viewport center.
|
||||
const platform::SurfaceCoordinateTransform transform = {1624, 811, 1624, 811,
|
||||
1.145833F};
|
||||
EXPECT_NEAR(probe_.mouse_moves[0].x, (transform.guest_width() - 1.0F) / 2.0F,
|
||||
0.01f);
|
||||
EXPECT_NEAR(probe_.mouse_moves[0].y,
|
||||
(transform.guest_height() - 1.0F) / 2.0F, 0.01f);
|
||||
}
|
||||
|
||||
TEST_F(RobloxInputRouterTest,
|
||||
|
||||
@@ -569,6 +569,85 @@ TEST(RobloxTextDisplayStateTest, WrapsMultilineTextInsideNativeTextBox) {
|
||||
EXPECT_TRUE(overlay.Shutdown().ok());
|
||||
}
|
||||
|
||||
TEST(RobloxTextDisplayStateTest, AdvancesCaretForTrailingSpace) {
|
||||
RobloxTextSurfaceOverlay overlay;
|
||||
ASSERT_TRUE(overlay.Initialize({800, 600}).ok());
|
||||
RobloxTextDisplaySink sink = overlay.sink();
|
||||
const auto rightmost_pixel = [&](const std::string& text,
|
||||
RobloxTextDisplayEvent event) {
|
||||
RobloxTextDisplayUpdate update =
|
||||
Show(1, text, static_cast<int32_t>(text.size()));
|
||||
update.event = event;
|
||||
update.font_size = 18.0F;
|
||||
sink.update(sink.context, update);
|
||||
MocktailTextOverlayFrameInfo frame;
|
||||
EXPECT_TRUE(overlay.QueryFrame(&frame));
|
||||
std::vector<std::uint8_t> rgba(frame.rgba_bytes);
|
||||
EXPECT_TRUE(overlay.CopyFrame(frame.revision, rgba.data(), rgba.size()));
|
||||
return FindAlphaBounds(frame, rgba).maximum_x;
|
||||
};
|
||||
|
||||
const int typed = rightmost_pixel("hi", RobloxTextDisplayEvent::kShow);
|
||||
const int spaced = rightmost_pixel("hi ", RobloxTextDisplayEvent::kUpdate);
|
||||
|
||||
EXPECT_GT(spaced, typed);
|
||||
EXPECT_TRUE(overlay.Shutdown().ok());
|
||||
}
|
||||
|
||||
TEST(RobloxTextDisplayStateTest, KeepsCaretBeforeTrailingSpaceDistinct) {
|
||||
RobloxTextSurfaceOverlay overlay;
|
||||
ASSERT_TRUE(overlay.Initialize({800, 600}).ok());
|
||||
RobloxTextDisplaySink sink = overlay.sink();
|
||||
const std::string text = "hi ";
|
||||
const auto rightmost_pixel = [&](int32_t cursor_utf16,
|
||||
RobloxTextDisplayEvent event) {
|
||||
RobloxTextDisplayUpdate update = Show(1, text, cursor_utf16);
|
||||
update.event = event;
|
||||
update.font_size = 18.0F;
|
||||
sink.update(sink.context, update);
|
||||
MocktailTextOverlayFrameInfo frame;
|
||||
EXPECT_TRUE(overlay.QueryFrame(&frame));
|
||||
std::vector<std::uint8_t> rgba(frame.rgba_bytes);
|
||||
EXPECT_TRUE(overlay.CopyFrame(frame.revision, rgba.data(), rgba.size()));
|
||||
const auto bounds = FindAlphaBounds(frame, rgba);
|
||||
EXPECT_TRUE(bounds.valid());
|
||||
return bounds.valid() ? bounds.maximum_x : -1;
|
||||
};
|
||||
|
||||
const int before_space = rightmost_pixel(2, RobloxTextDisplayEvent::kShow);
|
||||
const int after_space = rightmost_pixel(3, RobloxTextDisplayEvent::kUpdate);
|
||||
|
||||
EXPECT_GT(after_space, before_space);
|
||||
EXPECT_TRUE(overlay.Shutdown().ok());
|
||||
}
|
||||
|
||||
TEST(RobloxTextDisplayStateTest, ReopensFontsAfterPointSizeChange) {
|
||||
RobloxTextSurfaceOverlay overlay;
|
||||
ASSERT_TRUE(overlay.Initialize({800, 600}).ok());
|
||||
RobloxTextDisplaySink sink = overlay.sink();
|
||||
const std::string text = "size";
|
||||
const auto text_width = [&](float font_size, RobloxTextDisplayEvent event) {
|
||||
RobloxTextDisplayUpdate update = Show(1, text, 4);
|
||||
update.event = event;
|
||||
update.area_height = 64;
|
||||
update.font_size = font_size;
|
||||
sink.update(sink.context, update);
|
||||
MocktailTextOverlayFrameInfo frame;
|
||||
EXPECT_TRUE(overlay.QueryFrame(&frame));
|
||||
std::vector<std::uint8_t> rgba(frame.rgba_bytes);
|
||||
EXPECT_TRUE(overlay.CopyFrame(frame.revision, rgba.data(), rgba.size()));
|
||||
const auto bounds = FindAlphaBounds(frame, rgba);
|
||||
EXPECT_TRUE(bounds.valid());
|
||||
return bounds.valid() ? bounds.maximum_x - bounds.minimum_x : 0;
|
||||
};
|
||||
|
||||
const int small = text_width(14.0F, RobloxTextDisplayEvent::kShow);
|
||||
const int large = text_width(28.0F, RobloxTextDisplayEvent::kUpdate);
|
||||
|
||||
EXPECT_GT(large, small);
|
||||
EXPECT_TRUE(overlay.Shutdown().ok());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -168,7 +168,11 @@ TEST(RuntimeConfigBootstrapTest,
|
||||
"button targets that server.\n enabled: true",
|
||||
"# Boolean (default: true): never expose private or reserved "
|
||||
"joins.\n public_servers_only: true",
|
||||
"# playing: \"{place_name}\"\n # state: Playing Roblox",
|
||||
"# playing: \"Playing {place_name}\"\n # state: \"by "
|
||||
"{creator_name}\"",
|
||||
"# title: Roblox",
|
||||
"# large: \"{place_icon}\"\n # large_text: \"{place_name}\"\n"
|
||||
" # small: roblox_small\n # small_text: Roblox",
|
||||
"# Integer (default: 1280): initial window width in logical desktop "
|
||||
"units.\n "
|
||||
"width: 1280",
|
||||
@@ -356,6 +360,11 @@ integrations:
|
||||
playing: "Inside {place_name}"
|
||||
state: Playing Roblox on Linux
|
||||
unknown_place: Unknown world
|
||||
title: "{place_name}"
|
||||
images:
|
||||
large: ""
|
||||
small: nightcap_logo
|
||||
small_text: On Linux
|
||||
updates:
|
||||
automatic: true
|
||||
)yaml");
|
||||
@@ -405,6 +414,78 @@ updates:
|
||||
EXPECT_EQ(loaded.config.discord_rpc().text.state,
|
||||
"Playing Roblox on Linux");
|
||||
EXPECT_EQ(loaded.config.discord_rpc().text.unknown_place, "Unknown world");
|
||||
EXPECT_EQ(loaded.config.discord_rpc().text.title, "{place_name}");
|
||||
EXPECT_TRUE(loaded.config.discord_rpc().images.large.empty());
|
||||
EXPECT_EQ(loaded.config.discord_rpc().images.large_text, "{place_name}");
|
||||
EXPECT_EQ(loaded.config.discord_rpc().images.small, "nightcap_logo");
|
||||
EXPECT_EQ(loaded.config.discord_rpc().images.small_text, "On Linux");
|
||||
EXPECT_TRUE(loaded.config.discord_rpc_valid());
|
||||
}
|
||||
|
||||
TEST(RuntimeConfigFileTest, RejectsControlBytesInDiscordPresenceFields) {
|
||||
TemporaryDirectory temporary;
|
||||
const std::filesystem::path file = temporary.Write(R"yaml(
|
||||
version: 1
|
||||
integrations:
|
||||
discord_rpc:
|
||||
images:
|
||||
small_text: "bad\x01text"
|
||||
)yaml");
|
||||
|
||||
const RuntimeConfigLoadResult loaded =
|
||||
LoadRuntimeConfig(MapEnvironment(), file);
|
||||
|
||||
EXPECT_FALSE(loaded);
|
||||
EXPECT_NE(loaded.error.find("integrations.discord_rpc.images.small_text"),
|
||||
std::string::npos);
|
||||
EXPECT_NE(loaded.error.find("control bytes"), std::string::npos)
|
||||
<< loaded.error;
|
||||
}
|
||||
|
||||
TEST(RuntimeConfigFileTest, EmptyDiscordStateLoadsAsHidden) {
|
||||
TemporaryDirectory temporary;
|
||||
const std::filesystem::path file = temporary.Write(R"yaml(
|
||||
version: 1
|
||||
integrations:
|
||||
discord_rpc:
|
||||
text:
|
||||
state: ""
|
||||
)yaml");
|
||||
|
||||
const RuntimeConfigLoadResult loaded =
|
||||
LoadRuntimeConfig(MapEnvironment(), file);
|
||||
|
||||
ASSERT_TRUE(loaded) << loaded.error;
|
||||
EXPECT_TRUE(loaded.config.discord_rpc().text.state.empty());
|
||||
EXPECT_TRUE(loaded.config.discord_rpc_valid());
|
||||
}
|
||||
|
||||
TEST(RuntimeConfigFileTest, ExportsDiscordPresenceFieldsIncludingEmptyValues) {
|
||||
std::string error;
|
||||
const RuntimeConfig configured = RuntimeConfig::FromEnvironment(
|
||||
MapEnvironment({{"MOCKTAIL_DISCORD_RPC_TEXT_TITLE", "Nightcap"},
|
||||
{"MOCKTAIL_DISCORD_RPC_TEXT_STATE", ""},
|
||||
{"MOCKTAIL_DISCORD_RPC_IMAGE_LARGE", ""},
|
||||
{"MOCKTAIL_DISCORD_RPC_IMAGE_SMALL", "linux"}}));
|
||||
ASSERT_TRUE(ExportRuntimeConfigEnvironment(configured, &error)) << error;
|
||||
|
||||
const RuntimeConfig exported =
|
||||
RuntimeConfig::FromEnvironment(ProcessEnvironment());
|
||||
EXPECT_EQ(exported.discord_rpc().text.title, "Nightcap");
|
||||
EXPECT_TRUE(exported.discord_rpc().text.state.empty());
|
||||
EXPECT_TRUE(exported.discord_rpc_valid());
|
||||
EXPECT_TRUE(exported.discord_rpc().images.large.empty());
|
||||
EXPECT_EQ(exported.discord_rpc().images.large_text, "{place_name}");
|
||||
EXPECT_EQ(exported.discord_rpc().images.small, "linux");
|
||||
EXPECT_EQ(exported.discord_rpc().images.small_text, "Roblox");
|
||||
for (const char* variable :
|
||||
{"MOCKTAIL_DISCORD_RPC_TEXT_TITLE", "MOCKTAIL_DISCORD_RPC_TEXT_STATE",
|
||||
"MOCKTAIL_DISCORD_RPC_IMAGE_LARGE",
|
||||
"MOCKTAIL_DISCORD_RPC_IMAGE_LARGE_TEXT",
|
||||
"MOCKTAIL_DISCORD_RPC_IMAGE_SMALL",
|
||||
"MOCKTAIL_DISCORD_RPC_IMAGE_SMALL_TEXT"}) {
|
||||
unsetenv(variable);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(RuntimeConfigFileTest, EnvironmentOverridesYaml) {
|
||||
|
||||
@@ -84,9 +84,14 @@ TEST(RuntimeConfigTest, UsesSupportedDefaults) {
|
||||
EXPECT_EQ(config.discord_rpc().application_id, "1549428231927500843");
|
||||
EXPECT_EQ(config.discord_rpc().text.browsing, "Browsing experiences");
|
||||
EXPECT_EQ(config.discord_rpc().text.joining, "Joining an experience");
|
||||
EXPECT_EQ(config.discord_rpc().text.playing, "{place_name}");
|
||||
EXPECT_EQ(config.discord_rpc().text.state, "Playing Roblox");
|
||||
EXPECT_EQ(config.discord_rpc().text.playing, "Playing {place_name}");
|
||||
EXPECT_EQ(config.discord_rpc().text.state, "by {creator_name}");
|
||||
EXPECT_EQ(config.discord_rpc().text.unknown_place, "Unknown experience");
|
||||
EXPECT_EQ(config.discord_rpc().text.title, "Roblox");
|
||||
EXPECT_EQ(config.discord_rpc().images.large, "{place_icon}");
|
||||
EXPECT_EQ(config.discord_rpc().images.large_text, "{place_name}");
|
||||
EXPECT_EQ(config.discord_rpc().images.small, "roblox_small");
|
||||
EXPECT_EQ(config.discord_rpc().images.small_text, "Roblox");
|
||||
EXPECT_TRUE(config.discord_rpc_valid());
|
||||
EXPECT_FALSE(config.has_unsafe_detached_thread_overrides());
|
||||
EXPECT_TRUE(config.unsafe_detached_thread_overrides().empty());
|
||||
@@ -121,6 +126,10 @@ TEST(RuntimeConfigTest, ReadsTypedRuntimeValues) {
|
||||
{"MOCKTAIL_DISCORD_RPC_JOIN_BUTTON_LABEL", "Play Together"},
|
||||
{"MOCKTAIL_DISCORD_APPLICATION_ID", "123456789012345678"},
|
||||
{"MOCKTAIL_DISCORD_RPC_TEXT_PLAYING", "In {place_name}"},
|
||||
{"MOCKTAIL_DISCORD_RPC_TEXT_TITLE", "Nightcap"},
|
||||
{"MOCKTAIL_DISCORD_RPC_IMAGE_LARGE", ""},
|
||||
{"MOCKTAIL_DISCORD_RPC_IMAGE_SMALL", "linux"},
|
||||
{"MOCKTAIL_DISCORD_RPC_IMAGE_SMALL_TEXT", "On Linux"},
|
||||
});
|
||||
const RuntimeConfig config = RuntimeConfig::FromEnvironment(environment);
|
||||
|
||||
@@ -162,6 +171,11 @@ TEST(RuntimeConfigTest, ReadsTypedRuntimeValues) {
|
||||
EXPECT_EQ(config.discord_rpc().join_button_label, "Play Together");
|
||||
EXPECT_EQ(config.discord_rpc().application_id, "123456789012345678");
|
||||
EXPECT_EQ(config.discord_rpc().text.playing, "In {place_name}");
|
||||
EXPECT_EQ(config.discord_rpc().text.title, "Nightcap");
|
||||
EXPECT_TRUE(config.discord_rpc().images.large.empty());
|
||||
EXPECT_EQ(config.discord_rpc().images.large_text, "{place_name}");
|
||||
EXPECT_EQ(config.discord_rpc().images.small, "linux");
|
||||
EXPECT_EQ(config.discord_rpc().images.small_text, "On Linux");
|
||||
EXPECT_TRUE(config.discord_rpc_valid());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
# Licensed under the Apache License, Version 2.0.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "scripts/summarize_profile_trace.py"
|
||||
SPEC = importlib.util.spec_from_file_location("summarize_profile_trace",
|
||||
SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
summary = importlib.util.module_from_spec(SPEC)
|
||||
# Dataclasses resolve their module through sys.modules.
|
||||
sys.modules[SPEC.name] = summary
|
||||
SPEC.loader.exec_module(summary)
|
||||
|
||||
|
||||
def frame(ts_us: int, interval_ms: float) -> dict:
|
||||
return {"name": "frame interval (ms)", "ph": "C", "ts": ts_us,
|
||||
"pid": 1, "tid": 1, "args": {"value": interval_ms}}
|
||||
|
||||
|
||||
def slice_event(name: str, dur_us: int, ts_us: int = 0) -> dict:
|
||||
return {"name": name, "cat": "test", "ph": "X", "ts": ts_us,
|
||||
"dur": dur_us, "pid": 1, "tid": 1}
|
||||
|
||||
|
||||
class SummarizeTest(unittest.TestCase):
|
||||
|
||||
def test_frame_interval_percentiles_use_nearest_rank(self) -> None:
|
||||
events = [frame(index, float(index)) for index in range(1, 101)]
|
||||
|
||||
result = summary.summarize(events)
|
||||
|
||||
frames = result.frames
|
||||
self.assertEqual(frames.count, 100)
|
||||
self.assertAlmostEqual(frames.mean, 50.5)
|
||||
self.assertEqual(frames.p50, 50.0)
|
||||
self.assertEqual(frames.p95, 95.0)
|
||||
self.assertEqual(frames.p99, 99.0)
|
||||
self.assertEqual(frames.max, 100.0)
|
||||
|
||||
def test_slices_are_grouped_by_name_in_milliseconds(self) -> None:
|
||||
events = [
|
||||
slice_event("vkQueuePresentKHR", 2_000),
|
||||
slice_event("vkQueuePresentKHR", 4_000),
|
||||
slice_event("vkCreateGraphicsPipelines", 30_000),
|
||||
]
|
||||
|
||||
result = summary.summarize(events)
|
||||
|
||||
present = result.slices["vkQueuePresentKHR"]
|
||||
self.assertEqual(present.count, 2)
|
||||
self.assertAlmostEqual(present.total, 6.0)
|
||||
self.assertAlmostEqual(present.mean, 3.0)
|
||||
self.assertAlmostEqual(present.max, 4.0)
|
||||
self.assertEqual(result.slices["vkCreateGraphicsPipelines"].count, 1)
|
||||
|
||||
def test_other_events_are_ignored(self) -> None:
|
||||
events = [
|
||||
{"name": "thread", "ph": "M", "ts": 0, "pid": 1, "tid": 1},
|
||||
{"name": "other counter", "ph": "C", "ts": 0, "pid": 1, "tid": 1,
|
||||
"args": {"value": 5.0}},
|
||||
]
|
||||
|
||||
result = summary.summarize(events)
|
||||
|
||||
self.assertIsNone(result.frames)
|
||||
self.assertEqual(result.slices, {})
|
||||
|
||||
def test_markdown_puts_each_trace_in_its_own_column(self) -> None:
|
||||
before = summary.summarize([frame(1, 20.0), slice_event("a", 1_000)])
|
||||
after = summary.summarize([frame(1, 10.0), slice_event("a", 500)])
|
||||
|
||||
table = summary.to_markdown([("before.json", before),
|
||||
("after.json", after)])
|
||||
|
||||
lines = table.splitlines()
|
||||
self.assertEqual(lines[0], "| metric | before.json | after.json |")
|
||||
self.assertIn("| frame interval p50 (ms) | 20.00 | 10.00 |", lines)
|
||||
self.assertIn("| a mean (ms) | 1.00 | 0.50 |", lines)
|
||||
|
||||
def test_markdown_marks_missing_values(self) -> None:
|
||||
with_slice = summary.summarize([slice_event("a", 1_000)])
|
||||
without = summary.summarize([])
|
||||
|
||||
table = summary.to_markdown([("one", with_slice), ("two", without)])
|
||||
|
||||
self.assertIn("| a count | 1 | - |", table.splitlines())
|
||||
self.assertIn("| frames | - | - |", table.splitlines())
|
||||
|
||||
def test_main_reads_trace_files(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "trace.json"
|
||||
path.write_text(json.dumps([frame(1, 16.0),
|
||||
slice_event("b", 2_000)]))
|
||||
output = io.StringIO()
|
||||
|
||||
with contextlib.redirect_stdout(output):
|
||||
code = summary.main([str(path)])
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("| frame interval p99 (ms) | 16.00 |",
|
||||
output.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -98,6 +98,56 @@ TEST(TextureOverrideTest, ResampleAveragesSourceBoxes) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rounded mean of the source box [x*sw/w, (x+1)*sw/w) per axis, at least one
|
||||
// texel wide.
|
||||
std::vector<std::uint8_t> ReferenceBoxResample(const RgbaImage& source,
|
||||
std::uint32_t width,
|
||||
std::uint32_t height) {
|
||||
std::vector<std::uint8_t> out(static_cast<std::size_t>(width) * height * 4);
|
||||
const auto span = [](std::uint32_t i, std::uint32_t from, std::uint32_t to) {
|
||||
const std::uint64_t first = static_cast<std::uint64_t>(i) * from / to;
|
||||
const std::uint64_t last =
|
||||
std::max<std::uint64_t>(first + 1, (i + 1ULL) * from / to);
|
||||
return std::pair{first, last};
|
||||
};
|
||||
for (std::uint32_t y = 0; y < height; ++y) {
|
||||
const auto [y0, y1] = span(y, source.height, height);
|
||||
for (std::uint32_t x = 0; x < width; ++x) {
|
||||
const auto [x0, x1] = span(x, source.width, width);
|
||||
for (int channel = 0; channel < 4; ++channel) {
|
||||
std::uint64_t sum = 0;
|
||||
for (std::uint64_t sy = y0; sy < y1; ++sy) {
|
||||
for (std::uint64_t sx = x0; sx < x1; ++sx) {
|
||||
sum += source.pixels[(sy * source.width + sx) * 4 + channel];
|
||||
}
|
||||
}
|
||||
const std::uint64_t count = (y1 - y0) * (x1 - x0);
|
||||
out[(static_cast<std::size_t>(y) * width + x) * 4 + channel] =
|
||||
static_cast<std::uint8_t>((sum + count / 2) / count);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
TEST(TextureOverrideTest, ResampleMatchesBoxFilterForMixedSizes) {
|
||||
const std::pair<std::uint32_t, std::uint32_t> sources[] = {
|
||||
{1, 1}, {3, 5}, {48, 48}, {60, 33}, {64, 17}};
|
||||
const std::pair<std::uint32_t, std::uint32_t> targets[] = {
|
||||
{1, 1}, {2, 7}, {5, 3}, {48, 48}, {192, 192}, {240, 132}, {31, 200}};
|
||||
for (const auto& [source_width, source_height] : sources) {
|
||||
const RgbaImage source = Gradient(source_width, source_height);
|
||||
for (const auto& [width, height] : targets) {
|
||||
std::vector<std::uint8_t> actual(
|
||||
static_cast<std::size_t>(width) * height * 4, 0);
|
||||
ResampleRgba(source, width, height, actual.data());
|
||||
ASSERT_EQ(actual, ReferenceBoxResample(source, width, height))
|
||||
<< source_width << "x" << source_height << " -> " << width << "x"
|
||||
<< height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(TextureOverrideTest, LookupFindsPngNamedByHashAndCachesMisses) {
|
||||
TempDir dir;
|
||||
ASSERT_FALSE(dir.path().empty());
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail::graphics {
|
||||
namespace {
|
||||
@@ -127,6 +129,10 @@ VkExtent3D g_copied_extent{};
|
||||
VkDeviceSize g_created_buffer_size = 0;
|
||||
std::uintptr_t g_next_image = 0x100;
|
||||
std::uint32_t g_copy_image_calls = 0;
|
||||
std::uint32_t g_create_buffer_calls = 0;
|
||||
std::uint32_t g_allocate_memory_calls = 0;
|
||||
std::uint32_t g_destroy_buffer_calls = 0;
|
||||
std::uint32_t g_free_memory_calls = 0;
|
||||
VkImageBlit g_blit{};
|
||||
VkFilter g_blit_filter = VK_FILTER_NEAREST;
|
||||
|
||||
@@ -159,6 +165,7 @@ VKAPI_ATTR VkResult VKAPI_CALL FakeCreateBuffer(VkDevice,
|
||||
const VkAllocationCallbacks*,
|
||||
VkBuffer* buffer) {
|
||||
g_created_buffer_size = info->size;
|
||||
++g_create_buffer_calls;
|
||||
*buffer = FakeHandle<VkBuffer>(kStagingBuffer);
|
||||
return VK_SUCCESS;
|
||||
}
|
||||
@@ -173,6 +180,7 @@ VKAPI_ATTR VkResult VKAPI_CALL FakeAllocateMemory(
|
||||
VkDevice, const VkMemoryAllocateInfo*, const VkAllocationCallbacks*,
|
||||
VkDeviceMemory* memory) {
|
||||
*memory = FakeHandle<VkDeviceMemory>(kStagingMemory);
|
||||
++g_allocate_memory_calls;
|
||||
return VK_SUCCESS;
|
||||
}
|
||||
|
||||
@@ -193,13 +201,74 @@ VKAPI_ATTR VkResult VKAPI_CALL FakeMapMemory(VkDevice, VkDeviceMemory memory,
|
||||
|
||||
VKAPI_ATTR void VKAPI_CALL FakeUnmapMemory(VkDevice, VkDeviceMemory) {}
|
||||
VKAPI_ATTR void VKAPI_CALL FakeDestroyBuffer(VkDevice, VkBuffer,
|
||||
const VkAllocationCallbacks*) {}
|
||||
const VkAllocationCallbacks*) {
|
||||
++g_destroy_buffer_calls;
|
||||
}
|
||||
VKAPI_ATTR void VKAPI_CALL FakeFreeMemory(VkDevice, VkDeviceMemory,
|
||||
const VkAllocationCallbacks*) {}
|
||||
const VkAllocationCallbacks*) {
|
||||
++g_free_memory_calls;
|
||||
}
|
||||
// Records the host-write barrier that must precede each staged copy, so the
|
||||
// decode's writes are visible to the device even though they land after the
|
||||
// submit that reads them.
|
||||
std::uint32_t g_barriers_before_copy = 0;
|
||||
VkPipelineStageFlags g_barrier_src_stage = 0;
|
||||
VkPipelineStageFlags g_barrier_dst_stage = 0;
|
||||
VkAccessFlags g_barrier_src_access = 0;
|
||||
VkAccessFlags g_barrier_dst_access = 0;
|
||||
std::uint32_t g_pending_barriers = 0;
|
||||
|
||||
VKAPI_ATTR void VKAPI_CALL FakePipelineBarrier(
|
||||
VkCommandBuffer, VkPipelineStageFlags src_stage,
|
||||
VkPipelineStageFlags dst_stage, VkDependencyFlags, std::uint32_t count,
|
||||
const VkMemoryBarrier* barriers, std::uint32_t, const VkBufferMemoryBarrier*,
|
||||
std::uint32_t, const VkImageMemoryBarrier*) {
|
||||
g_barrier_src_stage = src_stage;
|
||||
g_barrier_dst_stage = dst_stage;
|
||||
if (count > 0) {
|
||||
g_barrier_src_access = barriers[0].srcAccessMask;
|
||||
g_barrier_dst_access = barriers[0].dstAccessMask;
|
||||
}
|
||||
++g_pending_barriers;
|
||||
}
|
||||
|
||||
VKAPI_ATTR void VKAPI_CALL FakeCopyBufferToImage(
|
||||
VkCommandBuffer, VkBuffer, VkImage, VkImageLayout, std::uint32_t count,
|
||||
const VkBufferImageCopy* regions) {
|
||||
g_copied_extent = count > 0 ? regions[0].imageExtent : VkExtent3D{};
|
||||
g_barriers_before_copy = g_pending_barriers;
|
||||
}
|
||||
|
||||
constexpr std::uintptr_t kTimelineSemaphore = 0x900;
|
||||
|
||||
std::mutex g_signal_mutex;
|
||||
std::vector<std::uint64_t> g_signalled;
|
||||
|
||||
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateSemaphore(
|
||||
VkDevice, const VkSemaphoreCreateInfo*, const VkAllocationCallbacks*,
|
||||
VkSemaphore* semaphore) {
|
||||
*semaphore = FakeHandle<VkSemaphore>(kTimelineSemaphore);
|
||||
return VK_SUCCESS;
|
||||
}
|
||||
|
||||
VKAPI_ATTR VkResult VKAPI_CALL
|
||||
FakeSignalSemaphore(VkDevice, const VkSemaphoreSignalInfo* info) {
|
||||
std::lock_guard<std::mutex> lock(g_signal_mutex);
|
||||
g_signalled.push_back(info->value);
|
||||
return VK_SUCCESS;
|
||||
}
|
||||
|
||||
VKAPI_ATTR void VKAPI_CALL FakeDestroySemaphore(VkDevice, VkSemaphore,
|
||||
const VkAllocationCallbacks*) {}
|
||||
|
||||
std::vector<std::uint64_t> SignalledValues() {
|
||||
std::lock_guard<std::mutex> lock(g_signal_mutex);
|
||||
return g_signalled;
|
||||
}
|
||||
|
||||
void ResetSignalledValues() {
|
||||
std::lock_guard<std::mutex> lock(g_signal_mutex);
|
||||
g_signalled.clear();
|
||||
}
|
||||
|
||||
VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL FakeGetDeviceProcAddr(
|
||||
@@ -207,6 +276,12 @@ VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL FakeGetDeviceProcAddr(
|
||||
const auto is = [name](const char* candidate) {
|
||||
return std::strcmp(name, candidate) == 0;
|
||||
};
|
||||
if (is("vkCreateSemaphore"))
|
||||
return reinterpret_cast<PFN_vkVoidFunction>(FakeCreateSemaphore);
|
||||
if (is("vkSignalSemaphore"))
|
||||
return reinterpret_cast<PFN_vkVoidFunction>(FakeSignalSemaphore);
|
||||
if (is("vkDestroySemaphore"))
|
||||
return reinterpret_cast<PFN_vkVoidFunction>(FakeDestroySemaphore);
|
||||
if (is("vkCreateImage")) return reinterpret_cast<PFN_vkVoidFunction>(FakeCreateImage);
|
||||
if (is("vkCreateBuffer")) return reinterpret_cast<PFN_vkVoidFunction>(FakeCreateBuffer);
|
||||
if (is("vkGetBufferMemoryRequirements"))
|
||||
@@ -219,6 +294,8 @@ VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL FakeGetDeviceProcAddr(
|
||||
if (is("vkFreeMemory")) return reinterpret_cast<PFN_vkVoidFunction>(FakeFreeMemory);
|
||||
if (is("vkCmdCopyBufferToImage"))
|
||||
return reinterpret_cast<PFN_vkVoidFunction>(FakeCopyBufferToImage);
|
||||
if (is("vkCmdPipelineBarrier"))
|
||||
return reinterpret_cast<PFN_vkVoidFunction>(FakePipelineBarrier);
|
||||
if (is("vkCmdCopyImage")) return reinterpret_cast<PFN_vkVoidFunction>(FakeCopyImage);
|
||||
if (is("vkCmdBlitImage")) return reinterpret_cast<PFN_vkVoidFunction>(FakeBlitImage);
|
||||
return nullptr;
|
||||
@@ -528,6 +605,135 @@ TEST(VulkanEtc2EmulationTest, FillsScaledMipsFromLevelZero) {
|
||||
emulation.ReleaseCommandBuffer(command_buffer);
|
||||
}
|
||||
|
||||
// Level 0 carries the texels every scaled mip is built from, so a mip
|
||||
// recorded ahead of it must still resolve against it.
|
||||
TEST(VulkanEtc2EmulationTest, FillsScaledMipsFromLevelZeroRecordedLast) {
|
||||
const VkDevice device = FakeHandle<VkDevice>(0x16);
|
||||
const VkBuffer source = FakeHandle<VkBuffer>(0x206);
|
||||
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
|
||||
const VkCommandBuffer command_buffer = FakeHandle<VkCommandBuffer>(0x706);
|
||||
g_source.fill(0);
|
||||
g_staging.fill(0xAA);
|
||||
ASSERT_EQ(setenv("MOCKTAIL_SMALL_TEXTURE_UPSCALE", "2", 1), 0);
|
||||
|
||||
VkPhysicalDeviceMemoryProperties memory{};
|
||||
memory.memoryTypeCount = 1;
|
||||
memory.memoryTypes[0].propertyFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
|
||||
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
|
||||
VulkanEtc2Emulation emulation;
|
||||
ASSERT_EQ(unsetenv("MOCKTAIL_SMALL_TEXTURE_UPSCALE"), 0);
|
||||
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x26), true,
|
||||
memory, FakeGetDeviceProcAddr);
|
||||
|
||||
VkImageCreateInfo image_info{};
|
||||
image_info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
|
||||
image_info.format = VK_FORMAT_ETC2_R8G8B8_UNORM_BLOCK;
|
||||
image_info.imageType = VK_IMAGE_TYPE_2D;
|
||||
image_info.extent = {4, 4, 1};
|
||||
image_info.mipLevels = 3;
|
||||
image_info.arrayLayers = 1;
|
||||
VkImage image = VK_NULL_HANDLE;
|
||||
ASSERT_EQ(emulation.CreateImage(device, &image_info, nullptr, &image),
|
||||
VK_SUCCESS);
|
||||
ASSERT_EQ(emulation.BindBufferMemory(device, source, source_memory, 0),
|
||||
VK_SUCCESS);
|
||||
void* mapped = nullptr;
|
||||
ASSERT_EQ(emulation.MapMemory(device, source_memory, 0, g_source.size(), 0,
|
||||
&mapped),
|
||||
VK_SUCCESS);
|
||||
|
||||
const std::array<std::uint8_t, 8> level0 = {0x81, 0x81, 0x81, 0x02,
|
||||
0, 0, 0, 0};
|
||||
const std::array<std::uint8_t, 8> level1 = {0x88, 0x88, 0x88, 0x00,
|
||||
0, 0, 0, 0};
|
||||
std::memcpy(g_source.data(), level0.data(), level0.size());
|
||||
std::memcpy(g_source.data() + 8, level1.data(), level1.size());
|
||||
|
||||
// Level 1 is recorded first, so its staging bytes lead the batch.
|
||||
std::array<VkBufferImageCopy, 2> regions{};
|
||||
regions[0].imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
|
||||
regions[0].imageSubresource.layerCount = 1;
|
||||
regions[0].bufferOffset = 8;
|
||||
regions[0].imageSubresource.mipLevel = 1;
|
||||
regions[0].imageExtent = {2, 2, 1};
|
||||
regions[1] = regions[0];
|
||||
regions[1].bufferOffset = 0;
|
||||
regions[1].imageSubresource.mipLevel = 0;
|
||||
regions[1].imageExtent = {4, 4, 1};
|
||||
emulation.CmdCopyBufferToImage(device, command_buffer, source, image,
|
||||
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 2,
|
||||
regions.data());
|
||||
emulation.PrepareSubmit(&command_buffer, 1);
|
||||
|
||||
RgbaImage level0_texels;
|
||||
level0_texels.width = 4;
|
||||
level0_texels.height = 4;
|
||||
level0_texels.pixels.resize(4 * 4 * 4);
|
||||
ASSERT_TRUE(DecodeEtcImage(EtcFormat::kEtc2Rgb8, level0.data(),
|
||||
level0.size(), 4, 4, level0_texels.pixels.data(),
|
||||
level0_texels.pixels.size()));
|
||||
std::array<std::uint8_t, 4 * 4 * 4> expected{};
|
||||
ResampleRgba(level0_texels, 4, 4, expected.data());
|
||||
for (std::size_t index = 0; index < expected.size(); ++index) {
|
||||
ASSERT_EQ(g_staging[index], expected[index]) << index;
|
||||
}
|
||||
emulation.ReleaseCommandBuffer(command_buffer);
|
||||
}
|
||||
|
||||
TEST(VulkanEtc2EmulationTest, ReusesStagingBuffersAcrossCommandBuffers) {
|
||||
const VkDevice device = FakeHandle<VkDevice>(0x17);
|
||||
const VkBuffer source = FakeHandle<VkBuffer>(0x207);
|
||||
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
|
||||
g_source.fill(0);
|
||||
|
||||
VkPhysicalDeviceMemoryProperties memory{};
|
||||
memory.memoryTypeCount = 1;
|
||||
memory.memoryTypes[0].propertyFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
|
||||
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
|
||||
VulkanEtc2Emulation emulation;
|
||||
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x27), true,
|
||||
memory, FakeGetDeviceProcAddr);
|
||||
|
||||
VkImageCreateInfo image_info{};
|
||||
image_info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
|
||||
image_info.format = VK_FORMAT_ETC2_R8G8B8_UNORM_BLOCK;
|
||||
image_info.extent = {4, 4, 1};
|
||||
VkImage image = VK_NULL_HANDLE;
|
||||
ASSERT_EQ(emulation.CreateImage(device, &image_info, nullptr, &image),
|
||||
VK_SUCCESS);
|
||||
ASSERT_EQ(emulation.BindBufferMemory(device, source, source_memory, 0),
|
||||
VK_SUCCESS);
|
||||
void* mapped = nullptr;
|
||||
ASSERT_EQ(emulation.MapMemory(device, source_memory, 0, g_source.size(), 0,
|
||||
&mapped),
|
||||
VK_SUCCESS);
|
||||
|
||||
g_create_buffer_calls = 0;
|
||||
g_allocate_memory_calls = 0;
|
||||
g_destroy_buffer_calls = 0;
|
||||
g_free_memory_calls = 0;
|
||||
VkBufferImageCopy region{};
|
||||
region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
|
||||
region.imageSubresource.layerCount = 1;
|
||||
region.imageExtent = {4, 4, 1};
|
||||
for (std::uintptr_t frame = 0; frame < 8; ++frame) {
|
||||
const VkCommandBuffer command_buffer =
|
||||
FakeHandle<VkCommandBuffer>(0x780 + frame);
|
||||
emulation.CmdCopyBufferToImage(device, command_buffer, source, image,
|
||||
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1,
|
||||
®ion);
|
||||
emulation.PrepareSubmit(&command_buffer, 1);
|
||||
emulation.ReleaseCommandBuffer(command_buffer);
|
||||
}
|
||||
EXPECT_EQ(g_create_buffer_calls, 1u);
|
||||
EXPECT_EQ(g_allocate_memory_calls, 1u);
|
||||
EXPECT_EQ(g_destroy_buffer_calls, 0u);
|
||||
|
||||
emulation.DestroyDevice(device);
|
||||
EXPECT_EQ(g_destroy_buffer_calls, 1u);
|
||||
EXPECT_EQ(g_free_memory_calls, 1u);
|
||||
}
|
||||
|
||||
// Copies out of a scaled image cover its host bounds and shrink back onto
|
||||
// an unscaled destination, which a plain copy cannot do.
|
||||
TEST(VulkanEtc2EmulationTest, BlitsCopiesThatTouchScaledImages) {
|
||||
@@ -591,5 +797,288 @@ TEST(VulkanEtc2EmulationTest, BlitsCopiesThatTouchScaledImages) {
|
||||
EXPECT_EQ(g_copy_image_calls, 1u);
|
||||
}
|
||||
|
||||
// bufferRowLength counts texels of source pitch, not of the copied region, so
|
||||
// a padded row holds blocks the region does not cover.
|
||||
TEST(VulkanEtc2EmulationTest, ReadsCompressedRowsAtTheRequestedStride) {
|
||||
const VkDevice device = FakeHandle<VkDevice>(0x18);
|
||||
const VkBuffer source = FakeHandle<VkBuffer>(0x208);
|
||||
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
|
||||
const VkCommandBuffer command_buffer = FakeHandle<VkCommandBuffer>(0x708);
|
||||
g_source.fill(0);
|
||||
g_staging.fill(0xAA);
|
||||
ASSERT_EQ(setenv("MOCKTAIL_SMALL_TEXTURE_UPSCALE", "1", 1), 0);
|
||||
|
||||
VkPhysicalDeviceMemoryProperties memory{};
|
||||
memory.memoryTypeCount = 1;
|
||||
memory.memoryTypes[0].propertyFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
|
||||
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
|
||||
VulkanEtc2Emulation emulation;
|
||||
ASSERT_EQ(unsetenv("MOCKTAIL_SMALL_TEXTURE_UPSCALE"), 0);
|
||||
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x28), true,
|
||||
memory, FakeGetDeviceProcAddr);
|
||||
|
||||
VkImageCreateInfo image_info{};
|
||||
image_info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
|
||||
image_info.format = VK_FORMAT_ETC2_R8G8B8_UNORM_BLOCK;
|
||||
image_info.imageType = VK_IMAGE_TYPE_2D;
|
||||
image_info.extent = {8, 8, 1};
|
||||
image_info.mipLevels = 1;
|
||||
image_info.arrayLayers = 1;
|
||||
VkImage image = VK_NULL_HANDLE;
|
||||
ASSERT_EQ(emulation.CreateImage(device, &image_info, nullptr, &image),
|
||||
VK_SUCCESS);
|
||||
ASSERT_EQ(g_created_extent.width, 8u);
|
||||
ASSERT_EQ(emulation.BindBufferMemory(device, source, source_memory, 0),
|
||||
VK_SUCCESS);
|
||||
void* mapped = nullptr;
|
||||
ASSERT_EQ(emulation.MapMemory(device, source_memory, 0, g_source.size(), 0,
|
||||
&mapped),
|
||||
VK_SUCCESS);
|
||||
|
||||
// Differential block: every texel decodes to 134.
|
||||
const std::array<std::uint8_t, 8> covered = {0x81, 0x81, 0x81, 0x02,
|
||||
0, 0, 0, 0};
|
||||
// Individual block: every texel decodes to 138.
|
||||
const std::array<std::uint8_t, 8> padding = {0x88, 0x88, 0x88, 0x00,
|
||||
0, 0, 0, 0};
|
||||
// 16 texels of pitch is 4 blocks per row; the 8x8 region covers the first 2.
|
||||
const std::array<const std::uint8_t*, 6> layout = {
|
||||
covered.data(), covered.data(), padding.data(),
|
||||
padding.data(), covered.data(), covered.data()};
|
||||
for (std::size_t block = 0; block < layout.size(); ++block) {
|
||||
std::memcpy(g_source.data() + block * 8, layout[block], 8);
|
||||
}
|
||||
|
||||
VkBufferImageCopy region{};
|
||||
region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
|
||||
region.imageSubresource.layerCount = 1;
|
||||
region.imageExtent = {8, 8, 1};
|
||||
region.bufferRowLength = 16;
|
||||
emulation.CmdCopyBufferToImage(device, command_buffer, source, image,
|
||||
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1,
|
||||
®ion);
|
||||
emulation.PrepareSubmit(&command_buffer, 1);
|
||||
|
||||
std::array<std::uint8_t, 32> packed{};
|
||||
for (std::size_t block = 0; block < 4; ++block) {
|
||||
std::memcpy(packed.data() + block * 8, covered.data(), covered.size());
|
||||
}
|
||||
std::array<std::uint8_t, 8 * 8 * 4> expected{};
|
||||
ASSERT_TRUE(DecodeEtcImage(EtcFormat::kEtc2Rgb8, packed.data(), packed.size(),
|
||||
8, 8, expected.data(), expected.size()));
|
||||
for (std::size_t index = 0; index < expected.size(); ++index) {
|
||||
ASSERT_EQ(g_staging[index], expected[index]) << "byte " << index;
|
||||
}
|
||||
emulation.ReleaseCommandBuffer(command_buffer);
|
||||
}
|
||||
|
||||
// Records one 4x4 ETC2 upload whose every texel decodes to 134, and returns
|
||||
// the image it targets.
|
||||
VkImage RecordDecodeOf134(VulkanEtc2Emulation* emulation, VkDevice device,
|
||||
VkBuffer source, VkDeviceMemory source_memory,
|
||||
VkCommandBuffer command_buffer) {
|
||||
VkImageCreateInfo image_info{};
|
||||
image_info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
|
||||
image_info.format = VK_FORMAT_ETC2_R8G8B8_UNORM_BLOCK;
|
||||
image_info.extent = {4, 4, 1};
|
||||
VkImage image = VK_NULL_HANDLE;
|
||||
EXPECT_EQ(emulation->CreateImage(device, &image_info, nullptr, &image),
|
||||
VK_SUCCESS);
|
||||
EXPECT_EQ(emulation->BindBufferMemory(device, source, source_memory, 0),
|
||||
VK_SUCCESS);
|
||||
void* mapped = nullptr;
|
||||
EXPECT_EQ(emulation->MapMemory(device, source_memory, 0, g_source.size(), 0,
|
||||
&mapped),
|
||||
VK_SUCCESS);
|
||||
|
||||
const std::array<std::uint8_t, 8> block = {0x81, 0x81, 0x81, 0x02,
|
||||
0, 0, 0, 0};
|
||||
std::memcpy(g_source.data(), block.data(), block.size());
|
||||
|
||||
VkBufferImageCopy region{};
|
||||
region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
|
||||
region.imageSubresource.layerCount = 1;
|
||||
region.imageExtent = {4, 4, 1};
|
||||
emulation->CmdCopyBufferToImage(device, command_buffer, source, image,
|
||||
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1,
|
||||
®ion);
|
||||
return image;
|
||||
}
|
||||
|
||||
VkPhysicalDeviceMemoryProperties HostVisibleMemory() {
|
||||
VkPhysicalDeviceMemoryProperties memory{};
|
||||
memory.memoryTypeCount = 1;
|
||||
memory.memoryTypes[0].propertyFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
|
||||
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
|
||||
return memory;
|
||||
}
|
||||
|
||||
TEST(VulkanEtc2EmulationTest, DecodesOffTheCallingThreadWithATimeline) {
|
||||
const VkDevice device = FakeHandle<VkDevice>(0x18);
|
||||
const VkBuffer source = FakeHandle<VkBuffer>(0x208);
|
||||
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
|
||||
const VkCommandBuffer command_buffer = FakeHandle<VkCommandBuffer>(0x708);
|
||||
g_source.fill(0);
|
||||
g_staging.fill(0xAA);
|
||||
ResetSignalledValues();
|
||||
|
||||
VulkanEtc2Emulation emulation;
|
||||
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x28), true,
|
||||
HostVisibleMemory(), FakeGetDeviceProcAddr, true);
|
||||
RecordDecodeOf134(&emulation, device, source, source_memory, command_buffer);
|
||||
|
||||
const Etc2SubmitWait wait =
|
||||
emulation.PrepareSubmit(&command_buffer, 1, true);
|
||||
EXPECT_EQ(wait.semaphore, FakeHandle<VkSemaphore>(kTimelineSemaphore));
|
||||
EXPECT_GT(wait.value, 0U);
|
||||
|
||||
// Releasing the command buffer waits for the decode that writes its staging.
|
||||
emulation.ReleaseCommandBuffer(command_buffer);
|
||||
EXPECT_EQ(g_staging[0], 134);
|
||||
EXPECT_EQ(SignalledValues(), std::vector<std::uint64_t>{wait.value});
|
||||
}
|
||||
|
||||
TEST(VulkanEtc2EmulationTest, DecodesOnTheCallerWithoutATimeline) {
|
||||
const VkDevice device = FakeHandle<VkDevice>(0x19);
|
||||
const VkBuffer source = FakeHandle<VkBuffer>(0x209);
|
||||
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
|
||||
const VkCommandBuffer command_buffer = FakeHandle<VkCommandBuffer>(0x709);
|
||||
g_source.fill(0);
|
||||
g_staging.fill(0xAA);
|
||||
ResetSignalledValues();
|
||||
|
||||
VulkanEtc2Emulation emulation;
|
||||
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x29), true,
|
||||
HostVisibleMemory(), FakeGetDeviceProcAddr, false);
|
||||
RecordDecodeOf134(&emulation, device, source, source_memory, command_buffer);
|
||||
|
||||
const Etc2SubmitWait wait =
|
||||
emulation.PrepareSubmit(&command_buffer, 1, true);
|
||||
EXPECT_EQ(wait.semaphore, VK_NULL_HANDLE);
|
||||
EXPECT_EQ(wait.value, 0U);
|
||||
// Decoded before the call returned, so no wait is owed.
|
||||
EXPECT_EQ(g_staging[0], 134);
|
||||
EXPECT_TRUE(SignalledValues().empty());
|
||||
emulation.ReleaseCommandBuffer(command_buffer);
|
||||
}
|
||||
|
||||
TEST(VulkanEtc2EmulationTest, UnmapWaitsForDecodesReadingThatMemory) {
|
||||
const VkDevice device = FakeHandle<VkDevice>(0x1C);
|
||||
const VkBuffer source = FakeHandle<VkBuffer>(0x20C);
|
||||
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
|
||||
const VkCommandBuffer command_buffer = FakeHandle<VkCommandBuffer>(0x70D);
|
||||
g_source.fill(0);
|
||||
g_staging.fill(0xAA);
|
||||
ResetSignalledValues();
|
||||
|
||||
VulkanEtc2Emulation emulation;
|
||||
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x2C), true,
|
||||
HostVisibleMemory(), FakeGetDeviceProcAddr, true);
|
||||
RecordDecodeOf134(&emulation, device, source, source_memory, command_buffer);
|
||||
|
||||
const Etc2SubmitWait wait =
|
||||
emulation.PrepareSubmit(&command_buffer, 1, true);
|
||||
ASSERT_NE(wait.semaphore, VK_NULL_HANDLE);
|
||||
|
||||
// A deferred gather reads the application's mapping on a worker, so the
|
||||
// unmap must not return while a decode can still dereference it.
|
||||
emulation.UnmapMemory(device, source_memory);
|
||||
const std::vector<std::uint64_t> signalled = SignalledValues();
|
||||
ASSERT_FALSE(signalled.empty()) << "unmap returned with a decode in flight";
|
||||
EXPECT_GE(signalled.back(), wait.value);
|
||||
EXPECT_EQ(g_staging[0], 134);
|
||||
emulation.ReleaseCommandBuffer(command_buffer);
|
||||
}
|
||||
|
||||
TEST(VulkanEtc2EmulationTest, FreeMemoryWaitsForDecodesReadingThatMemory) {
|
||||
const VkDevice device = FakeHandle<VkDevice>(0x1D);
|
||||
const VkBuffer source = FakeHandle<VkBuffer>(0x20D);
|
||||
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
|
||||
const VkCommandBuffer command_buffer = FakeHandle<VkCommandBuffer>(0x70E);
|
||||
g_source.fill(0);
|
||||
g_staging.fill(0xAA);
|
||||
ResetSignalledValues();
|
||||
|
||||
VulkanEtc2Emulation emulation;
|
||||
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x2D), true,
|
||||
HostVisibleMemory(), FakeGetDeviceProcAddr, true);
|
||||
RecordDecodeOf134(&emulation, device, source, source_memory, command_buffer);
|
||||
|
||||
const Etc2SubmitWait wait =
|
||||
emulation.PrepareSubmit(&command_buffer, 1, true);
|
||||
ASSERT_NE(wait.semaphore, VK_NULL_HANDLE);
|
||||
|
||||
emulation.FreeMemory(device, source_memory, nullptr);
|
||||
const std::vector<std::uint64_t> signalled = SignalledValues();
|
||||
ASSERT_FALSE(signalled.empty()) << "free returned with a decode in flight";
|
||||
EXPECT_GE(signalled.back(), wait.value);
|
||||
EXPECT_EQ(g_staging[0], 134);
|
||||
emulation.ReleaseCommandBuffer(command_buffer);
|
||||
}
|
||||
|
||||
TEST(VulkanEtc2EmulationTest, GuardsStagedCopiesWithAHostWriteBarrier) {
|
||||
const VkDevice device = FakeHandle<VkDevice>(0x1B);
|
||||
const VkBuffer source = FakeHandle<VkBuffer>(0x20B);
|
||||
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
|
||||
const VkCommandBuffer command_buffer = FakeHandle<VkCommandBuffer>(0x70C);
|
||||
g_source.fill(0);
|
||||
g_staging.fill(0xAA);
|
||||
g_pending_barriers = 0;
|
||||
g_barriers_before_copy = 0;
|
||||
|
||||
VulkanEtc2Emulation emulation;
|
||||
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x2B), true,
|
||||
HostVisibleMemory(), FakeGetDeviceProcAddr, true);
|
||||
RecordDecodeOf134(&emulation, device, source, source_memory, command_buffer);
|
||||
|
||||
// An asynchronous decode writes the staging after the submit that reads it,
|
||||
// so the queue submission's host-to-device domain operation does not cover
|
||||
// those writes; the copy must be preceded by an explicit barrier.
|
||||
EXPECT_EQ(g_barriers_before_copy, 1U);
|
||||
EXPECT_EQ(g_barrier_src_stage,
|
||||
static_cast<VkPipelineStageFlags>(VK_PIPELINE_STAGE_HOST_BIT));
|
||||
EXPECT_NE(g_barrier_dst_stage &
|
||||
static_cast<VkPipelineStageFlags>(VK_PIPELINE_STAGE_TRANSFER_BIT),
|
||||
0U);
|
||||
EXPECT_EQ(g_barrier_src_access,
|
||||
static_cast<VkAccessFlags>(VK_ACCESS_HOST_WRITE_BIT));
|
||||
EXPECT_NE(g_barrier_dst_access &
|
||||
static_cast<VkAccessFlags>(VK_ACCESS_TRANSFER_READ_BIT),
|
||||
0U);
|
||||
emulation.PrepareSubmit(&command_buffer, 1, true);
|
||||
emulation.ReleaseCommandBuffer(command_buffer);
|
||||
}
|
||||
|
||||
TEST(VulkanEtc2EmulationTest, SignalsDecodeValuesInHandOutOrder) {
|
||||
const VkDevice device = FakeHandle<VkDevice>(0x1A);
|
||||
const VkBuffer source = FakeHandle<VkBuffer>(0x20A);
|
||||
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
|
||||
const VkCommandBuffer first = FakeHandle<VkCommandBuffer>(0x70A);
|
||||
const VkCommandBuffer second = FakeHandle<VkCommandBuffer>(0x70B);
|
||||
g_source.fill(0);
|
||||
g_staging.fill(0xAA);
|
||||
ResetSignalledValues();
|
||||
|
||||
VulkanEtc2Emulation emulation;
|
||||
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x2A), true,
|
||||
HostVisibleMemory(), FakeGetDeviceProcAddr, true);
|
||||
RecordDecodeOf134(&emulation, device, source, source_memory, first);
|
||||
RecordDecodeOf134(&emulation, device, source, source_memory, second);
|
||||
|
||||
const Etc2SubmitWait one = emulation.PrepareSubmit(&first, 1, true);
|
||||
const Etc2SubmitWait two = emulation.PrepareSubmit(&second, 1, true);
|
||||
EXPECT_GT(two.value, one.value);
|
||||
|
||||
emulation.ReleaseCommandBuffer(first);
|
||||
emulation.ReleaseCommandBuffer(second);
|
||||
|
||||
const std::vector<std::uint64_t> signalled = SignalledValues();
|
||||
ASSERT_FALSE(signalled.empty());
|
||||
EXPECT_EQ(signalled.back(), two.value);
|
||||
for (std::size_t index = 1; index < signalled.size(); ++index) {
|
||||
EXPECT_GT(signalled[index], signalled[index - 1]) << "at " << index;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mocktail::graphics
|
||||
@@ -135,11 +135,44 @@ TEST(WindowPointerCaptureOwnerTest, QueryCanClearItselfWithoutDeadlock) {
|
||||
EXPECT_TRUE(owner.RegisterQuery(Query, &replacement));
|
||||
}
|
||||
|
||||
TEST(WindowPointerCaptureOwnerTest, RightDragDoesNotCaptureOutsideAnExperience) {
|
||||
// The app surface has no camera. Capturing there would freeze the cursor
|
||||
// over Roblox's own menu UI.
|
||||
FakeBackend backend;
|
||||
QueryState query{true, false};
|
||||
WindowPointerCaptureOwner owner(&backend);
|
||||
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
|
||||
ASSERT_TRUE(owner.Pump(false));
|
||||
|
||||
EXPECT_TRUE(owner.OnRightButton(true, false));
|
||||
EXPECT_FALSE(owner.captured());
|
||||
EXPECT_TRUE(owner.OnRightButton(false, false));
|
||||
EXPECT_FALSE(owner.captured());
|
||||
}
|
||||
|
||||
TEST(WindowPointerCaptureOwnerTest, RightDragCapturesOnceTheGameSurfaceIsLive) {
|
||||
FakeBackend backend;
|
||||
QueryState query{true, false};
|
||||
WindowPointerCaptureOwner owner(&backend);
|
||||
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
|
||||
ASSERT_TRUE(owner.Pump(false));
|
||||
|
||||
owner.SetGameSessionActive(true);
|
||||
EXPECT_TRUE(owner.OnRightButton(true, false));
|
||||
EXPECT_TRUE(owner.captured());
|
||||
|
||||
// Leaving the experience releases a capture that is still held.
|
||||
owner.SetGameSessionActive(false);
|
||||
EXPECT_TRUE(owner.Pump(false));
|
||||
EXPECT_FALSE(owner.captured());
|
||||
}
|
||||
|
||||
TEST(WindowPointerCaptureOwnerTest, RightDragCapturesUntilButtonRelease) {
|
||||
FakeBackend backend;
|
||||
QueryState query{true, false};
|
||||
WindowPointerCaptureOwner owner(&backend);
|
||||
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
|
||||
owner.SetGameSessionActive(true);
|
||||
ASSERT_TRUE(owner.Pump(false));
|
||||
|
||||
EXPECT_TRUE(owner.OnRightButton(true, false));
|
||||
@@ -230,6 +263,7 @@ TEST(WindowPointerCaptureOwnerTest, RightDragDoesNotDependOnNativeLockQuery) {
|
||||
QueryState query{false, false};
|
||||
WindowPointerCaptureOwner owner(&backend);
|
||||
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
|
||||
owner.SetGameSessionActive(true);
|
||||
|
||||
EXPECT_TRUE(owner.OnRightButton(true, false));
|
||||
EXPECT_TRUE(owner.captured());
|
||||
@@ -244,6 +278,7 @@ TEST(WindowPointerCaptureOwnerTest, TextAndFocusCancelRightDragCapture) {
|
||||
QueryState query{true, false};
|
||||
WindowPointerCaptureOwner owner(&backend);
|
||||
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
|
||||
owner.SetGameSessionActive(true);
|
||||
|
||||
EXPECT_TRUE(owner.OnRightButton(true, true));
|
||||
EXPECT_FALSE(owner.captured());
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 268 B |
@@ -0,0 +1,42 @@
|
||||
# Texture overrides
|
||||
|
||||
Optional PNGs you can drop into your own override directory. Nothing here is
|
||||
installed or loaded automatically.
|
||||
|
||||
## Using one
|
||||
|
||||
Copy the file into the override directory, keeping its name:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/mocktail/textures
|
||||
cp textures/75dcd1166a6cbaa9.png ~/.config/mocktail/textures/
|
||||
```
|
||||
|
||||
Restart the game. The log line `[vulkan] texture override loaded: <path>`
|
||||
confirms it was picked up.
|
||||
|
||||
To remove it, delete the file again.
|
||||
|
||||
`MOCKTAIL_TEXTURE_OVERRIDE_DIR` points the lookup somewhere else.
|
||||
|
||||
## What is here
|
||||
|
||||
| File | Replaces |
|
||||
| --- | --- |
|
||||
| `75dcd1166a6cbaa9.png` | The in-game crosshair, as a thin dot-and-ticks reticle (128x128). |
|
||||
|
||||
## Naming
|
||||
|
||||
The file name is the 16 hex digit FNV-1a hash of the texture's level-0
|
||||
compressed ETC2 bytes, which is what the override lookup keys on. A texture
|
||||
Roblox re-encodes gets a new hash, so an override can stop matching after a
|
||||
client update.
|
||||
|
||||
To find the hash of a texture you want to replace, set
|
||||
`MOCKTAIL_TEXTURE_DUMP_DIR` to an empty directory and play until the texture
|
||||
appears. Every uploaded texture is written there once as
|
||||
`<hash>_<width>x<height>.png`. Rename the one you want to `<hash>.png` and put
|
||||
it in the override directory.
|
||||
|
||||
Overrides are resampled to the size the game asked for, so the replacement does
|
||||
not have to match the original's dimensions.
|
||||
Reference in new issue
Block a user