Fix Roblox text input rendering

Fixes #68
Fixes #70
Follow-up to #21
This commit is contained in:
komaruworld committed 2026-08-28 16:29:50 +03:00
1 parent 5f2be59192
commit 497481e8fa
13 files changed
+656 -59

No files matched your search

+12
View File
@@ -18,6 +18,7 @@ add_library(mocktail_input_runtime STATIC
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_native_text_box_info_reader.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_display_state.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_editor.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_font_resolver.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_surface_overlay.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_window_input_runtime.cc
)
@@ -36,6 +37,7 @@ target_link_libraries(mocktail_input_runtime PUBLIC
PRIVATE
PkgConfig::FONTCONFIG
SDL3_ttf::SDL3_ttf
nlohmann_json::nlohmann_json
)
target_compile_features(mocktail_input_runtime PUBLIC cxx_std_17)
mocktail_apply_compile_options(mocktail_input_runtime)
@@ -68,6 +70,15 @@ if(BUILD_TESTING AND TARGET GTest::gtest_main)
)
mocktail_apply_compile_options(roblox_text_display_state_test)
add_executable(roblox_text_font_resolver_test
${MOCKTAIL_INPUT_ROOT}/tests/roblox_text_font_resolver_test.cc
)
target_link_libraries(roblox_text_font_resolver_test PRIVATE
Mocktail::InputRuntime
GTest::gtest_main
)
mocktail_apply_compile_options(roblox_text_font_resolver_test)
add_executable(roblox_input_native_adapter_test
${MOCKTAIL_INPUT_ROOT}/tests/roblox_input_native_adapter_test.cc
)
@@ -92,6 +103,7 @@ if(BUILD_TESTING AND TARGET GTest::gtest_main)
gtest_discover_tests(roblox_input_router_test)
gtest_discover_tests(roblox_text_editor_test)
gtest_discover_tests(roblox_text_display_state_test)
gtest_discover_tests(roblox_text_font_resolver_test)
gtest_discover_tests(roblox_input_native_adapter_test)
gtest_discover_tests(roblox_native_text_box_info_reader_test)
endif()
@@ -57,10 +57,13 @@ struct RobloxTextOverlayPresentation {
uint64_t generation = 0;
RobloxTextOverlayGeometry geometry;
float font_size = 0.0F;
bool multiline = false;
int32_t x_alignment = 0;
int32_t y_alignment = 1;
int32_t text_color = 0;
int32_t font = 0;
int32_t text_input_type = 0;
bool text_wrapped = false;
std::string display_utf8;
std::size_t caret_utf8_byte = 0;
std::size_t selection_begin_utf8_byte = 0;
+3
View File
@@ -67,10 +67,13 @@ struct RobloxTextDisplayUpdate {
int32_t area_width = 0;
int32_t area_height = 0;
float font_size = 0.0F;
bool multiline = false;
int32_t x_alignment = 0;
int32_t y_alignment = 1;
int32_t text_color = 0;
int32_t font = 0;
int32_t text_input_type = 0;
bool text_wrapped = false;
const char* utf8 = nullptr;
std::size_t utf8_size = 0;
int32_t cursor_utf16 = 0;
@@ -0,0 +1,26 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_TEXT_FONT_RESOLVER_H_
#define MOCKTAIL_RUNTIME_ROBLOX_TEXT_FONT_RESOLVER_H_
#include <cstdint>
#include <string>
namespace mocktail {
namespace runtime {
// Android's RbxKeyboard resolves the Roblox Font enum through
// assets/android/fonts/font-mappings.json. The selected file lives below
// assets/content/fonts while legacy SourceSansPro faces live in assets/fonts.
struct RobloxTextFontSelection {
std::string primary_file;
float size_scale = 0.795F;
bool exact_mapping = false;
};
RobloxTextFontSelection ResolveRobloxTextFont(
int32_t font, const std::string& asset_content_root);
RobloxTextFontSelection ResolveRobloxTextFont(int32_t font);
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_TEXT_FONT_RESOLVER_H_
+12
View File
@@ -106,10 +106,13 @@ RobloxTextOverlayPresentation::RobloxTextOverlayPresentation(
generation(other.generation),
geometry(other.geometry),
font_size(other.font_size),
multiline(other.multiline),
x_alignment(other.x_alignment),
y_alignment(other.y_alignment),
text_color(other.text_color),
font(other.font),
text_input_type(other.text_input_type),
text_wrapped(other.text_wrapped),
display_utf8(other.display_utf8),
caret_utf8_byte(other.caret_utf8_byte),
selection_begin_utf8_byte(other.selection_begin_utf8_byte),
@@ -127,10 +130,13 @@ RobloxTextOverlayPresentation& RobloxTextOverlayPresentation::operator=(
generation = other.generation;
geometry = other.geometry;
font_size = other.font_size;
multiline = other.multiline;
x_alignment = other.x_alignment;
y_alignment = other.y_alignment;
text_color = other.text_color;
font = other.font;
text_input_type = other.text_input_type;
text_wrapped = other.text_wrapped;
display_utf8 = other.display_utf8;
caret_utf8_byte = other.caret_utf8_byte;
selection_begin_utf8_byte = other.selection_begin_utf8_byte;
@@ -145,10 +151,13 @@ void RobloxTextOverlayPresentation::ClearSensitive() {
generation = 0;
geometry = {};
font_size = 0.0F;
multiline = false;
x_alignment = 0;
y_alignment = 1;
text_color = 0;
font = 0;
text_input_type = 0;
text_wrapped = false;
caret_utf8_byte = 0;
selection_begin_utf8_byte = 0;
selection_end_utf8_byte = 0;
@@ -208,10 +217,13 @@ Status BuildRobloxTextOverlayPresentation(
candidate.generation = update.generation;
candidate.geometry = ResolveRobloxTextOverlayGeometry(update, viewport);
candidate.font_size = update.font_size;
candidate.multiline = update.multiline;
candidate.x_alignment = update.x_alignment;
candidate.y_alignment = update.y_alignment;
candidate.text_color = update.text_color;
candidate.font = update.font;
candidate.text_input_type = update.text_input_type;
candidate.text_wrapped = update.text_wrapped;
std::size_t codepoints_before_cursor = 0;
const std::size_t caret_source_byte = Utf8ByteForUtf16Cursor(
+3
View File
@@ -725,10 +725,13 @@ void RobloxTextEditor::PublishDisplayLocked(RobloxTextDisplayEvent event) {
update.area_width = session_.area_width;
update.area_height = session_.area_height;
update.font_size = session_.font_size;
update.multiline = session_.multiline;
update.x_alignment = session_.x_alignment;
update.y_alignment = session_.y_alignment;
update.text_color = session_.text_color;
update.font = session_.font;
update.text_input_type = session_.text_input_type;
update.text_wrapped = session_.text_wrapped;
if (event != RobloxTextDisplayEvent::kHide) {
update.utf8 = text_.data();
update.utf8_size = text_.size();
+140
View File
@@ -0,0 +1,140 @@
#include "runtime/roblox_text_font_resolver.h"
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <mutex>
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
#include <unordered_map>
#include <utility>
namespace mocktail {
namespace runtime {
namespace {
struct FontMapping {
std::string file;
float size_scale = 0.795F;
};
using FontMappings = std::unordered_map<int32_t, FontMapping>;
std::mutex g_catalog_mutex;
std::unordered_map<std::string, FontMappings> g_catalogs;
bool IsSafeFontFileName(const std::string& value) {
if (value.empty()) {
return false;
}
const std::filesystem::path path(value);
return !path.is_absolute() && !path.has_parent_path() &&
path.filename() == path;
}
bool IsRegularFile(const std::filesystem::path& path) {
std::error_code error;
return std::filesystem::is_regular_file(path, error) && !error;
}
FontMappings LoadMappings(const std::filesystem::path& asset_content_root) {
FontMappings mappings;
const std::filesystem::path mapping_file =
asset_content_root.parent_path() / "android/fonts/font-mappings.json";
std::ifstream input(mapping_file, std::ios::binary);
if (!input) {
return mappings;
}
const nlohmann::json document = nlohmann::json::parse(input, nullptr, false);
if (document.is_discarded() || !document.is_array()) {
return mappings;
}
for (const nlohmann::json& item : document) {
if (!item.is_object()) {
continue;
}
const auto enum_value = item.find("enum");
const auto font_value = item.find("font");
const auto ratio_value = item.find("fromRbxFontRatio");
if (enum_value == item.end() || !enum_value->is_number_integer() ||
font_value == item.end() || !font_value->is_string() ||
ratio_value == item.end() || !ratio_value->is_number()) {
continue;
}
const int64_t encoded_enum = enum_value->get<int64_t>();
const double encoded_ratio = ratio_value->get<double>();
const std::string encoded_font = font_value->get<std::string>();
if (encoded_enum < 0 || encoded_enum > INT32_MAX ||
!std::isfinite(encoded_ratio) || encoded_ratio <= 0.0 ||
encoded_ratio > 4.0 || !IsSafeFontFileName(encoded_font)) {
continue;
}
mappings[static_cast<int32_t>(encoded_enum)] = {
encoded_font, static_cast<float>(encoded_ratio)};
}
return mappings;
}
std::optional<FontMapping> MappingFor(
int32_t font, const std::filesystem::path& asset_content_root) {
const std::string key = asset_content_root.lexically_normal().string();
std::lock_guard<std::mutex> lock(g_catalog_mutex);
auto catalog = g_catalogs.find(key);
if (catalog == g_catalogs.end()) {
catalog = g_catalogs.emplace(key, LoadMappings(asset_content_root)).first;
}
const auto mapped = catalog->second.find(font);
return mapped == catalog->second.end()
? std::optional<FontMapping>()
: std::optional<FontMapping>(mapped->second);
}
std::string LegacyFontName(int32_t font) {
if (font == 4) {
return "SourceSansPro-Bold.ttf";
}
if (font == 5) {
return "SourceSansPro-Light.ttf";
}
return "SourceSansPro-Regular.ttf";
}
} // namespace
RobloxTextFontSelection ResolveRobloxTextFont(
int32_t font, const std::string& asset_content_root) {
RobloxTextFontSelection selection;
if (asset_content_root.empty()) {
return selection;
}
const std::filesystem::path content(asset_content_root);
const std::optional<FontMapping> mapped = MappingFor(font, content);
if (mapped.has_value()) {
const std::filesystem::path candidate = content / "fonts" / mapped->file;
if (IsRegularFile(candidate)) {
selection.primary_file = candidate.lexically_normal().string();
selection.size_scale = mapped->size_scale;
selection.exact_mapping = true;
return selection;
}
}
const std::filesystem::path legacy =
content.parent_path() / "fonts" / LegacyFontName(font);
if (IsRegularFile(legacy)) {
selection.primary_file = legacy.lexically_normal().string();
}
return selection;
}
RobloxTextFontSelection ResolveRobloxTextFont(int32_t font) {
const char* content = std::getenv("MOCKTAIL_ASSET_PATH");
return ResolveRobloxTextFont(
font, content != nullptr && content[0] != '\0' ? content : "");
}
} // namespace runtime
} // namespace mocktail
+86 -1
View File
@@ -21,6 +21,7 @@ namespace {
constexpr std::size_t kMaximumPendingTextCommands = 64;
constexpr std::size_t kMaximumPendingTextBytes = 4U * 1024U * 1024U;
constexpr uint32_t kMissingGeometryRetryPumpInterval = 8;
void SecureClear(std::string* value) {
if (value == nullptr) {
@@ -164,7 +165,13 @@ class ProductionTextInputBackend final
};
struct RobloxTextInputJniBridge::State {
enum class CommandType { kShow, kHide, kReplaceText, kRefreshProperties };
enum class CommandType {
kShow,
kHide,
kReplaceText,
kRefreshProperties,
kRecoverGeometry
};
struct Command {
CommandType type = CommandType::kHide;
@@ -198,6 +205,7 @@ struct RobloxTextInputJniBridge::State {
desired_active = false;
active_geometry = {};
has_active_geometry = false;
missing_geometry_retry_pumps = 0;
ClearCommandsLocked();
}
@@ -321,6 +329,7 @@ struct RobloxTextInputJniBridge::State {
if (HasUsableGeometry(effective_info)) {
active_geometry = effective_info;
has_active_geometry = true;
missing_geometry_retry_pumps = 0;
}
std::fprintf(stderr,
" [input] TextBox focus snapshot native=%d "
@@ -345,6 +354,7 @@ struct RobloxTextInputJniBridge::State {
active_generation = generation;
active_handle = request.text_box;
desired_active = true;
missing_geometry_retry_pumps = 0;
command.type = CommandType::kShow;
command.generation = generation;
command.textbox_handle = request.text_box;
@@ -372,6 +382,7 @@ struct RobloxTextInputJniBridge::State {
active_info = {};
active_geometry = {};
has_active_geometry = false;
missing_geometry_retry_pumps = 0;
}
void ReplaceText(const std::string& text) {
@@ -426,6 +437,41 @@ 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) {
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 ||
applied_generation != active_generation ||
applied_handle != active_handle) {
missing_geometry_retry_pumps = 0;
return;
}
const auto already_refreshing =
std::find_if(pending->begin(), pending->end(), [](const Command& item) {
return item.type == CommandType::kRefreshProperties ||
item.type == CommandType::kRecoverGeometry;
});
if (already_refreshing != pending->end()) {
return;
}
++missing_geometry_retry_pumps;
if (missing_geometry_retry_pumps < kMissingGeometryRetryPumpInterval) {
return;
}
missing_geometry_retry_pumps = 0;
Command command;
command.type = CommandType::kRecoverGeometry;
command.generation = active_generation;
command.textbox_handle = active_handle;
pending->push_back(std::move(command));
}
bool IsDesiredFocusCurrent(uint64_t requested_generation,
int64_t requested_handle) const {
std::lock_guard<std::mutex> lock(mutex);
@@ -456,12 +502,25 @@ struct RobloxTextInputJniBridge::State {
} else {
active_geometry = candidate;
has_active_geometry = true;
missing_geometry_retry_pumps = 0;
}
active_info = candidate;
*effective_info = candidate;
return true;
}
void ForgetRecoveredGeometryIfCurrent(uint64_t requested_generation,
int64_t requested_handle) {
std::lock_guard<std::mutex> lock(mutex);
if (accepting && desired_active &&
active_generation == requested_generation &&
active_handle == requested_handle) {
active_geometry = {};
has_active_geometry = false;
missing_geometry_retry_pumps = 0;
}
}
bool Drain() {
std::deque<Command> pending;
bool terminal = false;
@@ -479,6 +538,8 @@ struct RobloxTextInputJniBridge::State {
return CompleteTerminalOnMainThread("queue");
}
AppendMissingGeometryRetry(&pending);
bool success = true;
for (Command& command : pending) {
if (!CompletionStatus().ok()) {
@@ -490,6 +551,9 @@ struct RobloxTextInputJniBridge::State {
Status status = Status::Ok();
const char* failure_message = "Roblox text-input command failed";
const char* failure_stage = "command";
const bool best_effort_geometry_recovery =
command.type == CommandType::kRecoverGeometry;
bool presentation_properties_updated = false;
if (command.type == CommandType::kShow) {
failure_message = "Roblox TextBox focus session failed";
failure_stage = "begin-focus";
@@ -514,10 +578,13 @@ struct RobloxTextInputJniBridge::State {
session.area_width = area.width;
session.area_height = area.height;
session.font_size = command.info.font_size;
session.font = command.info.font;
session.x_alignment = command.info.x_alignment;
session.y_alignment = command.info.y_alignment;
session.text_color = command.info.text_color;
session.text_input_type = command.info.text_input_type;
session.return_key_type = command.info.return_key_type;
session.text_wrapped = command.info.text_wrapped;
status = backend->BeginTextFocusSession(std::move(session));
const window::TextInputOptions options{command.info.text_input_type,
command.info.multiline};
@@ -581,6 +648,7 @@ struct RobloxTextInputJniBridge::State {
failure_stage = "update-properties";
status = backend->UpdateTextFocusProperties(
command.generation, ToFocusProperties(effective_info));
presentation_properties_updated = status.ok();
const window::TextInputArea area = ToTextInputArea(effective_info);
const window::TextInputOptions options{
effective_info.text_input_type, effective_info.multiline};
@@ -602,6 +670,20 @@ struct RobloxTextInputJniBridge::State {
}
}
}
if (best_effort_geometry_recovery && !status.ok()) {
if (!presentation_properties_updated) {
ForgetRecoveredGeometryIfCurrent(command.generation,
command.textbox_handle);
}
if (!logged_geometry_retry_error) {
logged_geometry_retry_error = true;
std::fprintf(stderr,
" [input] deferred TextBox geometry query failed; "
"typing remains active: %s\n",
status.message().c_str());
}
status = Status::Ok();
}
command.ClearSensitiveData();
if (status.ok()) {
const Status terminal_status = CompletionStatus();
@@ -664,6 +746,7 @@ struct RobloxTextInputJniBridge::State {
active_info = {};
active_geometry = {};
has_active_geometry = false;
missing_geometry_retry_pumps = 0;
ClearCommandsLocked();
}
@@ -703,12 +786,14 @@ struct RobloxTextInputJniBridge::State {
jnivm::RobloxTextBoxInfo active_info;
jnivm::RobloxTextBoxInfo active_geometry;
bool has_active_geometry = false;
uint32_t missing_geometry_retry_pumps = 0;
bool desired_active = false;
std::size_t pending_text_bytes = 0;
bool accepting = true;
bool terminal_cleanup_completed = false;
bool logged_missing_overlay_geometry = false;
bool logged_property_refresh = false;
bool logged_geometry_retry_error = false;
bool applied_active = false;
uint64_t applied_generation = 0;
int64_t applied_handle = 0;
+126 -55
View File
@@ -18,6 +18,8 @@
#include <utility>
#include <vector>
#include "runtime/roblox_text_font_resolver.h"
namespace mocktail {
namespace runtime {
namespace {
@@ -62,9 +64,8 @@ bool IsUtf8Continuation(unsigned char value) {
}
SensitiveString VisibleTextWindow(
const RobloxTextOverlayPresentation& presentation,
std::size_t* caret_byte, std::size_t* selection_begin_byte,
std::size_t* selection_end_byte) {
const RobloxTextOverlayPresentation& presentation, std::size_t* caret_byte,
std::size_t* selection_begin_byte, std::size_t* selection_end_byte) {
SensitiveString result;
const std::string& text = presentation.display_utf8;
const std::size_t caret = std::min(presentation.caret_utf8_byte, text.size());
@@ -107,16 +108,18 @@ SensitiveString VisibleTextWindow(
};
*selection_begin_byte =
map_to_window(std::min(selection_begin, selection_end));
*selection_end_byte =
map_to_window(std::max(selection_begin, selection_end));
*selection_end_byte = map_to_window(std::max(selection_begin, selection_end));
return result;
}
std::vector<std::string> ResolveFontFiles() {
std::vector<std::string> ResolveFontFiles(
const RobloxTextFontSelection& selection) {
std::vector<std::string> files;
const char* configured = std::getenv("MOCKTAIL_TEXT_FONT");
if (configured != nullptr && configured[0] != '\0') {
files.emplace_back(configured);
} else if (!selection.primary_file.empty()) {
files.push_back(selection.primary_file);
}
if (!FcInit()) {
return files;
@@ -234,20 +237,31 @@ SDL_Color ResolveTextColor(std::int32_t packed_color) {
static_cast<std::uint8_t>(packed), alpha};
}
int TextByteX(TTF_Text* layout, std::size_t byte_offset, int text_width) {
struct TextBytePosition {
int x = 0;
int y = 0;
int height = 0;
};
TextBytePosition TextPosition(TTF_Text* layout, std::size_t byte_offset,
int text_width) {
if (layout == nullptr) {
return 0;
return {};
}
TTF_SubString substring{};
const int offset = static_cast<int>(std::min<std::size_t>(
byte_offset, static_cast<std::size_t>(std::numeric_limits<int>::max())));
if (!TTF_GetTextSubString(layout, offset, &substring)) {
return 0;
return {};
}
TextBytePosition position;
position.x = substring.rect.x;
position.y = substring.rect.y;
position.height = substring.rect.h;
if ((substring.flags & TTF_SUBSTRING_TEXT_END) != 0) {
return std::min(text_width, substring.rect.x + substring.rect.w);
position.x = std::min(text_width, substring.rect.x + substring.rect.w);
}
return substring.rect.x;
return position;
}
} // namespace
@@ -337,8 +351,7 @@ void RobloxTextSurfaceOverlay::ApplyUpdate(
++state_revision_;
}
const RobloxTextOverlayPresentation& presentation = state_.presentation();
const bool drawable =
presentation.visible && presentation.geometry.valid();
const bool drawable = presentation.visible && presentation.geometry.valid();
if (!drawable) {
ClearFrameLocked();
}
@@ -414,18 +427,31 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
std::vector<std::uint8_t> candidate(
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 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);
const bool wrapped_layout =
presentation.multiline || presentation.text_wrapped;
if (!TTF_Init()) {
SecureClear(&candidate);
return RasterizationFailure("unable to initialize SDL3_ttf");
}
const RobloxTextFontSelection font_selection =
ResolveRobloxTextFont(presentation.font);
const float native_point_size =
std::isfinite(presentation.font_size) ? presentation.font_size : 0.0F;
const float point_size =
native_point_size > 0.0F
? std::clamp(native_point_size, 8.0F, 96.0F)
? std::clamp(native_point_size * font_selection.size_scale, 1.0F,
512.0F)
: std::clamp(static_cast<float>(height) * 0.38F, 12.0F, 28.0F);
const std::vector<std::string> font_files = ResolveFontFiles();
const std::vector<std::string> font_files = ResolveFontFiles(font_selection);
std::vector<TTF_Font*> fonts;
fonts.reserve(font_files.size());
for (const std::string& file : font_files) {
@@ -437,34 +463,46 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
if (fonts.empty()) {
TTF_Quit();
SecureClear(&candidate);
return RasterizationFailure("unable to open a Fontconfig sans font");
return RasterizationFailure("unable to open a Roblox or fallback font");
}
for (std::size_t index = 1; index < fonts.size(); ++index) {
(void)TTF_AddFallbackFont(fonts.front(), fonts[index]);
}
if (wrapped_layout) {
TTF_HorizontalAlignment alignment = TTF_HORIZONTAL_ALIGN_LEFT;
if (presentation.x_alignment == 1) {
alignment = TTF_HORIZONTAL_ALIGN_RIGHT;
} else if (presentation.x_alignment == 2) {
alignment = TTF_HORIZONTAL_ALIGN_CENTER;
}
TTF_SetFontWrapAlignment(fonts.front(), alignment);
}
std::size_t caret_byte = 0;
std::size_t selection_begin_byte = 0;
std::size_t selection_end_byte = 0;
SensitiveString text =
VisibleTextWindow(presentation, &caret_byte, &selection_begin_byte,
&selection_end_byte);
SensitiveString text = VisibleTextWindow(
presentation, &caret_byte, &selection_begin_byte, &selection_end_byte);
SDL_Surface* rendered = nullptr;
SDL_Surface* converted = nullptr;
const SDL_Color text_color = ResolveTextColor(presentation.text_color);
TTF_Text* layout = TTF_CreateText(nullptr, fonts.front(), text.value.data(),
text.value.size());
if (layout != nullptr && wrapped_layout) {
(void)TTF_SetTextWrapWidth(layout, available_width);
}
int text_width = 0;
int text_height = std::max(1, TTF_GetFontHeight(fonts.front()));
int caret_x = 0;
int selection_begin_x = 0;
int selection_end_x = 0;
TextBytePosition caret_position;
TextBytePosition selection_begin_position;
TextBytePosition selection_end_position;
if (layout != nullptr) {
(void)TTF_GetTextSize(layout, &text_width, &text_height);
caret_x = TextByteX(layout, caret_byte, text_width);
selection_begin_x =
TextByteX(layout, selection_begin_byte, text_width);
selection_end_x = TextByteX(layout, selection_end_byte, text_width);
caret_position = TextPosition(layout, caret_byte, text_width);
selection_begin_position =
TextPosition(layout, selection_begin_byte, text_width);
selection_end_position =
TextPosition(layout, selection_end_byte, text_width);
}
if (!text.value.empty()) {
// SDL_ttf implementations have historically differed on whether fg.a is
@@ -472,8 +510,12 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
// Android ARGB opacity explicitly while copying the straight-alpha mask.
const SDL_Color raster_color = {text_color.r, text_color.g, text_color.b,
255};
rendered = TTF_RenderText_Blended(fonts.front(), text.value.data(),
text.value.size(), raster_color);
rendered = wrapped_layout
? TTF_RenderText_Blended_Wrapped(
fonts.front(), text.value.data(), text.value.size(),
raster_color, available_width)
: TTF_RenderText_Blended(fonts.front(), text.value.data(),
text.value.size(), raster_color);
if (rendered != nullptr) {
converted = SDL_ConvertSurface(rendered, SDL_PIXELFORMAT_RGBA32);
}
@@ -483,23 +525,15 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
if (!text.value.empty() && (rendered == nullptr || converted == nullptr)) {
status = RasterizationFailure("unable to rasterize Unicode text");
} else {
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 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);
int text_offset = 0;
if (text_width <= available_width) {
if (!wrapped_layout && text_width <= available_width) {
if (presentation.x_alignment == 1) {
text_offset = available_width - text_width;
} else if (presentation.x_alignment == 2) {
text_offset = (available_width - text_width) / 2;
}
} else if (caret_x > available_width - 3) {
text_offset = available_width - 3 - caret_x;
} else if (!wrapped_layout && caret_position.x > available_width - 3) {
text_offset = available_width - 3 - caret_position.x;
}
int text_y = clip_top;
if (presentation.y_alignment == 1) {
@@ -507,21 +541,53 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
} else if (presentation.y_alignment == 2) {
text_y = std::max(clip_top, clip_bottom - text_height);
}
if (selection_begin_byte != selection_end_byte) {
const int selection_left = std::clamp(
clip_left + text_offset + std::min(selection_begin_x, selection_end_x),
clip_left, clip_right);
const int selection_right = std::clamp(
clip_left + text_offset + std::max(selection_begin_x, selection_end_x),
clip_left, clip_right);
const int selection_top = std::clamp(text_y, clip_top, clip_bottom);
const int selection_bottom = std::clamp(
text_y + text_height, selection_top, clip_bottom);
for (int y = selection_top; y < selection_bottom; ++y) {
for (int x = selection_left; x < selection_right; ++x) {
const auto fill_highlight = [&](int left, int top, int right, int bottom) {
const int bounded_left = std::clamp(left, clip_left, clip_right);
const int bounded_right = std::clamp(right, bounded_left, clip_right);
const int bounded_top = std::clamp(top, clip_top, clip_bottom);
const int bounded_bottom = std::clamp(bottom, bounded_top, clip_bottom);
for (int y = bounded_top; y < bounded_bottom; ++y) {
for (int x = bounded_left; x < bounded_right; ++x) {
SetPixel(&candidate, frame_width, x, y, 65, 132, 228, 112);
}
}
};
if (selection_begin_byte != selection_end_byte) {
if (wrapped_layout && layout != nullptr) {
const std::size_t range_bytes =
selection_end_byte - selection_begin_byte;
const int range_offset = static_cast<int>(std::min<std::size_t>(
selection_begin_byte,
static_cast<std::size_t>(std::numeric_limits<int>::max())));
const int range_length = static_cast<int>(std::min<std::size_t>(
range_bytes,
static_cast<std::size_t>(std::numeric_limits<int>::max())));
int count = 0;
TTF_SubString** substrings = TTF_GetTextSubStringsForRange(
layout, range_offset, range_length, &count);
if (substrings != nullptr) {
for (int index = 0; index < count; ++index) {
const TTF_SubString* substring = substrings[index];
if (substring == nullptr) {
continue;
}
fill_highlight(
clip_left + text_offset + substring->rect.x,
text_y + substring->rect.y,
clip_left + text_offset + substring->rect.x + substring->rect.w,
text_y + substring->rect.y + substring->rect.h);
}
SDL_free(substrings);
}
} else {
fill_highlight(
clip_left + text_offset +
std::min(selection_begin_position.x, selection_end_position.x),
text_y,
clip_left + text_offset +
std::max(selection_begin_position.x, selection_end_position.x),
text_y + text_height);
}
}
if (converted != nullptr) {
BlendSurface(converted, clip_left + text_offset, text_y, clip_left,
@@ -529,12 +595,17 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
&candidate);
}
const int caret_screen_x =
std::clamp(clip_left + text_offset + caret_x, clip_left,
std::clamp(clip_left + text_offset + caret_position.x, clip_left,
std::max(clip_left, clip_right - 2));
const int caret_line_height =
caret_position.height > 0
? caret_position.height
: std::max(1, TTF_GetFontHeight(fonts.front()));
const int caret_height =
std::min(std::max(12, text_height), clip_bottom - clip_top);
const int caret_y = std::clamp(
text_y, clip_top, std::max(clip_top, clip_bottom - caret_height));
std::min(std::max(12, caret_line_height), clip_bottom - clip_top);
const int caret_y =
std::clamp(text_y + caret_position.y, clip_top,
std::max(clip_top, clip_bottom - caret_height));
for (int y = caret_y; y < caret_y + caret_height; ++y) {
SetPixel(&candidate, frame_width, caret_screen_x, y, text_color.r,
text_color.g, text_color.b, text_color.a);
+53 -3
View File
@@ -131,6 +131,25 @@ TEST(RobloxTextDisplayStateTest, MapsUtf16CursorToUnicodeByteBoundary) {
EXPECT_EQ(presentation.caret_utf8_byte, std::string(u8"A😀").size());
}
TEST(RobloxTextDisplayStateTest, PreservesRobloxFontAndWrappingProperties) {
const std::string text = "styled";
RobloxTextDisplayUpdate update = Show(7, text, 6);
update.font_size = 21.0F;
update.font = 13;
update.multiline = true;
update.text_wrapped = true;
RobloxTextOverlayPresentation presentation;
ASSERT_TRUE(BuildRobloxTextOverlayPresentation(update, {1280, 720},
&presentation)
.ok());
EXPECT_FLOAT_EQ(presentation.font_size, 21.0F);
EXPECT_EQ(presentation.font, 13);
EXPECT_TRUE(presentation.multiline);
EXPECT_TRUE(presentation.text_wrapped);
}
TEST(RobloxTextDisplayStateTest, MapsUnicodeSelectionToVisibleByteRange) {
const std::string text = u8"A😀Б";
RobloxTextDisplayUpdate update = Show(7, text, 3);
@@ -138,9 +157,9 @@ TEST(RobloxTextDisplayStateTest, MapsUnicodeSelectionToVisibleByteRange) {
update.selection_end_utf16 = 3;
RobloxTextOverlayPresentation presentation;
ASSERT_TRUE(BuildRobloxTextOverlayPresentation(update, {1280, 720},
&presentation)
.ok());
ASSERT_TRUE(
BuildRobloxTextOverlayPresentation(update, {1280, 720}, &presentation)
.ok());
EXPECT_EQ(presentation.selection_begin_utf8_byte, std::string("A").size());
EXPECT_EQ(presentation.selection_end_utf8_byte,
@@ -385,6 +404,37 @@ TEST(RobloxTextDisplayStateTest, RasterizesSelectionHighlightBehindText) {
EXPECT_TRUE(overlay.Shutdown().ok());
}
TEST(RobloxTextDisplayStateTest, WrapsMultilineTextInsideNativeTextBox) {
RobloxTextSurfaceOverlay overlay;
ASSERT_TRUE(overlay.Initialize({800, 600}).ok());
const std::string text =
"one two three four five six seven eight nine ten eleven twelve";
RobloxTextDisplayUpdate show = Show(1, text, 0);
show.area_width = 150;
show.area_height = 140;
show.font_size = 20.0F;
show.y_alignment = 0;
show.text_wrapped = true;
RobloxTextDisplaySink sink = overlay.sink();
sink.update(sink.context, show);
MocktailTextOverlayFrameInfo frame;
ASSERT_TRUE(overlay.QueryFrame(&frame));
std::vector<std::uint8_t> rgba(frame.rgba_bytes);
ASSERT_TRUE(overlay.CopyFrame(frame.revision, rgba.data(), rgba.size()));
bool has_lower_line_pixel = false;
for (std::uint32_t y = 40; y < frame.height; ++y) {
for (std::uint32_t x = 0; x < frame.width; ++x) {
const std::size_t alpha =
(static_cast<std::size_t>(y) * frame.width + x) * 4 + 3;
has_lower_line_pixel =
has_lower_line_pixel || (alpha < rgba.size() && rgba[alpha] != 0);
}
}
EXPECT_TRUE(has_lower_line_pixel);
EXPECT_TRUE(overlay.Shutdown().ok());
}
} // namespace
} // namespace runtime
} // namespace mocktail
+20
View File
@@ -35,7 +35,11 @@ struct DisplayCall {
int32_t area_y = 0;
int32_t area_width = 0;
int32_t area_height = 0;
float font_size = 0.0F;
bool multiline = false;
int32_t font = 0;
int32_t text_input_type = 0;
bool text_wrapped = false;
std::string text;
int32_t cursor_utf16 = 0;
int32_t selection_begin_utf16 = 0;
@@ -55,7 +59,11 @@ void DisplayUpdate(void* context, const RobloxTextDisplayUpdate& update) {
call.area_y = update.area_y;
call.area_width = update.area_width;
call.area_height = update.area_height;
call.font_size = update.font_size;
call.multiline = update.multiline;
call.font = update.font;
call.text_input_type = update.text_input_type;
call.text_wrapped = update.text_wrapped;
if (update.utf8 != nullptr) {
call.text.assign(update.utf8, update.utf8_size);
}
@@ -631,7 +639,11 @@ TEST(RobloxTextEditorTest,
EXPECT_EQ(refresh.area_y, 36);
EXPECT_EQ(refresh.area_width, 633);
EXPECT_EQ(refresh.area_height, 36);
EXPECT_FLOAT_EQ(refresh.font_size, 16.0F);
EXPECT_TRUE(refresh.multiline);
EXPECT_EQ(refresh.font, 4);
EXPECT_EQ(refresh.text_input_type, 3);
EXPECT_TRUE(refresh.text_wrapped);
EXPECT_EQ(refresh.text, "unchanged");
EXPECT_EQ(refresh.cursor_utf16, 9);
EXPECT_EQ(editor.Snapshot().generation, 7u);
@@ -649,6 +661,10 @@ TEST(RobloxTextEditorTest,
session.area_y = 34;
session.area_width = 280;
session.area_height = 44;
session.font_size = 22.0F;
session.font = 13;
session.multiline = true;
session.text_wrapped = true;
session.text_input_type = 5;
ASSERT_TRUE(editor.BeginFocusSession(std::move(session)).ok());
@@ -659,7 +675,11 @@ TEST(RobloxTextEditorTest,
EXPECT_EQ(display_probe.calls[0].area_y, 34);
EXPECT_EQ(display_probe.calls[0].area_width, 280);
EXPECT_EQ(display_probe.calls[0].area_height, 44);
EXPECT_FLOAT_EQ(display_probe.calls[0].font_size, 22.0F);
EXPECT_TRUE(display_probe.calls[0].multiline);
EXPECT_EQ(display_probe.calls[0].font, 13);
EXPECT_EQ(display_probe.calls[0].text_input_type, 5);
EXPECT_TRUE(display_probe.calls[0].text_wrapped);
EXPECT_EQ(display_probe.calls[0].text, "initial");
EXPECT_EQ(display_probe.calls[0].cursor_utf16, 7);
+102
View File
@@ -0,0 +1,102 @@
#include "runtime/roblox_text_font_resolver.h"
#include <gtest/gtest.h>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
namespace mocktail {
namespace runtime {
namespace {
class TemporaryFontAssets final {
public:
TemporaryFontAssets() {
char pattern[] = "/tmp/mocktail-font-assets-XXXXXX";
const char* created = mkdtemp(pattern);
if (created != nullptr) {
root_ = created;
}
}
~TemporaryFontAssets() {
if (!root_.empty()) {
std::error_code error;
std::filesystem::remove_all(root_, error);
}
}
const std::filesystem::path& root() const { return root_; }
std::filesystem::path content() const { return root_ / "assets/content"; }
bool CreateFile(const std::filesystem::path& relative,
const std::string& contents = {}) const {
const std::filesystem::path path = root_ / relative;
std::error_code error;
std::filesystem::create_directories(path.parent_path(), error);
if (error) {
return false;
}
std::ofstream output(path, std::ios::binary);
output << contents;
return output.good();
}
private:
std::filesystem::path root_;
};
TEST(RobloxTextFontResolverTest, UsesExactApkMappingAndSizeRatio) {
TemporaryFontAssets assets;
ASSERT_FALSE(assets.root().empty());
ASSERT_TRUE(assets.CreateFile(
"assets/android/fonts/font-mappings.json",
R"([{"enum":13,"font":"PressStart2P-Regular.ttf","fromRbxFontRatio":0.9761678384}])"));
ASSERT_TRUE(
assets.CreateFile("assets/content/fonts/PressStart2P-Regular.ttf"));
const RobloxTextFontSelection selection =
ResolveRobloxTextFont(13, assets.content().string());
EXPECT_TRUE(selection.exact_mapping);
EXPECT_EQ(selection.primary_file,
(assets.content() / "fonts/PressStart2P-Regular.ttf").string());
EXPECT_FLOAT_EQ(selection.size_scale, 0.9761678384F);
}
TEST(RobloxTextFontResolverTest, MatchesLegacyRbxKeyboardFallbacks) {
TemporaryFontAssets assets;
ASSERT_FALSE(assets.root().empty());
ASSERT_TRUE(assets.CreateFile("assets/fonts/SourceSansPro-Bold.ttf"));
const RobloxTextFontSelection selection =
ResolveRobloxTextFont(4, assets.content().string());
EXPECT_FALSE(selection.exact_mapping);
EXPECT_EQ(selection.primary_file,
(assets.root() / "assets/fonts/SourceSansPro-Bold.ttf").string());
EXPECT_FLOAT_EQ(selection.size_scale, 0.795F);
}
TEST(RobloxTextFontResolverTest, RejectsMappingPathTraversal) {
TemporaryFontAssets assets;
ASSERT_FALSE(assets.root().empty());
ASSERT_TRUE(assets.CreateFile(
"assets/android/fonts/font-mappings.json",
R"([{"enum":13,"font":"../../outside.ttf","fromRbxFontRatio":1.0}])"));
ASSERT_TRUE(assets.CreateFile("assets/fonts/SourceSansPro-Regular.ttf"));
const RobloxTextFontSelection selection =
ResolveRobloxTextFont(13, assets.content().string());
EXPECT_FALSE(selection.exact_mapping);
EXPECT_EQ(
selection.primary_file,
(assets.root() / "assets/fonts/SourceSansPro-Regular.ttf").string());
}
} // namespace
} // namespace runtime
} // namespace mocktail
@@ -25,8 +25,12 @@ class FakeBackend final : public RobloxTextInputJniBridgeBackend {
last_area_y = session.area_y;
last_area_width = session.area_width;
last_area_height = session.area_height;
last_font = session.font;
last_font_size = session.font_size;
last_text_input_type = session.text_input_type;
last_multiline = session.multiline;
last_text_wrapped = session.text_wrapped;
last_return_key_type = session.return_key_type;
last_manual_focus_release = session.manual_focus_release;
active = true;
std::fill(session.initial_text.begin(), session.initial_text.end(), '\0');
@@ -124,8 +128,12 @@ class FakeBackend final : public RobloxTextInputJniBridgeBackend {
int32_t last_area_y = 0;
int32_t last_area_width = 0;
int32_t last_area_height = 0;
int32_t last_font = 0;
float last_font_size = 0.0F;
int32_t last_text_input_type = 0;
int32_t last_return_key_type = 0;
bool last_multiline = false;
bool last_text_wrapped = false;
bool last_manual_focus_release = false;
std::string last_initial_text;
std::vector<std::string> replaced_texts;
@@ -607,6 +615,68 @@ TEST(RobloxTextInputJniBridgeTest,
EXPECT_TRUE(bridge->Shutdown().ok());
}
TEST(RobloxTextInputJniBridgeTest,
InitialFocusPreservesCompleteRobloxTextPresentation) {
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, "styled");
request.info.font = 13;
request.info.font_size = 21.0F;
request.info.multiline = true;
request.info.text_wrapped = true;
request.info.return_key_type = 4;
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(request));
ASSERT_TRUE(backend->Pump());
EXPECT_EQ(backend->last_font, 13);
EXPECT_FLOAT_EQ(backend->last_font_size, 21.0F);
EXPECT_TRUE(backend->last_multiline);
EXPECT_TRUE(backend->last_text_wrapped);
EXPECT_EQ(backend->last_return_key_type, 4);
EXPECT_TRUE(bridge->Shutdown().ok());
}
TEST(RobloxTextInputJniBridgeTest,
MissingGeometryIsRecoveredWithoutAPropertyCallback) {
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, "editable");
request.info.width = 0.0F;
request.info.height = 0.0F;
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(request));
ASSERT_TRUE(backend->Pump());
backend->calls.clear();
backend->query_result.available = true;
backend->query_result.info.x = 25.0F;
backend->query_result.info.y = 50.0F;
backend->query_result.info.width = 420.0F;
backend->query_result.info.height = 36.0F;
backend->query_result.info.font = 46;
backend->query_result.info.font_size = 18.0F;
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, 25);
EXPECT_EQ(backend->last_properties.area_y, 50);
EXPECT_EQ(backend->last_properties.area_width, 420);
EXPECT_EQ(backend->last_properties.area_height, 36);
EXPECT_EQ(backend->last_properties.font, 46);
EXPECT_FLOAT_EQ(backend->last_properties.font_size, 18.0F);
EXPECT_TRUE(bridge->Shutdown().ok());
}
TEST(RobloxTextInputJniBridgeTest,
ShutdownClosesAppliedSessionBeforeReleasingBackend) {
jnivm::VM vm;