diff --git a/soh/soh/Enhancements/cosmetics/CosmeticsEditor.cpp b/soh/soh/Enhancements/cosmetics/CosmeticsEditor.cpp index 3a475516bad..ed5375c877c 100644 --- a/soh/soh/Enhancements/cosmetics/CosmeticsEditor.cpp +++ b/soh/soh/Enhancements/cosmetics/CosmeticsEditor.cpp @@ -4,6 +4,14 @@ #include "soh/Enhancements/game-interactor/GameInteractor.h" #include +#include +#include +#include +#include +#include +#include +#include +#include "ship/resource/Resource.h" #include #include #include @@ -97,6 +105,7 @@ std::map groupLabels = { { COSMETICS_GROUP_NAVI, "Navi" }, { COSMETICS_GROUP_IVAN, "Ivan" }, { COSMETICS_GROUP_MESSAGE, "Message" }, + { COSMETICS_GROUP_CUSTOM_MODEL, "Custom Model" }, }; static const std::unordered_map cosmeticsRandomizerModes = { @@ -122,11 +131,128 @@ typedef struct { bool advancedOption; } CosmeticOption; +typedef struct { + std::string key; + std::string label; + std::string valuesCvar; + std::string rainbowCvar; + std::string lockedCvar; + std::string changedCvar; + ImVec4 currentColor; + Color_RGBA8 defaultColor; + bool supportsAlpha; + bool supportsRainbow; + bool hasStandaloneMaterial; +} CustomModelCategoryOption; + +typedef struct { + std::string key; + std::string categoryKey; + std::string identityKey; + std::string label; + std::string valuesCvar; + std::string rainbowCvar; + std::string lockedCvar; + std::string changedCvar; + ImVec4 currentColor; + Color_RGBA8 defaultColor; + bool supportsAlpha; + bool supportsRainbow; + std::string displayListPath; + std::vector primColorIndices; + std::vector envColorIndices; + std::vector primPatchNames; + std::vector envPatchNames; +} CustomModelCosmeticOption; + +static std::map customModelCategories; +static std::map customModelCosmetics; +static std::map> customModelCategoryMembers; +static bool customModelCosmeticsDiscovered = false; +static bool customModelAutoDiscoveryAttempted = false; +static std::unordered_map> customModelOriginalGfx; + Color_RGBA8 ColorRGBA8(uint8_t r, uint8_t g, uint8_t b, uint8_t a) { Color_RGBA8 color = { r, g, b, a }; return color; } +static std::string StripOtrPrefix(const std::string& path) { + static constexpr const char* prefix = "__OTR__"; + if (path.rfind(prefix, 0) == 0) { + return path.substr(strlen(prefix)); + } + return path; +} + +static std::string EnsureOtrPrefix(const std::string& path) { + static constexpr const char* prefix = "__OTR__"; + if (path.rfind(prefix, 0) == 0) { + return path; + } + return std::string(prefix) + path; +} + +static void PatchCustomModelGfx(const std::string& path, const std::string& patchName, int index, Gfx instruction) { + auto res = std::static_pointer_cast( + Ship::Context::GetInstance()->GetResourceManager()->LoadResource(path, true)); + + if (res == nullptr || index < 0 || index >= static_cast(res->Instructions.size())) { + return; + } + + Gfx* gfx = (Gfx*)&res->Instructions[index]; + + if (!customModelOriginalGfx[path].contains(patchName)) { + customModelOriginalGfx[path][patchName] = *gfx; + } + + *gfx = instruction; +} + +static void UnpatchCustomModelGfx(const std::string& path, const std::string& patchName, int index) { + auto res = std::static_pointer_cast( + Ship::Context::GetInstance()->GetResourceManager()->LoadResource(path, true)); + + if (res == nullptr || index < 0 || index >= static_cast(res->Instructions.size())) { + return; + } + + if (!customModelOriginalGfx.contains(path) || !customModelOriginalGfx[path].contains(patchName)) { + return; + } + + Gfx* gfx = (Gfx*)&res->Instructions[index]; + *gfx = customModelOriginalGfx[path][patchName]; + customModelOriginalGfx[path].erase(patchName); + if (customModelOriginalGfx[path].empty()) { + customModelOriginalGfx.erase(path); + } +} + +static std::string ToTitleCase(const std::string& value) { + std::string result = value; + bool capitalizeNext = true; + for (char& c : result) { + if (c == '_' || c == '-') { + c = ' '; + capitalizeNext = true; + continue; + } + if (capitalizeNext && std::isalpha(static_cast(c))) { + c = static_cast(std::toupper(static_cast(c))); + capitalizeNext = false; + } else { + c = static_cast(std::tolower(static_cast(c))); + } + } + return result; +} + +static std::string BuildCosmeticCvar(const std::string& key, const std::string& suffix) { + return std::string(CVAR_PREFIX_COSMETIC) + "." + key + suffix; +} + #define COSMETIC_OPTION(id, label, group, defaultColor, supportsAlpha, supportsRainbow, advancedOption) \ { \ id, { \ @@ -461,6 +587,540 @@ static std::map cosmeticOptions = { }; // clang-format on +static void ClearCustomModelCosmetics() { + customModelCategories.clear(); + customModelCosmetics.clear(); + customModelCategoryMembers.clear(); + customModelCosmeticsDiscovered = false; + customModelAutoDiscoveryAttempted = false; +} + +static std::vector GetCustomModelSearchDirs() { + std::vector searchDirs; + auto addDir = [&searchDirs](const std::string& baseDir) { + std::string altDirPrefix = Ship::IResource::gAltAssetPrefix; + if (!altDirPrefix.empty() && altDirPrefix.back() != '/') { + altDirPrefix.push_back('/'); + } + // Mods are expected under alt/ + if (!altDirPrefix.empty()) { + searchDirs.push_back(altDirPrefix + baseDir); + } + }; + + addDir("objects/object_link_boy/"); + addDir("objects/object_link_child/"); + addDir("objects/object_link_boy_kokiri/"); + addDir("objects/object_link_child_kokiri/"); + addDir("objects/object_link_boy_goron/"); + addDir("objects/object_link_child_goron/"); + addDir("objects/object_link_boy_zora/"); + addDir("objects/object_link_child_zora/"); + addDir("objects/object_custom_equip/"); + addDir("objects/gameplay_keep/"); + + return searchDirs; +} + +static bool PathMatchesAnyDir(const std::string& path, const std::vector& directories) { + for (const auto& dir : directories) { + if (path.rfind(dir, 0) == 0) { + return true; + } + } + return false; +} + +static Color_RGBA8 ExtractPrimColor(const Gfx* gfx) { + return ColorRGBA8(_SHIFTR(gfx->words.w1, 24, 8), _SHIFTR(gfx->words.w1, 16, 8), _SHIFTR(gfx->words.w1, 8, 8), + _SHIFTR(gfx->words.w1, 0, 8)); +} + +static Color_RGBA8 ExtractEnvColor(const Gfx* gfx) { + return ColorRGBA8(_SHIFTR(gfx->words.w1, 24, 8), _SHIFTR(gfx->words.w1, 16, 8), _SHIFTR(gfx->words.w1, 8, 8), + _SHIFTR(gfx->words.w1, 0, 8)); +} + +struct CustomMaterialParseResult { + std::string option; + std::string subOption; + bool hasNameDefaultColor = false; + Color_RGBA8 nameDefaultColor = ColorRGBA8(255, 255, 255, 255); +}; + +static bool IsHexToken(const std::string& value) { + if (value.size() != 6 && value.size() != 8) { + return false; + } + for (char c : value) { + if (!std::isxdigit(static_cast(c))) { + return false; + } + } + return true; +} + +static std::string JoinParts(const std::vector& parts, size_t startIndex, size_t endIndexExclusive) { + std::string result; + for (size_t i = startIndex; i < endIndexExclusive; ++i) { + if (!result.empty()) { + result.push_back('_'); + } + result += parts[i]; + } + return result; +} + +static bool ParseHexColor(const std::string& hex, Color_RGBA8& outColor) { + std::string cleaned = hex; + if (!cleaned.empty() && cleaned[0] == '#') { + cleaned = cleaned.substr(1); + } + if (cleaned.size() != 6 && cleaned.size() != 8) { + return false; + } + + auto hexToByte = [](char c) -> int { + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return 10 + (c - 'a'); + if (c >= 'A' && c <= 'F') + return 10 + (c - 'A'); + return -1; + }; + + auto readByte = [&](size_t idx) -> int { + int hi = hexToByte(cleaned[idx]); + int lo = hexToByte(cleaned[idx + 1]); + if (hi < 0 || lo < 0) { + return -1; + } + return (hi << 4) | lo; + }; + + int r = readByte(0); + int g = readByte(2); + int b = readByte(4); + int a = cleaned.size() == 8 ? readByte(6) : 255; + if (r < 0 || g < 0 || b < 0 || a < 0) { + return false; + } + + outColor = + ColorRGBA8(static_cast(r), static_cast(g), static_cast(b), static_cast(a)); + return true; +} + +static bool ExtractCustomMaterialKeys(const std::string& path, CustomMaterialParseResult& out) { + size_t cosmeticPos = path.rfind("cosmetic_"); + if (cosmeticPos == std::string::npos) { + return false; + } + + size_t tokenStart = cosmeticPos + strlen("cosmetic_"); + std::string token = path.substr(tokenStart); + + // Trim at path separators or file extension if they appear after the cosmetic token + size_t slashPos = token.find('/'); + if (slashPos != std::string::npos) { + token = token.substr(0, slashPos); + } + size_t dotPos = token.find('.'); + if (dotPos != std::string::npos) { + token = token.substr(0, dotPos); + } + + size_t hashPos = token.find('#'); + if (hashPos != std::string::npos) { + std::string hexColor = token.substr(hashPos); + if (ParseHexColor(hexColor, out.nameDefaultColor)) { + out.hasNameDefaultColor = true; + } + token = token.substr(0, hashPos); + } + + if (token.empty()) { + return false; + } + + // Split on underscores + std::vector parts; + size_t start = 0; + while (start < token.size()) { + size_t sep = token.find('_', start); + if (sep == std::string::npos) { + parts.push_back(token.substr(start)); + break; + } + parts.push_back(token.substr(start, sep - start)); + start = sep + 1; + } + + if (parts.empty()) { + return false; + } + + // Detect trailing hex token without a # + if (!out.hasNameDefaultColor && parts.size() > 1) { + for (size_t i = parts.size(); i-- > 1;) { // scan from the end, leave option intact + if (IsHexToken(parts[i])) { + ParseHexColor(parts[i], out.nameDefaultColor); + out.hasNameDefaultColor = true; + parts.erase(parts.begin() + i, parts.end()); // drop hex and anything after (like layerOpaque) + break; + } + } + } + + if (parts.empty()) { + return false; + } + + out.option = parts[0]; + // Drop any trailing tokens beyond the first suboption (e.g., layerOpaque) once we've handled hex. + if (parts.size() > 2) { + parts.erase(parts.begin() + 2, parts.end()); + } + + if (parts.size() == 1) { + out.subOption = out.option; + } else { + out.subOption = JoinParts(parts, 1, parts.size()); + } + + return !out.option.empty() && !out.subOption.empty(); +} + +static void DiscoverCustomModelCosmetics() { + ClearCustomModelCosmetics(); + + std::vector searchDirs = GetCustomModelSearchDirs(); + if (searchDirs.empty()) { + return; + } + + auto archiveManager = Ship::Context::GetInstance()->GetResourceManager()->GetArchiveManager(); + auto files = archiveManager->ListFiles(); + if (files == nullptr) { + return; + } + + for (const auto& rawPath : *files) { + std::string normalizedPath = StripOtrPrefix(rawPath); + if (!PathMatchesAnyDir(normalizedPath, searchDirs)) { + continue; + } + + CustomMaterialParseResult materialInfo; + if (!ExtractCustomMaterialKeys(normalizedPath, materialInfo)) { + continue; + } + std::string optionKey = materialInfo.option; + std::string subOptionKey = materialInfo.subOption; + + std::transform(optionKey.begin(), optionKey.end(), optionKey.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + std::transform(subOptionKey.begin(), subOptionKey.end(), subOptionKey.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + + std::string resourcePath = EnsureOtrPrefix(normalizedPath); + auto resource = std::dynamic_pointer_cast( + Ship::Context::GetInstance()->GetResourceManager()->LoadResource(resourcePath, true)); + + if (resource == nullptr || resource->Instructions.empty()) { + continue; + } + + std::vector primIndices; + std::vector envIndices; + Color_RGBA8 primColor = ColorRGBA8(255, 255, 255, 255); + Color_RGBA8 envColor = ColorRGBA8(255, 255, 255, 255); + + for (size_t i = 0; i < resource->Instructions.size(); i++) { + const Gfx* gfx = &resource->Instructions[i]; + uint8_t command = gfx->words.w0 >> 24; + if (command == G_SETPRIMCOLOR) { + if (primIndices.empty()) { + primColor = ExtractPrimColor(gfx); + } + primIndices.push_back(static_cast(i)); + } else if (command == G_SETENVCOLOR) { + if (envIndices.empty()) { + envColor = ExtractEnvColor(gfx); + } + envIndices.push_back(static_cast(i)); + } + } + + if (primIndices.empty() && envIndices.empty()) { + continue; + } + + Color_RGBA8 defaultColor = !primIndices.empty() ? primColor : envColor; + if (materialInfo.hasNameDefaultColor) { + defaultColor = materialInfo.nameDefaultColor; + } + + std::string categoryLabel = ToTitleCase(optionKey); + std::string identityLabel = ToTitleCase(subOptionKey); + std::string categoryBase = "CustomModel.Category." + optionKey; + + bool isStandaloneOption = (optionKey == subOptionKey); + + if (customModelCategories.find(optionKey) == customModelCategories.end()) { + CustomModelCategoryOption categoryOption{ + .key = optionKey, + .label = categoryLabel, + .valuesCvar = BuildCosmeticCvar(categoryBase, ".Value"), + .rainbowCvar = BuildCosmeticCvar(categoryBase, ".Rainbow"), + .lockedCvar = BuildCosmeticCvar(categoryBase, ".Locked"), + .changedCvar = BuildCosmeticCvar(categoryBase, ".Changed"), + .currentColor = { defaultColor.r / 255.0f, defaultColor.g / 255.0f, defaultColor.b / 255.0f, + defaultColor.a / 255.0f }, + .defaultColor = defaultColor, + .supportsAlpha = true, + .supportsRainbow = true, + .hasStandaloneMaterial = isStandaloneOption, + }; + + Color_RGBA8 categoryColor = CVarGetColor(categoryOption.valuesCvar.c_str(), categoryOption.defaultColor); + categoryOption.currentColor = ImVec4(categoryColor.r / 255.0f, categoryColor.g / 255.0f, + categoryColor.b / 255.0f, categoryColor.a / 255.0f); + + customModelCategories[optionKey] = categoryOption; + } else if (isStandaloneOption) { + customModelCategories[optionKey].hasStandaloneMaterial = true; + } + + std::string cosmeticBase = "CustomModel." + optionKey + "." + subOptionKey; + CustomModelCosmeticOption cosmeticOption{ + .key = optionKey + "." + subOptionKey, + .categoryKey = optionKey, + .identityKey = subOptionKey, + .label = identityLabel, + .valuesCvar = BuildCosmeticCvar(cosmeticBase, ".Value"), + .rainbowCvar = BuildCosmeticCvar(cosmeticBase, ".Rainbow"), + .lockedCvar = BuildCosmeticCvar(cosmeticBase, ".Locked"), + .changedCvar = BuildCosmeticCvar(cosmeticBase, ".Changed"), + .currentColor = { defaultColor.r / 255.0f, defaultColor.g / 255.0f, defaultColor.b / 255.0f, + defaultColor.a / 255.0f }, + .defaultColor = defaultColor, + .supportsAlpha = true, + .supportsRainbow = true, + .displayListPath = resourcePath, + .primColorIndices = primIndices, + .envColorIndices = envIndices, + }; + + for (size_t i = 0; i < cosmeticOption.primColorIndices.size(); ++i) { + cosmeticOption.primPatchNames.push_back("CustomModel_" + optionKey + "_" + subOptionKey + "_Prim_" + + std::to_string(i)); + } + for (size_t i = 0; i < cosmeticOption.envColorIndices.size(); ++i) { + cosmeticOption.envPatchNames.push_back("CustomModel_" + optionKey + "_" + subOptionKey + "_Env_" + + std::to_string(i)); + } + Color_RGBA8 cvarColor = CVarGetColor(cosmeticOption.valuesCvar.c_str(), cosmeticOption.defaultColor); + cosmeticOption.currentColor = { cvarColor.r / 255.0f, cvarColor.g / 255.0f, cvarColor.b / 255.0f, + cvarColor.a / 255.0f }; + + // If option and suboption are the same, treat this as the standalone option entry and share CVars with the + // option row. Skip adding a separate suboption row in the UI. + if (optionKey == subOptionKey) { + CustomModelCategoryOption& category = customModelCategories[optionKey]; + cosmeticOption.valuesCvar = category.valuesCvar; + cosmeticOption.rainbowCvar = category.rainbowCvar; + cosmeticOption.lockedCvar = category.lockedCvar; + cosmeticOption.changedCvar = category.changedCvar; + cosmeticOption.label = category.label; + category.hasStandaloneMaterial = true; + } else { + customModelCategoryMembers[optionKey].push_back(cosmeticOption.key); + } + + customModelCosmetics[cosmeticOption.key] = cosmeticOption; + } + + for (auto& [category, members] : customModelCategoryMembers) { + std::sort(members.begin(), members.end()); + } + + customModelCosmeticsDiscovered = !customModelCosmetics.empty(); +} + +static void ApplyCustomModelCosmetics(bool manualChange = true) { + (void)manualChange; + bool usingCustomModel = ResourceGetIsCustomByName(gLinkAdultSkel) || ResourceGetIsCustomByName(gLinkChildSkel); + + if (!usingCustomModel) { + for (auto& [key, cosmetic] : customModelCosmetics) { + for (size_t i = 0; i < cosmetic.primPatchNames.size(); i++) { + UnpatchCustomModelGfx(cosmetic.displayListPath, cosmetic.primPatchNames[i], + cosmetic.primColorIndices[i]); + } + for (size_t i = 0; i < cosmetic.envPatchNames.size(); i++) { + UnpatchCustomModelGfx(cosmetic.displayListPath, cosmetic.envPatchNames[i], cosmetic.envColorIndices[i]); + } + } + return; + } + + if (!customModelCosmeticsDiscovered || customModelCategories.empty()) { + return; + } + + for (auto& [categoryKey, category] : customModelCategories) { + Color_RGBA8 color = CVarGetColor(category.valuesCvar.c_str(), category.defaultColor); + category.currentColor = ImVec4(color.r / 255.0f, color.g / 255.0f, color.b / 255.0f, color.a / 255.0f); + } + + for (auto& [key, cosmetic] : customModelCosmetics) { + auto categoryIter = customModelCategories.find(cosmetic.categoryKey); + if (categoryIter == customModelCategories.end()) { + continue; + } + + (void)categoryIter; // category is still used for grouping but no longer overrides suboptions. + + bool cosmeticChanged = + CVarGetInteger(cosmetic.changedCvar.c_str(), 0) || CVarGetInteger(cosmetic.rainbowCvar.c_str(), 0); + Color_RGBA8 cosmeticColor = CVarGetColor(cosmetic.valuesCvar.c_str(), cosmetic.defaultColor); + + Color_RGBA8 finalColor = cosmeticColor; + + cosmetic.currentColor = + ImVec4(finalColor.r / 255.0f, finalColor.g / 255.0f, finalColor.b / 255.0f, finalColor.a / 255.0f); + + // Apply to both prim and env where they exist; prim-only materials still work, and env-based materials keep + // updating to the chosen color without needing injected commands. + for (size_t i = 0; i < cosmetic.primColorIndices.size(); i++) { + PatchCustomModelGfx(cosmetic.displayListPath, cosmetic.primPatchNames[i], cosmetic.primColorIndices[i], + gsDPSetPrimColor(0, 0, finalColor.r, finalColor.g, finalColor.b, finalColor.a)); + } + for (size_t i = 0; i < cosmetic.envColorIndices.size(); i++) { + PatchCustomModelGfx(cosmetic.displayListPath, cosmetic.envPatchNames[i], cosmetic.envColorIndices[i], + gsDPSetEnvColor(finalColor.r, finalColor.g, finalColor.b, finalColor.a)); + } + } +} + +static void RandomizeCustomModelCategory(CustomModelCategoryOption& category) { + ImVec4 randomColor = GetRandomValue(); + Color_RGBA8 newColor = { static_cast(randomColor.x * 255.0f), static_cast(randomColor.y * 255.0f), + static_cast(randomColor.z * 255.0f), + static_cast(category.supportsAlpha ? category.currentColor.w * 255.0f : 255) }; + + category.currentColor = { newColor.r / 255.0f, newColor.g / 255.0f, newColor.b / 255.0f, newColor.a / 255.0f }; + CVarSetColor(category.valuesCvar.c_str(), newColor); + CVarSetInteger(category.rainbowCvar.c_str(), 0); + CVarSetInteger(category.changedCvar.c_str(), 1); +} + +static void RandomizeCustomModelCosmetic(CustomModelCosmeticOption& cosmetic) { + ImVec4 randomColor = GetRandomValue(); + Color_RGBA8 newColor = { static_cast(randomColor.x * 255.0f), static_cast(randomColor.y * 255.0f), + static_cast(randomColor.z * 255.0f), + static_cast(cosmetic.supportsAlpha ? cosmetic.currentColor.w * 255.0f : 255) }; + + cosmetic.currentColor = { newColor.r / 255.0f, newColor.g / 255.0f, newColor.b / 255.0f, newColor.a / 255.0f }; + CVarSetColor(cosmetic.valuesCvar.c_str(), newColor); + CVarSetInteger(cosmetic.rainbowCvar.c_str(), 0); + CVarSetInteger(cosmetic.changedCvar.c_str(), 1); +} + +static void ResetCustomModelCategory(CustomModelCategoryOption& category) { + CVarClear(category.changedCvar.c_str()); + CVarClear(category.rainbowCvar.c_str()); + CVarClear(category.lockedCvar.c_str()); + CVarClear(category.valuesCvar.c_str()); + category.currentColor = { category.defaultColor.r / 255.0f, category.defaultColor.g / 255.0f, + category.defaultColor.b / 255.0f, category.defaultColor.a / 255.0f }; +} + +static void ResetCustomModelCosmetic(CustomModelCosmeticOption& cosmetic) { + CVarClear(cosmetic.changedCvar.c_str()); + CVarClear(cosmetic.rainbowCvar.c_str()); + CVarClear(cosmetic.lockedCvar.c_str()); + CVarClear(cosmetic.valuesCvar.c_str()); + cosmetic.currentColor = { cosmetic.defaultColor.r / 255.0f, cosmetic.defaultColor.g / 255.0f, + cosmetic.defaultColor.b / 255.0f, cosmetic.defaultColor.a / 255.0f }; +} + +static void CustomModel_RandomizeAll() { + for (auto& [categoryKey, category] : customModelCategories) { + if (!CVarGetInteger(category.lockedCvar.c_str(), 0)) { + RandomizeCustomModelCategory(category); + } + } + + for (auto& [key, cosmetic] : customModelCosmetics) { + if (!CVarGetInteger(cosmetic.lockedCvar.c_str(), 0)) { + RandomizeCustomModelCosmetic(cosmetic); + } + } + + Ship::Context::GetInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + ApplyCustomModelCosmetics(); +} + +static void CustomModel_ResetAll() { + for (auto& [categoryKey, category] : customModelCategories) { + if (!CVarGetInteger(category.lockedCvar.c_str(), 0)) { + ResetCustomModelCategory(category); + } + } + + for (auto& [key, cosmetic] : customModelCosmetics) { + if (!CVarGetInteger(cosmetic.lockedCvar.c_str(), 0)) { + ResetCustomModelCosmetic(cosmetic); + } + } + + Ship::Context::GetInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + ApplyCustomModelCosmetics(); +} + +static void CustomModel_RandomizeCategory(const std::string& categoryKey) { + auto categoryIt = customModelCategories.find(categoryKey); + if (categoryIt != customModelCategories.end() && !CVarGetInteger(categoryIt->second.lockedCvar.c_str(), 0)) { + RandomizeCustomModelCategory(categoryIt->second); + } + + auto membersIt = customModelCategoryMembers.find(categoryKey); + if (membersIt != customModelCategoryMembers.end()) { + for (const auto& cosmeticKey : membersIt->second) { + auto cosmeticIt = customModelCosmetics.find(cosmeticKey); + if (cosmeticIt != customModelCosmetics.end() && !CVarGetInteger(cosmeticIt->second.lockedCvar.c_str(), 0)) { + RandomizeCustomModelCosmetic(cosmeticIt->second); + } + } + } + + Ship::Context::GetInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + ApplyCustomModelCosmetics(); +} + +static void CustomModel_ResetCategory(const std::string& categoryKey) { + auto categoryIt = customModelCategories.find(categoryKey); + if (categoryIt != customModelCategories.end() && !CVarGetInteger(categoryIt->second.lockedCvar.c_str(), 0)) { + ResetCustomModelCategory(categoryIt->second); + } + + auto membersIt = customModelCategoryMembers.find(categoryKey); + if (membersIt != customModelCategoryMembers.end()) { + for (const auto& cosmeticKey : membersIt->second) { + auto cosmeticIt = customModelCosmetics.find(cosmeticKey); + if (cosmeticIt != customModelCosmetics.end() && !CVarGetInteger(cosmeticIt->second.lockedCvar.c_str(), 0)) { + ResetCustomModelCosmetic(cosmeticIt->second); + } + } + } + + Ship::Context::GetInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + ApplyCustomModelCosmetics(); +} + static const char* MarginCvarList[]{ CVAR_COSMETIC("HUD.Hearts"), CVAR_COSMETIC("HUD.HeartsCount"), CVAR_COSMETIC("HUD.MagicBar"), CVAR_COSMETIC("HUD.VisualSoA"), CVAR_COSMETIC("HUD.BButton"), CVAR_COSMETIC("HUD.AButton"), @@ -534,6 +1194,11 @@ int hue = 0; void CosmeticsUpdateTick() { int index = 0; float rainbowSpeed = CVarGetFloat(CVAR_COSMETIC("RainbowSpeed"), 0.6f); + if (!customModelCosmeticsDiscovered && !customModelAutoDiscoveryAttempted && + (ResourceGetIsCustomByName(gLinkAdultSkel) || ResourceGetIsCustomByName(gLinkChildSkel))) { + customModelAutoDiscoveryAttempted = true; + DiscoverCustomModelCosmetics(); + } for (auto& [id, cosmeticOption] : cosmeticOptions) { if (cosmeticOption.supportsRainbow && CVarGetInteger(cosmeticOption.rainbowCvar, 0)) { double frequency = 2 * M_PI / (360 * rainbowSpeed); @@ -562,6 +1227,44 @@ void CosmeticsUpdateTick() { } } ApplyOrResetCustomGfxPatches(false); + for (auto& [categoryKey, category] : customModelCategories) { + if (category.supportsRainbow && CVarGetInteger(category.rainbowCvar.c_str(), 0)) { + double frequency = 2 * M_PI / (360 * rainbowSpeed); + Color_RGBA8 newColor; + newColor.r = static_cast(sin(frequency * (hue + index) + 0) * 127) + 128; + newColor.g = static_cast(sin(frequency * (hue + index) + (2 * M_PI / 3)) * 127) + 128; + newColor.b = static_cast(sin(frequency * (hue + index) + (4 * M_PI / 3)) * 127) + 128; + newColor.a = category.supportsAlpha ? static_cast(category.currentColor.w * 255.0f) + : static_cast(category.defaultColor.a); + category.currentColor = + ImVec4(newColor.r / 255.0f, newColor.g / 255.0f, newColor.b / 255.0f, newColor.a / 255.0f); + CVarSetColor(category.valuesCvar.c_str(), newColor); + } + if (!CVarGetInteger(CVAR_COSMETIC("RainbowSync"), 0)) { + index += static_cast(60 * rainbowSpeed); + } + } + + for (auto& [id, cosmetic] : customModelCosmetics) { + if (cosmetic.supportsRainbow && CVarGetInteger(cosmetic.rainbowCvar.c_str(), 0)) { + double frequency = 2 * M_PI / (360 * rainbowSpeed); + Color_RGBA8 newColor; + newColor.r = static_cast(sin(frequency * (hue + index) + 0) * 127) + 128; + newColor.g = static_cast(sin(frequency * (hue + index) + (2 * M_PI / 3)) * 127) + 128; + newColor.b = static_cast(sin(frequency * (hue + index) + (4 * M_PI / 3)) * 127) + 128; + newColor.a = cosmetic.supportsAlpha ? static_cast(cosmetic.currentColor.w * 255.0f) + : static_cast(cosmetic.defaultColor.a); + + cosmetic.currentColor = + ImVec4(newColor.r / 255.0f, newColor.g / 255.0f, newColor.b / 255.0f, newColor.a / 255.0f); + CVarSetColor(cosmetic.valuesCvar.c_str(), newColor); + } + if (!CVarGetInteger(CVAR_COSMETIC("RainbowSync"), 0)) { + index += static_cast(60 * rainbowSpeed); + } + } + + ApplyCustomModelCosmetics(false); hue++; if (hue >= (360 * rainbowSpeed)) { hue = 0; @@ -2283,6 +2986,203 @@ void DrawCosmeticGroup(CosmeticGroup cosmeticGroup) { UIWidgets::Separator(true, true, 2.0f, 2.0f); } +static void DrawCustomModelCategoryRow(CustomModelCategoryOption& category) { + std::string label = category.label + " (Option)"; + std::string categoryBaseCvar = category.valuesCvar; + constexpr const char* valueSuffix = ".Value"; + if (categoryBaseCvar.size() > strlen(valueSuffix) && + categoryBaseCvar.rfind(valueSuffix) == categoryBaseCvar.size() - strlen(valueSuffix)) { + categoryBaseCvar = categoryBaseCvar.substr(0, categoryBaseCvar.size() - strlen(valueSuffix)); + } + + if (UIWidgets::CVarColorPicker(label.c_str(), categoryBaseCvar.c_str(), category.defaultColor, + category.supportsAlpha, 0, THEME_COLOR)) { + CVarSetInteger(category.rainbowCvar.c_str(), 0); + CVarSetInteger(category.changedCvar.c_str(), 1); + ApplyCustomModelCosmetics(); + Ship::Context::GetInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + } + + ImGui::SameLine((ImGui::CalcTextSize("Message Light Blue (None No Shadow)").x * 1.0f) + 60.0f); + if (UIWidgets::Button( + ("Random##" + label).c_str(), + UIWidgets::ButtonOptions().Size(ImVec2(80, 31)).Padding(ImVec2(2.0f, 0.0f)).Color(THEME_COLOR))) { + RandomizeCustomModelCategory(category); + ApplyCustomModelCosmetics(); + Ship::Context::GetInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + } + + if (category.supportsRainbow) { + ImGui::SameLine(); + if (UIWidgets::CVarCheckbox(("Rainbow##" + label).c_str(), category.rainbowCvar.c_str(), + UIWidgets::CheckboxOptions().Color(THEME_COLOR))) { + CVarSetInteger(category.changedCvar.c_str(), 1); + ApplyCustomModelCosmetics(); + Ship::Context::GetInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + } + } + + ImGui::SameLine(); + UIWidgets::CVarCheckbox(("Locked##" + label).c_str(), category.lockedCvar.c_str(), + UIWidgets::CheckboxOptions().Color(THEME_COLOR)); + + if (CVarGetInteger(category.changedCvar.c_str(), 0)) { + ImGui::SameLine(); + if (UIWidgets::Button(("Reset##" + label).c_str(), + UIWidgets::ButtonOptions().Size(ImVec2(80, 31)).Padding(ImVec2(2.0f, 0.0f)))) { + ResetCustomModelCategory(category); + // Reset all suboptions tied to this option so they return to their defaults alongside the option. + auto membersIt = customModelCategoryMembers.find(category.key); + if (membersIt != customModelCategoryMembers.end()) { + for (const auto& cosmeticKey : membersIt->second) { + auto cosmeticIt = customModelCosmetics.find(cosmeticKey); + if (cosmeticIt != customModelCosmetics.end()) { + ResetCustomModelCosmetic(cosmeticIt->second); + } + } + } + ApplyCustomModelCosmetics(); + Ship::Context::GetInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + } + } +} + +static void DrawCustomModelCosmeticRow(CustomModelCosmeticOption& cosmetic) { + std::string cosmeticBaseCvar = cosmetic.valuesCvar; + constexpr const char* valueSuffixCosmetic = ".Value"; + if (cosmeticBaseCvar.size() > strlen(valueSuffixCosmetic) && + cosmeticBaseCvar.rfind(valueSuffixCosmetic) == cosmeticBaseCvar.size() - strlen(valueSuffixCosmetic)) { + cosmeticBaseCvar = cosmeticBaseCvar.substr(0, cosmeticBaseCvar.size() - strlen(valueSuffixCosmetic)); + } + + if (UIWidgets::CVarColorPicker(cosmetic.label.c_str(), cosmeticBaseCvar.c_str(), cosmetic.defaultColor, + cosmetic.supportsAlpha, 0, THEME_COLOR)) { + CVarSetInteger(cosmetic.rainbowCvar.c_str(), 0); + CVarSetInteger(cosmetic.changedCvar.c_str(), 1); + ApplyCustomModelCosmetics(); + Ship::Context::GetInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + } + + ImGui::SameLine((ImGui::CalcTextSize("Message Light Blue (None No Shadow)").x * 1.0f) + 60.0f); + if (UIWidgets::Button( + ("Random##" + cosmetic.label).c_str(), + UIWidgets::ButtonOptions().Size(ImVec2(80, 31)).Padding(ImVec2(2.0f, 0.0f)).Color(THEME_COLOR))) { + RandomizeCustomModelCosmetic(cosmetic); + ApplyCustomModelCosmetics(); + Ship::Context::GetInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + } + + if (cosmetic.supportsRainbow) { + ImGui::SameLine(); + if (UIWidgets::CVarCheckbox(("Rainbow##" + cosmetic.label).c_str(), cosmetic.rainbowCvar.c_str(), + UIWidgets::CheckboxOptions().Color(THEME_COLOR))) { + CVarSetInteger(cosmetic.changedCvar.c_str(), 1); + ApplyCustomModelCosmetics(); + Ship::Context::GetInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + } + } + + ImGui::SameLine(); + UIWidgets::CVarCheckbox(("Locked##" + cosmetic.label).c_str(), cosmetic.lockedCvar.c_str(), + UIWidgets::CheckboxOptions().Color(THEME_COLOR)); + + if (CVarGetInteger(cosmetic.changedCvar.c_str(), 0)) { + ImGui::SameLine(); + if (UIWidgets::Button(("Reset##" + cosmetic.label).c_str(), + UIWidgets::ButtonOptions().Size(ImVec2(80, 31)).Padding(ImVec2(2.0f, 0.0f)))) { + ResetCustomModelCosmetic(cosmetic); + ApplyCustomModelCosmetics(); + Ship::Context::GetInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + } + } +} + +static void DrawCustomModelCategory(const std::string& categoryKey) { + auto categoryIt = customModelCategories.find(categoryKey); + if (categoryIt == customModelCategories.end()) { + return; + } + + CustomModelCategoryOption& category = categoryIt->second; + + ImGui::Text("%s", category.label.c_str()); + ImGui::SameLine((ImGui::CalcTextSize("Message Light Blue (None No Shadow)").x * 1.0f) + 60.0f); + + if (UIWidgets::Button( + ("Random##" + categoryKey).c_str(), + UIWidgets::ButtonOptions().Size(ImVec2(80, 31)).Padding(ImVec2(2.0f, 0.0f)).Color(THEME_COLOR))) { + CustomModel_RandomizeCategory(categoryKey); + } + + ImGui::SameLine(); + if (UIWidgets::Button(("Reset##" + categoryKey).c_str(), + UIWidgets::ButtonOptions().Size(ImVec2(80, 31)).Padding(ImVec2(2.0f, 0.0f)))) { + CustomModel_ResetCategory(categoryKey); + } + + UIWidgets::Spacer(); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + drawList->ChannelsSplit(2); + drawList->ChannelsSetCurrent(1); // foreground for widgets + + if (category.hasStandaloneMaterial) { + DrawCustomModelCategoryRow(category); + } + + auto membersIt = customModelCategoryMembers.find(categoryKey); + if (membersIt != customModelCategoryMembers.end()) { + float indent = 20.0f; + ImGui::Indent(indent); + size_t subIndex = 0; + for (const auto& cosmeticKey : membersIt->second) { + auto cosmeticIt = customModelCosmetics.find(cosmeticKey); + if (cosmeticIt != customModelCosmetics.end()) { + // Skip standalone option entries to avoid duplicate rows when option == suboption + if (cosmeticIt->second.categoryKey == cosmeticIt->second.identityKey) { + continue; + } + + ImVec2 rowStart = ImGui::GetCursorScreenPos(); + drawList->ChannelsSetCurrent(1); // foreground for the widgets + DrawCustomModelCosmeticRow(cosmeticIt->second); + ImVec2 rowEnd = ImGui::GetCursorScreenPos(); + + drawList->ChannelsSetCurrent(0); // background for the guide line + float lineX = rowStart.x - indent * 0.35f; + bool isFirstSubOption = (subIndex == 0); + float topYOffset = isFirstSubOption ? -0.3f : -1.3f; + float topY = rowStart.y + ImGui::GetTextLineHeight() * topYOffset; + float bottomY = rowEnd.y - ImGui::GetTextLineHeight() * 1.3f; + drawList->AddLine(ImVec2(lineX, topY), ImVec2(lineX, bottomY), + ImGui::GetColorU32(ImGuiCol_TextDisabled), 1.0f); + drawList->AddLine(ImVec2(lineX, bottomY), ImVec2(lineX + indent * 0.35f, bottomY), + ImGui::GetColorU32(ImGuiCol_TextDisabled), 1.0f); + subIndex++; + } + } + ImGui::Unindent(indent); + } + + drawList->ChannelsMerge(); + + UIWidgets::Separator(true, true, 2.0f, 2.0f); +} + +static void DrawCustomModelTab() { + UIWidgets::Separator(true, true, 2.0f, 2.0f); + if (!customModelCosmeticsDiscovered) { + ImGui::Separator(); + ImGui::TextWrapped("No custom model materials using the prefix \"cosmetic_