Fix input, text and add fps in esc menu

Refs #101
This commit is contained in:
komaruworld committed 2026-08-31 18:50:57 +03:00
1 parent 0cc9ab2e32
commit d5f9c91358
34 files changed
+691 -163

No files matched your search

+7 -6
View File
@@ -39,12 +39,13 @@ graphics:
# without a working Vulkan driver; OpenGL ES 3.0 or newer is required.
# Recommended values: direct-vulkan, opengl, system, angle-vulkan.
backend: direct-vulkan
# String or integer (default: display): requested frame-rate policy.
# Supported values: display, unlimited, 30, 60, 120, 144, 240. Unlimited
# selects Roblox's unmodified 240-FPS scheduler maximum.
frame_rate_limit: display
# String (default: auto): presentation synchronization: auto, on, or off.
vsync: auto
# Optional string or integer: Mocktail frame-rate override. Leave commented
# (or use -1) to let Roblox Basic Settings own the cap. Supported overrides:
# display, unlimited, or any positive integer; fixed values are forwarded to
# DFIntTaskSchedulerTargetFps without a whitelist.
# frame_rate_limit: -1
# Optional presentation synchronization override: auto, on, or off.
# vsync: off
performance:
# Boolean (default: false): size Roblox scheduler/render queues from every
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_PLATFORM_PLATFORM_RUNTIME_H_
#define MOCKTAIL_PLATFORM_PLATFORM_RUNTIME_H_
#include <cmath>
#include <cstdint>
#include <string>
#include <variant>
@@ -58,11 +59,69 @@ struct NativeWindowDescriptor {
struct QuitEvent {};
// Android exposes pointer and TextBox geometry in density-independent surface
// coordinates. SDL exposes pointer positions in logical window coordinates,
// while Vulkan presents to the pixel-sized drawable. Keep all three spaces in
// one value so input, IME placement and same-surface overlays use exact inverse
// transforms.
struct SurfaceCoordinateTransform {
int32_t logical_width = 0;
int32_t logical_height = 0;
int32_t pixel_width = 0;
int32_t pixel_height = 0;
float density_scale = 1.0F;
bool valid() const {
return logical_width > 0 && logical_height > 0 && pixel_width > 0 &&
pixel_height > 0 && std::isfinite(density_scale) &&
density_scale > 0.0F;
}
float guest_width() const {
return valid() ? static_cast<float>(pixel_width) / density_scale : 0.0F;
}
float guest_height() const {
return valid() ? static_cast<float>(pixel_height) / density_scale : 0.0F;
}
int32_t rounded_guest_width() const {
return valid() ? static_cast<int32_t>(std::lround(guest_width())) : 0;
}
int32_t rounded_guest_height() const {
return valid() ? static_cast<int32_t>(std::lround(guest_height())) : 0;
}
float HostLogicalToGuestX(float coordinate) const {
return valid() ? coordinate * static_cast<float>(pixel_width) /
static_cast<float>(logical_width) / density_scale
: coordinate;
}
float HostLogicalToGuestY(float coordinate) const {
return valid() ? coordinate * static_cast<float>(pixel_height) /
static_cast<float>(logical_height) / density_scale
: coordinate;
}
float GuestToHostLogicalX(float coordinate) const {
return valid() ? coordinate * density_scale *
static_cast<float>(logical_width) /
static_cast<float>(pixel_width)
: coordinate;
}
float GuestToHostLogicalY(float coordinate) const {
return valid() ? coordinate * density_scale *
static_cast<float>(logical_height) /
static_cast<float>(pixel_height)
: coordinate;
}
};
struct WindowResizedEvent {
int logical_width = 0;
int logical_height = 0;
int pixel_width = 0;
int pixel_height = 0;
// Android DisplayMetrics.density equivalent. This is independent of the
// logical-to-pixel drawable ratio on X11/XWayland.
float density_scale = 1.0F;
};
struct WindowFocusEvent {
+7 -2
View File
@@ -10,6 +10,7 @@ namespace runtime {
inline constexpr int kMaximumSupportedRobloxSchedulerFps = 240;
enum class FrameRateLimitMode {
kUnmanaged,
kDisplay,
kFixed,
kUnlimited,
@@ -17,7 +18,7 @@ enum class FrameRateLimitMode {
};
struct FrameRatePolicy {
FrameRateLimitMode mode = FrameRateLimitMode::kDisplay;
FrameRateLimitMode mode = FrameRateLimitMode::kUnmanaged;
int fixed_fps = 0;
bool valid() const { return mode != FrameRateLimitMode::kInvalid; }
@@ -25,7 +26,11 @@ struct FrameRatePolicy {
FrameRatePolicy ParseFrameRatePolicy(std::string_view value);
// Merges the scheduler policy into Roblox's supported client-settings ingress.
// Enables Roblox's Basic Settings frame-rate control and, when explicitly
// requested, merges a Mocktail scheduler policy into client settings.
// Unmanaged (-1) does not add a scheduler target, so Roblox owns the cap.
// Every positive fixed value is forwarded verbatim to
// DFIntTaskSchedulerTargetFps; it is not restricted to common refresh rates.
// Unlimited selects the unmodified payload's maximum scheduler target (240).
// With VSync auto/off, graphics policy separately requests an unthrottled
// Vulkan present mode when the host exposes one.
+4 -1
View File
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_INPUT_ROUTER_H_
#define MOCKTAIL_RUNTIME_ROBLOX_INPUT_ROUTER_H_
#include <cmath>
#include <cstdint>
#include <mutex>
#include <vector>
@@ -17,10 +18,12 @@ struct RobloxInputViewport {
int32_t logical_height = 0;
int32_t pixel_width = 0;
int32_t pixel_height = 0;
float density_scale = 1.0F;
bool valid() const {
return logical_width > 0 && logical_height > 0 && pixel_width > 0 &&
pixel_height > 0;
pixel_height > 0 && std::isfinite(density_scale) &&
density_scale > 0.0F;
}
};
+8 -1
View File
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_TEXT_DISPLAY_STATE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_TEXT_DISPLAY_STATE_H_
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <string>
@@ -14,8 +15,14 @@ namespace runtime {
struct RobloxTextOverlayViewport {
int width = 0;
int height = 0;
float density_scale = 1.0F;
bool valid() const { return width > 0 && height > 0; }
bool valid() const {
return width > 0 && height > 0 && std::isfinite(density_scale) &&
density_scale > 0.0F;
}
int coordinate_width() const;
int coordinate_height() const;
};
struct RobloxTextOverlayGeometry {
+5 -2
View File
@@ -28,7 +28,10 @@ PresentModePolicy ResolvePresentModePolicy(std::string_view vsync_value,
if (frame_rate_value == "unlimited") {
return PresentModePolicy::kUnthrottled;
}
// display, 30, 60, 120, 144, 240: cap the scheduler, keep vsync present.
if (frame_rate_value.empty() || frame_rate_value == "-1") {
return PresentModePolicy::kHostDefault;
}
// Display or any fixed scheduler target keeps synchronized presentation.
return PresentModePolicy::kVsync;
}
@@ -36,7 +39,7 @@ PresentModePolicy CachedPresentModePolicy() {
static const PresentModePolicy policy = [] {
return ResolvePresentModePolicy(
EnvironmentOr("MOCKTAIL_VSYNC", "auto"),
EnvironmentOr("MOCKTAIL_FRAME_RATE_LIMIT", "display"));
EnvironmentOr("MOCKTAIL_FRAME_RATE_LIMIT", "-1"));
}();
return policy;
}
+1 -1
View File
@@ -4426,7 +4426,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
runtime_config.input_capabilities();
if (!runtime_config.frame_rate().valid()) {
std::cerr << "[FATAL] Invalid MOCKTAIL_FRAME_RATE_LIMIT; expected "
"display, 30, 60, 120, 144, 240, or unlimited\n";
"-1, display, unlimited, or a positive integer\n";
return EXIT_FAILURE;
}
if (!runtime_config.theme_mode_valid()) {
+5 -1
View File
@@ -21,7 +21,9 @@ bool ConvertSdlEvent(SDL_Window* window, const SDL_Event& source,
return true;
case SDL_EVENT_WINDOW_RESIZED:
case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED:
case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: {
case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED:
case SDL_EVENT_WINDOW_ENTER_FULLSCREEN:
case SDL_EVENT_WINDOW_LEAVE_FULLSCREEN: {
WindowResizedEvent resized;
if (!SDL_GetWindowSize(window, &resized.logical_width,
&resized.logical_height) ||
@@ -29,6 +31,8 @@ bool ConvertSdlEvent(SDL_Window* window, const SDL_Event& source,
&resized.pixel_height)) {
return false;
}
const float density_scale = SDL_GetWindowDisplayScale(window);
resized.density_scale = density_scale > 0.0F ? density_scale : 1.0F;
destination->payload = resized;
return true;
}
+18 -8
View File
@@ -3,7 +3,7 @@
#define JSON_NOEXCEPTION 1
#include <nlohmann/json.hpp>
#include <array>
#include <charconv>
#include <string>
namespace mocktail {
@@ -29,17 +29,21 @@ bool SetCompatibleValue(nlohmann::json *object, const char *key,
} // namespace
FrameRatePolicy ParseFrameRatePolicy(std::string_view value) {
if (value.empty() || value == "display") {
if (value.empty() || value == "-1") {
return {};
}
if (value == "display") {
return {FrameRateLimitMode::kDisplay, 0};
}
if (value == "unlimited") {
return {FrameRateLimitMode::kUnlimited, 0};
}
constexpr std::array<int, 5> kSupportedFixedRates = {30, 60, 120, 144, 240};
for (const int rate : kSupportedFixedRates) {
if (value == std::to_string(rate)) {
return {FrameRateLimitMode::kFixed, rate};
}
int fps = 0;
const auto parsed =
std::from_chars(value.data(), value.data() + value.size(), fps);
if (parsed.ec == std::errc() &&
parsed.ptr == value.data() + value.size() && fps > 0) {
return {FrameRateLimitMode::kFixed, fps};
}
return {FrameRateLimitMode::kInvalid, 0};
}
@@ -62,7 +66,13 @@ bool MergeFrameRateClientSettingsOverrides(const FrameRatePolicy &policy,
}
return false;
}
if (policy.mode == FrameRateLimitMode::kDisplay) {
if (!SetCompatibleValue(&overrides,
"FFlagGameBasicSettingsFramerateCap5", "True",
error)) {
return false;
}
if (policy.mode == FrameRateLimitMode::kUnmanaged ||
policy.mode == FrameRateLimitMode::kDisplay) {
*merged_json = overrides.dump();
return true;
}
+41 -48
View File
@@ -32,16 +32,10 @@ RobloxInputDispatchResult Result(RobloxInputDispatchState state,
return {state, kind, std::move(status)};
}
float ScaleToSurfacePixels(float coordinate, int32_t logical_extent,
int32_t pixel_extent) {
if (__builtin_expect(logical_extent == pixel_extent, 1)) {
return coordinate;
}
if (__builtin_expect(logical_extent <= 0, 0)) {
return coordinate;
}
return coordinate * static_cast<float>(pixel_extent) /
static_cast<float>(logical_extent);
platform::SurfaceCoordinateTransform CoordinateTransform(
const RobloxInputViewport& viewport) {
return {viewport.logical_width, viewport.logical_height, viewport.pixel_width,
viewport.pixel_height, viewport.density_scale};
}
} // namespace
@@ -389,7 +383,7 @@ RobloxInputDispatchResult RobloxInputRouter::HandleEvent(
std::get_if<platform::WindowResizedEvent>(&event.payload)) {
RobloxInputViewport viewport = {
resized->logical_width, resized->logical_height, resized->pixel_width,
resized->pixel_height};
resized->pixel_height, resized->density_scale};
if (!viewport.valid()) {
return Result(RobloxInputDispatchState::kRejectedInvalid,
RobloxInputEventKind::kViewport,
@@ -524,26 +518,21 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseMotionLocked(
RobloxInputEventKind::kMouseMotion,
Unsupported("native mouse motion is unavailable"));
}
const float delta_x = ScaleToSurfacePixels(
event.delta_x, snapshot_.viewport.logical_width,
snapshot_.viewport.pixel_width);
const float delta_y = ScaleToSurfacePixels(
event.delta_y, snapshot_.viewport.logical_height,
snapshot_.viewport.pixel_height);
const platform::SurfaceCoordinateTransform transform =
CoordinateTransform(snapshot_.viewport);
const float delta_x = transform.HostLogicalToGuestX(event.delta_x);
const float delta_y = transform.HostLogicalToGuestY(event.delta_y);
if (event.x == 0.0f && event.y == 0.0f && (delta_x != 0.0f || delta_y != 0.0f)) {
if (event.x == 0.0f && event.y == 0.0f &&
(delta_x != 0.0f || delta_y != 0.0f)) {
mouse_x_ += delta_x;
mouse_y_ += delta_y;
} else {
mouse_x_ = ScaleToSurfacePixels(event.x, snapshot_.viewport.logical_width,
snapshot_.viewport.pixel_width);
mouse_y_ = ScaleToSurfacePixels(event.y, snapshot_.viewport.logical_height,
snapshot_.viewport.pixel_height);
mouse_x_ = transform.HostLogicalToGuestX(event.x);
mouse_y_ = transform.HostLogicalToGuestY(event.y);
}
const float max_x = static_cast<float>(
snapshot_.viewport.pixel_width > 0 ? snapshot_.viewport.pixel_width - 1 : 0);
const float max_y = static_cast<float>(
snapshot_.viewport.pixel_height > 0 ? snapshot_.viewport.pixel_height - 1 : 0);
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);
const float clamped_x = std::clamp(mouse_x_, 0.0f, max_x);
const float clamped_y = std::clamp(mouse_y_, 0.0f, max_y);
@@ -565,16 +554,17 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseButtonLocked(
RobloxInputEventKind::kMouseButton,
Unsupported("SDL mouse button has no Android mapping"));
}
if (event.x > 0.0f || event.y > 0.0f || (mouse_x_ == 0.0f && mouse_y_ == 0.0f)) {
mouse_x_ = ScaleToSurfacePixels(event.x, snapshot_.viewport.logical_width,
snapshot_.viewport.pixel_width);
mouse_y_ = ScaleToSurfacePixels(event.y, snapshot_.viewport.logical_height,
snapshot_.viewport.pixel_height);
if (event.x > 0.0f || event.y > 0.0f ||
(mouse_x_ == 0.0f && mouse_y_ == 0.0f)) {
const platform::SurfaceCoordinateTransform transform =
CoordinateTransform(snapshot_.viewport);
mouse_x_ = transform.HostLogicalToGuestX(event.x);
mouse_y_ = transform.HostLogicalToGuestY(event.y);
}
const float max_x = static_cast<float>(
snapshot_.viewport.pixel_width > 0 ? snapshot_.viewport.pixel_width - 1 : 0);
const float max_y = static_cast<float>(
snapshot_.viewport.pixel_height > 0 ? snapshot_.viewport.pixel_height - 1 : 0);
const platform::SurfaceCoordinateTransform transform =
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);
const float clamped_x = std::clamp(mouse_x_, 0.0f, max_x);
const float clamped_y = std::clamp(mouse_y_, 0.0f, max_y);
if (event.pressed) {
@@ -607,12 +597,10 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseWheelLocked(
RobloxInputEventKind::kMouseWheel,
Unsupported("vertical native mouse wheel is unavailable"));
}
mouse_x_ = ScaleToSurfacePixels(
std::max(0.0f, event.mouse_x), snapshot_.viewport.logical_width,
snapshot_.viewport.pixel_width);
mouse_y_ = ScaleToSurfacePixels(
std::max(0.0f, event.mouse_y), snapshot_.viewport.logical_height,
snapshot_.viewport.pixel_height);
const platform::SurfaceCoordinateTransform transform =
CoordinateTransform(snapshot_.viewport);
mouse_x_ = transform.HostLogicalToGuestX(std::max(0.0f, event.mouse_x));
mouse_y_ = transform.HostLogicalToGuestY(std::max(0.0f, event.mouse_y));
return NativeResultLocked(
sink_.mouse_wheel(sink_.context, mouse_x_, mouse_y_, event.delta_y),
RobloxInputEventKind::kMouseWheel);
@@ -625,10 +613,14 @@ RobloxInputDispatchResult RobloxInputRouter::HandleTouchLocked(
RobloxInputEventKind::kTouch,
Unsupported("native touch input is unavailable"));
}
const float x = std::clamp(event.x, 0.0f, 1.0f) *
static_cast<float>(snapshot_.viewport.logical_width);
const float y = std::clamp(event.y, 0.0f, 1.0f) *
static_cast<float>(snapshot_.viewport.logical_height);
const platform::SurfaceCoordinateTransform transform =
CoordinateTransform(snapshot_.viewport);
const int32_t guest_width = transform.rounded_guest_width();
const int32_t guest_height = transform.rounded_guest_height();
const float x =
std::clamp(event.x, 0.0f, 1.0f) * static_cast<float>(guest_width);
const float y =
std::clamp(event.y, 0.0f, 1.0f) * static_cast<float>(guest_height);
auto active = FindTouchIteratorLocked(event.touch_id, event.finger_id);
int32_t action = kAndroidTouchMove;
int32_t pointer_id = -1;
@@ -659,8 +651,7 @@ RobloxInputDispatchResult RobloxInputRouter::HandleTouchLocked(
}
Status status = sink_.touch(sink_.context, pointer_id, x, y, action,
snapshot_.viewport.logical_width,
snapshot_.viewport.logical_height);
guest_width, guest_height);
if (event.action == platform::TouchEvent::Action::kDown && status.ok()) {
active_touches_.push_back(
{event.touch_id, event.finger_id, pointer_id, x, y});
@@ -726,9 +717,11 @@ Status RobloxInputRouter::ReleasePressedInputsLocked() {
if (sink_.touch == nullptr) {
continue;
}
const platform::SurfaceCoordinateTransform transform =
CoordinateTransform(snapshot_.viewport);
Status status = sink_.touch(
sink_.context, touch.pointer_id, touch.x, touch.y, kAndroidTouchUp,
snapshot_.viewport.logical_width, snapshot_.viewport.logical_height);
transform.rounded_guest_width(), transform.rounded_guest_height());
if (status.ok()) {
++snapshot_.touch_events;
} else if (first_error.ok()) {
+16 -2
View File
@@ -3,6 +3,7 @@
#include <utf8proc.h>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <limits>
@@ -90,12 +91,25 @@ bool NativeGeometryIntersectsViewport(
static_cast<int64_t>(update.area_width);
const int64_t bottom = static_cast<int64_t>(update.area_y) +
static_cast<int64_t>(update.area_height);
return right > 0 && bottom > 0 && update.area_x < viewport.width &&
update.area_y < viewport.height;
return right > 0 && bottom > 0 &&
update.area_x < viewport.coordinate_width() &&
update.area_y < viewport.coordinate_height();
}
} // namespace
int RobloxTextOverlayViewport::coordinate_width() const {
return valid() ? static_cast<int>(
std::lround(static_cast<float>(width) / density_scale))
: 0;
}
int RobloxTextOverlayViewport::coordinate_height() const {
return valid() ? static_cast<int>(
std::lround(static_cast<float>(height) / density_scale))
: 0;
}
RobloxTextOverlayPresentation::~RobloxTextOverlayPresentation() {
ClearSensitive();
}
+19
View File
@@ -230,6 +230,25 @@ Status RobloxTextEditor::ReplaceFocusedTextFromEngine(
SecureClear(&authoritative_utf8);
return InvalidArgument("engine TextBox text is invalid UTF-8 or too large");
}
// Games can clear a TextBox from Lua after consuming its value. An empty
// engine update must win even when it matches an older host echo; otherwise
// the stale host buffer remains visible and the next word starts with it.
if (authoritative_utf8.empty()) {
ClearPendingNativeEchoesLocked();
SecureClear(&text_);
cursor_byte_ = 0;
selection_anchor_byte_ = 0;
composition_begin_byte_ = 0;
composition_end_byte_ = 0;
composition_original_cursor_byte_ = 0;
composition_original_anchor_byte_ = 0;
SecureClear(&composition_replaced_text_);
composition_active_ = false;
snapshot_.text_bytes = 0;
UpdateSelectionSnapshotLocked();
PublishDisplayLocked(RobloxTextDisplayEvent::kUpdate);
return Status::Ok();
}
// EngineJavaCallback2.g may asynchronously reflect an earlier outbound
// editor state. Consume any known snapshot instead of letting it replace a
// newer host composition. Unknown values remain authoritative Lua-side
+51 -25
View File
@@ -21,7 +21,7 @@ namespace {
constexpr std::size_t kMaximumPendingTextCommands = 64;
constexpr std::size_t kMaximumPendingTextBytes = 4U * 1024U * 1024U;
constexpr uint32_t kMissingGeometryRetryPumpInterval = 8;
constexpr uint32_t kGeometryRefreshPumpInterval = 8;
void SecureClear(std::string* value) {
if (value == nullptr) {
@@ -49,6 +49,21 @@ bool HasUsableGeometry(const jnivm::RobloxTextBoxInfo& info) {
RoundedCoordinate(info.height) > 0;
}
bool SameTextBoxInfo(const jnivm::RobloxTextBoxInfo& left,
const jnivm::RobloxTextBoxInfo& right) {
return left.x == right.x && left.y == right.y &&
left.width == right.width && left.height == right.height &&
left.font_size == right.font_size &&
left.multiline == right.multiline &&
left.x_alignment == right.x_alignment &&
left.y_alignment == right.y_alignment &&
left.text_color == right.text_color && left.font == right.font &&
left.text_input_type == right.text_input_type &&
left.return_key_type == right.return_key_type &&
left.manual_focus_release == right.manual_focus_release &&
left.text_wrapped == right.text_wrapped;
}
void PreserveGeometry(const jnivm::RobloxTextBoxInfo& source,
jnivm::RobloxTextBoxInfo* destination) {
if (destination == nullptr) {
@@ -205,7 +220,7 @@ struct RobloxTextInputJniBridge::State {
desired_active = false;
active_geometry = {};
has_active_geometry = false;
missing_geometry_retry_pumps = 0;
geometry_refresh_pumps = 0;
ClearCommandsLocked();
}
@@ -329,13 +344,15 @@ struct RobloxTextInputJniBridge::State {
if (HasUsableGeometry(effective_info)) {
active_geometry = effective_info;
has_active_geometry = true;
missing_geometry_retry_pumps = 0;
geometry_refresh_pumps = 0;
}
std::fprintf(stderr,
" [input] TextBox focus snapshot native=%d "
"handle_changed=%d geometry=%dx%d inherited=%d\n",
"handle_changed=%d geometry=%d,%d %dx%d inherited=%d\n",
request.show_native_input ? 1 : 0,
refreshes_active_focus && !same_handle ? 1 : 0,
RoundedCoordinate(effective_info.x),
RoundedCoordinate(effective_info.y),
RoundedCoordinate(effective_info.width),
RoundedCoordinate(effective_info.height),
inherited_geometry ? 1 : 0);
@@ -354,7 +371,7 @@ struct RobloxTextInputJniBridge::State {
active_generation = generation;
active_handle = request.text_box;
desired_active = true;
missing_geometry_retry_pumps = 0;
geometry_refresh_pumps = 0;
command.type = CommandType::kShow;
command.generation = generation;
command.textbox_handle = request.text_box;
@@ -382,7 +399,7 @@ struct RobloxTextInputJniBridge::State {
active_info = {};
active_geometry = {};
has_active_geometry = false;
missing_geometry_retry_pumps = 0;
geometry_refresh_pumps = 0;
}
void ReplaceText(const std::string& text) {
@@ -437,19 +454,21 @@ struct RobloxTextInputJniBridge::State {
commands.push_back(std::move(command));
}
// The first showKeyboard snapshot can precede Roblox's layout pass and
// contain a 0x0 rectangle. Keep the editor authoritative, then retry the
// typed native query on the main-thread pump until real bounds appear.
void AppendMissingGeometryRetry(std::deque<Command>* pending) {
// NativeTextBoxInfo can change without an EngineJavaCallback2 property
// notification when an ancestor GUI moves or the surface changes size.
// Android's RbxKeyboard follows that layout; keep the host overlay attached
// by polling only while a TextBox owns focus. Unchanged snapshots are
// discarded before they reach SDL or the rasterizer.
void AppendGeometryRefresh(std::deque<Command>* pending) {
if (pending == nullptr) {
return;
}
std::lock_guard<std::mutex> lock(mutex);
if (!accepting || !desired_active || has_active_geometry ||
active_generation == 0 || active_handle == 0 || !applied_active ||
if (!accepting || !desired_active || active_generation == 0 ||
active_handle == 0 || !applied_active ||
applied_generation != active_generation ||
applied_handle != active_handle) {
missing_geometry_retry_pumps = 0;
geometry_refresh_pumps = 0;
return;
}
const auto already_refreshing =
@@ -460,11 +479,11 @@ struct RobloxTextInputJniBridge::State {
if (already_refreshing != pending->end()) {
return;
}
++missing_geometry_retry_pumps;
if (missing_geometry_retry_pumps < kMissingGeometryRetryPumpInterval) {
++geometry_refresh_pumps;
if (geometry_refresh_pumps < kGeometryRefreshPumpInterval) {
return;
}
missing_geometry_retry_pumps = 0;
geometry_refresh_pumps = 0;
Command command;
command.type = CommandType::kRecoverGeometry;
command.generation = active_generation;
@@ -483,10 +502,11 @@ struct RobloxTextInputJniBridge::State {
bool CommitPropertySnapshotIfCurrent(
uint64_t requested_generation, int64_t requested_handle,
const RobloxNativeTextBoxInfoSnapshot& snapshot,
jnivm::RobloxTextBoxInfo* effective_info) {
if (effective_info == nullptr) {
jnivm::RobloxTextBoxInfo* effective_info, bool* properties_changed) {
if (effective_info == nullptr || properties_changed == nullptr) {
return false;
}
*properties_changed = false;
std::lock_guard<std::mutex> lock(mutex);
if (!accepting || !desired_active ||
active_generation != requested_generation ||
@@ -502,8 +522,9 @@ struct RobloxTextInputJniBridge::State {
} else {
active_geometry = candidate;
has_active_geometry = true;
missing_geometry_retry_pumps = 0;
geometry_refresh_pumps = 0;
}
*properties_changed = !SameTextBoxInfo(active_info, candidate);
active_info = candidate;
*effective_info = candidate;
return true;
@@ -517,7 +538,7 @@ struct RobloxTextInputJniBridge::State {
active_handle == requested_handle) {
active_geometry = {};
has_active_geometry = false;
missing_geometry_retry_pumps = 0;
geometry_refresh_pumps = 0;
}
}
@@ -538,7 +559,7 @@ struct RobloxTextInputJniBridge::State {
return CompleteTerminalOnMainThread("queue");
}
AppendMissingGeometryRetry(&pending);
AppendGeometryRefresh(&pending);
bool success = true;
for (Command& command : pending) {
@@ -642,9 +663,12 @@ struct RobloxTextInputJniBridge::State {
status = backend->QueryCurrentTextBoxInfo(&query);
if (status.ok() && query.available) {
jnivm::RobloxTextBoxInfo effective_info;
bool properties_changed = false;
if (CommitPropertySnapshotIfCurrent(command.generation,
command.textbox_handle,
query.info, &effective_info)) {
query.info, &effective_info,
&properties_changed) &&
properties_changed) {
failure_stage = "update-properties";
status = backend->UpdateTextFocusProperties(
command.generation, ToFocusProperties(effective_info));
@@ -662,8 +686,10 @@ struct RobloxTextInputJniBridge::State {
logged_property_refresh = true;
std::fprintf(stderr,
" [input] native TextBox properties refreshed "
"generation=%llu geometry=%dx%d\n",
"generation=%llu geometry=%d,%d %dx%d\n",
static_cast<unsigned long long>(command.generation),
RoundedCoordinate(effective_info.x),
RoundedCoordinate(effective_info.y),
RoundedCoordinate(effective_info.width),
RoundedCoordinate(effective_info.height));
}
@@ -746,7 +772,7 @@ struct RobloxTextInputJniBridge::State {
active_info = {};
active_geometry = {};
has_active_geometry = false;
missing_geometry_retry_pumps = 0;
geometry_refresh_pumps = 0;
ClearCommandsLocked();
}
@@ -786,7 +812,7 @@ struct RobloxTextInputJniBridge::State {
jnivm::RobloxTextBoxInfo active_info;
jnivm::RobloxTextBoxInfo active_geometry;
bool has_active_geometry = false;
uint32_t missing_geometry_retry_pumps = 0;
uint32_t geometry_refresh_pumps = 0;
bool desired_active = false;
std::size_t pending_text_bytes = 0;
bool accepting = true;
+38 -5
View File
@@ -243,6 +243,36 @@ struct TextBytePosition {
int height = 0;
};
int LeadingTextInset(const RobloxTextOverlayPresentation& presentation,
int frame_width, int frame_height) {
const int ordinary_inset = std::clamp(frame_width / 12, 8, 14);
if (presentation.multiline || presentation.text_wrapped ||
presentation.x_alignment != 0 || frame_height <= 0) {
return ordinary_inset;
}
// NativeTextBoxInfo has no padding or compound-drawable fields. Roblox's
// top navigation search boxes use a very wide TextBox with a leading icon
// layered inside that rectangle, so faithfully using x would paint editor
// text over the icon. Reserve one icon cell plus its margins for that
// recognizable layout while leaving ordinary and in-experience fields
// unchanged.
const int64_t width = frame_width;
const int64_t height = frame_height;
const int64_t y = presentation.geometry.y;
const bool wide_top_navigation_field =
width >= height * 12 && y >= 0 && y <= height * 3;
if (!wide_top_navigation_field) {
return ordinary_inset;
}
// The decoration occupies one square cell. Keep the normal inter-element
// gap after it; reserving two full heights visibly pushes place-search text
// toward the middle of the field on scaled desktops.
const int decorated_inset = static_cast<int>(std::min<int64_t>(
height + ordinary_inset / 2, std::min<int64_t>(width / 3, 160)));
return std::max(ordinary_inset, decorated_inset);
}
TextBytePosition TextPosition(TTF_Text* layout, std::size_t byte_offset,
int text_width) {
if (layout == nullptr) {
@@ -389,8 +419,10 @@ bool RobloxTextSurfaceOverlay::QueryFrame(MocktailTextOverlayFrameInfo* frame) {
return false;
}
frame->visible = 1;
frame->coordinate_width = static_cast<std::uint32_t>(viewport_.width);
frame->coordinate_height = static_cast<std::uint32_t>(viewport_.height);
frame->coordinate_width =
static_cast<std::uint32_t>(viewport_.coordinate_width());
frame->coordinate_height =
static_cast<std::uint32_t>(viewport_.coordinate_height());
frame->x = presentation.geometry.x;
frame->y = presentation.geometry.y;
frame->width = static_cast<std::uint32_t>(presentation.geometry.width);
@@ -429,9 +461,10 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
static_cast<std::size_t>(width * height * 4), 0);
const int frame_width = static_cast<int>(width);
const int frame_height = static_cast<int>(height);
const int padding = std::clamp(frame_width / 12, 8, 14);
const int clip_left = std::min(padding, frame_width);
const int clip_right = std::max(clip_left, frame_width - padding);
const int ordinary_padding = std::clamp(frame_width / 12, 8, 14);
const int clip_left = std::min(
LeadingTextInset(presentation, frame_width, frame_height), frame_width);
const int clip_right = std::max(clip_left, frame_width - ordinary_padding);
const int clip_top = 3;
const int clip_bottom = std::max(clip_top, frame_height - 3);
const int available_width = std::max(1, clip_right - clip_left);
+5 -3
View File
@@ -37,7 +37,8 @@ Status RobloxWindowInputRuntime::Initialize() {
"SDL input window is unavailable");
}
Status status = text_surface_overlay_.Initialize(
{window_viewport.pixel_width, window_viewport.pixel_height});
{window_viewport.pixel_width, window_viewport.pixel_height,
window_viewport.dpi_scale});
if (!status.ok()) {
return status;
}
@@ -45,7 +46,8 @@ Status RobloxWindowInputRuntime::Initialize() {
(SDL_GetWindowFlags(sdl_window) & SDL_WINDOW_INPUT_FOCUS) != 0;
status = runtime_.Initialize(
{window_viewport.logical_width, window_viewport.logical_height,
window_viewport.pixel_width, window_viewport.pixel_height},
window_viewport.pixel_width, window_viewport.pixel_height,
window_viewport.dpi_scale},
initially_focused);
if (!status.ok()) {
(void)text_surface_overlay_.Shutdown();
@@ -82,7 +84,7 @@ void RobloxWindowInputRuntime::PlatformEventCallback(
if (const auto* resized =
std::get_if<platform::WindowResizedEvent>(&event.payload)) {
(void)self->text_surface_overlay_.UpdateViewport(
{resized->pixel_width, resized->pixel_height});
{resized->pixel_width, resized->pixel_height, resized->density_scale});
}
(void)self->runtime_.HandleEvent(event);
}
+1 -1
View File
@@ -268,7 +268,7 @@ RuntimeConfig RuntimeConfig::FromEnvironment(const Environment& environment) {
config.device_profile_.roblox_http_user_agent;
}
config.frame_rate_ = ParseFrameRatePolicy(
environment.GetOr("MOCKTAIL_FRAME_RATE_LIMIT", "display"));
environment.GetOr("MOCKTAIL_FRAME_RATE_LIMIT", "-1"));
config.vsync_mode_ = environment.GetOr("MOCKTAIL_VSYNC", "auto");
config.performance_ = ParsePerformancePolicy(
environment.GetOr("MOCKTAIL_MULTITHREADED_RENDERING", "0"),
+7 -6
View File
@@ -57,12 +57,13 @@ graphics:
# without a working Vulkan driver; OpenGL ES 3.0 or newer is required.
# Recommended values: direct-vulkan, opengl, system, angle-vulkan.
backend: direct-vulkan
# String or integer (default: display): requested frame-rate policy.
# Supported values: display, unlimited, 30, 60, 120, 144, 240. Unlimited
# selects Roblox's unmodified 240-FPS scheduler maximum.
frame_rate_limit: display
# String (default: auto): presentation synchronization: auto, on, or off.
vsync: auto
# Optional string or integer: Mocktail frame-rate override. Leave commented
# (or use -1) to let Roblox Basic Settings own the cap. Supported overrides:
# display, unlimited, or any positive integer; fixed values are forwarded to
# DFIntTaskSchedulerTargetFps without a whitelist.
# frame_rate_limit: -1
# Optional presentation synchronization override: auto, on, or off.
# vsync: off
performance:
# Boolean (default: false): size Roblox scheduler/render queues from every
+3
View File
@@ -674,6 +674,9 @@ bool LoadYaml(const std::filesystem::path& path, ValueMap* values, bool* loaded,
}
std::string FrameRateValue(const FrameRatePolicy& policy) {
if (policy.mode == FrameRateLimitMode::kUnmanaged) {
return "-1";
}
if (policy.mode == FrameRateLimitMode::kUnlimited) {
return "unlimited";
}
+32 -14
View File
@@ -156,6 +156,13 @@ static char g_preferred_gles_library[4096];
static bool g_auto_angle_retry_attempted = false;
static std::filesystem::path g_window_state_path;
WindowStartupPresentationPlan RestoredWindowPresentationPlan() {
if (!g_state.state_persistence_active) {
return {};
}
return PlanWindowStartupPresentation(g_state.persisted_window);
}
// Floors the live window at the size a restored geometry already has to clear.
void ApplyMinimumWindowSize(SDL_Window* window) {
if (window == nullptr) {
@@ -168,6 +175,17 @@ void ApplyMinimumWindowSize(SDL_Window* window) {
}
}
void ApplyRestoredWindowMaximization(
SDL_Window* window, const WindowStartupPresentationPlan& plan) {
if (window == nullptr || !plan.maximize_after_constraints) {
return;
}
if (!SDL_MaximizeWindow(window)) {
std::fprintf(stderr, " [window-state] SDL maximize restore failed: %s\n",
SDL_GetError());
}
}
void ApplyWindowIcon(SDL_Window* window) {
const Status status = platform::ApplySdlWindowIcon(window);
if (!status.ok()) {
@@ -865,13 +883,12 @@ bool CreateSoftwareWaitingWindow(int width, int height, const char* title) {
fprintf(stderr,
" [window] TEST-ONLY graphics stubs explicitly enabled; creating "
"a non-rendering waiting window\n");
const WindowStartupPresentationPlan presentation =
RestoredWindowPresentationPlan();
SDL_WindowFlags window_flags = ApplyHighPixelDensityWindowFlag(
0, IsEnabledEnv("MOCKTAIL_WIN_HIGH_DPI"));
if (g_state.state_persistence_active && g_state.persisted_window.fullscreen) {
SDL_WINDOW_RESIZABLE, IsEnabledEnv("MOCKTAIL_WIN_HIGH_DPI"));
if (presentation.fullscreen_at_creation) {
window_flags |= SDL_WINDOW_FULLSCREEN;
} else if (g_state.state_persistence_active &&
g_state.persisted_window.maximized) {
window_flags |= SDL_WINDOW_MAXIMIZED;
}
if (!ShouldShowWindowImmediately()) {
window_flags |= SDL_WINDOW_HIDDEN;
@@ -887,6 +904,7 @@ bool CreateSoftwareWaitingWindow(int width, int height, const char* title) {
ApplyWindowIcon(g_state.sdl_window);
ApplyMinimumWindowSize(g_state.sdl_window);
ApplyRestoredWindowPosition();
ApplyRestoredWindowMaximization(g_state.sdl_window, presentation);
g_state.software_window = true;
fprintf(stderr, " [window] software SDL waiting window created (%dx%d)\n",
@@ -1003,15 +1021,13 @@ bool Init(int width, int height, const char* title) {
fprintf(stderr, " [window] SDL_Init failed: %s\n", SDL_GetError());
return false;
}
const WindowStartupPresentationPlan presentation =
RestoredWindowPresentationPlan();
SDL_WindowFlags window_flags = ApplyHighPixelDensityWindowFlag(
SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE,
IsEnabledEnv("MOCKTAIL_WIN_HIGH_DPI"));
if (g_state.state_persistence_active &&
g_state.persisted_window.fullscreen) {
if (presentation.fullscreen_at_creation) {
window_flags |= SDL_WINDOW_FULLSCREEN;
} else if (g_state.state_persistence_active &&
g_state.persisted_window.maximized) {
window_flags |= SDL_WINDOW_MAXIMIZED;
}
if (!ShouldShowWindowImmediately()) {
window_flags |= SDL_WINDOW_HIDDEN;
@@ -1027,6 +1043,7 @@ bool Init(int width, int height, const char* title) {
ApplyWindowIcon(g_state.sdl_window);
ApplyMinimumWindowSize(g_state.sdl_window);
ApplyRestoredWindowPosition();
ApplyRestoredWindowMaximization(g_state.sdl_window, presentation);
g_state.direct_vulkan = true;
g_state.initialised = true;
ResolveNativeWindowHandle();
@@ -1146,14 +1163,13 @@ bool Init(int width, int height, const char* title) {
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
const WindowStartupPresentationPlan presentation =
RestoredWindowPresentationPlan();
SDL_WindowFlags window_flags = ApplyHighPixelDensityWindowFlag(
SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE,
IsEnabledEnv("MOCKTAIL_WIN_HIGH_DPI"));
if (g_state.state_persistence_active && g_state.persisted_window.fullscreen) {
if (presentation.fullscreen_at_creation) {
window_flags |= SDL_WINDOW_FULLSCREEN;
} else if (g_state.state_persistence_active &&
g_state.persisted_window.maximized) {
window_flags |= SDL_WINDOW_MAXIMIZED;
}
if (!ShouldShowWindowImmediately()) {
window_flags |= SDL_WINDOW_HIDDEN;
@@ -1175,6 +1191,7 @@ bool Init(int width, int height, const char* title) {
ApplyWindowIcon(g_state.sdl_window);
ApplyMinimumWindowSize(g_state.sdl_window);
ApplyRestoredWindowPosition();
ApplyRestoredWindowMaximization(g_state.sdl_window, presentation);
fprintf(stderr, " [window] SDL3 window created (%dx%d)\n", width, height);
ShowWindowAccordingToStartupMode();
@@ -2169,6 +2186,7 @@ bool PumpEvents() {
}
if ((event.type == SDL_EVENT_WINDOW_RESIZED ||
event.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED ||
event.type == SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED ||
event.type == SDL_EVENT_WINDOW_ENTER_FULLSCREEN ||
event.type == SDL_EVENT_WINDOW_LEAVE_FULLSCREEN) &&
g_text_input_owner != nullptr &&
+28 -2
View File
@@ -8,6 +8,15 @@
namespace mocktail {
namespace window {
namespace {
// ClearQuery may be called by teardown reached from the native query itself.
// Such a call owns the in-flight marker and must not wait for its own stack to
// unwind.
thread_local WindowPointerCaptureOwner* t_pointer_query_owner = nullptr;
} // namespace
WindowPointerCaptureOwner::WindowPointerCaptureOwner(
PointerCaptureBackend* backend)
: backend_(backend) {}
@@ -34,7 +43,9 @@ void WindowPointerCaptureOwner::ClearQuery() {
clearing_ = true;
callback_ = nullptr;
context_ = nullptr;
condition_.wait(lock, [this] { return in_flight_ == 0; });
condition_.wait(lock, [this] {
return in_flight_ == 0 || t_pointer_query_owner == this;
});
clearing_ = false;
lock.unlock();
condition_.notify_all();
@@ -54,7 +65,19 @@ bool WindowPointerCaptureOwner::Pump(bool text_input_active) {
bool locked_center = false;
const bool query_ok =
callback != nullptr && callback(context, &locked_center);
callback != nullptr &&
[&] {
WindowPointerCaptureOwner* previous_query_owner =
t_pointer_query_owner;
t_pointer_query_owner = this;
struct ResetCallbackMarker {
WindowPointerCaptureOwner* previous_query_owner;
~ResetCallbackMarker() {
t_pointer_query_owner = previous_query_owner;
}
} reset_callback_marker{previous_query_owner};
return callback(context, &locked_center);
}();
if (callback != nullptr) {
{
std::lock_guard<std::mutex> lock(mutex_);
@@ -168,16 +191,19 @@ bool SdlPointerCaptureBackend::Apply(bool relative_mode, bool cursor_visible) {
if (relative_mode && !relative_mode_) {
SDL_GetMouseState(&capture_anchor_x_, &capture_anchor_y_);
capture_anchor_valid_ = true;
SDL_SetWindowMouseGrab(window, true);
}
if (!relative_mode && relative_mode_ && capture_anchor_valid_) {
// SDL requires the warp before relative mode is disabled. This preserves
// the desktop pointer position across Roblox's transient RMB camera drag.
SDL_WarpMouseInWindow(window, capture_anchor_x_, capture_anchor_y_);
SDL_SetWindowMouseGrab(window, false);
}
if (relative_mode != relative_mode_ &&
!SDL_SetWindowRelativeMouseMode(window, relative_mode)) {
if (relative_mode) {
capture_anchor_valid_ = false;
SDL_SetWindowMouseGrab(window, false);
}
return false;
}
+8
View File
@@ -107,6 +107,14 @@ bool ReadBoolean(const nlohmann::json& object, const char* name, bool* value) {
} // namespace
WindowStartupPresentationPlan PlanWindowStartupPresentation(
const PersistedWindowState& state) {
if (state.fullscreen) {
return {true, false};
}
return {false, state.maximized};
}
WindowStateLoadResult LoadWindowState(const std::filesystem::path& path) {
WindowStateLoadResult result;
if (!path.is_absolute()) {
+13
View File
@@ -26,6 +26,19 @@ struct PersistedWindowState {
bool maximized = false;
};
// Fullscreen can be part of native window creation, but maximization must be
// requested only after startup size constraints and the windowed restore
// position have been applied. On X11, changing the minimum size of a window
// created with SDL_WINDOW_MAXIMIZED can synchronously wait on that initial
// maximize transition and crash or hang before the first event pump.
struct WindowStartupPresentationPlan {
bool fullscreen_at_creation = false;
bool maximize_after_constraints = false;
};
WindowStartupPresentationPlan PlanWindowStartupPresentation(
const PersistedWindowState& state);
struct WindowStateLoadResult {
bool found = false;
PersistedWindowState state;
+36 -5
View File
@@ -7,8 +7,11 @@
#include <SDL3/SDL_video.h>
#include <algorithm>
#include <cmath>
#include <utility>
#include "mocktail/platform/platform_runtime.h"
namespace mocktail {
namespace window {
@@ -194,11 +197,39 @@ bool WindowTextInputOwner::StopAndClear() {
}
bool SdlTextInputBackend::SetArea(const TextInputArea& area) {
SDL_Rect rect = {area.x, area.y, area.width, area.height};
const int cursor = std::max(0, std::min(area.cursor, area.width));
return window_ != nullptr && SDL_SetTextInputArea(
static_cast<SDL_Window*>(window_), &rect,
cursor);
auto* window = static_cast<SDL_Window*>(window_);
if (window == nullptr) {
return false;
}
platform::SurfaceCoordinateTransform transform;
if (!SDL_GetWindowSize(window, &transform.logical_width,
&transform.logical_height) ||
!SDL_GetWindowSizeInPixels(window, &transform.pixel_width,
&transform.pixel_height)) {
return false;
}
const float density_scale = SDL_GetWindowDisplayScale(window);
transform.density_scale = density_scale > 0.0F ? density_scale : 1.0F;
if (!transform.valid()) {
return false;
}
const int left =
static_cast<int>(std::lround(transform.GuestToHostLogicalX(area.x)));
const int top =
static_cast<int>(std::lround(transform.GuestToHostLogicalY(area.y)));
const int right = static_cast<int>(std::lround(
transform.GuestToHostLogicalX(static_cast<float>(area.x) + area.width)));
const int bottom = static_cast<int>(std::lround(
transform.GuestToHostLogicalY(static_cast<float>(area.y) + area.height)));
SDL_Rect rect = {left, top, std::max(1, right - left),
std::max(1, bottom - top)};
const int native_cursor = std::max(0, std::min(area.cursor, area.width));
const int cursor_edge =
static_cast<int>(std::lround(transform.GuestToHostLogicalX(
static_cast<float>(area.x) + native_cursor)));
const int cursor = std::max(0, std::min(cursor_edge - left, rect.w));
return window_ != nullptr && SDL_SetTextInputArea(window, &rect, cursor);
}
bool SdlTextInputBackend::ClearArea() {
+40 -1
View File
@@ -1,6 +1,7 @@
#include "runtime/frame_rate_policy.h"
#include <gtest/gtest.h>
#include <nlohmann/json.hpp>
#include <string>
@@ -9,11 +10,49 @@ namespace runtime {
namespace {
TEST(FrameRatePolicyTest, ParsesSupportedModes) {
EXPECT_EQ(ParseFrameRatePolicy("").mode,
FrameRateLimitMode::kUnmanaged);
EXPECT_EQ(ParseFrameRatePolicy("-1").mode,
FrameRateLimitMode::kUnmanaged);
EXPECT_EQ(ParseFrameRatePolicy("display").mode, FrameRateLimitMode::kDisplay);
EXPECT_EQ(ParseFrameRatePolicy("10").fixed_fps, 10);
EXPECT_EQ(ParseFrameRatePolicy("70").fixed_fps, 70);
EXPECT_EQ(ParseFrameRatePolicy("144").fixed_fps, 144);
EXPECT_EQ(ParseFrameRatePolicy("unlimited").mode,
FrameRateLimitMode::kUnlimited);
EXPECT_FALSE(ParseFrameRatePolicy("999").valid());
EXPECT_EQ(ParseFrameRatePolicy("999").fixed_fps, 999);
EXPECT_FALSE(ParseFrameRatePolicy("0").valid());
EXPECT_FALSE(ParseFrameRatePolicy("-10").valid());
EXPECT_FALSE(ParseFrameRatePolicy("10fps").valid());
EXPECT_FALSE(ParseFrameRatePolicy("10 ").valid());
}
TEST(FrameRatePolicyTest, UnmanagedOnlyEnablesRobloxBasicSettingsCap) {
std::string merged;
std::string error;
ASSERT_TRUE(MergeFrameRateClientSettingsOverrides(
ParseFrameRatePolicy("-1"),
R"({"FStringGraphicsVulkanShaderMTDenyPattern":"4318:.*"})", &merged,
&error))
<< error;
const nlohmann::json parsed =
nlohmann::json::parse(merged, nullptr, false, true);
ASSERT_TRUE(parsed.is_object());
EXPECT_EQ(parsed.at("FFlagGameBasicSettingsFramerateCap5"), "True");
EXPECT_FALSE(parsed.contains("DFIntTaskSchedulerTargetFps"));
EXPECT_FALSE(parsed.contains("FFlagTaskSchedulerLimitTargetFpsTo2402"));
EXPECT_EQ(parsed.at("FStringGraphicsVulkanShaderMTDenyPattern"), "4318:.*");
}
TEST(FrameRatePolicyTest, MergesArbitraryFixedValueIntoSchedulerFflag) {
std::string merged;
std::string error;
ASSERT_TRUE(MergeFrameRateClientSettingsOverrides(
ParseFrameRatePolicy("10"), "{}", &merged, &error));
EXPECT_NE(merged.find("\"DFIntTaskSchedulerTargetFps\":\"10\""),
std::string::npos);
EXPECT_NE(merged.find("\"FFlagGameBasicSettingsFramerateCap5\":\"True\""),
std::string::npos);
}
TEST(FrameRatePolicyTest, MergesUnlimitedWithoutLosingGraphicsPolicy) {
+2
View File
@@ -7,6 +7,8 @@ namespace graphics {
namespace {
TEST(PresentModePolicyTest, DerivesDisplayAndUnlimitedDefaults) {
EXPECT_EQ(ResolvePresentModePolicy("auto", "-1"),
PresentModePolicy::kHostDefault);
EXPECT_EQ(ResolvePresentModePolicy("auto", "display"),
PresentModePolicy::kVsync);
EXPECT_EQ(ResolvePresentModePolicy("auto", "60"),
+4 -4
View File
@@ -216,7 +216,7 @@ TEST_F(RobloxInputNativeAdapterTest,
TEST_F(RobloxInputNativeAdapterTest, CallsExactExportedJniSignatures) {
RobloxInputRuntime runtime(Environment(), Symbols());
ASSERT_TRUE(runtime.Initialize({1280, 720, 2560, 1440}, true).ok());
ASSERT_TRUE(runtime.Initialize({1280, 720, 2560, 1440, 2.0F}, true).ok());
EXPECT_TRUE(runtime
.HandleEvent(Event(platform::MouseMotionEvent{
@@ -243,16 +243,16 @@ TEST_F(RobloxInputNativeAdapterTest, CallsExactExportedJniSignatures) {
ASSERT_EQ(probe_.calls.size(), 5U);
EXPECT_EQ(probe_.calls[0].name, "mouse-move");
EXPECT_EQ(probe_.calls[0].arguments,
(std::vector<double>{180.0, 380.0, 8.0, -4.0}));
(std::vector<double>{90.0, 190.0, 4.0, -2.0}));
EXPECT_EQ(probe_.calls[1].name, "mouse-button");
EXPECT_EQ(probe_.calls[1].arguments,
(std::vector<double>{200.0, 400.0, JNI_TRUE, 3.0}));
(std::vector<double>{100.0, 200.0, JNI_TRUE, 3.0}));
EXPECT_EQ(probe_.calls[2].name, "touch");
EXPECT_EQ(probe_.calls[2].arguments,
(std::vector<double>{0.0, 320.0, 360.0, 0.0, 1280.0, 720.0}));
EXPECT_EQ(probe_.calls[3].name, "mouse-wheel");
EXPECT_EQ(probe_.calls[3].arguments,
(std::vector<double>{200.0, 400.0, 3.0}));
(std::vector<double>{100.0, 200.0, 3.0}));
EXPECT_EQ(probe_.calls[4].name, "key");
EXPECT_EQ(probe_.calls[4].arguments,
(std::vector<double>{JNI_TRUE, 17.0, 51.0, JNI_FALSE}));
+57 -19
View File
@@ -163,7 +163,7 @@ platform::PlatformEvent Event(platform::PlatformEventPayload payload) {
class RobloxInputRouterTest : public ::testing::Test {
protected:
void SetUp() override {
ASSERT_TRUE(router_.Activate({1280, 720, 2560, 1440}, true).ok());
ASSERT_TRUE(router_.Activate({1280, 720, 2560, 1440, 2.0F}, true).ok());
}
Probe probe_;
@@ -189,6 +189,26 @@ TEST(RobloxInputMappingTest, MapsSdlUsbKeysToLinuxAndAndroidCodes) {
EXPECT_FALSE(MapSdlKeyToAndroid(SDL_SCANCODE_UNKNOWN).valid());
}
TEST(SurfaceCoordinateTransformTest,
KeepsAndroidDensityAndDrawableScaleAsInverseOperations) {
const platform::SurfaceCoordinateTransform retina = {1280, 720, 2560, 1440,
2.0F};
ASSERT_TRUE(retina.valid());
EXPECT_EQ(retina.rounded_guest_width(), 1280);
EXPECT_EQ(retina.rounded_guest_height(), 720);
EXPECT_FLOAT_EQ(retina.HostLogicalToGuestX(321.0F), 321.0F);
EXPECT_FLOAT_EQ(retina.GuestToHostLogicalY(123.0F), 123.0F);
const platform::SurfaceCoordinateTransform x11 = {1624, 811, 1624, 811,
1.145833F};
const float host_x = x11.GuestToHostLogicalX(943.0F);
const float host_y = x11.GuestToHostLogicalY(527.0F);
EXPECT_NEAR(host_x, 1080.5F, 0.1F);
EXPECT_NEAR(host_y, 603.85F, 0.1F);
EXPECT_NEAR(x11.HostLogicalToGuestX(host_x), 943.0F, 0.01F);
EXPECT_NEAR(x11.HostLogicalToGuestY(host_y), 527.0F, 0.01F);
}
TEST_F(RobloxInputRouterTest, RoutesMouseMotionButtonAndVerticalWheel) {
EXPECT_TRUE(router_
.HandleEvent(Event(platform::MouseMotionEvent{
@@ -204,13 +224,13 @@ TEST_F(RobloxInputRouterTest, RoutesMouseMotionButtonAndVerticalWheel) {
.dispatched());
ASSERT_EQ(probe_.mouse_moves.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].x, 200.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].y, 160.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_x, 10.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_y, -6.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].x, 100.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].y, 80.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_x, 5.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_y, -3.0f);
ASSERT_EQ(probe_.mouse_buttons.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].x, 200.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].y, 160.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].x, 100.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].y, 80.0f);
EXPECT_EQ(probe_.mouse_buttons[0].button, 1);
ASSERT_EQ(probe_.mouse_wheels.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].x, 0.0f);
@@ -221,7 +241,7 @@ TEST_F(RobloxInputRouterTest, RoutesMouseMotionButtonAndVerticalWheel) {
TEST_F(RobloxInputRouterTest,
ScalesMouseCoordinatesAfterDisplayScaleChange) {
const RobloxInputDispatchResult resize = router_.HandleEvent(
Event(platform::WindowResizedEvent{1000, 800, 1250, 1000}));
Event(platform::WindowResizedEvent{1000, 800, 1250, 1000, 1.25F}));
ASSERT_EQ(resize.state, RobloxInputDispatchState::kStateUpdated);
ASSERT_TRUE(router_
@@ -238,21 +258,39 @@ TEST_F(RobloxInputRouterTest,
.dispatched());
ASSERT_EQ(probe_.mouse_moves.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].x, 500.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].y, 300.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_x, 10.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_y, -5.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].x, 400.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].y, 240.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_x, 8.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_y, -4.0f);
ASSERT_EQ(probe_.mouse_buttons.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].x, 375.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].y, 250.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].x, 300.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].y, 200.0f);
ASSERT_EQ(probe_.mouse_wheels.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].x, 750.0f);
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].y, 500.0f);
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].x, 600.0f);
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].y, 400.0f);
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].delta_y, 2.0f);
}
TEST_F(RobloxInputRouterTest,
DividesPhysicalPointerCoordinatesByAndroidDensity) {
const RobloxInputDispatchResult resize = router_.HandleEvent(
Event(platform::WindowResizedEvent{1624, 811, 1624, 811, 1.145833F}));
ASSERT_EQ(resize.state, RobloxInputDispatchState::kStateUpdated);
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseMotionEvent{
1080.5F, 604.0F, 11.45833F, -5.729165F, 0}))
.dispatched());
ASSERT_EQ(probe_.mouse_moves.size(), 1U);
EXPECT_NEAR(probe_.mouse_moves[0].x, 943.0F, 0.1F);
EXPECT_NEAR(probe_.mouse_moves[0].y, 527.14F, 0.1F);
EXPECT_NEAR(probe_.mouse_moves[0].delta_x, 10.0F, 0.01F);
EXPECT_NEAR(probe_.mouse_moves[0].delta_y, -5.0F, 0.01F);
}
TEST_F(RobloxInputRouterTest, ScalesNormalizedTouchAndKeepsStablePointerIds) {
EXPECT_TRUE(router_
.HandleEvent(Event(platform::TouchEvent{
@@ -298,7 +336,7 @@ TEST_F(RobloxInputRouterTest, ScalesNormalizedTouchAndKeepsStablePointerIds) {
TEST_F(RobloxInputRouterTest, ResizeChangesTouchScaleAndNativeViewport) {
RobloxInputDispatchResult resize = router_.HandleEvent(
Event(platform::WindowResizedEvent{640, 360, 1920, 1080}));
Event(platform::WindowResizedEvent{640, 360, 1920, 1080, 3.0F}));
EXPECT_EQ(resize.state, RobloxInputDispatchState::kStateUpdated);
EXPECT_TRUE(router_
@@ -530,8 +568,8 @@ TEST_F(RobloxInputRouterTest,
ASSERT_TRUE(result.dispatched());
ASSERT_EQ(probe_.mouse_buttons.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].x, 642.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].y, 246.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].x, 321.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].y, 123.0f);
EXPECT_TRUE(probe_.mouse_buttons[0].pressed);
EXPECT_EQ(probe_.mouse_buttons[0].button, 0);
}
+63
View File
@@ -294,6 +294,30 @@ TEST(RobloxTextDisplayStateTest,
EXPECT_TRUE(overlay.Shutdown().ok());
}
TEST(RobloxTextDisplayStateTest,
PublishesNativeTextGeometryInAndroidDensityCoordinates) {
RobloxTextSurfaceOverlay overlay;
ASSERT_TRUE(overlay.Initialize({1624, 811, 1.145833F}).ok());
RobloxTextDisplaySink sink = overlay.sink();
const std::string text = "density mapped";
RobloxTextDisplayUpdate show = Show(10, text, 0);
show.area_x = 943;
show.area_y = 527;
show.area_width = 409;
show.area_height = 76;
sink.update(sink.context, show);
MocktailTextOverlayFrameInfo frame;
ASSERT_TRUE(overlay.QueryFrame(&frame));
EXPECT_EQ(frame.coordinate_width, 1417U);
EXPECT_EQ(frame.coordinate_height, 708U);
EXPECT_EQ(frame.x, 943);
EXPECT_EQ(frame.y, 527);
EXPECT_EQ(frame.width, 409U);
EXPECT_EQ(frame.height, 76U);
EXPECT_TRUE(overlay.Shutdown().ok());
}
TEST(RobloxTextDisplayStateTest,
InactiveOverlayCannotClearAnotherOverlayPublication) {
RobloxTextSurfaceOverlay active;
@@ -347,6 +371,45 @@ TEST(RobloxTextDisplayStateTest, MatchesApkHorizontalGravityEnum) {
EXPECT_TRUE(overlay.Shutdown().ok());
}
TEST(RobloxTextDisplayStateTest,
KeepsTopNavigationSearchTextClearOfLeadingIcon) {
RobloxTextSurfaceOverlay overlay;
ASSERT_TRUE(overlay.Initialize({2560, 1440}).ok());
RobloxTextDisplaySink sink = overlay.sink();
const std::string text = "search text";
const auto render = [&](uint64_t generation, int32_t y, int32_t width,
int32_t height) {
RobloxTextDisplayUpdate show = Show(generation, text, 0);
show.area_y = y;
show.area_width = width;
show.area_height = height;
show.font_size = 18.0F;
show.x_alignment = 0;
show.text_input_type = 1;
show.text_color = static_cast<int32_t>(0xFFFFFFFFU);
sink.update(sink.context, show);
MocktailTextOverlayFrameInfo frame;
EXPECT_TRUE(overlay.QueryFrame(&frame));
std::vector<std::uint8_t> rgba(frame.rgba_bytes);
EXPECT_TRUE(overlay.CopyFrame(frame.revision, rgba.data(), rgba.size()));
return FindAlphaBounds(frame, rgba);
};
// Geometry captured from the Roblox top navigation search field. Its
// NativeTextBoxInfo spans the magnifying-glass icon but exposes no padding.
const AlphaBounds search = render(1, 34, 1047, 36);
const AlphaBounds ordinary = render(2, 200, 1047, 36);
ASSERT_TRUE(search.valid());
ASSERT_TRUE(ordinary.valid());
EXPECT_GE(search.minimum_x, 38);
EXPECT_LE(search.minimum_x, 55);
EXPECT_GT(search.minimum_x - ordinary.minimum_x, 20);
EXPECT_LT(search.minimum_x - ordinary.minimum_x, 45);
EXPECT_TRUE(overlay.Shutdown().ok());
}
TEST(RobloxTextDisplayStateTest, KeepsBackgroundTransparentAndArgbCaretAlpha) {
RobloxTextSurfaceOverlay overlay;
ASSERT_TRUE(overlay.Initialize({800, 600}).ok());
+18
View File
@@ -299,6 +299,24 @@ TEST(RobloxTextEditorTest, OutOfOrderHostEchoesCannotRewindCurrentText) {
EXPECT_EQ(display_probe.calls.back().text, "ab");
}
TEST(RobloxTextEditorTest, EmptyEngineUpdateOverridesOlderEmptyHostEcho) {
Probe probe;
DisplayProbe display_probe;
RobloxTextEditor editor(Sink(&probe), DisplaySink(&display_probe));
ASSERT_TRUE(editor.BeginFocusSession(Session(42, 7, "x")).ok());
ASSERT_TRUE(editor.HandleKey(Key(SDL_SCANCODE_BACKSPACE)).status.ok());
ASSERT_TRUE(editor.HandleTextInput({"next"}).status.ok());
ASSERT_TRUE(editor.ReplaceFocusedTextFromEngine(7, {}).ok());
EXPECT_EQ(editor.Snapshot().text_bytes, 0u);
EXPECT_EQ(editor.Snapshot().cursor_utf16, 0);
ASSERT_FALSE(display_probe.calls.empty());
EXPECT_EQ(display_probe.calls.back().event,
RobloxTextDisplayEvent::kUpdate);
EXPECT_TRUE(display_probe.calls.back().text.empty());
}
TEST(RobloxTextEditorTest, UnknownEngineUpdateCancelsActiveComposition) {
Probe probe;
DisplayProbe display_probe;
@@ -677,6 +677,46 @@ TEST(RobloxTextInputJniBridgeTest,
EXPECT_TRUE(bridge->Shutdown().ok());
}
TEST(RobloxTextInputJniBridgeTest,
FocusedTextBoxTracksGeometryChangesWithoutAPropertyCallback) {
jnivm::VM vm;
auto backend = std::make_shared<FakeBackend>();
std::unique_ptr<RobloxTextInputJniBridge> bridge;
ASSERT_TRUE(
RobloxTextInputJniBridge::CreateForTesting(&vm, backend, &bridge).ok());
jnivm::RobloxTextInputShowRequest request = ShowRequest(43, "moving");
request.info.x = 10.0F;
request.info.y = 20.0F;
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(request));
ASSERT_TRUE(backend->Pump());
backend->calls.clear();
backend->query_result.available = true;
backend->query_result.info.x = 325.0F;
backend->query_result.info.y = 410.0F;
backend->query_result.info.width = 420.0F;
backend->query_result.info.height = 64.0F;
backend->query_result.info.text_input_type = 1;
for (int pump = 0; pump < 8; ++pump) {
ASSERT_TRUE(backend->Pump());
}
EXPECT_EQ(backend->calls,
(std::vector<std::string>{"query:1", "properties:1", "show:1"}));
EXPECT_EQ(backend->last_properties.area_x, 325);
EXPECT_EQ(backend->last_properties.area_y, 410);
EXPECT_EQ(backend->last_properties.area_width, 420);
EXPECT_EQ(backend->last_properties.area_height, 64);
backend->calls.clear();
for (int pump = 0; pump < 8; ++pump) {
ASSERT_TRUE(backend->Pump());
}
EXPECT_EQ(backend->calls, (std::vector<std::string>{"query:1"}));
EXPECT_TRUE(bridge->Shutdown().ok());
}
TEST(RobloxTextInputJniBridgeTest,
ShutdownClosesAppliedSessionBeforeReleasingBackend) {
jnivm::VM vm;
+5 -5
View File
@@ -100,7 +100,7 @@ TEST(RuntimeConfigBootstrapTest, CreatesCompletePrivateFirstRunFile) {
ASSERT_TRUE(loaded) << loaded.error;
EXPECT_TRUE(loaded.file_loaded);
EXPECT_EQ(loaded.config.graphics_backend(), GraphicsBackend::kVulkan);
EXPECT_EQ(loaded.config.frame_rate().mode, FrameRateLimitMode::kDisplay);
EXPECT_EQ(loaded.config.frame_rate().mode, FrameRateLimitMode::kUnmanaged);
EXPECT_EQ(loaded.config.vsync_mode(), "auto");
EXPECT_FALSE(loaded.config.performance().multithreaded_rendering);
EXPECT_EQ(loaded.config.performance().memory_limit_mb, 0U);
@@ -132,10 +132,10 @@ TEST(RuntimeConfigBootstrapTest,
"window.\n headless: false",
"# Recommended values: direct-vulkan, opengl, system, "
"angle-vulkan.\n backend: direct-vulkan",
"# selects Roblox's unmodified 240-FPS scheduler maximum.\n "
"frame_rate_limit: display",
"# String (default: auto): presentation synchronization: auto, "
"on, or off.\n vsync: auto",
"# DFIntTaskSchedulerTargetFps without a whitelist.\n "
"# frame_rate_limit: -1",
"# Optional presentation synchronization override: auto, on, or "
"off.\n # vsync: off",
"# physical CPU core. A place's Lua/main thread can still remain "
"serial.\n multithreaded_rendering: false",
"# sizes and coalesces midphase work. Supported: auto, latency, "
+1 -1
View File
@@ -54,7 +54,7 @@ TEST(RuntimeConfigTest, UsesSupportedDefaults) {
EXPECT_TRUE(config.desktop_playability());
ASSERT_TRUE(config.roblox_http_user_agent().has_value());
EXPECT_EQ(*config.roblox_http_user_agent(), kRobloxDesktopHttpUserAgent);
EXPECT_EQ(config.frame_rate().mode, FrameRateLimitMode::kDisplay);
EXPECT_EQ(config.frame_rate().mode, FrameRateLimitMode::kUnmanaged);
EXPECT_FALSE(config.performance().multithreaded_rendering);
EXPECT_GT(config.performance().physical_core_count, 0);
EXPECT_EQ(config.performance().memory_limit_mb, 0U);
@@ -38,6 +38,20 @@ bool Query(void* context, bool* locked_center) {
return true;
}
struct ReentrantQueryState {
WindowPointerCaptureOwner* owner = nullptr;
};
bool ClearQueryReentrantly(void* context, bool* locked_center) {
auto* state = static_cast<ReentrantQueryState*>(context);
if (state == nullptr || state->owner == nullptr || locked_center == nullptr) {
return false;
}
*locked_center = false;
state->owner->ClearQuery();
return true;
}
TEST(WindowPointerCaptureOwnerTest, FollowsNativeMouseLockState) {
FakeBackend backend;
QueryState query;
@@ -91,6 +105,19 @@ TEST(WindowPointerCaptureOwnerTest, QueryOrCaptureFailureStaysReleased) {
EXPECT_FALSE(owner.captured());
}
TEST(WindowPointerCaptureOwnerTest, QueryCanClearItselfWithoutDeadlock) {
FakeBackend backend;
WindowPointerCaptureOwner owner(&backend);
ReentrantQueryState query{&owner};
ASSERT_TRUE(owner.RegisterQuery(ClearQueryReentrantly, &query));
EXPECT_TRUE(owner.Pump(false));
EXPECT_FALSE(owner.captured());
QueryState replacement;
EXPECT_TRUE(owner.RegisterQuery(Query, &replacement));
}
TEST(WindowPointerCaptureOwnerTest, RightDragCapturesUntilButtonRelease) {
FakeBackend backend;
QueryState query{true, false};
+22
View File
@@ -75,6 +75,28 @@ TEST(WindowStateStoreTest, RoundTripsGeometryAndPresentationState) {
EXPECT_FALSE(loaded.state.maximized);
}
TEST(WindowStateStoreTest,
DefersMaximizedRestoreUntilAfterStartupConstraints) {
PersistedWindowState windowed;
WindowStartupPresentationPlan plan =
PlanWindowStartupPresentation(windowed);
EXPECT_FALSE(plan.fullscreen_at_creation);
EXPECT_FALSE(plan.maximize_after_constraints);
PersistedWindowState maximized;
maximized.maximized = true;
plan = PlanWindowStartupPresentation(maximized);
EXPECT_FALSE(plan.fullscreen_at_creation);
EXPECT_TRUE(plan.maximize_after_constraints);
PersistedWindowState fullscreen_and_maximized;
fullscreen_and_maximized.fullscreen = true;
fullscreen_and_maximized.maximized = true;
plan = PlanWindowStartupPresentation(fullscreen_and_maximized);
EXPECT_TRUE(plan.fullscreen_at_creation);
EXPECT_FALSE(plan.maximize_after_constraints);
}
TEST(WindowStateStoreTest, RejectsMalformedAndOutOfRangeState) {
TemporaryDirectory temporary;
const std::filesystem::path path = temporary.path() / "window-state.json";