mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 15:00:47 +02:00
1 parent
0cc9ab2e32
commit
d5f9c91358
34 files changed
+691
-163
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, "
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user