Add Sober-compatible asset overlay system

This commit is contained in:
dim-ghub committed 2026-10-11 23:11:54 -05:00
1 parent c9eb4fb7f0
commit a1166fb6ba
5 files changed
+233 -19

No files matched your search

+4
View File
@@ -67,6 +67,9 @@ class RuntimePaths {
return active_payload_manifest_;
}
const std::filesystem::path& cookie_file() const { return cookie_file_; }
const std::filesystem::path& asset_overlay_dir() const {
return asset_overlay_dir_;
}
bool use_real_sober_paths() const { return use_real_sober_paths_; }
std::filesystem::path DefaultAssetPath() const;
@@ -99,6 +102,7 @@ class RuntimePaths {
std::filesystem::path config_file_;
std::filesystem::path active_payload_manifest_;
std::filesystem::path cookie_file_;
std::filesystem::path asset_overlay_dir_;
bool use_real_sober_paths_ = false;
};
+13 -2
View File
@@ -349,6 +349,14 @@ RuntimePaths RuntimePaths::FromEnvironment(
paths.cookie_file_ = paths.auth_root_ / "roblox.cookie";
paths.use_real_sober_paths_ =
LegacyEnabled(environment, "MOCKTAIL_USE_REAL_SOBER_PATHS");
if (environment.HasNonEmpty("MOCKTAIL_ASSET_OVERLAY_DIR")) {
paths.asset_overlay_dir_ =
environment.GetOr("MOCKTAIL_ASSET_OVERLAY_DIR", "");
} else {
paths.asset_overlay_dir_ = paths.DefaultSoberAwarePath(
paths.sober_data_root_ / "asset_overlay",
paths.data_root_ / "asset_overlay");
}
return paths;
}
@@ -593,7 +601,8 @@ bool ExportRuntimePathEnvironment(const RuntimePaths& paths,
std::error_code filesystem_error;
for (const std::filesystem::path& directory :
{paths.android_runtime_root(), paths.android_cache_root(),
paths.vulkan_shader_cache_file().parent_path()}) {
paths.vulkan_shader_cache_file().parent_path(),
paths.asset_overlay_dir()}) {
if (!RuntimePaths::EnsureDirectory(directory, &filesystem_error)) {
if (error != nullptr) {
*error = "cannot create resolved runtime path: " + directory.string();
@@ -609,7 +618,9 @@ bool ExportRuntimePathEnvironment(const RuntimePaths& paths,
SetEnvironmentDefault("MOCKTAIL_VULKAN_SHADER_CACHE_HOST_PATH",
paths.vulkan_shader_cache_file(), error) &&
SetEnvironmentDefault("MOCKTAIL_TEXTURE_OVERRIDE_DIR",
paths.config_root() / "textures", error);
paths.config_root() / "textures", error) &&
SetEnvironmentDefault("MOCKTAIL_ASSET_OVERLAY_DIR",
paths.asset_overlay_dir(), error);
}
bool PrepareManagedPayloadWorkingDirectory(const RuntimePaths& paths,
+63 -17
View File
@@ -43,6 +43,7 @@ struct AAsset {
std::string apk_entry;
std::vector<unsigned char> data;
bool transcoded = false;
bool is_overlay = false;
size_t offset = 0;
};
struct AAssetDir {};
@@ -115,25 +116,33 @@ bool FileExists(const std::string& path) {
return !path.empty() && ::stat(path.c_str(), &st) == 0 && S_ISREG(st.st_mode);
}
struct ResolvedAsset {
std::string path;
bool is_overlay = false;
};
struct AssetLookupCache {
std::mutex mutex;
std::unordered_map<std::string, std::string> resolved_paths;
std::unordered_map<std::string, ResolvedAsset> resolved_paths;
};
AssetLookupCache g_asset_lookup_cache;
constexpr std::size_t kMaxCachedAssetPaths = 1024 * 5;
std::string AssetCacheKey(const std::string& root,
const std::string& overlay,
const std::string& requested) {
std::string key;
key.reserve(root.size() + 1 + requested.size());
key.reserve(root.size() + 1 + overlay.size() + 1 + requested.size());
key.append(root);
key.push_back('\0');
key.append(overlay);
key.push_back('\0');
key.append(requested);
return key;
}
std::string ResolveAssetPath(const char* filename) {
ResolvedAsset ResolveAssetPath(const char* filename) {
if (filename == nullptr) {
return {};
}
@@ -145,13 +154,16 @@ std::string ResolveAssetPath(const char* filename) {
const std::string requested(requested_view);
const char* root_env = GetEnvNonEmpty("MOCKTAIL_ASSET_ROOT");
const std::string root = root_env != nullptr ? root_env : "rbx_bin/assets";
const std::string cache_key = AssetCacheKey(root, requested);
const char* overlay_env = GetEnvNonEmpty("MOCKTAIL_ASSET_OVERLAY_DIR");
const std::string overlay =
overlay_env != nullptr ? overlay_env : "rbx_bin/asset_overlay";
const std::string cache_key = AssetCacheKey(root, overlay, requested);
{
std::scoped_lock lock(g_asset_lookup_cache.mutex);
auto it = g_asset_lookup_cache.resolved_paths.find(cache_key);
if (it != g_asset_lookup_cache.resolved_paths.end()) {
if (FileExists(it->second)) {
if (FileExists(it->second.path)) {
return it->second;
}
g_asset_lookup_cache.resolved_paths.erase(it);
@@ -160,14 +172,44 @@ std::string ResolveAssetPath(const char* filename) {
const std::string stripped(StripAndroidAssetPrefix(requested_view));
const std::vector<std::string> candidates = {
if (!overlay.empty()) {
const std::vector<std::string> overlay_candidates = {
overlay + "/" + requested,
overlay + "/" + stripped,
overlay + "/content/" + requested,
overlay + "/content/" + stripped,
overlay + "/ExtraContent/" + requested,
overlay + "/ExtraContent/" + stripped,
};
for (const std::string& candidate : overlay_candidates) {
if (FileExists(candidate)) {
ResolvedAsset resolved{candidate, true};
std::scoped_lock lock(g_asset_lookup_cache.mutex);
if (g_asset_lookup_cache.resolved_paths.size() >=
kMaxCachedAssetPaths &&
g_asset_lookup_cache.resolved_paths.find(cache_key) ==
g_asset_lookup_cache.resolved_paths.end()) {
g_asset_lookup_cache.resolved_paths.erase(
g_asset_lookup_cache.resolved_paths.begin());
}
g_asset_lookup_cache.resolved_paths.insert_or_assign(cache_key,
resolved);
return resolved;
}
}
}
const std::vector<std::string> root_candidates = {
root + "/" + requested,
root + "/" + stripped,
root + "/content/" + requested,
root + "/content/" + stripped,
root + "/ExtraContent/" + requested,
root + "/ExtraContent/" + stripped,
};
for (const std::string& candidate : candidates) {
for (const std::string& candidate : root_candidates) {
if (FileExists(candidate)) {
ResolvedAsset resolved{candidate, false};
std::scoped_lock lock(g_asset_lookup_cache.mutex);
if (g_asset_lookup_cache.resolved_paths.size() >=
kMaxCachedAssetPaths &&
@@ -177,8 +219,8 @@ std::string ResolveAssetPath(const char* filename) {
g_asset_lookup_cache.resolved_paths.begin());
}
g_asset_lookup_cache.resolved_paths.insert_or_assign(cache_key,
candidate);
return candidate;
resolved);
return resolved;
}
}
@@ -330,24 +372,27 @@ AAsset* AAssetManager_open(AAssetManager* mgr, const char* filename,
if (mgr == nullptr) {
return nullptr;
}
std::string path = ResolveAssetPath(filename);
if (path.empty()) {
ResolvedAsset resolved = ResolveAssetPath(filename);
if (resolved.path.empty()) {
return nullptr;
}
auto* asset = new AAsset;
asset->path = path;
asset->path = resolved.path;
asset->apk_entry = ApkEntryName(filename);
if (!LoadFile(path, &asset->data)) {
asset->is_overlay = resolved.is_overlay;
if (!LoadFile(resolved.path, &asset->data)) {
delete asset;
return nullptr;
}
if (MocktailUsesDirectVulkan()) {
asset->transcoded =
libc_shim::TranscodeEtc1SkyTextureForVulkan(path.c_str(), &asset->data);
libc_shim::TranscodeEtc1SkyTextureForVulkan(resolved.path.c_str(),
&asset->data);
}
if (AssetTraceEnabled()) {
std::fprintf(stderr, "[asset] open %s -> %s (%zu bytes)\n", filename,
path.c_str(), asset->data.size());
std::fprintf(stderr, "[asset] open %s -> %s (%zu bytes%s)\n", filename,
resolved.path.c_str(), asset->data.size(),
resolved.is_overlay ? " [overlay]" : "");
}
return asset;
}
@@ -464,7 +509,8 @@ int AAsset_openFileDescriptor(AAsset* asset, off_t* outStart,
int fd = -1;
off_t start = 0;
off_t descriptor_length = static_cast<off_t>(asset->data.size());
if (!OpenStoredApkEntryDescriptor(asset->apk_entry, &fd, &start,
if (asset->is_overlay ||
!OpenStoredApkEntryDescriptor(asset->apk_entry, &fd, &start,
&descriptor_length)) {
fd = ::open(asset->path.c_str(), O_RDONLY | O_CLOEXEC);
}
+113
View File
@@ -55,6 +55,7 @@ class AssetManagerTest : public ::testing::Test {
root_ = MakeTempRoot();
ASSERT_FALSE(root_.empty());
setenv("MOCKTAIL_ASSET_ROOT", root_.c_str(), 1);
unsetenv("MOCKTAIL_ASSET_OVERLAY_DIR");
const char* backend = std::getenv("MOCKTAIL_GRAPHICS_BACKEND");
had_backend_ = backend != nullptr;
previous_backend_ = backend != nullptr ? backend : "";
@@ -63,6 +64,7 @@ class AssetManagerTest : public ::testing::Test {
void TearDown() override {
unsetenv("MOCKTAIL_ASSET_ROOT");
unsetenv("MOCKTAIL_ASSET_OVERLAY_DIR");
if (had_backend_) {
setenv("MOCKTAIL_GRAPHICS_BACKEND", previous_backend_.c_str(), 1);
} else {
@@ -298,4 +300,115 @@ TEST_F(AssetManagerTest, CacheSeparatesRuntimeAssetRootChanges) {
AAsset_close(second);
}
TEST_F(AssetManagerTest, OverlayAssetTakesPrecedenceOverRootAsset) {
WriteAsset("content/textures/Cursors/KeyboardMouse/ArrowCursor.png",
"stock-arrow");
const std::string overlay_root = MakeTempRoot();
ASSERT_FALSE(overlay_root.empty());
WriteAssetAt(overlay_root,
"content/textures/Cursors/KeyboardMouse/ArrowCursor.png",
"custom-overlay-arrow");
ASSERT_EQ(setenv("MOCKTAIL_ASSET_OVERLAY_DIR", overlay_root.c_str(), 1), 0);
AAssetManager* manager = AAssetManager_fromJava(nullptr, nullptr);
// 1. Direct content/ path
AAsset* direct = AAssetManager_open(
manager, "content/textures/Cursors/KeyboardMouse/ArrowCursor.png", 0);
ASSERT_NE(direct, nullptr);
EXPECT_EQ(std::string(static_cast<const char*>(AAsset_getBuffer(direct)),
static_cast<std::size_t>(AAsset_getLength(direct))),
"custom-overlay-arrow");
AAsset_close(direct);
// 2. rbxasset:// URI prefix without content/
AAsset* rbxasset = AAssetManager_open(
manager, "rbxasset://textures/Cursors/KeyboardMouse/ArrowCursor.png", 0);
ASSERT_NE(rbxasset, nullptr);
EXPECT_EQ(std::string(static_cast<const char*>(AAsset_getBuffer(rbxasset)),
static_cast<std::size_t>(AAsset_getLength(rbxasset))),
"custom-overlay-arrow");
AAsset_close(rbxasset);
// 3. Bare textures/ prefix
AAsset* bare = AAssetManager_open(
manager, "textures/Cursors/KeyboardMouse/ArrowCursor.png", 0);
ASSERT_NE(bare, nullptr);
EXPECT_EQ(std::string(static_cast<const char*>(AAsset_getBuffer(bare)),
static_cast<std::size_t>(AAsset_getLength(bare))),
"custom-overlay-arrow");
AAsset_close(bare);
}
TEST_F(AssetManagerTest, OverlayOpenFileDescriptorReadsDirectlyFromFile) {
const std::string overlay_root = MakeTempRoot();
ASSERT_FALSE(overlay_root.empty());
WriteAssetAt(overlay_root, "content/custom_shader.bin", "custom-overlay-shader");
ASSERT_EQ(setenv("MOCKTAIL_ASSET_OVERLAY_DIR", overlay_root.c_str(), 1), 0);
// Point APK path to non-existent or dummy file to ensure descriptor reads overlay
ASSERT_EQ(setenv("MOCKTAIL_ASSET_APK_PATH", "/nonexistent/apk/path.apk", 1), 0);
AAssetManager* manager = AAssetManager_fromJava(nullptr, nullptr);
AAsset* asset = AAssetManager_open(manager, "content/custom_shader.bin", 0);
ASSERT_NE(asset, nullptr);
off_t start = -1;
off_t length = -1;
int fd = AAsset_openFileDescriptor(asset, &start, &length);
ASSERT_GE(fd, 0);
EXPECT_EQ(start, 0);
EXPECT_EQ(length, 21);
char buf[32] = {};
EXPECT_EQ(pread(fd, buf, 21, start), 21);
EXPECT_STREQ(buf, "custom-overlay-shader");
close(fd);
AAsset_close(asset);
unsetenv("MOCKTAIL_ASSET_APK_PATH");
}
TEST_F(AssetManagerTest, NonOverlaidAssetFallsBackToRootAsset) {
WriteAsset("content/sounds/oof.mp3", "stock-sound");
const std::string overlay_root = MakeTempRoot();
ASSERT_FALSE(overlay_root.empty());
WriteAssetAt(overlay_root, "content/textures/unrelated.png", "other-texture");
ASSERT_EQ(setenv("MOCKTAIL_ASSET_OVERLAY_DIR", overlay_root.c_str(), 1), 0);
AAssetManager* manager = AAssetManager_fromJava(nullptr, nullptr);
AAsset* asset = AAssetManager_open(manager, "sounds/oof.mp3", 0);
ASSERT_NE(asset, nullptr);
EXPECT_EQ(std::string(static_cast<const char*>(AAsset_getBuffer(asset)),
static_cast<std::size_t>(AAsset_getLength(asset))),
"stock-sound");
AAsset_close(asset);
}
TEST_F(AssetManagerTest, CacheSeparatesOverlayDirectoryChanges) {
WriteAsset("content/test.txt", "stock");
const std::string first_overlay = MakeTempRoot();
ASSERT_FALSE(first_overlay.empty());
WriteAssetAt(first_overlay, "content/test.txt", "overlay-1");
ASSERT_EQ(setenv("MOCKTAIL_ASSET_OVERLAY_DIR", first_overlay.c_str(), 1), 0);
AAssetManager* manager = AAssetManager_fromJava(nullptr, nullptr);
AAsset* first = AAssetManager_open(manager, "content/test.txt", 0);
ASSERT_NE(first, nullptr);
EXPECT_EQ(std::string(static_cast<const char*>(AAsset_getBuffer(first)),
static_cast<std::size_t>(AAsset_getLength(first))),
"overlay-1");
AAsset_close(first);
const std::string second_overlay = MakeTempRoot();
ASSERT_FALSE(second_overlay.empty());
WriteAssetAt(second_overlay, "content/test.txt", "overlay-2");
ASSERT_EQ(setenv("MOCKTAIL_ASSET_OVERLAY_DIR", second_overlay.c_str(), 1), 0);
AAsset* second = AAssetManager_open(manager, "content/test.txt", 0);
ASSERT_NE(second, nullptr);
EXPECT_EQ(std::string(static_cast<const char*>(AAsset_getBuffer(second)),
static_cast<std::size_t>(AAsset_getLength(second))),
"overlay-2");
AAsset_close(second);
}
} // namespace
+40
View File
@@ -439,6 +439,36 @@ TEST(RuntimePathsTest, SelectsExistingRealSoberAssetPath) {
EXPECT_EQ(paths.DefaultAssetPath(), sober_assets);
}
TEST(RuntimePathsTest, SelectsExistingRealSoberAssetOverlayPath) {
TemporaryDirectory temporary;
ASSERT_FALSE(temporary.path().empty());
const std::filesystem::path home = temporary.path() / "home";
const std::filesystem::path sober_overlay =
home / ".var/app/org.vinegarhq.Sober/data/sober/asset_overlay";
ASSERT_TRUE(RuntimePaths::EnsureDirectory(sober_overlay));
const MapEnvironment environment({
{"HOME", home.string()},
{"MOCKTAIL_USE_REAL_SOBER_PATHS", "1"},
});
const RuntimePaths paths = RuntimePaths::FromEnvironment(
environment, temporary.path() / "empty-workspace");
EXPECT_EQ(paths.asset_overlay_dir(), sober_overlay);
}
TEST(RuntimePathsTest, RespectsExplicitAssetOverlayOverride) {
TemporaryDirectory temporary;
ASSERT_FALSE(temporary.path().empty());
const MapEnvironment environment({
{"HOME", temporary.path().string()},
{"MOCKTAIL_ASSET_OVERLAY_DIR", "/custom/asset_overlay"},
});
const RuntimePaths paths = RuntimePaths::FromEnvironment(
environment, temporary.path());
EXPECT_EQ(paths.asset_overlay_dir(), "/custom/asset_overlay");
}
TEST(RuntimePathsTest, PrefersWorkspaceAssets) {
TemporaryDirectory temporary;
ASSERT_FALSE(temporary.path().empty());
@@ -591,10 +621,13 @@ TEST(RuntimePathsTest, ExportsXdgBackedAndroidPathsAndCreatesParents) {
"MOCKTAIL_VULKAN_SHADER_CACHE_HOST_PATH");
const ScopedProcessEnvironment texture_overrides(
"MOCKTAIL_TEXTURE_OVERRIDE_DIR");
const ScopedProcessEnvironment overlay_dir(
"MOCKTAIL_ASSET_OVERLAY_DIR");
ASSERT_EQ(unsetenv("MOCKTAIL_RUNTIME_ROOT"), 0);
ASSERT_EQ(unsetenv("MOCKTAIL_ANDROID_CACHE_HOST_ROOT"), 0);
ASSERT_EQ(unsetenv("MOCKTAIL_VULKAN_SHADER_CACHE_HOST_PATH"), 0);
ASSERT_EQ(unsetenv("MOCKTAIL_TEXTURE_OVERRIDE_DIR"), 0);
ASSERT_EQ(unsetenv("MOCKTAIL_ASSET_OVERLAY_DIR"), 0);
const MapEnvironment environment({
{"HOME", temporary.path().string()},
@@ -614,10 +647,13 @@ TEST(RuntimePathsTest, ExportsXdgBackedAndroidPathsAndCreatesParents) {
paths.vulkan_shader_cache_file().c_str());
EXPECT_STREQ(std::getenv("MOCKTAIL_TEXTURE_OVERRIDE_DIR"),
(paths.config_root() / "textures").c_str());
EXPECT_STREQ(std::getenv("MOCKTAIL_ASSET_OVERLAY_DIR"),
paths.asset_overlay_dir().c_str());
EXPECT_TRUE(std::filesystem::is_directory(paths.android_runtime_root()));
EXPECT_TRUE(std::filesystem::is_directory(paths.android_cache_root()));
EXPECT_TRUE(std::filesystem::is_directory(
paths.vulkan_shader_cache_file().parent_path()));
EXPECT_TRUE(std::filesystem::is_directory(paths.asset_overlay_dir()));
}
TEST(RuntimePathsTest, PreservesExplicitAndroidPathOverrides) {
@@ -628,12 +664,15 @@ TEST(RuntimePathsTest, PreservesExplicitAndroidPathOverrides) {
"MOCKTAIL_ANDROID_CACHE_HOST_ROOT");
const ScopedProcessEnvironment shader_cache(
"MOCKTAIL_VULKAN_SHADER_CACHE_HOST_PATH");
const ScopedProcessEnvironment overlay_dir(
"MOCKTAIL_ASSET_OVERLAY_DIR");
ASSERT_EQ(setenv("MOCKTAIL_RUNTIME_ROOT", "/explicit/runtime", 1), 0);
ASSERT_EQ(setenv("MOCKTAIL_ANDROID_CACHE_HOST_ROOT", "/explicit/cache", 1),
0);
ASSERT_EQ(setenv("MOCKTAIL_VULKAN_SHADER_CACHE_HOST_PATH",
"/explicit/shader-cache", 1),
0);
ASSERT_EQ(setenv("MOCKTAIL_ASSET_OVERLAY_DIR", "/explicit/overlay", 1), 0);
const MapEnvironment environment({{"HOME", temporary.path().string()}});
const RuntimePaths paths =
@@ -646,6 +685,7 @@ TEST(RuntimePathsTest, PreservesExplicitAndroidPathOverrides) {
"/explicit/cache");
EXPECT_STREQ(std::getenv("MOCKTAIL_VULKAN_SHADER_CACHE_HOST_PATH"),
"/explicit/shader-cache");
EXPECT_STREQ(std::getenv("MOCKTAIL_ASSET_OVERLAY_DIR"), "/explicit/overlay");
}
} // namespace