From c614fba0327a0c8d2e5c985a9c53b5454c005ba6 Mon Sep 17 00:00:00 2001 From: vii Date: Thu, 15 Oct 2026 20:24:03 -0500 Subject: [PATCH 1/3] feat(profile): trace emulated ETC2 texture VRAM and report its peak Emulated images keep a running total of their decoded and ETC2/EAC sizes over every mip and layer. --profile writes both as counters, and summarize_profile_trace.py prints their peaks. --- CHANGELOG.md | 2 + docs/BENCHMARKING.md | 6 ++ docs/PERFORMANCE.md | 13 ++- .../mocktail/graphics/vulkan_etc2_emulation.h | 16 +++ scripts/summarize_profile_trace.py | 19 +++- src/graphics/vulkan_etc2_emulation.cc | 97 ++++++++++++++++++- tests/summarize_profile_trace_test.py | 14 +++ tests/vulkan_etc2_emulation_test.cc | 76 +++++++++++++++ 8 files changed, 234 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd2edf..d77a9a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ Release notes on GitHub are taken from the matching section here. maintains it. - The log now names each Roblox Java call that Nightcap has no handler for, once per call, as `[JNI] no handler for .`. This shows what a new Roblox version needs. +- `--profile` traces now show how much GPU memory decoded ETC2 textures take, as the `emulated texture (MB)` + and `compressed texture (MB)` counters. `scripts/summarize_profile_trace.py` prints the peak of each. ### Changed diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index 991742d..b744070 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -68,6 +68,12 @@ the CPU time of every thread together, so dividing it by the frame interval gives the number of cores busy. A long frame with little present-thread CPU was spent waiting. +`emulated texture (MB)` is the VRAM held by the decoded images that stand in +for live ETC2/EAC textures, over every mip level and layer, upscaled where +small-texture upscale applies. `compressed texture (MB)` is the size the same +textures would take as ETC2/EAC. Both change only when such an image is +created or destroyed. `summarize_profile_trace.py` prints each one's peak. + ## The benchmark runs Run every scenario before and after the change, on the same build type diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index fb7656c..dd7e64c 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -129,14 +129,23 @@ runs cooler for a few frames per second less; measure before keeping it. ## Small texture upscaling -`MOCKTAIL_SMALL_TEXTURE_UPSCALE` (default `4`) redraws ETC2 textures of 64px or +`MOCKTAIL_SMALL_TEXTURE_UPSCALE` (default `4`) stores ETC2 textures of 64px or smaller at N times their width and height. At the default that is 16 times the pixels per affected texture. +Upscaling alone adds no detail. Each original pixel becomes an N×N block of the +same colour, and the GPU's filtering already blends a small texture that much +when it draws it. The extra size only shows when a replacement PNG in +`~/.config/mocktail/textures` has more detail than the original. + The cost lands in two places. Decoding and resampling is CPU work on up to 8 threads while textures load, which shows up as a burst rather than a steady load. The enlarged textures then stay in GPU memory for the session and cost -extra sampling bandwidth on every frame that uses them. +extra sampling bandwidth on every frame that uses them. In Blade Ball the +memory share is small: decoded textures came to 5.1 times their ETC2 size both +with the default and with upscaling off. The `emulated texture (MB)` and +`compressed texture (MB)` counters in a `--profile` trace give the figures for +any game. 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 diff --git a/include/mocktail/graphics/vulkan_etc2_emulation.h b/include/mocktail/graphics/vulkan_etc2_emulation.h index c5ca9a5..1005621 100644 --- a/include/mocktail/graphics/vulkan_etc2_emulation.h +++ b/include/mocktail/graphics/vulkan_etc2_emulation.h @@ -30,6 +30,18 @@ bool Etc2UploadByteCounts(EtcFormat format, const VkExtent3D& extent, std::uint32_t layer_count, VkDeviceSize* compressed, VkDeviceSize* decoded); +// Sums every mip level and layer; `scale` multiplies the host image's +// level-0 width and height only. +bool Etc2ImageByteCounts(EtcFormat format, const VkExtent3D& extent, + std::uint32_t mip_levels, std::uint32_t layers, + std::uint32_t scale, VkDeviceSize* compressed, + VkDeviceSize* decoded); + +struct EmulatedTextureBytes { + VkDeviceSize compressed = 0; + VkDeviceSize decoded = 0; +}; + // Whether emulated ETC2 is reported to the application as a supported // feature, from the MOCKTAIL_ADVERTISE_ETC2 value: "0" hides it, anything // else (or unset) reports it. Hidden ETC2 still decodes ETC2 images the @@ -152,6 +164,10 @@ class VulkanEtc2Emulation final { // pending, after waiting for any decode still writing into them. void ReleaseCommandBuffer(VkCommandBuffer command_buffer); + // Traced as the "emulated texture (MB)" and "compressed texture (MB)" + // counters. + EmulatedTextureBytes TextureBytes() const; + struct State; private: diff --git a/scripts/summarize_profile_trace.py b/scripts/summarize_profile_trace.py index 64a2c64..4795377 100755 --- a/scripts/summarize_profile_trace.py +++ b/scripts/summarize_profile_trace.py @@ -6,8 +6,9 @@ Usage: summarize_profile_trace.py TRACE [TRACE ...] Each trace gets its own column, so `before.json after.json` compares them. -Frame times come from the "frame interval (ms)" counter; every other row is -a slice name recorded by the Vulkan adapter. +Frame times come from the "frame interval (ms)" counter, peaks from the +texture counters; every other row is a slice name recorded by the Vulkan +adapter. """ from __future__ import annotations @@ -20,6 +21,7 @@ import sys from typing import Iterable FRAME_COUNTER = "frame interval (ms)" +PEAK_COUNTERS = ("emulated texture (MB)", "compressed texture (MB)") @dataclass(frozen=True) @@ -37,6 +39,7 @@ class Distribution: class Summary: frames: Distribution | None slices: dict[str, Distribution] + peaks: dict[str, float] def _nearest_rank(ordered: list[float], percent: float) -> float: @@ -57,16 +60,21 @@ def _distribution(values: list[float]) -> Distribution: def summarize(events: Iterable[dict]) -> Summary: frames: list[float] = [] slices: dict[str, list[float]] = {} + peaks: dict[str, float] = {} for event in events: phase = event.get("ph") if phase == "C" and event.get("name") == FRAME_COUNTER: frames.append(float(event["args"]["value"])) + elif phase == "C" and event.get("name") in PEAK_COUNTERS: + value = float(event["args"]["value"]) + peaks[event["name"]] = max(peaks.get(event["name"], value), value) elif phase == "X": slices.setdefault(event["name"], []).append(event["dur"] / 1000) return Summary( frames=_distribution(frames) if frames else None, slices={name: _distribution(values) - for name, values in slices.items()}) + for name, values in slices.items()}, + peaks=peaks) def _row(label: str, cells: list[str]) -> str: @@ -96,6 +104,11 @@ def to_markdown(traces: list[tuple[str, Summary]]) -> str: if any(frames): lines += [_metric_row(f"frame interval {field} (ms)", frames, field) for field in ("mean", "p50", "p95", "p99", "max")] + for name in PEAK_COUNTERS: + if any(name in summary.peaks for summary in summaries): + lines.append(_row(f"{name} peak", [ + f"{summary.peaks[name]:.2f}" if name in summary.peaks else "-" + for summary in summaries])) # Largest total time first, taken from whichever trace spent the most. totals: dict[str, float] = {} diff --git a/src/graphics/vulkan_etc2_emulation.cc b/src/graphics/vulkan_etc2_emulation.cc index b989958..0afecc0 100644 --- a/src/graphics/vulkan_etc2_emulation.cc +++ b/src/graphics/vulkan_etc2_emulation.cc @@ -92,6 +92,34 @@ bool Etc2UploadByteCounts(EtcFormat format, const VkExtent3D& extent, return true; } +bool Etc2ImageByteCounts(EtcFormat format, const VkExtent3D& extent, + std::uint32_t mip_levels, std::uint32_t layers, + std::uint32_t scale, VkDeviceSize* compressed, + VkDeviceSize* decoded) { + if (extent.width == 0 || extent.height == 0 || extent.depth == 0 || + mip_levels == 0 || layers == 0 || scale == 0 || compressed == nullptr || + decoded == nullptr) { + return false; + } + const auto level = [](std::uint32_t size, std::uint32_t mip) { + return std::max(1, static_cast(size) >> mip); + }; + VkDeviceSize compressed_total = 0; + VkDeviceSize decoded_total = 0; + for (std::uint32_t mip = 0; mip < mip_levels && mip < 32; ++mip) { + const VkDeviceSize depth = level(extent.depth, mip); + compressed_total += ((level(extent.width, mip) + 3) / 4) * + ((level(extent.height, mip) + 3) / 4) * depth * + EtcBlockBytes(format); + decoded_total += level(extent.width * scale, mip) * + level(extent.height * scale, mip) * depth * + EtcDecodedTexelBytes(format); + } + *compressed = compressed_total * layers; + *decoded = decoded_total * layers; + return true; +} + namespace { bool EmulationDisabledByEnvironment() { @@ -207,8 +235,21 @@ struct ImageRecord { // Decoded level-0 texels of a scaled image; every host mip is resampled // from them so the texture keeps its detail at every draw size. std::shared_ptr base; + EmulatedTextureBytes bytes; }; +// `now` is read under the lock that produced `totals`, so concurrent changes +// reach the trace in the order they happened. +void TraceTextureBytes(const EmulatedTextureBytes& totals, std::uint64_t now) { + ChromeTraceWriter* const trace = ActiveProfileTrace(); + if (trace == nullptr) { + return; + } + constexpr double kMegabyte = 1024.0 * 1024.0; + trace->Counter("emulated texture (MB)", now, totals.decoded / kMegabyte); + trace->Counter("compressed texture (MB)", now, totals.compressed / kMegabyte); +} + bool IsColorFormat(EtcFormat format) { return format == EtcFormat::kEtc2Rgb8 || format == EtcFormat::kEtc2Rgb8A1 || format == EtcFormat::kEtc2Rgba8; @@ -669,6 +710,8 @@ struct VulkanEtc2Emulation::State { std::unordered_map physical_devices; std::vector> devices; std::unordered_map images; + // Sum over `images`; guarded by `mutex`. + EmulatedTextureBytes texture_bytes; std::unordered_map buffers; std::unordered_map mappings; std::unordered_map commands; @@ -1245,6 +1288,8 @@ void VulkanEtc2Emulation::DestroyDevice(VkDevice device) { // Nothing may free staging or the semaphore while a decode still writes it. state_->DrainDecodes(); std::vector> doomed; + EmulatedTextureBytes totals; + std::uint64_t now = 0; { std::lock_guard lock(state_->mutex); for (auto it = state_->commands.begin(); it != state_->commands.end();) { @@ -1265,11 +1310,20 @@ void VulkanEtc2Emulation::DestroyDevice(VkDevice device) { } } for (auto it = state_->images.begin(); it != state_->images.end();) { - it = it->second.device == device ? state_->images.erase(it) : std::next(it); + if (it->second.device == device) { + state_->texture_bytes.compressed -= it->second.bytes.compressed; + state_->texture_bytes.decoded -= it->second.bytes.decoded; + it = state_->images.erase(it); + } else { + ++it; + } } + totals = state_->texture_bytes; + now = TraceClockNanos(); state_->has_commands.store(!state_->commands.empty(), std::memory_order_release); } + TraceTextureBytes(totals, now); if (const HostDevice* dev = state_->Find(device); dev != nullptr) { for (const auto& block : doomed) { DestroyStagingBlock(*dev, *block); @@ -1316,6 +1370,14 @@ VkResult VulkanEtc2Emulation::CreateImage(VkDevice device, } const VkResult result = dev->create_image(device, &host_info, allocator, image); if (result == VK_SUCCESS && image != nullptr) { + EmulatedTextureBytes bytes; + if (!Etc2ImageByteCounts(format.etc_format, create_info->extent, + create_info->mipLevels, create_info->arrayLayers, + scale, &bytes.compressed, &bytes.decoded)) { + bytes = {}; + } + EmulatedTextureBytes totals; + std::uint64_t now = 0; { std::lock_guard lock(state_->mutex); state_->images[*image] = {device, @@ -1324,8 +1386,14 @@ VkResult VulkanEtc2Emulation::CreateImage(VkDevice device, create_info->extent.width, create_info->extent.height, nullptr, - nullptr}; + nullptr, + bytes}; + state_->texture_bytes.compressed += bytes.compressed; + state_->texture_bytes.decoded += bytes.decoded; + totals = state_->texture_bytes; + now = TraceClockNanos(); } + TraceTextureBytes(totals, now); if (ShouldLog(&g_image_logs, 4)) { std::fprintf(stderr, " [vulkan] ETC2 image %ux%u mips=%u format=%d -> host " @@ -1346,12 +1414,33 @@ void VulkanEtc2Emulation::DestroyImage(VkDevice device, VkImage image, return; } if (dev->emulated) { - std::lock_guard lock(state_->mutex); - state_->images.erase(image); + EmulatedTextureBytes totals; + std::uint64_t now = 0; + bool changed = false; + { + std::lock_guard lock(state_->mutex); + const auto it = state_->images.find(image); + if (it != state_->images.end()) { + state_->texture_bytes.compressed -= it->second.bytes.compressed; + state_->texture_bytes.decoded -= it->second.bytes.decoded; + state_->images.erase(it); + totals = state_->texture_bytes; + now = TraceClockNanos(); + changed = true; + } + } + if (changed) { + TraceTextureBytes(totals, now); + } } dev->destroy_image(device, image, allocator); } +EmulatedTextureBytes VulkanEtc2Emulation::TextureBytes() const { + std::lock_guard lock(state_->mutex); + return state_->texture_bytes; +} + VkResult VulkanEtc2Emulation::CreateImageView( VkDevice device, const VkImageViewCreateInfo* create_info, const VkAllocationCallbacks* allocator, VkImageView* view) { diff --git a/tests/summarize_profile_trace_test.py b/tests/summarize_profile_trace_test.py index b15d777..c431df0 100644 --- a/tests/summarize_profile_trace_test.py +++ b/tests/summarize_profile_trace_test.py @@ -89,6 +89,20 @@ class SummarizeTest(unittest.TestCase): self.assertIn("| frame interval p50 (ms) | 20.00 | 10.00 |", lines) self.assertIn("| a mean (ms) | 1.00 | 0.50 |", lines) + def test_texture_counters_report_their_peak(self) -> None: + events = [ + {"name": "emulated texture (MB)", "ph": "C", "ts": ts, "pid": 1, + "tid": 1, "args": {"value": value}} + for ts, value in ((1, 10.0), (2, 300.0), (3, 120.0)) + ] + with_textures = summary.summarize(events) + without = summary.summarize([]) + + self.assertEqual(with_textures.peaks["emulated texture (MB)"], 300.0) + table = summary.to_markdown([("one", with_textures), ("two", without)]) + self.assertIn("| emulated texture (MB) peak | 300.00 | - |", + table.splitlines()) + def test_markdown_marks_missing_values(self) -> None: with_slice = summary.summarize([slice_event("a", 1_000)]) without = summary.summarize([]) diff --git a/tests/vulkan_etc2_emulation_test.cc b/tests/vulkan_etc2_emulation_test.cc index a9ee767..c6845ac 100644 --- a/tests/vulkan_etc2_emulation_test.cc +++ b/tests/vulkan_etc2_emulation_test.cc @@ -102,6 +102,37 @@ TEST(VulkanEtc2EmulationTest, CountsUploadBytesPerRegion) { EXPECT_EQ(decoded, 2U); } +TEST(VulkanEtc2EmulationTest, CountsWholeImageBytesAcrossMipsAndLayers) { + VkDeviceSize compressed = 0; + VkDeviceSize decoded = 0; + // 64x64 with a full chain: 64, 32, 16, 8, 4, 2, 1. + ASSERT_TRUE(Etc2ImageByteCounts(EtcFormat::kEtc2Rgb8, {64, 64, 1}, 7, 1, 1, + &compressed, &decoded)); + EXPECT_EQ(compressed, 343U * 8U); + EXPECT_EQ(decoded, 5461U * 4U); + + // Two layers of EAC R11, 2 bytes per decoded texel. + ASSERT_TRUE(Etc2ImageByteCounts(EtcFormat::kEacR11, {8, 4, 1}, 1, 2, 1, + &compressed, &decoded)); + EXPECT_EQ(compressed, 2U * 2U * 8U); + EXPECT_EQ(decoded, 2U * 32U * 2U); +} + +TEST(VulkanEtc2EmulationTest, CountsDecodedBytesAtTheUpscaledExtent) { + VkDeviceSize compressed = 0; + VkDeviceSize decoded = 0; + // The host image is 256x256 with the same 7 levels: 256 down to 4. + ASSERT_TRUE(Etc2ImageByteCounts(EtcFormat::kEtc2Rgba8, {64, 64, 1}, 7, 1, 4, + &compressed, &decoded)); + EXPECT_EQ(compressed, 343U * 16U); + EXPECT_EQ(decoded, 87376U * 4U); + + EXPECT_FALSE(Etc2ImageByteCounts(EtcFormat::kEtc2Rgb8, {0, 4, 1}, 1, 1, 1, + &compressed, &decoded)); + EXPECT_FALSE(Etc2ImageByteCounts(EtcFormat::kEtc2Rgb8, {4, 4, 1}, 0, 1, 1, + &compressed, &decoded)); +} + TEST(VulkanEtc2EmulationTest, RejectsUnsupportedRegions) { VkDeviceSize compressed = 0; VkDeviceSize decoded = 0; @@ -147,6 +178,9 @@ VKAPI_ATTR VkResult VKAPI_CALL FakeCreateImage(VkDevice, return VK_SUCCESS; } +VKAPI_ATTR void VKAPI_CALL FakeDestroyImage(VkDevice, VkImage, + const VkAllocationCallbacks*) {} + VKAPI_ATTR void VKAPI_CALL FakeCopyImage(VkCommandBuffer, VkImage, VkImageLayout, VkImage, VkImageLayout, std::uint32_t, const VkImageCopy*) { @@ -295,6 +329,7 @@ VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL FakeGetDeviceProcAddr( if (is("vkDestroySemaphore")) return reinterpret_cast(FakeDestroySemaphore); if (is("vkCreateImage")) return reinterpret_cast(FakeCreateImage); + if (is("vkDestroyImage")) return reinterpret_cast(FakeDestroyImage); if (is("vkCreateBuffer")) return reinterpret_cast(FakeCreateBuffer); if (is("vkGetBufferMemoryRequirements")) return reinterpret_cast(FakeBufferRequirements); @@ -313,6 +348,47 @@ VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL FakeGetDeviceProcAddr( return nullptr; } +TEST(VulkanEtc2EmulationTest, TotalsTheBytesOfLiveEmulatedImages) { + const VkDevice device = FakeHandle(0x12); + VkPhysicalDeviceMemoryProperties memory{}; + VulkanEtc2Emulation emulation; + emulation.RegisterDevice(device, FakeHandle(0x20), true, + memory, FakeGetDeviceProcAddr); + + VkImageCreateInfo image_info{}; + image_info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + image_info.imageType = VK_IMAGE_TYPE_3D; + image_info.format = VK_FORMAT_ETC2_R8G8B8_UNORM_BLOCK; + image_info.extent = {8, 8, 1}; + image_info.mipLevels = 1; + image_info.arrayLayers = 1; + VkImage first = VK_NULL_HANDLE; + VkImage second = VK_NULL_HANDLE; + ASSERT_EQ(emulation.CreateImage(device, &image_info, nullptr, &first), + VK_SUCCESS); + ASSERT_EQ(emulation.CreateImage(device, &image_info, nullptr, &second), + VK_SUCCESS); + image_info.format = VK_FORMAT_R8G8B8A8_UNORM; + VkImage plain = VK_NULL_HANDLE; + ASSERT_EQ(emulation.CreateImage(device, &image_info, nullptr, &plain), + VK_SUCCESS); + + EmulatedTextureBytes totals = emulation.TextureBytes(); + EXPECT_EQ(totals.compressed, 2U * 4U * 8U); + EXPECT_EQ(totals.decoded, 2U * 64U * 4U); + + emulation.DestroyImage(device, first, nullptr); + emulation.DestroyImage(device, plain, nullptr); + totals = emulation.TextureBytes(); + EXPECT_EQ(totals.compressed, 4U * 8U); + EXPECT_EQ(totals.decoded, 64U * 4U); + + emulation.DestroyDevice(device); + totals = emulation.TextureBytes(); + EXPECT_EQ(totals.compressed, 0U); + EXPECT_EQ(totals.decoded, 0U); +} + TEST(VulkanEtc2EmulationTest, SkipsUploadOutsideTheMappedWindow) { const VkDevice device = FakeHandle(0x10); const VkBuffer source = FakeHandle(0x200); From 876f8b8c32cad5e244cf46b22fe7ba52a32b5dba Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 16 Oct 2026 00:03:03 -0500 Subject: [PATCH 2/3] perf(runtime): compile menu Lua to native code and switch it off in games --- CHANGELOG.md | 3 + CMakeLists.txt | 1 + README.md | 5 + docs/PERFORMANCE.md | 31 +++++ include/runtime/luau_codegen_switch.h | 68 ++++++++++ src/legacy/legacy_runtime.cc | 24 +++- src/main.cc | 13 +- src/runtime/luau_codegen_switch.cc | 150 +++++++++++++++++++++ src/runtime/roblox_desktop_app_policy.cc | 4 + tests/CMakeLists.txt | 8 ++ tests/luau_codegen_switch_test.cc | 162 +++++++++++++++++++++++ tests/roblox_desktop_app_policy_test.cc | 16 +++ 12 files changed, 481 insertions(+), 4 deletions(-) create mode 100644 include/runtime/luau_codegen_switch.h create mode 100644 src/runtime/luau_codegen_switch.cc create mode 100644 tests/luau_codegen_switch_test.cc diff --git a/CHANGELOG.md b/CHANGELOG.md index d77a9a5..11706cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ Release notes on GitHub are taken from the matching section here. - Nightcap answers Roblox's Java calls with less work per call, and the audio thread no longer allocates memory for each block of sound it plays. +- The home screen and avatar editor have fewer long frames. PC profiles compile the app's menu scripts to + native code and turn this off while you are in a game, so games run as before. Set + `FFlagDebugLuauCodegenAll` in `fflags.json` to choose it yourself. ## [0.3.4] - 2026-10-11 diff --git a/CMakeLists.txt b/CMakeLists.txt index 50e4063..2efdf5d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -279,6 +279,7 @@ add_library(mocktail_runtime STATIC src/runtime/game_session_coordinator.cc src/runtime/graphics_launch_policy.cc src/runtime/http_client_policy.cc + src/runtime/luau_codegen_switch.cc src/runtime/memory_limit.cc src/runtime/owned_pthread.cc src/runtime/performance_policy.cc diff --git a/README.md b/README.md index 4dd1b2a..c91e25d 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,11 @@ caused by `Color3` errors in the newer SDUI page. An explicit PC profiles also set `FIntFlyoutTestVariantRollout12` to `2`, which shows the desktop side panel menu. An explicit override takes precedence. +PC profiles compile the app's menus to native code with +`FFlagDebugLuauCodegenAll`, and turn it off while you are in a game. Setting +it yourself turns the switching off and your value applies everywhere. See +[docs/PERFORMANCE.md](docs/PERFORMANCE.md#native-code-in-the-menus). + ```json { "FFlagExample": "True", diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index dd7e64c..a78a1cf 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -160,6 +160,37 @@ Set it to `1` to turn upscaling off: MOCKTAIL_SMALL_TEXTURE_UPSCALE=1 mocktail ``` +## Native code in the menus + +The home screen and avatar editor run Roblox's Lua app in the Luau interpreter, +and its long frames come from that thread. PC profiles set +`FFlagDebugLuauCodegenAll`, which compiles every script to native code. That +makes the menus' Lua about 2.5 times cheaper for about 56 MB of compiled code. + +In a game the same flag compiles every game script too: Blade Ball grew from 7 +MB to about 300 MB and hit Roblox's native code limit, which shows warnings in +F9. So Nightcap turns the flag off when a game starts joining and back on when +you return to the menus. Roblox reads the flag each time it compiles a script, +and a game's compiled code is freed when you leave it. Games run with Roblox's +own per-script choice, the same as without the flag. + +The log shows each switch: + +```text +[luau-codegen] menus-only switch at +0x +[luau-codegen] native code for all scripts off +[luau-codegen] native code for all scripts on +``` + +Setting `FFlagDebugLuauCodegenAll` in `fflags.json` turns the switching off +and your value applies everywhere. To keep the interpreter in the menus too: + +```json +{ + "FFlagDebugLuauCodegenAll": false +} +``` + ## Frame rate and present mode `graphics.frame_rate_limit` (default `-1`) leaves Roblox's own in-game diff --git a/include/runtime/luau_codegen_switch.h b/include/runtime/luau_codegen_switch.h new file mode 100644 index 0000000..5f9dc9f --- /dev/null +++ b/include/runtime/luau_codegen_switch.h @@ -0,0 +1,68 @@ +#ifndef MOCKTAIL_RUNTIME_LUAU_CODEGEN_SWITCH_H_ +#define MOCKTAIL_RUNTIME_LUAU_CODEGEN_SWITCH_H_ + +#include +#include +#include +#include +#include +#include + +#include "runtime/roblox_experience_presence.h" + +namespace mocktail { +namespace runtime { + +// Nightcap defaults this on and keeps it on only outside games; a user value +// set in fflags.json or the overrides environment disables the switching. +inline constexpr std::string_view kLuauCodegenAllClientSetting = + "FFlagDebugLuauCodegenAll"; +inline constexpr std::string_view kLuauCodegenAllFastFlagName = + "DebugLuauCodegenAll"; + +struct LoadedImageSegment { + std::uintptr_t address = 0; + std::size_t size = 0; + bool writable = false; +}; + +std::vector LoadedImageSegments(std::uintptr_t image_base); + +// Record layout: {u8 value; pad; const char* name; size_t length}, in a +// writable segment filled by libroblox's static constructors. Returns null +// unless exactly one record names a NUL-terminated copy of `name`. +std::uint8_t* FindFastFlagValue( + const std::vector& segments, std::string_view name); + +bool ClientSettingsSetLuauCodegenAll(std::string_view overrides_json); + +bool LuauCodegenAllForPhase(RobloxExperiencePresencePhase phase); + +// Locates the flag on the first notification; a byte that is not 0 or 1 is +// left alone. +class LuauCodegenMenuSwitch { + public: + using LocateFlagFn = std::function; + + LuauCodegenMenuSwitch(LocateFlagFn locate_flag, + RobloxExperiencePresenceObserver next); + + RobloxExperiencePresenceObserver observer(); + void OnPhase(RobloxExperiencePresencePhase phase, + const RobloxExperienceLaunchRequest* request); + + private: + static void Notify(void* context, RobloxExperiencePresencePhase phase, + const RobloxExperienceLaunchRequest* request); + + std::mutex mutex_; + LocateFlagFn locate_flag_; + bool located_ = false; + std::uint8_t* flag_ = nullptr; + const RobloxExperiencePresenceObserver next_; +}; + +} // namespace runtime +} // namespace mocktail + +#endif // MOCKTAIL_RUNTIME_LUAU_CODEGEN_SWITCH_H_ diff --git a/src/legacy/legacy_runtime.cc b/src/legacy/legacy_runtime.cc index 4a578a2..d444d19 100644 --- a/src/legacy/legacy_runtime.cc +++ b/src/legacy/legacy_runtime.cc @@ -66,6 +66,7 @@ #include "runtime/display_size.h" #include "runtime/environment.h" #include "runtime/discord_rpc.h" +#include "runtime/luau_codegen_switch.h" #include "runtime/jnivm_platform_web_callbacks.h" #include "runtime/owned_pthread.h" #include "runtime/platform_cache_migration.h" @@ -4656,6 +4657,27 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options, << "; continuing without Rich Presence\n"; } } + mocktail::runtime::LuauCodegenMenuSwitch luau_codegen_switch( + [&process_environment]() -> std::uint8_t* { + if (process_environment.GetOr("MOCKTAIL_LUAU_CODEGEN_MENU_SWITCH", + "0") != "1") { + return nullptr; + } + const std::uintptr_t base = g_libroblox_base; + std::uint8_t* flag = mocktail::runtime::FindFastFlagValue( + mocktail::runtime::LoadedImageSegments(base), + mocktail::runtime::kLuauCodegenAllFastFlagName); + if (flag == nullptr) { + std::cerr << " [luau-codegen] DebugLuauCodegenAll not found; " + "leaving it on in games\n"; + } else { + std::cout << " [luau-codegen] menus-only switch at +0x" << std::hex + << (reinterpret_cast(flag) - base) + << std::dec << '\n'; + } + return flag; + }, + discord_rpc.observer()); const mocktail::runtime::InputCapabilityConfig& input_capabilities = runtime_config.input_capabilities(); if (!runtime_config.frame_rate().valid()) { @@ -6834,7 +6856,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options, jni_factory, present_boundary, std::move(surface_config), &dependencies.roblox_credential(), mocktail::runtime::RobloxExperienceSurfaceProvider{}, - discord_rpc.observer(), + luau_codegen_switch.observer(), dependencies.clear_persisted_web_view_cookie(), runtime_config.microphone_enabled(), platform_web_symbols.account); diff --git a/src/main.cc b/src/main.cc index 500b206..f1c9dfd 100644 --- a/src/main.cc +++ b/src/main.cc @@ -33,6 +33,7 @@ #include "runtime/fleasion.h" #include "runtime/game_mode.h" #include "runtime/graphics_launch_policy.h" +#include "runtime/luau_codegen_switch.h" #include "runtime/memory_limit.h" #include "runtime/payload_update_preflight.h" #include "runtime/performance_policy.h" @@ -1012,6 +1013,7 @@ int main(int argc, char* argv[]) { std::cout << " [auth] explicit guest identity selected for production VM\n"; } + unsetenv("MOCKTAIL_LUAU_CODEGEN_MENU_SWITCH"); if (runtime_config.config.desktop_playability()) { std::filesystem::path assets = paths.DefaultAssetPath(); if (environment.HasNonEmpty("MOCKTAIL_ASSET_PATH")) { @@ -1034,10 +1036,13 @@ int main(int argc, char* argv[]) { return EXIT_FAILURE; } std::string desktop_client_settings; + const std::string user_client_settings = + environment.GetOr("MOCKTAIL_CLIENT_SETTINGS_OVERRIDES_JSON", "{}"); + const bool user_set_luau_codegen = + mocktail::runtime::ClientSettingsSetLuauCodegenAll( + user_client_settings); if (!mocktail::runtime::MergeDesktopAppPolicyClientSettingsOverride( - desktop_policy.policy_json, - environment.GetOr("MOCKTAIL_CLIENT_SETTINGS_OVERRIDES_JSON", - "{}"), + desktop_policy.policy_json, user_client_settings, &desktop_client_settings, &command_line_error)) { std::cerr << "[FATAL] Cannot activate desktop Roblox app-policy: " << command_line_error << '\n'; @@ -1048,6 +1053,8 @@ int main(int argc, char* argv[]) { std::cerr << "[FATAL] Cannot export desktop Roblox app-policy\n"; return EXIT_FAILURE; } + setenv("MOCKTAIL_LUAU_CODEGEN_MENU_SWITCH", + user_set_luau_codegen ? "0" : "1", 1); std::cout << " [runtime] desktop app-policy ready: normalized=" << desktop_policy.normalized_policy_count << " updated=" << (desktop_policy.updated ? 1 : 0) diff --git a/src/runtime/luau_codegen_switch.cc b/src/runtime/luau_codegen_switch.cc new file mode 100644 index 0000000..7dca0c7 --- /dev/null +++ b/src/runtime/luau_codegen_switch.cc @@ -0,0 +1,150 @@ +#include "runtime/luau_codegen_switch.h" + +#include + +#include +#include +#include +#include +#include + +#include + +namespace mocktail { +namespace runtime { +namespace { + +constexpr std::size_t kNameSlotOffset = 8; +constexpr std::size_t kRecordTailSize = 16; + +std::string_view SegmentBytes(const LoadedImageSegment& segment) { + return {reinterpret_cast(segment.address), segment.size}; +} + +std::vector NameAddresses( + const std::vector& segments, std::string_view name) { + std::string needle(name); + needle.push_back('\0'); + std::vector addresses; + for (const LoadedImageSegment& segment : segments) { + const std::string_view bytes = SegmentBytes(segment); + for (std::size_t at = bytes.find(needle); at != std::string_view::npos; + at = bytes.find(needle, at + 1)) { + addresses.push_back(segment.address + at); + } + } + return addresses; +} + +} // namespace + +std::vector LoadedImageSegments(std::uintptr_t image_base) { + std::vector segments; + if (image_base == 0) { + return segments; + } + const auto* header = reinterpret_cast(image_base); + if (std::memcmp(header->e_ident, ELFMAG, SELFMAG) != 0 || + header->e_ident[EI_CLASS] != ELFCLASS64 || header->e_phoff == 0) { + return segments; + } + const auto* program = reinterpret_cast( + image_base + static_cast(header->e_phoff)); + for (std::size_t index = 0; index < header->e_phnum; ++index) { + if (program[index].p_type != PT_LOAD || program[index].p_memsz == 0) { + continue; + } + segments.push_back({image_base + program[index].p_vaddr, + static_cast(program[index].p_memsz), + (program[index].p_flags & PF_W) != 0}); + } + return segments; +} + +std::uint8_t* FindFastFlagValue(const std::vector& segments, + std::string_view name) { + const std::vector names = NameAddresses(segments, name); + if (names.empty()) { + return nullptr; + } + std::uint8_t* found = nullptr; + int matches = 0; + for (const LoadedImageSegment& segment : segments) { + if (!segment.writable || segment.size < kNameSlotOffset + kRecordTailSize) { + continue; + } + const std::uintptr_t first = + (segment.address + kNameSlotOffset + 7) & ~std::uintptr_t{7}; + const std::uintptr_t end = segment.address + segment.size; + for (std::uintptr_t slot = first; slot + kRecordTailSize <= end; + slot += 8) { + std::uintptr_t pointer = 0; + std::memcpy(&pointer, reinterpret_cast(slot), + sizeof(pointer)); + if (std::find(names.begin(), names.end(), pointer) == names.end()) { + continue; + } + std::uint64_t length = 0; + std::memcpy(&length, reinterpret_cast(slot + 8), + sizeof(length)); + if (length != name.size()) { + continue; + } + found = reinterpret_cast(slot - kNameSlotOffset); + ++matches; + } + } + return matches == 1 ? found : nullptr; +} + +bool ClientSettingsSetLuauCodegenAll(std::string_view overrides_json) { + const nlohmann::json overrides = + nlohmann::json::parse(overrides_json, nullptr, false, true); + return overrides.is_object() && + overrides.contains(std::string(kLuauCodegenAllClientSetting)); +} + +bool LuauCodegenAllForPhase(RobloxExperiencePresencePhase phase) { + return phase == RobloxExperiencePresencePhase::kBrowsing; +} + +LuauCodegenMenuSwitch::LuauCodegenMenuSwitch( + LocateFlagFn locate_flag, RobloxExperiencePresenceObserver next) + : locate_flag_(std::move(locate_flag)), next_(next) {} + +RobloxExperiencePresenceObserver LuauCodegenMenuSwitch::observer() { + return {this, &LuauCodegenMenuSwitch::Notify}; +} + +void LuauCodegenMenuSwitch::OnPhase( + RobloxExperiencePresencePhase phase, + const RobloxExperienceLaunchRequest* request) { + { + std::lock_guard lock(mutex_); + if (!located_) { + located_ = true; + flag_ = locate_flag_ ? locate_flag_() : nullptr; + } + if (flag_ != nullptr) { + const std::uint8_t current = __atomic_load_n(flag_, __ATOMIC_RELAXED); + const std::uint8_t wanted = LuauCodegenAllForPhase(phase) ? 1 : 0; + if (current <= 1 && current != wanted) { + __atomic_store_n(flag_, wanted, __ATOMIC_RELAXED); + std::cout << " [luau-codegen] native code for all scripts " + << (wanted != 0 ? "on" : "off") << '\n'; + } + } + } + if (next_.valid()) { + next_.notify(next_.context, phase, request); + } +} + +void LuauCodegenMenuSwitch::Notify( + void* context, RobloxExperiencePresencePhase phase, + const RobloxExperienceLaunchRequest* request) { + static_cast(context)->OnPhase(phase, request); +} + +} // namespace runtime +} // namespace mocktail diff --git a/src/runtime/roblox_desktop_app_policy.cc b/src/runtime/roblox_desktop_app_policy.cc index 28ebfa8..9c85cbc 100644 --- a/src/runtime/roblox_desktop_app_policy.cc +++ b/src/runtime/roblox_desktop_app_policy.cc @@ -14,6 +14,7 @@ #include #include +#include "runtime/luau_codegen_switch.h" #include "runtime/runtime_paths.h" namespace mocktail { @@ -357,6 +358,9 @@ bool MergeDesktopAppPolicyClientSettingsOverride(std::string_view policy_json, return false; } overrides[std::string(kDesktopAppPolicyOverride)] = policy_json; + // Native Luau code makes the app's menus faster. LuauCodegenMenuSwitch turns + // it off in games, which keep Roblox's own per-script choice. + overrides.emplace(std::string(kLuauCodegenAllClientSetting), "True"); // The SDUI Charts page in 2.736/2.738 can pass a table to TextColor3 and // leave the whole LuaApp blank. Default to the working legacy page while // preserving explicit user overrides for testing newer UI patches. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cb2e7f9..2305b73 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -279,6 +279,13 @@ target_link_libraries(roblox_desktop_app_policy_test PRIVATE ) mocktail_apply_compile_options(roblox_desktop_app_policy_test) +add_executable(luau_codegen_switch_test luau_codegen_switch_test.cc) +target_link_libraries(luau_codegen_switch_test PRIVATE + Mocktail::Runtime + GTest::gtest_main +) +mocktail_apply_compile_options(luau_codegen_switch_test) + add_executable(browser_tracker_service_test browser_tracker_service_test.cc) target_link_libraries(browser_tracker_service_test PRIVATE Mocktail::Services @@ -1143,6 +1150,7 @@ if(MOCKTAIL_ENABLE_UPSTREAM_JNIVM) endif() gtest_discover_tests(libc_shim_test) gtest_discover_tests(discord_rpc_test) +gtest_discover_tests(luau_codegen_switch_test) gtest_discover_tests(asset_manager_test) gtest_discover_tests(android_looper_test) gtest_discover_tests(android_native_window_test) diff --git a/tests/luau_codegen_switch_test.cc b/tests/luau_codegen_switch_test.cc new file mode 100644 index 0000000..41ebd46 --- /dev/null +++ b/tests/luau_codegen_switch_test.cc @@ -0,0 +1,162 @@ +#include "runtime/luau_codegen_switch.h" + +#include +#include + +#include +#include +#include +#include + +namespace mocktail::runtime { +namespace { + +constexpr char kStrings[] = "\0FFlagOther\0DebugLuauCodegenAll\0tail"; + +struct alignas(8) FlagRecord { + std::uint8_t value = 1; + std::uint8_t padding[7] = {}; + const char* name = nullptr; + std::uint64_t name_length = 0; + std::uint64_t next = 0; +}; + +const char* NameInStrings() { + return kStrings + 12; +} + +std::vector Segments(std::vector& records) { + return { + {reinterpret_cast(kStrings), sizeof(kStrings), false}, + {reinterpret_cast(records.data()), + records.size() * sizeof(FlagRecord), true}, + }; +} + +TEST(LuauCodegenSwitchTest, FindsTheFlagRecordByName) { + ASSERT_EQ(std::string(NameInStrings()), "DebugLuauCodegenAll"); + std::vector records(3); + records[1].name = NameInStrings(); + records[1].name_length = 19; + const auto segments = Segments(records); + EXPECT_EQ(FindFastFlagValue(segments, kLuauCodegenAllFastFlagName), + &records[1].value); +} + +TEST(LuauCodegenSwitchTest, RejectsWrongLengthMissingOrDuplicateRecords) { + std::vector records(3); + records[1].name = NameInStrings(); + records[1].name_length = 18; + auto segments = Segments(records); + EXPECT_EQ(FindFastFlagValue(segments, kLuauCodegenAllFastFlagName), nullptr); + + records[1].name_length = 19; + EXPECT_EQ(FindFastFlagValue(segments, "DebugLuauCodegen"), nullptr); + + records[2].name = NameInStrings(); + records[2].name_length = 19; + EXPECT_EQ(FindFastFlagValue(segments, kLuauCodegenAllFastFlagName), nullptr); +} + +TEST(LuauCodegenSwitchTest, IgnoresRecordsInReadOnlySegments) { + std::vector records(1); + records[0].name = NameInStrings(); + records[0].name_length = 19; + auto segments = Segments(records); + segments[1].writable = false; + EXPECT_EQ(FindFastFlagValue(segments, kLuauCodegenAllFastFlagName), nullptr); +} + +TEST(LuauCodegenSwitchTest, ReadsLoadSegmentsFromTheElfHeader) { + alignas(8) std::uint8_t image[0x400] = {}; + auto* header = reinterpret_cast(image); + std::memcpy(header->e_ident, ELFMAG, SELFMAG); + header->e_ident[EI_CLASS] = ELFCLASS64; + header->e_phoff = sizeof(Elf64_Ehdr); + header->e_phnum = 3; + auto* program = reinterpret_cast(image + header->e_phoff); + program[0] = {PT_LOAD, PF_R | PF_X, 0, 0, 0, 0x200, 0x200, 0x1000}; + program[1] = {PT_DYNAMIC, PF_R | PF_W, 0, 0x200, 0, 0x10, 0x10, 8}; + program[2] = {PT_LOAD, PF_R | PF_W, 0, 0x200, 0, 0x80, 0x180, 0x1000}; + const auto base = reinterpret_cast(image); + const auto segments = LoadedImageSegments(base); + ASSERT_EQ(segments.size(), 2u); + EXPECT_EQ(segments[0].address, base); + EXPECT_EQ(segments[0].size, 0x200u); + EXPECT_FALSE(segments[0].writable); + EXPECT_EQ(segments[1].address, base + 0x200); + EXPECT_EQ(segments[1].size, 0x180u); + EXPECT_TRUE(segments[1].writable); + + std::memset(image, 0, SELFMAG); + EXPECT_TRUE(LoadedImageSegments(base).empty()); +} + +TEST(LuauCodegenSwitchTest, DetectsAUserSetting) { + EXPECT_FALSE(ClientSettingsSetLuauCodegenAll("{}")); + EXPECT_FALSE(ClientSettingsSetLuauCodegenAll(R"({"FIntOther":1})")); + EXPECT_FALSE(ClientSettingsSetLuauCodegenAll("not-json")); + EXPECT_TRUE( + ClientSettingsSetLuauCodegenAll(R"({"FFlagDebugLuauCodegenAll":true})")); + EXPECT_TRUE( + ClientSettingsSetLuauCodegenAll(R"({"FFlagDebugLuauCodegenAll":"False"})")); +} + +TEST(LuauCodegenSwitchTest, EnablesCodegenOnlyWhileBrowsing) { + EXPECT_TRUE(LuauCodegenAllForPhase(RobloxExperiencePresencePhase::kBrowsing)); + EXPECT_FALSE(LuauCodegenAllForPhase(RobloxExperiencePresencePhase::kJoining)); + EXPECT_FALSE(LuauCodegenAllForPhase(RobloxExperiencePresencePhase::kPlaying)); +} + +struct Forwarded { + int count = 0; + RobloxExperiencePresencePhase phase = RobloxExperiencePresencePhase::kBrowsing; +}; + +void RecordForward(void* context, RobloxExperiencePresencePhase phase, + const RobloxExperienceLaunchRequest*) { + auto* forwarded = static_cast(context); + ++forwarded->count; + forwarded->phase = phase; +} + +TEST(LuauCodegenSwitchTest, WritesTheFlagPerPhaseAndForwards) { + std::uint8_t flag = 1; + int locate_calls = 0; + Forwarded forwarded; + LuauCodegenMenuSwitch menu_switch( + [&] { + ++locate_calls; + return &flag; + }, + {&forwarded, &RecordForward}); + const RobloxExperiencePresenceObserver observer = menu_switch.observer(); + ASSERT_TRUE(observer.valid()); + + observer.notify(observer.context, RobloxExperiencePresencePhase::kJoining, + nullptr); + EXPECT_EQ(flag, 0); + observer.notify(observer.context, RobloxExperiencePresencePhase::kPlaying, + nullptr); + EXPECT_EQ(flag, 0); + observer.notify(observer.context, RobloxExperiencePresencePhase::kBrowsing, + nullptr); + EXPECT_EQ(flag, 1); + EXPECT_EQ(locate_calls, 1); + EXPECT_EQ(forwarded.count, 3); + EXPECT_EQ(forwarded.phase, RobloxExperiencePresencePhase::kBrowsing); +} + +TEST(LuauCodegenSwitchTest, LeavesUnexpectedValuesAndMissingFlagsAlone) { + std::uint8_t flag = 7; + LuauCodegenMenuSwitch menu_switch([&] { return &flag; }, {}); + menu_switch.OnPhase(RobloxExperiencePresencePhase::kJoining, nullptr); + EXPECT_EQ(flag, 7); + + LuauCodegenMenuSwitch missing([] { return nullptr; }, {}); + missing.OnPhase(RobloxExperiencePresencePhase::kJoining, nullptr); + EXPECT_TRUE(missing.observer().valid()); +} + +} // namespace +} // namespace mocktail::runtime diff --git a/tests/roblox_desktop_app_policy_test.cc b/tests/roblox_desktop_app_policy_test.cc index 997d57a..db17731 100644 --- a/tests/roblox_desktop_app_policy_test.cc +++ b/tests/roblox_desktop_app_policy_test.cc @@ -236,6 +236,22 @@ TEST(RobloxDesktopAppPolicyTest, DefaultsDesktopFlyoutSidePanelUnlessOverridden) "0"); } +TEST(RobloxDesktopAppPolicyTest, DefaultsLuauCodegenAllUnlessOverridden) { + const std::string policy = + nlohmann::json({{"PlatformGroup", "Desktop"}}).dump(); + std::string merged; + std::string error; + ASSERT_TRUE(MergeDesktopAppPolicyClientSettingsOverride(policy, "{}", &merged, + &error)) + << error; + EXPECT_EQ(nlohmann::json::parse(merged)["FFlagDebugLuauCodegenAll"], "True"); + + ASSERT_TRUE(MergeDesktopAppPolicyClientSettingsOverride( + policy, R"({"FFlagDebugLuauCodegenAll":false})", &merged, &error)) + << error; + EXPECT_EQ(nlohmann::json::parse(merged)["FFlagDebugLuauCodegenAll"], false); +} + TEST(RobloxDesktopAppPolicyTest, RejectsMalformedOrSymlinkedStorage) { TemporaryDirectory temporary; const std::filesystem::path default_path = temporary.path() / "default.json"; From c4be3de14a2a99b0cfd0d56ba9a099725b4d3976 Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 16 Oct 2026 01:09:27 -0500 Subject: [PATCH 3/3] fix(runtime): start a queued game after leaving the current one --- CHANGELOG.md | 9 +++++++-- src/runtime/roblox_experience_composition.cc | 7 ++++--- tests/roblox_experience_composition_test.cc | 8 ++------ 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11706cb..cc3230c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,13 +22,18 @@ Release notes on GitHub are taken from the matching section here. - `--profile` traces now show how much GPU memory decoded ETC2 textures take, as the `emulated texture (MB)` and `compressed texture (MB)` counters. `scripts/summarize_profile_trace.py` prints the peak of each. +### Fixed + +- Joining a game from a website link while already in a game no longer freezes Nightcap. It leaves the + current game and joins the new one, and leaving that game returns to the home screen. + ### Changed -- Nightcap answers Roblox's Java calls with less work per call, and the audio thread no longer allocates - memory for each block of sound it plays. - The home screen and avatar editor have fewer long frames. PC profiles compile the app's menu scripts to native code and turn this off while you are in a game, so games run as before. Set `FFlagDebugLuauCodegenAll` in `fflags.json` to choose it yourself. +- Nightcap answers Roblox's Java calls with less work per call, and the audio thread no longer allocates + memory for each block of sound it plays. ## [0.3.4] - 2026-10-11 diff --git a/src/runtime/roblox_experience_composition.cc b/src/runtime/roblox_experience_composition.cc index cc42fb0..5ce7e66 100644 --- a/src/runtime/roblox_experience_composition.cc +++ b/src/runtime/roblox_experience_composition.cc @@ -1446,8 +1446,9 @@ Status RobloxExperienceComposition::DrainLaunchRequests() { return Status::Ok(); if (!launch_in_progress_ && active_launch_ == nullptr && game_active_ && !pending_launch_requests_.empty()) { - // Start the queued launch only after consuming this leave callback, so - // it cannot reach the next controller. + // Blocks a reentrant drain while the leave pumps main-thread messages. + // The leave ends in the Native stage with no Lua app return, so the + // queued launch starts on the next drain. controlled_switch_waiting_for_return_ = true; switch_active_game = true; } @@ -1456,7 +1457,7 @@ Status RobloxExperienceComposition::DrainLaunchRequests() { std::fprintf(stderr, "[experience] leaving active game for queued launch\n"); const Status leave_status = LeaveGame(); - if (!leave_status.ok()) { + { std::lock_guard lock(mutex_); controlled_switch_waiting_for_return_ = false; } diff --git a/tests/roblox_experience_composition_test.cc b/tests/roblox_experience_composition_test.cc index b48f86c..e136b8a 100644 --- a/tests/roblox_experience_composition_test.cc +++ b/tests/roblox_experience_composition_test.cc @@ -816,13 +816,9 @@ TEST(RobloxExperienceCompositionTest, ASSERT_TRUE(composition.DrainLaunchRequests().ok()); EXPECT_EQ(probe.leaves, 1); EXPECT_EQ(probe.starts.load(), 1); - ASSERT_TRUE(composition.DrainLaunchRequests().ok()); - EXPECT_EQ(probe.leaves, 1); - EXPECT_EQ(probe.starts.load(), 1); - // The exact gameDidLeave callback is the safe boundary between the old - // controller and the replacement server launch. - composition.NotifyLuaAppDidReturn(); + // nativeAppBridgeV2LeaveGame ends in the Native stage and Roblox sends no + // Lua app return, so the queued launch starts once the leave has returned. for (int attempt = 0; attempt < 1000 && probe.starts.load() < 2; ++attempt) { ASSERT_TRUE(composition.DrainLaunchRequests().ok()); std::this_thread::sleep_for(std::chrono::milliseconds(1));