diff --git a/Source/Core/DolphinLib.props b/Source/Core/DolphinLib.props
index 01b74f3c06..bdbd579c6f 100644
--- a/Source/Core/DolphinLib.props
+++ b/Source/Core/DolphinLib.props
@@ -631,6 +631,8 @@
+
+
@@ -1241,6 +1243,8 @@
+
+
diff --git a/Source/Core/VideoCommon/Assets/CustomAsset.cpp b/Source/Core/VideoCommon/Assets/CustomAsset.cpp
new file mode 100644
index 0000000000..545b60e093
--- /dev/null
+++ b/Source/Core/VideoCommon/Assets/CustomAsset.cpp
@@ -0,0 +1,45 @@
+// Copyright 2023 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "VideoCommon/Assets/CustomAsset.h"
+
+namespace VideoCommon
+{
+CustomAsset::CustomAsset(std::shared_ptr library,
+ const CustomAssetLibrary::AssetID& asset_id)
+ : m_owning_library(std::move(library)), m_asset_id(asset_id)
+{
+}
+
+bool CustomAsset::Load()
+{
+ const auto load_information = LoadImpl(m_asset_id);
+ if (load_information.m_bytes_loaded > 0)
+ {
+ m_bytes_loaded = load_information.m_bytes_loaded;
+ m_last_loaded_time = load_information.m_load_time;
+ }
+ return load_information.m_bytes_loaded != 0;
+}
+
+CustomAssetLibrary::TimeType CustomAsset::GetLastWriteTime() const
+{
+ return m_owning_library->GetLastAssetWriteTime(m_asset_id);
+}
+
+const CustomAssetLibrary::TimeType& CustomAsset::GetLastLoadedTime() const
+{
+ return m_last_loaded_time;
+}
+
+const CustomAssetLibrary::AssetID& CustomAsset::GetAssetId() const
+{
+ return m_asset_id;
+}
+
+std::size_t CustomAsset::GetByteSizeInMemory() const
+{
+ return m_bytes_loaded;
+}
+
+} // namespace VideoCommon
diff --git a/Source/Core/VideoCommon/Assets/CustomAsset.h b/Source/Core/VideoCommon/Assets/CustomAsset.h
new file mode 100644
index 0000000000..d3ba5226da
--- /dev/null
+++ b/Source/Core/VideoCommon/Assets/CustomAsset.h
@@ -0,0 +1,81 @@
+// Copyright 2023 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include "Common/CommonTypes.h"
+#include "VideoCommon/Assets/CustomAssetLibrary.h"
+
+#include
+#include
+#include
+
+namespace VideoCommon
+{
+// An abstract class that provides operations for loading
+// data from a 'CustomAssetLibrary'
+class CustomAsset
+{
+public:
+ CustomAsset(std::shared_ptr library,
+ const CustomAssetLibrary::AssetID& asset_id);
+ virtual ~CustomAsset() = default;
+ CustomAsset(const CustomAsset&) = default;
+ CustomAsset(CustomAsset&&) = default;
+ CustomAsset& operator=(const CustomAsset&) = default;
+ CustomAsset& operator=(CustomAsset&&) = default;
+
+ // Loads the asset from the library returning a pass/fail result
+ bool Load();
+
+ // Queries the last time the asset was modified or standard epoch time
+ // if the asset hasn't been modified yet
+ CustomAssetLibrary::TimeType GetLastWriteTime() const;
+
+ // Returns the time that the data was last loaded
+ const CustomAssetLibrary::TimeType& GetLastLoadedTime() const;
+
+ // Returns an id that uniquely identifies this asset
+ const CustomAssetLibrary::AssetID& GetAssetId() const;
+
+ // A rough estimate of how much space this asset
+ // will take in memroy
+ std::size_t GetByteSizeInMemory() const;
+
+protected:
+ const std::shared_ptr m_owning_library;
+
+private:
+ virtual CustomAssetLibrary::LoadInfo LoadImpl(const CustomAssetLibrary::AssetID& asset_id) = 0;
+ CustomAssetLibrary::AssetID m_asset_id;
+ std::size_t m_bytes_loaded = 0;
+ CustomAssetLibrary::TimeType m_last_loaded_time;
+};
+
+// An abstract class that is expected to
+// be the base class for all assets
+// It provides a simple interface for
+// verifying that an asset data of type
+// 'UnderlyingType' is loaded by
+// checking against 'GetData()'
+template
+class CustomLoadableAsset : public CustomAsset
+{
+public:
+ using CustomAsset::CustomAsset;
+
+ const UnderlyingType* GetData() const
+ {
+ std::lock_guard lk(m_lock);
+ if (m_loaded)
+ return &m_data;
+ return nullptr;
+ }
+
+protected:
+ bool m_loaded = false;
+ mutable std::mutex m_lock;
+ UnderlyingType m_data;
+};
+
+} // namespace VideoCommon
diff --git a/Source/Core/VideoCommon/Assets/CustomAssetLibrary.cpp b/Source/Core/VideoCommon/Assets/CustomAssetLibrary.cpp
new file mode 100644
index 0000000000..062f5801e1
--- /dev/null
+++ b/Source/Core/VideoCommon/Assets/CustomAssetLibrary.cpp
@@ -0,0 +1,82 @@
+// Copyright 2023 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "VideoCommon/Assets/CustomAssetLibrary.h"
+
+#include
+
+#include "Common/Logging/Log.h"
+#include "VideoCommon/GraphicsModSystem/Runtime/CustomTextureData.h"
+
+namespace VideoCommon
+{
+namespace
+{
+std::size_t GetAssetSize(const CustomTextureData& data)
+{
+ std::size_t total = 0;
+ for (const auto& level : data.m_levels)
+ {
+ total += level.data.size();
+ }
+ return total;
+}
+} // namespace
+CustomAssetLibrary::LoadInfo CustomAssetLibrary::LoadGameTexture(const AssetID& asset_id,
+ CustomTextureData* data)
+{
+ const auto load_info = LoadTexture(asset_id, data);
+ if (load_info.m_bytes_loaded == 0)
+ return {};
+
+ // Note: 'LoadTexture()' ensures we have a level loaded
+ const auto& first_mip = data->m_levels[0];
+
+ // Verify that each mip level is the correct size (divide by 2 each time).
+ u32 current_mip_width = first_mip.width;
+ u32 current_mip_height = first_mip.height;
+ for (u32 mip_level = 1; mip_level < static_cast(data->m_levels.size()); mip_level++)
+ {
+ if (current_mip_width != 1 || current_mip_height != 1)
+ {
+ current_mip_width = std::max(current_mip_width / 2, 1u);
+ current_mip_height = std::max(current_mip_height / 2, 1u);
+
+ const VideoCommon::CustomTextureData::Level& level = data->m_levels[mip_level];
+ if (current_mip_width == level.width && current_mip_height == level.height)
+ continue;
+
+ ERROR_LOG_FMT(VIDEO,
+ "Invalid custom game texture size {}x{} for texture asset {}. Mipmap level {} "
+ "must be {}x{}.",
+ level.width, level.height, asset_id, mip_level, current_mip_width,
+ current_mip_height);
+ }
+ else
+ {
+ // It is invalid to have more than a single 1x1 mipmap.
+ ERROR_LOG_FMT(VIDEO,
+ "Custom game texture {} has too many 1x1 mipmaps. Skipping extra levels.",
+ asset_id);
+ }
+
+ // Drop this mip level and any others after it.
+ while (data->m_levels.size() > mip_level)
+ data->m_levels.pop_back();
+ }
+
+ // All levels have to have the same format.
+ if (std::any_of(data->m_levels.begin(), data->m_levels.end(),
+ [&first_mip](const VideoCommon::CustomTextureData::Level& l) {
+ return l.format != first_mip.format;
+ }))
+ {
+ ERROR_LOG_FMT(VIDEO, "Custom game texture {} has inconsistent formats across mip levels.",
+ asset_id);
+
+ return {};
+ }
+
+ return load_info;
+}
+} // namespace VideoCommon
diff --git a/Source/Core/VideoCommon/Assets/CustomAssetLibrary.h b/Source/Core/VideoCommon/Assets/CustomAssetLibrary.h
new file mode 100644
index 0000000000..eb78e9f770
--- /dev/null
+++ b/Source/Core/VideoCommon/Assets/CustomAssetLibrary.h
@@ -0,0 +1,49 @@
+// Copyright 2023 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include
+#include
+#include