diff --git a/config/mocktail.example.yaml b/config/mocktail.example.yaml index 741e8a5..5bdc7e1 100644 --- a/config/mocktail.example.yaml +++ b/config/mocktail.example.yaml @@ -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 diff --git a/include/mocktail/platform/platform_runtime.h b/include/mocktail/platform/platform_runtime.h index 9ad8ba2..1094e56 100644 --- a/include/mocktail/platform/platform_runtime.h +++ b/include/mocktail/platform/platform_runtime.h @@ -1,6 +1,7 @@ #ifndef MOCKTAIL_PLATFORM_PLATFORM_RUNTIME_H_ #define MOCKTAIL_PLATFORM_PLATFORM_RUNTIME_H_ +#include #include #include #include @@ -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(pixel_width) / density_scale : 0.0F; + } + float guest_height() const { + return valid() ? static_cast(pixel_height) / density_scale : 0.0F; + } + int32_t rounded_guest_width() const { + return valid() ? static_cast(std::lround(guest_width())) : 0; + } + int32_t rounded_guest_height() const { + return valid() ? static_cast(std::lround(guest_height())) : 0; + } + + float HostLogicalToGuestX(float coordinate) const { + return valid() ? coordinate * static_cast(pixel_width) / + static_cast(logical_width) / density_scale + : coordinate; + } + float HostLogicalToGuestY(float coordinate) const { + return valid() ? coordinate * static_cast(pixel_height) / + static_cast(logical_height) / density_scale + : coordinate; + } + float GuestToHostLogicalX(float coordinate) const { + return valid() ? coordinate * density_scale * + static_cast(logical_width) / + static_cast(pixel_width) + : coordinate; + } + float GuestToHostLogicalY(float coordinate) const { + return valid() ? coordinate * density_scale * + static_cast(logical_height) / + static_cast(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 { diff --git a/include/runtime/frame_rate_policy.h b/include/runtime/frame_rate_policy.h index c824b1a..7ce6ddc 100644 --- a/include/runtime/frame_rate_policy.h +++ b/include/runtime/frame_rate_policy.h @@ -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. diff --git a/include/runtime/roblox_input_router.h b/include/runtime/roblox_input_router.h index 0399d71..b349b3f 100644 --- a/include/runtime/roblox_input_router.h +++ b/include/runtime/roblox_input_router.h @@ -1,6 +1,7 @@ #ifndef MOCKTAIL_RUNTIME_ROBLOX_INPUT_ROUTER_H_ #define MOCKTAIL_RUNTIME_ROBLOX_INPUT_ROUTER_H_ +#include #include #include #include @@ -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; } }; diff --git a/include/runtime/roblox_text_display_state.h b/include/runtime/roblox_text_display_state.h index adc5176..59381c9 100644 --- a/include/runtime/roblox_text_display_state.h +++ b/include/runtime/roblox_text_display_state.h @@ -1,6 +1,7 @@ #ifndef MOCKTAIL_RUNTIME_ROBLOX_TEXT_DISPLAY_STATE_H_ #define MOCKTAIL_RUNTIME_ROBLOX_TEXT_DISPLAY_STATE_H_ +#include #include #include #include @@ -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 { diff --git a/src/graphics/present_mode_policy.cc b/src/graphics/present_mode_policy.cc index 6c2b435..4e28297 100644 --- a/src/graphics/present_mode_policy.cc +++ b/src/graphics/present_mode_policy.cc @@ -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; } diff --git a/src/legacy/legacy_runtime.cc b/src/legacy/legacy_runtime.cc index b88eb97..fe23d00 100644 --- a/src/legacy/legacy_runtime.cc +++ b/src/legacy/legacy_runtime.cc @@ -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()) { diff --git a/src/platform/sdl_event_converter.cc b/src/platform/sdl_event_converter.cc index 6fc1074..d9fc121 100644 --- a/src/platform/sdl_event_converter.cc +++ b/src/platform/sdl_event_converter.cc @@ -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; } diff --git a/src/runtime/frame_rate_policy.cc b/src/runtime/frame_rate_policy.cc index ea8f506..6eb2946 100644 --- a/src/runtime/frame_rate_policy.cc +++ b/src/runtime/frame_rate_policy.cc @@ -3,7 +3,7 @@ #define JSON_NOEXCEPTION 1 #include -#include +#include #include 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 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; } diff --git a/src/runtime/roblox_input_router.cc b/src/runtime/roblox_input_router.cc index 38bef96..d88f66f 100644 --- a/src/runtime/roblox_input_router.cc +++ b/src/runtime/roblox_input_router.cc @@ -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(pixel_extent) / - static_cast(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(&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( - snapshot_.viewport.pixel_width > 0 ? snapshot_.viewport.pixel_width - 1 : 0); - const float max_y = static_cast( - 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( - snapshot_.viewport.pixel_width > 0 ? snapshot_.viewport.pixel_width - 1 : 0); - const float max_y = static_cast( - 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(snapshot_.viewport.logical_width); - const float y = std::clamp(event.y, 0.0f, 1.0f) * - static_cast(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(guest_width); + const float y = + std::clamp(event.y, 0.0f, 1.0f) * static_cast(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()) { diff --git a/src/runtime/roblox_text_display_state.cc b/src/runtime/roblox_text_display_state.cc index 89e9a83..a870d53 100644 --- a/src/runtime/roblox_text_display_state.cc +++ b/src/runtime/roblox_text_display_state.cc @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -90,12 +91,25 @@ bool NativeGeometryIntersectsViewport( static_cast(update.area_width); const int64_t bottom = static_cast(update.area_y) + static_cast(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( + std::lround(static_cast(width) / density_scale)) + : 0; +} + +int RobloxTextOverlayViewport::coordinate_height() const { + return valid() ? static_cast( + std::lround(static_cast(height) / density_scale)) + : 0; +} + RobloxTextOverlayPresentation::~RobloxTextOverlayPresentation() { ClearSensitive(); } diff --git a/src/runtime/roblox_text_editor.cc b/src/runtime/roblox_text_editor.cc index 4085a2a..118c404 100644 --- a/src/runtime/roblox_text_editor.cc +++ b/src/runtime/roblox_text_editor.cc @@ -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 diff --git a/src/runtime/roblox_text_input_jni_bridge.cc b/src/runtime/roblox_text_input_jni_bridge.cc index 5c5d706..23e75b8 100644 --- a/src/runtime/roblox_text_input_jni_bridge.cc +++ b/src/runtime/roblox_text_input_jni_bridge.cc @@ -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* 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* pending) { if (pending == nullptr) { return; } std::lock_guard 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 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(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; diff --git a/src/runtime/roblox_text_surface_overlay.cc b/src/runtime/roblox_text_surface_overlay.cc index 18da884..ae0bb80 100644 --- a/src/runtime/roblox_text_surface_overlay.cc +++ b/src/runtime/roblox_text_surface_overlay.cc @@ -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(std::min( + height + ordinary_inset / 2, std::min(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(viewport_.width); - frame->coordinate_height = static_cast(viewport_.height); + frame->coordinate_width = + static_cast(viewport_.coordinate_width()); + frame->coordinate_height = + static_cast(viewport_.coordinate_height()); frame->x = presentation.geometry.x; frame->y = presentation.geometry.y; frame->width = static_cast(presentation.geometry.width); @@ -429,9 +461,10 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() { static_cast(width * height * 4), 0); const int frame_width = static_cast(width); const int frame_height = static_cast(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); diff --git a/src/runtime/roblox_window_input_runtime.cc b/src/runtime/roblox_window_input_runtime.cc index 1b52092..cc4c2b2 100644 --- a/src/runtime/roblox_window_input_runtime.cc +++ b/src/runtime/roblox_window_input_runtime.cc @@ -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(&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); } diff --git a/src/runtime/runtime_config.cc b/src/runtime/runtime_config.cc index 82f2059..f48bad2 100644 --- a/src/runtime/runtime_config.cc +++ b/src/runtime/runtime_config.cc @@ -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"), diff --git a/src/runtime/runtime_config_bootstrap.cc b/src/runtime/runtime_config_bootstrap.cc index a0cdc41..6389354 100644 --- a/src/runtime/runtime_config_bootstrap.cc +++ b/src/runtime/runtime_config_bootstrap.cc @@ -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 diff --git a/src/runtime/runtime_config_file.cc b/src/runtime/runtime_config_file.cc index 1e05ab5..10f8aa1 100644 --- a/src/runtime/runtime_config_file.cc +++ b/src/runtime/runtime_config_file.cc @@ -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"; } diff --git a/src/window/window.cc b/src/window/window.cc index 2f4c765..668a498 100644 --- a/src/window/window.cc +++ b/src/window/window.cc @@ -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 && diff --git a/src/window/window_pointer_capture_owner.cc b/src/window/window_pointer_capture_owner.cc index e1932cb..a9b3e3d 100644 --- a/src/window/window_pointer_capture_owner.cc +++ b/src/window/window_pointer_capture_owner.cc @@ -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 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; } diff --git a/src/window/window_state_store.cc b/src/window/window_state_store.cc index 177f7e1..935c8d3 100644 --- a/src/window/window_state_store.cc +++ b/src/window/window_state_store.cc @@ -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()) { diff --git a/src/window/window_state_store.h b/src/window/window_state_store.h index f5b5d8b..92cc468 100644 --- a/src/window/window_state_store.h +++ b/src/window/window_state_store.h @@ -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; diff --git a/src/window/window_text_input_owner.cc b/src/window/window_text_input_owner.cc index dbf1954..89ef4c6 100644 --- a/src/window/window_text_input_owner.cc +++ b/src/window/window_text_input_owner.cc @@ -7,8 +7,11 @@ #include #include +#include #include +#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(window_), &rect, - cursor); + auto* window = static_cast(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(std::lround(transform.GuestToHostLogicalX(area.x))); + const int top = + static_cast(std::lround(transform.GuestToHostLogicalY(area.y))); + const int right = static_cast(std::lround( + transform.GuestToHostLogicalX(static_cast(area.x) + area.width))); + const int bottom = static_cast(std::lround( + transform.GuestToHostLogicalY(static_cast(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(std::lround(transform.GuestToHostLogicalX( + static_cast(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() { diff --git a/tests/frame_rate_policy_test.cc b/tests/frame_rate_policy_test.cc index 8fbddf8..1a020b4 100644 --- a/tests/frame_rate_policy_test.cc +++ b/tests/frame_rate_policy_test.cc @@ -1,6 +1,7 @@ #include "runtime/frame_rate_policy.h" #include +#include #include @@ -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) { diff --git a/tests/present_mode_policy_test.cc b/tests/present_mode_policy_test.cc index c766d24..d7ea1a0 100644 --- a/tests/present_mode_policy_test.cc +++ b/tests/present_mode_policy_test.cc @@ -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"), diff --git a/tests/roblox_input_native_adapter_test.cc b/tests/roblox_input_native_adapter_test.cc index 5db952a..d3bc3da 100644 --- a/tests/roblox_input_native_adapter_test.cc +++ b/tests/roblox_input_native_adapter_test.cc @@ -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{180.0, 380.0, 8.0, -4.0})); + (std::vector{90.0, 190.0, 4.0, -2.0})); EXPECT_EQ(probe_.calls[1].name, "mouse-button"); EXPECT_EQ(probe_.calls[1].arguments, - (std::vector{200.0, 400.0, JNI_TRUE, 3.0})); + (std::vector{100.0, 200.0, JNI_TRUE, 3.0})); EXPECT_EQ(probe_.calls[2].name, "touch"); EXPECT_EQ(probe_.calls[2].arguments, (std::vector{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{200.0, 400.0, 3.0})); + (std::vector{100.0, 200.0, 3.0})); EXPECT_EQ(probe_.calls[4].name, "key"); EXPECT_EQ(probe_.calls[4].arguments, (std::vector{JNI_TRUE, 17.0, 51.0, JNI_FALSE})); diff --git a/tests/roblox_input_router_test.cc b/tests/roblox_input_router_test.cc index 9c5ad21..419d8c7 100644 --- a/tests/roblox_input_router_test.cc +++ b/tests/roblox_input_router_test.cc @@ -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); } diff --git a/tests/roblox_text_display_state_test.cc b/tests/roblox_text_display_state_test.cc index 9c45472..131f36e 100644 --- a/tests/roblox_text_display_state_test.cc +++ b/tests/roblox_text_display_state_test.cc @@ -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(0xFFFFFFFFU); + sink.update(sink.context, show); + + MocktailTextOverlayFrameInfo frame; + EXPECT_TRUE(overlay.QueryFrame(&frame)); + std::vector rgba(frame.rgba_bytes); + EXPECT_TRUE(overlay.CopyFrame(frame.revision, rgba.data(), rgba.size())); + return FindAlphaBounds(frame, rgba); + }; + + // 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()); diff --git a/tests/roblox_text_editor_test.cc b/tests/roblox_text_editor_test.cc index 63a9cbd..a3e9694 100644 --- a/tests/roblox_text_editor_test.cc +++ b/tests/roblox_text_editor_test.cc @@ -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; diff --git a/tests/roblox_text_input_jni_bridge_test.cc b/tests/roblox_text_input_jni_bridge_test.cc index fc8b2ff..c5f0c61 100644 --- a/tests/roblox_text_input_jni_bridge_test.cc +++ b/tests/roblox_text_input_jni_bridge_test.cc @@ -677,6 +677,46 @@ TEST(RobloxTextInputJniBridgeTest, EXPECT_TRUE(bridge->Shutdown().ok()); } +TEST(RobloxTextInputJniBridgeTest, + FocusedTextBoxTracksGeometryChangesWithoutAPropertyCallback) { + jnivm::VM vm; + auto backend = std::make_shared(); + std::unique_ptr 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{"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{"query:1"})); + EXPECT_TRUE(bridge->Shutdown().ok()); +} + TEST(RobloxTextInputJniBridgeTest, ShutdownClosesAppliedSessionBeforeReleasingBackend) { jnivm::VM vm; diff --git a/tests/runtime_config_file_test.cc b/tests/runtime_config_file_test.cc index 2778bc4..504ef3e 100644 --- a/tests/runtime_config_file_test.cc +++ b/tests/runtime_config_file_test.cc @@ -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, " diff --git a/tests/runtime_config_test.cc b/tests/runtime_config_test.cc index e2d4760..f9345ae 100644 --- a/tests/runtime_config_test.cc +++ b/tests/runtime_config_test.cc @@ -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); diff --git a/tests/window_pointer_capture_owner_test.cc b/tests/window_pointer_capture_owner_test.cc index 22e0625..3c595e7 100644 --- a/tests/window_pointer_capture_owner_test.cc +++ b/tests/window_pointer_capture_owner_test.cc @@ -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(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}; diff --git a/tests/window_state_store_test.cc b/tests/window_state_store_test.cc index c917195..49899b8 100644 --- a/tests/window_state_store_test.cc +++ b/tests/window_state_store_test.cc @@ -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";