feat(assets): let the overlay replace files Roblox downloads

Roblox maps some rbxasset:// names to downloads and opens no local file for
them. For each file under content/ in the overlay folder, change its name in
the loaded libroblox so Roblox opens the local file.

Idea from @dim-ghub in #20.
This commit is contained in:
vii committed 2026-10-12 01:20:13 -05:00
1 parent b46098c211
commit a87373d003
8 files changed
+286 -2

No files matched your search

+4
View File
@@ -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
+1
View File
@@ -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
+27
View File
@@ -0,0 +1,27 @@
#ifndef MOCKTAIL_RUNTIME_ASSET_OVERLAY_NAMES_H_
#define MOCKTAIL_RUNTIME_ASSET_OVERLAY_NAMES_H_
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
namespace mocktail {
namespace runtime {
// The rbxasset:// name of every regular file under <overlay_dir>/content, such
// as rbxasset://textures/MouseLockedCursor.png. Sorted.
std::vector<std::string> 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<std::string>& names);
}
}
#endif
+44
View File
@@ -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<std::string> 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<const Elf64_Ehdr*>(base);
const auto* segments =
reinterpret_cast<const Elf64_Phdr*>(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<uintptr_t>(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<void*>(page_begin), span,
PROT_READ | PROT_WRITE) != 0) {
continue;
}
changed += mocktail::runtime::DisableAssetNameMappings(
reinterpret_cast<std::uint8_t*>(begin), segment.p_filesz, names);
mprotect(reinterpret_cast<void*>(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;
+64
View File
@@ -0,0 +1,64 @@
#include "runtime/asset_overlay_names.h"
#include <algorithm>
#include <cstring>
#include <system_error>
namespace mocktail {
namespace runtime {
std::vector<std::string> OverlayRbxAssetNames(
const std::filesystem::path& overlay_dir) {
std::vector<std::string> 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<std::string>& 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<std::uint8_t*>(
memmem(cursor, static_cast<std::size_t>(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
+7
View File
@@ -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)
+122
View File
@@ -0,0 +1,122 @@
#include "runtime/asset_overlay_names.h"
#include <gtest/gtest.h>
#include <unistd.h>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <string>
#include <string_view>
#include <vector>
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<std::uint8_t> StringTable(
const std::vector<std::string_view>& strings) {
std::vector<std::uint8_t> 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<std::uint8_t>& table, std::size_t offset) {
return std::string(reinterpret_cast<const char*>(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<std::string>{
"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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> table = StringTable({"alpha", "beta"});
const std::vector<std::uint8_t> 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
+17 -2
View File
@@ -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 <name> -> <path>`. A line that ends in `[overlay]` came from the
overlay folder. `MOCKTAIL_ASSET_OVERLAY_DIR` points the lookup somewhere else.
`[asset] open <name> -> <path>`, and every file it asks for and does not get as
`[asset] miss <name>`. 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.