diff --git a/.github/workflows/aur.yml b/.github/workflows/aur.yml
index af296fc..431beb6 100644
--- a/.github/workflows/aur.yml
+++ b/.github/workflows/aur.yml
@@ -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" "$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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index abb57dc..feacf6a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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[@]}"
diff --git a/.gitignore b/.gitignore
index cfa7074..8c4c8a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a7afa1..e520eb4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0df5b53..e70f30c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -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
- 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.
Flatpak
flatpak install --user https://coderdayton.github.io/nightcap/nightcap.flatpakref
flatpak run io.github.CoderDayton.nightcap
+ DEB, RPM and pacman
+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
Build from source
git clone --recurse-submodules https://github.com/CoderDayton/nightcap.git
cd nightcap && make build
@@ -40,6 +44,7 @@ cd nightcap && make build
AppImage
Flatpak bundle
+ DEB, RPM, pacman
Signing key
Terms
Privacy
diff --git a/src/graphics/bionic_vulkan_loader_adapter.cc b/src/graphics/bionic_vulkan_loader_adapter.cc
index bc32c9f..b9a6a34 100644
--- a/src/graphics/bionic_vulkan_loader_adapter.cc
+++ b/src/graphics/bionic_vulkan_loader_adapter.cc
@@ -11,6 +11,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -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> wait_semaphores;
+ std::deque> wait_stages;
+ std::deque> wait_values;
+ std::deque> signal_values;
+ std::deque timelines;
+ std::deque> wait_infos;
+ std::vector rewritten;
+ std::vector 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& semaphores = rewrite->wait_semaphores.emplace_back(
+ original.pWaitSemaphores,
+ original.pWaitSemaphores + original.waitSemaphoreCount);
+ semaphores.push_back(wait.semaphore);
+ std::vector& 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& 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(values.size());
+ timeline.pWaitSemaphoreValues = values.data();
+ if (original.signalSemaphoreCount != 0) {
+ std::vector& signals =
+ rewrite->signal_values.emplace_back(original.signalSemaphoreCount, 0);
+ timeline.signalSemaphoreValueCount =
+ static_cast(signals.size());
+ timeline.pSignalSemaphoreValues = signals.data();
+ }
+
+ VkSubmitInfo& target = rewrite->rewritten[index];
+ target.pNext = &timeline;
+ target.waitSemaphoreCount = static_cast(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 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(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& 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(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(host_submit));
observation.SetResult(result);
return result;
diff --git a/src/graphics/vulkan_etc2_emulation.cc b/src/graphics/vulkan_etc2_emulation.cc
index c13f7f3..cf7c6f1 100644
--- a/src/graphics/vulkan_etc2_emulation.cc
+++ b/src/graphics/vulkan_etc2_emulation.cc
@@ -6,9 +6,11 @@
#include
#include
+#include
#include
#include
#include
+#include
#include
#include
#include
@@ -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
@@ -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 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 uploads;
std::vector staging;
std::vector 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 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 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 deferred_sources;
};
std::atomic 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& uploads,
+ const std::vector& 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(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 scratch_source;
+ RawBuffer scratch_source;
std::vector 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> queue;
+ std::vector> 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 AcquireBatch() {
+ std::lock_guard lock(queue_mutex);
+ if (spare_batches.empty()) {
+ return std::make_unique();
+ }
+ std::unique_ptr batch = std::move(spare_batches.back());
+ spare_batches.pop_back();
+ batch->uploads.clear();
+ return batch;
+ }
+
+ void StartDispatcher() {
+ std::lock_guard 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 batch) {
+ std::lock_guard 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 batch;
+ {
+ std::unique_lock 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 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(batch.uploads.size()));
+ trace_scope.Arg("compressed_bytes",
+ static_cast(batch.source_bytes));
+ trace_scope.Arg("decoded_bytes",
+ static_cast(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& uploads,
+ std::uint8_t* source_base, std::uint8_t* decoded_base) {
+ std::vector 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 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 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 lock(queue_mutex);
+ ticket = issued_ticket;
+ }
+ WaitForTicket(ticket);
+ }
+
+ void StopDispatcher() {
+ std::thread worker;
+ {
+ std::lock_guard 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(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(
get, device, "vkGetBufferMemoryRequirements");
+ dev->pipeline_barrier =
+ DeviceProc(get, device, "vkCmdPipelineBarrier");
+ dev->signal_semaphore = DeviceProc(
+ get, device, "vkSignalSemaphore", "vkSignalSemaphoreKHR");
+ dev->destroy_semaphore =
+ DeviceProc(get, device, "vkDestroySemaphore");
+ if (emulated && timeline_semaphore && dev->signal_semaphore != nullptr) {
+ const auto create_semaphore =
+ DeviceProc(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 doomed;
{
std::lock_guard 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 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 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 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 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(compressed_total));
trace_scope.Arg("decoded_bytes", static_cast(decoded_total));
- std::lock_guard scratch_lock(state_->scratch_mutex);
- std::vector& scratch_source = state_->scratch_source;
- std::vector& scratch_decoded = state_->scratch_decoded;
- if (scratch_source.size() < compressed_total) {
- scratch_source.resize(compressed_total);
+ std::unique_ptr 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 uploads;
+ uploads.reserve(copied_uploads.size());
+ for (const PendingUpload* upload : copied_uploads) {
+ uploads.push_back(*upload);
}
- std::vector 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(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 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 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 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 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(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 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 doomed;
{
std::lock_guard lock(state_->mutex);
diff --git a/src/platform/sdl_event_converter.cc b/src/platform/sdl_event_converter.cc
index 3808832..5a39a04 100644
--- a/src/platform/sdl_event_converter.cc
+++ b/src/platform/sdl_event_converter.cc
@@ -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(source.motion.state)};
+ source.motion.x, source.motion.y,
+ source.motion.xrel, source.motion.yrel,
+ static_cast(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:
diff --git a/src/runtime/roblox_experience_composition.cc b/src/runtime/roblox_experience_composition.cc
index 40e4678..8ca2ebc 100644
--- a/src/runtime/roblox_experience_composition.cc
+++ b/src/runtime/roblox_experience_composition.cc
@@ -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 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 lock(mutex_);
subscribed_ = false;
game_active_ = false;
+ window::SetGameSessionActive(false);
active_game_canonical_json_.clear();
presence_request_.reset();
playing_presence_published_ = false;
diff --git a/src/runtime/roblox_input_router.cc b/src/runtime/roblox_input_router.cc
index 7889541..ad45e7b 100644
--- a/src/runtime/roblox_input_router.cc
+++ b/src/runtime/roblox_input_router.cc
@@ -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,
diff --git a/src/runtime/roblox_text_surface_overlay.cc b/src/runtime/roblox_text_surface_overlay.cc
index 958611b..5982d70 100644
--- a/src/runtime/roblox_text_surface_overlay.cc
+++ b/src/runtime/roblox_text_surface_overlay.cc
@@ -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(height) * 0.38F, 12.0F, 28.0F);
- const std::vector font_files = ResolveFontFiles(font_selection);
- std::vector 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& 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 files =
+ ResolveFontFiles(ResolveRobloxTextFont(font));
+ std::vector 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;
diff --git a/src/window/window.cc b/src/window/window.cc
index f88d829..da41aa0 100644
--- a/src/window/window.cc
+++ b/src/window/window.cc
@@ -151,13 +151,33 @@ static std::unique_ptr g_resize_readiness_gate;
static std::unique_ptr g_text_input_backend;
static std::unique_ptr g_text_input_owner;
static std::unique_ptr g_pointer_capture_backend;
-static std::unique_ptr 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 g_pointer_capture_owner;
static std::unique_ptr 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 SharedPointerCaptureOwner() {
+ std::lock_guard 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 owner;
+ {
+ std::lock_guard 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(g_text_input_backend.get());
g_pointer_capture_backend =
std::make_unique(g_state.sdl_window);
- g_pointer_capture_owner = std::make_unique(
- g_pointer_capture_backend.get());
+ {
+ std::lock_guard lock(g_pointer_capture_owner_mutex);
+ g_pointer_capture_owner = std::make_shared(
+ g_pointer_capture_backend.get());
+ }
fprintf(stderr,
" [input] SDL event pump target=%llu Hz raw-motion, no smoothing\n",
static_cast(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 owner =
+ SharedPointerCaptureOwner();
+ return owner != nullptr && owner->RegisterQuery(callback, context);
+}
+
+void SetGameSessionActive(bool active) {
+ const std::shared_ptr 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 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();
diff --git a/src/window/window.h b/src/window/window.h
index 23c4ea4..5472f1b 100644
--- a/src/window/window.h
+++ b/src/window/window.h
@@ -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);
diff --git a/src/window/window_pointer_capture_owner.cc b/src/window/window_pointer_capture_owner.cc
index 58a5d14..ac7bb58 100644
--- a/src/window/window_pointer_capture_owner.cc
+++ b/src/window/window_pointer_capture_owner.cc
@@ -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);
diff --git a/src/window/window_pointer_capture_owner.h b/src/window/window_pointer_capture_owner.h
index 2d90bf0..674e167 100644
--- a/src/window/window_pointer_capture_owner.h
+++ b/src/window/window_pointer_capture_owner.h
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_WINDOW_WINDOW_POINTER_CAPTURE_OWNER_H_
#define MOCKTAIL_WINDOW_WINDOW_POINTER_CAPTURE_OWNER_H_
+#include
#include
#include
#include
@@ -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 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;
diff --git a/tests/native_packaging_test.sh b/tests/native_packaging_test.sh
index bc9577c..bd04723 100755
--- a/tests/native_packaging_test.sh
+++ b/tests/native_packaging_test.sh
@@ -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}" ||
diff --git a/tests/roblox_input_router_test.cc b/tests/roblox_input_router_test.cc
index faa4f0c..eef3f6a 100644
--- a/tests/roblox_input_router_test.cc
+++ b/tests/roblox_input_router_test.cc
@@ -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
diff --git a/tests/roblox_text_display_state_test.cc b/tests/roblox_text_display_state_test.cc
index 5931957..6420b0a 100644
--- a/tests/roblox_text_display_state_test.cc
+++ b/tests/roblox_text_display_state_test.cc
@@ -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(text.size()));
+ update.event = event;
+ update.font_size = 18.0F;
+ sink.update(sink.context, update);
+ MocktailTextOverlayFrameInfo frame;
+ EXPECT_TRUE(overlay.QueryFrame(&frame));
+ std::vector 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 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 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
diff --git a/tests/vulkan_etc2_emulation_test.cc b/tests/vulkan_etc2_emulation_test.cc
index 142fd87..604f032 100644
--- a/tests/vulkan_etc2_emulation_test.cc
+++ b/tests/vulkan_etc2_emulation_test.cc
@@ -8,6 +8,8 @@
#include
#include
#include
+#include
+#include
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 g_signalled;
+
+VKAPI_ATTR VkResult VKAPI_CALL FakeCreateSemaphore(
+ VkDevice, const VkSemaphoreCreateInfo*, const VkAllocationCallbacks*,
+ VkSemaphore* semaphore) {
+ *semaphore = FakeHandle(kTimelineSemaphore);
+ return VK_SUCCESS;
+}
+
+VKAPI_ATTR VkResult VKAPI_CALL
+FakeSignalSemaphore(VkDevice, const VkSemaphoreSignalInfo* info) {
+ std::lock_guard 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 SignalledValues() {
+ std::lock_guard lock(g_signal_mutex);
+ return g_signalled;
+}
+
+void ResetSignalledValues() {
+ std::lock_guard 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(FakeCreateSemaphore);
+ if (is("vkSignalSemaphore"))
+ return reinterpret_cast(FakeSignalSemaphore);
+ if (is("vkDestroySemaphore"))
+ return reinterpret_cast(FakeDestroySemaphore);
if (is("vkCreateImage")) return reinterpret_cast(FakeCreateImage);
if (is("vkCreateBuffer")) return reinterpret_cast(FakeCreateBuffer);
if (is("vkGetBufferMemoryRequirements"))
@@ -229,6 +294,8 @@ VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL FakeGetDeviceProcAddr(
if (is("vkFreeMemory")) return reinterpret_cast(FakeFreeMemory);
if (is("vkCmdCopyBufferToImage"))
return reinterpret_cast(FakeCopyBufferToImage);
+ if (is("vkCmdPipelineBarrier"))
+ return reinterpret_cast(FakePipelineBarrier);
if (is("vkCmdCopyImage")) return reinterpret_cast(FakeCopyImage);
if (is("vkCmdBlitImage")) return reinterpret_cast(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 block = {0x81, 0x81, 0x81, 0x02,
+ 0, 0, 0, 0};
+ std::memcpy(g_source.data(), block.data(), block.size());
+
+ VkBufferImageCopy region{};
+ region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
+ region.imageSubresource.layerCount = 1;
+ region.imageExtent = {4, 4, 1};
+ emulation->CmdCopyBufferToImage(device, command_buffer, source, image,
+ VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1,
+ ®ion);
+ return image;
+}
+
+VkPhysicalDeviceMemoryProperties HostVisibleMemory() {
+ VkPhysicalDeviceMemoryProperties memory{};
+ memory.memoryTypeCount = 1;
+ memory.memoryTypes[0].propertyFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
+ VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
+ return memory;
+}
+
+TEST(VulkanEtc2EmulationTest, DecodesOffTheCallingThreadWithATimeline) {
+ const VkDevice device = FakeHandle(0x18);
+ const VkBuffer source = FakeHandle(0x208);
+ const VkDeviceMemory source_memory = FakeHandle(kSourceMemory);
+ const VkCommandBuffer command_buffer = FakeHandle(0x708);
+ g_source.fill(0);
+ g_staging.fill(0xAA);
+ ResetSignalledValues();
+
+ VulkanEtc2Emulation emulation;
+ emulation.RegisterDevice(device, FakeHandle(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(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{wait.value});
+}
+
+TEST(VulkanEtc2EmulationTest, DecodesOnTheCallerWithoutATimeline) {
+ const VkDevice device = FakeHandle(0x19);
+ const VkBuffer source = FakeHandle(0x209);
+ const VkDeviceMemory source_memory = FakeHandle(kSourceMemory);
+ const VkCommandBuffer command_buffer = FakeHandle(0x709);
+ g_source.fill(0);
+ g_staging.fill(0xAA);
+ ResetSignalledValues();
+
+ VulkanEtc2Emulation emulation;
+ emulation.RegisterDevice(device, FakeHandle(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(0x1C);
+ const VkBuffer source = FakeHandle(0x20C);
+ const VkDeviceMemory source_memory = FakeHandle(kSourceMemory);
+ const VkCommandBuffer command_buffer = FakeHandle(0x70D);
+ g_source.fill(0);
+ g_staging.fill(0xAA);
+ ResetSignalledValues();
+
+ VulkanEtc2Emulation emulation;
+ emulation.RegisterDevice(device, FakeHandle(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 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(0x1D);
+ const VkBuffer source = FakeHandle(0x20D);
+ const VkDeviceMemory source_memory = FakeHandle(kSourceMemory);
+ const VkCommandBuffer command_buffer = FakeHandle(0x70E);
+ g_source.fill(0);
+ g_staging.fill(0xAA);
+ ResetSignalledValues();
+
+ VulkanEtc2Emulation emulation;
+ emulation.RegisterDevice(device, FakeHandle(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 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(0x1B);
+ const VkBuffer source = FakeHandle(0x20B);
+ const VkDeviceMemory source_memory = FakeHandle(kSourceMemory);
+ const VkCommandBuffer command_buffer = FakeHandle(0x70C);
+ g_source.fill(0);
+ g_staging.fill(0xAA);
+ g_pending_barriers = 0;
+ g_barriers_before_copy = 0;
+
+ VulkanEtc2Emulation emulation;
+ emulation.RegisterDevice(device, FakeHandle(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(VK_PIPELINE_STAGE_HOST_BIT));
+ EXPECT_NE(g_barrier_dst_stage &
+ static_cast(VK_PIPELINE_STAGE_TRANSFER_BIT),
+ 0U);
+ EXPECT_EQ(g_barrier_src_access,
+ static_cast(VK_ACCESS_HOST_WRITE_BIT));
+ EXPECT_NE(g_barrier_dst_access &
+ static_cast(VK_ACCESS_TRANSFER_READ_BIT),
+ 0U);
+ emulation.PrepareSubmit(&command_buffer, 1, true);
+ emulation.ReleaseCommandBuffer(command_buffer);
+}
+
+TEST(VulkanEtc2EmulationTest, SignalsDecodeValuesInHandOutOrder) {
+ const VkDevice device = FakeHandle(0x1A);
+ const VkBuffer source = FakeHandle(0x20A);
+ const VkDeviceMemory source_memory = FakeHandle(kSourceMemory);
+ const VkCommandBuffer first = FakeHandle(0x70A);
+ const VkCommandBuffer second = FakeHandle(0x70B);
+ g_source.fill(0);
+ g_staging.fill(0xAA);
+ ResetSignalledValues();
+
+ VulkanEtc2Emulation emulation;
+ emulation.RegisterDevice(device, FakeHandle(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 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
diff --git a/tests/window_pointer_capture_owner_test.cc b/tests/window_pointer_capture_owner_test.cc
index 1fa40e6..29b6548 100644
--- a/tests/window_pointer_capture_owner_test.cc
+++ b/tests/window_pointer_capture_owner_test.cc
@@ -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());