прототип, без успехов. может как-нибудь потом

This commit is contained in:
2026-08-10 21:17:04 +04:00
parent b4a2eb8bc2
commit 87ec8c2ce9
13 changed files with 934 additions and 0 deletions
@@ -0,0 +1,70 @@
#pragma once
#include <array>
#include <cstdint>
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<CustomizationElement, 1> elements;
};
struct CustomizationOption
{
uint32_t id;
const char* name;
std::array<CustomizationChoice, 4> 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;
}
}
@@ -0,0 +1,436 @@
#include "CustomizationRenderer.hpp"
#include <wxl/PluginApi.h>
#include "engine/events/Event.hpp"
#include "game/M2.hpp"
#include "game/World.hpp"
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <limits>
#include <windows.h>
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<uintptr_t>(address);
if (size > std::numeric_limits<uintptr_t>::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<const void*>(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<uintptr_t>(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<m2::off::CharModelObject*>(observedCharModelObject_);
if (!cmo || !cmo->sceneNode)
{
lastError_ = "Waiting for active player CharModelObject event";
return false;
}
auto* root = static_cast<m2::off::M2Instance*>(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<m2::off::M2Instance*>(attachmentRenderContext_);
auto* parent = static_cast<m2::off::M2Instance*>(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::M2_IsDrawableFn>(
m2::off::kIsDrawable)(child, nullptr, 0, 0);
if (drawable)
{
m2::AttachToScene(parentRenderContext_, child,
kCollectionAttachmentSlot, false);
if (reinterpret_cast<void*>(static_cast<uintptr_t>(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<m2::off::M2Instance*>(renderContext);
auto* parent = static_cast<m2::off::M2Instance*>(parentRenderContext_);
if (!parent || !child->bonePalettePtr || !parent->bonePalettePtr)
return;
m2::M2Model childModel(reinterpret_cast<void*>(child->model));
m2::M2Model parentModel(reinterpret_cast<void*>(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<void*>(child->bonePalettePtr),
reinterpret_cast<const void*>(parent->bonePalettePtr),
static_cast<size_t>(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<size_t>(skin->indexCount) * sizeof(uint16_t)) ||
!IsReadableRange(skin->submeshes,
static_cast<size_t>(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<m2::off::M2Instance*>(parentRenderContext_);
void* scene = reinterpret_cast<void*>(static_cast<uintptr_t>(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<char*>(element.modelPath));
if (!child)
{
SetError("CreateSceneModel returned null");
return false;
}
attachmentRenderContext_ = child;
auto* instance = static_cast<m2::off::M2Instance*>(child);
attachmentModel_ = reinterpret_cast<void*>(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<m2::off::M2Instance*>(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);
}
}
@@ -0,0 +1,57 @@
#pragma once
#include "CustomizationState.hpp"
#include <cstdint>
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";
};
}
@@ -0,0 +1,29 @@
#pragma once
#include "CustomizationData.hpp"
#include <cstdint>
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;
};
}
@@ -0,0 +1,115 @@
#include "CustomizationRenderer.hpp"
#include <wxl/PluginApi.h>
#include "engine/events/Event.hpp"
#include <cstdint>
#include <cstdio>
#include <memory>
namespace moonwell::customization
{
namespace ev = wxl::events;
const WXL_Api* g_api = nullptr;
std::unique_ptr<CustomizationRenderer> 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<const ev::UpdateArgs*>(raw);
g_renderer->OnUpdate(args ? args->dt : 0.0f);
}
void __cdecl OnItemSlotChange(void*, const void* raw)
{
const auto* args = static_cast<const ev::ItemSlotChangeArgs*>(raw);
if (args) g_renderer->OnEquipmentChanged(args->charModelObj);
}
void __cdecl OnItemSlotClear(void*, const void* raw)
{
const auto* args = static_cast<const ev::ItemSlotClearArgs*>(raw);
if (args) g_renderer->OnEquipmentChanged(args->charModelObj);
}
void __cdecl OnSkinFinalize(void*, const void* raw)
{
const auto* args = static_cast<const ev::M2SkinFinalizeArgs*>(raw);
if (args) g_renderer->OnSkinFinalize(args->model);
}
void __cdecl OnBuildBonePalette(void*, const void* raw)
{
const auto* args = static_cast<const ev::BuildBonePaletteArgs*>(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<CustomizationRenderer>(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;
}