Compare commits

..
16 Commits
Author SHA1 Message Date
vii d982cd9506 Merge pull request #2 from CoderDayton/release/0.3.1
Release 0.3.1: packaging, input and shutdown fixes
2026-09-20 12:53:44 -05:00
vii 65adb3bddc fix(packaging): fail the release when a package glob matches nothing
An unmatched glob reached sha256sum and gh as literal text. The publish job
now collects the assets into an array and stops if a package is missing.
2026-09-20 12:33:26 -05:00
vii 47e23df2e0 docs(changelog): release 0.3.1 2026-09-20 12:31:39 -05:00
vii c0ff1842ee docs(changelog): record the packaging, input and shutdown fixes 2026-09-20 12:30:00 -05:00
vii e073947b91 fix(window): keep the pointer capture owner alive across shutdown
Cross-thread callers take a strong reference under a mutex, so a shutdown
racing SetGameSessionActive no longer frees the owner mid-call.
2026-09-20 12:26:39 -05:00
vii 66de662e5b fix(input): decide pointer capture from state, not coordinates
The router inferred relative mode from SDL reporting zero absolute
coordinates. X11 does; Wayland reports a position that accumulates the
deltas and leaves the window, so every camera-drag event took the
absolute branch and walked Roblox's cursor to the viewport edge.

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

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

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

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

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

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

No files matched your search

+16 -74
View File
@@ -1,8 +1,6 @@
name: Publish AUR packages
name: Check AUR packages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
@@ -13,8 +11,8 @@ concurrency:
cancel-in-progress: true
jobs:
publish:
name: Publish AUR packages
metadata:
name: Refresh AUR metadata
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
@@ -27,10 +25,9 @@ jobs:
shell: bash
run: |
set -Eeuo pipefail
package_dir="$GITHUB_WORKSPACE/packaging/aur/mocktail-git"
version="$(sed -n \
's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' \
CMakeLists.txt | head -n 1)"
package_dir="$GITHUB_WORKSPACE/packaging/aur/nightcap-git"
version="$(git describe --tags --abbrev=0 --match='v[0-9]*')"
version="${version#v}"
[[ "$version" =~ ^[0-9]+(\.[0-9]+)*$ ]]
pkgver="${version}.r$(git rev-list --count HEAD).g$(git rev-parse --short=7 HEAD)"
sed -i "s/^pkgver=.*/pkgver=${pkgver}/" "$package_dir/PKGBUILD"
@@ -39,76 +36,21 @@ jobs:
--volume "$package_dir:/pkg" \
archlinux:base-devel bash -Eeuo pipefail -c '
useradd --create-home builder
install -d -o builder -g builder /tmp/mocktail-git
install -d -o builder -g builder /tmp/nightcap-git
install -o builder -g builder -m 0644 \
/pkg/PKGBUILD /tmp/mocktail-git/PKGBUILD
/pkg/PKGBUILD /tmp/nightcap-git/PKGBUILD
runuser -u builder -- bash -c \
"cd /tmp/mocktail-git && makepkg --printsrcinfo" \
"cd /tmp/nightcap-git && makepkg --printsrcinfo" \
> /pkg/.SRCINFO
'
grep -Fqx "pkgver=${pkgver}" "$package_dir/PKGBUILD"
grep -Fqx $'\tpkgver = '"${pkgver}" "$package_dir/.SRCINFO"
printf 'AUR_PKGVER=%s\n' "$pkgver" >> "$GITHUB_ENV"
- name: Publish to the AUR
shell: bash
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
AUR_SSH_KEY_PASSPHRASE: ${{ secrets.AUR_SSH_KEY_PASSPHRASE }}
run: |
set -Eeuo pipefail
[[ -n "$AUR_SSH_PRIVATE_KEY" ]]
[[ -n "$AUR_SSH_KEY_PASSPHRASE" ]]
private_key="$RUNNER_TEMP/aur_ed25519"
askpass="$RUNNER_TEMP/aur_askpass"
known_hosts="$RUNNER_TEMP/aur_known_hosts"
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$private_key"
unset AUR_SSH_PRIVATE_KEY
chmod 0600 "$private_key"
printf '%s\n' \
'#!/usr/bin/env bash' \
'printf '\''%s\n'\'' "$AUR_SSH_KEY_PASSPHRASE"' \
> "$askpass"
chmod 0700 "$askpass"
eval "$(ssh-agent -s)"
trap 'ssh-agent -k >/dev/null; rm -f -- "$private_key" "$askpass"' EXIT
DISPLAY=:0 SSH_ASKPASS="$askpass" SSH_ASKPASS_REQUIRE=force \
ssh-add "$private_key" </dev/null
unset AUR_SSH_KEY_PASSPHRASE
ssh-keyscan -T 10 -t ed25519 aur.archlinux.org > "$known_hosts"
fingerprint="$(ssh-keygen -lf "$known_hosts" -E sha256 | awk '{print $2}')"
[[ "$fingerprint" == \
'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4' ]]
export GIT_SSH_COMMAND="ssh -F /dev/null -i $private_key \
-o BatchMode=yes -o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=$known_hosts"
publish_aur_package() {
local package_name="$1"
local commit_message="$2"
local source_dir="packaging/aur/$package_name"
local aur_dir="$RUNNER_TEMP/${package_name}-aur"
git clone --branch master --single-branch \
"ssh://aur@aur.archlinux.org/${package_name}.git" "$aur_dir"
install -m 0644 "$source_dir/PKGBUILD" "$aur_dir/PKGBUILD"
install -m 0644 "$source_dir/.SRCINFO" "$aur_dir/.SRCINFO"
git -C "$aur_dir" config user.name komaruworld
git -C "$aur_dir" config user.email rtysus228@gmail.com
git -C "$aur_dir" add PKGBUILD .SRCINFO
if git -C "$aur_dir" diff --cached --quiet; then
printf '%s is already published\n' "$package_name"
return
fi
git -C "$aur_dir" commit -m "$commit_message"
git -C "$aur_dir" push origin HEAD:master
}
publish_aur_package mocktail-git "Update to ${AUR_PKGVER}"
publish_aur_package mocktail 'make builds stable'
publish_aur_package mocktail-bin 'make builds stable'
- name: Upload AUR metadata
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Nightcap-AUR
path: packaging/aur/nightcap-git/.SRCINFO
if-no-files-found: error
retention-days: 14
+174 -4
View File
@@ -126,10 +126,153 @@ jobs:
if-no-files-found: error
retention-days: 14
deb:
name: DEB (Ubuntu 26.04)
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
submodules: recursive
- name: Build DEB
shell: bash
run: |
set -Eeuo pipefail
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
--env MOCKTAIL_PACKAGE_VERSION="$(./scripts/package_version.sh)" \
ubuntu:26.04 bash -Eeuo pipefail -c '
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install --yes --no-install-recommends \
build-essential cmake git ninja-build pkg-config lld fakeroot \
dpkg-dev file libsdl3-dev libsdl3-ttf-dev \
libcurl4-openssl-dev libssl-dev nlohmann-json3-dev \
libyaml-dev libelf-dev libminizip-dev libcapstone-dev \
libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
libutf8proc-dev libfontconfig1-dev libegl-dev \
libvulkan-dev libplacebo-dev zlib1g-dev libpng-dev
cmake -S . -B build-deb -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DMOCKTAIL_PACKAGE_NAME=nightcap \
-DMOCKTAIL_PACKAGE_VERSION="$MOCKTAIL_PACKAGE_VERSION" \
-DBUILD_TESTING=OFF
cmake --build build-deb --parallel 4
cpack --config build-deb/CPackConfig.cmake \
-G DEB -B dist/deb
dpkg-deb --info dist/deb/*.deb >/dev/null
'
- name: Upload DEB
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Nightcap-DEB
path: dist/deb/*.deb
if-no-files-found: error
retention-days: 14
rpm:
name: RPM (Fedora 44)
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
submodules: recursive
- name: Build RPM
shell: bash
run: |
set -Eeuo pipefail
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
--env MOCKTAIL_PACKAGE_VERSION="$(./scripts/package_version.sh)" \
fedora:44 bash -Eeuo pipefail -c '
dnf install --assumeyes \
gcc-c++ cmake git ninja-build pkgconf-pkg-config lld rpm-build \
SDL3-devel SDL3_ttf-devel libcurl-devel openssl-devel \
nlohmann-json-devel libyaml-devel elfutils-libelf-devel \
minizip-ng-compat-devel capstone-devel gtk4-devel libadwaita-devel \
webkitgtk6.0-devel utf8proc-devel fontconfig-devel \
libglvnd-devel vulkan-headers vulkan-loader-devel \
libplacebo-devel zlib-ng-compat-devel libpng-devel
cmake -S . -B build-rpm -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_INSTALL_LIBDIR=lib \
-DMOCKTAIL_PACKAGE_NAME=nightcap \
-DMOCKTAIL_PACKAGE_VERSION="$MOCKTAIL_PACKAGE_VERSION" \
-DBUILD_TESTING=OFF
cmake --build build-rpm --parallel 4
cpack --config build-rpm/CPackConfig.cmake \
-G RPM -B dist/rpm
rpm --query --package --info dist/rpm/*.rpm >/dev/null
'
- name: Upload RPM
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Nightcap-RPM
path: dist/rpm/*.rpm
if-no-files-found: error
retention-days: 14
arch:
name: Pacman package (Arch Linux)
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
submodules: recursive
- name: Build pacman package
shell: bash
run: |
set -Eeuo pipefail
version="$(./scripts/package_version.sh)"
sed -i "s/^pkgver=.*/pkgver=${version}/" packaging/arch/PKGBUILD
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
pacman -Syu --needed --noconfirm \
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
nlohmann-json libyaml libelf minizip capstone gtk4 \
libadwaita webkitgtk-6.0 libutf8proc fontconfig libpng \
libplacebo vulkan-headers vulkan-icd-loader libglvnd zlib
useradd --create-home builder
chown -R builder:builder /workspace
runuser -u builder -- env MOCKTAIL_SOURCE_DIR=/workspace \
makepkg --dir /workspace/packaging/arch \
--noconfirm --cleanbuild --force
install -d dist/arch
cp packaging/arch/*.pkg.tar.zst dist/arch/
pacman -Qp dist/arch/*.pkg.tar.zst >/dev/null
'
- name: Upload pacman package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Nightcap-Arch
path: dist/arch/*.pkg.tar.zst
if-no-files-found: error
retention-days: 14
publish:
name: Publish GitHub release
if: startsWith(github.ref, 'refs/tags/v')
needs: [appimage]
needs: [appimage, deb, rpm, arch]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
@@ -138,6 +281,13 @@ jobs:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download packages
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: 'Nightcap-*'
merge-multiple: true
path: release
- name: Download AppImage
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
@@ -152,7 +302,28 @@ jobs:
set -Eeuo pipefail
tag="${GITHUB_REF_NAME}"
mv release/Mocktail-x86_64.AppImage release/Nightcap-x86_64.AppImage
(cd release && sha256sum Nightcap-x86_64.AppImage > Nightcap-x86_64.AppImage.sha256)
# An unmatched glob would otherwise reach sha256sum and gh as its own
# literal text, so every package pattern has to match before upload.
shopt -s nullglob
assets=(Nightcap-x86_64.AppImage)
for glob in '*.deb' '*.rpm' '*.pkg.tar.zst'; do
matched=(release/$glob)
if [[ ${#matched[@]} -eq 0 ]]; then
echo "::error::no ${glob} package among the release artifacts"
exit 1
fi
assets+=("${matched[@]#release/}")
done
uploads=()
for asset in "${assets[@]}"; do
uploads+=("release/$asset" "release/$asset.sha256")
done
(
cd release
for asset in "${assets[@]}"; do
sha256sum "$asset" > "$asset.sha256"
done
)
./scripts/release_notes.sh "$tag" > release/notes.md
# The Flatpak workflow creates the same release; whichever job
# arrives second sees "already exists" and continues.
@@ -164,5 +335,4 @@ jobs:
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" \
--title "Nightcap $tag" --notes-file release/notes.md
gh release upload "$tag" --repo "$GITHUB_REPOSITORY" --clobber \
release/Nightcap-x86_64.AppImage \
release/Nightcap-x86_64.AppImage.sha256
"${uploads[@]}"
+3
View File
@@ -177,3 +177,6 @@ lsan.log*
# Test fixtures
!/third_party/mcpelauncher-linker/core/libziparchive/**/*.zip
!/third_party/mcpelauncher-linker/core/libziparchive/**/*.apk
# Working plans, not part of the shipped documentation
/docs/plans/
+57 -1
View File
@@ -6,6 +6,61 @@ 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
@@ -135,7 +190,8 @@ First Nightcap release, forked from komaruworld/mocktail.
- The AppImage build no longer fails on stale checksums for the slimmed
Arch packages.
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.0...HEAD
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.1...HEAD
[0.3.1]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.1
[0.3.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.0
[0.2.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.2.0
[0.1.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.1.0
+22 -17
View File
@@ -786,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."
@@ -812,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)
@@ -822,15 +828,14 @@ set(CPACK_RPM_PACKAGE_LICENSE "Apache-2.0")
set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games")
set(CPACK_RPM_PACKAGE_AUTOREQPROV ON)
if(MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail-nightly")
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "mocktail")
set(CPACK_DEBIAN_PACKAGE_REPLACES "mocktail")
set(CPACK_RPM_PACKAGE_CONFLICTS "mocktail")
else()
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "mocktail-nightly")
set(CPACK_DEBIAN_PACKAGE_REPLACES "mocktail-nightly")
set(CPACK_RPM_PACKAGE_CONFLICTS "mocktail-nightly")
endif()
# Every variant installs the same /usr/bin/mocktail and /usr/share/mocktail,
# so only one of them can be present at a time.
set(MOCKTAIL_PACKAGE_SIBLINGS ${MOCKTAIL_PACKAGE_NAMES})
list(REMOVE_ITEM MOCKTAIL_PACKAGE_SIBLINGS "${MOCKTAIL_PACKAGE_NAME}")
string(JOIN ", " MOCKTAIL_PACKAGE_CONFLICTS ${MOCKTAIL_PACKAGE_SIBLINGS})
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "${MOCKTAIL_PACKAGE_CONFLICTS}")
set(CPACK_DEBIAN_PACKAGE_REPLACES "${MOCKTAIL_PACKAGE_CONFLICTS}")
set(CPACK_RPM_PACKAGE_CONFLICTS "${MOCKTAIL_PACKAGE_CONFLICTS}")
include(CPack)
add_dependencies(mocktail all_stubs)
+25 -9
View File
@@ -61,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
@@ -116,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.
@@ -128,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
@@ -140,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
```
@@ -152,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
+13 -1
View File
@@ -44,7 +44,8 @@ Each slice is named after the call it times, on the thread that made it.
| `present` | `host present`: the host driver's present, nested inside the slice above | |
| `wait` | `vkAcquireNextImageKHR`, `vkAcquireNextImage2KHR`, `vkWaitForFences`, `vkWaitSemaphores`, `vkWaitSemaphoresKHR`, `vkQueueWaitIdle`, `vkDeviceWaitIdle` | |
| `submit` | `vkQueueSubmit`, `vkQueueSubmit2`, `vkQueueSubmit2KHR` | `submits` |
| `texture` | `etc2 decode`: CPU decode of ETC2 textures before a submit | `uploads`, `compressed_bytes`, `decoded_bytes` |
| `texture` | `etc2 decode`: CPU decode of ETC2 textures. On a device with a timeline semaphore this runs on the decode dispatcher, not the submitting thread | `uploads`, `compressed_bytes`, `decoded_bytes` |
| `texture` | `etc2 gather`: the part of an asynchronous upload that stays on the submitting thread. Absent when the decode ran inline | `uploads`, `compressed_bytes`, `decoded_bytes`, `ticket` |
| `pipeline` | `vkCreateGraphicsPipelines`, `vkCreateComputePipelines` | `count`, `cache` (1 when a pipeline cache was passed), `result` |
| `pipeline` | `vkCreateShaderModule` | `bytes`, `result` |
| `pipeline` | `vkCreatePipelineCache` | `initial_bytes`, `result` |
@@ -61,6 +62,17 @@ Run every scenario before and after the change, on the same build type
Record `graphics.frame_rate_limit` and `graphics.vsync` in the pull request,
because a frame cap hides frame time gains.
Record `~/.config/gamemode.ini` too, and do not change it between the before
and after runs. `desiredgov` there decides the CPU governor for the whole
session and therefore which engine pump mode is chosen, which moves process
CPU by more than most changes under test. See
[PERFORMANCE.md](PERFORMANCE.md).
Frame interval p99 varies by 2 ms or more between identical runs, so a
difference that size is not a result. Three runs per scenario is the minimum
for any frame-time claim; slice totals for a specific subsystem are far less
noisy and can be read from fewer.
| Scenario | How to start | What to do | What it shows |
| --- | --- | --- | --- |
| Home screen, cold | `mocktail --profile home-cold.json` with an empty shader cache (below) | Wait 30 s on the home screen, exit | First-load stutter: `pipeline` and `texture` slices |
+48 -2
View File
@@ -27,20 +27,48 @@ request. If GameMode narrowed it, Nightcap ends the request and prints:
```
The trade is that GameMode's governor and priority boost go with it. To keep
those and lose only the pinning, turn pinning off for every game on the host:
those and lose only the pinning, turn pinning off for every game on the host.
These are the settings to run with:
```ini
# ~/.config/gamemode.ini
[general]
desiredgov=performance
[cpu]
pin_cores=no
park_cores=no
```
`desiredgov` is the one to check first. The packaged default in
`/usr/share/gamemode/gamemode.ini` is already `performance`, but a value in
your own file replaces it, and `desiredgov=powersave` there means GameMode
clocks the CPU **down** for the whole session while still reporting
`[gamemode] performance request active`. The symptoms are a low CPU clock
under load, a GPU that never leaves its lower SM clocks, and the engine pump
falling back to `tpause` — which costs about 70% of a core instead of 3%,
because `sleep` is only chosen when the governor reads `performance`. On one
13900K host, correcting `powersave` to `performance` took the process from
177% CPU to 42% and the package from 70°C to 53-63°C, at the same frame rate.
GameMode reads `$XDG_CONFIG_HOME`, `/usr/share/gamemode` and `/etc`, in that
order. It does not read anything under Nightcap's own config directory, so this
file has to live in one of those three places. The daemon reads it when it
starts, so restart it after editing: `systemctl --user restart gamemoded`.
Restarting the daemon drops the session of any game already running; relaunch
the game for new settings to apply to it.
`gamemoded` is D-Bus activated through
`com.feralinteractive.GameMode.service`, so `systemctl --user stop gamemoded`
does not hold — the next client request starts it again within seconds. To
measure with GameMode genuinely off, mask it:
```bash
systemctl --user mask gamemoded # and --unmask afterwards
```
The governor switch runs through polkit. Debian and Ubuntu only allow it for
members of the `gamemode` group; without that, the daemon's journal shows
`cpugovctl set performance: Not authorized` and the governor stays put while
@@ -59,9 +87,20 @@ to the affinity. `off` never contacts the daemon.
To see which CPUs the process may use:
```bash
taskset -pc $(pgrep -x mocktail)
pid=$(pgrep -x Main)
for t in /proc/$pid/task/*; do taskset -pc "${t##*/}"; done
```
Two traps here. The engine process reports its name as `Main`, not
`mocktail`; `pgrep -x mocktail` finds only the launcher, which sits near 0%
CPU and tells you nothing. And `taskset -p` reads one thread, not the whole
process, so a single call reports the main thread's mask and says nothing
about the workers — which is where the game's load actually is.
For the same reason, `ps -L -o psr=` shows which core each thread is running
on right now, not which cores it is allowed on. On a hybrid CPU the scheduler
preferring P-cores for runnable work is normal and is not a mask.
## The main-thread pump
Roblox posts its per-frame main-thread step at a time of its own choosing,
@@ -99,6 +138,13 @@ threads while textures load, which shows up as a burst rather than a steady
load. The enlarged textures then stay in GPU memory for the session and cost
extra sampling bandwidth on every frame that uses them.
That decode does not run on the thread that submits the frame. On a device
with a timeline semaphore, uploads decode on a background dispatcher and the
submit carries a semaphore wait, so the GPU waits for the texture instead of
the render thread. In the trace this is the `etc2 decode` slice on its own
thread, with only a negligible `etc2 gather` left on the submitting one. A
device without that feature decodes inline as before.
Set it to `1` to turn upscaling off:
```bash
@@ -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 {
@@ -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;
};
+8 -5
View File
@@ -1,12 +1,14 @@
# Maintainer: komaruworld <rtysus228@gmail.com>
# Maintainer: vii <coderdayton14@gmail.com>
pkgname=mocktail
pkgver=1.0.3
pkgrel=2
pkgname=nightcap
pkgver=0.3.1
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
url='https://github.com/CoderDayton/nightcap'
license=('Apache-2.0')
provides=('mocktail')
conflicts=('mocktail')
depends=(
'capstone'
'curl'
@@ -16,6 +18,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libutf8proc'
'libyaml'
'minizip'
@@ -1,8 +1,8 @@
pkgbase = mocktail-bin
pkgbase = nightcap-bin
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux (prebuilt)
pkgver = 1.0.4
pkgver = 0.3.1
pkgrel = 1
url = https://github.com/komaruworld/mocktail
url = https://github.com/CoderDayton/nightcap
arch = x86_64
license = Apache-2.0
depends = capstone
@@ -17,6 +17,7 @@ pkgbase = mocktail-bin
depends = libelf
depends = libglvnd
depends = libplacebo
depends = libpng
depends = libsoup3
depends = libutf8proc
depends = libyaml
@@ -29,12 +30,14 @@ pkgbase = mocktail-bin
depends = zlib
optdepends = gamemode: Feral GameMode integration
provides = mocktail
provides = nightcap
conflicts = mocktail
conflicts = mocktail-git
noextract = mocktail-1.0.4-x86_64.pkg.tar.zst
conflicts = nightcap
conflicts = nightcap-git
noextract = nightcap-0.3.1-1-x86_64.pkg.tar.zst
options = !debug
options = !strip
source = mocktail-1.0.4-x86_64.pkg.tar.zst::https://github.com/komaruworld/mocktail/releases/download/1.0.4/mocktail-1.0.4-x86_64.pkg.tar.zst
sha256sums = d5cc7216e88fd2889369e8eb7fba4276b2746c2b084dce566aaaa7505bc2ffb7
source = nightcap-0.3.1-1-x86_64.pkg.tar.zst::https://github.com/CoderDayton/nightcap/releases/download/v0.3.1/nightcap-0.3.1-1-x86_64.pkg.tar.zst
sha256sums = SKIP
pkgname = mocktail-bin
pkgname = nightcap-bin
@@ -1,11 +1,11 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
# Maintainer: vii <coderdayton14 at gmail dot com>
pkgname=mocktail-bin
pkgver=1.0.4
pkgname=nightcap-bin
pkgver=0.3.1
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux (prebuilt)'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
url='https://github.com/CoderDayton/nightcap'
license=('Apache-2.0')
depends=(
'capstone'
@@ -20,6 +20,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libsoup3'
'libutf8proc'
'libyaml'
@@ -32,17 +33,18 @@ depends=(
'zlib'
)
optdepends=('gamemode: Feral GameMode integration')
provides=('mocktail')
conflicts=('mocktail' 'mocktail-git')
provides=('mocktail' 'nightcap')
conflicts=('mocktail' 'nightcap' 'nightcap-git')
options=('!debug' '!strip')
# Release asset names do not include the AUR package release.
_archive="mocktail-${pkgver}-${CARCH}.pkg.tar.zst"
source=("${_archive}::https://github.com/komaruworld/mocktail/releases/download/${pkgver}/${_archive}")
# The release asset carries the pacman package release built by release.yml.
_archive="nightcap-${pkgver}-1-${CARCH}.pkg.tar.zst"
source=("${_archive}::https://github.com/CoderDayton/nightcap/releases/download/v${pkgver}/${_archive}")
noextract=("${_archive}")
sha256sums=('d5cc7216e88fd2889369e8eb7fba4276b2746c2b084dce566aaaa7505bc2ffb7')
# Replace with the checksum published beside the release asset.
sha256sums=('SKIP')
package() {
bsdtar -xf "${srcdir}/${_archive}" -C "${pkgdir}" usr
install -Dm644 "${pkgdir}/usr/share/licenses/mocktail/LICENSE" \
install -Dm644 "${pkgdir}/usr/share/licenses/nightcap/LICENSE" \
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
@@ -1,8 +1,8 @@
pkgbase = mocktail
pkgbase = nightcap-git
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
pkgver = 1.0.4
pkgver = 0.3.1.r0.g0000000
pkgrel = 1
url = https://github.com/komaruworld/mocktail
url = https://github.com/CoderDayton/nightcap
arch = x86_64
license = Apache-2.0
makedepends = cmake>=3.20
@@ -24,6 +24,7 @@ pkgbase = mocktail
depends = libelf
depends = libglvnd
depends = libplacebo
depends = libpng
depends = libsoup3
depends = libutf8proc
depends = libyaml
@@ -35,11 +36,13 @@ pkgbase = mocktail
depends = webkitgtk-6.0
depends = zlib
optdepends = gamemode: Feral GameMode integration
conflicts = mocktail-bin
conflicts = mocktail-git
provides = mocktail
provides = nightcap
conflicts = mocktail
conflicts = nightcap
conflicts = nightcap-bin
options = !debug
options = !lto
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=1.0.4
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#branch=main
sha256sums = SKIP
pkgname = mocktail
pkgname = nightcap-git
@@ -1,11 +1,11 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
# Maintainer: vii <coderdayton14 at gmail dot com>
pkgname=mocktail-git
pkgver=1.0.2.r24.g9bdd737
pkgname=nightcap-git
pkgver=0.3.1.r0.g0000000
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
url='https://github.com/CoderDayton/nightcap'
license=('Apache-2.0')
depends=(
'capstone'
@@ -20,6 +20,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libsoup3'
'libutf8proc'
'libyaml'
@@ -41,31 +42,30 @@ makedepends=(
'vulkan-headers'
)
optdepends=('gamemode: Feral GameMode integration')
provides=('mocktail')
conflicts=('mocktail' 'mocktail-bin')
provides=('mocktail' 'nightcap')
conflicts=('mocktail' 'nightcap' 'nightcap-bin')
options=('!debug')
source=(
'mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main'
'nightcap::git+https://github.com/CoderDayton/nightcap.git#branch=main'
)
sha256sums=(
'SKIP'
)
pkgver() {
cd mocktail
cd nightcap
local version
version="$(sed -n \
's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' \
CMakeLists.txt | head -n 1)"
version="$(git describe --tags --abbrev=0 --match='v[0-9]*' 2>/dev/null)"
version="${version#v}"
printf '%s.r%s.g%s' \
"${version}" \
"${version:-0.0.0}" \
"$(git rev-list --count HEAD)" \
"$(git rev-parse --short=7 HEAD)"
}
prepare() {
cd mocktail
cd nightcap
sed -i \
's|add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)|find_package(VulkanHeaders CONFIG REQUIRED)|' \
@@ -73,7 +73,7 @@ prepare() {
}
build() {
cmake -S mocktail -B build -G Ninja \
cmake -S nightcap -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_INSTALL_LIBDIR=lib \
@@ -86,6 +86,6 @@ build() {
package() {
DESTDIR="${pkgdir}" cmake --install build
install -Dm644 mocktail/LICENSE \
install -Dm644 nightcap/LICENSE \
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
@@ -1,8 +1,8 @@
pkgbase = mocktail-git
pkgbase = nightcap
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
pkgver = 1.0.2.r24.g9bdd737
pkgver = 0.3.1
pkgrel = 1
url = https://github.com/komaruworld/mocktail
url = https://github.com/CoderDayton/nightcap
arch = x86_64
license = Apache-2.0
makedepends = cmake>=3.20
@@ -24,6 +24,7 @@ pkgbase = mocktail-git
depends = libelf
depends = libglvnd
depends = libplacebo
depends = libpng
depends = libsoup3
depends = libutf8proc
depends = libyaml
@@ -37,9 +38,11 @@ pkgbase = mocktail-git
optdepends = gamemode: Feral GameMode integration
provides = mocktail
conflicts = mocktail
conflicts = mocktail-bin
conflicts = nightcap-bin
conflicts = nightcap-git
options = !debug
source = mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main
options = !lto
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#tag=v0.3.1
sha256sums = SKIP
pkgname = mocktail-git
pkgname = nightcap
@@ -1,11 +1,11 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
# Maintainer: vii <coderdayton14 at gmail dot com>
pkgname=mocktail
pkgver=1.0.4
pkgname=nightcap
pkgver=0.3.1
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
url='https://github.com/CoderDayton/nightcap'
license=('Apache-2.0')
depends=(
'capstone'
@@ -20,6 +20,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libsoup3'
'libutf8proc'
'libyaml'
@@ -41,17 +42,18 @@ makedepends=(
'vulkan-headers'
)
optdepends=('gamemode: Feral GameMode integration')
conflicts=('mocktail-bin' 'mocktail-git')
provides=('mocktail')
conflicts=('mocktail' 'nightcap-bin' 'nightcap-git')
options=('!debug' '!lto')
source=(
"mocktail::git+https://github.com/komaruworld/mocktail.git#tag=${pkgver}"
"nightcap::git+https://github.com/CoderDayton/nightcap.git#tag=v${pkgver}"
)
sha256sums=(
'SKIP'
)
prepare() {
cd mocktail
cd nightcap
sed -i \
's|add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)|find_package(VulkanHeaders CONFIG REQUIRED)|' \
@@ -59,7 +61,7 @@ prepare() {
}
build() {
cmake -S mocktail -B build -G Ninja \
cmake -S nightcap -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_INSTALL_LIBDIR=lib \
@@ -72,6 +74,6 @@ build() {
package() {
DESTDIR="${pkgdir}" cmake --install build
install -Dm644 mocktail/LICENSE \
install -Dm644 nightcap/LICENSE \
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
@@ -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>
+2
View File
@@ -23,6 +23,7 @@
elfutils,
libglvnd,
libplacebo,
libpng,
libsoup_3,
libutf8proc,
libyaml,
@@ -92,6 +93,7 @@ stdenv.mkDerivation (finalAttrs: {
elfutils
libglvnd
libplacebo
libpng
libsoup_3
libutf8proc
libyaml
+3
View File
@@ -119,6 +119,7 @@ libyaml
fontconfig
minizip
libplacebo
libpng
util-linux'
INSTALL_ARGS='-S --needed --noconfirm'
;;
@@ -144,6 +145,7 @@ libyaml
fontconfig
minizip
libplacebo
libpng
util-linux'
if HostUsesMusl; then
PACKAGES="$PACKAGES gcompat"
@@ -172,6 +174,7 @@ libyaml
fontconfig
minizip
libplacebo
libpng
coreutils
findutils
util-linux'
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Modified by vii from komaruworld/mocktail. See README "About this fork".
# Copyright 2026 Mocktail Project Authors
# SPDX-License-Identifier: Apache-2.0
#
# Prints the native package version for the current ref. A release tag gives
# its own version; anything else gives 0.0.0, which sorts below every release.
set -Eeuo pipefail
ref="${1:-${GITHUB_REF_NAME:-}}"
version="${ref#v}"
if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
printf '%s\n' "${version}"
else
printf '0.0.0\n'
fi
+5
View File
@@ -22,6 +22,10 @@
<p>Flatpak</p>
<code>flatpak install --user https://coderdayton.github.io/nightcap/nightcap.flatpakref
flatpak run io.github.CoderDayton.nightcap</code>
<p>DEB, RPM and pacman</p>
<code>sudo apt install ./nightcap_&lt;version&gt;-1_amd64.deb
sudo dnf install ./nightcap-&lt;version&gt;-1.x86_64.rpm
sudo pacman -U nightcap-&lt;version&gt;-1-x86_64.pkg.tar.zst</code>
<p>Build from source</p>
<code>git clone --recurse-submodules https://github.com/CoderDayton/nightcap.git
cd nightcap &amp;&amp; make build
@@ -40,6 +44,7 @@ cd nightcap &amp;&amp; make build
<p class="links">
<a href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">AppImage</a>
<a href="Nightcap-x86_64.flatpak">Flatpak bundle</a>
<a href="https://github.com/CoderDayton/nightcap/releases/latest">DEB, RPM, pacman</a>
<a href="nightcap-flatpak.gpg">Signing key</a>
<a href="terms.html">Terms</a>
<a href="privacy.html">Privacy</a>
+139 -20
View File
@@ -11,6 +11,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <mutex>
#include <string>
#include <utility>
@@ -1300,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
@@ -1664,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 =
@@ -2143,14 +2261,11 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit(
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkQueueSubmit", "submit");
scope.Arg("submits", submit_count);
if (submits != nullptr) {
for (std::uint32_t index = 0; index < submit_count; ++index) {
State().etc2.PrepareSubmit(submits[index].pCommandBuffers,
submits[index].commandBufferCount);
}
}
Etc2SubmitRewrite rewrite;
const VkSubmitInfo* prepared =
PrepareEtc2Submit(submit_count, submits, &rewrite);
const VkResult result = State().text_overlay.QueueSubmit(
queue, submit_count, submits, fence, host_submit);
queue, submit_count, prepared, fence, host_submit);
observation.SetResult(result);
return result;
}
@@ -2168,9 +2283,11 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit2(
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkQueueSubmit2", "submit");
scope.Arg("submits", submit_count);
PrepareEtc2Submit2(submit_count, submits);
Etc2SubmitRewrite rewrite;
const VkSubmitInfo2* prepared =
PrepareEtc2Submit2(submit_count, submits, &rewrite);
const VkResult result = State().text_overlay.QueueSubmit2(
queue, submit_count, submits, fence, host_submit);
queue, submit_count, prepared, fence, host_submit);
observation.SetResult(result);
return result;
}
@@ -2188,9 +2305,11 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit2KHR(
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkQueueSubmit2KHR", "submit");
scope.Arg("submits", submit_count);
PrepareEtc2Submit2(submit_count, submits);
Etc2SubmitRewrite rewrite;
const VkSubmitInfo2* prepared =
PrepareEtc2Submit2(submit_count, submits, &rewrite);
const VkResult result = State().text_overlay.QueueSubmit2(
queue, submit_count, submits, fence,
queue, submit_count, prepared, fence,
reinterpret_cast<PFN_vkQueueSubmit2>(host_submit));
observation.SetResult(result);
return result;
+453 -77
View File
@@ -6,9 +6,11 @@
#include <algorithm>
#include <atomic>
#include <condition_variable>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <memory>
#include <mutex>
#include <thread>
@@ -157,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>
@@ -215,6 +224,29 @@ struct Staging {
// 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;
@@ -257,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};
@@ -327,6 +385,55 @@ bool CreateStaging(const HostDevice& dev, VkDeviceSize size, Staging* staging,
}
// 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);
@@ -509,7 +616,7 @@ struct VulkanEtc2Emulation::State {
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 =
@@ -745,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) {
@@ -775,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;
@@ -821,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 "
@@ -837,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);
@@ -872,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(
@@ -1035,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);
}
@@ -1048,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);
}
@@ -1061,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);
}
@@ -1111,6 +1445,7 @@ void VulkanEtc2Emulation::CmdCopyBufferToImage(
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());
}
@@ -1146,6 +1481,7 @@ void VulkanEtc2Emulation::CmdCopyBufferToImage2(
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();
@@ -1261,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;
@@ -1323,8 +1660,26 @@ void VulkanEtc2Emulation::PrepareSubmit(const VkCommandBuffer* command_buffers,
}
}
}
TraceScope trace_scope(work.empty() ? nullptr : ActiveProfileTrace(),
"etc2 decode", "texture");
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,
@@ -1377,89 +1732,110 @@ void VulkanEtc2Emulation::PrepareSubmit(const VkCommandBuffer* command_buffers,
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);
// 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) {
uploads.push_back(*upload);
}
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;
const VkDeviceSize compressed_layer = upload.compressed / upload.layers;
if (upload.source_span == copy.compressed) {
std::memcpy(source, copy.source, copy.compressed);
} else {
// Padded rows: gather the covered blocks into the packed layout the
// decoder expects.
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(source + layer * compressed_layer + row * packed_row,
copy.source + layer * upload.source_layer_stride +
row * upload.source_row_stride,
packed_row);
}
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);
}
}
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);
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);
}
}
}
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);
}
}
}
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<State::EmitJob> emit_jobs;
emit_jobs.reserve(copied_uploads.size());
for (const PendingUpload* upload : copied_uploads) {
emit_jobs.push_back({upload, scratch_source.data() + compressed_offset,
scratch_decoded.data() + decoded_offset});
compressed_offset += upload->compressed;
decoded_offset += upload->decoded;
}
state_->EmitBatch(std::move(emit_jobs), DecodeWorkerCount());
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);
+11 -6
View File
@@ -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:
@@ -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;
+14 -11
View File
@@ -550,13 +550,14 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseMotionLocked(
const float delta_x = transform.HostLogicalToGuestX(event.delta_x);
const float delta_y = transform.HostLogicalToGuestY(event.delta_y);
// Relative-mode motion reports zero absolute coordinates and a delta. The
// pointer holds its captured position while the delta passes through, so a
// long camera turn neither parks the reported position at the viewport edge
// The pointer holds its captured position while the delta passes through, so
// a long camera turn neither parks the reported position at the viewport edge
// nor swallows the reversal. Roblox draws its cursor at the held position
// when the capture ends, which matches where the host pointer is warped.
if (event.x == 0.0f && event.y == 0.0f &&
(delta_x != 0.0f || delta_y != 0.0f)) {
// Capture is read from the event, not inferred: only X11 zeroes the absolute
// coordinates, while Wayland reports a position that accumulates the deltas.
if (event.relative || (event.x == 0.0f && event.y == 0.0f &&
(delta_x != 0.0f || delta_y != 0.0f))) {
HoldPointerLocked(transform);
} else {
const float max_x = std::max(0.0F, transform.guest_width() - 1.0F);
@@ -595,9 +596,10 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseButtonLocked(
CoordinateTransform(snapshot_.viewport);
const float max_x = std::max(0.0F, transform.guest_width() - 1.0F);
const float max_y = std::max(0.0F, transform.guest_height() - 1.0F);
// Button events during pointer capture carry zero coordinates and must not
// move the held position.
if (event.x > 0.0f || event.y > 0.0f) {
// Button events during pointer capture must not move the held position.
// Their coordinates are zeroed on X11 and drifted on Wayland, so the capture
// flag decides.
if (!event.relative && (event.x > 0.0f || event.y > 0.0f)) {
mouse_x_ = std::clamp(transform.HostLogicalToGuestX(event.x), 0.0f, max_x);
mouse_y_ = std::clamp(transform.HostLogicalToGuestY(event.y), 0.0f, max_y);
pointer_anchored_ = true;
@@ -638,9 +640,10 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseWheelLocked(
}
const platform::SurfaceCoordinateTransform transform =
CoordinateTransform(snapshot_.viewport);
// Wheel events during pointer capture carry zero coordinates and must not
// move the held position.
if (event.mouse_x > 0.0f || event.mouse_y > 0.0f) {
// Wheel events during pointer capture must not move the held position.
// Their coordinates are zeroed on X11 and drifted on Wayland, so the capture
// flag decides.
if (!event.relative && (event.mouse_x > 0.0f || event.mouse_y > 0.0f)) {
const float max_x = std::max(0.0F, transform.guest_width() - 1.0F);
const float max_y = std::max(0.0F, transform.guest_height() - 1.0F);
mouse_x_ = std::clamp(transform.HostLogicalToGuestX(event.mouse_x), 0.0f,
+68 -30
View File
@@ -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;
+43 -9
View File
@@ -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();
+4
View File
@@ -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);
+5 -1
View File
@@ -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;
+68 -17
View File
@@ -8,13 +8,15 @@ set -Eeuo pipefail
readonly ROOT="${1:?source root is required}"
readonly CMAKE_FILE="${ROOT}/CMakeLists.txt"
readonly PKGBUILD="${ROOT}/packaging/arch/PKGBUILD"
readonly AUR_STABLE_PKGBUILD="${ROOT}/packaging/aur/mocktail/PKGBUILD"
readonly AUR_STABLE_SRCINFO="${ROOT}/packaging/aur/mocktail/.SRCINFO"
readonly AUR_BIN_PKGBUILD="${ROOT}/packaging/aur/mocktail-bin/PKGBUILD"
readonly AUR_BIN_SRCINFO="${ROOT}/packaging/aur/mocktail-bin/.SRCINFO"
readonly AUR_PKGBUILD="${ROOT}/packaging/aur/mocktail-git/PKGBUILD"
readonly AUR_SRCINFO="${ROOT}/packaging/aur/mocktail-git/.SRCINFO"
readonly AUR_STABLE_PKGBUILD="${ROOT}/packaging/aur/nightcap/PKGBUILD"
readonly AUR_STABLE_SRCINFO="${ROOT}/packaging/aur/nightcap/.SRCINFO"
readonly AUR_BIN_PKGBUILD="${ROOT}/packaging/aur/nightcap-bin/PKGBUILD"
readonly AUR_BIN_SRCINFO="${ROOT}/packaging/aur/nightcap-bin/.SRCINFO"
readonly AUR_PKGBUILD="${ROOT}/packaging/aur/nightcap-git/PKGBUILD"
readonly AUR_SRCINFO="${ROOT}/packaging/aur/nightcap-git/.SRCINFO"
readonly AUR_WORKFLOW="${ROOT}/.github/workflows/aur.yml"
readonly WORKFLOW="${ROOT}/.github/workflows/packages.yml"
readonly RELEASE_WORKFLOW="${ROOT}/.github/workflows/release.yml"
readonly README="${ROOT}/README.md"
readonly STUB_CMAKE="${ROOT}/stubs/CMakeLists.txt"
readonly ANDROID_STUB="${ROOT}/stubs/libandroid_stub.cc"
@@ -35,31 +37,39 @@ grep -Fq 'CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON' "${CMAKE_FILE}" ||
Fail 'DEB packages do not derive shared-library dependencies'
grep -Fq 'CPACK_RPM_PACKAGE_AUTOREQPROV ON' "${CMAKE_FILE}" ||
Fail 'RPM packages do not derive runtime requirements'
grep -Fq 'pkgname=mocktail' "${PKGBUILD}" ||
grep -Fq 'set(MOCKTAIL_PACKAGE_VERSION' "${CMAKE_FILE}" ||
Fail 'native package version cannot be set from the release tag'
grep -Fq 'https://github.com/CoderDayton/nightcap' "${CMAKE_FILE}" ||
Fail 'native packages point at the upstream project page'
grep -Fq 'pkgname=nightcap' "${PKGBUILD}" ||
Fail 'Arch package name is not stable'
grep -Fq "provides=('mocktail')" "${PKGBUILD}" ||
Fail 'Arch package does not provide the upstream package name'
grep -Fq "conflicts=('mocktail')" "${PKGBUILD}" ||
Fail 'Arch package can be installed beside upstream Mocktail'
grep -Fq "'sdl3>=3.4'" "${PKGBUILD}" ||
Fail 'Arch package does not enforce the SDL minimum'
grep -Fq "'libplacebo'" "${PKGBUILD}" ||
Fail 'Arch package does not declare the graphics composition dependency'
grep -Fq 'pkgname=mocktail-git' "${AUR_PKGBUILD}" ||
grep -Fq 'pkgname=nightcap-git' "${AUR_PKGBUILD}" ||
Fail 'AUR VCS package name is not stable'
grep -Fq 'pkgname=mocktail' "${AUR_STABLE_PKGBUILD}" ||
grep -Fq 'pkgname=nightcap' "${AUR_STABLE_PKGBUILD}" ||
Fail 'AUR source package name is not stable'
grep -Fq '#tag=${pkgver}' "${AUR_STABLE_PKGBUILD}" ||
grep -Fq '#tag=v${pkgver}' "${AUR_STABLE_PKGBUILD}" ||
Fail 'AUR source package does not pin its Git tag'
grep -Fq "'vulkan-headers'" "${AUR_STABLE_PKGBUILD}" ||
Fail 'AUR source package does not use the packaged Vulkan headers'
grep -Fq 'pkgbase = mocktail' "${AUR_STABLE_SRCINFO}" ||
grep -Fq 'pkgbase = nightcap' "${AUR_STABLE_SRCINFO}" ||
Fail 'AUR source package has no generated .SRCINFO metadata'
grep -Fq 'pkgname=mocktail-bin' "${AUR_BIN_PKGBUILD}" ||
grep -Fq 'pkgname=nightcap-bin' "${AUR_BIN_PKGBUILD}" ||
Fail 'AUR binary package name is not stable'
grep -Fq 'pkgbase = mocktail-bin' "${AUR_BIN_SRCINFO}" ||
grep -Fq 'pkgbase = nightcap-bin' "${AUR_BIN_SRCINFO}" ||
Fail 'AUR binary package has no generated .SRCINFO metadata'
grep -Fq -- \
"'mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main'" \
"'nightcap::git+https://github.com/CoderDayton/nightcap.git#branch=main'" \
"${AUR_PKGBUILD}" ||
Fail 'AUR package does not build from the upstream Git repository'
grep -Fq "provides=('mocktail')" "${AUR_PKGBUILD}" ||
Fail 'AUR package does not build from this project Git repository'
grep -Fq "provides=('mocktail' 'nightcap')" "${AUR_PKGBUILD}" ||
Fail 'AUR VCS package does not provide the stable package name'
grep -Fq "'vulkan-headers'" "${AUR_PKGBUILD}" ||
Fail 'AUR VCS package does not use the packaged Vulkan headers'
@@ -76,8 +86,26 @@ grep -Fq -- \
'-DMOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=/usr/share/mocktail/metadata/' \
"${AUR_PKGBUILD}" ||
Fail 'AUR package embeds a build-tree compatibility manifest path'
grep -Fq 'pkgbase = mocktail-git' "${AUR_SRCINFO}" ||
grep -Fq 'pkgbase = nightcap-git' "${AUR_SRCINFO}" ||
Fail 'AUR package has no generated .SRCINFO metadata'
grep -Fq 'git describe' "${AUR_PKGBUILD}" ||
Fail 'AUR VCS package does not version from the release tag'
grep -Fq 'git describe' "${AUR_WORKFLOW}" ||
Fail 'AUR workflow does not version from the release tag'
for pkgbuild in "${PKGBUILD}" "${AUR_STABLE_PKGBUILD}" "${AUR_BIN_PKGBUILD}"; do
grep -Fqx 'pkgver=0.3.1' "${pkgbuild}" ||
Fail "package version does not match the release being prepared: ${pkgbuild}"
done
# Nightcap keeps its AUR recipes in the tree. Nothing publishes them.
for unexpected in \
'aur.archlinux.org' \
'AUR_SSH_PRIVATE_KEY' \
'push origin'; do
if grep -Fq -- "${unexpected}" "${AUR_WORKFLOW}"; then
Fail "AUR workflow should not publish: ${unexpected}"
fi
done
grep -Fq 'LINKER:--no-as-needed' "${STUB_CMAKE}" ||
Fail 'system shims can lose their host libc dependencies'
grep -Fq 'MOCKTAIL_MINIZIP_HAS_STREAM_TELL' "${STUB_CMAKE}" ||
@@ -116,6 +144,29 @@ for unexpected in \
fi
done
for expected in \
'-G DEB' \
'-G RPM' \
'makepkg --dir' \
'-DMOCKTAIL_PACKAGE_NAME=nightcap' \
'-DMOCKTAIL_PACKAGE_VERSION=' \
'Nightcap-x86_64.AppImage' \
'gh release upload'; do
grep -Fq -- "${expected}" "${RELEASE_WORKFLOW}" ||
Fail "release workflow is missing: ${expected}"
done
# Nightcap publishes its packages on the release page only. Pushing them to
# APT, RPM or AUR repositories is out of scope for this workflow.
for unexpected in \
'createrepo' \
'reprepro' \
'aur.archlinux.org'; do
if grep -Fq -- "${unexpected}" "${RELEASE_WORKFLOW}"; then
Fail "release workflow should not publish to a repository: ${unexpected}"
fi
done
grep -Fq 'Ubuntu 26.04+' "${README}" ||
Fail 'README has no Ubuntu source dependency guide'
grep -Fq 'Arch Linux' "${README}" ||
+61
View File
@@ -277,6 +277,67 @@ TEST_F(RobloxInputRouterTest, HoldsPointerPositionDuringRelativeMotion) {
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
+79
View File
@@ -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
+275
View File
@@ -8,6 +8,8 @@
#include <array>
#include <cstdint>
#include <cstring>
#include <mutex>
#include <vector>
namespace mocktail::graphics {
namespace {
@@ -206,10 +208,67 @@ VKAPI_ATTR void VKAPI_CALL FakeFreeMemory(VkDevice, VkDeviceMemory,
const VkAllocationCallbacks*) {
++g_free_memory_calls;
}
// Records the host-write barrier that must precede each staged copy, so the
// decode's writes are visible to the device even though they land after the
// submit that reads them.
std::uint32_t g_barriers_before_copy = 0;
VkPipelineStageFlags g_barrier_src_stage = 0;
VkPipelineStageFlags g_barrier_dst_stage = 0;
VkAccessFlags g_barrier_src_access = 0;
VkAccessFlags g_barrier_dst_access = 0;
std::uint32_t g_pending_barriers = 0;
VKAPI_ATTR void VKAPI_CALL FakePipelineBarrier(
VkCommandBuffer, VkPipelineStageFlags src_stage,
VkPipelineStageFlags dst_stage, VkDependencyFlags, std::uint32_t count,
const VkMemoryBarrier* barriers, std::uint32_t, const VkBufferMemoryBarrier*,
std::uint32_t, const VkImageMemoryBarrier*) {
g_barrier_src_stage = src_stage;
g_barrier_dst_stage = dst_stage;
if (count > 0) {
g_barrier_src_access = barriers[0].srcAccessMask;
g_barrier_dst_access = barriers[0].dstAccessMask;
}
++g_pending_barriers;
}
VKAPI_ATTR void VKAPI_CALL FakeCopyBufferToImage(
VkCommandBuffer, VkBuffer, VkImage, VkImageLayout, std::uint32_t count,
const VkBufferImageCopy* regions) {
g_copied_extent = count > 0 ? regions[0].imageExtent : VkExtent3D{};
g_barriers_before_copy = g_pending_barriers;
}
constexpr std::uintptr_t kTimelineSemaphore = 0x900;
std::mutex g_signal_mutex;
std::vector<std::uint64_t> g_signalled;
VKAPI_ATTR VkResult VKAPI_CALL FakeCreateSemaphore(
VkDevice, const VkSemaphoreCreateInfo*, const VkAllocationCallbacks*,
VkSemaphore* semaphore) {
*semaphore = FakeHandle<VkSemaphore>(kTimelineSemaphore);
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL
FakeSignalSemaphore(VkDevice, const VkSemaphoreSignalInfo* info) {
std::lock_guard<std::mutex> lock(g_signal_mutex);
g_signalled.push_back(info->value);
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeDestroySemaphore(VkDevice, VkSemaphore,
const VkAllocationCallbacks*) {}
std::vector<std::uint64_t> SignalledValues() {
std::lock_guard<std::mutex> lock(g_signal_mutex);
return g_signalled;
}
void ResetSignalledValues() {
std::lock_guard<std::mutex> lock(g_signal_mutex);
g_signalled.clear();
}
VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL FakeGetDeviceProcAddr(
@@ -217,6 +276,12 @@ VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL FakeGetDeviceProcAddr(
const auto is = [name](const char* candidate) {
return std::strcmp(name, candidate) == 0;
};
if (is("vkCreateSemaphore"))
return reinterpret_cast<PFN_vkVoidFunction>(FakeCreateSemaphore);
if (is("vkSignalSemaphore"))
return reinterpret_cast<PFN_vkVoidFunction>(FakeSignalSemaphore);
if (is("vkDestroySemaphore"))
return reinterpret_cast<PFN_vkVoidFunction>(FakeDestroySemaphore);
if (is("vkCreateImage")) return reinterpret_cast<PFN_vkVoidFunction>(FakeCreateImage);
if (is("vkCreateBuffer")) return reinterpret_cast<PFN_vkVoidFunction>(FakeCreateBuffer);
if (is("vkGetBufferMemoryRequirements"))
@@ -229,6 +294,8 @@ VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL FakeGetDeviceProcAddr(
if (is("vkFreeMemory")) return reinterpret_cast<PFN_vkVoidFunction>(FakeFreeMemory);
if (is("vkCmdCopyBufferToImage"))
return reinterpret_cast<PFN_vkVoidFunction>(FakeCopyBufferToImage);
if (is("vkCmdPipelineBarrier"))
return reinterpret_cast<PFN_vkVoidFunction>(FakePipelineBarrier);
if (is("vkCmdCopyImage")) return reinterpret_cast<PFN_vkVoidFunction>(FakeCopyImage);
if (is("vkCmdBlitImage")) return reinterpret_cast<PFN_vkVoidFunction>(FakeBlitImage);
return nullptr;
@@ -805,5 +872,213 @@ TEST(VulkanEtc2EmulationTest, ReadsCompressedRowsAtTheRequestedStride) {
emulation.ReleaseCommandBuffer(command_buffer);
}
// Records one 4x4 ETC2 upload whose every texel decodes to 134, and returns
// the image it targets.
VkImage RecordDecodeOf134(VulkanEtc2Emulation* emulation, VkDevice device,
VkBuffer source, VkDeviceMemory source_memory,
VkCommandBuffer command_buffer) {
VkImageCreateInfo image_info{};
image_info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
image_info.format = VK_FORMAT_ETC2_R8G8B8_UNORM_BLOCK;
image_info.extent = {4, 4, 1};
VkImage image = VK_NULL_HANDLE;
EXPECT_EQ(emulation->CreateImage(device, &image_info, nullptr, &image),
VK_SUCCESS);
EXPECT_EQ(emulation->BindBufferMemory(device, source, source_memory, 0),
VK_SUCCESS);
void* mapped = nullptr;
EXPECT_EQ(emulation->MapMemory(device, source_memory, 0, g_source.size(), 0,
&mapped),
VK_SUCCESS);
const std::array<std::uint8_t, 8> block = {0x81, 0x81, 0x81, 0x02,
0, 0, 0, 0};
std::memcpy(g_source.data(), block.data(), block.size());
VkBufferImageCopy region{};
region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
region.imageSubresource.layerCount = 1;
region.imageExtent = {4, 4, 1};
emulation->CmdCopyBufferToImage(device, command_buffer, source, image,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1,
&region);
return image;
}
VkPhysicalDeviceMemoryProperties HostVisibleMemory() {
VkPhysicalDeviceMemoryProperties memory{};
memory.memoryTypeCount = 1;
memory.memoryTypes[0].propertyFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
return memory;
}
TEST(VulkanEtc2EmulationTest, DecodesOffTheCallingThreadWithATimeline) {
const VkDevice device = FakeHandle<VkDevice>(0x18);
const VkBuffer source = FakeHandle<VkBuffer>(0x208);
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
const VkCommandBuffer command_buffer = FakeHandle<VkCommandBuffer>(0x708);
g_source.fill(0);
g_staging.fill(0xAA);
ResetSignalledValues();
VulkanEtc2Emulation emulation;
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x28), true,
HostVisibleMemory(), FakeGetDeviceProcAddr, true);
RecordDecodeOf134(&emulation, device, source, source_memory, command_buffer);
const Etc2SubmitWait wait =
emulation.PrepareSubmit(&command_buffer, 1, true);
EXPECT_EQ(wait.semaphore, FakeHandle<VkSemaphore>(kTimelineSemaphore));
EXPECT_GT(wait.value, 0U);
// Releasing the command buffer waits for the decode that writes its staging.
emulation.ReleaseCommandBuffer(command_buffer);
EXPECT_EQ(g_staging[0], 134);
EXPECT_EQ(SignalledValues(), std::vector<std::uint64_t>{wait.value});
}
TEST(VulkanEtc2EmulationTest, DecodesOnTheCallerWithoutATimeline) {
const VkDevice device = FakeHandle<VkDevice>(0x19);
const VkBuffer source = FakeHandle<VkBuffer>(0x209);
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
const VkCommandBuffer command_buffer = FakeHandle<VkCommandBuffer>(0x709);
g_source.fill(0);
g_staging.fill(0xAA);
ResetSignalledValues();
VulkanEtc2Emulation emulation;
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x29), true,
HostVisibleMemory(), FakeGetDeviceProcAddr, false);
RecordDecodeOf134(&emulation, device, source, source_memory, command_buffer);
const Etc2SubmitWait wait =
emulation.PrepareSubmit(&command_buffer, 1, true);
EXPECT_EQ(wait.semaphore, VK_NULL_HANDLE);
EXPECT_EQ(wait.value, 0U);
// Decoded before the call returned, so no wait is owed.
EXPECT_EQ(g_staging[0], 134);
EXPECT_TRUE(SignalledValues().empty());
emulation.ReleaseCommandBuffer(command_buffer);
}
TEST(VulkanEtc2EmulationTest, UnmapWaitsForDecodesReadingThatMemory) {
const VkDevice device = FakeHandle<VkDevice>(0x1C);
const VkBuffer source = FakeHandle<VkBuffer>(0x20C);
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
const VkCommandBuffer command_buffer = FakeHandle<VkCommandBuffer>(0x70D);
g_source.fill(0);
g_staging.fill(0xAA);
ResetSignalledValues();
VulkanEtc2Emulation emulation;
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x2C), true,
HostVisibleMemory(), FakeGetDeviceProcAddr, true);
RecordDecodeOf134(&emulation, device, source, source_memory, command_buffer);
const Etc2SubmitWait wait =
emulation.PrepareSubmit(&command_buffer, 1, true);
ASSERT_NE(wait.semaphore, VK_NULL_HANDLE);
// A deferred gather reads the application's mapping on a worker, so the
// unmap must not return while a decode can still dereference it.
emulation.UnmapMemory(device, source_memory);
const std::vector<std::uint64_t> signalled = SignalledValues();
ASSERT_FALSE(signalled.empty()) << "unmap returned with a decode in flight";
EXPECT_GE(signalled.back(), wait.value);
EXPECT_EQ(g_staging[0], 134);
emulation.ReleaseCommandBuffer(command_buffer);
}
TEST(VulkanEtc2EmulationTest, FreeMemoryWaitsForDecodesReadingThatMemory) {
const VkDevice device = FakeHandle<VkDevice>(0x1D);
const VkBuffer source = FakeHandle<VkBuffer>(0x20D);
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
const VkCommandBuffer command_buffer = FakeHandle<VkCommandBuffer>(0x70E);
g_source.fill(0);
g_staging.fill(0xAA);
ResetSignalledValues();
VulkanEtc2Emulation emulation;
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x2D), true,
HostVisibleMemory(), FakeGetDeviceProcAddr, true);
RecordDecodeOf134(&emulation, device, source, source_memory, command_buffer);
const Etc2SubmitWait wait =
emulation.PrepareSubmit(&command_buffer, 1, true);
ASSERT_NE(wait.semaphore, VK_NULL_HANDLE);
emulation.FreeMemory(device, source_memory, nullptr);
const std::vector<std::uint64_t> signalled = SignalledValues();
ASSERT_FALSE(signalled.empty()) << "free returned with a decode in flight";
EXPECT_GE(signalled.back(), wait.value);
EXPECT_EQ(g_staging[0], 134);
emulation.ReleaseCommandBuffer(command_buffer);
}
TEST(VulkanEtc2EmulationTest, GuardsStagedCopiesWithAHostWriteBarrier) {
const VkDevice device = FakeHandle<VkDevice>(0x1B);
const VkBuffer source = FakeHandle<VkBuffer>(0x20B);
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
const VkCommandBuffer command_buffer = FakeHandle<VkCommandBuffer>(0x70C);
g_source.fill(0);
g_staging.fill(0xAA);
g_pending_barriers = 0;
g_barriers_before_copy = 0;
VulkanEtc2Emulation emulation;
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x2B), true,
HostVisibleMemory(), FakeGetDeviceProcAddr, true);
RecordDecodeOf134(&emulation, device, source, source_memory, command_buffer);
// An asynchronous decode writes the staging after the submit that reads it,
// so the queue submission's host-to-device domain operation does not cover
// those writes; the copy must be preceded by an explicit barrier.
EXPECT_EQ(g_barriers_before_copy, 1U);
EXPECT_EQ(g_barrier_src_stage,
static_cast<VkPipelineStageFlags>(VK_PIPELINE_STAGE_HOST_BIT));
EXPECT_NE(g_barrier_dst_stage &
static_cast<VkPipelineStageFlags>(VK_PIPELINE_STAGE_TRANSFER_BIT),
0U);
EXPECT_EQ(g_barrier_src_access,
static_cast<VkAccessFlags>(VK_ACCESS_HOST_WRITE_BIT));
EXPECT_NE(g_barrier_dst_access &
static_cast<VkAccessFlags>(VK_ACCESS_TRANSFER_READ_BIT),
0U);
emulation.PrepareSubmit(&command_buffer, 1, true);
emulation.ReleaseCommandBuffer(command_buffer);
}
TEST(VulkanEtc2EmulationTest, SignalsDecodeValuesInHandOutOrder) {
const VkDevice device = FakeHandle<VkDevice>(0x1A);
const VkBuffer source = FakeHandle<VkBuffer>(0x20A);
const VkDeviceMemory source_memory = FakeHandle<VkDeviceMemory>(kSourceMemory);
const VkCommandBuffer first = FakeHandle<VkCommandBuffer>(0x70A);
const VkCommandBuffer second = FakeHandle<VkCommandBuffer>(0x70B);
g_source.fill(0);
g_staging.fill(0xAA);
ResetSignalledValues();
VulkanEtc2Emulation emulation;
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x2A), true,
HostVisibleMemory(), FakeGetDeviceProcAddr, true);
RecordDecodeOf134(&emulation, device, source, source_memory, first);
RecordDecodeOf134(&emulation, device, source, source_memory, second);
const Etc2SubmitWait one = emulation.PrepareSubmit(&first, 1, true);
const Etc2SubmitWait two = emulation.PrepareSubmit(&second, 1, true);
EXPECT_GT(two.value, one.value);
emulation.ReleaseCommandBuffer(first);
emulation.ReleaseCommandBuffer(second);
const std::vector<std::uint64_t> signalled = SignalledValues();
ASSERT_FALSE(signalled.empty());
EXPECT_EQ(signalled.back(), two.value);
for (std::size_t index = 1; index < signalled.size(); ++index) {
EXPECT_GT(signalled[index], signalled[index - 1]) << "at " << index;
}
}
} // namespace
} // namespace mocktail::graphics
@@ -135,11 +135,44 @@ TEST(WindowPointerCaptureOwnerTest, QueryCanClearItselfWithoutDeadlock) {
EXPECT_TRUE(owner.RegisterQuery(Query, &replacement));
}
TEST(WindowPointerCaptureOwnerTest, RightDragDoesNotCaptureOutsideAnExperience) {
// The app surface has no camera. Capturing there would freeze the cursor
// over Roblox's own menu UI.
FakeBackend backend;
QueryState query{true, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
ASSERT_TRUE(owner.Pump(false));
EXPECT_TRUE(owner.OnRightButton(true, false));
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.OnRightButton(false, false));
EXPECT_FALSE(owner.captured());
}
TEST(WindowPointerCaptureOwnerTest, RightDragCapturesOnceTheGameSurfaceIsLive) {
FakeBackend backend;
QueryState query{true, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
ASSERT_TRUE(owner.Pump(false));
owner.SetGameSessionActive(true);
EXPECT_TRUE(owner.OnRightButton(true, false));
EXPECT_TRUE(owner.captured());
// Leaving the experience releases a capture that is still held.
owner.SetGameSessionActive(false);
EXPECT_TRUE(owner.Pump(false));
EXPECT_FALSE(owner.captured());
}
TEST(WindowPointerCaptureOwnerTest, RightDragCapturesUntilButtonRelease) {
FakeBackend backend;
QueryState query{true, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
owner.SetGameSessionActive(true);
ASSERT_TRUE(owner.Pump(false));
EXPECT_TRUE(owner.OnRightButton(true, false));
@@ -230,6 +263,7 @@ TEST(WindowPointerCaptureOwnerTest, RightDragDoesNotDependOnNativeLockQuery) {
QueryState query{false, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
owner.SetGameSessionActive(true);
EXPECT_TRUE(owner.OnRightButton(true, false));
EXPECT_TRUE(owner.captured());
@@ -244,6 +278,7 @@ TEST(WindowPointerCaptureOwnerTest, TextAndFocusCancelRightDragCapture) {
QueryState query{true, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
owner.SetGameSessionActive(true);
EXPECT_TRUE(owner.OnRightButton(true, true));
EXPECT_FALSE(owner.captured());