mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-18 02:00:48 +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 | ||
|
|
40698efb7d | ||
|
|
3d2a2b3d98 | ||
|
|
78a314b761 | ||
|
|
2d1dd3788b | ||
|
|
56d3160641 | ||
|
|
5db99041bc | ||
|
|
9ec56338c3 | ||
|
|
894ebf8eb7 | ||
|
|
6bec8a6c5c | ||
|
|
b9e7bc761b | ||
|
|
b54c896498 | ||
|
|
1cd7b7db8f | ||
|
|
f87aaca2fb | ||
|
|
901ba615ab | ||
|
|
cdbf9ac1eb | ||
|
|
f098bc1b5d | ||
|
|
e1f7c8c06c | ||
|
|
f383fbbff4 |
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
|
||||
@@ -64,3 +64,4 @@ jobs:
|
||||
bash tests/managed_payload_build_contract_test.sh .
|
||||
bash tests/flatpak_packaging_test.sh .
|
||||
bash tests/native_packaging_test.sh .
|
||||
bash tests/anylinux_dependencies_test.sh .
|
||||
@@ -160,6 +160,9 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Download Flatpak
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
@@ -174,13 +177,16 @@ jobs:
|
||||
set -Eeuo pipefail
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
(cd release && sha256sum Nightcap-x86_64.flatpak > Nightcap-x86_64.flatpak.sha256)
|
||||
./scripts/release_notes.sh "$tag" > release/notes.md
|
||||
# The Release workflow creates the same release; whichever job
|
||||
# arrives second sees "already exists" and continues.
|
||||
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--title "Nightcap $tag" --generate-notes ||
|
||||
--title "Nightcap $tag" --notes-file release/notes.md ||
|
||||
gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null
|
||||
fi
|
||||
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.flatpak \
|
||||
release/Nightcap-x86_64.flatpak.sha256
|
||||
+58
-220
@@ -1,39 +1,47 @@
|
||||
name: Native packages
|
||||
name: Nightly AppImage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.gitignore'
|
||||
- 'README.md'
|
||||
- 'assets/screenshots/**'
|
||||
- 'docs/**'
|
||||
- 'packaging/aur/**'
|
||||
- 'scripts/build_flatpak.sh'
|
||||
- 'tests/**'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.gitignore'
|
||||
- 'README.md'
|
||||
- 'assets/screenshots/**'
|
||||
- 'docs/**'
|
||||
- 'packaging/aur/**'
|
||||
- 'scripts/build_flatpak.sh'
|
||||
- 'tests/**'
|
||||
schedule:
|
||||
# 05:00 UTC daily. A run whose commit already carries the continuous tag
|
||||
# exits without rebuilding, so a quiet day costs one probe job.
|
||||
- cron: '0 5 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: packages-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: nightly-appimage
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
probe:
|
||||
name: Check for new commits
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
build: ${{ steps.decide.outputs.build }}
|
||||
steps:
|
||||
- name: Compare HEAD against the published continuous tag
|
||||
id: decide
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
published="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/continuous" \
|
||||
--jq .object.sha 2>/dev/null || true)"
|
||||
if [[ "$GITHUB_EVENT_NAME" == "schedule" && "$published" == "$GITHUB_SHA" ]]; then
|
||||
echo 'build=false' >>"$GITHUB_OUTPUT"
|
||||
echo "continuous already points at $GITHUB_SHA; nothing to build"
|
||||
else
|
||||
echo 'build=true' >>"$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
appimage:
|
||||
name: AppImage (AnyLinux)
|
||||
needs: probe
|
||||
if: needs.probe.outputs.build == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
@@ -45,10 +53,13 @@ jobs:
|
||||
|
||||
- name: Build AppImage
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
--env GH_TOKEN \
|
||||
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
|
||||
pacman -Syu --noconfirm --needed \
|
||||
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
|
||||
@@ -135,155 +146,17 @@ jobs:
|
||||
- name: Upload AppImage
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Mocktail-AppImage
|
||||
name: Nightcap-AppImage
|
||||
path: dist/Mocktail-x86_64.AppImage
|
||||
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: |
|
||||
nightly_suffix=".nightly.$(git show -s --format=%cd --date=format:%Y%m%d%H%M%S "$GITHUB_SHA").g${GITHUB_SHA:0:8}"
|
||||
docker run --rm \
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
--env MOCKTAIL_PACKAGE_VERSION_SUFFIX="$nightly_suffix" \
|
||||
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
|
||||
cmake -S . -B build-deb -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DMOCKTAIL_PACKAGE_NAME=mocktail-nightly \
|
||||
-DMOCKTAIL_PACKAGE_VERSION_SUFFIX="$MOCKTAIL_PACKAGE_VERSION_SUFFIX" \
|
||||
-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: Mocktail-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: |
|
||||
nightly_suffix=".nightly.$(git show -s --format=%cd --date=format:%Y%m%d%H%M%S "$GITHUB_SHA").g${GITHUB_SHA:0:8}"
|
||||
docker run --rm \
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
--env MOCKTAIL_PACKAGE_VERSION_SUFFIX="$nightly_suffix" \
|
||||
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
|
||||
cmake -S . -B build-rpm -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DCMAKE_INSTALL_LIBDIR=lib \
|
||||
-DMOCKTAIL_PACKAGE_NAME=mocktail-nightly \
|
||||
-DMOCKTAIL_PACKAGE_VERSION_SUFFIX="$MOCKTAIL_PACKAGE_VERSION_SUFFIX" \
|
||||
-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: Mocktail-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: |
|
||||
docker run --rm \
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
archlinux:base-devel 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 \
|
||||
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: Mocktail-Arch
|
||||
path: dist/arch/*.pkg.tar.zst
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
publish:
|
||||
name: Publish continuous packages
|
||||
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
needs: [appimage, deb, rpm, arch]
|
||||
name: Publish continuous release
|
||||
needs: [probe, appimage]
|
||||
# A manual run from another branch still builds and smoke-tests, but the
|
||||
# continuous tag and release only ever follow main.
|
||||
if: needs.probe.outputs.build == 'true' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
@@ -294,84 +167,49 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download packages
|
||||
- name: Download AppImage
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: Mocktail-*
|
||||
name: Nightcap-AppImage
|
||||
path: release
|
||||
merge-multiple: true
|
||||
|
||||
- name: Prepare continuous packages
|
||||
- name: Name and checksum the nightly build
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
mv -- release/*.AppImage release/mocktail-nightly.AppImage
|
||||
mv -- release/*.deb release/mocktail-nightly.deb
|
||||
mv -- release/*.rpm release/mocktail-nightly.rpm
|
||||
mv -- release/*.pkg.tar.zst release/mocktail-nightly.pkg.tar.zst
|
||||
# The Makefile builds under upstream's artifact name; the published
|
||||
# asset carries this fork's.
|
||||
mv -- release/Mocktail-x86_64.AppImage release/Nightcap-nightly-x86_64.AppImage
|
||||
(cd release && sha256sum Nightcap-nightly-x86_64.AppImage \
|
||||
> Nightcap-nightly-x86_64.AppImage.sha256)
|
||||
find release -maxdepth 1 -type f -print
|
||||
|
||||
- name: Build package repositories
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
|
||||
FLATPAK_GPG_KEY_ID: ${{ secrets.FLATPAK_GPG_KEY_ID }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
[[ -n "$FLATPAK_GPG_PRIVATE_KEY" && -n "$FLATPAK_GPG_KEY_ID" ]]
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes --no-install-recommends \
|
||||
apt-utils createrepo-c gnupg rpm
|
||||
install -d stable/deb stable/rpm
|
||||
stable_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)"
|
||||
gh release download "$stable_tag" --pattern '*.deb' --dir stable/deb
|
||||
gh release download "$stable_tag" --pattern '*.rpm' --dir stable/rpm
|
||||
mapfile -t stable_debs < <(find stable/deb -maxdepth 1 -type f -name '*.deb' -print)
|
||||
mapfile -t stable_rpms < <(find stable/rpm -maxdepth 1 -type f -name '*.rpm' -print)
|
||||
[[ "${#stable_debs[@]}" -eq 1 && "${#stable_rpms[@]}" -eq 1 ]]
|
||||
install -d -m 0700 "$RUNNER_TEMP/mocktail-native-gnupg"
|
||||
printf '%s' "$FLATPAK_GPG_PRIVATE_KEY" | \
|
||||
gpg --batch --homedir "$RUNNER_TEMP/mocktail-native-gnupg" --import
|
||||
gpg --batch --homedir "$RUNNER_TEMP/mocktail-native-gnupg" \
|
||||
--export "$FLATPAK_GPG_KEY_ID" >mocktail-packages.gpg
|
||||
./scripts/assemble_native_repositories.sh \
|
||||
"${stable_debs[0]}" release/mocktail-nightly.deb \
|
||||
"${stable_rpms[0]}" release/mocktail-nightly.rpm \
|
||||
mocktail-packages.gpg "$FLATPAK_GPG_KEY_ID" \
|
||||
"$RUNNER_TEMP/mocktail-native-gnupg" native-repositories
|
||||
|
||||
- name: Update continuous release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
git config user.name komaruworld
|
||||
git config user.email rtysus228@gmail.com
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git tag --force continuous "$GITHUB_SHA"
|
||||
git push origin refs/tags/continuous --force
|
||||
notes="Automated build of main at ${GITHUB_SHA:0:8}. Untested and unreleased; use a tagged release for normal play."
|
||||
if ! gh release view continuous >/dev/null 2>&1; then
|
||||
gh release create continuous \
|
||||
--prerelease \
|
||||
--title "Mocktail continuous" \
|
||||
--notes "Automated development packages from main."
|
||||
--title "Nightcap continuous" \
|
||||
--notes "$notes"
|
||||
fi
|
||||
gh release edit continuous --title "Nightcap continuous" --notes "$notes"
|
||||
gh release upload continuous release/* --clobber
|
||||
# Drop assets left by earlier runs under other names.
|
||||
while IFS= read -r asset; do
|
||||
case "$asset" in
|
||||
mocktail-nightly.AppImage|mocktail-nightly.deb|mocktail-nightly.rpm|mocktail-nightly.pkg.tar.zst)
|
||||
Nightcap-nightly-x86_64.AppImage|Nightcap-nightly-x86_64.AppImage.sha256)
|
||||
;;
|
||||
Mocktail-*.AppImage|mocktail_*.deb|mocktail-*.deb|mocktail-*.rpm|mocktail-*.pkg.tar.zst)
|
||||
*)
|
||||
gh release delete-asset continuous "$asset" --yes
|
||||
;;
|
||||
esac
|
||||
done < <(gh release view continuous --json assets --jq '.assets[].name')
|
||||
|
||||
- name: Upload package repositories
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Mocktail-native-repositories
|
||||
path: native-repositories
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -24,12 +24,20 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Check release notes
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
run: ./scripts/release_notes.sh "$GITHUB_REF_NAME" >/dev/null
|
||||
|
||||
- name: Build AppImage
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
--env GH_TOKEN \
|
||||
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
|
||||
pacman -Syu --noconfirm --needed \
|
||||
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
|
||||
@@ -118,15 +126,168 @@ 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:
|
||||
contents: write
|
||||
steps:
|
||||
- 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:
|
||||
@@ -141,14 +302,37 @@ 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.
|
||||
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--title "Nightcap $tag" --generate-notes ||
|
||||
--title "Nightcap $tag" --notes-file release/notes.md ||
|
||||
gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null
|
||||
fi
|
||||
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/
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Nightcap. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Release notes on
|
||||
GitHub are taken from the matching section here.
|
||||
|
||||
## [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
|
||||
|
||||
- FAQ covering Discord RPC, log locations, and VR, adapted from upstream.
|
||||
- `input.raw_mouse` in `config.yaml` sends mouse look deltas in the host's
|
||||
own units. Turn it on when first-person aiming feels too slow or too fast
|
||||
under fractional display scaling. Off by default.
|
||||
- Nightly AppImage builds, published as the `continuous` prerelease. They are
|
||||
untested; use a tagged release for normal play.
|
||||
|
||||
### Changed
|
||||
|
||||
- Values in `fflags.json` now win over the performance preset instead of
|
||||
stopping the launch. Startup prints each key it kept.
|
||||
- Discord Rich Presence uses Nightcap's own Discord application, so it shows
|
||||
as "Playing Nightcap". Terms and privacy pages for it live on the
|
||||
project site.
|
||||
- Discord Rich Presence is on by default. Set
|
||||
`integrations.discord_rpc.enabled: false` to turn it off.
|
||||
- The AppImage build verifies its reduced dependency packages against digests
|
||||
taken at build time, rather than a digest list kept in the repository.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mouse look keeps responding after a long turn. The tracked pointer position
|
||||
could drift past the edge of the view, which swallowed every reversal until
|
||||
the mouse travelled all the way back.
|
||||
- Nightcap no longer refuses to start with `cannot lock appStorage`. It
|
||||
created its own credential directory using the process umask, then rejected
|
||||
that directory for being group writable. Systems with a permissive umask,
|
||||
such as `002`, failed on first run.
|
||||
|
||||
## [0.1.0] - 2026-09-15
|
||||
|
||||
First Nightcap release, forked from komaruworld/mocktail.
|
||||
|
||||
### Added
|
||||
|
||||
- ETC2 and EAC texture emulation on Vulkan, so games that use compressed
|
||||
mobile textures render correctly on desktop GPUs. Decoding runs on worker
|
||||
threads to avoid stutter.
|
||||
- PNG texture overrides in `~/.config/mocktail/textures`, keyed by upload
|
||||
hash, with a dump mode to find the texture you want to replace.
|
||||
- Small textures are upscaled 4x by default. Set
|
||||
`MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn it off.
|
||||
- AppImage and signed Flatpak builds on every release tag. The Flatpak repo
|
||||
is published at https://coderdayton.github.io/nightcap/.
|
||||
- Roadmap in `docs/ROADMAP.md`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Frames are presented as soon as they are ready instead of waiting in a
|
||||
queue, which lowers input latency.
|
||||
- Roblox now sees the real host RAM and display size instead of a low-end
|
||||
phone profile.
|
||||
- App ID is `io.github.CoderDayton.nightcap`, so Nightcap installs next to
|
||||
upstream Mocktail. The binary and config paths keep the `mocktail` name.
|
||||
- New name, icon, and colors.
|
||||
- The desktop entry launches on Wayland and points at the installed binary.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Servers no longer kick with error 319 shortly after joining.
|
||||
- The FMOD output-device bridge is optional, so the updater no longer fails
|
||||
without it.
|
||||
- The AppImage build no longer fails on stale checksums for the slimmed
|
||||
Arch packages.
|
||||
|
||||
[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
|
||||
+24
-18
@@ -61,7 +61,7 @@ set(MOCKTAIL_DEFAULT_SIGNING_TRUST_MANIFEST
|
||||
CACHE FILEPATH
|
||||
"Fallback signing trust manifest used outside an installed runtime"
|
||||
)
|
||||
set(MOCKTAIL_DISCORD_APPLICATION_ID "1537088975720812655" CACHE STRING
|
||||
set(MOCKTAIL_DISCORD_APPLICATION_ID "1549428231927500843" CACHE STRING
|
||||
"Discord application ID baked into Mocktail Rich Presence releases"
|
||||
)
|
||||
string(LENGTH "${MOCKTAIL_DISCORD_APPLICATION_ID}"
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<!-- Modified by vii from komaruworld/mocktail. See README "About this fork". -->
|
||||
# FAQ
|
||||
|
||||
## How do I turn Discord RPC off?
|
||||
|
||||
RPC is on by default. It never signs in to Discord and never reads an
|
||||
account token. To turn it off, launch Nightcap once to create the config,
|
||||
then close it. Open `config.yaml`:
|
||||
|
||||
- Native / AppImage: `~/.config/mocktail/config.yaml`
|
||||
- Flatpak: `~/.var/app/io.github.CoderDayton.nightcap/config/mocktail/config.yaml`
|
||||
|
||||
For native installs, a custom `$XDG_CONFIG_HOME` replaces `~/.config`.
|
||||
|
||||
Set `enabled` to `false` under `integrations.discord_rpc`. If the block is
|
||||
missing, add it under the existing `integrations` section:
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
discord_rpc:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
Save the file and restart Nightcap. Set `enabled` back to `true` to turn
|
||||
RPC on again.
|
||||
|
||||
## Can Discord count Nightcap as Roblox play time or quests?
|
||||
|
||||
No. Discord only credits play time and quests to games its own scanner
|
||||
detects, and Rich Presence never counts. On Linux the scanner only matches
|
||||
games with a Linux entry in Discord's list. Roblox has none, so no process
|
||||
name Nightcap could use would be detected. Only Roblox or Discord can change
|
||||
that.
|
||||
|
||||
## Where can I find logs for a bug report?
|
||||
|
||||
Nightcap saves logs automatically. Reproduce the issue, close Nightcap, and
|
||||
attach `latest.log`:
|
||||
|
||||
- Native / AppImage: `~/.local/state/mocktail/logs/latest.log`
|
||||
- Flatpak: `~/.var/app/io.github.CoderDayton.nightcap/.local/state/mocktail/logs/latest.log`
|
||||
|
||||
For native installs, a custom `$XDG_STATE_HOME` replaces `~/.local/state`.
|
||||
|
||||
## Can I play in VR?
|
||||
|
||||
VR is experimental and lives on upstream's `vr` branch, which does not
|
||||
include the Nightcap changes. Install the
|
||||
[build dependencies](README.md#building), then switch to that branch and
|
||||
build:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/komaruworld/mocktail.git
|
||||
git fetch upstream
|
||||
git switch -c vr upstream/vr
|
||||
git submodule update --init --recursive
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DMOCKTAIL_ENABLE_VR=ON
|
||||
cmake --build build -j4
|
||||
```
|
||||
|
||||
Start WiVRn or SteamVR/ALVR, connect your headset, then run:
|
||||
|
||||
```bash
|
||||
./build/mocktail -vr
|
||||
```
|
||||
@@ -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
|
||||
@@ -35,7 +36,10 @@ side by side.
|
||||
binary.
|
||||
|
||||
Everything else works the same as upstream. See the
|
||||
[roadmap](docs/ROADMAP.md) for what is coming next.
|
||||
[roadmap](docs/ROADMAP.md) for what is coming next, the
|
||||
[changelog](CHANGELOG.md) for what each release changed,
|
||||
[performance](docs/PERFORMANCE.md) for CPU load, heat and the knobs that change
|
||||
them, and the [FAQ](FAQ.md) for Discord RPC, logs, and VR.
|
||||
|
||||
## Install with Flatpak
|
||||
|
||||
@@ -57,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
|
||||
|
||||
@@ -112,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.
|
||||
|
||||
@@ -124,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
|
||||
@@ -136,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
|
||||
```
|
||||
@@ -148,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
|
||||
@@ -195,7 +215,6 @@ Support this fork by giving it a star or with cryptocurrency:
|
||||
|
||||
- LTC: `LdABR2ELRYrUESWZWrEk1uNx38gHHMuHUU`
|
||||
- SOL: `tjeDnPoWeyW8zfTf8c5CVAm5QTV8vFPaPouaScAdwfT`
|
||||
- BCH: `qpktw5jwut9ae8e6898qt3v7rj3km44utgk2r9mj8n`
|
||||
|
||||
Support the upstream project, which does most of the heavy lifting:
|
||||
|
||||
|
||||
@@ -77,6 +77,14 @@ audio:
|
||||
# between boots; prefer the exact device name when it is unique.
|
||||
input_device: default
|
||||
|
||||
input:
|
||||
# Boolean (default: false): send mouse look deltas in the host's own units.
|
||||
# They are otherwise scaled by the window's pixel density divided by the
|
||||
# desktop display scale, which cancels to 1.0 unless fractional scaling makes
|
||||
# the two disagree. Turn this on when first-person aiming feels too slow or
|
||||
# too fast under fractional scaling. The pointer position is unaffected.
|
||||
raw_mouse: false
|
||||
|
||||
integrations:
|
||||
fleasion:
|
||||
# Boolean (default: false): trust Fleasion's CA without editing Roblox files.
|
||||
@@ -89,9 +97,9 @@ integrations:
|
||||
# or ~/.config/Fleasion/proxy_ca/ca.crt. Never select ca.key.
|
||||
# ca_certificate: /home/user/.config/Fleasion/proxy_ca/ca.crt
|
||||
discord_rpc:
|
||||
# Boolean (default: false): publish Mocktail activity to Discord Desktop.
|
||||
# Boolean (default: true): publish Nightcap activity to Discord Desktop.
|
||||
# This never signs in to Discord and never reads an account token.
|
||||
enabled: false
|
||||
enabled: true
|
||||
# Boolean (default: true): show the current Roblox experience name.
|
||||
show_place_name: true
|
||||
# Boolean (default: true): show how long the current session has run.
|
||||
@@ -104,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).
|
||||
+42
-37
@@ -13,66 +13,71 @@ Goal: a small GTK4 settings window, reachable from the app menu and from a
|
||||
`--settings` flag, that reads and writes the same `config.yaml`.
|
||||
|
||||
- Graphics tab: backend, frame rate limit, vsync, small texture upscale.
|
||||
- Input tab: raw mouse and controller options (see item 2).
|
||||
- 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
|
||||
touch can be set without a text editor.
|
||||
|
||||
## 2. Controller and raw mouse input
|
||||
## 2. Controller button glyphs
|
||||
|
||||
Upstream added Xbox and PlayStation gamepad support through SDL. Desktop play
|
||||
still has rough edges.
|
||||
Upstream added Xbox and PlayStation gamepad support through SDL. Controllers
|
||||
already hot-plug and release their held inputs when unplugged. One rough edge
|
||||
is left.
|
||||
|
||||
- Raw mouse input in first-person games, with no acceleration or clamping.
|
||||
- Better controller support: hot-plug without a restart, correct button
|
||||
glyphs for the connected pad, and no stuck inputs after a disconnect.
|
||||
- Correct button glyphs for the connected pad. `SDL_GetGamepadType` is only
|
||||
read for Xbox and PlayStation pads, so a Switch Pro, a GameCube pad, or any
|
||||
generic pad SDL reports as `SDL_GAMEPAD_TYPE_STANDARD` reaches Roblox as
|
||||
type 0 and draws generic glyphs. `SDL_GetRealGamepadType`, and the vendor
|
||||
and product IDs, are available and unused.
|
||||
|
||||
Done when a mouse feels like a mouse and a plugged-in controller just works.
|
||||
Done when a plugged-in pad shows its own buttons.
|
||||
|
||||
## 3. Discord Rich Presence
|
||||
## 3. First-load stutter
|
||||
|
||||
Upstream ships Discord RPC, off by default, behind `discord_rpc.enabled`. It
|
||||
shows the experience name, elapsed time, 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.
|
||||
|
||||
- Turn it on by default. It never reads a Discord token, so the privacy cost
|
||||
is only the experience name, which the player already shares in Roblox.
|
||||
- Show the experience icon as the large image instead of a static logo.
|
||||
- Show the current server region and player count when Roblox exposes them.
|
||||
- Make the join button work for private servers the player has a link for.
|
||||
- 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 trace file readable by Perfetto.
|
||||
- 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
|
||||
|
||||
- Lower input latency: frames are presented as soon as they are ready.
|
||||
- Controllers hot-plug without a restart, and a pad that is unplugged mid-game
|
||||
releases its held buttons and sticks instead of leaving them stuck.
|
||||
- Mouse look keeps responding after a long turn, instead of going dead until
|
||||
the pointer travels back from past the screen edge.
|
||||
- Optional raw mouse input: `input.raw_mouse` in `config.yaml` sends look
|
||||
deltas in host units, so fractional display scaling no longer changes
|
||||
first-person sensitivity.
|
||||
- Small textures upscaled 4x by default, with PNG overrides in
|
||||
`~/.config/mocktail/textures`.
|
||||
- ETC2 textures decoded on desktop GPUs without stutter.
|
||||
- No more error 319 kicks after joining a server.
|
||||
- Roblox sees the real host RAM and screen size.
|
||||
- Desktop launcher that works on Wayland and finds the installed binary.
|
||||
- Discord Rich Presence on by default, with the experience name, creator,
|
||||
elapsed time, the experience icon with a Roblox badge, and a join button
|
||||
when the server is public. See the [FAQ](../FAQ.md) to switch it off.
|
||||
- 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();
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||
#ifndef MOCKTAIL_RUNTIME_PERFORMANCE_POLICY_H_
|
||||
#define MOCKTAIL_RUNTIME_PERFORMANCE_POLICY_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "runtime/frame_rate_policy.h"
|
||||
#include "runtime/game_mode.h"
|
||||
@@ -49,18 +51,18 @@ PerformancePolicy ParsePerformancePolicy(
|
||||
// Respects CPU affinity and falls back to the available logical count.
|
||||
int DetectAvailablePhysicalCoreCount();
|
||||
|
||||
// Disabled policy preserves overrides; conflicting values fail.
|
||||
bool MergePerformanceClientSettingsOverrides(const PerformancePolicy& policy,
|
||||
std::string_view base_json,
|
||||
std::string* merged_json,
|
||||
std::string* error);
|
||||
// Disabled policy preserves overrides. A base_json value that differs from
|
||||
// the preset is kept as-is and its key is appended to kept_overrides.
|
||||
bool MergePerformanceClientSettingsOverrides(
|
||||
const PerformancePolicy& policy, std::string_view base_json,
|
||||
std::string* merged_json, std::string* error,
|
||||
std::vector<std::string>* kept_overrides = nullptr);
|
||||
|
||||
// Applies mandatory HTTP and crash policies after caller settings.
|
||||
bool MergeRuntimeClientSettingsOverrides(const FrameRatePolicy& frame_rate,
|
||||
const PerformancePolicy& performance,
|
||||
std::string_view base_json,
|
||||
std::string* merged_json,
|
||||
std::string* error);
|
||||
bool MergeRuntimeClientSettingsOverrides(
|
||||
const FrameRatePolicy& frame_rate, const PerformancePolicy& performance,
|
||||
std::string_view base_json, std::string* merged_json, std::string* error,
|
||||
std::vector<std::string>* kept_overrides = nullptr);
|
||||
|
||||
// Keeps the legacy test bypass disabled. The host PermissionsProtocol now
|
||||
// answers microphone authorization from capture configuration. No permission
|
||||
|
||||
@@ -123,6 +123,7 @@ class RobloxInputRuntime final {
|
||||
RobloxInputSnapshot Snapshot() const;
|
||||
Status GetMainWindowIsMouseLockedCenter(bool* locked_center);
|
||||
bool SupportsGamepads() const;
|
||||
void SetRawMouseEnabled(bool enabled);
|
||||
|
||||
static void PlatformEventCallback(void* context,
|
||||
const platform::PlatformEvent& event);
|
||||
|
||||
@@ -171,6 +171,9 @@ class RobloxInputRouter final {
|
||||
const RobloxTextFocusProperties& properties);
|
||||
RobloxInputDispatchResult HandleEvent(const platform::PlatformEvent& event);
|
||||
RobloxInputSnapshot Snapshot() const;
|
||||
// Raw mouse sends look deltas in host units instead of guest surface units.
|
||||
// The reported pointer position stays in guest units either way.
|
||||
void SetRawMouseEnabled(bool enabled);
|
||||
|
||||
private:
|
||||
struct ActiveTouch {
|
||||
@@ -220,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);
|
||||
@@ -235,8 +242,14 @@ 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;
|
||||
};
|
||||
|
||||
const char* RobloxInputDispatchStateName(RobloxInputDispatchState state);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ class RobloxWindowInputRuntime final {
|
||||
Status UpdateTextFocusProperties(uint64_t generation,
|
||||
const RobloxTextFocusProperties& properties);
|
||||
RobloxInputSnapshot Snapshot() const;
|
||||
void SetRawMouseEnabled(bool enabled);
|
||||
|
||||
private:
|
||||
static void PlatformEventCallback(void* context,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||
#ifndef MOCKTAIL_RUNTIME_RUNTIME_CONFIG_H_
|
||||
#define MOCKTAIL_RUNTIME_RUNTIME_CONFIG_H_
|
||||
|
||||
@@ -38,6 +39,10 @@ struct InputCapabilityConfig {
|
||||
bool touch_enabled = false;
|
||||
bool mouse_enabled = true;
|
||||
bool keyboard_enabled = true;
|
||||
// Mouse look deltas keep their host units instead of being scaled into guest
|
||||
// surface units. The two differ only when the desktop display scale and the
|
||||
// window's pixel density disagree, as under fractional scaling.
|
||||
bool raw_mouse = false;
|
||||
};
|
||||
|
||||
struct NetworkProxyConfig {
|
||||
@@ -46,16 +51,29 @@ 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 = false;
|
||||
bool enabled = true;
|
||||
bool show_place_name = true;
|
||||
bool show_elapsed_time = true;
|
||||
bool join_enabled = true;
|
||||
@@ -63,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 +0,0 @@
|
||||
# Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||
# pkgforge-dev/archlinux-pkgs-debloated, continuous release, 2026-09-15.
|
||||
# These assets are rolling. Refresh names/hashes together; do not bypass a
|
||||
# checksum mismatch. Only the disposable AppImage build container installs them.
|
||||
eb87771dabc819b318e0ddf94e7ea175a662c1b6fbe748661126c520a0fd4627 ffmpeg-mini-x86_64.pkg.tar.zst
|
||||
e62d30173cc87d3a717a218610c9291b9f647b29b99ee86a0a591226c39a0728 gdk-pixbuf2-mini-x86_64.pkg.tar.zst
|
||||
ca18bc00a343650cde3d674135fa482b54faa639017f82792906db8c68ca9fb6 gtk4-mini-x86_64.pkg.tar.zst
|
||||
8500f31bb0f793d159d58f2e428594baba63f0c1a0276394c5a3f1ac1da422ef icu-mini-x86_64.pkg.tar.zst
|
||||
3d51ee49d0e49a065971703b3f61b610bd1c307cd5165ec16846d75f3efe4a08 librsvg-mini-x86_64.pkg.tar.zst
|
||||
fd52222372202ac7d37ff3f6ef0a47b0decee224aab37ac2b24086fb623b8b86 libxml2-mini-x86_64.pkg.tar.zst
|
||||
da5598b3f8e3a33ba0811c225752466a1abd3821053a19b480410937454feb04 mesa-mini-x86_64.pkg.tar.zst
|
||||
ca23eed050345d8c3f45a21610f91aecc273569b09d8d6eed574014e2e560d89 opus-mini-x86_64.pkg.tar.zst
|
||||
@@ -0,0 +1,12 @@
|
||||
# Allowed reduced packages from pkgforge-dev/archlinux-pkgs-debloated.
|
||||
# continuous is rolling: the installer snapshots GitHub asset IDs and SHA256
|
||||
# digests together at build time, and verifies every archive before installation.
|
||||
# Only the disposable AppImage build container installs these packages.
|
||||
ffmpeg-mini-x86_64.pkg.tar.zst
|
||||
gdk-pixbuf2-mini-x86_64.pkg.tar.zst
|
||||
gtk4-mini-x86_64.pkg.tar.zst
|
||||
icu-mini-x86_64.pkg.tar.zst
|
||||
librsvg-mini-x86_64.pkg.tar.zst
|
||||
libxml2-mini-x86_64.pkg.tar.zst
|
||||
mesa-mini-x86_64.pkg.tar.zst
|
||||
opus-mini-x86_64.pkg.tar.zst
|
||||
@@ -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
|
||||
|
||||
@@ -70,3 +70,5 @@ EOF
|
||||
|
||||
install -m 0644 -- "${PROJECT_DIR}/site/index.html" "${OUTPUT}/index.html"
|
||||
install -m 0644 -- "${PROJECT_DIR}/site/styles.css" "${OUTPUT}/styles.css"
|
||||
install -m 0644 -- "${PROJECT_DIR}/site/terms.html" "${OUTPUT}/terms.html"
|
||||
install -m 0644 -- "${PROJECT_DIR}/site/privacy.html" "${OUTPUT}/privacy.html"
|
||||
@@ -4,36 +4,122 @@
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
# The reduced packages are intentionally limited to the disposable builder.
|
||||
# Never replace a developer's desktop / graphics packages on the host.
|
||||
[[ -f /.dockerenv || -f /run/.containerenv ]] || {
|
||||
printf 'AnyLinux dependency installation requires a disposable container\n' >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$(id -u)" == 0 ]] || {
|
||||
printf 'AnyLinux dependency installation requires container root\n' >&2
|
||||
AnyLinuxDependencyDie() {
|
||||
printf '[anylinux-dependencies] error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
readonly ROOT="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly MANIFEST="${ROOT}/packaging/anylinux-dependencies.sha256"
|
||||
readonly DOWNLOADS="$(mktemp -d "${TMPDIR:-/tmp}/mocktail-debloated.XXXXXX")"
|
||||
trap 'rm -rf -- "${DOWNLOADS}"' EXIT
|
||||
readonly BASE_URL=https://github.com/pkgforge-dev/archlinux-pkgs-debloated/releases/download/continuous
|
||||
declare -a packages=()
|
||||
while read -r digest filename remainder; do
|
||||
[[ -n "${digest}" && "${digest}" != \#* ]] || continue
|
||||
[[ "${digest}" =~ ^[0-9a-f]{64}$ &&
|
||||
"${filename}" =~ ^[a-z0-9-]+-x86_64\.pkg\.tar\.zst$ &&
|
||||
-z "${remainder}" ]] || {
|
||||
printf 'Invalid AnyLinux dependency manifest entry\n' >&2
|
||||
exit 1
|
||||
}
|
||||
curl --fail --location --retry 3 --output "${DOWNLOADS}/${filename}" \
|
||||
"${BASE_URL}/${filename}"
|
||||
printf '%s %s\n' "${digest}" "${DOWNLOADS}/${filename}" |
|
||||
sha256sum --check --strict
|
||||
packages+=("${DOWNLOADS}/${filename}")
|
||||
done <"${MANIFEST}"
|
||||
(( ${#packages[@]} > 0 ))
|
||||
pacman -U --noconfirm --needed "${packages[@]}"
|
||||
AnyLinuxDependencyRequireContainer() {
|
||||
# Never replace a developer's desktop / graphics packages on the host.
|
||||
[[ -f /.dockerenv || -f /run/.containerenv ]] ||
|
||||
AnyLinuxDependencyDie 'installation requires a disposable container'
|
||||
[[ "$(id -u)" == 0 ]] ||
|
||||
AnyLinuxDependencyDie 'installation requires container root'
|
||||
}
|
||||
|
||||
AnyLinuxDependencySnapshot() {
|
||||
local manifest="$1" release="$2" filename remainder names_json
|
||||
local -a names=()
|
||||
local -A seen=()
|
||||
while read -r filename remainder || [[ -n "${filename}" ]]; do
|
||||
[[ -n "${filename}" && "${filename}" != \#* ]] || continue
|
||||
[[ "${filename}" =~ ^[a-z0-9-]+-mini-x86_64\.pkg\.tar\.zst$ &&
|
||||
-z "${remainder}" && -z "${seen[${filename}]:-}" ]] ||
|
||||
AnyLinuxDependencyDie 'invalid or duplicate dependency manifest entry'
|
||||
seen["${filename}"]=1
|
||||
names+=("${filename}")
|
||||
done <"${manifest}"
|
||||
(( ${#names[@]} > 0 )) || AnyLinuxDependencyDie 'empty dependency manifest'
|
||||
names_json="$(printf '%s\n' "${names[@]}" | jq --raw-input --slurp \
|
||||
'split("\n")[:-1]')"
|
||||
|
||||
# continuous assets are replaced on upstream rebuilds. Resolve all required
|
||||
# names and GitHub-computed digests in one response, then download by asset ID
|
||||
# rather than by rolling filename. These are per-build integrity checks, not
|
||||
# immutable version pins. Never accept absent digests or compute our own trust.
|
||||
jq --exit-status --raw-output --argjson names "${names_json}" \
|
||||
--arg base 'https://api.github.com/repos/pkgforge-dev/archlinux-pkgs-debloated/releases/assets/' '
|
||||
(if .tag_name != "continuous" or (.assets | type) != "array" then
|
||||
error("invalid continuous release metadata")
|
||||
else .assets end) as $assets |
|
||||
$names[] as $name |
|
||||
[$assets[] | select(.name == $name)] as $matches |
|
||||
if ($matches | length) != 1 then
|
||||
error("missing or duplicate dependency asset: " + $name)
|
||||
else $matches[0] end |
|
||||
if .state != "uploaded" or (.id | type) != "number" or
|
||||
.id <= 0 or .id != (.id | floor) or
|
||||
(.digest | type) != "string" then
|
||||
error("invalid dependency asset metadata: " + $name)
|
||||
elif (.digest | test("^sha256:[0-9a-f]{64}$") | not) or
|
||||
.url != ($base + (.id | tostring)) then
|
||||
error("invalid dependency digest or URL: " + $name)
|
||||
else
|
||||
[(.digest | ltrimstr("sha256:")), $name, .url] | @tsv
|
||||
end
|
||||
' "${release}" || AnyLinuxDependencyDie 'cannot resolve verified dependency assets'
|
||||
}
|
||||
|
||||
AnyLinuxDownloadDependencies() {
|
||||
local manifest="$1" downloads="$2" attempt digest filename url failed
|
||||
local -a curl_options=(
|
||||
--fail --silent --show-error --location --retry 3
|
||||
--proto '=https' --proto-redir '=https'
|
||||
--connect-timeout 15 --max-time 180
|
||||
--header 'X-GitHub-Api-Version: 2022-11-28'
|
||||
)
|
||||
if [[ -n "${GH_TOKEN:-}" ]]; then
|
||||
curl_options+=(--header "Authorization: Bearer ${GH_TOKEN}")
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
curl "${curl_options[@]}" --header 'Accept: application/vnd.github+json' \
|
||||
--output "${downloads}/release.json" \
|
||||
https://api.github.com/repos/pkgforge-dev/archlinux-pkgs-debloated/releases/tags/continuous ||
|
||||
AnyLinuxDependencyDie 'cannot fetch upstream release metadata'
|
||||
AnyLinuxDependencySnapshot "${manifest}" "${downloads}/release.json" \
|
||||
>"${downloads}/dependencies.tsv"
|
||||
: >"${downloads}/SHA256SUMS"
|
||||
failed=false
|
||||
while IFS=$'\t' read -r digest filename url; do
|
||||
printf '[anylinux-dependencies] downloading %s (SHA256 %s)\n' \
|
||||
"${filename}" "${digest}" >&2
|
||||
if ! curl "${curl_options[@]}" --header 'Accept: application/octet-stream' \
|
||||
--output "${downloads}/${filename}" "${url}"; then
|
||||
failed=true
|
||||
break
|
||||
fi
|
||||
printf '%s %s\n' "${digest}" "${filename}" >>"${downloads}/SHA256SUMS"
|
||||
done <"${downloads}/dependencies.tsv"
|
||||
if [[ "${failed}" == true ]]; then
|
||||
# An upstream upload can delete an ID between resolution and download.
|
||||
# Retry a complete snapshot, never mix old checksums with new filenames.
|
||||
printf '[anylinux-dependencies] download failed; refreshing snapshot (%s/3)\n' \
|
||||
"${attempt}" >&2
|
||||
continue
|
||||
fi
|
||||
(cd -- "${downloads}" && sha256sum --check --strict SHA256SUMS) ||
|
||||
AnyLinuxDependencyDie 'dependency checksum verification failed'
|
||||
return 0
|
||||
done
|
||||
AnyLinuxDependencyDie 'dependency downloads failed after 3 snapshots'
|
||||
}
|
||||
|
||||
AnyLinuxDependenciesMain() (
|
||||
AnyLinuxDependencyRequireContainer
|
||||
(( $# == 0 )) || AnyLinuxDependencyDie 'no arguments are supported'
|
||||
readonly ROOT="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly MANIFEST="${ROOT}/packaging/anylinux-dependencies.txt"
|
||||
readonly DOWNLOADS="$(mktemp -d "${TMPDIR:-/tmp}/mocktail-debloated.XXXXXX")"
|
||||
trap 'rm -rf -- "${DOWNLOADS}"' EXIT
|
||||
AnyLinuxDownloadDependencies "${MANIFEST}" "${DOWNLOADS}"
|
||||
local digest filename url
|
||||
local -a packages=()
|
||||
while IFS=$'\t' read -r digest filename url; do
|
||||
packages+=("${DOWNLOADS}/${filename}")
|
||||
done <"${DOWNLOADS}/dependencies.tsv"
|
||||
pacman -U --noconfirm --needed "${packages[@]}"
|
||||
)
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
AnyLinuxDependenciesMain "$@"
|
||||
fi
|
||||
@@ -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
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Print the CHANGELOG.md section for one version, for use as GitHub release
|
||||
# notes. Usage: scripts/release_notes.sh v0.1.0 [CHANGELOG.md]
|
||||
# Exits 1 when the version has no section, so a release cannot ship without
|
||||
# notes.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly tag="${1:?tag is required, e.g. v0.1.0}"
|
||||
readonly changelog="${2:-$(dirname -- "${BASH_SOURCE[0]}")/../CHANGELOG.md}"
|
||||
readonly version="${tag#v}"
|
||||
|
||||
notes="$(awk -v version="${version}" '
|
||||
/^## \[/ {
|
||||
if (found) exit
|
||||
found = ($0 ~ "^## \\[" version "\\]")
|
||||
next
|
||||
}
|
||||
/^\[[^]]+\]: / { if (found) exit }
|
||||
found { print }
|
||||
' "${changelog}")"
|
||||
|
||||
# Trim leading and trailing blank lines.
|
||||
notes="$(printf '%s\n' "${notes}" | sed -e '/./,$!d' | sed -e ':a' -e '/^\n*$/{$d;N;ba' -e '}')"
|
||||
|
||||
[[ -n "${notes}" ]] || {
|
||||
printf 'CHANGELOG.md has no section for version %s\n' "${version}" >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "${notes}"
|
||||
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,7 +44,10 @@ 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>
|
||||
</p>
|
||||
<p><a class="source" href="https://github.com/CoderDayton/nightcap">Source code</a> ·
|
||||
<a class="source" href="https://github.com/komaruworld/mocktail">Upstream Mocktail</a></p>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Nightcap Privacy Policy</title>
|
||||
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body><main>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p>Nightcap is a Linux runtime for the Roblox Android client. This policy covers Nightcap itself and its Discord application. Roblox and Discord have their own policies for the data they handle.</p>
|
||||
|
||||
<h2>What Nightcap collects</h2>
|
||||
<p>Nothing. Nightcap has no server. It does not collect, store, or send telemetry, analytics, crash reports, or account data to the Nightcap project.</p>
|
||||
|
||||
<h2>What stays on your machine</h2>
|
||||
<ul>
|
||||
<li>Configuration in <code>~/.config/mocktail/</code></li>
|
||||
<li>Session logs and support bundles in <code>~/.local/state/mocktail/</code></li>
|
||||
<li>Roblox login session under <code>~/.local/share/mocktail/</code></li>
|
||||
<li>Roblox cache, downloaded payloads, and shader cache under <code>~/.cache/mocktail/</code></li>
|
||||
</ul>
|
||||
<p>Flatpak installs keep the same layout under <code>~/.var/app/io.github.CoderDayton.nightcap/</code>.</p>
|
||||
|
||||
<h2>Discord Rich Presence</h2>
|
||||
<p>When Rich Presence is on, Nightcap sends the current experience name, its public icon URL, elapsed time, and an optional public server join code to the Discord desktop client on the same machine over a local socket. Discord displays this on your profile under its own privacy settings. Nightcap never signs in to Discord, never reads a Discord token, and never contacts Discord's servers.</p>
|
||||
<p>Set <code>integrations.discord_rpc.enabled: false</code> in <code>config.yaml</code> to turn it off.</p>
|
||||
|
||||
<h2>Roblox</h2>
|
||||
<p>Nightcap talks to Roblox's services exactly as the Roblox client would. That traffic is covered by the Roblox Privacy Policy.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>Open an issue at <a class="source" href="https://github.com/CoderDayton/nightcap/issues">github.com/CoderDayton/nightcap/issues</a>.</p>
|
||||
|
||||
<p class="links">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="terms.html">Terms of Service</a>
|
||||
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
|
||||
</p>
|
||||
</main></body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Nightcap Terms of Service</title>
|
||||
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body><main>
|
||||
<h1>Terms of Service</h1>
|
||||
<p>Nightcap is free and open-source software licensed under the Apache License, Version 2.0. The license text shipped with the source governs your use of the software.</p>
|
||||
|
||||
<h2>No warranty</h2>
|
||||
<p>Nightcap is provided "as is", without warranty of any kind. The authors are not liable for any loss, damage, or account action that results from using it. See sections 7 and 8 of the Apache License.</p>
|
||||
|
||||
<h2>Not affiliated</h2>
|
||||
<p>Nightcap is an independent community project. It is not affiliated with, endorsed by, or supported by Roblox Corporation or Discord Inc. Roblox and Discord are trademarks of their respective owners.</p>
|
||||
|
||||
<h2>Your responsibility</h2>
|
||||
<p>You are responsible for following the Roblox Terms of Use and the Discord Terms of Service while using Nightcap with those services. Nightcap does not modify Roblox game logic, does not bypass Roblox security, and is not a cheat.</p>
|
||||
|
||||
<h2>Discord application</h2>
|
||||
<p>The Nightcap Discord application only provides Rich Presence for the Nightcap desktop runtime. It requests no permissions, has no bot, and does not access your Discord account.</p>
|
||||
|
||||
<h2>Changes</h2>
|
||||
<p>These terms may change with new releases. The current version always lives at this address.</p>
|
||||
|
||||
<p class="links">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="privacy.html">Privacy Policy</a>
|
||||
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
|
||||
</p>
|
||||
</main></body>
|
||||
</html>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -6801,6 +6893,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
window_input_runtime =
|
||||
std::make_shared<mocktail::runtime::RobloxWindowInputRuntime>(
|
||||
input_environment, roblox_capabilities.input);
|
||||
window_input_runtime->SetRawMouseEnabled(input_capabilities.raw_mouse);
|
||||
const mocktail::Status input_status = window_input_runtime->Initialize();
|
||||
if (!input_status.ok()) {
|
||||
std::cerr << "[FATAL] Typed production input did not initialize: "
|
||||
@@ -6830,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;
|
||||
@@ -6945,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,
|
||||
|
||||
+14
-1
@@ -1,3 +1,4 @@
|
||||
// Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
@@ -551,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;
|
||||
@@ -779,14 +786,20 @@ int main(int argc, char* argv[]) {
|
||||
<< " FFlag overrides from " << fflags_path << '\n';
|
||||
}
|
||||
std::string client_settings_overrides;
|
||||
std::vector<std::string> kept_fflag_overrides;
|
||||
if (!mocktail::runtime::MergeRuntimeClientSettingsOverrides(
|
||||
runtime_config.config.frame_rate(),
|
||||
runtime_config.config.performance(), fflags.json,
|
||||
&client_settings_overrides, &command_line_error)) {
|
||||
&client_settings_overrides, &command_line_error,
|
||||
&kept_fflag_overrides)) {
|
||||
std::cerr << "[FATAL] Cannot apply runtime client-settings policy: "
|
||||
<< command_line_error << '\n';
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
for (const std::string& name : kept_fflag_overrides) {
|
||||
std::cout << " [runtime] keeping " << name << " from " << fflags_path
|
||||
<< " over the performance preset\n";
|
||||
}
|
||||
std::string audio_capture_overrides;
|
||||
if (!mocktail::runtime::MergeAudioCaptureClientSettingsOverrides(
|
||||
runtime_config.config.microphone_enabled(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||
#include "runtime/performance_policy.h"
|
||||
|
||||
#include <sched.h>
|
||||
@@ -27,22 +28,21 @@ struct ClientSetting {
|
||||
std::string_view value;
|
||||
};
|
||||
|
||||
bool SetCompatibleValue(nlohmann::json* object, const ClientSetting& setting,
|
||||
std::string* error) {
|
||||
// A caller-supplied value always wins over the preset; the key is reported
|
||||
// through kept_overrides so startup can say which preset values were skipped.
|
||||
void SetCompatibleValue(nlohmann::json* object, const ClientSetting& setting,
|
||||
std::vector<std::string>* kept_overrides) {
|
||||
const std::string name(setting.name);
|
||||
const std::string value(setting.value);
|
||||
const auto existing = object->find(name);
|
||||
if (existing != object->end()) {
|
||||
if (!existing->is_string() || existing->get<std::string>() != value) {
|
||||
if (error != nullptr) {
|
||||
*error = std::string("performance policy conflicts with ") + name;
|
||||
}
|
||||
return false;
|
||||
if ((!existing->is_string() || existing->get<std::string>() != value) &&
|
||||
kept_overrides != nullptr) {
|
||||
kept_overrides->push_back(name);
|
||||
}
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
(*object)[name] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReadTopologyValue(int logical_cpu, std::string_view name, int* value) {
|
||||
@@ -154,10 +154,10 @@ int DetectAvailablePhysicalCoreCount() {
|
||||
: static_cast<int>(physical_cores.size());
|
||||
}
|
||||
|
||||
bool MergePerformanceClientSettingsOverrides(const PerformancePolicy& policy,
|
||||
std::string_view base_json,
|
||||
std::string* merged_json,
|
||||
std::string* error) {
|
||||
bool MergePerformanceClientSettingsOverrides(
|
||||
const PerformancePolicy& policy, std::string_view base_json,
|
||||
std::string* merged_json, std::string* error,
|
||||
std::vector<std::string>* kept_overrides) {
|
||||
if (merged_json == nullptr) {
|
||||
if (error != nullptr) {
|
||||
*error = "performance client-settings output is required";
|
||||
@@ -184,13 +184,10 @@ bool MergePerformanceClientSettingsOverrides(const PerformancePolicy& policy,
|
||||
return true;
|
||||
}
|
||||
const auto apply_settings = [&overrides,
|
||||
error](const auto& settings) -> bool {
|
||||
kept_overrides](const auto& settings) {
|
||||
for (const ClientSetting& setting : settings) {
|
||||
if (!SetCompatibleValue(&overrides, setting, error)) {
|
||||
return false;
|
||||
}
|
||||
SetCompatibleValue(&overrides, setting, kept_overrides);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const bool throughput_mode =
|
||||
policy.physics_worker_mode == PhysicsWorkerMode::kThroughput;
|
||||
@@ -198,9 +195,7 @@ bool MergePerformanceClientSettingsOverrides(const PerformancePolicy& policy,
|
||||
const std::array<ClientSetting, 1> physics_settings = {{
|
||||
{"DFIntSimMidPhaseContactPipelineBatchSize", "128"},
|
||||
}};
|
||||
if (!apply_settings(physics_settings)) {
|
||||
return false;
|
||||
}
|
||||
apply_settings(physics_settings);
|
||||
}
|
||||
int render_worker_count = 0;
|
||||
if (throughput_mode ||
|
||||
@@ -223,9 +218,7 @@ bool MergePerformanceClientSettingsOverrides(const PerformancePolicy& policy,
|
||||
{"FIntTaskSchedulerAutoThreadLimit", workers},
|
||||
{"DFIntSimMidPhaseContactPipelineBatchSize", "128"},
|
||||
}};
|
||||
if (!apply_settings(physics_settings)) {
|
||||
return false;
|
||||
}
|
||||
apply_settings(physics_settings);
|
||||
} else if (policy.multithreaded_rendering &&
|
||||
policy.physics_worker_mode != PhysicsWorkerMode::kLatency) {
|
||||
const std::string workers = std::to_string(render_worker_count);
|
||||
@@ -233,9 +226,7 @@ bool MergePerformanceClientSettingsOverrides(const PerformancePolicy& policy,
|
||||
{"FIntTaskSchedulerThreadMin", workers},
|
||||
{"FIntTaskSchedulerAsyncTasksMinimumThreadCount", workers},
|
||||
}};
|
||||
if (!apply_settings(scheduler_settings)) {
|
||||
return false;
|
||||
}
|
||||
apply_settings(scheduler_settings);
|
||||
}
|
||||
if ((policy.multithreaded_rendering || throughput_mode) &&
|
||||
policy.physics_worker_mode != PhysicsWorkerMode::kLatency) {
|
||||
@@ -322,26 +313,21 @@ bool MergePerformanceClientSettingsOverrides(const PerformancePolicy& policy,
|
||||
{"FIntDefaultAudioDecodeBufferSizeMs", "50"},
|
||||
{"FIntMaxAudibleSoundChannels", "32"},
|
||||
}};
|
||||
if (!apply_settings(rendering_settings)) {
|
||||
return false;
|
||||
}
|
||||
apply_settings(rendering_settings);
|
||||
if (!manual_quality) {
|
||||
if (!SetCompatibleValue(
|
||||
&overrides, {"FIntDebugFRMQualityLevelOverride", quality_str},
|
||||
error)) {
|
||||
return false;
|
||||
}
|
||||
SetCompatibleValue(&overrides,
|
||||
{"FIntDebugFRMQualityLevelOverride", quality_str},
|
||||
kept_overrides);
|
||||
}
|
||||
}
|
||||
*merged_json = overrides.dump();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MergeRuntimeClientSettingsOverrides(const FrameRatePolicy& frame_rate,
|
||||
const PerformancePolicy& performance,
|
||||
std::string_view base_json,
|
||||
std::string* merged_json,
|
||||
std::string* error) {
|
||||
bool MergeRuntimeClientSettingsOverrides(
|
||||
const FrameRatePolicy& frame_rate, const PerformancePolicy& performance,
|
||||
std::string_view base_json, std::string* merged_json, std::string* error,
|
||||
std::vector<std::string>* kept_overrides) {
|
||||
std::string frame_rate_overrides;
|
||||
if (!MergeFrameRateClientSettingsOverrides(frame_rate, base_json,
|
||||
&frame_rate_overrides, error)) {
|
||||
@@ -349,7 +335,8 @@ bool MergeRuntimeClientSettingsOverrides(const FrameRatePolicy& frame_rate,
|
||||
}
|
||||
std::string performance_overrides;
|
||||
if (!MergePerformanceClientSettingsOverrides(
|
||||
performance, frame_rate_overrides, &performance_overrides, error)) {
|
||||
performance, frame_rate_overrides, &performance_overrides, error,
|
||||
kept_overrides)) {
|
||||
return false;
|
||||
}
|
||||
std::string http_client_overrides;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -627,6 +627,10 @@ RobloxInputDispatchResult RobloxInputRuntime::HandleEvent(
|
||||
return result;
|
||||
}
|
||||
|
||||
void RobloxInputRuntime::SetRawMouseEnabled(bool enabled) {
|
||||
router_.SetRawMouseEnabled(enabled);
|
||||
}
|
||||
|
||||
RobloxInputSnapshot RobloxInputRuntime::Snapshot() const {
|
||||
return router_.Snapshot();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -532,6 +533,11 @@ RobloxInputSnapshot RobloxInputRouter::Snapshot() const {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
void RobloxInputRouter::SetRawMouseEnabled(bool enabled) {
|
||||
const std::lock_guard<std::mutex> lock(mutex_);
|
||||
raw_mouse_ = enabled;
|
||||
}
|
||||
|
||||
RobloxInputDispatchResult RobloxInputRouter::HandleMouseMotionLocked(
|
||||
const platform::MouseMotionEvent& event) {
|
||||
if (sink_.mouse_move == nullptr) {
|
||||
@@ -544,21 +550,32 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseMotionLocked(
|
||||
const float delta_x = transform.HostLogicalToGuestX(event.delta_x);
|
||||
const float delta_y = transform.HostLogicalToGuestY(event.delta_y);
|
||||
|
||||
if (event.x == 0.0f && event.y == 0.0f &&
|
||||
(delta_x != 0.0f || delta_y != 0.0f)) {
|
||||
mouse_x_ += delta_x;
|
||||
mouse_y_ += delta_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 {
|
||||
mouse_x_ = transform.HostLogicalToGuestX(event.x);
|
||||
mouse_y_ = transform.HostLogicalToGuestY(event.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_ = 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;
|
||||
}
|
||||
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);
|
||||
|
||||
// Raw mouse reports the host's own delta, skipping the guest-surface scale
|
||||
// that otherwise changes look sensitivity when the desktop display scale and
|
||||
// the window's pixel density disagree. The position stays in guest units.
|
||||
const float reported_delta_x = raw_mouse_ ? event.delta_x : delta_x;
|
||||
const float reported_delta_y = raw_mouse_ ? event.delta_y : delta_y;
|
||||
|
||||
return NativeResultLocked(
|
||||
sink_.mouse_move(sink_.context, clamped_x, clamped_y, delta_x, delta_y),
|
||||
sink_.mouse_move(sink_.context, mouse_x_, mouse_y_, reported_delta_x,
|
||||
reported_delta_y),
|
||||
RobloxInputEventKind::kMouseMotion);
|
||||
}
|
||||
|
||||
@@ -575,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) {
|
||||
@@ -620,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;
|
||||
|
||||
@@ -195,6 +195,10 @@ Status RobloxWindowInputRuntime::UpdateTextFocusProperties(
|
||||
return runtime_.UpdateTextFocusProperties(generation, properties);
|
||||
}
|
||||
|
||||
void RobloxWindowInputRuntime::SetRawMouseEnabled(bool enabled) {
|
||||
runtime_.SetRawMouseEnabled(enabled);
|
||||
}
|
||||
|
||||
RobloxInputSnapshot RobloxWindowInputRuntime::Snapshot() const {
|
||||
return runtime_.Snapshot();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||
#include "runtime/runtime_config.h"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -7,6 +8,7 @@
|
||||
#include <climits>
|
||||
#include <cstdlib>
|
||||
#include <optional>
|
||||
#include <tuple>
|
||||
|
||||
#ifndef MOCKTAIL_DISCORD_APPLICATION_ID
|
||||
#define MOCKTAIL_DISCORD_APPLICATION_ID ""
|
||||
@@ -39,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);
|
||||
@@ -229,6 +235,11 @@ RuntimeConfig RuntimeConfig::FromEnvironment(const Environment& environment) {
|
||||
keyboard.has_value()) {
|
||||
config.input_capabilities_.keyboard_enabled = *keyboard;
|
||||
}
|
||||
if (const std::optional<bool> raw_mouse =
|
||||
InputEnabled(environment, "MOCKTAIL_RAW_MOUSE");
|
||||
raw_mouse.has_value()) {
|
||||
config.input_capabilities_.raw_mouse = *raw_mouse;
|
||||
}
|
||||
bool customized_device = false;
|
||||
config.device_profile_valid_ =
|
||||
config.device_profile_valid_ &&
|
||||
@@ -325,7 +336,7 @@ RuntimeConfig RuntimeConfig::FromEnvironment(const Environment& environment) {
|
||||
}
|
||||
bool discord_booleans_valid = true;
|
||||
config.discord_rpc_.enabled = ReadBoolean(
|
||||
environment, "MOCKTAIL_DISCORD_RPC_ENABLED", false,
|
||||
environment, "MOCKTAIL_DISCORD_RPC_ENABLED", true,
|
||||
&discord_booleans_valid);
|
||||
config.discord_rpc_.show_place_name = ReadBoolean(
|
||||
environment, "MOCKTAIL_DISCORD_RPC_SHOW_PLACE_NAME", true,
|
||||
@@ -351,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)) {
|
||||
|
||||
@@ -17,7 +17,8 @@ namespace runtime {
|
||||
namespace {
|
||||
|
||||
constexpr std::string_view kDefaultRuntimeConfig =
|
||||
R"yaml(# Mocktail configuration.
|
||||
R"yaml(# Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||
# Mocktail configuration.
|
||||
# The file is created once with mode 0600. Mocktail never replaces your edits.
|
||||
|
||||
# Integer: configuration schema version. Only version 1 is supported.
|
||||
@@ -95,6 +96,14 @@ audio:
|
||||
# between boots; prefer the exact device name when it is unique.
|
||||
input_device: default
|
||||
|
||||
input:
|
||||
# Boolean (default: false): send mouse look deltas in the host's own units.
|
||||
# They are otherwise scaled by the window's pixel density divided by the
|
||||
# desktop display scale, which cancels to 1.0 unless fractional scaling makes
|
||||
# the two disagree. Turn this on when first-person aiming feels too slow or
|
||||
# too fast under fractional scaling. The pointer position is unaffected.
|
||||
raw_mouse: false
|
||||
|
||||
integrations:
|
||||
fleasion:
|
||||
# Boolean (default: false): trust Fleasion's CA without editing Roblox files.
|
||||
@@ -107,9 +116,9 @@ integrations:
|
||||
# or ~/.config/Fleasion/proxy_ca/ca.crt. Never select ca.key.
|
||||
# ca_certificate: /home/user/.config/Fleasion/proxy_ca/ca.crt
|
||||
discord_rpc:
|
||||
# Boolean (default: false): publish Mocktail activity to Discord Desktop.
|
||||
# Boolean (default: true): publish Nightcap activity to Discord Desktop.
|
||||
# This never signs in to Discord and never reads an account token.
|
||||
enabled: false
|
||||
enabled: true
|
||||
# Boolean (default: true): show the current Roblox experience name.
|
||||
show_place_name: true
|
||||
# Boolean (default: true): show how long the current session has run.
|
||||
@@ -122,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
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment,
|
||||
"window.title",
|
||||
"window.high_dpi",
|
||||
"input.touch_enabled",
|
||||
"input.raw_mouse",
|
||||
"compatibility.desktop_playability",
|
||||
"network.use_system_proxy",
|
||||
"network.proxy_host",
|
||||
@@ -204,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()) {
|
||||
@@ -433,6 +439,14 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment,
|
||||
}
|
||||
(*environment)["MOCKTAIL_TOUCH_MODE"] = parsed ? "on" : "off";
|
||||
}
|
||||
if (const auto raw_mouse = value("input.raw_mouse"); raw_mouse.has_value()) {
|
||||
bool parsed = false;
|
||||
if (!ParseBoolean(*raw_mouse, &parsed)) {
|
||||
*error = "input.raw_mouse must be true or false";
|
||||
return false;
|
||||
}
|
||||
(*environment)["MOCKTAIL_RAW_MOUSE"] = parsed ? "on" : "off";
|
||||
}
|
||||
if (const auto desktop_playability =
|
||||
value("compatibility.desktop_playability");
|
||||
desktop_playability.has_value()) {
|
||||
@@ -545,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},
|
||||
}) {
|
||||
@@ -565,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;
|
||||
}
|
||||
|
||||
@@ -877,6 +919,9 @@ bool ExportRuntimeConfigEnvironment(const RuntimeConfig& config,
|
||||
SetEnvironmentValue(
|
||||
"MOCKTAIL_KEYBOARD_MODE",
|
||||
config.input_capabilities().keyboard_enabled ? "on" : "off", error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_RAW_MOUSE",
|
||||
config.input_capabilities().raw_mouse ? "on" : "off",
|
||||
error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DESKTOP_PLAYABILITY",
|
||||
config.desktop_playability() ? "1" : "0", error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_FRAME_RATE_LIMIT",
|
||||
@@ -928,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;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <random>
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail {
|
||||
namespace services {
|
||||
@@ -211,6 +212,48 @@ bool FindExistingDirectoryAncestor(const std::filesystem::path& directory,
|
||||
}
|
||||
}
|
||||
|
||||
// Creates every component of `directory` below `existing_ancestor` with mode
|
||||
// 0700, whatever the process umask. mkdir masks its mode argument, so a umask
|
||||
// of 002 would leave the directory group writable and rejected by the
|
||||
// IsPrivateDirectory checks that guard this file's credentials, while a umask
|
||||
// carrying owner bits would leave it unwritable by its own owner. Only
|
||||
// components this call creates are tightened; one that already exists keeps
|
||||
// its mode and is judged by IsPrivateDirectory like any other.
|
||||
bool CreatePrivateDirectories(const std::filesystem::path& directory,
|
||||
const std::filesystem::path& existing_ancestor) {
|
||||
std::vector<std::filesystem::path> missing;
|
||||
for (std::filesystem::path candidate = directory;
|
||||
candidate != existing_ancestor;) {
|
||||
if (candidate.empty()) return false;
|
||||
missing.push_back(candidate);
|
||||
// Mirror FindExistingDirectoryAncestor's walk, including the implicit "."
|
||||
// parent of a bare relative name, so the two agree on which components are
|
||||
// missing.
|
||||
std::filesystem::path parent = candidate.parent_path();
|
||||
if (parent.empty() && candidate != ".") parent = ".";
|
||||
if (parent.empty() || parent == candidate) return false;
|
||||
candidate = parent;
|
||||
}
|
||||
for (auto component = missing.rbegin(); component != missing.rend();
|
||||
++component) {
|
||||
if (mkdir(component->c_str(), 0700) != 0) {
|
||||
if (errno != EEXIST) return false;
|
||||
continue;
|
||||
}
|
||||
const int created = open(component->c_str(),
|
||||
O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW);
|
||||
if (created < 0) return false;
|
||||
struct stat metadata = {};
|
||||
const bool tightened = fstat(created, &metadata) == 0 &&
|
||||
S_ISDIR(metadata.st_mode) &&
|
||||
metadata.st_uid == geteuid() &&
|
||||
fchmod(created, 0700) == 0;
|
||||
close(created);
|
||||
if (!tightened) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FsyncDirectoryChain(const std::filesystem::path& first,
|
||||
const std::filesystem::path& last) {
|
||||
std::filesystem::path current = first;
|
||||
@@ -322,8 +365,7 @@ bool AtomicWriteContents(const std::filesystem::path& path,
|
||||
return false;
|
||||
}
|
||||
const bool parent_existed = existing_ancestor == parent;
|
||||
std::filesystem::create_directories(parent, filesystem_error);
|
||||
if (filesystem_error) {
|
||||
if (!CreatePrivateDirectories(parent, existing_ancestor)) {
|
||||
error->assign(directory_error);
|
||||
return false;
|
||||
}
|
||||
@@ -449,10 +491,11 @@ BrowserTrackerResult BrowserTrackerService::EnsureInitialized(
|
||||
return result;
|
||||
}
|
||||
const bool lock_parent_existed = existing_lock_ancestor == lock_parent;
|
||||
std::filesystem::create_directories(lock_parent, filesystem_error);
|
||||
const bool lock_parent_created =
|
||||
CreatePrivateDirectories(lock_parent, existing_lock_ancestor);
|
||||
struct stat lock_parent_status = {};
|
||||
const bool safe_lock_parent =
|
||||
!filesystem_error &&
|
||||
lock_parent_created &&
|
||||
lstat(lock_parent.c_str(), &lock_parent_status) == 0 &&
|
||||
IsPrivateDirectory(lock_parent_status);
|
||||
const int lock = safe_lock_parent
|
||||
|
||||
+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)
|
||||
@@ -1049,6 +1081,12 @@ add_test(
|
||||
${CMAKE_SOURCE_DIR}/packaging/AppRun
|
||||
)
|
||||
|
||||
add_test(
|
||||
NAME AnyLinuxDependenciesTest.RollingAssetsAreVerified
|
||||
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/anylinux_dependencies_test.sh
|
||||
${CMAKE_SOURCE_DIR}
|
||||
)
|
||||
|
||||
add_test(
|
||||
NAME AnyLinuxPackagingTest.RelocatedRuntimePaths
|
||||
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/anylinux_packaging_test.sh
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly ROOT="${1:?source root is required}"
|
||||
readonly TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mocktail-dependency-test.XXXXXX")"
|
||||
trap 'rm -rf -- "${TEMP_DIR}"' EXIT
|
||||
source "${ROOT}/scripts/install_anylinux_dependencies.sh"
|
||||
|
||||
Fail() {
|
||||
printf 'AnyLinux dependency test failed: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
readonly API=https://api.github.com/repos/pkgforge-dev/archlinux-pkgs-debloated/releases
|
||||
readonly FFMPEG=ffmpeg-mini-x86_64.pkg.tar.zst
|
||||
readonly OPUS=opus-mini-x86_64.pkg.tar.zst
|
||||
printf 'ffmpeg fixture\n' >"${TEMP_DIR}/ffmpeg"
|
||||
printf 'updated ffmpeg fixture\n' >"${TEMP_DIR}/updated-ffmpeg"
|
||||
printf 'opus fixture\n' >"${TEMP_DIR}/opus"
|
||||
readonly FFMPEG_HASH="$(sha256sum "${TEMP_DIR}/ffmpeg" | cut -d ' ' -f 1)"
|
||||
readonly UPDATED_FFMPEG_HASH="$(sha256sum "${TEMP_DIR}/updated-ffmpeg" | cut -d ' ' -f 1)"
|
||||
readonly OPUS_HASH="$(sha256sum "${TEMP_DIR}/opus" | cut -d ' ' -f 1)"
|
||||
printf '# Required fixture packages\n\n%s\n%s' "${FFMPEG}" "${OPUS}" \
|
||||
>"${TEMP_DIR}/manifest.txt"
|
||||
jq --null-input --arg api "${API}" --arg ffmpeg "${FFMPEG}" --arg opus "${OPUS}" \
|
||||
--arg ffmpeg_hash "${FFMPEG_HASH}" --arg opus_hash "${OPUS_HASH}" '
|
||||
{tag_name: "continuous", assets: [
|
||||
{id: 10, name: $ffmpeg, state: "uploaded", digest: ("sha256:" + $ffmpeg_hash),
|
||||
url: ($api + "/assets/10")},
|
||||
{id: 20, name: $opus, state: "uploaded", digest: ("sha256:" + $opus_hash),
|
||||
url: ($api + "/assets/20")},
|
||||
{id: 30, name: "unrequested-mini-x86_64.pkg.tar.zst", state: "uploaded",
|
||||
digest: null, url: ($api + "/assets/30")}
|
||||
]}
|
||||
' >"${TEMP_DIR}/release.json"
|
||||
jq --arg hash "${UPDATED_FFMPEG_HASH}" '
|
||||
.assets[0].id = 11 | .assets[0].url |= sub("/10$"; "/11") |
|
||||
.assets[0].digest = ("sha256:" + $hash) |
|
||||
.assets[1].id = 21 | .assets[1].url |= sub("/20$"; "/21")
|
||||
' \
|
||||
"${TEMP_DIR}/release.json" >"${TEMP_DIR}/refreshed-release.json"
|
||||
mkdir -p "${TEMP_DIR}/project/scripts" "${TEMP_DIR}/project/packaging"
|
||||
cp "${ROOT}/scripts/install_anylinux_dependencies.sh" "${TEMP_DIR}/project/scripts/"
|
||||
cp "${TEMP_DIR}/manifest.txt" "${TEMP_DIR}/project/packaging/anylinux-dependencies.txt"
|
||||
|
||||
# Record requests without any network access or changes to host packages.
|
||||
curl() {
|
||||
local output="" accept="" authorization="" url=""
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--output) output="$2"; shift 2 ;;
|
||||
--header)
|
||||
case "$2" in
|
||||
Accept:*) accept="$2" ;;
|
||||
Authorization:*) authorization="$2" ;;
|
||||
esac
|
||||
shift 2 ;;
|
||||
--retry|--proto|--proto-redir|--connect-timeout|--max-time) shift 2 ;;
|
||||
--fail|--silent|--show-error|--location) shift ;;
|
||||
https://*) url="$1"; shift ;;
|
||||
*) Fail "unexpected curl option: $1" ;;
|
||||
esac
|
||||
done
|
||||
if [[ -n "${GH_TOKEN:-}" ]]; then
|
||||
[[ "${authorization}" == "Authorization: Bearer ${GH_TOKEN}" ]] ||
|
||||
Fail 'missing authenticated GitHub request'
|
||||
fi
|
||||
printf '%s\n' "${url}" >>"${TEMP_DIR}/calls"
|
||||
case "${url}" in
|
||||
"${API}/tags/continuous")
|
||||
[[ "${accept}" == 'Accept: application/vnd.github+json' ]]
|
||||
if [[ "${SCENARIO}" == metadata-failure ]]; then return 22; fi
|
||||
if [[ "${SCENARIO}" == replaced && -f "${TEMP_DIR}/asset-replaced" ]]; then
|
||||
cp "${TEMP_DIR}/refreshed-release.json" "${output}"
|
||||
else
|
||||
cp "${TEST_RELEASE}" "${output}"
|
||||
fi ;;
|
||||
"${API}/assets/10"|"${API}/assets/11"|"${API}/assets/20"|"${API}/assets/21")
|
||||
[[ "${accept}" == 'Accept: application/octet-stream' ]]
|
||||
if [[ "${SCENARIO}" == download-failure ]]; then return 22; fi
|
||||
if [[ "${SCENARIO}" == replaced && "${url}" == "${API}/assets/20" ]]; then
|
||||
touch "${TEMP_DIR}/asset-replaced"
|
||||
return 22
|
||||
fi
|
||||
if [[ "${url}" == "${API}/assets/20" || "${url}" == "${API}/assets/21" ]]; then
|
||||
cp "${TEMP_DIR}/opus" "${output}"
|
||||
elif [[ "${url}" == "${API}/assets/11" ]]; then
|
||||
cp "${TEMP_DIR}/updated-ffmpeg" "${output}"
|
||||
else
|
||||
cp "${TEMP_DIR}/ffmpeg" "${output}"
|
||||
fi
|
||||
if [[ "${SCENARIO}" == corrupted && "${url}" == "${API}/assets/20" ]]; then
|
||||
printf 'corrupted\n' >>"${output}"
|
||||
fi ;;
|
||||
*) Fail "unrequested or rolling asset URL: ${url}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
pacman() {
|
||||
[[ "$#" == 5 && "$1" == -U && "$2" == --noconfirm && "$3" == --needed ]]
|
||||
if [[ "${SCENARIO}" == replaced ]]; then
|
||||
cmp "${TEMP_DIR}/updated-ffmpeg" "$4"
|
||||
else
|
||||
cmp "${TEMP_DIR}/ffmpeg" "$4"
|
||||
fi
|
||||
cmp "${TEMP_DIR}/opus" "$5"
|
||||
local downloads="${4%/*}"
|
||||
(cd "${downloads}" && sha256sum --check --strict SHA256SUMS)
|
||||
cp "$4" "$5" "${downloads}/SHA256SUMS" "${downloads}/dependencies.tsv" \
|
||||
"${TEMP_DIR}/${SCENARIO}/"
|
||||
touch "${TEMP_DIR}/installed"
|
||||
}
|
||||
|
||||
export -f curl pacman Fail
|
||||
export TEMP_DIR API FFMPEG OPUS SCENARIO TEST_RELEASE
|
||||
|
||||
ExpectRejectedSnapshot() {
|
||||
local description="$1" filter="$2"
|
||||
jq "${filter}" "${TEMP_DIR}/release.json" >"${TEMP_DIR}/invalid.json"
|
||||
if (AnyLinuxDependencySnapshot "${TEMP_DIR}/manifest.txt" \
|
||||
"${TEMP_DIR}/invalid.json") >"${TEMP_DIR}/invalid.log" 2>&1; then
|
||||
Fail "accepted ${description}"
|
||||
fi
|
||||
}
|
||||
|
||||
(AnyLinuxDependencySnapshot "${TEMP_DIR}/manifest.txt" \
|
||||
"${TEMP_DIR}/release.json") >"${TEMP_DIR}/snapshot.tsv" ||
|
||||
Fail 'rejected valid dependency snapshot'
|
||||
printf '%s\t%s\t%s\n' \
|
||||
"${FFMPEG_HASH}" "${FFMPEG}" "${API}/assets/10" \
|
||||
"${OPUS_HASH}" "${OPUS}" "${API}/assets/20" \
|
||||
>"${TEMP_DIR}/expected-snapshot.tsv"
|
||||
cmp "${TEMP_DIR}/expected-snapshot.tsv" "${TEMP_DIR}/snapshot.tsv" ||
|
||||
Fail 'incorrect dependency snapshot'
|
||||
|
||||
ExpectRejectedSnapshot 'missing asset' '.assets |= .[1:]'
|
||||
ExpectRejectedSnapshot 'duplicate asset' '.assets += [.assets[0]]'
|
||||
ExpectRejectedSnapshot 'absent SHA256' '.assets[0].digest = null'
|
||||
ExpectRejectedSnapshot 'malformed SHA256' '.assets[0].digest = "sha256:bad"'
|
||||
ExpectRejectedSnapshot 'foreign asset URL' '.assets[0].url = "https://example.org/archive"'
|
||||
ExpectRejectedSnapshot 'fractional asset ID' '.assets[0].id = 10.5'
|
||||
ExpectRejectedSnapshot 'unuploaded asset' '.assets[0].state = "starter"'
|
||||
ExpectRejectedSnapshot 'different release' '.tag_name = "other"'
|
||||
|
||||
for invalid_manifest in '../ffmpeg-mini-x86_64.pkg.tar.zst' \
|
||||
"${FFMPEG} extra" "${FFMPEG}"$'\n'"${FFMPEG}" ''; do
|
||||
printf '%s\n' "${invalid_manifest}" >"${TEMP_DIR}/invalid-manifest.txt"
|
||||
if (AnyLinuxDependencySnapshot "${TEMP_DIR}/invalid-manifest.txt" \
|
||||
"${TEMP_DIR}/release.json") >"${TEMP_DIR}/invalid.log" 2>&1; then
|
||||
Fail 'accepted unsafe, duplicate, or empty package allowlist'
|
||||
fi
|
||||
done
|
||||
|
||||
for SCENARIO in success replaced corrupted download-failure metadata-failure invalid-metadata; do
|
||||
TEST_RELEASE="${TEMP_DIR}/release.json"
|
||||
if [[ "${SCENARIO}" == invalid-metadata ]]; then
|
||||
# A valid first row must not allow downloads when a later row is invalid.
|
||||
jq '.assets[1].digest = null' "${TEMP_DIR}/release.json" >"${TEMP_DIR}/invalid.json"
|
||||
TEST_RELEASE="${TEMP_DIR}/invalid.json"
|
||||
fi
|
||||
mkdir "${TEMP_DIR}/${SCENARIO}"
|
||||
: >"${TEMP_DIR}/calls"
|
||||
if GH_TOKEN=fixture-token bash -Eeuo pipefail -c '
|
||||
source "$1/project/scripts/install_anylinux_dependencies.sh"
|
||||
AnyLinuxDependencyRequireContainer() { :; }
|
||||
AnyLinuxDependenciesMain
|
||||
' _ "${TEMP_DIR}" \
|
||||
>"${TEMP_DIR}/${SCENARIO}.log" 2>&1; then
|
||||
[[ "${SCENARIO}" == success || "${SCENARIO}" == replaced ]] ||
|
||||
Fail "installed packages after ${SCENARIO}"
|
||||
[[ -f "${TEMP_DIR}/installed" ]]
|
||||
(cd "${TEMP_DIR}/${SCENARIO}" && sha256sum --check --strict SHA256SUMS)
|
||||
rm -- "${TEMP_DIR}/installed"
|
||||
else
|
||||
if [[ "${SCENARIO}" == success || "${SCENARIO}" == replaced ]]; then
|
||||
cat -- "${TEMP_DIR}/${SCENARIO}.log" >&2
|
||||
Fail "valid download failed: ${SCENARIO}"
|
||||
fi
|
||||
[[ ! -e "${TEMP_DIR}/installed" ]] || Fail 'installed unverified archives'
|
||||
fi
|
||||
case "${SCENARIO}" in
|
||||
success)
|
||||
[[ "$(wc -l <"${TEMP_DIR}/calls")" == 3 ]]
|
||||
grep -Fq "${API}/assets/10" "${TEMP_DIR}/calls" ;;
|
||||
replaced)
|
||||
[[ "$(wc -l <"${TEMP_DIR}/calls")" == 6 ]]
|
||||
grep -Fq "${API}/assets/11" "${TEMP_DIR}/calls"
|
||||
grep -Fq "${API}/assets/11" "${TEMP_DIR}/replaced/dependencies.tsv"
|
||||
grep -Fq "${API}/assets/21" "${TEMP_DIR}/replaced/dependencies.tsv" ;;
|
||||
corrupted)
|
||||
grep -Fq 'dependency checksum verification failed' "${TEMP_DIR}/${SCENARIO}.log" ;;
|
||||
download-failure)
|
||||
[[ "$(wc -l <"${TEMP_DIR}/calls")" == 6 ]]
|
||||
grep -Fq 'failed after 3 snapshots' "${TEMP_DIR}/${SCENARIO}.log" ;;
|
||||
metadata-failure|invalid-metadata)
|
||||
[[ "$(wc -l <"${TEMP_DIR}/calls")" == 1 ]] ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if (id() { printf '1000\n'; }; AnyLinuxDependencyRequireContainer) \
|
||||
>"${TEMP_DIR}/guard.log" 2>&1; then
|
||||
Fail 'container/root installation guard was bypassed'
|
||||
fi
|
||||
|
||||
printf 'AnyLinux dependency tests passed\n'
|
||||
@@ -628,6 +628,13 @@ TEST(OpenSlQueueTest, GetStatePollsWhilePlaybackReleasesBuffers) {
|
||||
probe.cv.wait_for(lock, 2s, [&probe] { return probe.calls == 1; }));
|
||||
}
|
||||
|
||||
// A freshly spawned thread is not guaranteed to have been scheduled yet, so
|
||||
// wait for the first concurrent poll before asking the poller to stop.
|
||||
const auto poll_deadline = std::chrono::steady_clock::now() + 2s;
|
||||
while (polls.load(std::memory_order_relaxed) == 0 &&
|
||||
std::chrono::steady_clock::now() < poll_deadline) {
|
||||
std::this_thread::yield();
|
||||
}
|
||||
stop_polling.store(true, std::memory_order_relaxed);
|
||||
poller.join();
|
||||
EXPECT_GT(polls.load(std::memory_order_relaxed), 0);
|
||||
|
||||
@@ -41,18 +41,26 @@ class FakeHttpClient final : public HttpClient {
|
||||
class BrowserTrackerServiceTest : public testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
// The service refuses to hold credentials in a group- or world-writable
|
||||
// directory, so the directories these tests create must be private
|
||||
// whatever umask the developer or CI runner happens to use.
|
||||
previous_umask_ = umask(077);
|
||||
root_ = std::filesystem::temp_directory_path() /
|
||||
("mocktail-browser-tracker-" + std::to_string(getpid()) + "-" +
|
||||
::testing::UnitTest::GetInstance()->current_test_info()->name());
|
||||
std::filesystem::remove_all(root_);
|
||||
}
|
||||
void TearDown() override { std::filesystem::remove_all(root_); }
|
||||
void TearDown() override {
|
||||
std::filesystem::remove_all(root_);
|
||||
umask(previous_umask_);
|
||||
}
|
||||
|
||||
std::filesystem::path Storage() const {
|
||||
return root_ / "appData/LocalStorage/appStorage.json";
|
||||
}
|
||||
std::filesystem::path Cookies() const { return root_ / "roblox.cookie"; }
|
||||
std::filesystem::path root_;
|
||||
mode_t previous_umask_ = 0;
|
||||
};
|
||||
|
||||
TEST_F(BrowserTrackerServiceTest, ReusesValidStoredIdentityWithoutNetwork) {
|
||||
@@ -171,6 +179,58 @@ TEST_F(BrowserTrackerServiceTest,
|
||||
EXPECT_FALSE(std::filesystem::exists(Storage()));
|
||||
}
|
||||
|
||||
TEST_F(BrowserTrackerServiceTest, CreatesPrivateStorageDirectoryUnderAnyUmask) {
|
||||
std::filesystem::create_directories(root_);
|
||||
ASSERT_EQ(chmod(root_.c_str(), 0700), 0);
|
||||
std::ofstream(Cookies()) << ".ROBLOSECURITY=secret";
|
||||
FakeHttpClient http;
|
||||
http.response = {true,
|
||||
200,
|
||||
R"({"browserTrackerId":987654321})",
|
||||
"",
|
||||
{"Set-Cookie: RBXEventTrackerV2="
|
||||
"CreateDate=1&rbxid=2&browserid=987654321; Path=/"}};
|
||||
|
||||
// A permissive umask must not widen the directories the service creates for
|
||||
// its own credentials; it refuses to use any that are group or world
|
||||
// writable.
|
||||
const mode_t previous_umask = umask(0);
|
||||
const BrowserTrackerResult result =
|
||||
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
|
||||
umask(previous_umask);
|
||||
|
||||
ASSERT_TRUE(result) << result.error;
|
||||
struct stat storage_parent = {};
|
||||
ASSERT_EQ(stat(Storage().parent_path().c_str(), &storage_parent), 0);
|
||||
EXPECT_EQ(storage_parent.st_mode & 0777, 0700U);
|
||||
}
|
||||
|
||||
TEST_F(BrowserTrackerServiceTest,
|
||||
CreatesUsableStorageDirectoryUnderRestrictiveUmask) {
|
||||
std::filesystem::create_directories(root_);
|
||||
ASSERT_EQ(chmod(root_.c_str(), 0700), 0);
|
||||
std::ofstream(Cookies()) << ".ROBLOSECURITY=secret";
|
||||
FakeHttpClient http;
|
||||
http.response = {true,
|
||||
200,
|
||||
R"({"browserTrackerId":987654321})",
|
||||
"",
|
||||
{"Set-Cookie: RBXEventTrackerV2="
|
||||
"CreateDate=1&rbxid=2&browserid=987654321; Path=/"}};
|
||||
|
||||
// mkdir applies the umask to its mode argument, so a umask that strips owner
|
||||
// bits would otherwise leave the service's own directory unwritable.
|
||||
const mode_t previous_umask = umask(0200);
|
||||
const BrowserTrackerResult result =
|
||||
BrowserTrackerService(http).EnsureInitialized(Storage(), Cookies(), true);
|
||||
umask(previous_umask);
|
||||
|
||||
ASSERT_TRUE(result) << result.error;
|
||||
struct stat storage_parent = {};
|
||||
ASSERT_EQ(stat(Storage().parent_path().c_str(), &storage_parent), 0);
|
||||
EXPECT_EQ(storage_parent.st_mode & 0777, 0700U);
|
||||
}
|
||||
|
||||
TEST_F(BrowserTrackerServiceTest, RejectsCorruptStoredIdentityFailClosed) {
|
||||
std::filesystem::create_directories(Storage().parent_path());
|
||||
std::ofstream(Storage()) << R"({"BrowserTrackerId":"0"})";
|
||||
|
||||
@@ -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}" ||
|
||||
@@ -86,34 +114,58 @@ grep -Fq 'mz_stream_tell(unzGetStream(archive))' "${ANDROID_STUB}" ||
|
||||
Fail 'Android assets do not support the minizip-ng offset API'
|
||||
|
||||
for expected in \
|
||||
'ubuntu:26.04' \
|
||||
'fedora:44' \
|
||||
'archlinux:base-devel' \
|
||||
'minizip-ng-compat-devel' \
|
||||
'-G DEB' \
|
||||
'-G RPM' \
|
||||
'makepkg --dir' \
|
||||
'cron:' \
|
||||
'APPIMAGE_FORMAT=anylinux' \
|
||||
'quick-sharun' \
|
||||
'./scripts/install_anylinux_dependencies.sh' \
|
||||
'--env GH_TOKEN' \
|
||||
'MOCKTAIL_ANYLINUX_SYSTEM_INSTALL=1' \
|
||||
'--appimage-extract-and-run mocktail_updater status' \
|
||||
'Mocktail-x86_64.AppImage' \
|
||||
'Nightcap-nightly-x86_64.AppImage' \
|
||||
'github-actions[bot]' \
|
||||
'gh release upload continuous'; do
|
||||
grep -Fq -- "${expected}" "${WORKFLOW}" ||
|
||||
Fail "native package workflow is missing: ${expected}"
|
||||
Fail "nightly AppImage workflow is missing: ${expected}"
|
||||
done
|
||||
|
||||
grep -Fq 'paths-ignore:' "${WORKFLOW}" ||
|
||||
Fail 'native package workflow has no path filters'
|
||||
grep -Fq "'assets/screenshots/**'" "${WORKFLOW}" ||
|
||||
Fail 'native packages rebuild for screenshot-only changes'
|
||||
if grep -Fq "'packaging/flatpak/**'" "${WORKFLOW}"; then
|
||||
Fail 'native packages ignore Flatpak changes required by Pages publication'
|
||||
fi
|
||||
if grep -Fq "'site/**'" "${WORKFLOW}"; then
|
||||
Fail 'native packages ignore website changes required by Pages publication'
|
||||
fi
|
||||
# The nightly runs on a schedule and on demand. Per-commit triggers would
|
||||
# rebuild a full AppImage for every push and pull request. The DEB, RPM and
|
||||
# pacman packages are built by the Makefile but are not published from here;
|
||||
# upstream ships those for unmodified Mocktail.
|
||||
for unexpected in \
|
||||
'push:' \
|
||||
'pull_request:' \
|
||||
'-G DEB' \
|
||||
'-G RPM' \
|
||||
'makepkg --dir'; do
|
||||
if grep -Fq -- "${unexpected}" "${WORKFLOW}"; then
|
||||
Fail "nightly AppImage workflow should not contain: ${unexpected}"
|
||||
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'
|
||||
|
||||
@@ -51,6 +51,9 @@ class TemporaryDirectory final {
|
||||
void Write(const std::filesystem::path& path, std::string_view contents) {
|
||||
std::filesystem::create_directories(path.parent_path());
|
||||
std::ofstream(path, std::ios::binary) << contents;
|
||||
// Approval artifacts are rejected when group or world writable, which a
|
||||
// permissive umask would otherwise leave them.
|
||||
chmod(path.c_str(), 0600);
|
||||
}
|
||||
|
||||
std::string ReadFile(const std::filesystem::path& path) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||
#include "runtime/performance_policy.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
@@ -5,6 +6,7 @@
|
||||
#define JSON_NOEXCEPTION 1
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
@@ -228,22 +230,50 @@ TEST(PerformancePolicyTest, ComposesFrameRateBeforePerformanceMode) {
|
||||
EXPECT_EQ(parsed.at("FFlagLuaAppDefaultHttpRetry"), "False");
|
||||
}
|
||||
|
||||
TEST(PerformancePolicyTest, RejectsConflictingOrMalformedOverrides) {
|
||||
TEST(PerformancePolicyTest, KeepsUserOverridesThatConflictWithPreset) {
|
||||
std::string merged;
|
||||
std::string error;
|
||||
std::vector<std::string> kept;
|
||||
ASSERT_TRUE(MergePerformanceClientSettingsOverrides(
|
||||
{true, 14},
|
||||
R"({"FIntOcclusionWorkerThreadCount":"2","FFlagMovePrerenderV2":true,)"
|
||||
R"("FIntDebugForceMSAASamples":4})",
|
||||
&merged, &error, &kept))
|
||||
<< error;
|
||||
const nlohmann::json parsed = nlohmann::json::parse(merged);
|
||||
EXPECT_EQ(parsed.at("FIntOcclusionWorkerThreadCount"), "2");
|
||||
EXPECT_EQ(parsed.at("FFlagMovePrerenderV2"), true);
|
||||
EXPECT_EQ(parsed.at("FIntDebugForceMSAASamples"), 4);
|
||||
EXPECT_EQ(parsed.at("FFlagFastGPULightGrid"), "True");
|
||||
EXPECT_EQ(kept, (std::vector<std::string>{"FIntOcclusionWorkerThreadCount",
|
||||
"FFlagMovePrerenderV2",
|
||||
"FIntDebugForceMSAASamples"}));
|
||||
|
||||
PerformancePolicy latency =
|
||||
ParsePerformancePolicy("true", "0", "auto", "latency");
|
||||
latency.physical_core_count = 14;
|
||||
kept.clear();
|
||||
ASSERT_TRUE(MergePerformanceClientSettingsOverrides(
|
||||
latency, R"({"DFIntSimMidPhaseContactPipelineBatchSize":"64"})", &merged,
|
||||
&error, &kept))
|
||||
<< error;
|
||||
EXPECT_EQ(nlohmann::json::parse(merged)
|
||||
.at("DFIntSimMidPhaseContactPipelineBatchSize"),
|
||||
"64");
|
||||
EXPECT_EQ(kept, (std::vector<std::string>{
|
||||
"DFIntSimMidPhaseContactPipelineBatchSize"}));
|
||||
|
||||
kept.clear();
|
||||
ASSERT_TRUE(MergeRuntimeClientSettingsOverrides(
|
||||
ParseFrameRatePolicy(""), {true, 14},
|
||||
R"({"FIntOcclusionWorkerThreadCount":"2"})", &merged, &error, &kept))
|
||||
<< error;
|
||||
EXPECT_EQ(kept, (std::vector<std::string>{"FIntOcclusionWorkerThreadCount"}));
|
||||
}
|
||||
|
||||
TEST(PerformancePolicyTest, RejectsMalformedOverrides) {
|
||||
std::string merged = "unchanged";
|
||||
std::string error;
|
||||
EXPECT_FALSE(MergePerformanceClientSettingsOverrides(
|
||||
{true, 14}, R"({"FIntOcclusionWorkerThreadCount":"2"})", &merged,
|
||||
&error));
|
||||
EXPECT_EQ(merged, "unchanged");
|
||||
EXPECT_NE(error.find("FIntOcclusionWorkerThreadCount"), std::string::npos);
|
||||
|
||||
error.clear();
|
||||
EXPECT_FALSE(MergePerformanceClientSettingsOverrides(
|
||||
{true, 14}, R"({"FFlagMovePrerenderV2":true})", &merged, &error));
|
||||
EXPECT_EQ(merged, "unchanged");
|
||||
EXPECT_NE(error.find("FFlagMovePrerenderV2"), std::string::npos);
|
||||
|
||||
error.clear();
|
||||
EXPECT_FALSE(MergePerformanceClientSettingsOverrides({true, 14}, "not-json",
|
||||
&merged, &error));
|
||||
EXPECT_EQ(merged, "unchanged");
|
||||
@@ -254,16 +284,6 @@ TEST(PerformancePolicyTest, RejectsConflictingOrMalformedOverrides) {
|
||||
&merged, &error));
|
||||
EXPECT_EQ(merged, "unchanged");
|
||||
EXPECT_NE(error.find("JSON object"), std::string::npos);
|
||||
|
||||
PerformancePolicy latency =
|
||||
ParsePerformancePolicy("true", "0", "auto", "latency");
|
||||
latency.physical_core_count = 14;
|
||||
error.clear();
|
||||
EXPECT_FALSE(MergePerformanceClientSettingsOverrides(
|
||||
latency, R"({"DFIntSimMidPhaseContactPipelineBatchSize":"64"})", &merged,
|
||||
&error));
|
||||
EXPECT_NE(error.find("DFIntSimMidPhaseContactPipelineBatchSize"),
|
||||
std::string::npos);
|
||||
}
|
||||
|
||||
TEST(PerformancePolicyTest, HostAudioMenuUsesBridgedDeviceQueries) {
|
||||
|
||||
@@ -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,11 +233,241 @@ 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, 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,
|
||||
0.0f, 0}))
|
||||
.dispatched());
|
||||
ASSERT_TRUE(
|
||||
router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, 2000.0f,
|
||||
0.0f, 0}))
|
||||
.dispatched());
|
||||
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(), 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
|
||||
// while the display scale is not, which is the case where the guest-surface
|
||||
// scale actually changes a look delta.
|
||||
constexpr platform::WindowResizedEvent kFractionalScalingViewport{
|
||||
1624, 811, 1624, 811, 1.145833F};
|
||||
|
||||
TEST_F(RobloxInputRouterTest, ScalesLookDeltasUnlessRawMouseIsEnabled) {
|
||||
ASSERT_EQ(router_.HandleEvent(Event(kFractionalScalingViewport)).state,
|
||||
RobloxInputDispatchState::kStateUpdated);
|
||||
ASSERT_TRUE(
|
||||
router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, 10.0f,
|
||||
-6.0f, 0}))
|
||||
.dispatched());
|
||||
|
||||
ASSERT_EQ(probe_.mouse_moves.size(), 1U);
|
||||
EXPECT_NEAR(probe_.mouse_moves[0].delta_x, 8.727f, 0.01f);
|
||||
EXPECT_NEAR(probe_.mouse_moves[0].delta_y, -5.236f, 0.01f);
|
||||
}
|
||||
|
||||
TEST_F(RobloxInputRouterTest, RawMouseSendsLookDeltasUnscaled) {
|
||||
ASSERT_EQ(router_.HandleEvent(Event(kFractionalScalingViewport)).state,
|
||||
RobloxInputDispatchState::kStateUpdated);
|
||||
router_.SetRawMouseEnabled(true);
|
||||
ASSERT_TRUE(
|
||||
router_
|
||||
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, 10.0f,
|
||||
-6.0f, 0}))
|
||||
.dispatched());
|
||||
|
||||
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:
|
||||
// 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,
|
||||
ScalesMouseCoordinatesAfterDisplayScaleChange) {
|
||||
const RobloxInputDispatchResult resize = router_.HandleEvent(
|
||||
|
||||
@@ -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
|
||||
@@ -156,9 +156,9 @@ TEST(RuntimeConfigBootstrapTest,
|
||||
"startup.\n output_device: default",
|
||||
"# between boots; prefer the exact device name when it is unique.\n"
|
||||
" input_device: default",
|
||||
"# Boolean (default: false): publish Mocktail activity to Discord "
|
||||
"# Boolean (default: true): publish Nightcap activity to Discord "
|
||||
"Desktop.\n # This never signs in to Discord and never reads an "
|
||||
"account token.\n enabled: false",
|
||||
"account token.\n enabled: true",
|
||||
"# Boolean (default: true): show the current Roblox experience "
|
||||
"name.\n show_place_name: true",
|
||||
"# Boolean (default: true): show how long the current session has "
|
||||
@@ -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",
|
||||
@@ -288,6 +292,28 @@ TEST(RuntimeConfigFileTest, RejectsSymlinkAndOversizedConfiguration) {
|
||||
EXPECT_NE(loaded.error.find("no larger than 1 MiB"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(RuntimeConfigFileTest, RawMouseDefaultsOffAndLoadsFromYaml) {
|
||||
TemporaryDirectory defaulted_directory;
|
||||
const RuntimeConfigLoadResult defaulted = LoadRuntimeConfig(
|
||||
MapEnvironment(), defaulted_directory.Write(R"yaml(
|
||||
version: 1
|
||||
input:
|
||||
touch_enabled: false
|
||||
)yaml"));
|
||||
ASSERT_TRUE(defaulted) << defaulted.error;
|
||||
EXPECT_FALSE(defaulted.config.input_capabilities().raw_mouse);
|
||||
|
||||
TemporaryDirectory enabled_directory;
|
||||
const RuntimeConfigLoadResult enabled = LoadRuntimeConfig(
|
||||
MapEnvironment(), enabled_directory.Write(R"yaml(
|
||||
version: 1
|
||||
input:
|
||||
raw_mouse: true
|
||||
)yaml"));
|
||||
ASSERT_TRUE(enabled) << enabled.error;
|
||||
EXPECT_TRUE(enabled.config.input_capabilities().raw_mouse);
|
||||
}
|
||||
|
||||
TEST(RuntimeConfigFileTest, LoadsTypedDesktopAndGraphicsSettings) {
|
||||
TemporaryDirectory temporary;
|
||||
const std::filesystem::path file = temporary.Write(R"yaml(
|
||||
@@ -334,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");
|
||||
@@ -383,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) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||
#include "runtime/runtime_config.h"
|
||||
#include "runtime/system_proxy.h"
|
||||
|
||||
@@ -74,18 +75,23 @@ TEST(RuntimeConfigTest, UsesSupportedDefaults) {
|
||||
EXPECT_FALSE(config.fleasion_enabled());
|
||||
EXPECT_TRUE(config.fleasion_valid());
|
||||
EXPECT_FALSE(config.network_proxy().has_value());
|
||||
EXPECT_FALSE(config.discord_rpc().enabled);
|
||||
EXPECT_TRUE(config.discord_rpc().enabled);
|
||||
EXPECT_TRUE(config.discord_rpc().show_place_name);
|
||||
EXPECT_TRUE(config.discord_rpc().show_elapsed_time);
|
||||
EXPECT_TRUE(config.discord_rpc().join_enabled);
|
||||
EXPECT_TRUE(config.discord_rpc().public_servers_only);
|
||||
EXPECT_EQ(config.discord_rpc().join_button_label, "Join Server");
|
||||
EXPECT_EQ(config.discord_rpc().application_id, "1537088975720812655");
|
||||
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());
|
||||
@@ -120,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);
|
||||
|
||||
@@ -161,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()
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user