From 7dd32335ca67d71fc01a79e96ec975fde5d5b97b Mon Sep 17 00:00:00 2001
From: vii
- 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/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}" ||
From 6829fd00ed7ccc06c33654e034b6fb7f9664cb25 Mon Sep 17 00:00:00 2001
From: vii
Date: Sat, 19 Sep 2026 18:02:16 -0500
Subject: [PATCH 06/12] perf(graphics): decode ETC2 uploads off the submitting
thread
Uploads decode on a background dispatcher and the submit carries a
timeline semaphore wait, so the GPU waits for the texture rather than
the render thread. The gather of the application's compressed bytes
moves there too when they are already mapped; unmapping or freeing that
memory drains the decodes still reading it.
Staged copies gain an explicit host-write barrier. A queue submission
only makes host writes from before it visible to the device, and these
writes land after the submit.
Devices without timelineSemaphore, and submits whose pNext already sizes
arrays by their semaphore counts, decode inline as before.
Blade Ball, median of three: worst vkQueueSubmit 140ms -> 1ms, p99
4.9ms -> 0.05ms, total 2534ms -> 309ms. Frame interval unchanged; its
tail is the engine's own main-thread step.
---
CHANGELOG.md | 24 +
docs/BENCHMARKING.md | 14 +-
docs/PERFORMANCE.md | 50 +-
.../mocktail/graphics/vulkan_etc2_emulation.h | 39 +-
src/graphics/bionic_vulkan_loader_adapter.cc | 159 +++++-
src/graphics/vulkan_etc2_emulation.cc | 530 +++++++++++++++---
tests/vulkan_etc2_emulation_test.cc | 275 +++++++++
7 files changed, 985 insertions(+), 106 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a7afa1..1f40e60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,30 @@ GitHub are taken from the matching section here.
## [Unreleased]
+### 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.
+- 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
diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md
index f662383..3f84705 100644
--- a/docs/BENCHMARKING.md
+++ b/docs/BENCHMARKING.md
@@ -44,7 +44,8 @@ Each slice is named after the call it times, on the thread that made it.
| `present` | `host present`: the host driver's present, nested inside the slice above | |
| `wait` | `vkAcquireNextImageKHR`, `vkAcquireNextImage2KHR`, `vkWaitForFences`, `vkWaitSemaphores`, `vkWaitSemaphoresKHR`, `vkQueueWaitIdle`, `vkDeviceWaitIdle` | |
| `submit` | `vkQueueSubmit`, `vkQueueSubmit2`, `vkQueueSubmit2KHR` | `submits` |
-| `texture` | `etc2 decode`: CPU decode of ETC2 textures before a submit | `uploads`, `compressed_bytes`, `decoded_bytes` |
+| `texture` | `etc2 decode`: CPU decode of ETC2 textures. On a device with a timeline semaphore this runs on the decode dispatcher, not the submitting thread | `uploads`, `compressed_bytes`, `decoded_bytes` |
+| `texture` | `etc2 gather`: the part of an asynchronous upload that stays on the submitting thread. Absent when the decode ran inline | `uploads`, `compressed_bytes`, `decoded_bytes`, `ticket` |
| `pipeline` | `vkCreateGraphicsPipelines`, `vkCreateComputePipelines` | `count`, `cache` (1 when a pipeline cache was passed), `result` |
| `pipeline` | `vkCreateShaderModule` | `bytes`, `result` |
| `pipeline` | `vkCreatePipelineCache` | `initial_bytes`, `result` |
@@ -61,6 +62,17 @@ Run every scenario before and after the change, on the same build type
Record `graphics.frame_rate_limit` and `graphics.vsync` in the pull request,
because a frame cap hides frame time gains.
+Record `~/.config/gamemode.ini` too, and do not change it between the before
+and after runs. `desiredgov` there decides the CPU governor for the whole
+session and therefore which engine pump mode is chosen, which moves process
+CPU by more than most changes under test. See
+[PERFORMANCE.md](PERFORMANCE.md).
+
+Frame interval p99 varies by 2 ms or more between identical runs, so a
+difference that size is not a result. Three runs per scenario is the minimum
+for any frame-time claim; slice totals for a specific subsystem are far less
+noisy and can be read from fewer.
+
| Scenario | How to start | What to do | What it shows |
| --- | --- | --- | --- |
| Home screen, cold | `mocktail --profile home-cold.json` with an empty shader cache (below) | Wait 30 s on the home screen, exit | First-load stutter: `pipeline` and `texture` slices |
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
index 6dcd951..fb7656c 100644
--- a/docs/PERFORMANCE.md
+++ b/docs/PERFORMANCE.md
@@ -27,20 +27,48 @@ request. If GameMode narrowed it, Nightcap ends the request and prints:
```
The trade is that GameMode's governor and priority boost go with it. To keep
-those and lose only the pinning, turn pinning off for every game on the host:
+those and lose only the pinning, turn pinning off for every game on the host.
+
+These are the settings to run with:
```ini
# ~/.config/gamemode.ini
+[general]
+desiredgov=performance
+
[cpu]
pin_cores=no
park_cores=no
```
+`desiredgov` is the one to check first. The packaged default in
+`/usr/share/gamemode/gamemode.ini` is already `performance`, but a value in
+your own file replaces it, and `desiredgov=powersave` there means GameMode
+clocks the CPU **down** for the whole session while still reporting
+`[gamemode] performance request active`. The symptoms are a low CPU clock
+under load, a GPU that never leaves its lower SM clocks, and the engine pump
+falling back to `tpause` — which costs about 70% of a core instead of 3%,
+because `sleep` is only chosen when the governor reads `performance`. On one
+13900K host, correcting `powersave` to `performance` took the process from
+177% CPU to 42% and the package from 70°C to 53-63°C, at the same frame rate.
+
GameMode reads `$XDG_CONFIG_HOME`, `/usr/share/gamemode` and `/etc`, in that
order. It does not read anything under Nightcap's own config directory, so this
file has to live in one of those three places. The daemon reads it when it
starts, so restart it after editing: `systemctl --user restart gamemoded`.
+Restarting the daemon drops the session of any game already running; relaunch
+the game for new settings to apply to it.
+
+`gamemoded` is D-Bus activated through
+`com.feralinteractive.GameMode.service`, so `systemctl --user stop gamemoded`
+does not hold — the next client request starts it again within seconds. To
+measure with GameMode genuinely off, mask it:
+
+```bash
+systemctl --user mask gamemoded # and --unmask afterwards
+```
+
The governor switch runs through polkit. Debian and Ubuntu only allow it for
members of the `gamemode` group; without that, the daemon's journal shows
`cpugovctl set performance: Not authorized` and the governor stays put while
@@ -59,9 +87,20 @@ to the affinity. `off` never contacts the daemon.
To see which CPUs the process may use:
```bash
-taskset -pc $(pgrep -x mocktail)
+pid=$(pgrep -x Main)
+for t in /proc/$pid/task/*; do taskset -pc "${t##*/}"; done
```
+Two traps here. The engine process reports its name as `Main`, not
+`mocktail`; `pgrep -x mocktail` finds only the launcher, which sits near 0%
+CPU and tells you nothing. And `taskset -p` reads one thread, not the whole
+process, so a single call reports the main thread's mask and says nothing
+about the workers — which is where the game's load actually is.
+
+For the same reason, `ps -L -o psr=` shows which core each thread is running
+on right now, not which cores it is allowed on. On a hybrid CPU the scheduler
+preferring P-cores for runnable work is normal and is not a mask.
+
## The main-thread pump
Roblox posts its per-frame main-thread step at a time of its own choosing,
@@ -99,6 +138,13 @@ threads while textures load, which shows up as a burst rather than a steady
load. The enlarged textures then stay in GPU memory for the session and cost
extra sampling bandwidth on every frame that uses them.
+That decode does not run on the thread that submits the frame. On a device
+with a timeline semaphore, uploads decode on a background dispatcher and the
+submit carries a semaphore wait, so the GPU waits for the texture instead of
+the render thread. In the trace this is the `etc2 decode` slice on its own
+thread, with only a negligible `etc2 gather` left on the submitting one. A
+device without that feature decodes inline as before.
+
Set it to `1` to turn upscaling off:
```bash
diff --git a/include/mocktail/graphics/vulkan_etc2_emulation.h b/include/mocktail/graphics/vulkan_etc2_emulation.h
index 7b01817..a356021 100644
--- a/include/mocktail/graphics/vulkan_etc2_emulation.h
+++ b/include/mocktail/graphics/vulkan_etc2_emulation.h
@@ -47,6 +47,14 @@ inline constexpr std::uint32_t kSmallTextureMaxExtent = 64;
inline constexpr std::uint32_t kDefaultSmallTextureUpscale = 4;
std::uint32_t SmallTextureUpscale(const char* value);
+// A timeline semaphore and the value an asynchronous decode will signal.
+// `semaphore` is VK_NULL_HANDLE when the decode already finished on the
+// calling thread and the submit needs no extra wait.
+struct Etc2SubmitWait {
+ VkSemaphore semaphore = VK_NULL_HANDLE;
+ std::uint64_t value = 0;
+};
+
// Reports ETC2/EAC support on hosts without native ETC2 (desktop GPUs) and
// stores those textures decoded. Images are created with the host format;
// each staged upload into one is redirected to a host-visible staging buffer
@@ -66,10 +74,14 @@ class VulkanEtc2Emulation final {
bool PhysicalDeviceNeedsEmulation(VkPhysicalDevice physical_device,
PFN_vkGetPhysicalDeviceFeatures host);
+ // `timeline_semaphore` states whether the device was created with the
+ // timelineSemaphore feature enabled. Without it every decode runs on the
+ // calling thread.
void RegisterDevice(VkDevice device, VkPhysicalDevice physical_device,
bool emulated,
const VkPhysicalDeviceMemoryProperties& memory,
- PFN_vkGetDeviceProcAddr get_device_proc_addr);
+ PFN_vkGetDeviceProcAddr get_device_proc_addr,
+ bool timeline_semaphore = false);
void DestroyDevice(VkDevice device);
VkResult CreateImage(VkDevice device, const VkImageCreateInfo* create_info,
@@ -112,12 +124,27 @@ class VulkanEtc2Emulation final {
const VkCommandBuffer* secondaries);
// Decodes every upload recorded into these command buffers and the
- // secondary command buffers they execute. Large batches decode on worker
- // threads; this returns once every upload is decoded.
- void PrepareSubmit(const VkCommandBuffer* command_buffers,
- std::uint32_t count);
+ // secondary command buffers they execute.
+ //
+ // With `allow_async` on a device that has a timeline semaphore, the decode
+ // runs on worker threads, and so does the gather of the compressed bytes
+ // whenever the application already has them mapped. The returned semaphore
+ // and value must then be added to the submit's waits, so the GPU copy
+ // cannot execute before the decode finishes. Values are signalled in the
+ // order they are handed out.
+ //
+ // Those threads read the application's source bytes after this returns,
+ // which Vulkan allows: the application may not write them between the
+ // submit and the copy's completion. Unmapping or freeing that memory waits
+ // for the decodes still reading it.
+ //
+ // Returns `{VK_NULL_HANDLE, 0}` when there was nothing to decode, when
+ // `allow_async` is false, or when the device has no timeline semaphore. In
+ // those cases every upload is already decoded when this returns.
+ Etc2SubmitWait PrepareSubmit(const VkCommandBuffer* command_buffers,
+ std::uint32_t count, bool allow_async = false);
// Destroys the staging buffers owned by a command buffer that is no longer
- // pending.
+ // pending, after waiting for any decode still writing into them.
void ReleaseCommandBuffer(VkCommandBuffer command_buffer);
struct State;
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/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
From 6c3631058ca056df9b0881805228ef17ecfcfaea Mon Sep 17 00:00:00 2001
From: vii
Date: Sun, 20 Sep 2026 12:05:47 -0500
Subject: [PATCH 07/12] chore(docs): ignore the working plans directory
---
.gitignore | 3 +++
1 file changed, 3 insertions(+)
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/
From 66de662e5bab50ace2d5d4f6acc72168858bc3de Mon Sep 17 00:00:00 2001
From: vii
Date: Sun, 20 Sep 2026 12:05:48 -0500
Subject: [PATCH 08/12] fix(input): decide pointer capture from state, not
coordinates
The router inferred relative mode from SDL reporting zero absolute
coordinates. X11 does; Wayland reports a position that accumulates the
deltas and leaves the window, so every camera-drag event took the
absolute branch and walked Roblox's cursor to the viewport edge.
Motion, button and wheel events now carry the window's relative mouse
mode, and the RMB camera-drag fallback only captures inside an
experience so the app surface keeps a free cursor.
---
include/mocktail/platform/platform_runtime.h | 8 +++
src/platform/sdl_event_converter.cc | 17 ++++--
src/runtime/roblox_experience_composition.cc | 4 ++
src/runtime/roblox_input_router.cc | 25 ++++----
src/window/window.cc | 6 ++
src/window/window.h | 4 ++
src/window/window_pointer_capture_owner.cc | 6 +-
src/window/window_pointer_capture_owner.h | 7 +++
tests/roblox_input_router_test.cc | 61 ++++++++++++++++++++
tests/window_pointer_capture_owner_test.cc | 35 +++++++++++
10 files changed, 155 insertions(+), 18 deletions(-)
diff --git a/include/mocktail/platform/platform_runtime.h b/include/mocktail/platform/platform_runtime.h
index 33c9d87..b1817d3 100644
--- a/include/mocktail/platform/platform_runtime.h
+++ b/include/mocktail/platform/platform_runtime.h
@@ -152,6 +152,10 @@ struct MouseMotionEvent {
float delta_x = 0.0f;
float delta_y = 0.0f;
std::uint32_t buttons = 0;
+ // True while the window holds relative mouse mode. The absolute coordinates
+ // are meaningless then: X11 reports zeroes, Wayland reports a position that
+ // accumulates the deltas and can leave the window entirely.
+ bool relative = false;
};
struct MouseButtonEvent {
@@ -160,6 +164,8 @@ struct MouseButtonEvent {
std::uint8_t clicks = 0;
float x = 0.0f;
float y = 0.0f;
+ // See MouseMotionEvent::relative.
+ bool relative = false;
};
struct MouseWheelEvent {
@@ -167,6 +173,8 @@ struct MouseWheelEvent {
float delta_y = 0.0f;
float mouse_x = 0.0f;
float mouse_y = 0.0f;
+ // See MouseMotionEvent::relative.
+ bool relative = false;
};
struct TouchEvent {
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/window/window.cc b/src/window/window.cc
index f88d829..2cbad41 100644
--- a/src/window/window.cc
+++ b/src/window/window.cc
@@ -1488,6 +1488,12 @@ bool SetMouseLockQueryCallback(MouseLockQueryCallback callback, void* context) {
g_pointer_capture_owner->RegisterQuery(callback, context);
}
+void SetGameSessionActive(bool active) {
+ if (g_pointer_capture_owner != nullptr) {
+ g_pointer_capture_owner->SetGameSessionActive(active);
+ }
+}
+
void ClearMouseLockQueryCallback() {
if (g_pointer_capture_owner != nullptr) {
g_pointer_capture_owner->ClearQuery();
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/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/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());
From e073947b919573f8b01ff8ddfc781b9dc65932a1 Mon Sep 17 00:00:00 2001
From: vii
Date: Sun, 20 Sep 2026 12:26:39 -0500
Subject: [PATCH 09/12] fix(window): keep the pointer capture owner alive
across shutdown
Cross-thread callers take a strong reference under a mutex, so a shutdown
racing SetGameSessionActive no longer frees the owner mid-call.
---
src/window/window.cc | 50 ++++++++++++++++++++++++++++++++++----------
1 file changed, 39 insertions(+), 11 deletions(-)
diff --git a/src/window/window.cc b/src/window/window.cc
index 2cbad41..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,19 +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) {
- if (g_pointer_capture_owner != nullptr) {
- g_pointer_capture_owner->SetGameSessionActive(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,
@@ -2342,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();
@@ -2361,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();
From c0ff1842ee9efdf3e7e9418233645366df8e0479 Mon Sep 17 00:00:00 2001
From: vii
Date: Sun, 20 Sep 2026 12:30:00 -0500
Subject: [PATCH 10/12] docs(changelog): record the packaging, input and
shutdown fixes
---
CHANGELOG.md | 29 +++++++++++++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f40e60..da5c660 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,16 @@ GitHub are taken from the matching section here.
## [Unreleased]
+### 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.
@@ -18,6 +28,25 @@ GitHub are taken from the matching section here.
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
From 47e23df2e0e75f00681dd7733ae13612e064bb79 Mon Sep 17 00:00:00 2001
From: vii
Date: Sun, 20 Sep 2026 12:31:39 -0500
Subject: [PATCH 11/12] docs(changelog): release 0.3.1
---
CHANGELOG.md | 5 ++++-
packaging/io.github.CoderDayton.nightcap.metainfo.xml | 11 +++++++++++
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index da5c660..e520eb4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@ 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
@@ -188,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/packaging/io.github.CoderDayton.nightcap.metainfo.xml b/packaging/io.github.CoderDayton.nightcap.metainfo.xml
index 95f0f28..f85ef46 100644
--- a/packaging/io.github.CoderDayton.nightcap.metainfo.xml
+++ b/packaging/io.github.CoderDayton.nightcap.metainfo.xml
@@ -57,6 +57,17 @@
mocktail
+
+
+
+ - Added DEB, RPM, and pacman packages to every release.
+ - Smoother frames: textures no longer decode on the thread that submits the frame.
+ - Fixed the mouse pointer jumping when a right-click camera drag ended.
+ - Fixed the text caret not moving when a trailing space was typed.
+ - Faster typing in text boxes.
+
+
+
From 65adb3bddc9f39f9ada6fda590c70b2502460282 Mon Sep 17 00:00:00 2001
From: vii
Date: Sun, 20 Sep 2026 12:33:26 -0500
Subject: [PATCH 12/12] fix(packaging): fail the release when a package glob
matches nothing
An unmatched glob reached sha256sum and gh as literal text. The publish job
now collects the assets into an array and stops if a package is missing.
---
.github/workflows/release.yml | 24 ++++++++++++++++++------
1 file changed, 18 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2571885..feacf6a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -302,9 +302,25 @@ jobs:
set -Eeuo pipefail
tag="${GITHUB_REF_NAME}"
mv release/Mocktail-x86_64.AppImage release/Nightcap-x86_64.AppImage
+ # 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 Nightcap-x86_64.AppImage *.deb *.rpm *.pkg.tar.zst; do
+ for asset in "${assets[@]}"; do
sha256sum "$asset" > "$asset.sha256"
done
)
@@ -319,8 +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 \
- release/*.deb release/*.deb.sha256 \
- release/*.rpm release/*.rpm.sha256 \
- release/*.pkg.tar.zst release/*.pkg.tar.zst.sha256
+ "${uploads[@]}"