diff --git a/include/runtime/runtime_paths.h b/include/runtime/runtime_paths.h index b55a4cd..a452c1e 100644 --- a/include/runtime/runtime_paths.h +++ b/include/runtime/runtime_paths.h @@ -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; }; diff --git a/src/runtime/runtime_paths.cc b/src/runtime/runtime_paths.cc index 8db215a..b0077f9 100644 --- a/src/runtime/runtime_paths.cc +++ b/src/runtime/runtime_paths.cc @@ -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, diff --git a/stubs/libandroid_stub.cc b/stubs/libandroid_stub.cc index d5f41d3..4993c07 100644 --- a/stubs/libandroid_stub.cc +++ b/stubs/libandroid_stub.cc @@ -43,6 +43,7 @@ struct AAsset { std::string apk_entry; std::vector 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 resolved_paths; + std::unordered_map 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 candidates = { + if (!overlay.empty()) { + const std::vector 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 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(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); } diff --git a/tests/asset_manager_test.cc b/tests/asset_manager_test.cc index 1cad3a8..2146462 100644 --- a/tests/asset_manager_test.cc +++ b/tests/asset_manager_test.cc @@ -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(AAsset_getBuffer(direct)), + static_cast(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(AAsset_getBuffer(rbxasset)), + static_cast(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(AAsset_getBuffer(bare)), + static_cast(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(AAsset_getBuffer(asset)), + static_cast(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(AAsset_getBuffer(first)), + static_cast(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(AAsset_getBuffer(second)), + static_cast(AAsset_getLength(second))), + "overlay-2"); + AAsset_close(second); +} + } // namespace diff --git a/tests/runtime_paths_test.cc b/tests/runtime_paths_test.cc index fffb674..11f5f0f 100644 --- a/tests/runtime_paths_test.cc +++ b/tests/runtime_paths_test.cc @@ -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