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