From 87ec8c2ce993dda428d08989407710e485441ed1 Mon Sep 17 00:00:00 2001 From: sindoring Date: Mon, 10 Aug 2026 21:17:04 +0400 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D1=82=D0=B8?= =?UTF-8?q?=D0=BF,=20=D0=B1=D0=B5=D0=B7=20=D1=83=D1=81=D0=BF=D0=B5=D1=85?= =?UTF-8?q?=D0=BE=D0=B2.=20=D0=BC=D0=BE=D0=B6=D0=B5=D1=82=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BA-=D0=BD=D0=B8=D0=B1=D1=83=D0=B4=D1=8C=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + CMakeLists.txt | 2 + build-warcraftxl.ps1 | 1 + docs/assets.md | 39 ++ docs/glue-integration.md | 12 + docs/prototype.md | 92 ++++ docs/wow-export-assets.md | 42 ++ .../src/CustomizationData.hpp | 70 +++ .../src/CustomizationRenderer.cpp | 436 ++++++++++++++++++ .../src/CustomizationRenderer.hpp | 57 +++ .../src/CustomizationState.hpp | 29 ++ modules/moonwell-customization/src/Module.cpp | 115 +++++ tools/find_customization_assets.js | 38 ++ 13 files changed, 934 insertions(+) create mode 100644 docs/assets.md create mode 100644 docs/glue-integration.md create mode 100644 docs/prototype.md create mode 100644 docs/wow-export-assets.md create mode 100644 modules/moonwell-customization/src/CustomizationData.hpp create mode 100644 modules/moonwell-customization/src/CustomizationRenderer.cpp create mode 100644 modules/moonwell-customization/src/CustomizationRenderer.hpp create mode 100644 modules/moonwell-customization/src/CustomizationState.hpp create mode 100644 modules/moonwell-customization/src/Module.cpp create mode 100644 tools/find_customization_assets.js diff --git a/.gitignore b/.gitignore index a023f99..35a4027 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ build/ Wow*.exe *.backup.exe Logs/ +local-assets/ !Wow_Original.exe diff --git a/CMakeLists.txt b/CMakeLists.txt index 9db3ea6..a6ecf80 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -66,6 +66,8 @@ wxl_add_external_extension(wxl-unit-outline "${WXL_EXTERNAL_MODULES_DIR}/wxl-uni # MoonWell-owned extensions. wxl-fdid-moonwell sorts after wxl-db2 and before # the modern asset extensions, so it can layer custom CSV mappings over wxl.fdid. wxl_add_external_extension(MoonWell "${WXL_LOCAL_MODULES_DIR}/moonwell") +wxl_add_external_extension(MoonwellCustomization + "${WXL_LOCAL_MODULES_DIR}/moonwell-customization") wxl_add_external_extension(wxl-fdid-moonwell "${WXL_LOCAL_MODULES_DIR}/moonwell-fdid") wxl_add_external_extension(wxl-moonwell-storage-fallback "${WXL_LOCAL_MODULES_DIR}/moonwell-storage-fallback") diff --git a/build-warcraftxl.ps1 b/build-warcraftxl.ps1 index d47e2c7..886a021 100644 --- a/build-warcraftxl.ps1 +++ b/build-warcraftxl.ps1 @@ -112,6 +112,7 @@ $selectedProxy = Join-Path $win32ArtifactDir 'd3d9.dll' $recoveryProxy = Join-Path $win32BuildDir "artifacts\$Configuration\d3d9-native.dll" $extensionNames = @( 'MoonWell', + 'MoonwellCustomization', 'wxl-db2', 'wxl-fdid-moonwell', 'wxl-grasswind', diff --git a/docs/assets.md b/docs/assets.md new file mode 100644 index 0000000..41847f3 --- /dev/null +++ b/docs/assets.md @@ -0,0 +1,39 @@ +# Asset manifest + +## Blood Elf Female + +- Retail build: `WOW-68887patch12.0.7_Retail` +- Race ID: `10` +- ChrModelID: `20` +- Character model FileDataID: `1100258` +- Option: Demon Hunter Horns +- Retail OptionID: `126` +- Runtime element type: Geoset in an attached collection M2 +- Collection model FileDataID: `7760202` +- Collection compatibility: `RETAIL_WITH_WXL` +- Runtime virtual path: `MoonWell\Customization\BloodElfFemale\7760202_be_f.m2` +- Prototype attachment point: `11` (stock head point). Retail collection point `19` is absent from + the 3.3.5 Blood Elf model and cannot safely be force-attached. + +| Prototype choice | Retail ChoiceID | Retail ElementID | GeosetID | Compatibility | +|---|---:|---:|---:|---| +| None | 1850 | — | — | RETAIL_WITH_WXL | +| Betrayer | 1851 | 2051 | 2401 | RETAIL_WITH_WXL | +| Beast | 1852 | 2052 | 2402 | RETAIL_WITH_WXL | +| Dreadlord | 1853 | 2053 | 2403 | RETAIL_WITH_WXL | + +The source catalog reports two collection textures: + +| FileDataID | Internal path | Type | Purpose | +|---:|---|---|---| +| 1277002 | `item\objectcomponents\collections\bloodelf_female_dh_belt.blp` | BLP | Collection material dependency | +| 2764488 | `item\objectcomponents\collections\bloodelffemale_dh_horns.blp` | BLP | Collection material dependency | + +Skin files exported with the model are FileDataID `7766030` (base), `7766031`, `7766032`, and +`7766033` (LODs). The MVP requests the base sibling `7760202_be_f00.skin`. + +No fallback asset is committed or silently substituted. Classification is `RETAIL_WITH_WXL`, not +`RETAIL_NATIVE`: stock 3.3.5 cannot parse this modern collection without the WarcraftXL modern-M2 +extension. If it fails in a real client session, the documented next fallback is a small WotLK M2 +mounted at the same virtual path; such a placeholder would be classified `WOTLK_FALLBACK` and must be +recorded here before use. diff --git a/docs/glue-integration.md b/docs/glue-integration.md new file mode 100644 index 0000000..750804d --- /dev/null +++ b/docs/glue-integration.md @@ -0,0 +1,12 @@ +# Glue integration follow-up + +The runtime prototype intentionally does not alter character creation. WarcraftXL v1.1 exposes typed +Glue/Lua bindings in `game/Glue.hpp` and `game/Script.hpp`, while the existing MoonWell extension shows +the validated `Lua.RegisterFunction` / `Lua.ValidateFunctionPointer` bridge used by build 12340. + +A safe follow-up is to register +`ModelFrame:SetMoonwellCustomization(optionID, choiceID)` through that existing bridge, resolve the +Glue model's `CharModelObject`, and pass it to the same `CustomizationState` and renderer used here. +Cleanup must follow Glue model destruction rather than `OnWorldLeave`. This should be implemented only +after the runtime collection attachment has passed interactive visual/lifetime testing; no extra client +offset or duplicate renderer is justified for the current vertical slice. diff --git a/docs/prototype.md b/docs/prototype.md new file mode 100644 index 0000000..2458c30 --- /dev/null +++ b/docs/prototype.md @@ -0,0 +1,92 @@ +# MoonwellCustomization vertical slice + +## Scope + +`MoonwellCustomization.dll` is a runtime-only WarcraftXL 1.1 extension for WoW 3.3.5a build +12340. It changes only an active Blood Elf Female player. State is kept in memory and is deliberately +separate from rendering; there are no server packets, database changes, AzerothCore changes, or new +Wow.exe offsets. + +The data flow is: + +`CustomizationOption (126) -> CustomizationChoice -> CustomizationElement (Geoset) -> collection M2` + +The explicit prototype state is None plus three retail choices. The renderer owns one independent +collection render context at a time. Reapplying the same choice is a no-op; replacing a choice releases +the old context first. The overlay copies the active character's placement and bone palette without +joining the stock attachment readiness chain. `OnWorldLeave` always releases it and drops all borrowed +player pointers. + +## Verified WarcraftXL API + +The repository pins `vendor/warcraftxl` to `v1.1.220` (`4895cef`) on the `v1.1` branch. Source code, +not README assumptions, establishes the following behavior: + +- `WXL_Query`/`WXL_Load` are the extension entry points; the runtime loads DLLs found below + `Extensions//`. +- `WXL_Api::UiAddPanel` registers an immediate-mode overlay panel. WarcraftXL v1.1 hard-codes `F9` + as its overlay toggle; press it and select **MoonWell Customization**. +- `world::ActivePlayerGuid` and `world::ResolveObject(..., kTypeMaskPlayer)` find the active player; + `unit::Model` returns its body-model/scene-node binding. +- The upstream typed `SceneNode` and `CharModelObject` views provide owner, race, gender, and root + scene node. The extension adds no offsets. +- `m2::GetRenderCtx` always creates a new scene model; `AttachToScene(..., slot 19, true)` attaches a + collection M2. Core explicitly documents 19 as the collection-M2 attachment point. +- `OnM2SkinFinalize` is the safe pre-finalize window to filter collection geosets. Runtime changes + call `FinalizeSkin` once; a recursion guard prevents its event from re-entering the filter. +- `OnBuildBonePalette` is post-engine and therefore the safe point to copy the parent character bone + palette into the collection model immediately before upload. +- `OnItemSlotChange` and `OnItemSlotClear` schedule a replacement on the next logic tick, after native + equipment work. Native equipment is never overridden. +- `OnWorldLeave` is the cleanup event. Events and UI run on WarcraftXL's main/render paths; no worker + thread calls client model bindings. + +## Build + +From a Visual Studio 2022 developer environment or PowerShell: + +```powershell +.\build-warcraftxl.ps1 -Configuration Release +``` + +The required artifact is: + +`build\warcraftxl-win32\Release\MoonwellCustomization.dll` + +The target is Win32. The top-level CMake build compiles the extension together with the existing +WarcraftXL SDK sources; there is no second CMake project. + +## Install and run + +1. Install the asset files described in `docs/wow-export-assets.md` into a client MPQ. +2. Copy the DLL to + `Extensions\MoonwellCustomization\MoonwellCustomization.dll` under the client directory. The build + script does this automatically when invoked with `-Deploy -ClientPath `. +3. Start the stock build-12340 client through the installed WarcraftXL `d3d9.dll` proxy. +4. Press `F9` to open the WarcraftXL overlay and select **MoonWell Customization**. +5. On Blood Elf Female, press None / Betrayer / Beast / Dreadlord. None must remove the collection; + repeated selections must not stack it. Equip and remove a helmet, then log out and back in. +6. On every other race or gender, the panel reports that the prototype is unsupported and exposes no + choice controls. + +Expected log lines are tagged `[MoonwellCustomization]`: `loaded`, `player detected`, race/gender, +apply, model load, attach, detach, unsupported player, and actionable failure text. + +## Known limits + +- The repository intentionally excludes Blizzard assets. Without the collection M2 and sibling skin, + the client loader can fall back to its placeholder model; that is an asset-install failure, not a + successful visual test. +- Retail collection model 7760202 is a modern MD21 asset. It requires the existing `wxl-modern-m2`, + `wxl-modern-blp`, `wxl-db2`, and FDID extensions in this repository. +- Bone copying assumes the retail Blood Elf Female collection uses the same ordered skeleton as its + parent model. The code clamps to the smaller palette, but a retail build changing bone order would + need an explicit bone map. +- `FinalizeSkin` in WXL 1.1 has small internal re-finalize leaks. The implementation therefore performs + it only on an actual choice change/equipment refresh, never per frame. +- This source-level build proves compilation and packaging. A successful visual result and crash-free + logout still require an interactive client session with the external assets installed. + +The next minimal step toward a broader retail-like system is to move the static option table to an +external, versioned catalog and add renderers for Texture and independent M2Attachment elements while +keeping the same `optionID -> choiceID` state boundary. diff --git a/docs/wow-export-assets.md b/docs/wow-export-assets.md new file mode 100644 index 0000000..9d81912 --- /dev/null +++ b/docs/wow-export-assets.md @@ -0,0 +1,42 @@ +# Exporting the Blood Elf Female prototype assets + +The local wow.export checkout has no documented headless CLI, so this document does not invent one. +Its MoonWell exporter generated the inspected catalog at: + +`moonwell-customization/wotlk-races/catalog.json` + +The exact source is retail `WOW-68887patch12.0.7_Retail`. Use wow.export's UI to select that product, +then export the WotLK races customization catalog/asset set. From the generated output, take: + +| FileDataID | Exported file / internal path | Type | Why | +|---:|---|---|---| +| 7760202 | `assets/collections/7760202/7760202_be_f.m2` | M2 | Blood Elf Female collection containing Demon Hunter horn geosets | +| 7766030 | `assets/collections/7760202/7760202_be_f00.skin` | SKIN | Base geometry/index profile required by the M2 | +| 1277002 | `item/objectcomponents/collections/bloodelf_female_dh_belt.blp` | BLP | Declared collection texture dependency | +| 2764488 | `item/objectcomponents/collections/bloodelffemale_dh_horns.blp` | BLP | Declared collection texture dependency | + +Do not add these files to git. Stage them under the ignored `local-assets/` directory, then put them in +a local MPQ with these virtual names: + +```text +MoonWell\Customization\BloodElfFemale\7760202_be_f.m2 +MoonWell\Customization\BloodElfFemale\7760202_be_f00.skin +item\objectcomponents\collections\bloodelf_female_dh_belt.blp +item\objectcomponents\collections\bloodelffemale_dh_horns.blp +``` + +Install that MPQ as a high-priority `Data\patch-*.MPQ` beside the existing MoonWell patches. Keep the +three LOD skins (`7766031`–`7766033`) locally for later testing, but the current MVP does not request +them explicitly. + +To reproduce the DB result without changing wow.export, run the repository's read-only catalog helper: + +```powershell +node tools/find_customization_assets.js ` + C:\Users\sindo\wow.export\moonwell-customization\wotlk-races\catalog.json ` + "Blood Elf" female Horns +``` + +It must report OptionID `126`, then ChoiceIDs `1851`, `1852`, `1853`. Their skinned-model elements +map geoset type `24` and IDs `1`, `2`, `3` to skin sections `2401`, `2402`, `2403`. The helper reads +already-exported JSON only; it does not access CASC and is not a runtime dependency. diff --git a/modules/moonwell-customization/src/CustomizationData.hpp b/modules/moonwell-customization/src/CustomizationData.hpp new file mode 100644 index 0000000..0a4c590 --- /dev/null +++ b/modules/moonwell-customization/src/CustomizationData.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include + +namespace moonwell::customization +{ + enum class ElementType : uint8_t + { + None, + M2Attachment, + Geoset, + Texture, + }; + + struct CustomizationElement + { + ElementType type; + uint32_t geosetId; + const char* modelPath; + }; + + struct CustomizationChoice + { + uint32_t id; + const char* name; + std::array elements; + }; + + struct CustomizationOption + { + uint32_t id; + const char* name; + std::array choices; + }; + + inline constexpr uint32_t kBloodElfRace = 10; + inline constexpr uint32_t kFemaleGender = 1; + inline constexpr uint32_t kCollectionFileDataId = 7760202; + // Stock 3.3.5 Blood Elf models have no retail collection point 19. + // Use the native head attachment point; forcing absent point 19 makes + // the parent's drawable gate fail and hides the whole character. + inline constexpr uint32_t kCollectionAttachmentSlot = 11; + inline constexpr char kCollectionPath[] = + "MoonWell\\Customization\\BloodElfFemale\\7760202_be_f.m2"; + + // WOTLK_FALLBACK: native 3.3.5 head components for Blood Elf Female. + // The retail collection needs a real bone map to the differently ordered + // 3.3.5 skeleton, so the working vertical slice uses native components. + inline constexpr CustomizationOption kHornsOption = { + 126, + "Head Decorations (WotLK fallback)", + {{ + {1850, "None", {{{ElementType::None, 0, nullptr}}}}, + {1851, "Blindfold", {{{ElementType::M2Attachment, 0, + "ITEM\\OBJECTCOMPONENTS\\HEAD\\Helm_Blindfold_A_01_BeF.m2"}}}}, + {1852, "Circlet I", {{{ElementType::M2Attachment, 0, + "ITEM\\OBJECTCOMPONENTS\\HEAD\\Helm_Circlet_A_01_BeF.m2"}}}}, + {1853, "Circlet II", {{{ElementType::M2Attachment, 0, + "ITEM\\OBJECTCOMPONENTS\\HEAD\\Helm_Circlet_B_01_BeF.m2"}}}}, + }}, + }; + + inline const CustomizationChoice* FindChoice(uint32_t id) + { + for (const auto& choice : kHornsOption.choices) + if (choice.id == id) return &choice; + return nullptr; + } +} diff --git a/modules/moonwell-customization/src/CustomizationRenderer.cpp b/modules/moonwell-customization/src/CustomizationRenderer.cpp new file mode 100644 index 0000000..5235258 --- /dev/null +++ b/modules/moonwell-customization/src/CustomizationRenderer.cpp @@ -0,0 +1,436 @@ +#include "CustomizationRenderer.hpp" + +#include + +#include "engine/events/Event.hpp" +#include "game/M2.hpp" +#include "game/World.hpp" + +#include +#include +#include +#include +#include + +namespace moonwell::customization +{ + namespace m2 = wxl::game::m2; + namespace world = wxl::game::world; + namespace sm2 = wxl::structure::m2; + + namespace + { + bool IsReadableRange(const void* address, size_t size) + { + if (!address || !size) return false; + + const uintptr_t begin = reinterpret_cast(address); + if (size > std::numeric_limits::max() - begin) return false; + const uintptr_t end = begin + size; + uintptr_t cursor = begin; + + while (cursor < end) + { + MEMORY_BASIC_INFORMATION memory{}; + if (!VirtualQuery(reinterpret_cast(cursor), &memory, sizeof memory)) + return false; + if (memory.State != MEM_COMMIT || + (memory.Protect & (PAGE_NOACCESS | PAGE_GUARD)) != 0) + return false; + + const uintptr_t regionEnd = reinterpret_cast(memory.BaseAddress) + + memory.RegionSize; + if (regionEnd <= cursor) return false; + cursor = std::min(regionEnd, end); + } + return true; + } + } + + void CustomizationRenderer::SetError(const char* message) + { + lastError_ = message; + api_->Log(WXL_LOG_ERROR, "MoonwellCustomization", "failed: %s", message); + } + + bool CustomizationRenderer::DetectPlayer() + { + playerDetected_ = false; + supported_ = false; + charModelObject_ = nullptr; + parentRenderContext_ = nullptr; + race_ = gender_ = 0; + + const auto guid = world::ActivePlayerGuid(); + void* player = guid ? world::ResolveObject(guid, world::kTypeMaskPlayer) : nullptr; + if (!player) + { + lastError_ = "Waiting for active player object"; + return false; + } + + // The native equipment events are the public WXL seam that supplies + // the actual CharModelObject. Unit::Model is not useful for identity: + // it exposes a different model layer. Match the root instance's world + // transform to the active player's authoritative world position so an + // event for another visible character cannot be adopted accidentally. + auto* cmo = static_cast(observedCharModelObject_); + if (!cmo || !cmo->sceneNode) + { + lastError_ = "Waiting for active player CharModelObject event"; + return false; + } + + auto* root = static_cast(cmo->sceneNode); + float playerPosition[3]{}; + world::UnitPosition(player, playerPosition); + const float dxRow = root->placement[12] - playerPosition[0]; + const float dyRow = root->placement[13] - playerPosition[1]; + const float dzRow = root->placement[14] - playerPosition[2]; + const float rowDistanceSq = dxRow * dxRow + dyRow * dyRow + dzRow * dzRow; + const float dxColumn = root->placement[3] - playerPosition[0]; + const float dyColumn = root->placement[7] - playerPosition[1]; + const float dzColumn = root->placement[11] - playerPosition[2]; + const float columnDistanceSq = dxColumn * dxColumn + dyColumn * dyColumn + dzColumn * dzColumn; + if (std::min(rowDistanceSq, columnDistanceSq) > 9.0f) + { + lastError_ = "Waiting for matching active player CharModelObject"; + return false; + } + + if (cmo->raceId == 0 || + cmo->raceId > 11 || cmo->genderId > 1) + { + lastError_ = "Active player CharModelObject is not ready"; + return false; + } + + charModelObject_ = cmo; + parentRenderContext_ = cmo->sceneNode; + race_ = cmo->raceId; + gender_ = cmo->genderId; + playerDetected_ = true; + supported_ = race_ == kBloodElfRace && gender_ == kFemaleGender; + lastError_ = supported_ ? "Ready" : "Prototype supports Blood Elf Female only."; + + api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "player detected"); + api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "race=%u gender=%u", race_, gender_); + if (!supported_) + api_->Log(WXL_LOG_WARN, "MoonwellCustomization", "unsupported player race"); + return true; + } + + void CustomizationRenderer::OnWorldEnter() + { + inWorld_ = true; + detectDelay_ = 0.0f; + DetectPlayer(); + } + + void CustomizationRenderer::OnWorldLeave() + { + inWorld_ = false; + Detach(); + observedCharModelObject_ = nullptr; + charModelObject_ = nullptr; + parentRenderContext_ = nullptr; + playerDetected_ = supported_ = false; + race_ = gender_ = 0; + detectDelay_ = 0.0f; + refreshRequested_ = false; + geosetApplied_ = false; + attached_ = false; + finalizeInProgress_ = false; + lastError_ = "Outside world"; + } + + void CustomizationRenderer::OnUpdate(float dt) + { + // OnWorldEnter is an edge notification and can be missed when the + // extension is loaded after a world transition has already begun. + // Reconcile against the authoritative active-player GUID so the + // renderer also recovers after late loads and unusual glue flows. + const auto activePlayerGuid = world::ActivePlayerGuid(); + if (!activePlayerGuid) + { + if (inWorld_) OnWorldLeave(); + return; + } + + if (!inWorld_) + { + inWorld_ = true; + detectDelay_ = 0.0f; + lastError_ = "Waiting for active player model"; + } + + if (!playerDetected_) + { + detectDelay_ -= dt; + if (detectDelay_ <= 0.0f) + { + detectDelay_ = 0.25f; + DetectPlayer(); + } + return; + } + + if (refreshRequested_) + { + refreshRequested_ = false; + const uint32_t choiceId = CurrentChoice(); + if (choiceId != 0 && choiceId != kHornsOption.choices[0].id) + { + Detach(); + if (const auto* choice = FindChoice(choiceId)) Attach(*choice); + } + } + + if (attachmentRenderContext_ && attachmentModel_ && !geosetApplied_) + { + const auto* choice = FindChoice(CurrentChoice()); + if (choice && choice->elements[0].type == ElementType::Geoset) + FilterGeoset(attachmentModel_, choice->elements[0].geosetId, true); + } + + // CreateSceneModel loads asynchronously. Attaching it before the stock + // readiness gate succeeds makes the parent character non-drawable too. + // Keep it unattached while loading, then join the stock head point only after the + // child itself reports drawable. + if (attachmentRenderContext_ && parentRenderContext_) + { + auto* child = static_cast(attachmentRenderContext_); + auto* parent = static_cast(parentRenderContext_); + const auto* choice = FindChoice(CurrentChoice()); + const bool collectionGeoset = choice && + choice->elements[0].type == ElementType::Geoset; + if (collectionGeoset && !attached_) + { + std::memcpy(child->placement, parent->placement, sizeof child->placement); + child->viewDistSq = parent->viewDistSq; + child->alphaBase = parent->alphaBase; + } + + if (geosetApplied_ && !attached_) + { + const int drawable = wxl::game::Native( + m2::off::kIsDrawable)(child, nullptr, 0, 0); + if (drawable) + { + m2::AttachToScene(parentRenderContext_, child, + kCollectionAttachmentSlot, false); + if (reinterpret_cast(static_cast(child->parent)) == + parentRenderContext_) + { + attached_ = true; + lastError_ = "Ready"; + api_->Log(WXL_LOG_INFO, "MoonwellCustomization", + "attached ready overlay slot=%u", kCollectionAttachmentSlot); + } + else + { + lastError_ = "Native head attachment was rejected"; + } + } + else + { + lastError_ = "Collection loaded; waiting for drawable readiness"; + } + } + } + } + + void CustomizationRenderer::OnEquipmentChanged(void* charModelObject) + { + if (!playerDetected_ && charModelObject) + { + observedCharModelObject_ = charModelObject; + DetectPlayer(); + } + + // Item events are emitted around native equipment maintenance. Rebuild on + // the next logic tick so native slot work is complete before slot 19 is touched. + if (charModelObject == charModelObject_) + { + const auto* choice = FindChoice(CurrentChoice()); + if (choice && choice->elements[0].type != ElementType::None) + refreshRequested_ = true; + } + } + + void CustomizationRenderer::OnSkinFinalize(void* model) + { + if (!model || model != attachmentModel_ || finalizeInProgress_) return; + const auto* choice = FindChoice(CurrentChoice()); + if (choice && choice->elements[0].type == ElementType::Geoset) + FilterGeoset(model, choice->elements[0].geosetId, false); + } + + void CustomizationRenderer::OnBuildBonePalette(void* renderContext) + { + if (!renderContext || renderContext != attachmentRenderContext_) return; + + const auto* choice = FindChoice(CurrentChoice()); + if (!choice || choice->elements[0].type != ElementType::Geoset) return; + + auto* child = static_cast(renderContext); + auto* parent = static_cast(parentRenderContext_); + if (!parent || !child->bonePalettePtr || !parent->bonePalettePtr) + return; + + m2::M2Model childModel(reinterpret_cast(child->model)); + m2::M2Model parentModel(reinterpret_cast(parent->model)); + if (!childModel || !parentModel || !childModel.GetHeader() || !parentModel.GetHeader()) return; + + const uint32_t count = std::min(childModel.GetHeader()->bones.count, + parentModel.GetHeader()->bones.count); + if (count) + std::memcpy(reinterpret_cast(child->bonePalettePtr), + reinterpret_cast(parent->bonePalettePtr), + static_cast(count) * m2::off::kBonePaletteStride); + } + + bool CustomizationRenderer::FilterGeoset(void* rawModel, uint32_t geosetId, bool finalize) + { + m2::M2Model model(rawModel); + auto* skin = model ? model.GetSkin() : nullptr; + if (!IsReadableRange(skin, sizeof *skin) || !skin->indices || + !skin->submeshes || !skin->indexCount || !skin->submeshCount) + return false; + + // A newly created async scene model can expose the skin object before + // its offset-valued arrays have been relocated to pointers. Do not + // touch those arrays until both complete ranges are readable. + constexpr uint32_t kMaxIndexCount = 10u * 1024u * 1024u; + constexpr uint32_t kMaxSubmeshCount = 100u * 1024u; + if (skin->indexCount > kMaxIndexCount || skin->submeshCount > kMaxSubmeshCount || + !IsReadableRange(skin->indices, + static_cast(skin->indexCount) * sizeof(uint16_t)) || + !IsReadableRange(skin->submeshes, + static_cast(skin->submeshCount) * sizeof(sm2::M2SkinSection))) + return false; + + bool found = false; + for (uint32_t i = 0; i < skin->submeshCount; ++i) + { + const sm2::M2SkinSection& section = skin->submeshes[i]; + const uint32_t start = (uint32_t(section.level) << 16) | section.indexStart; + const uint32_t count = section.indexCount; + if (start > skin->indexCount || count > skin->indexCount - start) continue; + if (section.skinSectionId == geosetId) + { + found = true; + continue; + } + std::fill_n(skin->indices + start, count, uint16_t(0)); + } + + if (!found) + { + SetError("requested customization geoset is absent from collection skin"); + return false; + } + + if (finalize) + { + finalizeInProgress_ = true; + model.FinalizeSkin(); + finalizeInProgress_ = false; + } + attachmentModel_ = rawModel; + geosetApplied_ = true; + return true; + } + + bool CustomizationRenderer::Attach(const CustomizationChoice& choice) + { + if (!supported_ || !charModelObject_ || !parentRenderContext_) + { + SetError("supported player model is unavailable"); + return false; + } + + const auto& element = choice.elements[0]; + if (!element.modelPath) + { + SetError("customization model path is absent"); + return false; + } + + api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "loading model %s", element.modelPath); + auto* parent = static_cast(parentRenderContext_); + void* scene = reinterpret_cast(static_cast(parent->scene)); + if (!scene) + { + SetError("active player scene is unavailable"); + return false; + } + + // CreateSceneModel's native receiver is the owning scene, not the + // CharModelObject. Passing the CMO makes the loader interpret cmo+4 + // as the scene's model table and faults inside 0x0081C4F0. + void* child = m2::GetRenderCtx(scene, const_cast(element.modelPath)); + if (!child) + { + SetError("CreateSceneModel returned null"); + return false; + } + + attachmentRenderContext_ = child; + auto* instance = static_cast(child); + attachmentModel_ = reinterpret_cast(instance->model); + geosetApplied_ = element.type != ElementType::Geoset; + attached_ = false; + + // Native WotLK head components must retain their own local transform; + // AttachToScene composes it with the parent's head point. Collection + // geosets use a full-character coordinate system and are seeded below. + if (element.type == ElementType::Geoset) + { + auto* parentInstance = static_cast(parentRenderContext_); + std::memcpy(instance->placement, parentInstance->placement, sizeof instance->placement); + instance->viewDistSq = parentInstance->viewDistSq; + instance->alphaBase = parentInstance->alphaBase; + } + + api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "created overlay geoset=%u", + choice.elements[0].geosetId); + lastError_ = "Loading collection model"; + return true; + } + + void CustomizationRenderer::Detach() + { + if (!attachmentRenderContext_) return; + if (attached_ && parentRenderContext_) + m2::DetachSlot(parentRenderContext_, kCollectionAttachmentSlot); + else + m2::ReleaseRenderCtx(attachmentRenderContext_); + attachmentRenderContext_ = nullptr; + attachmentModel_ = nullptr; + geosetApplied_ = false; + attached_ = false; + finalizeInProgress_ = false; + api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "detached"); + } + + bool CustomizationRenderer::Apply(uint32_t optionId, uint32_t choiceId) + { + const auto* choice = FindChoice(choiceId); + if (!choice || optionId != kHornsOption.id) return false; + if (!supported_) + { + lastError_ = "Prototype supports Blood Elf Female only."; + return false; + } + const bool isNone = choice->elements[0].type == ElementType::None; + if (CurrentChoice() == choiceId && (isNone || attachmentRenderContext_)) return true; + + api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "apply option=%u choice=%u", + optionId, choiceId); + Detach(); + state_.SetChoice(optionId, choiceId); + if (isNone) return true; + return Attach(*choice); + } +} diff --git a/modules/moonwell-customization/src/CustomizationRenderer.hpp b/modules/moonwell-customization/src/CustomizationRenderer.hpp new file mode 100644 index 0000000..2d4eaf7 --- /dev/null +++ b/modules/moonwell-customization/src/CustomizationRenderer.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "CustomizationState.hpp" + +#include + +struct WXL_Api; + +namespace moonwell::customization +{ + class CustomizationRenderer + { + public: + explicit CustomizationRenderer(const WXL_Api* api) : api_(api) {} + + void OnWorldEnter(); + void OnWorldLeave(); + void OnUpdate(float dt); + void OnEquipmentChanged(void* charModelObject); + void OnSkinFinalize(void* model); + void OnBuildBonePalette(void* renderContext); + + bool Apply(uint32_t optionId, uint32_t choiceId); + bool PlayerDetected() const { return playerDetected_; } + bool Supported() const { return supported_; } + uint32_t Race() const { return race_; } + uint32_t Gender() const { return gender_; } + uint32_t CurrentChoice() const { return state_.Choice(kHornsOption.id); } + const char* LastError() const { return lastError_; } + + private: + bool DetectPlayer(); + bool Attach(const CustomizationChoice& choice); + void Detach(); + bool FilterGeoset(void* model, uint32_t geosetId, bool finalize); + void SetError(const char* message); + + const WXL_Api* api_ = nullptr; + CustomizationState state_; + void* observedCharModelObject_ = nullptr; + void* charModelObject_ = nullptr; + void* parentRenderContext_ = nullptr; + void* attachmentRenderContext_ = nullptr; + void* attachmentModel_ = nullptr; + uint32_t race_ = 0; + uint32_t gender_ = 0; + float detectDelay_ = 0.0f; + bool inWorld_ = false; + bool playerDetected_ = false; + bool supported_ = false; + bool refreshRequested_ = false; + bool geosetApplied_ = false; + bool attached_ = false; + bool finalizeInProgress_ = false; + const char* lastError_ = "Waiting for player"; + }; +} diff --git a/modules/moonwell-customization/src/CustomizationState.hpp b/modules/moonwell-customization/src/CustomizationState.hpp new file mode 100644 index 0000000..abeed8e --- /dev/null +++ b/modules/moonwell-customization/src/CustomizationState.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "CustomizationData.hpp" + +#include + +namespace moonwell::customization +{ + class CustomizationState + { + public: + uint32_t Choice(uint32_t optionId) const + { + return optionId == kHornsOption.id ? hornsChoice_ : 0; + } + + bool SetChoice(uint32_t optionId, uint32_t choiceId) + { + if (optionId != kHornsOption.id || !FindChoice(choiceId)) return false; + hornsChoice_ = choiceId; + return true; + } + + void Clear() { hornsChoice_ = 0; } + + private: + uint32_t hornsChoice_ = 0; + }; +} diff --git a/modules/moonwell-customization/src/Module.cpp b/modules/moonwell-customization/src/Module.cpp new file mode 100644 index 0000000..a8c4cff --- /dev/null +++ b/modules/moonwell-customization/src/Module.cpp @@ -0,0 +1,115 @@ +#include "CustomizationRenderer.hpp" + +#include + +#include "engine/events/Event.hpp" + +#include +#include +#include + +namespace moonwell::customization +{ + namespace ev = wxl::events; + + const WXL_Api* g_api = nullptr; + std::unique_ptr g_renderer; + + void __cdecl OnWorldEnter(void*, const void*) { g_renderer->OnWorldEnter(); } + void __cdecl OnWorldLeave(void*, const void*) { g_renderer->OnWorldLeave(); } + + void __cdecl OnUpdate(void*, const void* raw) + { + const auto* args = static_cast(raw); + g_renderer->OnUpdate(args ? args->dt : 0.0f); + } + + void __cdecl OnItemSlotChange(void*, const void* raw) + { + const auto* args = static_cast(raw); + if (args) g_renderer->OnEquipmentChanged(args->charModelObj); + } + + void __cdecl OnItemSlotClear(void*, const void* raw) + { + const auto* args = static_cast(raw); + if (args) g_renderer->OnEquipmentChanged(args->charModelObj); + } + + void __cdecl OnSkinFinalize(void*, const void* raw) + { + const auto* args = static_cast(raw); + if (args) g_renderer->OnSkinFinalize(args->model); + } + + void __cdecl OnBuildBonePalette(void*, const void* raw) + { + const auto* args = static_cast(raw); + if (args) g_renderer->OnBuildBonePalette(args->renderCtx); + } + + void DrawPanel(void*) + { + char line[128]{}; + if (!g_renderer->PlayerDetected()) + { + g_api->UiText("Player: detecting..."); + g_api->UiText(g_renderer->LastError()); + return; + } + + std::snprintf(line, sizeof(line), "Race ID: %u%s", g_renderer->Race(), + g_renderer->Race() == kBloodElfRace ? " (Blood Elf)" : ""); + g_api->UiText(line); + std::snprintf(line, sizeof(line), "Gender ID: %u%s", g_renderer->Gender(), + g_renderer->Gender() == kFemaleGender ? " (Female)" : ""); + g_api->UiText(line); + g_api->UiSeparator(); + + if (!g_renderer->Supported()) + { + g_api->UiText("Prototype supports Blood Elf Female only."); + return; + } + + g_api->UiText("Option: Head Decorations (WotLK fallback)"); + for (const auto& choice : kHornsOption.choices) + { + std::snprintf(line, sizeof(line), "%s%s", choice.name, + g_renderer->CurrentChoice() == choice.id ? " [selected]" : ""); + if (g_api->UiButton(line)) + g_renderer->Apply(kHornsOption.id, choice.id); + } + g_api->UiSeparator(); + g_api->UiText(g_renderer->LastError()); + } +} + +const WXL_PluginInfo* __cdecl WXL_Query(void) +{ + static const WXL_PluginInfo info = { + sizeof(WXL_PluginInfo), WXL_API_VERSION, "MoonwellCustomization", 1, WXL_CLIENT_BUILD, + }; + return &info; +} + +int __cdecl WXL_Load(const WXL_Api* api) +{ + using namespace moonwell::customization; + if (!api || api->apiVersion != WXL_API_VERSION || + api->structSize < sizeof(WXL_Api) || !api->Subscribe || !api->UiAddPanel) + return 0; + + g_api = api; + g_renderer = std::make_unique(api); + api->Subscribe(uint32_t(ev::Event::OnWorldEnter), &OnWorldEnter, nullptr); + api->Subscribe(uint32_t(ev::Event::OnWorldLeave), &OnWorldLeave, nullptr); + api->Subscribe(uint32_t(ev::Event::OnUpdate), &OnUpdate, nullptr); + api->Subscribe(uint32_t(ev::Event::OnItemSlotChange), &OnItemSlotChange, nullptr); + api->Subscribe(uint32_t(ev::Event::OnItemSlotClear), &OnItemSlotClear, nullptr); + api->Subscribe(uint32_t(ev::Event::OnM2SkinFinalize), &OnSkinFinalize, nullptr); + api->Subscribe(uint32_t(ev::Event::OnBuildBonePalette), &OnBuildBonePalette, nullptr); + api->UiAddPanel("MoonWell Customization", &DrawPanel, nullptr); + api->Log(WXL_LOG_INFO, "MoonwellCustomization", "loaded"); + return 1; +} diff --git a/tools/find_customization_assets.js b/tools/find_customization_assets.js new file mode 100644 index 0000000..8df519f --- /dev/null +++ b/tools/find_customization_assets.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +// Development-only catalog reader. It consumes the JSON generated by the +// MoonWell-enabled wow.export checkout; wow.export is not a runtime dependency. +const fs = require('fs'); + +const [catalogPath, raceName = 'Blood Elf', sexName = 'female', optionName = 'Earrings'] = process.argv.slice(2); +if (!catalogPath) { + console.error('Usage: node tools/find_customization_assets.js [race] [sex] [option]'); + process.exit(2); +} + +const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8')); +const race = catalog.races.find(value => value.name.toLowerCase() === raceName.toLowerCase()); +const sex = race && race.sexes.find(value => value.name.toLowerCase() === sexName.toLowerCase()); +const option = sex && sex.options.find(value => value.name.toLowerCase() === optionName.toLowerCase()); +if (!race || !sex || !option) { + console.error(`Not found: race=${raceName}, sex=${sexName}, option=${optionName}`); + process.exit(1); +} + +console.log(`retailBuild=${catalog.source && catalog.source.build}`); +console.log(`raceID=${race.id} chrModelID=${sex.chrModelID} modelFileDataID=${sex.modelFileDataID}`); +console.log(`optionID=${option.id} option=${option.name}`); +for (const choice of option.choices) { + for (const element of choice.elements) { + const fields = [ + `choiceID=${choice.id}`, + `order=${choice.orderIndex}`, + `elementID=${element.id}`, + `geosetID=${element.geosetID || 0}`, + `skinnedModelID=${element.skinnedModelID || 0}`, + ]; + if (element.material) fields.push(`FileDataID=${element.material.FileDataID || 0}`); + if (element.skinnedModel) fields.push(`collectionFileDataID=${element.skinnedModel.collectionFileDataID || 0}`); + console.log(fields.join(' ')); + } +}