diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b77ed6..32e6570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ Release notes on GitHub are taken from the matching section here. ships at the same path, such as a cursor, a font or a sky texture, and it stays in place when Roblox updates. The layout matches Sober's `asset_overlay` folder. `MOCKTAIL_ASSET_OVERLAY_DIR` moves it. Thanks to @dim-ghub. +- The asset overlay folder now also replaces files Roblox downloads and no longer ships, such as the shift + lock and text cursors. Put the file under `content/`, at the path Roblox uses. Thanks to @dim-ghub for the + idea. +- `MOCKTAIL_ASSET_TRACE=1` now also prints the files Roblox asks for and does not get, as `[asset] miss`. ## [0.3.4] - 2026-10-11 diff --git a/CMakeLists.txt b/CMakeLists.txt index a9647de..f0482eb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -298,6 +298,7 @@ add_library(mocktail_runtime STATIC src/runtime/payload_update_preflight.cc src/runtime/process_launch_policy.cc src/runtime/process_diagnostics.cc + src/runtime/asset_overlay_names.cc src/runtime/runtime_config.cc src/runtime/runtime_config_bootstrap.cc src/runtime/runtime_config_file.cc diff --git a/include/runtime/asset_overlay_names.h b/include/runtime/asset_overlay_names.h new file mode 100644 index 0000000..755589b --- /dev/null +++ b/include/runtime/asset_overlay_names.h @@ -0,0 +1,27 @@ +#ifndef MOCKTAIL_RUNTIME_ASSET_OVERLAY_NAMES_H_ +#define MOCKTAIL_RUNTIME_ASSET_OVERLAY_NAMES_H_ + +#include +#include +#include +#include +#include + +namespace mocktail { +namespace runtime { + +// The rbxasset:// name of every regular file under /content, such +// as rbxasset://textures/MouseLockedCursor.png. Sorted. +std::vector OverlayRbxAssetNames( + const std::filesystem::path& overlay_dir); + +// Roblox opens no local file for an rbxasset:// name it maps to a download. +// Writes '!' over the first byte of each whole NUL-terminated copy of a name +// in `data`, which unmaps it. Returns the number of copies changed. +std::size_t DisableAssetNameMappings(std::uint8_t* data, std::size_t size, + const std::vector& names); + +} +} + +#endif diff --git a/src/legacy/legacy_runtime.cc b/src/legacy/legacy_runtime.cc index 6331c5d..4a578a2 100644 --- a/src/legacy/legacy_runtime.cc +++ b/src/legacy/legacy_runtime.cc @@ -69,6 +69,7 @@ #include "runtime/jnivm_platform_web_callbacks.h" #include "runtime/owned_pthread.h" #include "runtime/platform_cache_migration.h" +#include "runtime/asset_overlay_names.h" #include "runtime/roblox_app_lifecycle.h" #include "runtime/roblox_capability_resolver.h" #include "runtime/roblox_platform_web_symbols.h" @@ -1963,6 +1964,48 @@ extern "C" void MocktailConstructorWrapper() { MaybePrintLibRobloxConstructorSummary(idx); } +// Unmaps the rbxasset:// names that have a file in the asset overlay folder, so +// Roblox opens those files instead of downloading the stock asset. Must run +// before any libroblox code does: the segment loses PROT_EXEC while it is +// written. +void DisableOverlaidAssetMappings(uintptr_t base) { + const std::vector names = mocktail::runtime::OverlayRbxAssetNames( + CurrentRuntimePaths().asset_overlay_dir()); + const long page_size = sysconf(_SC_PAGESIZE); + if (names.empty() || page_size <= 0) { + return; + } + const auto* header = reinterpret_cast(base); + const auto* segments = + reinterpret_cast(base + header->e_phoff); + std::size_t changed = 0; + for (std::size_t index = 0; index < header->e_phnum; ++index) { + const Elf64_Phdr& segment = segments[index]; + if (segment.p_type != PT_LOAD || (segment.p_flags & PF_W) != 0 || + segment.p_filesz == 0) { + continue; + } + const uintptr_t begin = base + segment.p_vaddr; + const uintptr_t page_mask = static_cast(page_size) - 1; + const uintptr_t page_begin = begin & ~page_mask; + const std::size_t span = + ((begin + segment.p_filesz + page_mask) & ~page_mask) - page_begin; + const int original = + PROT_READ | ((segment.p_flags & PF_X) != 0 ? PROT_EXEC : 0); + if (mprotect(reinterpret_cast(page_begin), span, + PROT_READ | PROT_WRITE) != 0) { + continue; + } + changed += mocktail::runtime::DisableAssetNameMappings( + reinterpret_cast(begin), segment.p_filesz, names); + mprotect(reinterpret_cast(page_begin), span, original); + } + std::cout << " [asset] overlay holds " << names.size() + << " rbxasset file(s); unmapped " << changed + << " download name(s)\n" + << std::flush; +} + extern "C" void mocktail_before_soinfo_constructors(const char* realpath, uintptr_t base) { if (realpath == nullptr || std::strstr(realpath, "libroblox.so") == nullptr || @@ -1971,6 +2014,7 @@ extern "C" void mocktail_before_soinfo_constructors(const char* realpath, } g_libroblox_base = base; + DisableOverlaidAssetMappings(base); g_mocktail_abort_libroblox_base = base; g_libroblox_base_static = base; diff --git a/src/runtime/asset_overlay_names.cc b/src/runtime/asset_overlay_names.cc new file mode 100644 index 0000000..9eb0d92 --- /dev/null +++ b/src/runtime/asset_overlay_names.cc @@ -0,0 +1,64 @@ +#include "runtime/asset_overlay_names.h" + +#include +#include +#include + +namespace mocktail { +namespace runtime { + +std::vector OverlayRbxAssetNames( + const std::filesystem::path& overlay_dir) { + std::vector names; + const std::filesystem::path content = overlay_dir / "content"; + std::error_code error; + std::filesystem::recursive_directory_iterator it( + content, std::filesystem::directory_options::skip_permission_denied, + error); + const std::filesystem::recursive_directory_iterator end; + for (; !error && it != end; it.increment(error)) { + std::error_code entry_error; + if (!it->is_regular_file(entry_error)) { + continue; + } + const std::filesystem::path relative = + it->path().lexically_relative(content); + names.push_back("rbxasset://" + relative.generic_string()); + } + std::sort(names.begin(), names.end()); + return names; +} + +std::size_t DisableAssetNameMappings(std::uint8_t* data, std::size_t size, + const std::vector& names) { + std::size_t changed = 0; + if (data == nullptr) { + return changed; + } + std::uint8_t* const end = data + size; + for (const std::string& name : names) { + // The trailing NUL is part of the needle, so a longer string never matches. + const std::size_t needle_size = name.size() + 1; + if (name.empty() || needle_size > size) { + continue; + } + std::uint8_t* cursor = data; + while (cursor + needle_size <= end) { + auto* found = static_cast( + memmem(cursor, static_cast(end - cursor), name.c_str(), + needle_size)); + if (found == nullptr) { + break; + } + if (found == data || found[-1] == 0) { + found[0] = '!'; + ++changed; + } + cursor = found + needle_size; + } + } + return changed; +} + +} // namespace runtime +} // namespace mocktail diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 58dbbbc..42ab56f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -214,6 +214,12 @@ target_link_libraries(runtime_paths_test PRIVATE GTest::gtest_main ) +add_executable(asset_overlay_names_test asset_overlay_names_test.cc) +target_link_libraries(asset_overlay_names_test PRIVATE + Mocktail::Runtime + GTest::gtest_main +) + add_executable(single_instance_lock_test single_instance_lock_test.cc) target_link_libraries(single_instance_lock_test PRIVATE Mocktail::Runtime @@ -1045,6 +1051,7 @@ gtest_discover_tests(game_mode_link_namespace_test PROPERTIES ) gtest_discover_tests(memory_limit_test) gtest_discover_tests(runtime_paths_test) +gtest_discover_tests(asset_overlay_names_test) gtest_discover_tests(single_instance_lock_test) gtest_discover_tests(supported_launch_policy_test) gtest_discover_tests(process_launch_policy_test) diff --git a/tests/asset_overlay_names_test.cc b/tests/asset_overlay_names_test.cc new file mode 100644 index 0000000..06c19b2 --- /dev/null +++ b/tests/asset_overlay_names_test.cc @@ -0,0 +1,122 @@ +#include "runtime/asset_overlay_names.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +using mocktail::runtime::DisableAssetNameMappings; +using mocktail::runtime::OverlayRbxAssetNames; + +class TemporaryDirectory { + public: + TemporaryDirectory() { + char pattern[] = "/tmp/mocktail_asset_overlay_names_XXXXXX"; + char* created = mkdtemp(pattern); + if (created != nullptr) { + path_ = created; + } + } + + ~TemporaryDirectory() { + std::error_code error; + std::filesystem::remove_all(path_, error); + } + + const std::filesystem::path& path() const { return path_; } + + private: + std::filesystem::path path_; +}; + +void WriteFile(const std::filesystem::path& path) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream(path) << "x"; +} + +// Joins strings the way a string table stores them: each followed by a NUL. +std::vector StringTable( + const std::vector& strings) { + std::vector table; + for (std::string_view text : strings) { + table.insert(table.end(), text.begin(), text.end()); + table.push_back(0); + } + return table; +} + +std::string TextAt(const std::vector& table, std::size_t offset) { + return std::string(reinterpret_cast(table.data()) + offset); +} + +TEST(AssetOverlayNamesTest, ListsFilesUnderContentAsRbxAssetNames) { + TemporaryDirectory overlay; + ASSERT_FALSE(overlay.path().empty()); + WriteFile(overlay.path() / "content/textures/MouseLockedCursor.png"); + WriteFile(overlay.path() / + "content/textures/Cursors/KeyboardMouse/IBeamCursor.png"); + WriteFile(overlay.path() / "notes.txt"); + std::filesystem::create_directories(overlay.path() / "content/empty"); + + EXPECT_EQ(OverlayRbxAssetNames(overlay.path()), + (std::vector{ + "rbxasset://textures/Cursors/KeyboardMouse/IBeamCursor.png", + "rbxasset://textures/MouseLockedCursor.png", + })); +} + +TEST(AssetOverlayNamesTest, MissingOverlayFolderHasNoNames) { + TemporaryDirectory overlay; + ASSERT_FALSE(overlay.path().empty()); + + EXPECT_TRUE(OverlayRbxAssetNames(overlay.path() / "absent").empty()); + EXPECT_TRUE(OverlayRbxAssetNames(overlay.path()).empty()); +} + +TEST(AssetOverlayNamesTest, ChangesOnlyTheNamedWholeStrings) { + const std::string name = "rbxasset://textures/MouseLockedCursor.png"; + const std::string longer = name + ".bak"; + const std::string prefixed = "x" + name; + std::vector table = + StringTable({"first", name, longer, prefixed, name}); + const std::size_t first_copy = 6; + const std::size_t longer_copy = first_copy + name.size() + 1; + const std::size_t prefixed_copy = longer_copy + longer.size() + 1; + const std::size_t second_copy = prefixed_copy + prefixed.size() + 1; + + EXPECT_EQ(DisableAssetNameMappings(table.data(), table.size(), {name}), 2u); + + EXPECT_EQ(TextAt(table, 0), "first"); + EXPECT_EQ(TextAt(table, first_copy), "!" + name.substr(1)); + EXPECT_EQ(TextAt(table, longer_copy), longer); + EXPECT_EQ(TextAt(table, prefixed_copy), prefixed); + EXPECT_EQ(TextAt(table, second_copy), "!" + name.substr(1)); +} + +TEST(AssetOverlayNamesTest, MatchesANameAtTheStartOfTheData) { + const std::string name = "rbxasset://textures/IBeamCursor.png"; + std::vector table = StringTable({name, "other"}); + + EXPECT_EQ(DisableAssetNameMappings(table.data(), table.size(), {name}), 1u); + EXPECT_EQ(TextAt(table, 0), "!" + name.substr(1)); +} + +TEST(AssetOverlayNamesTest, LeavesDataAloneWithoutAMatch) { + std::vector table = StringTable({"alpha", "beta"}); + const std::vector before = table; + + EXPECT_EQ(DisableAssetNameMappings(table.data(), table.size(), + {"rbxasset://missing.png", ""}), + 0u); + EXPECT_EQ(table, before); + EXPECT_EQ(DisableAssetNameMappings(nullptr, 0, {"alpha"}), 0u); +} + +} // namespace diff --git a/textures/README.md b/textures/README.md index d63aae5..c15a21d 100644 --- a/textures/README.md +++ b/textures/README.md @@ -57,9 +57,24 @@ Restart the game. The layout matches Sober's `asset_overlay` folder, so a file set made for Sober can be copied in as it is. The Flatpak build keeps the folder at `~/.var/app/io.github.CoderDayton.nightcap/data/mocktail/asset_overlay`. +Roblox downloads some of these files, such as the shift lock and text cursors, +and reads no local file for them. A file under `content/` in the overlay folder +still replaces one: at start, Nightcap changes that file's name inside the +loaded Roblox library, in memory only, so Roblox reads the local file. Nothing +is changed when the overlay folder holds no file. Roblox does not allow +modified clients, so use this at your own risk. + +```sh +dir=~/.local/share/mocktail/asset_overlay/content/textures +mkdir -p "$dir/Cursors/KeyboardMouse" +cp my-shift-lock.png "$dir/MouseLockedCursor.png" +cp my-text-cursor.png "$dir/Cursors/KeyboardMouse/IBeamCursor.png" +``` + Set `MOCKTAIL_ASSET_TRACE=1` to print every file Roblox opens as -`[asset] open -> `. A line that ends in `[overlay]` came from the -overlay folder. `MOCKTAIL_ASSET_OVERLAY_DIR` points the lookup somewhere else. +`[asset] open -> `, and every file it asks for and does not get as +`[asset] miss `. A line that ends in `[overlay]` came from the overlay +folder. `MOCKTAIL_ASSET_OVERLAY_DIR` points the lookup somewhere else. An overlay file changes the texture's bytes, so a texture override keyed on the original file's hash stops matching it. Use one of the two for a given texture.