From fd3af4c5d32a34b27c45b478c74fa1127b6438b9 Mon Sep 17 00:00:00 2001 From: iwubcode Date: Sat, 17 Aug 2019 14:40:58 -0500 Subject: [PATCH] InputCommon: Introducing the "Dynamic Input Texture". Configuration links an emulated input action to an image based on what host key is defined for that emulated input. Specific regions are called out in configuration that mark where to replace an input button with a host key image. --- Source/Core/Common/CommonPaths.h | 1 + Source/Core/Common/FileUtil.cpp | 2 + Source/Core/Common/FileUtil.h | 1 + .../Config/Graphics/AdvancedWidget.cpp | 7 +- Source/Core/InputCommon/CMakeLists.txt | 7 + .../ControllerEmu/ControllerEmu.cpp | 24 ++ .../InputCommon/ControllerEmu/ControllerEmu.h | 4 + .../DynamicInputTextureConfiguration.cpp | 367 ++++++++++++++++++ .../DynamicInputTextureConfiguration.h | 46 +++ .../DynamicInputTextureManager.cpp | 49 +++ .../InputCommon/DynamicInputTextureManager.h | 27 ++ Source/Core/InputCommon/ImageOperations.cpp | 250 ++++++++++++ Source/Core/InputCommon/ImageOperations.h | 65 ++++ Source/Core/InputCommon/InputCommon.vcxproj | 6 + .../InputCommon/InputCommon.vcxproj.filters | 4 + Source/Core/InputCommon/InputConfig.cpp | 7 + Source/Core/InputCommon/InputConfig.h | 6 +- Source/Core/VideoCommon/HiresTextures.cpp | 9 +- Source/Core/VideoCommon/HiresTextures.h | 1 + Source/Core/VideoCommon/RenderBase.cpp | 19 +- Source/Core/VideoCommon/RenderBase.h | 5 + Source/Core/VideoCommon/TextureCacheBase.cpp | 11 + Source/Core/VideoCommon/TextureCacheBase.h | 1 + docs/DynamicInputTextures.md | 204 ++++++++++ 24 files changed, 1114 insertions(+), 9 deletions(-) create mode 100644 Source/Core/InputCommon/DynamicInputTextureConfiguration.cpp create mode 100644 Source/Core/InputCommon/DynamicInputTextureConfiguration.h create mode 100644 Source/Core/InputCommon/DynamicInputTextureManager.cpp create mode 100644 Source/Core/InputCommon/DynamicInputTextureManager.h create mode 100644 Source/Core/InputCommon/ImageOperations.cpp create mode 100644 Source/Core/InputCommon/ImageOperations.h create mode 100644 docs/DynamicInputTextures.md diff --git a/Source/Core/Common/CommonPaths.h b/Source/Core/Common/CommonPaths.h index 46a201d823..a1d87800f4 100644 --- a/Source/Core/Common/CommonPaths.h +++ b/Source/Core/Common/CommonPaths.h @@ -69,6 +69,7 @@ #define WFSROOT_DIR "WFS" #define BACKUP_DIR "Backup" #define RESOURCEPACK_DIR "ResourcePacks" +#define DYNAMICINPUT_DIR "DynamicInputTextures" // This one is only used to remove it if it was present #define SHADERCACHE_LEGACY_DIR "ShaderCache" diff --git a/Source/Core/Common/FileUtil.cpp b/Source/Core/Common/FileUtil.cpp index 3c524ed9d8..6b1cf779e5 100644 --- a/Source/Core/Common/FileUtil.cpp +++ b/Source/Core/Common/FileUtil.cpp @@ -813,6 +813,7 @@ static void RebuildUserDirectories(unsigned int dir_index) s_user_paths[D_WFSROOT_IDX] = s_user_paths[D_USER_IDX] + WFSROOT_DIR DIR_SEP; s_user_paths[D_BACKUP_IDX] = s_user_paths[D_USER_IDX] + BACKUP_DIR DIR_SEP; s_user_paths[D_RESOURCEPACK_IDX] = s_user_paths[D_USER_IDX] + RESOURCEPACK_DIR DIR_SEP; + s_user_paths[D_DYNAMICINPUT_IDX] = s_user_paths[D_LOAD_IDX] + DYNAMICINPUT_DIR DIR_SEP; s_user_paths[F_DOLPHINCONFIG_IDX] = s_user_paths[D_CONFIG_IDX] + DOLPHIN_CONFIG; s_user_paths[F_GCPADCONFIG_IDX] = s_user_paths[D_CONFIG_IDX] + GCPAD_CONFIG; s_user_paths[F_WIIPADCONFIG_IDX] = s_user_paths[D_CONFIG_IDX] + WIIPAD_CONFIG; @@ -880,6 +881,7 @@ static void RebuildUserDirectories(unsigned int dir_index) case D_LOAD_IDX: s_user_paths[D_HIRESTEXTURES_IDX] = s_user_paths[D_LOAD_IDX] + HIRES_TEXTURES_DIR DIR_SEP; + s_user_paths[D_DYNAMICINPUT_IDX] = s_user_paths[D_LOAD_IDX] + DYNAMICINPUT_DIR DIR_SEP; break; } } diff --git a/Source/Core/Common/FileUtil.h b/Source/Core/Common/FileUtil.h index 2c13ae07c0..e593a0541f 100644 --- a/Source/Core/Common/FileUtil.h +++ b/Source/Core/Common/FileUtil.h @@ -54,6 +54,7 @@ enum D_WFSROOT_IDX, D_BACKUP_IDX, D_RESOURCEPACK_IDX, + D_DYNAMICINPUT_IDX, F_DOLPHINCONFIG_IDX, F_GCPADCONFIG_IDX, F_WIIPADCONFIG_IDX, diff --git a/Source/Core/DolphinQt/Config/Graphics/AdvancedWidget.cpp b/Source/Core/DolphinQt/Config/Graphics/AdvancedWidget.cpp index 05ddd8406f..173d1f4b34 100644 --- a/Source/Core/DolphinQt/Config/Graphics/AdvancedWidget.cpp +++ b/Source/Core/DolphinQt/Config/Graphics/AdvancedWidget.cpp @@ -232,9 +232,10 @@ void AdvancedWidget::AddDescriptions() "User/Dump/Textures//. This includes arbitrary base textures if 'Arbitrary " "Mipmap Detection' is enabled in Enhancements.\n\nIf unsure, leave " "this checked."); - static const char TR_LOAD_CUSTOM_TEXTURE_DESCRIPTION[] = QT_TR_NOOP( - "Loads custom textures from User/Load/Textures//.\n\nIf unsure, leave this " - "unchecked."); + static const char TR_LOAD_CUSTOM_TEXTURE_DESCRIPTION[] = + QT_TR_NOOP("Loads custom textures from User/Load/Textures// and " + "User/Load/DynamicInputTextures//.\n\nIf unsure, leave this " + "unchecked."); static const char TR_CACHE_CUSTOM_TEXTURE_DESCRIPTION[] = QT_TR_NOOP( "Caches custom textures to system RAM on startup.\n\nThis can require exponentially " "more RAM but fixes possible stuttering.\n\nIf unsure, leave this unchecked."); diff --git a/Source/Core/InputCommon/CMakeLists.txt b/Source/Core/InputCommon/CMakeLists.txt index d5efe58248..e0b8664a06 100644 --- a/Source/Core/InputCommon/CMakeLists.txt +++ b/Source/Core/InputCommon/CMakeLists.txt @@ -1,4 +1,10 @@ add_library(inputcommon + DynamicInputTextureConfiguration.cpp + DynamicInputTextureConfiguration.h + DynamicInputTextureManager.cpp + DynamicInputTextureManager.h + ImageOperations.cpp + ImageOperations.h InputConfig.cpp InputConfig.h InputProfile.cpp @@ -66,6 +72,7 @@ PUBLIC PRIVATE fmt::fmt + png ) if(WIN32) diff --git a/Source/Core/InputCommon/ControllerEmu/ControllerEmu.cpp b/Source/Core/InputCommon/ControllerEmu/ControllerEmu.cpp index a9c5844b48..8fc54fde06 100644 --- a/Source/Core/InputCommon/ControllerEmu/ControllerEmu.cpp +++ b/Source/Core/InputCommon/ControllerEmu/ControllerEmu.cpp @@ -112,6 +112,12 @@ void EmulatedController::SetDefaultDevice(ciface::Core::DeviceQualifier devq) } } +void EmulatedController::SetDynamicInputTextureManager( + InputCommon::DynamicInputTextureManager* dynamic_input_tex_config_manager) +{ + m_dynamic_input_tex_config_manager = dynamic_input_tex_config_manager; +} + void EmulatedController::LoadConfig(IniFile::Section* sec, const std::string& base) { std::string defdev = GetDefaultDevice().ToString(); @@ -123,6 +129,11 @@ void EmulatedController::LoadConfig(IniFile::Section* sec, const std::string& ba for (auto& cg : groups) cg->LoadConfig(sec, defdev, base); + + if (base.empty()) + { + GenerateTextures(sec); + } } void EmulatedController::SaveConfig(IniFile::Section* sec, const std::string& base) @@ -133,6 +144,11 @@ void EmulatedController::SaveConfig(IniFile::Section* sec, const std::string& ba for (auto& ctrlGroup : groups) ctrlGroup->SaveConfig(sec, defdev, base); + + if (base.empty()) + { + GenerateTextures(sec); + } } void EmulatedController::LoadDefaults(const ControllerInterface& ciface) @@ -147,4 +163,12 @@ void EmulatedController::LoadDefaults(const ControllerInterface& ciface) SetDefaultDevice(default_device_string); } } + +void EmulatedController::GenerateTextures(IniFile::Section* sec) +{ + if (m_dynamic_input_tex_config_manager) + { + m_dynamic_input_tex_config_manager->GenerateTextures(sec, GetName()); + } +} } // namespace ControllerEmu diff --git a/Source/Core/InputCommon/ControllerEmu/ControllerEmu.h b/Source/Core/InputCommon/ControllerEmu/ControllerEmu.h index b6808f1c0b..bcc25886f3 100644 --- a/Source/Core/InputCommon/ControllerEmu/ControllerEmu.h +++ b/Source/Core/InputCommon/ControllerEmu/ControllerEmu.h @@ -17,6 +17,7 @@ #include "Common/MathUtil.h" #include "InputCommon/ControlReference/ExpressionParser.h" #include "InputCommon/ControllerInterface/Device.h" +#include "InputCommon/DynamicInputTextureManager.h" class ControllerInterface; @@ -182,6 +183,7 @@ public: const ciface::Core::DeviceQualifier& GetDefaultDevice() const; void SetDefaultDevice(const std::string& device); void SetDefaultDevice(ciface::Core::DeviceQualifier devq); + void SetDynamicInputTextureManager(InputCommon::DynamicInputTextureManager*); void UpdateReferences(const ControllerInterface& devi); void UpdateSingleControlReference(const ControllerInterface& devi, ControlReference* ref); @@ -224,6 +226,8 @@ protected: void UpdateReferences(ciface::ExpressionParser::ControlEnvironment& env); private: + void GenerateTextures(IniFile::Section* sec); + InputCommon::DynamicInputTextureManager* m_dynamic_input_tex_config_manager = nullptr; ciface::Core::DeviceQualifier m_default_device; bool m_default_device_is_connected{false}; }; diff --git a/Source/Core/InputCommon/DynamicInputTextureConfiguration.cpp b/Source/Core/InputCommon/DynamicInputTextureConfiguration.cpp new file mode 100644 index 0000000000..ad200e70a4 --- /dev/null +++ b/Source/Core/InputCommon/DynamicInputTextureConfiguration.cpp @@ -0,0 +1,367 @@ +// Copyright 2019 Dolphin Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#include "InputCommon/DynamicInputTextureConfiguration.h" + +#include +#include +#include + +#include +#include + +#include "Common/CommonPaths.h" +#include "Common/File.h" +#include "Common/FileUtil.h" +#include "Common/Logging/Log.h" +#include "Common/StringUtil.h" +#include "Core/ConfigManager.h" +#include "InputCommon/ControllerEmu/ControllerEmu.h" +#include "InputCommon/ImageOperations.h" +#include "VideoCommon/RenderBase.h" + +namespace +{ +std::string GetStreamAsString(std::ifstream& stream) +{ + std::stringstream ss; + ss << stream.rdbuf(); + return ss.str(); +} +} // namespace + +namespace InputCommon +{ +DynamicInputTextureConfiguration::DynamicInputTextureConfiguration(const std::string& json_file) +{ + std::ifstream json_stream; + File::OpenFStream(json_stream, json_file, std::ios_base::in); + if (!json_stream.is_open()) + { + ERROR_LOG(VIDEO, "Failed to load dynamic input json file '%s'", json_file.c_str()); + m_valid = false; + return; + } + + picojson::value out; + const auto error = picojson::parse(out, GetStreamAsString(json_stream)); + + if (!error.empty()) + { + ERROR_LOG(VIDEO, "Failed to load dynamic input json file '%s' due to parse error: %s", + json_file.c_str(), error.c_str()); + m_valid = false; + return; + } + + const picojson::value& output_textures_json = out.get("output_textures"); + if (!output_textures_json.is()) + { + ERROR_LOG(VIDEO, + "Failed to load dynamic input json file '%s' because 'output_textures' is missing or " + "was not of type object", + json_file.c_str()); + m_valid = false; + return; + } + + const picojson::value& preserve_aspect_ratio_json = out.get("preserve_aspect_ratio"); + + bool preserve_aspect_ratio = true; + if (preserve_aspect_ratio_json.is()) + { + preserve_aspect_ratio = preserve_aspect_ratio_json.get(); + } + + const picojson::value& generated_folder_name_json = out.get("generated_folder_name"); + + const std::string& game_id = SConfig::GetInstance().GetGameID(); + std::string generated_folder_name = fmt::format("{}_Generated", game_id); + if (generated_folder_name_json.is()) + { + generated_folder_name = generated_folder_name_json.get(); + } + + const picojson::value& default_host_controls_json = out.get("default_host_controls"); + picojson::object default_host_controls; + if (default_host_controls_json.is()) + { + default_host_controls = default_host_controls_json.get(); + } + + const auto output_textures = output_textures_json.get(); + for (auto& [name, data] : output_textures) + { + DynamicInputTextureData texture_data; + texture_data.m_hires_texture_name = name; + + // Required fields + const picojson::value& image = data.get("image"); + const picojson::value& emulated_controls = data.get("emulated_controls"); + + if (!image.is() || !emulated_controls.is()) + { + ERROR_LOG(VIDEO, + "Failed to load dynamic input json file '%s' because required fields " + "'image', or 'emulated_controls' are either " + "missing or the incorrect type", + json_file.c_str()); + m_valid = false; + return; + } + + texture_data.m_image_name = image.to_str(); + texture_data.m_preserve_aspect_ratio = preserve_aspect_ratio; + texture_data.m_generated_folder_name = generated_folder_name; + + SplitPath(json_file, &m_base_path, nullptr, nullptr); + + const std::string image_full_path = m_base_path + texture_data.m_image_name; + if (!File::Exists(image_full_path)) + { + ERROR_LOG(VIDEO, + "Failed to load dynamic input json file '%s' because the image '%s' " + "could not be loaded", + json_file.c_str(), image_full_path.c_str()); + m_valid = false; + return; + } + + const auto& emulated_controls_json = emulated_controls.get(); + for (auto& [emulated_controller_name, map] : emulated_controls_json) + { + if (!map.is()) + { + ERROR_LOG(VIDEO, + "Failed to load dynamic input json file '%s' because 'emulated_controls' " + "map key '%s' is incorrect type. Expected map ", + json_file.c_str(), emulated_controller_name.c_str()); + m_valid = false; + return; + } + + auto& key_to_regions = texture_data.m_emulated_controllers[emulated_controller_name]; + for (auto& [emulated_control, regions_array] : map.get()) + { + if (!regions_array.is()) + { + ERROR_LOG(VIDEO, + "Failed to load dynamic input json file '%s' because emulated controller '%s' " + "key '%s' has incorrect value type. Expected array ", + json_file.c_str(), emulated_controller_name.c_str(), emulated_control.c_str()); + m_valid = false; + return; + } + + std::vector region_rects; + for (auto& region : regions_array.get()) + { + Rect r; + if (!region.is()) + { + ERROR_LOG( + VIDEO, + "Failed to load dynamic input json file '%s' because emulated controller '%s' " + "key '%s' has a region with the incorrect type. Expected array ", + json_file.c_str(), emulated_controller_name.c_str(), emulated_control.c_str()); + m_valid = false; + return; + } + + auto region_offsets = region.get(); + + if (region_offsets.size() != 4) + { + ERROR_LOG( + VIDEO, + "Failed to load dynamic input json file '%s' because emulated controller '%s' " + "key '%s' has a region that does not have 4 offsets (left, top, right, " + "bottom).", + json_file.c_str(), emulated_controller_name.c_str(), emulated_control.c_str()); + m_valid = false; + return; + } + + if (!std::all_of(region_offsets.begin(), region_offsets.end(), + [](picojson::value val) { return val.is(); })) + { + ERROR_LOG( + VIDEO, + "Failed to load dynamic input json file '%s' because emulated controller '%s' " + "key '%s' has a region that has the incorrect offset type.", + json_file.c_str(), emulated_controller_name.c_str(), emulated_control.c_str()); + m_valid = false; + return; + } + + r.left = static_cast(region_offsets[0].get()); + r.top = static_cast(region_offsets[1].get()); + r.right = static_cast(region_offsets[2].get()); + r.bottom = static_cast(region_offsets[3].get()); + region_rects.push_back(r); + } + key_to_regions.insert_or_assign(emulated_control, std::move(region_rects)); + } + } + + // Default to the default controls but overwrite if the creator + // has provided something specific + picojson::object host_controls = default_host_controls; + const picojson::value& host_controls_json = data.get("host_controls"); + if (host_controls_json.is()) + { + host_controls = host_controls_json.get(); + } + + if (host_controls.empty()) + { + ERROR_LOG(VIDEO, + "Failed to load dynamic input json file '%s' because field " + "'host_controls' is missing ", + json_file.c_str()); + m_valid = false; + return; + } + + for (auto& [host_device, map] : host_controls) + { + if (!map.is()) + { + ERROR_LOG(VIDEO, + "Failed to load dynamic input json file '%s' because 'host_controls' " + "map key '%s' is incorrect type ", + json_file.c_str(), host_device.c_str()); + m_valid = false; + return; + } + auto& host_control_to_imagename = texture_data.m_host_devices[host_device]; + for (auto& [host_control, image_name] : map.get()) + { + host_control_to_imagename.insert_or_assign(host_control, image_name.to_str()); + } + } + + m_dynamic_input_textures.emplace_back(std::move(texture_data)); + } +} + +DynamicInputTextureConfiguration::~DynamicInputTextureConfiguration() = default; + +void DynamicInputTextureConfiguration::GenerateTextures(const IniFile::Section* sec, + const std::string& controller_name) const +{ + bool any_dirty = false; + for (const auto& texture_data : m_dynamic_input_textures) + { + any_dirty |= GenerateTexture(sec, controller_name, texture_data); + } + + if (!any_dirty) + return; + if (!g_renderer) + return; + g_renderer->ForceReloadTextures(); +} + +bool DynamicInputTextureConfiguration::GenerateTexture( + const IniFile::Section* sec, const std::string& controller_name, + const DynamicInputTextureData& texture_data) const +{ + std::string device_name; + if (!sec->Get("Device", &device_name)) + { + return false; + } + + auto emulated_controls_iter = texture_data.m_emulated_controllers.find(controller_name); + if (emulated_controls_iter == texture_data.m_emulated_controllers.end()) + { + return false; + } + + bool device_found = true; + auto host_devices_iter = texture_data.m_host_devices.find(device_name); + if (host_devices_iter == texture_data.m_host_devices.end()) + { + // If we fail to find our exact device, + // it's possible the creator doesn't care (single player game) + // and has used a wildcard for any device + host_devices_iter = texture_data.m_host_devices.find(""); + + if (host_devices_iter == texture_data.m_host_devices.end()) + { + device_found = false; + } + } + + // Load image copy + auto base_image = LoadImage(m_base_path + texture_data.m_image_name); + bool dirty = false; + + for (auto& [emulated_key, rects] : emulated_controls_iter->second) + { + std::string host_key = ""; + sec->Get(emulated_key, &host_key); + + if (!device_found) + { + // If we get here, that means the controller is set to a + // device not exposed to the pack + continue; + } + + const auto input_image_iter = host_devices_iter->second.find(host_key); + if (input_image_iter != host_devices_iter->second.end()) + { + const auto host_key_image = LoadImage(m_base_path + input_image_iter->second); + + for (const auto& rect : rects) + { + InputCommon::ImagePixelData pixel_data; + if (host_key_image->width == rect.GetWidth() && host_key_image->height == rect.GetHeight()) + { + pixel_data = *host_key_image; + } + else if (texture_data.m_preserve_aspect_ratio) + { + pixel_data = ResizeKeepAspectRatio(ResizeMode::Nearest, *host_key_image, rect.GetWidth(), + rect.GetHeight(), Pixel{0, 0, 0, 0}); + } + else + { + pixel_data = + Resize(ResizeMode::Nearest, *host_key_image, rect.GetWidth(), rect.GetHeight()); + } + + CopyImageRegion(pixel_data, *base_image, Rect{0, 0, rect.GetWidth(), rect.GetHeight()}, + rect); + dirty = true; + } + } + } + + if (dirty) + { + const std::string& game_id = SConfig::GetInstance().GetGameID(); + const auto hi_res_folder = + File::GetUserPath(D_HIRESTEXTURES_IDX) + texture_data.m_generated_folder_name; + if (!File::IsDirectory(hi_res_folder)) + { + File::CreateDir(hi_res_folder); + } + WriteImage(hi_res_folder + DIR_SEP + texture_data.m_hires_texture_name, *base_image); + + const auto game_id_folder = hi_res_folder + DIR_SEP + "gameids"; + if (!File::IsDirectory(game_id_folder)) + { + File::CreateDir(game_id_folder); + } + File::CreateEmptyFile(game_id_folder + DIR_SEP + game_id + ".txt"); + + return true; + } + + return false; +} +} // namespace InputCommon diff --git a/Source/Core/InputCommon/DynamicInputTextureConfiguration.h b/Source/Core/InputCommon/DynamicInputTextureConfiguration.h new file mode 100644 index 0000000000..15e4d37129 --- /dev/null +++ b/Source/Core/InputCommon/DynamicInputTextureConfiguration.h @@ -0,0 +1,46 @@ +// Copyright 2019 Dolphin Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include + +#include "Common/CommonTypes.h" +#include "Common/IniFile.h" +#include "InputCommon/ImageOperations.h" + +namespace InputCommon +{ +class DynamicInputTextureConfiguration +{ +public: + explicit DynamicInputTextureConfiguration(const std::string& json_file); + ~DynamicInputTextureConfiguration(); + void GenerateTextures(const IniFile::Section* sec, const std::string& controller_name) const; + +private: + struct DynamicInputTextureData + { + std::string m_image_name; + std::string m_hires_texture_name; + std::string m_generated_folder_name; + + using EmulatedKeyToRegionsMap = std::unordered_map>; + std::unordered_map m_emulated_controllers; + + using HostKeyToImagePath = std::unordered_map; + std::unordered_map m_host_devices; + bool m_preserve_aspect_ratio = true; + }; + + bool GenerateTexture(const IniFile::Section* sec, const std::string& controller_name, + const DynamicInputTextureData& texture_data) const; + + std::vector m_dynamic_input_textures; + std::string m_base_path; + bool m_valid = true; +}; +} // namespace InputCommon diff --git a/Source/Core/InputCommon/DynamicInputTextureManager.cpp b/Source/Core/InputCommon/DynamicInputTextureManager.cpp new file mode 100644 index 0000000000..437d083ee9 --- /dev/null +++ b/Source/Core/InputCommon/DynamicInputTextureManager.cpp @@ -0,0 +1,49 @@ +// Copyright 2019 Dolphin Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#include "InputCommon/DynamicInputTextureManager.h" + +#include + +#include "Common/CommonPaths.h" +#include "Common/FileSearch.h" +#include "Common/FileUtil.h" +#include "Core/ConfigManager.h" + +#include "InputCommon/DynamicInputTextureConfiguration.h" +#include "VideoCommon/HiresTextures.h" + +namespace InputCommon +{ +DynamicInputTextureManager::DynamicInputTextureManager() = default; + +DynamicInputTextureManager::~DynamicInputTextureManager() = default; + +void DynamicInputTextureManager::Load() +{ + m_configuration.clear(); + + const std::string& game_id = SConfig::GetInstance().GetGameID(); + const std::set dynamic_input_directories = + GetTextureDirectoriesWithGameId(File::GetUserPath(D_DYNAMICINPUT_IDX), game_id); + + for (const auto& dynamic_input_directory : dynamic_input_directories) + { + const auto json_files = Common::DoFileSearch({dynamic_input_directory}, {".json"}); + for (auto& file : json_files) + { + m_configuration.emplace_back(file); + } + } +} + +void DynamicInputTextureManager::GenerateTextures(const IniFile::Section* sec, + const std::string& controller_name) +{ + for (const auto& configuration : m_configuration) + { + configuration.GenerateTextures(sec, controller_name); + } +} +} // namespace InputCommon diff --git a/Source/Core/InputCommon/DynamicInputTextureManager.h b/Source/Core/InputCommon/DynamicInputTextureManager.h new file mode 100644 index 0000000000..cd07854928 --- /dev/null +++ b/Source/Core/InputCommon/DynamicInputTextureManager.h @@ -0,0 +1,27 @@ +// Copyright 2019 Dolphin Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#pragma once + +#include "Common/IniFile.h" + +#include +#include + +namespace InputCommon +{ +class DynamicInputTextureConfiguration; +class DynamicInputTextureManager +{ +public: + DynamicInputTextureManager(); + ~DynamicInputTextureManager(); + void Load(); + void GenerateTextures(const IniFile::Section* sec, const std::string& controller_name); + +private: + std::vector m_configuration; + std::string m_config_type; +}; +} // namespace InputCommon diff --git a/Source/Core/InputCommon/ImageOperations.cpp b/Source/Core/InputCommon/ImageOperations.cpp new file mode 100644 index 0000000000..348fcc0a98 --- /dev/null +++ b/Source/Core/InputCommon/ImageOperations.cpp @@ -0,0 +1,250 @@ +// Copyright 2019 Dolphin Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#include "InputCommon/ImageOperations.h" + +#include +#include +#include +#include + +#include + +#include "Common/File.h" +#include "Common/FileUtil.h" +#include "Common/Image.h" + +namespace InputCommon +{ +namespace +{ +Pixel SampleNearest(const ImagePixelData& src, double u, double v) +{ + const u32 x = std::clamp(static_cast(u * src.width), 0u, src.width - 1); + const u32 y = std::clamp(static_cast(v * src.height), 0u, src.height - 1); + return src.pixels[x + y * src.width]; +} +} // namespace + +void CopyImageRegion(const ImagePixelData& src, ImagePixelData& dst, const Rect& src_region, + const Rect& dst_region) +{ + if (src_region.GetWidth() != dst_region.GetWidth() || + src_region.GetHeight() != dst_region.GetHeight()) + { + return; + } + + for (u32 x = 0; x < dst_region.GetWidth(); x++) + { + for (u32 y = 0; y < dst_region.GetHeight(); y++) + { + dst.pixels[(y + dst_region.top) * dst.width + x + dst_region.left] = + src.pixels[(y + src_region.top) * src.width + x + src_region.left]; + } + } +} + +std::optional LoadImage(const std::string& path) +{ + File::IOFile file; + file.Open(path, "rb"); + std::vector buffer(file.GetSize()); + file.ReadBytes(buffer.data(), file.GetSize()); + + ImagePixelData image; + std::vector data; + if (!Common::LoadPNG(buffer, &data, &image.width, &image.height)) + return std::nullopt; + + image.pixels.resize(image.width * image.height); + for (u32 x = 0; x < image.width; x++) + { + for (u32 y = 0; y < image.height; y++) + { + const u32 index = y * image.width + x; + const auto pixel = + Pixel{data[index * 4], data[index * 4 + 1], data[index * 4 + 2], data[index * 4 + 3]}; + image.pixels[index] = pixel; + } + } + + return image; +} + +// For Visual Studio, ignore the error caused by the 'setjmp' call +#ifdef _MSC_VER +#pragma warning(push) +#pragma warning(disable : 4611) +#endif + +bool WriteImage(const std::string& path, const ImagePixelData& image) +{ + bool success = false; + char title[] = "Dynamic Input Texture"; + char title_key[] = "Title"; + png_structp png_ptr = nullptr; + png_infop info_ptr = nullptr; + std::vector buffer; + + // Open file for writing (binary mode) + File::IOFile fp(path, "wb"); + if (!fp.IsOpen()) + { + goto finalise; + } + + // Initialize write structure + png_ptr = png_create_write_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr); + if (png_ptr == nullptr) + { + goto finalise; + } + + // Initialize info structure + info_ptr = png_create_info_struct(png_ptr); + if (info_ptr == nullptr) + { + goto finalise; + } + + // Classical libpng error handling uses longjmp to do C-style unwind. + // Modern libpng does support a user callback, but it's required to operate + // in the same way (just gives a chance to do stuff before the longjmp). + // Instead of futzing with it, we use gotos specifically so the compiler + // will still generate proper destructor calls for us (hopefully). + // We also do not use any local variables outside the region longjmp may + // have been called from if they were modified inside that region (they + // would need to be volatile). + if (setjmp(png_jmpbuf(png_ptr))) + { + goto finalise; + } + + // Begin region which may call longjmp + + png_init_io(png_ptr, fp.GetHandle()); + + // Write header (8 bit color depth) + png_set_IHDR(png_ptr, info_ptr, image.width, image.height, 8, PNG_COLOR_TYPE_RGB_ALPHA, + PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_BASE, PNG_FILTER_TYPE_BASE); + + png_text title_text; + title_text.compression = PNG_TEXT_COMPRESSION_NONE; + title_text.key = title_key; + title_text.text = title; + png_set_text(png_ptr, info_ptr, &title_text, 1); + + png_write_info(png_ptr, info_ptr); + + buffer.resize(image.width * 4); + + // Write image data + for (u32 y = 0; y < image.height; ++y) + { + for (u32 x = 0; x < image.width; x++) + { + const auto index = x + y * image.width; + const auto pixel = image.pixels[index]; + + const auto buffer_index = 4 * x; + buffer[buffer_index] = pixel.r; + buffer[buffer_index + 1] = pixel.g; + buffer[buffer_index + 2] = pixel.b; + buffer[buffer_index + 3] = pixel.a; + } + + // The old API uses u8* instead of const u8*. It doesn't write + // to this pointer, but to fit the API, we have to drop the const qualifier. + png_write_row(png_ptr, const_cast(buffer.data())); + } + + // End write + png_write_end(png_ptr, nullptr); + + // End region which may call longjmp + + success = true; + +finalise: + if (info_ptr != nullptr) + png_free_data(png_ptr, info_ptr, PNG_FREE_ALL, -1); + if (png_ptr != nullptr) + png_destroy_write_struct(&png_ptr, nullptr); + + return success; +} + +#ifdef _MSC_VER +#pragma warning(pop) +#endif + +ImagePixelData Resize(ResizeMode mode, const ImagePixelData& src, u32 new_width, u32 new_height) +{ + ImagePixelData result(new_width, new_height); + + for (u32 x = 0; x < new_width; x++) + { + const double u = x / static_cast(new_width - 1); + for (u32 y = 0; y < new_height; y++) + { + const double v = y / static_cast(new_height - 1); + + switch (mode) + { + case ResizeMode::Nearest: + result.pixels[y * new_width + x] = SampleNearest(src, u, v); + break; + } + } + } + + return result; +} + +ImagePixelData ResizeKeepAspectRatio(ResizeMode mode, const ImagePixelData& src, u32 new_width, + u32 new_height, const Pixel& background_color) +{ + ImagePixelData result(new_width, new_height, background_color); + + const double corrected_height = new_width * (src.height / static_cast(src.width)); + const double corrected_width = new_height * (src.width / static_cast(src.height)); + // initially no borders + u32 top = 0; + u32 left = 0; + + ImagePixelData resized; + if (corrected_height <= new_height) + { + // Handle vertical padding + + const int diff = new_height - std::trunc(corrected_height); + top = diff / 2; + if (diff % 2 != 0) + { + // If the difference is odd, we need to have one side be slightly larger + top += 1; + } + resized = Resize(mode, src, new_width, corrected_height); + } + else + { + // Handle horizontal padding + + const int diff = new_width - std::trunc(corrected_width); + left = diff / 2; + if (diff % 2 != 0) + { + // If the difference is odd, we need to have one side be slightly larger + left += 1; + } + resized = Resize(mode, src, corrected_width, new_height); + } + CopyImageRegion(resized, result, Rect{0, 0, resized.width, resized.height}, + Rect{left, top, left + resized.width, top + resized.height}); + + return result; +} + +} // namespace InputCommon diff --git a/Source/Core/InputCommon/ImageOperations.h b/Source/Core/InputCommon/ImageOperations.h new file mode 100644 index 0000000000..28d3859ff5 --- /dev/null +++ b/Source/Core/InputCommon/ImageOperations.h @@ -0,0 +1,65 @@ +// Copyright 2019 Dolphin Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include + +#include "Common/CommonTypes.h" +#include "Common/MathUtil.h" +#include "Common/Matrix.h" + +namespace InputCommon +{ +struct Pixel +{ + u8 r = 0; + u8 g = 0; + u8 b = 0; + u8 a = 0; + + bool operator==(const Pixel& o) const { return r == o.r && g == o.g && b == o.b && a == o.a; } + bool operator!=(const Pixel& o) const { return !(o == *this); } +}; + +using Point = Common::TVec2; +using Rect = MathUtil::Rectangle; + +struct ImagePixelData +{ + ImagePixelData() = default; + + explicit ImagePixelData(std::vector image_pixels, u32 width, u32 height) + : pixels(std::move(image_pixels)), width(width), height(height) + { + } + + explicit ImagePixelData(u32 width, u32 height, const Pixel& default_color = Pixel{0, 0, 0, 0}) + : pixels(width * height, default_color), width(width), height(height) + { + } + std::vector pixels; + u32 width = 0; + u32 height = 0; +}; + +void CopyImageRegion(const ImagePixelData& src, ImagePixelData& dst, const Rect& src_region, + const Rect& dst_region); + +std::optional LoadImage(const std::string& path); + +bool WriteImage(const std::string& path, const ImagePixelData& image); + +enum class ResizeMode +{ + Nearest, +}; + +ImagePixelData Resize(ResizeMode mode, const ImagePixelData& src, u32 new_width, u32 new_height); + +ImagePixelData ResizeKeepAspectRatio(ResizeMode mode, const ImagePixelData& src, u32 new_width, + u32 new_height, const Pixel& background_color); +} // namespace InputCommon diff --git a/Source/Core/InputCommon/InputCommon.vcxproj b/Source/Core/InputCommon/InputCommon.vcxproj index b451760354..8cd9f05963 100644 --- a/Source/Core/InputCommon/InputCommon.vcxproj +++ b/Source/Core/InputCommon/InputCommon.vcxproj @@ -50,7 +50,10 @@ + + + @@ -91,8 +94,11 @@ + + + diff --git a/Source/Core/InputCommon/InputCommon.vcxproj.filters b/Source/Core/InputCommon/InputCommon.vcxproj.filters index 671c2b077d..17f6cdf489 100644 --- a/Source/Core/InputCommon/InputCommon.vcxproj.filters +++ b/Source/Core/InputCommon/InputCommon.vcxproj.filters @@ -138,6 +138,8 @@ ControllerInterface\DualShockUDPClient + + @@ -250,6 +252,8 @@ ControllerInterface\DualShockUDPClient + + diff --git a/Source/Core/InputCommon/InputConfig.cpp b/Source/Core/InputCommon/InputConfig.cpp index 0a046211b0..501e75d958 100644 --- a/Source/Core/InputCommon/InputConfig.cpp +++ b/Source/Core/InputCommon/InputConfig.cpp @@ -39,6 +39,8 @@ bool InputConfig::LoadConfig(bool isGC) std::string ir_values[3]; #endif + m_dynamic_input_tex_config_manager.Load(); + if (SConfig::GetInstance().GetGameID() != "00000000") { std::string type; @@ -191,6 +193,11 @@ void InputConfig::UnregisterHotplugCallback() g_controller_interface.UnregisterDevicesChangedCallback(m_hotplug_callback_handle); } +void InputConfig::OnControllerCreated(ControllerEmu::EmulatedController& controller) +{ + controller.SetDynamicInputTextureManager(&m_dynamic_input_tex_config_manager); +} + bool InputConfig::IsControllerControlledByGamepadDevice(int index) const { if (static_cast(index) >= m_controllers.size()) diff --git a/Source/Core/InputCommon/InputConfig.h b/Source/Core/InputCommon/InputConfig.h index 37d6e06b96..3d0a60dd73 100644 --- a/Source/Core/InputCommon/InputConfig.h +++ b/Source/Core/InputCommon/InputConfig.h @@ -10,6 +10,7 @@ #include #include "InputCommon/ControllerInterface/ControllerInterface.h" +#include "InputCommon/DynamicInputTextureManager.h" namespace ControllerEmu { @@ -30,7 +31,8 @@ public: template void CreateController(Args&&... args) { - m_controllers.emplace_back(std::make_unique(std::forward(args)...)); + OnControllerCreated( + *m_controllers.emplace_back(std::make_unique(std::forward(args)...))); } ControllerEmu::EmulatedController* GetController(int index); @@ -47,9 +49,11 @@ public: void UnregisterHotplugCallback(); private: + void OnControllerCreated(ControllerEmu::EmulatedController& controller); ControllerInterface::HotplugCallbackHandle m_hotplug_callback_handle; std::vector> m_controllers; const std::string m_ini_name; const std::string m_gui_name; const std::string m_profile_name; + InputCommon::DynamicInputTextureManager m_dynamic_input_tex_config_manager; }; diff --git a/Source/Core/VideoCommon/HiresTextures.cpp b/Source/Core/VideoCommon/HiresTextures.cpp index 92ba7ff9e6..d1ced9e307 100644 --- a/Source/Core/VideoCommon/HiresTextures.cpp +++ b/Source/Core/VideoCommon/HiresTextures.cpp @@ -76,8 +76,7 @@ void HiresTexture::Update() if (!g_ActiveConfig.bHiresTextures) { - s_textureMap.clear(); - s_textureCache.clear(); + Clear(); return; } @@ -146,6 +145,12 @@ void HiresTexture::Update() } } +void HiresTexture::Clear() +{ + s_textureMap.clear(); + s_textureCache.clear(); +} + void HiresTexture::Prefetch() { Common::SetCurrentThreadName("Prefetcher"); diff --git a/Source/Core/VideoCommon/HiresTextures.h b/Source/Core/VideoCommon/HiresTextures.h index 2112218f98..7adabbf94e 100644 --- a/Source/Core/VideoCommon/HiresTextures.h +++ b/Source/Core/VideoCommon/HiresTextures.h @@ -22,6 +22,7 @@ class HiresTexture public: static void Init(); static void Update(); + static void Clear(); static void Shutdown(); static std::shared_ptr Search(const u8* texture, size_t texture_size, diff --git a/Source/Core/VideoCommon/RenderBase.cpp b/Source/Core/VideoCommon/RenderBase.cpp index a60f9b9f91..4777314194 100644 --- a/Source/Core/VideoCommon/RenderBase.cpp +++ b/Source/Core/VideoCommon/RenderBase.cpp @@ -1138,6 +1138,11 @@ void Renderer::EndUIFrame() BeginImGuiFrame(); } +void Renderer::ForceReloadTextures() +{ + m_force_reload_textures.Set(); +} + // Heuristic to detect if a GameCube game is in 16:9 anamorphic widescreen mode. void Renderer::UpdateWidescreenHeuristic() { @@ -1302,9 +1307,17 @@ void Renderer::Swap(u32 xfb_addr, u32 fb_width, u32 fb_stride, u32 fb_height, u6 // state changes the specialized shader will not take over. g_vertex_manager->InvalidatePipelineObject(); - // Flush any outstanding EFB copies to RAM, in case the game is running at an uncapped frame - // rate and not waiting for vblank. Otherwise, we'd end up with a huge list of pending copies. - g_texture_cache->FlushEFBCopies(); + if (m_force_reload_textures.TestAndClear()) + { + g_texture_cache->ForceReload(); + } + else + { + // Flush any outstanding EFB copies to RAM, in case the game is running at an uncapped frame + // rate and not waiting for vblank. Otherwise, we'd end up with a huge list of pending + // copies. + g_texture_cache->FlushEFBCopies(); + } if (!is_duplicate_frame) { diff --git a/Source/Core/VideoCommon/RenderBase.h b/Source/Core/VideoCommon/RenderBase.h index 3b6de06fd8..4044af8e0b 100644 --- a/Source/Core/VideoCommon/RenderBase.h +++ b/Source/Core/VideoCommon/RenderBase.h @@ -259,6 +259,9 @@ public: void BeginUIFrame(); void EndUIFrame(); + // Will forcibly reload all textures on the next swap + void ForceReloadTextures(); + protected: // Bitmask containing information about which configuration has changed for the backend. enum ConfigChangeBits : u32 @@ -410,6 +413,8 @@ private: void FinishFrameData(); std::unique_ptr m_netplay_chat_ui; + + Common::Flag m_force_reload_textures; }; extern std::unique_ptr g_renderer; diff --git a/Source/Core/VideoCommon/TextureCacheBase.cpp b/Source/Core/VideoCommon/TextureCacheBase.cpp index fdf3c0e6b3..ec54f59d9a 100644 --- a/Source/Core/VideoCommon/TextureCacheBase.cpp +++ b/Source/Core/VideoCommon/TextureCacheBase.cpp @@ -137,6 +137,17 @@ void TextureCacheBase::Invalidate() texture_pool.clear(); } +void TextureCacheBase::ForceReload() +{ + Invalidate(); + + // Clear all current hires textures, they are invalid + HiresTexture::Clear(); + + // Load fresh + HiresTexture::Update(); +} + void TextureCacheBase::OnConfigChanged(const VideoConfig& config) { if (config.bHiresTextures != backup_config.hires_textures || diff --git a/Source/Core/VideoCommon/TextureCacheBase.h b/Source/Core/VideoCommon/TextureCacheBase.h index e8ecf5b6fe..9245f7bf3f 100644 --- a/Source/Core/VideoCommon/TextureCacheBase.h +++ b/Source/Core/VideoCommon/TextureCacheBase.h @@ -205,6 +205,7 @@ public: bool Initialize(); void OnConfigChanged(const VideoConfig& config); + void ForceReload(); // Removes textures which aren't used for more than TEXTURE_KILL_THRESHOLD frames, // frameCount is the current frame number. diff --git a/docs/DynamicInputTextures.md b/docs/DynamicInputTextures.md new file mode 100644 index 0000000000..770bb1daba --- /dev/null +++ b/docs/DynamicInputTextures.md @@ -0,0 +1,204 @@ +# Dolphin Dynamic Input Textures Specification (v1) + +## Format +Dynamic Input Textures are generated textures based on a user's input formed from a group of png files and json files. + +``` +\__ Dolphin User Directory + \__ Load (Directory) + \__ DynamicInputTextures (Directory) + \__ FOLDER (Directory) + \__ PNG and JSON GO HERE +``` + +``FOLDER`` can be one or multiple directories which are named after: +* a complete Game ID (e.g. ``SMNE01`` for "New Super Mario Bros. Wii (NTSC)") +* one without a region (e.g. ``SMN`` for "New Super Mario Bros. Wii (All regions)"). +* Any folder name but with an empty ``.txt`` underneath it + +## How to enable + +Place the files in the format above and ensure that "Load Custom Textures" is enabled under the advanced tab of the graphics settings. + +### PNG files + +At a minimum two images are required to support the generation and any number of 'button' images. These need to be in PNG format. + +### JSON files + +You need at least a single json file that describes the generation parameters. You may have multiple JSON files if you prefer that from an organizational standpoint. + +#### Possible fields in the JSON for a texture + +In each json, one or more generated textures can be specified. Each of those textures can have the following fields: + +|Identifier |Required | Since | +|-------------------------|---------|-------| +|``image`` | **Yes** | v1 | +|``emulated_controls`` | **Yes** | v1 | +|``host_controls`` | No | v1 | + +*image* - the image that has the input buttons you wish to replace, can be upscaled/redrawn if desired. + +*emulated_controls* - a map of emulated devices (ex: ``Wiimote1``, ``GCPad2``) each with their own section of emulated buttons that map to an array of "regions". Each region is a rectangle defined as a json array of four entries. The rectangle bounds are offsets into the image where the replacement occurs (left-coordinate, top-coordinate, right-coordinate, bottom-coordinate). + +*host_controls* - a map of devices (ex: ``DInput/0/Keyboard Mouse``, ``XInput/1/Gamepad``, or blank for wildcard) each with their own section of host buttons (keyboard or gamepad values) that each map to an image. This image will act as a replacement in the original image if this key is mapped to one of the buttons under the ``emulated_controls`` section. Required if ``default_host_controls`` is not defined in the global section. + +#### Global fields in the JSON applied to all textures + +The following fields apply to all textures in the json file: + +|Identifier | Since | +|-------------------------|-------| +|``generated_folder_name``| v1 | +|``preserve_aspect_ratio``| v1 | +|``default_host_controls``| v1 | + +*generated_folder_name* - the folder name that the textures will be generated into. Optional, defaults to '_Generated' + +*preserve_aspect_ratio* - will preserve the aspect ratio when replacing the colored boxes with the image. Optional, defaults to on + +*default_host_controls* - a default map of devices to a map of host controls (keyboard or gamepad values) that each maps to an image. + +#### Examples + +Here's an example of generating a single image with the "A" and "B" Wiimote1 buttons replaced to either keyboard buttons or gamepad buttons depending on the user device and the input mapped: + +```js +{ + "generated_folder_name": "MyDynamicTexturePack", + "preserve_aspect_ratio": false, + "output_textures": + { + "tex1_128x128_02870c3b015d8b40_5.png": + { + "image": "icons.png", + "emulated_controls": { + "Wiimote1": + { + "Buttons/A": [ + [0, 0, 30, 30], + [500, 550, 530, 580], + ] + "Buttons/B": [ + [100, 342, 132, 374] + ] + } + }, + "host_controls": { + "DInput/0/Keyboard Mouse": { + "A": "keyboard/a.png", + "B": "keyboard/b.png" + }, + "XInput/0/Gamepad": { + "`Button A`": "gamepad/a.png", + "`Button B`": "gamepad/b.png" + } + } + } + } +} +``` + +As an example, you are writing a pack for a single-player game. You may want to provide DS4 controller icons but not care what device the user is using. You can use a wildcard for that: + +```js +{ + "preserve_aspect_ratio": false, + "output_textures": + { + "tex1_128x128_02870c3b015d8b40_5.png": + { + "image": "icons.png", + "emulated_controls": { + "Wiimote1": + { + "Buttons/A": [ + [0, 0, 30, 30], + [500, 550, 530, 580] + ] + "Buttons/B": [ + [100, 342, 132, 374] + ] + } + }, + "host_controls": { + "": { + "`Button X`": "ds4/x.png", + "`Button Y`": "ds4/y.png" + } + } + } + } +} +``` + +Here's an example of generating multiple images but using defaults from the global section except for one texture: + +```js +{ + "default_host_controls": { + "DInput/0/Keyboard Mouse": { + "A": "keyboard/a.png", + "B": "keyboard/b.png" + } + }, + "default_device": "DInput/0/Keyboard Mouse", + "output_textures": + { + "tex1_21x26_5cbc6943a74cb7ca_67a541879d5fe615_9.png": + { + "image": "icons1.png", + "emulated_controls": { + "Wiimote1": + { + "Buttons/A": [ + [62, 0, 102, 40] + ] + "Buttons/B": [ + [100, 342, 132, 374] + ] + } + } + }, + "tex1_21x26_5e71c27dad9cda76_3d76bd5d1e73c3b1_9.png": + { + "image": "icons2.png", + "emulated_controls": { + "Wiimote1": + { + "Buttons/A": [ + [857, 682, 907, 732] + ] + "Buttons/B": [ + [100, 342, 132, 374] + ] + } + } + }, + "tex1_24x24_003f3a17f66f1704_82848f47946caa41_9.png": + { + "image": "icons3.png", + "emulated_controls": { + "Wiimote1": + { + "Buttons/A": [ + [0, 0, 30, 30], + [500, 550, 530, 580] + ] + "Buttons/B": [ + [100, 342, 132, 374] + ] + } + }, + "host_controls": + { + "DInput/0/Keyboard Mouse": { + "A": "keyboard/a_special.png", + "B": "keyboard/b.png" + } + } + } + } +} +```