mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 14:00:46 +02:00
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:
1 parent
b46098c211
commit
a87373d003
8 files changed
+286
-2
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
Reference in new issue
Block a user