прототип, без успехов. может как-нибудь потом
This commit is contained in:
@@ -8,4 +8,5 @@ build/
|
||||
Wow*.exe
|
||||
*.backup.exe
|
||||
Logs/
|
||||
local-assets/
|
||||
!Wow_Original.exe
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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/<name>/`.
|
||||
- `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 <client>`.
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <catalog.json> [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(' '));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user