From d1fd8787dc321cfda0c453762f17c05cc88d4efb Mon Sep 17 00:00:00 2001 From: sindoring Date: Sat, 29 Aug 2026 14:40:38 +0400 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82?= =?UTF-8?q?=D1=8B=20=D0=BA=D0=B0=D1=81=D1=82=D0=BE=D0=BC=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/moonwell/src/MoonWell.cpp | 1034 ++++++++++++++++- .../Interface/GlueXML/CharacterCreate.lua | 569 ++++++++- .../Interface/GlueXML/CharacterCreate.xml | 51 +- 3 files changed, 1618 insertions(+), 36 deletions(-) diff --git a/modules/moonwell/src/MoonWell.cpp b/modules/moonwell/src/MoonWell.cpp index 1abbcaa..bdd934c 100644 --- a/modules/moonwell/src/MoonWell.cpp +++ b/modules/moonwell/src/MoonWell.cpp @@ -6,17 +6,24 @@ // boot phase, before GlueXML is loaded. #include "game/Script.hpp" +#include "game/Io.hpp" #include "wxl/PluginApi.h" #include "SpellOverrides.hpp" #include +#include #include #include #include #include #include #include +#include +#include +#include +#include +#include namespace moonwell { @@ -86,6 +93,960 @@ namespace moonwell SetCreatureFn g_nextSetCreature = nullptr; alignas(4) std::array g_encounterJournalCreatureRecord{}; + // CharSectionsLookup is the final stock-client lookup used while the character model is + // composed. Its successful answers are retained only for the optional texture diagnostic + // shown in GlueXML. They are render recipes, never customization state or catalog input. + struct CharSectionsRecord + { + uint32_t id; + uint32_t race; + uint32_t sex; + uint32_t sectionType; + const char* texture[3]; + uint32_t flags; + uint32_t variation; + uint32_t color; + }; + static_assert(sizeof(CharSectionsRecord) == 40, + "3.3.5a CharSections runtime record must remain 40 bytes"); + + struct ActiveCharSection + { + bool valid = false; + uint32_t id = 0; + uint32_t race = 0; + uint32_t sex = 0; + uint32_t sectionType = 0; + uint32_t flags = 0; + uint32_t variation = 0; + uint32_t color = 0; + std::array, 3> texture{}; + }; + + using CharSectionsLookupFn = const CharSectionsRecord* (__cdecl*)( + void* table, int race, int sex, int sectionType, int variation, int color, + uint8_t* found); + CharSectionsLookupFn g_nextCharSectionsLookup = nullptr; + std::array g_activeCharSections{}; + + struct FacialHairChoice + { + std::array geosets{}; + }; + + struct HairStyleChoice + { + uint32_t geoset = 0; + uint32_t showsScalp = 0; + }; + + struct SectionChoice + { + uint8_t raw = 0; + uint32_t id = 0; + uint32_t flags = 0; + std::array textures{}; + }; + + struct RaceSexCustomizationSource + { + std::vector skin; + std::vector underwear; + std::array, 256> faceBySkin; + std::array, 256> hairStyleByColor; + std::array, 256> hairColorByStyle; + std::array, 256> facialByColor; + }; + + struct LogicalCustomizationCandidate + { + uint8_t raw = 0; + std::array components{}; + }; + + struct LogicalCustomizationCatalog + { + std::vector candidates; + std::vector componentKinds; + }; + + std::unordered_map g_facialHairChoices; + std::unordered_map g_hairStyleChoices; + std::unordered_map g_customizationSources; + std::unordered_map g_customizationCatalogs; + bool g_facialHairCatalogAttempted = false; + bool g_hairStyleCatalogAttempted = false; + bool g_customizationSourceAttempted = false; + bool g_customizationStateReadyLogged = false; + bool g_customizationStatePendingLogged = false; + + // Verified against Wow.exe build 12340. CMSG_CHAR_CREATE reads these exact low bytes from + // the character-create object (0x004E03EB..0x004E042F). They are the authoritative five + // selectors; CharSections is only a render recipe derived from them. + constexpr uintptr_t kCharacterCreateStatePointer = 0x00B6B1A0; + constexpr size_t kCharacterCreateRaceOffset = 0x18; + constexpr size_t kCharacterCreateSexOffset = 0x1C; + constexpr std::array kCharacterCreateCustomizationOffsets = { + 0x28, // skin + 0x2C, // face + 0x34, // hair style + 0x24, // hair color + 0x30, // facial hair / features + }; + + uint32_t ReadU32(const uint8_t* data) + { + uint32_t value = 0; + std::memcpy(&value, data, sizeof(value)); + return value; + } + + bool ReadArchiveFile(const char* path, std::vector& data) + { + void* handle = nullptr; + if (!wxl::game::io::FileOpen(path, wxl::game::io::kOpenWholeFile, &handle) || !handle) + return false; + + uint32_t high = 0; + const uint32_t size = wxl::game::io::FileSize(handle, &high); + if (high != 0) + { + wxl::game::io::FileClose(handle); + return false; + } + + data.resize(size); + uint32_t read = 0; + const bool ok = (size == 0 + || wxl::game::io::FileRead(handle, data.data(), size, &read)) && read == size; + wxl::game::io::FileClose(handle); + if (!ok) data.clear(); + return ok; + } + + uint32_t FacialHairChoiceKey(uint32_t race, uint32_t sex, uint32_t variation) + { + return (race << 17) | ((sex & 1u) << 16) | (variation & 0xFFFFu); + } + + bool EnsureFacialHairCatalog() + { + if (g_facialHairCatalogAttempted) + return !g_facialHairChoices.empty(); + g_facialHairCatalogAttempted = true; + + std::vector data; + if (!ReadArchiveFile("DBFilesClient\\CharacterFacialHairStyles.dbc", data) + || data.size() < 20 || std::memcmp(data.data(), "WDBC", 4) != 0) + { + WLOG_ERROR("moonwell: cannot read CharacterFacialHairStyles.dbc"); + return false; + } + + const uint32_t count = ReadU32(data.data() + 4); + const uint32_t fields = ReadU32(data.data() + 8); + const uint32_t recordSize = ReadU32(data.data() + 12); + const uint64_t recordsEnd = 20ull + static_cast(count) * recordSize; + if (fields != 8 || recordSize != 32 || recordsEnd > data.size()) + { + WLOG_ERROR("moonwell: unexpected CharacterFacialHairStyles.dbc layout"); + return false; + } + + for (uint32_t index = 0; index < count; ++index) + { + const uint8_t* row = data.data() + 20 + static_cast(index) * recordSize; + const uint32_t race = ReadU32(row); + const uint32_t sex = ReadU32(row + 4); + const uint32_t variation = ReadU32(row + 8); + FacialHairChoice choice; + for (size_t geoset = 0; geoset < choice.geosets.size(); ++geoset) + choice.geosets[geoset] = ReadU32(row + 12 + geoset * 4); + g_facialHairChoices[FacialHairChoiceKey(race, sex, variation)] = choice; + } + + WLOG_INFO("moonwell: loaded %zu facial-hair customization choices", + g_facialHairChoices.size()); + return !g_facialHairChoices.empty(); + } + + bool EnsureHairStyleCatalog() + { + if (g_hairStyleCatalogAttempted) + return !g_hairStyleChoices.empty(); + g_hairStyleCatalogAttempted = true; + + std::vector data; + if (!ReadArchiveFile("DBFilesClient\\CharHairGeosets.dbc", data) + || data.size() < 20 || std::memcmp(data.data(), "WDBC", 4) != 0) + { + WLOG_ERROR("moonwell: cannot read CharHairGeosets.dbc"); + return false; + } + + const uint32_t count = ReadU32(data.data() + 4); + const uint32_t fields = ReadU32(data.data() + 8); + const uint32_t recordSize = ReadU32(data.data() + 12); + const uint64_t recordsEnd = 20ull + static_cast(count) * recordSize; + if (fields != 6 || recordSize != 24 || recordsEnd > data.size()) + { + WLOG_ERROR("moonwell: unexpected CharHairGeosets.dbc layout"); + return false; + } + + for (uint32_t index = 0; index < count; ++index) + { + const uint8_t* row = data.data() + 20 + static_cast(index) * recordSize; + const uint32_t race = ReadU32(row + 4); + const uint32_t sex = ReadU32(row + 8); + const uint32_t variation = ReadU32(row + 12); + if (race == 0 || race > 255 || sex > 1 || variation > 255) + continue; + g_hairStyleChoices[FacialHairChoiceKey(race, sex, variation)] = { + ReadU32(row + 16), ReadU32(row + 20), + }; + } + + WLOG_INFO("moonwell: loaded %zu hair-style customization choices", + g_hairStyleChoices.size()); + return !g_hairStyleChoices.empty(); + } + + uint32_t RaceSexCustomizationKey(uint32_t race, uint32_t sex) + { + return (race << 1) | (sex & 1u); + } + + void NormalizeSectionChoices(std::vector& choices) + { + std::sort(choices.begin(), choices.end(), [](const auto& left, const auto& right) + { + return left.raw < right.raw; + }); + choices.erase(std::unique(choices.begin(), choices.end(), + [](const auto& left, const auto& right) { return left.raw == right.raw; }), + choices.end()); + } + + bool EnsureCustomizationSources() + { + if (g_customizationSourceAttempted) + return !g_customizationSources.empty(); + g_customizationSourceAttempted = true; + + std::vector data; + if (!ReadArchiveFile("DBFilesClient\\CharSections.dbc", data) + || data.size() < 20 || std::memcmp(data.data(), "WDBC", 4) != 0) + { + WLOG_ERROR("moonwell: cannot read CharSections.dbc for customization catalog"); + return false; + } + + const uint32_t count = ReadU32(data.data() + 4); + const uint32_t fields = ReadU32(data.data() + 8); + const uint32_t recordSize = ReadU32(data.data() + 12); + const uint32_t stringSize = ReadU32(data.data() + 16); + const uint64_t recordsEnd = 20ull + static_cast(count) * recordSize; + if (fields != 10 || recordSize != 40 + || recordsEnd + stringSize > data.size()) + { + WLOG_ERROR("moonwell: unexpected CharSections.dbc layout"); + return false; + } + + const char* strings = reinterpret_cast(data.data() + recordsEnd); + std::unordered_map textureTokens; + const auto textureToken = [&](uint32_t offset) -> uint32_t + { + if (offset == 0 || offset >= stringSize) + return 0; + const char* begin = strings + offset; + const auto* end = static_cast( + std::memchr(begin, '\0', stringSize - offset)); + if (!end) + return 0; + const std::string_view path(begin, static_cast(end - begin)); + const auto [token, inserted] = textureTokens.emplace( + path, static_cast(textureTokens.size() + 1)); + return token->second; + }; + + size_t accepted = 0; + for (uint32_t index = 0; index < count; ++index) + { + const uint8_t* row = data.data() + 20 + static_cast(index) * recordSize; + const uint32_t race = ReadU32(row + 4); + const uint32_t sex = ReadU32(row + 8); + const uint32_t section = ReadU32(row + 12); + const uint32_t variation = ReadU32(row + 32); + const uint32_t color = ReadU32(row + 36); + if (race == 0 || race > 255 || sex > 1 || section > 4 + || variation > 255 || color > 255) + continue; + + SectionChoice choice; + choice.id = ReadU32(row); + choice.flags = ReadU32(row + 28); + for (size_t texture = 0; texture < choice.textures.size(); ++texture) + { + const uint32_t offset = ReadU32(row + 16 + texture * 4); + choice.textures[texture] = textureToken(offset); + } + + auto& source = g_customizationSources[RaceSexCustomizationKey(race, sex)]; + switch (section) + { + case 0: + if (variation == 0) + { + choice.raw = static_cast(color); + source.skin.push_back(choice); + ++accepted; + } + break; + case 1: + choice.raw = static_cast(variation); + source.faceBySkin[color].push_back(choice); + ++accepted; + break; + case 2: + choice.raw = static_cast(variation); + source.facialByColor[color].push_back(choice); + ++accepted; + break; + case 3: + choice.raw = static_cast(variation); + source.hairStyleByColor[color].push_back(choice); + choice.raw = static_cast(color); + source.hairColorByStyle[variation].push_back(choice); + ++accepted; + break; + case 4: + if (variation == 0) + { + choice.raw = static_cast(color); + source.underwear.push_back(choice); + ++accepted; + } + break; + } + } + + for (auto& [_, source] : g_customizationSources) + { + NormalizeSectionChoices(source.skin); + NormalizeSectionChoices(source.underwear); + for (size_t dependency = 0; dependency < 256; ++dependency) + { + NormalizeSectionChoices(source.faceBySkin[dependency]); + NormalizeSectionChoices(source.hairStyleByColor[dependency]); + NormalizeSectionChoices(source.hairColorByStyle[dependency]); + NormalizeSectionChoices(source.facialByColor[dependency]); + } + } + + // These two tables cover valid geometry-only choices that legitimately have no + // CharSections texture row (bald styles, horns, earrings and similar features). + EnsureHairStyleCatalog(); + EnsureFacialHairCatalog(); + WLOG_INFO("moonwell: indexed %zu CharSections rows for %zu race/sex catalogs", + accepted, g_customizationSources.size()); + return !g_customizationSources.empty(); + } + + bool GetActiveCharacterCustomizationValues(std::array& values, + uint32_t* race = nullptr, + uint32_t* sex = nullptr) + { + const auto* object = *reinterpret_cast( + kCharacterCreateStatePointer); + if (!object) + return false; + + const uint32_t activeRace = object[kCharacterCreateRaceOffset]; + const uint32_t activeSex = object[kCharacterCreateSexOffset]; + if (activeRace == 0 || activeSex > 1) + return false; + + for (size_t slot = 0; slot < values.size(); ++slot) + values[slot] = object[kCharacterCreateCustomizationOffsets[slot]]; + if (race) *race = activeRace; + if (sex) *sex = activeSex; + return true; + } + + void CopyCharSectionPath(std::array& destination, const char* source) + { + destination[0] = '\0'; + if (source) + strncpy_s(destination.data(), destination.size(), source, _TRUNCATE); + } + + const CharSectionsRecord* __cdecl CharSectionsLookupHook( + void* table, int race, int sex, int sectionType, int variation, int color, + uint8_t* found) + { + const CharSectionsRecord* record = g_nextCharSectionsLookup + ? g_nextCharSectionsLookup(table, race, sex, sectionType, variation, color, found) + : nullptr; + if (!record || sectionType < 0 || sectionType >= + static_cast(g_activeCharSections.size())) + return record; + + auto& active = g_activeCharSections[static_cast(sectionType)]; + active.valid = false; + active.id = record->id; + active.race = record->race; + active.sex = record->sex; + active.sectionType = record->sectionType; + active.flags = record->flags; + active.variation = record->variation; + active.color = record->color; + for (size_t i = 0; i < active.texture.size(); ++i) + CopyCharSectionPath(active.texture[i], record->texture[i]); + active.valid = true; + return record; + } + + enum CustomizationComponentKind : uint32_t + { + kComponentRaw = 1, + kComponentSkinColor = 10, + kComponentSkinDetail = 11, + kComponentFaceLower = 20, + kComponentFaceUpper = 21, + kComponentFaceExtra = 22, + kComponentHairStyle = 30, + kComponentHairColor = 40, + kComponentHairColorDetail = 41, + kComponentFacialGeoset1 = 50, + kComponentFacialGeoset2 = 51, + kComponentFacialGeoset3 = 52, + kComponentFacialGeoset4 = 53, + kComponentFacialGeoset5 = 54, + kComponentFacialTexture1 = 55, + kComponentFacialTexture2 = 56, + kComponentFacialTexture3 = 57, + kComponentFacialRaw = 59, + }; + + struct WorkingCustomizationCandidate + { + uint8_t raw = 0; + std::array observations{}; + }; + + const SectionChoice* FindSectionChoice(const std::vector& choices, + uint8_t raw) + { + const auto choice = std::lower_bound(choices.begin(), choices.end(), raw, + [](const SectionChoice& left, uint8_t right) { return left.raw < right; }); + return choice != choices.end() && choice->raw == raw ? &*choice : nullptr; + } + + bool HasTextureSignature(const std::array& signature) + { + return signature[0] != 0 || signature[1] != 0 || signature[2] != 0; + } + + bool ComponentVaries(const std::vector& candidates, + size_t observation) + { + if (candidates.size() < 2) + return false; + const uint32_t first = candidates.front().observations[observation]; + return std::any_of(candidates.begin() + 1, candidates.end(), + [=](const auto& candidate) + { + return candidate.observations[observation] != first; + }); + } + + void AddLogicalComponent(LogicalCustomizationCatalog& catalog, + const std::vector& candidates, + uint32_t kind, size_t observation) + { + if (!ComponentVaries(candidates, observation) + || catalog.componentKinds.size() >= 8) + return; + const size_t destination = catalog.componentKinds.size(); + catalog.componentKinds.push_back(kind); + for (size_t index = 0; index < candidates.size(); ++index) + catalog.candidates[index].components[destination] = + candidates[index].observations[observation]; + } + + void AddRawLogicalComponent(LogicalCustomizationCatalog& catalog, uint32_t kind) + { + if (catalog.candidates.size() < 2 || !catalog.componentKinds.empty()) + return; + catalog.componentKinds.push_back(kind); + for (auto& candidate : catalog.candidates) + candidate.components[0] = candidate.raw; + } + + void InitializeLogicalCandidates(LogicalCustomizationCatalog& catalog, + std::vector& working) + { + std::sort(working.begin(), working.end(), [](const auto& left, const auto& right) + { + return left.raw < right.raw; + }); + working.erase(std::unique(working.begin(), working.end(), + [](const auto& left, const auto& right) { return left.raw == right.raw; }), + working.end()); + catalog.candidates.resize(working.size()); + for (size_t index = 0; index < working.size(); ++index) + catalog.candidates[index].raw = working[index].raw; + } + + void BuildSkinCatalog(const RaceSexCustomizationSource& source, + LogicalCustomizationCatalog& catalog) + { + std::vector working; + working.reserve(source.skin.size()); + std::map, uint32_t> primaryClasses; + std::unordered_map> membersByClass; + + for (const auto& skin : source.skin) + { + std::array signature{}; + if (!source.faceBySkin[skin.raw].empty()) + signature = source.faceBySkin[skin.raw].front().textures; + if (!HasTextureSignature(signature)) + { + if (const auto* underwear = FindSectionChoice(source.underwear, skin.raw)) + signature = underwear->textures; + } + // A missing dependent texture is not evidence that two raw values are the same. + if (!HasTextureSignature(signature)) + signature = { 0xFFFFFFFFu, skin.raw, 0 }; + + auto [classIt, inserted] = primaryClasses.emplace( + signature, static_cast(primaryClasses.size() + 1)); + WorkingCustomizationCandidate candidate; + candidate.raw = skin.raw; + candidate.observations[0] = classIt->second; + working.push_back(candidate); + membersByClass[classIt->second].push_back(working.size() - 1); + } + + for (auto& [_, members] : membersByClass) + { + for (size_t ordinal = 0; ordinal < members.size(); ++ordinal) + working[members[ordinal]].observations[1] = static_cast(ordinal); + } + + InitializeLogicalCandidates(catalog, working); + AddLogicalComponent(catalog, working, kComponentSkinColor, 0); + AddLogicalComponent(catalog, working, kComponentSkinDetail, 1); + AddRawLogicalComponent(catalog, kComponentSkinColor); + } + + void BuildFaceCatalog(const RaceSexCustomizationSource& source, uint8_t skin, + LogicalCustomizationCatalog& catalog) + { + std::vector working; + for (const auto& face : source.faceBySkin[skin]) + { + WorkingCustomizationCandidate candidate; + candidate.raw = face.raw; + for (size_t texture = 0; texture < face.textures.size(); ++texture) + candidate.observations[texture] = face.textures[texture]; + working.push_back(candidate); + } + InitializeLogicalCandidates(catalog, working); + AddLogicalComponent(catalog, working, kComponentFaceLower, 0); + AddLogicalComponent(catalog, working, kComponentFaceUpper, 1); + AddLogicalComponent(catalog, working, kComponentFaceExtra, 2); + AddRawLogicalComponent(catalog, kComponentFaceLower); + } + + void BuildHairStyleCatalog(const RaceSexCustomizationSource& source, uint32_t race, + uint32_t sex, uint8_t color, + LogicalCustomizationCatalog& catalog) + { + std::array present{}; + for (const auto& style : source.hairStyleByColor[color]) + present[style.raw] = true; + for (uint32_t raw = 0; raw < 256; ++raw) + { + if (g_hairStyleChoices.contains(FacialHairChoiceKey(race, sex, raw))) + present[raw] = true; + } + + std::vector working; + for (uint32_t raw = 0; raw < present.size(); ++raw) + { + if (present[raw]) + working.push_back({ static_cast(raw), {} }); + } + InitializeLogicalCandidates(catalog, working); + AddRawLogicalComponent(catalog, kComponentHairStyle); + } + + void BuildHairColorCatalog(const RaceSexCustomizationSource& source, uint8_t hairStyle, + LogicalCustomizationCatalog& catalog) + { + std::vector working; + for (const auto& color : source.hairColorByStyle[hairStyle]) + { + WorkingCustomizationCandidate candidate; + candidate.raw = color.raw; + for (size_t texture = 0; texture < color.textures.size(); ++texture) + candidate.observations[texture] = color.textures[texture]; + if (!source.facialByColor[color.raw].empty()) + { + const auto& facial = source.facialByColor[color.raw].front(); + for (size_t texture = 0; texture < facial.textures.size(); ++texture) + candidate.observations[3 + texture] = facial.textures[texture]; + } + working.push_back(candidate); + } + InitializeLogicalCandidates(catalog, working); + if (working.empty()) + return; + + // Pick the smallest repeated DBC consequence as the primary colour axis. The remaining + // ordinal inside each equivalence class is the packed secondary axis. This discovers + // combinations such as base colour + highlight without interpreting file names. + size_t bestObservation = working.front().observations.size(); + size_t bestDistinct = working.size() + 1; + for (size_t observation = 0; + observation < working.front().observations.size(); ++observation) + { + std::unordered_set distinct; + bool complete = true; + for (const auto& candidate : working) + { + const uint32_t value = candidate.observations[observation]; + if (value == 0) + { + complete = false; + break; + } + distinct.insert(value); + } + if (complete && distinct.size() > 1 && distinct.size() < working.size() + && distinct.size() < bestDistinct) + { + bestObservation = observation; + bestDistinct = distinct.size(); + } + } + + if (bestObservation < working.front().observations.size()) + { + std::map primaryClasses; + std::unordered_map> membersByClass; + for (size_t index = 0; index < working.size(); ++index) + { + const uint32_t token = working[index].observations[bestObservation]; + auto [classIt, inserted] = primaryClasses.emplace( + token, static_cast(primaryClasses.size() + 1)); + working[index].observations[6] = classIt->second; + membersByClass[classIt->second].push_back(index); + } + for (auto& [_, members] : membersByClass) + { + for (size_t ordinal = 0; ordinal < members.size(); ++ordinal) + working[members[ordinal]].observations[7] = + static_cast(ordinal); + } + AddLogicalComponent(catalog, working, kComponentHairColor, 6); + AddLogicalComponent(catalog, working, kComponentHairColorDetail, 7); + } + AddRawLogicalComponent(catalog, kComponentHairColor); + } + + void BuildFacialCatalog(const RaceSexCustomizationSource& source, uint32_t race, + uint32_t sex, uint8_t color, + LogicalCustomizationCatalog& catalog) + { + std::array present{}; + for (const auto& facial : source.facialByColor[color]) + present[facial.raw] = true; + for (uint32_t raw = 0; raw < 256; ++raw) + { + if (g_facialHairChoices.contains(FacialHairChoiceKey(race, sex, raw))) + present[raw] = true; + } + + std::vector working; + for (uint32_t raw = 0; raw < present.size(); ++raw) + { + if (!present[raw]) + continue; + WorkingCustomizationCandidate candidate; + candidate.raw = static_cast(raw); + const auto geosets = g_facialHairChoices.find( + FacialHairChoiceKey(race, sex, raw)); + if (geosets != g_facialHairChoices.end()) + { + for (size_t geoset = 0; geoset < geosets->second.geosets.size(); ++geoset) + candidate.observations[geoset] = geosets->second.geosets[geoset]; + } + if (const auto* facial = FindSectionChoice( + source.facialByColor[color], static_cast(raw))) + { + for (size_t texture = 0; texture < facial->textures.size(); ++texture) + candidate.observations[5 + texture] = facial->textures[texture]; + } + working.push_back(candidate); + } + InitializeLogicalCandidates(catalog, working); + for (size_t geoset = 0; geoset < 5; ++geoset) + AddLogicalComponent(catalog, working, kComponentFacialGeoset1 + + static_cast(geoset), geoset); + for (size_t texture = 0; texture < 3; ++texture) + AddLogicalComponent(catalog, working, kComponentFacialTexture1 + + static_cast(texture), 5 + texture); + AddRawLogicalComponent(catalog, kComponentFacialRaw); + } + + uint64_t LogicalCatalogKey(uint32_t slot, uint32_t race, uint32_t sex, + uint32_t dependency) + { + return static_cast(race) + | (static_cast(sex) << 8) + | (static_cast(slot) << 9) + | (static_cast(dependency) << 16); + } + + const LogicalCustomizationCatalog* GetLogicalCustomizationCatalog( + uint32_t slot, uint32_t race, uint32_t sex, uint32_t dependency) + { + if (slot < 1 || slot > 5 || race == 0 || race > 255 || sex > 1 + || dependency > 255 || !EnsureCustomizationSources()) + return nullptr; + + const uint64_t key = LogicalCatalogKey(slot, race, sex, dependency); + if (const auto existing = g_customizationCatalogs.find(key); + existing != g_customizationCatalogs.end()) + return &existing->second; + + const auto sourceIt = g_customizationSources.find( + RaceSexCustomizationKey(race, sex)); + if (sourceIt == g_customizationSources.end()) + return nullptr; + + LogicalCustomizationCatalog catalog; + const auto& source = sourceIt->second; + switch (slot) + { + case 1: BuildSkinCatalog(source, catalog); break; + case 2: BuildFaceCatalog(source, static_cast(dependency), catalog); break; + case 3: BuildHairStyleCatalog(source, race, sex, + static_cast(dependency), catalog); break; + case 4: BuildHairColorCatalog(source, + static_cast(dependency), catalog); break; + case 5: BuildFacialCatalog(source, race, sex, + static_cast(dependency), catalog); break; + } + WLOG_INFO("moonwell: built customization catalog race=%u sex=%u slot=%u " + "dependency=%u candidates=%zu components=%zu", + race, sex, slot, dependency, catalog.candidates.size(), + catalog.componentKinds.size()); + + // A selector change can visit many dependency values over the lifetime of the glue + // screen (skin drives face; hair colour drives style and facial features). Keeping + // every historical catalog makes repeated customization consume memory without bound. + // Lua consumes one catalog synchronously, so retain only the current dependency for + // this race/sex/slot identity. + const uint64_t identity = key & 0xFFFFu; + for (auto it = g_customizationCatalogs.begin(); + it != g_customizationCatalogs.end();) + { + if ((it->first & 0xFFFFu) == identity) + it = g_customizationCatalogs.erase(it); + else + ++it; + } + const auto [inserted, _] = g_customizationCatalogs.emplace(key, std::move(catalog)); + return &inserted->second; + } + + uint32_t CharacterCustomizationDependency( + uint32_t slot, const std::array& values) + { + switch (slot) + { + case 2: return values[0]; // face depends on skin + case 3: return values[3]; // hair style depends on hair colour + case 4: return values[2]; // hair colour depends on hair style + case 5: return values[3]; // facial features depend on hair colour + default: return 0; + } + } + + bool PrimeCharacterCustomization(uint32_t slot, uint32_t raw) + { + if (slot < 1 || slot > kCharacterCreateCustomizationOffsets.size() || raw > 255) + return false; + + std::array values{}; + uint32_t race = 0; + uint32_t sex = 0; + if (!GetActiveCharacterCustomizationValues(values, &race, &sex)) + return false; + + const uint32_t dependency = CharacterCustomizationDependency(slot, values); + const auto* catalog = GetLogicalCustomizationCatalog(slot, race, sex, dependency); + if (!catalog || std::none_of(catalog->candidates.begin(), catalog->candidates.end(), + [raw](const auto& candidate) { return candidate.raw == raw; })) + return false; + + auto* object = *reinterpret_cast(kCharacterCreateStatePointer); + if (!object) + return false; + + // The stock fields are DWORDs even though character creation serializes their low + // bytes. Priming a mapped neighbour does not rebuild the model; Lua immediately calls + // the stock cycle once, which lands on the requested value and performs the complete + // stock render/dependency refresh before the next frame can be drawn. + std::memcpy(object + kCharacterCreateCustomizationOffsets[slot - 1], + &raw, sizeof(raw)); + return true; + } + + int PrimeCharacterCustomizationFromArgs(void* state, int firstArgument) + { + if (!state || !wxl::game::script::IsNumber(state, firstArgument) + || !wxl::game::script::IsNumber(state, firstArgument + 1)) + return 0; + + const auto slot = static_cast( + wxl::game::script::ToNumber(state, firstArgument)); + const auto raw = static_cast( + wxl::game::script::ToNumber(state, firstArgument + 1)); + wxl::game::script::PushBoolean(state, PrimeCharacterCustomization(slot, raw)); + return 1; + } + + int GetCharacterCustomizationCatalogFromArgs(void* state, int firstArgument) + { + if (!state || !wxl::game::script::IsNumber(state, firstArgument) + || !wxl::game::script::IsNumber(state, firstArgument + 1) + || !wxl::game::script::IsNumber(state, firstArgument + 2) + || !wxl::game::script::IsNumber(state, firstArgument + 3)) + return 0; + + const auto slot = static_cast( + wxl::game::script::ToNumber(state, firstArgument)); + const auto race = static_cast( + wxl::game::script::ToNumber(state, firstArgument + 1)); + const auto sex = static_cast( + wxl::game::script::ToNumber(state, firstArgument + 2)); + const auto dependency = static_cast( + wxl::game::script::ToNumber(state, firstArgument + 3)); + const auto* catalog = GetLogicalCustomizationCatalog(slot, race, sex, dependency); + if (!catalog) + return 0; + + const int candidateArgument = firstArgument + 4; + if (!wxl::game::script::IsNumber(state, candidateArgument)) + { + wxl::game::script::PushNumber(state, catalog->candidates.size()); + wxl::game::script::PushNumber(state, catalog->componentKinds.size()); + for (uint32_t kind : catalog->componentKinds) + wxl::game::script::PushNumber(state, kind); + return 2 + static_cast(catalog->componentKinds.size()); + } + + const int index = static_cast( + wxl::game::script::ToNumber(state, candidateArgument)); + if (index < 1 || index > static_cast(catalog->candidates.size())) + return 0; + const auto& candidate = catalog->candidates[static_cast(index - 1)]; + wxl::game::script::PushNumber(state, candidate.raw); + for (size_t component = 0; component < catalog->componentKinds.size(); ++component) + wxl::game::script::PushNumber(state, candidate.components[component]); + return 1 + static_cast(catalog->componentKinds.size()); + } + + int __cdecl GetCharacterCustomizationCatalog(void* state) + { + return GetCharacterCustomizationCatalogFromArgs(state, 1); + } + + int __cdecl GetCharacterCustomizationAsset(void* state) + { + if (!state || !wxl::game::script::IsNumber(state, 1)) + return 0; + + const int slot = static_cast(wxl::game::script::ToNumber(state, 1)); + constexpr std::array sectionBySlot = { 0, 1, 3, 3, 2 }; + if (slot < 1 || slot > static_cast(sectionBySlot.size())) + return 0; + + const auto& active = g_activeCharSections[ + static_cast(sectionBySlot[static_cast(slot - 1)])]; + if (!active.valid) + return 0; + + // Optional race/sex arguments keep another character-model preview from briefly + // publishing its row on the character-creation screen. DBC sex is 0/1. + if (wxl::game::script::IsNumber(state, 2) + && active.race != static_cast(wxl::game::script::ToNumber(state, 2))) + return 0; + if (wxl::game::script::IsNumber(state, 3) + && active.sex != static_cast(wxl::game::script::ToNumber(state, 3))) + return 0; + + wxl::game::script::PushNumber(state, active.id); + wxl::game::script::PushNumber(state, active.race); + wxl::game::script::PushNumber(state, active.sex); + wxl::game::script::PushNumber(state, active.sectionType); + wxl::game::script::PushNumber(state, active.variation); + wxl::game::script::PushNumber(state, active.color); + wxl::game::script::PushNumber(state, active.flags); + for (const auto& texture : active.texture) + wxl::game::script::PushString(state, texture.data()); + return 10; + } + + int __cdecl GetCharacterCustomizationState(void* state) + { + if (!state) + return 0; + std::array values{}; + uint32_t race = 0; + uint32_t sex = 0; + if (!GetActiveCharacterCustomizationValues(values, &race, &sex)) + { + if (!g_customizationStatePendingLogged) + { + WLOG_INFO("moonwell: character customization state is waiting for the " + "character-create object"); + g_customizationStatePendingLogged = true; + } + return 0; + } + if (!g_customizationStateReadyLogged) + { + WLOG_INFO("moonwell: character customization state ready (race=%u sex=%u)", + race, sex); + g_customizationStateReadyLogged = true; + } + for (uint32_t value : values) + wxl::game::script::PushNumber(state, value); + wxl::game::script::PushNumber(state, race); + wxl::game::script::PushNumber(state, sex); + return 7; + } + + bool InstallCharacterCustomizationAssetDebug() + { + if (!HookByName("Db2.CharSectionsLookup", &CharSectionsLookupHook, + &g_nextCharSectionsLookup)) + { + WLOG_ERROR("moonwell: character customization asset lookup hook failed"); + return false; + } + WLOG_INFO("moonwell: character customization asset lookup installed"); + return true; + } + void* GetCurrentModelFrame(void* state, uint32_t typeToken) { // FrameScript_GetObject takes the type token on the stack, but the @@ -441,14 +1402,49 @@ namespace moonwell using GetContextFn = void*(__cdecl*)(); RegisterFunctionFn g_nextRegisterFunction = nullptr; ValidateCallbackFn g_nextValidateCallback = nullptr; + wxl::game::script::Function g_stockGetHairCustomization = nullptr; void* g_registeredState = nullptr; bool g_registeringMoonWell = false; + constexpr const char* kCustomizationStateCommand = "__MoonWellState"; + constexpr const char* kCustomizationCatalogCommand = "__MoonWellCatalog"; + constexpr const char* kCustomizationPrimeCommand = "__MoonWellPrime"; + constexpr const char* kCustomizationLogCommand = "__MoonWellLog"; + + int __cdecl GetHairCustomizationBridge(void* state) + { + if (state && wxl::game::script::ArgCount(state) >= 1 + && wxl::game::script::IsString(state, 1)) + { + const char* command = wxl::game::script::ToString(state, 1); + if (command && std::strcmp(command, kCustomizationStateCommand) == 0) + return GetCharacterCustomizationState(state); + if (command && std::strcmp(command, kCustomizationCatalogCommand) == 0) + return GetCharacterCustomizationCatalogFromArgs(state, 2); + if (command && std::strcmp(command, kCustomizationPrimeCommand) == 0) + return PrimeCharacterCustomizationFromArgs(state, 2); + if (command && std::strcmp(command, kCustomizationLogCommand) == 0) + { + const char* message = wxl::game::script::ArgCount(state) >= 2 + && wxl::game::script::IsString(state, 2) + ? wxl::game::script::ToString(state, 2) : nullptr; + WLOG_INFO("moonwell: customization UI: %s", + message && *message ? message : "(empty message)"); + return 0; + } + } + return g_stockGetHairCustomization ? g_stockGetHairCustomization(state) : 0; + } + bool IsMoonWellCallback(uintptr_t callback) { return callback == reinterpret_cast(&SetLoginCharacterFlags) || callback == reinterpret_cast(&IsTraitor) - || callback == reinterpret_cast(&SetCharacterCreateCamera); + || callback == reinterpret_cast(&SetCharacterCreateCamera) + || callback == reinterpret_cast(&GetCharacterCustomizationAsset) + || callback == reinterpret_cast(&GetCharacterCustomizationState) + || callback == reinterpret_cast(&GetCharacterCustomizationCatalog) + || callback == reinterpret_cast(&GetHairCustomizationBridge); } void __cdecl ValidateCallbackHook(uintptr_t callback) @@ -462,20 +1458,47 @@ namespace moonwell if (!g_nextRegisterFunction || g_registeringMoonWell) return; constexpr uintptr_t kGetContext = 0x00817DB0; void* state = reinterpret_cast(kGetContext)(); - if (!state || state == g_registeredState) return; + if (!state) return; + + // Glue initialization rebuilds its native-global table in several passes while + // retaining the same lua_State address. Registering only once per pointer leaves the + // MoonWell globals absent after a later pass, so refresh them after every stock native + // registration. g_nextRegisterFunction bypasses this hook, preventing recursion. + const bool firstRegistrationForState = state != g_registeredState; g_registeringMoonWell = true; g_nextRegisterFunction("MoonWellSetLoginCharacterFlags", &SetLoginCharacterFlags); g_nextRegisterFunction("MoonWellIsTraitor", &IsTraitor); g_nextRegisterFunction("MoonWellSetCharacterCreateCamera", &SetCharacterCreateCamera); + g_nextRegisterFunction("MoonWellGetCharacterCustomizationAsset", + &GetCharacterCustomizationAsset); + g_nextRegisterFunction("MoonWellGetCharacterCustomizationState", + &GetCharacterCustomizationState); + g_nextRegisterFunction("MoonWellGetCharacterCustomizationCatalog", + &GetCharacterCustomizationCatalog); g_registeringMoonWell = false; - g_registeredState = state; - WLOG_INFO("moonwell: Lua functions registered for state %p", state); + if (firstRegistrationForState) + { + g_registeredState = state; + WLOG_INFO("moonwell: Lua functions registered for state %p", state); + } } void __cdecl RegisterFunctionHook(const char* name, wxl::game::script::Function function) { - if (g_nextRegisterFunction) g_nextRegisterFunction(name, function); + if (g_nextRegisterFunction) + { + if (name && std::strcmp(name, "GetHairCustomization") == 0) + { + g_stockGetHairCustomization = function; + g_nextRegisterFunction(name, &GetHairCustomizationBridge); + WLOG_INFO("moonwell: GetHairCustomization bridge registered"); + } + else + { + g_nextRegisterFunction(name, function); + } + } RegisterLuaFunctionsForCurrentState(); } @@ -507,6 +1530,7 @@ int __cdecl WXL_Load(const WXL_Api* api) moonwell::InstallBoot(); const bool lua = moonwell::InstallLuaBridge(); + moonwell::InstallCharacterCustomizationAssetDebug(); moonwell::InstallCharacterCreateCamera(); moonwell::InstallEncounterJournalModelPreview(); const bool spells = moonwell::spells::Install(api); diff --git a/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/CharacterCreate.lua b/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/CharacterCreate.lua index c333fbe..7b06467 100644 --- a/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/CharacterCreate.lua +++ b/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/CharacterCreate.lua @@ -5,6 +5,8 @@ CHARACTER_FACING_INCREMENT = 2; MAX_RACES = 10; MAX_CLASSES_PER_RACE = 10; NUM_CHAR_CUSTOMIZATIONS = 5; +MAX_EXTENDED_CHAR_CUSTOMIZATIONS = 20; +EXTENDED_CUSTOMIZATION_ROW_SPACING = 48; MIN_CHAR_NAME_LENGTH = 2; CHARACTER_CREATE_ROTATION_START_X = nil; CHARACTER_ROTATION_INCREMENT = 90; @@ -809,6 +811,11 @@ local function CharacterCreate_UpdatePersonalizationStep() end end + if CharacterCreate_UpdateExtendedCustomization then + CharacterCreate.extendedCustomizationStateKey = nil + CharacterCreate_UpdateExtendedCustomization() + end + CharacterCreate_UpdateGameModeVisibility(); end @@ -872,6 +879,8 @@ function CharacterCreate_OnLoad(self) _G["CharacterCustomizationButtonFrame"..i.."Text"]:SetText(_G["CHAR_CUSTOMIZATION"..i.."_DESC"]) end + self.extendedCustomizationElapsed = 0 + local backdropColor = FACTION_BACKDROP_COLOR_TABLE["Alliance"] CharacterCreateNameEdit:SetBackdropBorderColor(backdropColor[1], backdropColor[2], backdropColor[3]) CharacterCreateNameEdit:SetBackdropColor(backdropColor[4], backdropColor[5], backdropColor[6]) @@ -1117,13 +1126,30 @@ function CharacterCreateFrame_OnMouseUp(button) end end -function CharacterCreateFrame_OnUpdate() +function CharacterCreateFrame_OnUpdate(elapsed) if ( CHARACTER_CREATE_ROTATION_START_X ) then local x = GetCursorPosition(); local diff = (x - CHARACTER_CREATE_ROTATION_START_X) * CHARACTER_ROTATION_CONSTANT; CHARACTER_CREATE_ROTATION_START_X = GetCursorPosition(); SetCharacterCreateFacing(GetCharacterCreateFacing() + diff); end + + if CharacterCreate_ProcessPendingCustomization + and CharacterCreate_ProcessPendingCustomization(elapsed or 0) then + return + end + + CharacterCreate.extendedCustomizationElapsed = + (CharacterCreate.extendedCustomizationElapsed or 0) + (elapsed or 0) + if CharacterCreate.extendedCustomizationElapsed >= 0.10 then + CharacterCreate.extendedCustomizationElapsed = 0 + if CharacterCreate_UpdateCustomizationAssetDebug then + local ok, errorText = pcall(CharacterCreate_UpdateCustomizationAssetDebug) + if not ok and CharacterCreate_LogExtendedCustomization then + CharacterCreate_LogExtendedCustomization("update failed: "..tostring(errorText), true) + end + end + end end function CharacterCreateEnumerateRaces(...) @@ -1751,14 +1777,544 @@ function CharacterCreate_ResetState(immediate) end end +local EXTENDED_CUSTOMIZATION_SLOT_LABELS = { + [1] = "Цвет кожи", + [2] = "Лицо", + [3] = "Прическа", + [4] = "Цвет волос", + [5] = "Борода и усы", +} + +local EXTENDED_CUSTOMIZATION_COMPONENT_LABELS = { + [1] = "Внешность", + [10] = "Цвет кожи", + [11] = "Вариант кожи", + [20] = "Лицо", + [21] = "Верхняя часть лица", + [22] = "Дополнительная особенность лица", + [30] = "Прическа", + [40] = "Цвет волос", + [41] = "Вариант окраски волос", + [50] = "Борода", + [51] = "Усы", + [52] = "Бакенбарды", + [53] = "Особенность лица 1", + [54] = "Особенность лица 2", + [55] = "Нижняя текстура лица", + [56] = "Верхняя текстура лица", + [57] = "Дополнительная текстура лица", + [59] = "Борода и усы", +} + +local function CharacterCreate_GetCustomizationState() + -- The stock global is guaranteed to survive all Glue registration passes. The separately + -- registered MoonWell global may exist as a Lua function object while still failing validation, + -- so never prefer it over the verified dispatcher. + local api = GetHairCustomization + local useStockBridge = type(api) == "function" + if not useStockBridge then api = MoonWellGetCharacterCustomizationState end + if type(api) ~= "function" then + CharacterCreate.extendedCustomizationFailure = "MoonWell API недоступен" + return nil + end + local ok, skin, face, hairStyle, hairColor, facialHair, race, sex + if useStockBridge then + ok, skin, face, hairStyle, hairColor, facialHair, race, sex = + pcall(api, "__MoonWellState") + else + ok, skin, face, hairStyle, hairColor, facialHair, race, sex = pcall(api) + end + if not ok or skin == nil or face == nil or hairStyle == nil or hairColor == nil + or facialHair == nil or race == nil or sex == nil then + CharacterCreate.extendedCustomizationFailure = ok + and "Состояние персонажа не готово" or "Ошибка MoonWell API" + return nil + end + CharacterCreate.extendedCustomizationFailure = nil + local state = { skin, face, hairStyle, hairColor, facialHair } + state.race = race + state.sex = sex + return state +end + +local function CharacterCreate_ValueCount(values) + local count = 0 + for _ in pairs(values) do count = count + 1 end + return count +end + +local function CharacterCreate_AttributesAreCoupled(catalog, leftKey, rightKey) + local leftToRight = {} + local rightToLeft = {} + for _, candidate in ipairs(catalog.candidates) do + local left = candidate.attributes[leftKey] or "" + local right = candidate.attributes[rightKey] or "" + if leftToRight[left] and leftToRight[left] ~= right then return false end + if rightToLeft[right] and rightToLeft[right] ~= left then return false end + leftToRight[left] = right + rightToLeft[right] = left + end + return true +end + +local function CharacterCreate_BuildComponentGroups(catalog, slot) + local keys = {} + for key, meta in pairs(catalog.meta) do + if CharacterCreate_ValueCount(meta.values) > 1 then keys[#keys + 1] = key end + end + table.sort(keys, function(left, right) + return catalog.meta[left].order < catalog.meta[right].order + end) + + local parent = {} + for _, key in ipairs(keys) do parent[key] = key end + local function root(key) + while parent[key] ~= key do + parent[key] = parent[parent[key]] + key = parent[key] + end + return key + end + local function merge(left, right) + left = root(left) + right = root(right) + if left ~= right then parent[right] = left end + end + + for leftIndex=1, #keys do + for rightIndex=leftIndex + 1, #keys do + if CharacterCreate_AttributesAreCoupled(catalog, keys[leftIndex], keys[rightIndex]) then + merge(keys[leftIndex], keys[rightIndex]) + end + end + end + + local groupsByRoot = {} + for _, key in ipairs(keys) do + local keyRoot = root(key) + local group = groupsByRoot[keyRoot] + if not group then + group = { keys = {}, keySet = {}, order = catalog.meta[key].order, + label = catalog.meta[key].label } + groupsByRoot[keyRoot] = group + end + group.keys[#group.keys + 1] = key + group.keySet[key] = true + if catalog.meta[key].order < group.order then + group.order = catalog.meta[key].order + group.label = catalog.meta[key].label + end + end + + catalog.groups = {} + for _, group in pairs(groupsByRoot) do catalog.groups[#catalog.groups + 1] = group end + table.sort(catalog.groups, function(left, right) return left.order < right.order end) + if #catalog.groups == 0 and #catalog.candidates > 1 then + catalog.groups[1] = { + keys = {}, keySet = {}, raw = true, order = 1, + label = EXTENDED_CUSTOMIZATION_SLOT_LABELS[slot], + } + end +end + +local function CharacterCreate_CustomizationDependency(slot, state) + local dependency = 0 + if slot == 2 then dependency = state[1] + elseif slot == 3 or slot == 5 then dependency = state[4] + elseif slot == 4 then dependency = state[3] + end + return dependency +end + +local function CharacterCreate_CustomizationCatalogKey(slot, race, sex, state) + local dependency = CharacterCreate_CustomizationDependency(slot, state) + return race..":"..sex..":"..slot..":"..dependency +end + +local function CharacterCreate_CallCustomizationCatalogAPI(api, useStockBridge, slot, race, + sex, dependency, index) + if useStockBridge then + if index then + return pcall(api, "__MoonWellCatalog", slot, race, sex, dependency, index) + end + return pcall(api, "__MoonWellCatalog", slot, race, sex, dependency) + end + if index then return pcall(api, slot, race, sex, dependency, index) end + return pcall(api, slot, race, sex, dependency) +end + +local function CharacterCreate_BuildCustomizationCatalog(slot, race, sex, state) + CharacterCreate.extendedCustomizationCatalogs = CharacterCreate.extendedCustomizationCatalogs or {} + local key = CharacterCreate_CustomizationCatalogKey(slot, race, sex, state) + local catalog = CharacterCreate.extendedCustomizationCatalogs[slot] + if not catalog or catalog.key ~= key then + catalog = { + key = key, candidates = {}, byRaw = {}, meta = {}, ready = false, + } + CharacterCreate.extendedCustomizationCatalogs[slot] = catalog + end + if catalog.ready then return catalog end + + local api = GetHairCustomization + local useStockBridge = type(api) == "function" + if not useStockBridge then api = MoonWellGetCharacterCustomizationCatalog end + if type(api) ~= "function" then return nil end + local dependency = CharacterCreate_CustomizationDependency(slot, state) + local ok, count, componentCount, kind1, kind2, kind3, kind4, kind5, kind6, kind7, kind8 = + CharacterCreate_CallCustomizationCatalogAPI(api, useStockBridge, slot, race, sex, + dependency) + if not ok or type(count) ~= "number" or type(componentCount) ~= "number" then + return nil + end + local kinds = { kind1, kind2, kind3, kind4, kind5, kind6, kind7, kind8 } + for component=1, componentCount do + local keyName = "component_"..component + local kind = kinds[component] + catalog.meta[keyName] = { + key = keyName, + label = EXTENDED_CUSTOMIZATION_COMPONENT_LABELS[kind] + or EXTENDED_CUSTOMIZATION_SLOT_LABELS[slot] or "Внешность", + order = component, + values = {}, + } + end + + for index=1, count do + local entryOK, raw, value1, value2, value3, value4, value5, value6, value7, value8 = + CharacterCreate_CallCustomizationCatalogAPI(api, useStockBridge, slot, race, sex, + dependency, index) + if not entryOK or raw == nil then return nil end + local values = { value1, value2, value3, value4, value5, value6, value7, value8 } + local candidate = { raw = raw, attributes = {} } + for component=1, componentCount do + local keyName = "component_"..component + local value = tostring(values[component] or 0) + candidate.attributes[keyName] = value + catalog.meta[keyName].values[value] = true + end + catalog.candidates[#catalog.candidates + 1] = candidate + catalog.byRaw[raw] = candidate + end + CharacterCreate_BuildComponentGroups(catalog, slot) + catalog.ready = true + return catalog +end + +local function CharacterCreate_GroupSignature(candidate, group) + if group.raw then return tostring(candidate.raw) end + local values = {} + for _, key in ipairs(group.keys) do values[#values + 1] = candidate.attributes[key] or "" end + return table.concat(values, "|") +end + +local function CharacterCreate_MatchesOutsideGroup(candidate, current, catalog, group) + if group.raw then return true end + for key in pairs(catalog.meta) do + if not group.keySet[key] and (candidate.attributes[key] or "") ~= + (current.attributes[key] or "") then + return false + end + end + return true +end + +local function CharacterCreate_GetGroupChoices(catalog, group, currentRaw) + local current = catalog.byRaw[currentRaw] + if not current then return {}, 0 end + local choices = {} + local signatures = {} + local currentSignature = CharacterCreate_GroupSignature(current, group) + local currentIndex = 0 + for _, candidate in ipairs(catalog.candidates) do + if CharacterCreate_MatchesOutsideGroup(candidate, current, catalog, group) then + local signature = CharacterCreate_GroupSignature(candidate, group) + if not signatures[signature] then + signatures[signature] = true + choices[#choices + 1] = candidate + if signature == currentSignature then currentIndex = #choices end + end + end + end + return choices, currentIndex +end + +local function CharacterCreate_EnsureCustomizationSelectorFrames() + if CharacterCreate.customizationSelectorFrames then + return CharacterCreate.customizationSelectorFrames + end + + local frames = {} + for index=1, MAX_EXTENDED_CHAR_CUSTOMIZATIONS do + local frame = _G["CharacterCustomizationButtonFrame"..index] + if not frame then + frame = CreateFrame("Frame", "MoonWellCharacterCustomizationSelector"..index, + CharacterCreateFrame, "CharacterCustomizationFrameTemplate") + frame:Hide() + end + if frame:GetParent() ~= CharacterCreateFrame then + frame:SetParent(CharacterCreateFrame) + end + frame:SetFrameLevel(CharacterCreateFrame:GetFrameLevel() + 5) + frame:SetID(index) + frames[index] = frame + end + CharacterCreate.customizationSelectorFrames = frames + return frames +end + +function CharacterCreate_LogExtendedCustomization(messageText, once) + if once and CharacterCreate.extendedCustomizationLastLog == messageText then return end + if once then CharacterCreate.extendedCustomizationLastLog = messageText end + if type(GetHairCustomization) == "function" then + pcall(GetHairCustomization, "__MoonWellLog", tostring(messageText)) + end +end + +local function CharacterCreate_ShowCustomizationSelectors(frames, selectors) + local firstY = (#selectors - 1) * EXTENDED_CUSTOMIZATION_ROW_SPACING / 2 + local visibleCount = 0 + for index, frame in ipairs(frames) do + local selector = selectors[index] + if selector then + frame:ClearAllPoints() + frame:SetPoint("RIGHT", CharacterCreateFrame, "RIGHT", -80, + firstY - (index - 1) * EXTENDED_CUSTOMIZATION_ROW_SPACING) + frame:SetID(index) + local text = _G[frame:GetName().."Text"] + if selector.fallback then + text:SetText(selector.label) + else + text:SetText(string.format("%s: %d/%d", + selector.label, selector.currentIndex, #selector.choices)) + end + frame:Show() + visibleCount = visibleCount + 1 + else + frame:Hide() + end + end + return visibleCount +end + +function CharacterCreate_UpdateExtendedCustomization(activeState) + local customizationVisible = CharacterCreate.personalizationMode + and not CharacterCreate.gameModeSelectionMode + if not customizationVisible then + CharacterCreate.pendingExtendedCustomization = nil + CharacterCreate.extendedCustomizationApplying = false + if CharacterCreate.customizationSelectorFrames then + for _, frame in ipairs(CharacterCreate.customizationSelectorFrames) do frame:Hide() end + end + CharacterCreate.extendedCustomizationStateKey = nil + return + end + local frames = CharacterCreate_EnsureCustomizationSelectorFrames() + + local state = activeState or CharacterCreate_GetCustomizationState() + local race = state and state.race or 0 + local sex = state and state.sex or -1 + local stateKey + if state then + stateKey = string.format("%d:%d:%d:%d:%d:%d:%d", race, sex, + state[1], state[2], state[3], state[4], state[5]) + if CharacterCreate.extendedCustomizationStateKey == stateKey then + CharacterCreate_ShowCustomizationSelectors(frames, + CharacterCreate.visibleCustomizationSelectors or {}) + return + end + end + local selectors = {} + local catalogCount = 0 + + if state and race > 0 and (sex == 0 or sex == 1) then + for slot=1, NUM_CHAR_CUSTOMIZATIONS do + local slotSelectorCount = 0 + local catalog = CharacterCreate_BuildCustomizationCatalog(slot, race, sex, state) + if catalog then + catalogCount = catalogCount + 1 + for _, group in ipairs(catalog.groups) do + local choices, currentIndex = CharacterCreate_GetGroupChoices( + catalog, group, state[slot]) + if #choices > 1 and currentIndex > 0 then + selectors[#selectors + 1] = { + slot = slot, group = group, catalog = catalog, + choices = choices, currentIndex = currentIndex, + currentRaw = state[slot], + label = group.label, + } + slotSelectorCount = slotSelectorCount + 1 + end + end + end + if slotSelectorCount == 0 then + selectors[#selectors + 1] = { + slot = slot, fallback = true, + label = EXTENDED_CUSTOMIZATION_SLOT_LABELS[slot] + or _G["CHAR_CUSTOMIZATION"..slot.."_DESC"], + } + end + end + end + + if #selectors == 0 then + for slot=1, NUM_CHAR_CUSTOMIZATIONS do + selectors[#selectors + 1] = { + slot = slot, fallback = true, + label = EXTENDED_CUSTOMIZATION_SLOT_LABELS[slot] + or _G["CHAR_CUSTOMIZATION"..slot.."_DESC"], + } + end + if CharacterCreate.extendedCustomizationFailure then + selectors[1].label = "MoonWell: "..CharacterCreate.extendedCustomizationFailure + end + end + + CharacterCreate.visibleCustomizationSelectors = selectors + local visibleCount = CharacterCreate_ShowCustomizationSelectors(frames, selectors) + CharacterCreate.extendedCustomizationStateKey = + catalogCount == NUM_CHAR_CUSTOMIZATIONS and stateKey or nil + local firstFrame = frames[1] + CharacterCreate_LogExtendedCustomization(string.format( + "layout ready selectors=%d visible=%d catalogs=%d bounds=%.0f..%.0f root=%.0fx%.0f", + #selectors, visibleCount, catalogCount, firstFrame:GetLeft() or -1, + firstFrame:GetRight() or -1, CharacterCreateFrame:GetWidth() or -1, + CharacterCreateFrame:GetHeight() or -1), true) +end + +local function CharacterCreate_PrimeMappedCustomization(slot, raw) + local api = GetHairCustomization + if type(api) ~= "function" then return false end + local ok, primed = pcall(api, "__MoonWellPrime", slot, raw) + return ok and primed == true +end + +local function CharacterCreate_FinishPendingCustomization(reason) + local pending = CharacterCreate.pendingExtendedCustomization + CharacterCreate.pendingExtendedCustomization = nil + CharacterCreate.extendedCustomizationApplying = false + CharacterCreate.extendedCustomizationStateKey = nil + if reason then + CharacterCreate_LogExtendedCustomization(string.format( + "apply %s slot=%d target=%d steps=%d", + reason, pending and pending.slot or -1, pending and pending.targetRaw or -1, + pending and pending.completedSteps or 0), true) + end + CharacterCreate_UpdateExtendedCustomization() +end + +function CharacterCreate_ProcessPendingCustomization(elapsed) + local pending = CharacterCreate.pendingExtendedCustomization + if not pending then return false end + + pending.elapsed = (pending.elapsed or 0) + elapsed + if pending.elapsed < 0.02 then return true end + pending.elapsed = 0 + + local state = CharacterCreate_GetCustomizationState() + if not state then + CharacterCreate_FinishPendingCustomization("cancelled: state unavailable") + return false + end + if state[pending.slot] == pending.targetRaw then + CharacterCreate_FinishPendingCustomization("complete") + return false + end + if pending.remainingSteps <= 0 then + CharacterCreate_FinishPendingCustomization("cancelled: target unreachable") + return false + end + + pending.remainingSteps = pending.remainingSteps - 1 + pending.completedSteps = pending.completedSteps + 1 + CycleCharCustomization(pending.slot, pending.cycleDirection) + return true +end + +local function CharacterCreate_CycleExtendedCustomization(selectorIndex, direction) + local selectors = CharacterCreate.visibleCustomizationSelectors + local selector = selectors and selectors[selectorIndex] + if not selector then return false end + if CharacterCreate.pendingExtendedCustomization then return true end + + PlaySound("gsCharacterCreationLook") + if selector.fallback then + CycleCharCustomization(selector.slot, direction) + return true + end + + local targetIndex = selector.currentIndex + direction + if targetIndex < 1 then targetIndex = #selector.choices end + if targetIndex > #selector.choices then targetIndex = 1 end + local targetRaw = selector.choices[targetIndex].raw + + -- The catalog is also a direct raw-value map. Prime the stock field with the mapped neighbour of + -- the target, then make exactly one stock cycle call. Both operations happen inside this click, + -- before a frame is rendered, while the stock call still performs its complete dependency/model + -- refresh. Keep the incremental path only as a compatibility fallback for an older native bridge. + local currentCandidateIndex + local targetCandidateIndex + for candidateIndex, candidate in ipairs(selector.catalog.candidates) do + if candidate.raw == selector.currentRaw then + currentCandidateIndex = candidateIndex + end + if candidate.raw == targetRaw then targetCandidateIndex = candidateIndex end + end + local candidateCount = #selector.catalog.candidates + local cycleDirection = direction + if currentCandidateIndex and targetCandidateIndex and candidateCount > 0 then + local forward = (targetCandidateIndex - currentCandidateIndex) % candidateCount + local backward = (currentCandidateIndex - targetCandidateIndex) % candidateCount + cycleDirection = forward <= backward and 1 or -1 + end + if targetCandidateIndex and candidateCount > 1 then + local primeIndex = targetCandidateIndex - cycleDirection + if primeIndex < 1 then primeIndex = candidateCount end + if primeIndex > candidateCount then primeIndex = 1 end + local primeRaw = selector.catalog.candidates[primeIndex].raw + if CharacterCreate_PrimeMappedCustomization(selector.slot, primeRaw) then + CharacterCreate.extendedCustomizationApplying = true + CycleCharCustomization(selector.slot, cycleDirection) + CharacterCreate.extendedCustomizationApplying = false + local appliedState = CharacterCreate_GetCustomizationState() + if appliedState and appliedState[selector.slot] == targetRaw then + CharacterCreate.pendingExtendedCustomization = nil + CharacterCreate.extendedCustomizationStateKey = nil + CharacterCreate_LogExtendedCustomization(string.format( + "apply mapped slot=%d target=%d", selector.slot, targetRaw), true) + CharacterCreate_UpdateExtendedCustomization(appliedState) + return true + end + end + end + CharacterCreate.pendingExtendedCustomization = { + slot = selector.slot, + targetRaw = targetRaw, + cycleDirection = cycleDirection, + remainingSteps = candidateCount, + completedSteps = 0, + elapsed = 0.02, + } + CharacterCreate.extendedCustomizationApplying = true + return true +end + function CharacterCustomization_Left(id) - PlaySound("gsCharacterCreationLook"); - CycleCharCustomization(id, -1); + if not CharacterCreate_CycleExtendedCustomization(id, -1) then + PlaySound("gsCharacterCreationLook") + CycleCharCustomization(id, -1) + end end function CharacterCustomization_Right(id) - PlaySound("gsCharacterCreationLook"); - CycleCharCustomization(id, 1); + if not CharacterCreate_CycleExtendedCustomization(id, 1) then + PlaySound("gsCharacterCreationLook") + CycleCharCustomization(id, 1) + end +end + +function CharacterCreate_UpdateCustomizationAssetDebug() + if CharacterCreate.extendedCustomizationApplying then return end + CharacterCreate_UpdateExtendedCustomization(CharacterCreate_GetCustomizationState()) end function CharacterCreate_Randomize() @@ -1792,6 +2348,9 @@ function CharacterCreate_UpdateHairCustomization() CharacterCustomizationButtonFrame3Text:SetText(_G["HAIR_"..GetHairCustomization().."_STYLE"]); CharacterCustomizationButtonFrame4Text:SetText(_G["HAIR_"..GetHairCustomization().."_COLOR"]); CharacterCustomizationButtonFrame5Text:SetText(_G["FACIAL_HAIR_"..GetFacialHairCustomization()]); + if CharacterCreate.extendedCustomizationApplying then return end + CharacterCreate.extendedCustomizationStateKey = nil + CharacterCreate_UpdateExtendedCustomization() end function SetButtonDesaturated(button, desaturated, r, g, b) diff --git a/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/CharacterCreate.xml b/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/CharacterCreate.xml index f994d8c..b011dd5 100644 --- a/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/CharacterCreate.xml +++ b/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/CharacterCreate.xml @@ -72,7 +72,7 @@ if( self.enable ) then - CharacterClass_OnClick(self:GetID()); + CharacterClass_OnClick(self:GetID()); CharacterCreateTooltip:Hide(); end @@ -127,27 +127,27 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - + - + @@ -868,7 +867,7 @@ CharacterCreateFrame_OnMouseUp(button); - CharacterCreateFrame_OnUpdate(); + CharacterCreateFrame_OnUpdate(arg1);