From 54791779d43bb5419eef44ab2282c037692effab Mon Sep 17 00:00:00 2001 From: dim-ghub <59436063+dim-ghub@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:28:00 -0500 Subject: [PATCH] fix(input): render emoji in the TextBox overlay The overlay's fallback fonts were the first few fontconfig sans matches, which rarely include an emoji font. Fall back to Roblox's bundled TwemojiMozilla and RobloxEmoji fonts, then the system emoji font, before the sans list. --- include/runtime/roblox_text_font_resolver.h | 3 +++ src/runtime/roblox_text_font_resolver.cc | 20 +++++++++++++++ src/runtime/roblox_text_surface_overlay.cc | 27 ++++++++++++++++++++- tests/roblox_text_font_resolver_test.cc | 17 +++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/include/runtime/roblox_text_font_resolver.h b/include/runtime/roblox_text_font_resolver.h index 6abb2e20..f0841128 100644 --- a/include/runtime/roblox_text_font_resolver.h +++ b/include/runtime/roblox_text_font_resolver.h @@ -3,6 +3,7 @@ #include #include +#include namespace mocktail { namespace runtime { @@ -10,8 +11,10 @@ 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. +// Emoji come from the color fonts Roblox ships next to the mapped faces. struct RobloxTextFontSelection { std::string primary_file; + std::vector emoji_files; float size_scale = 0.795F; bool exact_mapping = false; }; diff --git a/src/runtime/roblox_text_font_resolver.cc b/src/runtime/roblox_text_font_resolver.cc index 418a7f3a..635a861a 100644 --- a/src/runtime/roblox_text_font_resolver.cc +++ b/src/runtime/roblox_text_font_resolver.cc @@ -10,6 +10,7 @@ #include #include #include +#include namespace mocktail { namespace runtime { @@ -101,6 +102,24 @@ std::string LegacyFontName(int32_t font) { return "SourceSansPro-Regular.ttf"; } +// TwemojiMozilla covers Unicode emoji; RobloxEmoji holds the private-use +// Roblox and Robux symbols. +constexpr const char* kEmojiFontNames[] = {"TwemojiMozilla.ttf", + "RobloxEmoji.ttf"}; + +std::vector EmojiFontFiles( + const std::filesystem::path& asset_content_root) { + std::vector files; + for (const char* name : kEmojiFontNames) { + const std::filesystem::path candidate = + asset_content_root / "fonts" / name; + if (IsRegularFile(candidate)) { + files.push_back(candidate.lexically_normal().string()); + } + } + return files; +} + } // namespace RobloxTextFontSelection ResolveRobloxTextFont( @@ -111,6 +130,7 @@ RobloxTextFontSelection ResolveRobloxTextFont( } const std::filesystem::path content(asset_content_root); + selection.emoji_files = EmojiFontFiles(content); const std::optional mapped = MappingFor(font, content); if (mapped.has_value()) { const std::filesystem::path candidate = content / "fonts" / mapped->file; diff --git a/src/runtime/roblox_text_surface_overlay.cc b/src/runtime/roblox_text_surface_overlay.cc index 5982d70e..cd05b1d0 100644 --- a/src/runtime/roblox_text_surface_overlay.cc +++ b/src/runtime/roblox_text_surface_overlay.cc @@ -26,7 +26,7 @@ namespace { constexpr std::size_t kMaximumRasterTextBytes = 4096; constexpr std::uint64_t kMaximumOverlayPixels = 16ULL * 1024ULL * 1024ULL; -constexpr std::size_t kMaximumFallbackFonts = 6; +constexpr std::size_t kMaximumFallbackFonts = 8; std::mutex g_active_overlay_mutex; RobloxTextSurfaceOverlay* g_active_overlay = nullptr; @@ -121,10 +121,35 @@ std::vector ResolveFontFiles( } else if (!selection.primary_file.empty()) { files.push_back(selection.primary_file); } + files.insert(files.end(), selection.emoji_files.begin(), + selection.emoji_files.end()); if (!FcInit()) { return files; } + // The sorted sans list rarely reaches an emoji font before the cap, so ask + // for the system emoji font by name ahead of it. + FcPattern* emoji = FcNameParse(reinterpret_cast("emoji")); + if (emoji != nullptr) { + FcConfigSubstitute(nullptr, emoji, FcMatchPattern); + FcDefaultSubstitute(emoji); + FcResult emoji_result = FcResultNoMatch; + FcPattern* matched = FcFontMatch(nullptr, emoji, &emoji_result); + FcPatternDestroy(emoji); + FcChar8* file = nullptr; + if (matched != nullptr && + FcPatternGetString(matched, FC_FILE, 0, &file) == FcResultMatch && + file != nullptr && file[0] != '\0') { + const std::string candidate(reinterpret_cast(file)); + if (std::find(files.begin(), files.end(), candidate) == files.end()) { + files.push_back(candidate); + } + } + if (matched != nullptr) { + FcPatternDestroy(matched); + } + } + FcPattern* pattern = FcNameParse(reinterpret_cast("sans")); if (pattern == nullptr) { return files; diff --git a/tests/roblox_text_font_resolver_test.cc b/tests/roblox_text_font_resolver_test.cc index 06211b25..18dbd53b 100644 --- a/tests/roblox_text_font_resolver_test.cc +++ b/tests/roblox_text_font_resolver_test.cc @@ -97,6 +97,23 @@ TEST(RobloxTextFontResolverTest, RejectsMappingPathTraversal) { (assets.root() / "assets/fonts/SourceSansPro-Regular.ttf").string()); } +TEST(RobloxTextFontResolverTest, AddsBundledEmojiFontsAsFallbacks) { + TemporaryFontAssets assets; + ASSERT_FALSE(assets.root().empty()); + ASSERT_TRUE(assets.CreateFile("assets/fonts/SourceSansPro-Regular.ttf")); + ASSERT_TRUE(assets.CreateFile("assets/content/fonts/RobloxEmoji.ttf")); + ASSERT_TRUE(assets.CreateFile("assets/content/fonts/TwemojiMozilla.ttf")); + + const RobloxTextFontSelection selection = + ResolveRobloxTextFont(3, assets.content().string()); + + ASSERT_EQ(selection.emoji_files.size(), 2U); + EXPECT_EQ(selection.emoji_files[0], + (assets.content() / "fonts/TwemojiMozilla.ttf").string()); + EXPECT_EQ(selection.emoji_files[1], + (assets.content() / "fonts/RobloxEmoji.ttf").string()); +} + } // namespace } // namespace runtime } // namespace mocktail