Merge branch 'main' into vr

This commit is contained in:
vii committed 2026-10-16 01:22:27 -05:00
commit adccf4e484
20 files changed
+726 -22

No files matched your search

+10
View File
@@ -25,9 +25,19 @@ Release notes on GitHub are taken from the matching section here.
edges more precisely and keeps text sharper for a little more GPU time, or `off`.
- The log now names each Roblox Java call that Nightcap has no handler for, once per call, as
`[JNI] no handler for <class>.<method><signature>`. 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.
### 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
- 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.
- VR on Vulkan no longer makes the CPU wait for the GPU each frame. The eye copies, the desktop mirror and
+1
View File
@@ -281,6 +281,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
+5
View File
@@ -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",
+6
View File
@@ -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
+42 -2
View File
@@ -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
@@ -151,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<offset in libroblox.so>
[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
@@ -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:
+68
View File
@@ -0,0 +1,68 @@
#ifndef MOCKTAIL_RUNTIME_LUAU_CODEGEN_SWITCH_H_
#define MOCKTAIL_RUNTIME_LUAU_CODEGEN_SWITCH_H_
#include <cstddef>
#include <cstdint>
#include <functional>
#include <mutex>
#include <string_view>
#include <vector>
#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<LoadedImageSegment> 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<LoadedImageSegment>& 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<std::uint8_t*()>;
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_
+16 -3
View File
@@ -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] = {}
+93 -4
View File
@@ -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<VkDeviceSize>(1, static_cast<VkDeviceSize>(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<const RgbaImage> 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<VkPhysicalDevice, bool> physical_devices;
std::vector<std::unique_ptr<HostDevice>> devices;
std::unordered_map<VkImage, ImageRecord> images;
// Sum over `images`; guarded by `mutex`.
EmulatedTextureBytes texture_bytes;
std::unordered_map<VkBuffer, BufferBinding> buffers;
std::unordered_map<VkDeviceMemory, Mapping> mappings;
std::unordered_map<VkCommandBuffer, CommandRecord> 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<std::unique_ptr<StagingBlock>> doomed;
EmulatedTextureBytes totals;
std::uint64_t now = 0;
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> lock(state_->mutex);
state_->images.erase(image);
EmulatedTextureBytes totals;
std::uint64_t now = 0;
bool changed = false;
{
std::lock_guard<std::mutex> 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<std::mutex> lock(state_->mutex);
return state_->texture_bytes;
}
VkResult VulkanEtc2Emulation::CreateImageView(
VkDevice device, const VkImageViewCreateInfo* create_info,
const VkAllocationCallbacks* allocator, VkImageView* view) {
+23 -1
View File
@@ -67,6 +67,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"
@@ -4692,6 +4693,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<std::uintptr_t>(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()) {
@@ -6917,7 +6939,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);
+10 -3
View File
@@ -38,6 +38,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"
@@ -1218,6 +1219,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")) {
@@ -1240,10 +1242,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';
@@ -1254,6 +1259,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)
+150
View File
@@ -0,0 +1,150 @@
#include "runtime/luau_codegen_switch.h"
#include <elf.h>
#include <algorithm>
#include <cstring>
#include <iostream>
#include <string>
#include <utility>
#include <nlohmann/json.hpp>
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<const char*>(segment.address), segment.size};
}
std::vector<std::uintptr_t> NameAddresses(
const std::vector<LoadedImageSegment>& segments, std::string_view name) {
std::string needle(name);
needle.push_back('\0');
std::vector<std::uintptr_t> 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<LoadedImageSegment> LoadedImageSegments(std::uintptr_t image_base) {
std::vector<LoadedImageSegment> segments;
if (image_base == 0) {
return segments;
}
const auto* header = reinterpret_cast<const Elf64_Ehdr*>(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<const Elf64_Phdr*>(
image_base + static_cast<std::uintptr_t>(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<std::size_t>(program[index].p_memsz),
(program[index].p_flags & PF_W) != 0});
}
return segments;
}
std::uint8_t* FindFastFlagValue(const std::vector<LoadedImageSegment>& segments,
std::string_view name) {
const std::vector<std::uintptr_t> 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<const void*>(slot),
sizeof(pointer));
if (std::find(names.begin(), names.end(), pointer) == names.end()) {
continue;
}
std::uint64_t length = 0;
std::memcpy(&length, reinterpret_cast<const void*>(slot + 8),
sizeof(length));
if (length != name.size()) {
continue;
}
found = reinterpret_cast<std::uint8_t*>(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<std::mutex> 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<LuauCodegenMenuSwitch*>(context)->OnPhase(phase, request);
}
} // namespace runtime
} // namespace mocktail
+4
View File
@@ -14,6 +14,7 @@
#include <string_view>
#include <utility>
#include "runtime/luau_codegen_switch.h"
#include "runtime/runtime_paths.h"
namespace mocktail {
@@ -359,6 +360,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.
+4 -3
View File
@@ -1461,8 +1461,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;
}
@@ -1471,7 +1472,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<std::mutex> lock(mutex_);
controlled_switch_waiting_for_return_ = false;
}
+8
View File
@@ -402,6 +402,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
@@ -1290,6 +1297,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)
+162
View File
@@ -0,0 +1,162 @@
#include "runtime/luau_codegen_switch.h"
#include <elf.h>
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
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<LoadedImageSegment> Segments(std::vector<FlagRecord>& records) {
return {
{reinterpret_cast<std::uintptr_t>(kStrings), sizeof(kStrings), false},
{reinterpret_cast<std::uintptr_t>(records.data()),
records.size() * sizeof(FlagRecord), true},
};
}
TEST(LuauCodegenSwitchTest, FindsTheFlagRecordByName) {
ASSERT_EQ(std::string(NameInStrings()), "DebugLuauCodegenAll");
std::vector<FlagRecord> 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<FlagRecord> 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<FlagRecord> 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<Elf64_Ehdr*>(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<Elf64_Phdr*>(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<std::uintptr_t>(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<Forwarded*>(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
+16
View File
@@ -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";
+2 -6
View File
@@ -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));
+14
View File
@@ -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([])
+76
View File
@@ -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<PFN_vkVoidFunction>(FakeDestroySemaphore);
if (is("vkCreateImage")) return reinterpret_cast<PFN_vkVoidFunction>(FakeCreateImage);
if (is("vkDestroyImage")) return reinterpret_cast<PFN_vkVoidFunction>(FakeDestroyImage);
if (is("vkCreateBuffer")) return reinterpret_cast<PFN_vkVoidFunction>(FakeCreateBuffer);
if (is("vkGetBufferMemoryRequirements"))
return reinterpret_cast<PFN_vkVoidFunction>(FakeBufferRequirements);
@@ -313,6 +348,47 @@ VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL FakeGetDeviceProcAddr(
return nullptr;
}
TEST(VulkanEtc2EmulationTest, TotalsTheBytesOfLiveEmulatedImages) {
const VkDevice device = FakeHandle<VkDevice>(0x12);
VkPhysicalDeviceMemoryProperties memory{};
VulkanEtc2Emulation emulation;
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(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<VkDevice>(0x10);
const VkBuffer source = FakeHandle<VkBuffer>(0x200);