diff --git a/Source/Core/DolphinLib.props b/Source/Core/DolphinLib.props
index 737a2a16d6..b08aa2042b 100644
--- a/Source/Core/DolphinLib.props
+++ b/Source/Core/DolphinLib.props
@@ -669,6 +669,8 @@
+
+
@@ -1323,6 +1325,8 @@
+
+
diff --git a/Source/Core/VideoCommon/Assets/CustomAssetLoader.cpp b/Source/Core/VideoCommon/Assets/CustomAssetLoader.cpp
new file mode 100644
index 0000000000..2ca227546c
--- /dev/null
+++ b/Source/Core/VideoCommon/Assets/CustomAssetLoader.cpp
@@ -0,0 +1,157 @@
+// Copyright 2025 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "VideoCommon/Assets/CustomAssetLoader.h"
+
+#include
+
+#include "Common/Logging/Log.h"
+#include "Common/Thread.h"
+
+#include "UICommon/UICommon.h"
+
+namespace VideoCommon
+{
+void CustomAssetLoader::Initialize()
+{
+ ResizeWorkerThreads(2);
+}
+
+void CustomAssetLoader::Shutdown()
+{
+ Reset(false);
+}
+
+bool CustomAssetLoader::StartWorkerThreads(u32 num_worker_threads)
+{
+ for (u32 i = 0; i < num_worker_threads; i++)
+ {
+ m_worker_threads.emplace_back(&CustomAssetLoader::WorkerThreadRun, this, i);
+ }
+
+ return HasWorkerThreads();
+}
+
+bool CustomAssetLoader::ResizeWorkerThreads(u32 num_worker_threads)
+{
+ if (m_worker_threads.size() == num_worker_threads)
+ return true;
+
+ StopWorkerThreads();
+ return StartWorkerThreads(num_worker_threads);
+}
+
+bool CustomAssetLoader::HasWorkerThreads() const
+{
+ return !m_worker_threads.empty();
+}
+
+void CustomAssetLoader::StopWorkerThreads()
+{
+ if (!HasWorkerThreads())
+ return;
+
+ // Signal worker threads to stop, and wake all of them.
+ {
+ std::lock_guard guard(m_assets_to_load_lock);
+ m_exit_flag.Set();
+ m_worker_thread_wake.notify_all();
+ }
+
+ // Wait for worker threads to exit.
+ for (std::thread& thr : m_worker_threads)
+ thr.join();
+ m_worker_threads.clear();
+ m_exit_flag.Clear();
+}
+
+void CustomAssetLoader::WorkerThreadRun(u32 thread_index)
+{
+ Common::SetCurrentThreadName(fmt::format("Asset Loader {}", thread_index).c_str());
+
+ std::unique_lock load_lock(m_assets_to_load_lock);
+ while (true)
+ {
+ m_worker_thread_wake.wait(load_lock,
+ [&] { return !m_assets_to_load.empty() || m_exit_flag.IsSet(); });
+
+ if (m_exit_flag.IsSet())
+ return;
+
+ // If more memory than allowed has already been loaded, we will load nothing more
+ // until the next ScheduleAssetsToLoad from Manager.
+ if (m_change_in_memory > m_allowed_memory)
+ {
+ m_assets_to_load.clear();
+ continue;
+ }
+
+ auto* const item = m_assets_to_load.front();
+ m_assets_to_load.pop_front();
+
+ // Make sure another thread isn't loading this handle.
+ if (!m_handles_in_progress.insert(item->GetHandle()).second)
+ continue;
+
+ load_lock.unlock();
+
+ // Unload previously loaded asset.
+ m_change_in_memory -= item->Unload();
+
+ const std::size_t bytes_loaded = item->Load();
+ m_change_in_memory += s64(bytes_loaded);
+
+ load_lock.lock();
+
+ {
+ INFO_LOG_FMT(VIDEO, "CustomAssetLoader thread {} loaded: {} ({})", thread_index,
+ item->GetAssetId(), UICommon::FormatSize(bytes_loaded));
+
+ std::lock_guard lk{m_assets_loaded_lock};
+ m_asset_handles_loaded.emplace_back(item->GetHandle(), bytes_loaded > 0);
+
+ // Make sure no other threads try to re-process this item.
+ // Manager will take the handles and re-ScheduleAssetsToLoad based on timestamps if needed.
+ std::erase(m_assets_to_load, item);
+ }
+
+ m_handles_in_progress.erase(item->GetHandle());
+ }
+}
+
+auto CustomAssetLoader::TakeLoadResults() -> LoadResults
+{
+ std::lock_guard guard(m_assets_loaded_lock);
+ return {std::move(m_asset_handles_loaded), m_change_in_memory.exchange(0)};
+}
+
+void CustomAssetLoader::ScheduleAssetsToLoad(std::list assets_to_load,
+ u64 allowed_memory)
+{
+ if (assets_to_load.empty()) [[unlikely]]
+ return;
+
+ // There's new assets to process, notify worker threads
+ std::lock_guard guard(m_assets_to_load_lock);
+ m_allowed_memory = allowed_memory;
+ m_assets_to_load = std::move(assets_to_load);
+ m_worker_thread_wake.notify_all();
+}
+
+void CustomAssetLoader::Reset(bool restart_worker_threads)
+{
+ const std::size_t worker_thread_count = m_worker_threads.size();
+ StopWorkerThreads();
+
+ m_assets_to_load.clear();
+ m_asset_handles_loaded.clear();
+ m_allowed_memory = 0;
+ m_change_in_memory = 0;
+
+ if (restart_worker_threads)
+ {
+ StartWorkerThreads(static_cast(worker_thread_count));
+ }
+}
+
+} // namespace VideoCommon
diff --git a/Source/Core/VideoCommon/Assets/CustomAssetLoader.h b/Source/Core/VideoCommon/Assets/CustomAssetLoader.h
new file mode 100644
index 0000000000..8b67da65a9
--- /dev/null
+++ b/Source/Core/VideoCommon/Assets/CustomAssetLoader.h
@@ -0,0 +1,82 @@
+// Copyright 2025 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "Common/Flag.h"
+#include "VideoCommon/Assets/CustomAsset.h"
+
+namespace VideoCommon
+{
+// This class takes any number of assets
+// and loads them across a configurable
+// thread pool
+class CustomAssetLoader
+{
+public:
+ CustomAssetLoader() = default;
+ ~CustomAssetLoader() = default;
+ CustomAssetLoader(const CustomAssetLoader&) = delete;
+ CustomAssetLoader(CustomAssetLoader&&) = delete;
+ CustomAssetLoader& operator=(const CustomAssetLoader&) = delete;
+ CustomAssetLoader& operator=(CustomAssetLoader&&) = delete;
+
+ void Initialize();
+ void Shutdown();
+
+ using AssetHandle = std::pair;
+ struct LoadResults
+
+ {
+ std::vector asset_handles;
+ s64 change_in_memory;
+ };
+
+ // Returns a vector of loaded asset handle / loaded result pairs
+ // and the change in memory.
+ LoadResults TakeLoadResults();
+
+ // Schedule assets to load on the worker threads
+ // and set how much memory is available for loading these additional assets.
+ void ScheduleAssetsToLoad(std::list assets_to_load, u64 allowed_memory);
+
+ void Reset(bool restart_worker_threads = true);
+
+private:
+ bool StartWorkerThreads(u32 num_worker_threads);
+ bool ResizeWorkerThreads(u32 num_worker_threads);
+ bool HasWorkerThreads() const;
+ void StopWorkerThreads();
+
+ void WorkerThreadRun(u32 thread_index);
+
+ Common::Flag m_exit_flag;
+
+ std::vector m_worker_threads;
+
+ std::mutex m_assets_to_load_lock;
+ std::list m_assets_to_load;
+
+ std::condition_variable m_worker_thread_wake;
+
+ std::vector m_asset_handles_loaded;
+
+ // Memory available to load new assets.
+ s64 m_allowed_memory = 0;
+
+ // Change in memory from just-loaded/unloaded asset results yet to be taken by the Manager.
+ std::atomic m_change_in_memory = 0;
+
+ std::mutex m_assets_loaded_lock;
+
+ std::set m_handles_in_progress;
+};
+} // namespace VideoCommon
diff --git a/Source/Core/VideoCommon/Assets/CustomResourceManager.cpp b/Source/Core/VideoCommon/Assets/CustomResourceManager.cpp
new file mode 100644
index 0000000000..f2af4ccbea
--- /dev/null
+++ b/Source/Core/VideoCommon/Assets/CustomResourceManager.cpp
@@ -0,0 +1,229 @@
+// Copyright 2025 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "VideoCommon/Assets/CustomResourceManager.h"
+
+#include "Common/Logging/Log.h"
+#include "Common/MemoryUtil.h"
+
+#include "UICommon/UICommon.h"
+
+#include "VideoCommon/Assets/CustomAsset.h"
+#include "VideoCommon/Assets/TextureAsset.h"
+#include "VideoCommon/VideoEvents.h"
+
+namespace VideoCommon
+{
+void CustomResourceManager::Initialize()
+{
+ // Use half of available system memory but leave at least 2GiB unused for system stability.
+ constexpr size_t must_keep_unused = 2 * size_t(1024 * 1024 * 1024);
+
+ const size_t sys_mem = Common::MemPhysical();
+ const size_t keep_unused_mem = std::max(sys_mem / 2, std::min(sys_mem, must_keep_unused));
+
+ m_max_ram_available = sys_mem - keep_unused_mem;
+
+ if (m_max_ram_available == 0)
+ ERROR_LOG_FMT(VIDEO, "Not enough system memory for custom resources.");
+
+ m_asset_loader.Initialize();
+
+ m_xfb_event =
+ AfterFrameEvent::Register([this](Core::System&) { XFBTriggered(); }, "CustomResourceManager");
+}
+
+void CustomResourceManager::Shutdown()
+{
+ Reset();
+
+ m_asset_loader.Shutdown();
+}
+
+void CustomResourceManager::Reset()
+{
+ m_asset_loader.Reset(true);
+
+ m_active_assets = {};
+ m_pending_assets = {};
+ m_asset_handle_to_data.clear();
+ m_asset_id_to_handle.clear();
+ m_texture_data_asset_cache.clear();
+ m_dirty_assets.clear();
+ m_ram_used = 0;
+}
+
+void CustomResourceManager::MarkAssetDirty(const CustomAssetLibrary::AssetID& asset_id)
+{
+ std::lock_guard guard(m_dirty_mutex);
+ m_dirty_assets.insert(asset_id);
+}
+
+CustomResourceManager::TextureTimePair CustomResourceManager::GetTextureDataFromAsset(
+ const CustomAssetLibrary::AssetID& asset_id,
+ std::shared_ptr library)
+{
+ auto& resource = m_texture_data_asset_cache[asset_id];
+ if (resource.asset_data != nullptr &&
+ resource.asset_data->load_status == AssetData::LoadStatus::ResourceDataAvailable)
+ {
+ m_active_assets.MakeAssetHighestPriority(resource.asset->GetHandle(), resource.asset);
+ return {resource.texture_data, resource.asset->GetLastLoadedTime()};
+ }
+
+ // If there is an error, don't try and load again until the error is fixed
+ if (resource.asset_data != nullptr && resource.asset_data->has_load_error)
+ return {};
+
+ LoadTextureDataAsset(asset_id, std::move(library), &resource);
+ m_active_assets.MakeAssetHighestPriority(resource.asset->GetHandle(), resource.asset);
+
+ return {};
+}
+
+void CustomResourceManager::LoadTextureDataAsset(
+ const CustomAssetLibrary::AssetID& asset_id,
+ std::shared_ptr library, InternalTextureDataResource* resource)
+{
+ if (!resource->asset)
+ {
+ resource->asset =
+ CreateAsset(asset_id, AssetData::AssetType::TextureData, std::move(library));
+ resource->asset_data = &m_asset_handle_to_data[resource->asset->GetHandle()];
+ }
+
+ auto texture_data = resource->asset->GetData();
+ if (!texture_data || resource->asset_data->load_status == AssetData::LoadStatus::PendingReload)
+ {
+ // Tell the system we are still interested in loading this asset
+ const auto asset_handle = resource->asset->GetHandle();
+ m_pending_assets.MakeAssetHighestPriority(asset_handle,
+ m_asset_handle_to_data[asset_handle].asset.get());
+ }
+ else if (resource->asset_data->load_status == AssetData::LoadStatus::LoadFinished)
+ {
+ resource->texture_data = std::move(texture_data);
+ resource->asset_data->load_status = AssetData::LoadStatus::ResourceDataAvailable;
+ }
+}
+
+void CustomResourceManager::XFBTriggered()
+{
+ ProcessDirtyAssets();
+ ProcessLoadedAssets();
+
+ if (m_ram_used > m_max_ram_available)
+ {
+ RemoveAssetsUntilBelowMemoryLimit();
+ }
+
+ if (m_pending_assets.IsEmpty())
+ return;
+
+ if (m_ram_used > m_max_ram_available)
+ return;
+
+ const u64 allowed_memory = m_max_ram_available - m_ram_used;
+ m_asset_loader.ScheduleAssetsToLoad(m_pending_assets.Elements(), allowed_memory);
+}
+
+void CustomResourceManager::ProcessDirtyAssets()
+{
+ decltype(m_dirty_assets) dirty_assets;
+
+ if (const auto lk = std::unique_lock{m_dirty_mutex, std::try_to_lock})
+ std::swap(dirty_assets, m_dirty_assets);
+
+ const auto now = CustomAsset::ClockType::now();
+ for (const auto& asset_id : dirty_assets)
+ {
+ if (const auto it = m_asset_id_to_handle.find(asset_id); it != m_asset_id_to_handle.end())
+ {
+ const auto asset_handle = it->second;
+ AssetData& asset_data = m_asset_handle_to_data[asset_handle];
+ asset_data.load_status = AssetData::LoadStatus::PendingReload;
+ asset_data.load_request_time = now;
+
+ // Asset was reloaded, clear any errors we might have
+ asset_data.has_load_error = false;
+
+ m_pending_assets.InsertAsset(it->second, asset_data.asset.get());
+
+ DEBUG_LOG_FMT(VIDEO, "Dirty asset pending reload: {}", asset_data.asset->GetAssetId());
+ }
+ }
+}
+
+void CustomResourceManager::ProcessLoadedAssets()
+{
+ const auto load_results = m_asset_loader.TakeLoadResults();
+
+ // Update the ram with the change in memory from the loader
+ //
+ // Note: Assets with outstanding reload requests will have
+ // two copies in memory temporarily (the old data stored in
+ // the asset shared_ptr that the resource manager owns, and
+ // the new data loaded from the loader in the asset's shared_ptr)
+ // This temporary duplication will not be reflected in the
+ // resource manager's ram used
+ m_ram_used += load_results.change_in_memory;
+
+ for (const auto& [handle, load_successful] : load_results.asset_handles)
+ {
+ AssetData& asset_data = m_asset_handle_to_data[handle];
+
+ // If we have a reload request that is newer than our loaded time
+ // we need to wait for another reload.
+ if (asset_data.load_request_time > asset_data.asset->GetLastLoadedTime())
+ continue;
+
+ m_pending_assets.RemoveAsset(handle);
+
+ asset_data.load_request_time = {};
+ if (!load_successful)
+ {
+ asset_data.has_load_error = true;
+ }
+ else
+ {
+ m_active_assets.InsertAsset(handle, asset_data.asset.get());
+ asset_data.load_status = AssetData::LoadStatus::LoadFinished;
+ }
+ }
+}
+
+void CustomResourceManager::RemoveAssetsUntilBelowMemoryLimit()
+{
+ const u64 threshold_ram = m_max_ram_available * 8 / 10;
+
+ if (m_ram_used > threshold_ram)
+ {
+ INFO_LOG_FMT(VIDEO, "Memory usage over threshold: {}", UICommon::FormatSize(m_ram_used));
+ }
+
+ // Clear out least recently used resources until
+ // we get safely in our threshold
+ while (m_ram_used > threshold_ram && m_active_assets.Size() > 0)
+ {
+ auto* const asset = m_active_assets.RemoveLowestPriorityAsset();
+
+ AssetData& asset_data = m_asset_handle_to_data[asset->GetHandle()];
+
+ // Remove the resource manager's cached entry with its asset data
+ if (asset_data.type == AssetData::AssetType::TextureData)
+ {
+ m_texture_data_asset_cache.erase(asset->GetAssetId());
+ }
+ // Remove the asset's copy
+ const std::size_t bytes_unloaded = asset_data.asset->Unload();
+ m_ram_used -= bytes_unloaded;
+
+ asset_data.load_status = AssetData::LoadStatus::Unloaded;
+ asset_data.load_request_time = {};
+
+ INFO_LOG_FMT(VIDEO, "Unloading asset: {} ({})", asset_data.asset->GetAssetId(),
+ UICommon::FormatSize(bytes_unloaded));
+ }
+}
+
+} // namespace VideoCommon
diff --git a/Source/Core/VideoCommon/Assets/CustomResourceManager.h b/Source/Core/VideoCommon/Assets/CustomResourceManager.h
new file mode 100644
index 0000000000..6a2b5cbbe3
--- /dev/null
+++ b/Source/Core/VideoCommon/Assets/CustomResourceManager.h
@@ -0,0 +1,215 @@
+// Copyright 2025 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include
+#include