feat(model): add DressUpModel try-on hand, enchant glow, sheathing and auto-dress

TryOn now takes retail's (item [, handSlotName, spellEnchantID]) and
returns an Enum.ItemTryOnReason. The pane gains IsGeoReady, SetSheathed /
GetSheathed and SetAutoDress / GetAutoDress.

Hand: TryOnItem (0x00504540) picks a lane at one site, 0x0050492D
(mov edx,[edi+0x2C]; cmp edx,0x0D). A 6-byte call there jumps a named hand
to the worker's own main-hand (0x0050495F) or off-hand (0x005049B1) path,
so its CanHoldBoth (0x00504BC0) eviction, lane store and attach all still
run; with no hand it replays the two instructions and the jnz reads their
flags. The name is matched like GetInventorySlotInfo matches it: SStrCmpI
over the PaperDollItemFrame rows, slot 16 or 17.

Enchant glow: the player's AttachEnchantVisual step (0x005EED50) on the
attachment AttachItemModel (0x0047A0C0) returns: FindChild (0x00712F00),
then AttachItemVisuals (0x00479700) with SpellItemEnchantment +0x58,
skipped when the display's own ItemVisuals row exists (GetItemDescVisual's
rule, 0x0062EC70). The enchant is the argument, else the link's enchant
field, which Item::Arg now parses.

Sheathing re-runs the unit's AttachHandItem step on the pane's lanes with
the sheathed flag. The lanes keep no display row, so the pane records one:
the unit's vtable +0x9C row when SetModelInstance (slot 0x00808438) clones
it, then whatever the worker's attach (0x00504AE6) installs; its two
evictions (0x00504A0C, 0x00504A55) drop theirs. Each lane keeps its glow
too, a player's read through GetVisibleItem + GetItemDescVisual, so a
re-hung weapon keeps it. Auto-dress off runs ClearModel (0x00504490) after
SetModelInstance unless Dress() is the one rebuilding.

TryOn's result reads the gates the worker passes: an uncached record is
DataPending, a held type installs, armor needs the component and the
ready byte (+0x40C).

DressUpItemLink now passes the whole link to DressUpItem, so a
Control-click shows the link's enchant, and skips a cached item that
IsDressableItem rejects. DressUpItem stays, since Blizzard_AuctionUI and
Turtle both replace it.
This commit is contained in:
Brues
2026-11-04 10:16:07 -06:00
parent 3e0c40184b
commit 4726fd7c85
8 changed files with 624 additions and 25 deletions
+1 -1
View File
@@ -112,7 +112,7 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| [Map](docs/API.md#map) | `C_Map.CanSetUserWaypointOnMap`, `C_Map.ClearUserWaypoint`, `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetFallbackWorldMapID`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapArtLayers`, `C_Map.GetMapArtLayerTextures`, `C_Map.GetMapChildrenInfo`, `C_Map.GetMapInfo`, `C_Map.GetMapInfoAtPosition`, `C_Map.GetMapOverlays`, `C_Map.GetMapPosFromWorldPos`, `C_Map.GetMapRectOnMap`, `C_Map.GetMapWorldSize`, `C_Map.GetPlayerMapPosition`, `C_Map.GetUserWaypoint`, `C_Map.GetUserWaypointFromHyperlink`, `C_Map.GetUserWaypointHyperlink`, `C_Map.GetUserWaypointPositionForMap`, `C_Map.GetWorldPosFromMapPos`, `C_Map.HasUserWaypoint`, `C_Map.MapHasArt`, `C_Map.SetUserWaypoint` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredAreaIDsAtPosition`, `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems`, `GetBuybackItemLink` |
| [Model](docs/API.md#model) | `DressUpModel:TryOn` (links, recipes, names), `DressUpModel:UndressSlot`, `Model:SetGlow`, `PlayerModel:RefreshCamera`, `PlayerModel:SetCamDistanceScale`, `PlayerModel:SetCreature`, `PlayerModel:SetDisplayInfo`, `PlayerModel:SetPortraitZoom`, `PlayerModel:SetRotation` (`animate`) |
| [Model](docs/API.md#model) | `DressUpModel:TryOn` (links, recipes, names, hand, enchant glow, result), `DressUpModel:UndressSlot`, `DressUpModel:IsGeoReady`, `DressUpModel:SetSheathed` / `GetSheathed`, `DressUpModel:SetAutoDress` / `GetAutoDress`, `Model:SetGlow`, `PlayerModel:RefreshCamera`, `PlayerModel:SetCamDistanceScale`, `PlayerModel:SetCreature`, `PlayerModel:SetDisplayInfo`, `PlayerModel:SetPortraitZoom`, `PlayerModel:SetRotation` (`animate`) |
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
| [NameCache](docs/API.md#namecache) | `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
+95 -4
View File
@@ -786,7 +786,12 @@ build instructions.
- [`model:RefreshCamera()`](#modelrefreshcamera)
- [`model:SetRotation(rotation [, animate])`](#modelsetrotationrotation--animate)
- [`DressUpModel:UndressSlot(slot)`](#dressupmodelundressslotslot)
- [`DressUpModel:TryOn(item)`](#dressupmodeltryonitem)
- [`DressUpModel:TryOn(item [, handSlotName, spellEnchantID])`](#dressupmodeltryonitem--handslotname-spellenchantid)
- [`DressUpModel:IsGeoReady()`](#dressupmodelisgeoready)
- [`DressUpModel:SetSheathed([sheathed, hideWeapons])`](#dressupmodelsetsheathedsheathed-hideweapons)
- [`DressUpModel:GetSheathed()`](#dressupmodelgetsheathed)
- [`DressUpModel:SetAutoDress([enabled])`](#dressupmodelsetautodressenabled)
- [`DressUpModel:GetAutoDress()`](#dressupmodelgetautodress)
- [`Model:SetGlow(value)`](#modelsetglowvalue)
- [ChatBubbles](#chatbubbles)
@@ -18594,7 +18599,7 @@ inventory slot number: 1 head, 3 shoulder, 4 shirt, 5 chest, 6 waist,
DressUpModel:UndressSlot(1) -- remove the helm
```
### `DressUpModel:TryOn(item)`
### `DressUpModel:TryOn(item [, handSlotName, spellEnchantID])`
Puts an item on a `DressUpModel`. `item` is one of these:
@@ -18609,12 +18614,98 @@ the item that the recipe makes. Thus Control-click on a pattern opens the
dressing room with the item that the pattern makes.
If `item` is none of these, the game shows the error "That item can't be
equipped". The model chooses the hand for a weapon itself.
equipped".
`handSlotName` sets the hand that holds a weapon, a shield, or an off-hand
item. Give `"MAINHANDSLOT"` or `"SECONDARYHANDSLOT"`. Upper and lower case
are the same. If the item in the other hand cannot be held with the new
item, the model removes it. Armor ignores `handSlotName`.
If `handSlotName` is `nil` or another name, the model chooses the hand. A
one-hand weapon goes into the off hand only when the main hand holds a
one-hand weapon and the unit can dual wield.
`spellEnchantID` is an enchant ID from `SpellItemEnchantment.dbc`, for
example 1900 (Crusader). The weapon shows the glow of that enchant. If
`spellEnchantID` is `nil`, the weapon shows the glow of the enchant in the
item link. An item that has its own glow keeps its own glow.
If the item is not in the item cache, the model wears it when the data
arrives. But then the model chooses the hand, and the weapon shows no
enchant glow.
`TryOn` returns an `Enum.ItemTryOnReason` value:
- `Success` (0): the model wears the item.
- `NotEquippable` (2): the model cannot wear the item. Necklaces, rings,
trinkets, and items that are not equipment give this result. Armor gives
this result when the model shows a shapeshifted unit or a creature.
- `DataPending` (3): the item is not in the item cache. The game requests
the item. When the data arrives, the model wears the item.
`WrongRace` (1) is in the enum, but `TryOn` does not return it.
```lua
DressUpModel:TryOn("|cff1eff00|Hitem:2077:0:0:0|h[Magician Staff]|h|r")
local result = DressUpModel:TryOn("|cff1eff00|Hitem:2077:0:0:0|h[Magician Staff]|h|r")
if result == Enum.ItemTryOnReason.DataPending then
print("The item loads now.")
end
```
### `DressUpModel:IsGeoReady()`
Returns `true` when the model file and all item models on it have
loaded. Otherwise it returns `false`. A model that the frame does not show
also gives `false`.
### `DressUpModel:SetSheathed([sheathed, hideWeapons])`
Moves the main-hand and off-hand weapons of the model. If `sheathed` is
`true`, the weapons go to their sheath positions. If `sheathed` is `false`
or `nil`, the weapons go into the hands. If `hideWeapons` is `true`, the
model shows no main-hand or off-hand weapon.
The setting stays on the frame. It applies to weapons that `TryOn` adds
later. It also applies when the model is built again: when you call
`SetUnit` or `Dress`, or when the frame shows again. `SetSheathed` does not
move a ranged weapon. The weapons keep their enchant glow.
Before the first `SetSheathed` call, the model holds its weapons the same
way as its unit. Weapons that `TryOn` adds then go into the hands.
```lua
DressUpModel:SetSheathed(true) -- weapons on the back and hips
DressUpModel:SetSheathed(false, true) -- no weapons
DressUpModel:SetSheathed() -- weapons in the hands
```
### `DressUpModel:GetSheathed()`
Returns the `sheathed` value of the last `SetSheathed` call on the frame.
Before the first call, it returns `false`.
### `DressUpModel:SetAutoDress([enabled])`
Sets whether the model wears the equipment of its unit. If `enabled` is
`true`, `SetUnit` shows the unit with its equipment. If `enabled` is
`false` or `nil`, `SetUnit` shows the unit with no equipment. The default
is `true`.
The setting applies the next time that the model is built: when you call
`SetUnit`, or when the frame shows again. `Dress()` always dresses the model
in the equipment of its unit.
```lua
DressUpModel:SetAutoDress(false)
DressUpModel:SetUnit("player") -- no equipment
DressUpModel:TryOn(2077) -- only the staff
```
### `DressUpModel:GetAutoDress()`
Returns the value of the last `SetAutoDress` call on the frame. Before the
first call, it returns `true`.
### `Model:SetGlow(value)`
This method exists on every `Model` frame, for code from later clients. It
+65
View File
@@ -4047,6 +4047,8 @@ enum Offsets {
// write it; the show hook (0x00505D49) reads it to rebuild the model.
// Those four are the only accesses in 0x00500000..0x00507000.
OFF_PLAYERMODEL_DISPLAY_RECORD = 0x3F0,
// The pane's unit GUID (u64), stored by the SetUnit helper (0x00505B50).
OFF_PLAYERMODEL_UNIT_GUID = 0x3E0,
// SetDisplay — `__thiscall(pane, record)`: clears the unit GUID, stores
// the record and, if the frame is visible, builds the model.
FUN_PLAYERMODEL_SET_DISPLAY = 0x00505CB0,
@@ -4069,11 +4071,63 @@ enum Offsets {
OFF_DRESSUPMODEL_COMPONENT = 0x3F8,
OFF_DRESSUPMODEL_MAIN_HAND_LANE = 0x3FC,
OFF_DRESSUPMODEL_OFF_HAND_LANE = 0x404,
// Byte: the unit showed its native display when the pane was built (not
// shapeshifted). TryOnItem's armor arms need it and the component.
OFF_DRESSUPMODEL_READY = 0x40C,
// SetModelInstance — `__thiscall(pane, model)`, DressUpModel vtable slot
// 36 (0x008083A8 + 0x90; its only reference): sets the model, clones the
// unit's component and copies the unit's hand lanes. Null on hide.
FUN_DRESSUPMODEL_SET_MODEL_INSTANCE = 0x00504350,
SLOT_DRESSUPMODEL_SET_MODEL_INSTANCE = 0x00808438,
// ClearModel — `__thiscall(pane)`: Undress's worker.
FUN_DRESSUPMODEL_CLEAR_MODEL = 0x00504490,
// The engine's Lua DressUpModel:Dress() — `int __fastcall(L)`: re-runs
// the SetUnit helper (0x00505B50) with the pane's GUID.
FUN_SCRIPT_DRESSUPMODEL_DRESS = 0x00504CD0,
// AttachItemModel — `int __fastcall(model, displayRecord, bodySlot,
// sheatheType, bool sheathed, bool shield, bool ranged)`, ret 0x14: hangs
// a held item at the hand, or at its sheath point when `sheathed`. The
// unit's own AttachHandItem (0x0060B8C0) passes its sheath state there.
FUN_ATTACH_ITEM_MODEL = 0x0047A0C0,
// TryOnItem's held arm: the install (`push 0` as sheathed) and the two
// evictions of the other lane, each a `call` to the function above or
// FUN_DETACH_HELD_ITEM_SLOT. The third detach (0x00504A85) is the
// target lane's own occupant.
CALL_TRY_ON_ATTACH_ITEM = 0x00504AE6,
CALL_TRY_ON_EVICT_OFF_HAND = 0x00504A0C,
CALL_TRY_ON_EVICT_MAIN_HAND = 0x00504A55,
// TryOnItem's hand choice, reached only from the held arm (jump-table
// entry 0x0A: inventory types 13-15, 17, 21-23, 25, 26). The site is
// `mov edx, [edi+0x2C]; cmp edx, 0x0D` (8B 57 2C 83 FA 0D) before the
// `jnz`. Both paths start with ECX = the player (or null), ESI = the
// pane, EDI = the item record, and set [ebp-4] to body slot 0xF / 0x10
// before the CanHoldBoth (0x00504BC0) eviction of the other lane.
SITE_TRY_ON_HAND_CHOICE = 0x0050492D,
TRY_ON_MAIN_HAND_PATH = 0x0050495F,
TRY_ON_OFF_HAND_PATH = 0x005049B1,
// AttachItemVisuals — `__fastcall(model, itemVisualID)`: detaches the
// model's children, then hangs each ItemVisualEffects model of the
// ItemVisuals row (attachments 0-4). Ignores an id with no row.
FUN_ATTACH_ITEM_VISUALS = 0x00479700,
// CM2Model::FindChild — `__thiscall(model, attachId)`, ret 4: the child
// model hung at that attachment, or null.
FUN_M2MODEL_FIND_CHILD = 0x00712F00,
// GetItemDescVisual — `int __thiscall(visibleItem)`: -1 when the item's
// display has its own ItemVisuals row (AttachItemModel already hung it),
// else the visual of the first of its 7 enchants (+0x0C) that has one,
// else 0. CGPlayer_C::AttachEnchantVisual (0x005EED50) gives the result
// to FindChild + AttachItemVisuals on the attachment AttachItemModel
// used; AttachHandItem (0x0060B8C0) runs that for players only.
FUN_VISIBLE_ITEM_GET_DESC_VISUAL = 0x0062EC70,
// UnequipCharacterSlot — `__thiscall(component, slot 0..11)`.
FUN_CHARCOMP_UNEQUIP_SLOT = 0x00478960,
// DetachHeldItemSlot — `__fastcall(model, bodySlot 0xF/0x10,
// sheatheType, bool shield)`; Undress passes `invType == 14` as shield.
FUN_DETACH_HELD_ITEM_SLOT = 0x0047A310,
// Unit vtable slot 39 — `const void *__thiscall(unit, hand 0/1/2)`: the
// ItemDisplayInfo row of the hand's item (CGUnit_C 0x00605EE0, CGPlayer_C
// 0x005EC350); AttachHandItem's record source.
VTBL_UNIT_GET_HELD_ITEM_DISPLAY = 0x9C,
// TryOnItem — `__thiscall(pane, itemID)`: the worker behind TryOn.
FUN_DRESSUPMODEL_TRY_ON_ITEM = 0x00504540,
// The worker's item-cache callback (0x00504BA0) re-runs it once the
@@ -5861,6 +5915,14 @@ enum Offsets {
VAR_ITEMDISPLAYINFO_RECORDS = 0x00C0DC10,
VAR_ITEMDISPLAYINFO_COUNT = 0x00C0DC14,
OFF_ITEMDISPLAYINFO_ICON = 0x14,
// ItemVisuals ID (column 22). Read by AttachItemModel (0x0047A200) and
// GetItemDescVisual (0x0062EC93); 365 stock rows set it.
OFF_ITEMDISPLAYINFO_ITEM_VISUAL = 0x58,
// ItemVisuals.dbc — an ID plus five ItemVisualEffects IDs (6 columns),
// the glow AttachItemVisuals hangs on a weapon. Instance 0x00C0DB68.
VAR_ITEMVISUALS_RECORDS = 0x00C0DB70,
VAR_ITEMVISUALS_COUNT = 0x00C0DB74,
// ItemSet.dbc — armor/equipment set definitions referenced by
// `ItemStats_C::m_itemSet`. Standard 5-DWORD class shape at
@@ -8075,6 +8137,9 @@ enum Offsets {
OFF_SPELLITEMENCHANT_AMOUNT = 0x10, // int Amount[3] (EffectPointsMin)
OFF_SPELLITEMENCHANT_ARG = 0x28, // int EffectArg[3]
OFF_SPELLITEMENCHANT_NAME = 0x34, // char *name[8], locale-indexed
// ItemVisuals ID (column 22), the weapon glow; read by GetItemDescVisual
// (0x0062ECD7). Crusader 1900 = 103, Fiery 803 = 25, Icy 1894 = 126.
OFF_SPELLITEMENCHANT_ITEM_VISUAL = 0x58,
// ItemRandomProperties.dbc — the table a random-suffix ID (the third
// field of an `item:id:enchant:suffix:unique` link) indexes to get
@@ -143,6 +143,7 @@ FrameXML\ColorSwatch.lua
FrameXML\ColorSwatch.xml
FrameXML\ScriptErrorsFrame.lua
FrameXML\ScriptErrorsFrame.xml
FrameXML\DressUpFrame.lua
Menu\MenuConstants.lua
Menu\Classic\MenuConstants.lua
@@ -0,0 +1,19 @@
-- 4.3.4's DressUpItemLink (FrameXML/ModelFrames.lua): the whole link goes to
-- the model, so DressUpModel:TryOn shows the link's enchant glow, and an item
-- the model cannot wear does not open the window. 1.12.1's version passes
-- only the item ID.
--
-- Differences from the 4.3.4 source:
-- - The link goes through DressUpItem, which 4.3.4 folded in. Here
-- Blizzard_AuctionUI replaces DressUpItem to show its own model, and
-- some clients add steps to it (Turtle dresses the tabard).
-- - An uncached item skips the IsDressableItem test, which reads only the
-- cache. 1.12.1 opens no tooltip before a chat link's Control-click, so
-- the test would drop most chat links; TryOn loads the item instead.
function DressUpItemLink(link)
if ( not link or (C_Item.IsItemDataCachedByID(link) and not IsDressableItem(link)) ) then
return;
end
DressUpItem(link);
end
+8 -4
View File
@@ -40,12 +40,16 @@ Resolved ResolveString(const char *s) {
if (const char *m = std::strstr(s, "item:")) {
m += 5;
out.itemID = std::atoi(m);
// Random suffix is the 3rd colon-separated field: itemID:enchant:suffix.
// Walk to the 2nd ':' (stopping at the link boundary), read the number
// after it. Bare "item:N" (no colons) leaves suffix 0.
// The enchant and random suffix are the 2nd and 3rd colon-separated
// fields: itemID:enchant:suffix. Walk the colons (stopping at the link
// boundary) and read the number after each. Bare "item:N" leaves both 0.
int colons = 0;
for (const char *p = m; *p != '\0' && *p != '|'; ++p) {
if (*p == ':' && ++colons == 2) {
if (*p != ':')
continue;
if (++colons == 1) {
out.enchant = std::atoi(p + 1);
} else {
out.suffix = std::atoi(p + 1);
break;
}
+1
View File
@@ -24,6 +24,7 @@ struct Resolved {
int itemID;
int suffix; // random-property / suffix ID (3rd `item:` field), 0 if none
const char *name;
int enchant; // permanent enchant ID (2nd `item:` field), 0 if none
};
// Resolves a Lua arg at 1-based stack `idx` to an item reference.
+434 -16
View File
@@ -11,8 +11,9 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `DressUpModel:UndressSlot(slot)` and the 4.3.4 `DressUpModel:TryOn(item)`
// (DressUpModelFrame_Scripts.cpp).
// `DressUpModel:UndressSlot(slot)`, the 4.3.4 `DressUpModel:TryOn(item)`
// (DressUpModelFrame_Scripts.cpp), and retail's TryOn result, IsGeoReady,
// SetSheathed / GetSheathed and SetAutoDress / GetAutoDress.
//
// UndressSlot is one step of vanilla's own Undress (DressUpModel::ClearModel
// 0x00504490), which 4.3.4 split per slot: a component slot is cleared with
@@ -23,15 +24,45 @@
// takes 4.3.4's argument forms in front of it: an item ID, an item link or
// `item:` string, a trade-skill `enchant:` link (the item its recipe makes),
// or the name of an item the player carries. Anything else shows
// ERR_NOT_EQUIPPABLE, as 4.3.4 does. 4.3.4's optional hand argument
// ("mainhand" / "offhand") is not supported: vanilla's worker picks the hand
// itself.
// ERR_NOT_EQUIPPABLE, as 4.3.4 does.
//
// Retail's hand argument is an inventory slot name, matched the way
// GetInventorySlotInfo matches it (SStrCmpI over the PaperDollItemFrame
// rows): MainHandSlot or SecondaryHandSlot. The worker picks a hand at one
// site (SITE_TRY_ON_HAND_CHOICE); a named hand sends a held item down that
// hand's path, so the worker's own eviction of an incompatible other lane
// runs, as 4.3.4's does for a forced hand.
//
// The enchant glow is the player's AttachEnchantVisual step on the item the
// worker hangs: FindChild at the attachment AttachItemModel used, then
// AttachItemVisuals with the enchant's ItemVisuals row, unless the item's
// display hangs its own (GetItemDescVisual's rule, and 4.3.4's
// `enchants && !visuals`). The enchant is the spellEnchantID argument, else
// the link's enchant field, as 4.3.4 passes it. The worker's cache callback
// re-runs with neither hand nor enchant, as 4.3.4's does.
//
// 4.3.4's worker (`TryOnWithVisual`, 0x00A05010) tries on what a recipe makes:
// an item with no inventory type whose spell teaches a CREATE_ITEM spell is
// swapped for the made item. Vanilla's worker shows ERR_NOT_EQUIPPABLE
// instead. `TryOnItem` adds the swap in front of the worker, for our TryOn
// and for the worker's cache-load callback, so an uncached pattern works too.
//
// TryOn's result (retail's ItemTryOnReason) is read from the same gates the
// worker passes: an uncached record is DataPending (the worker has queued the
// cache callback), a held type is installed, and an armor type needs the
// pane's component and ready byte (a shapeshifted unit has neither path).
//
// Sheathing is the unit's own AttachHandItem step on the pane's lanes: detach
// the lane, then AttachItemModel with the sheathed flag. The lanes keep no
// display row, so the pane keeps one per lane: the unit's slot-39 row when the
// pane is built, then whatever TryOnItem installs. Until SetSheathed is called
// the pane keeps vanilla's placement (the unit's own, then the hand for every
// try-on). Each lane keeps its glow too, so a re-hung weapon gets it back: a
// player's from GetItemDescVisual on its visible item (only players glow in
// vanilla), a try-on's from its enchant.
//
// Auto-dress off undresses the pane with ClearModel right after
// SetModelInstance clones the unit, unless Dress() is the one rebuilding it.
#include "Game.h"
#include "Offsets.h"
@@ -42,11 +73,13 @@
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include "object/Resolve.h"
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unordered_map>
namespace Model::DressUp {
namespace {
@@ -54,18 +87,212 @@ namespace {
using UnequipSlot_t = void(__thiscall *)(void *component, uint32_t slot);
using DetachHeldItem_t = void(__fastcall *)(void *model, uint32_t bodySlot, uint32_t sheatheType,
bool shield);
using AttachItemModel_t = int(__fastcall *)(void *model, const void *record, uint32_t bodySlot,
uint32_t sheatheType, bool sheathed, bool shield,
bool ranged);
using TryOnItem_t = void(__thiscall *)(void *pane, uint32_t itemID);
using SetModelInstance_t = void(__thiscall *)(void *pane, void *model);
using ClearModel_t = void(__thiscall *)(void *pane);
using GetHeldItemDisplay_t = const void *(__thiscall *)(void *unit, uint32_t hand);
using ModelLoaded_t = int(__thiscall *)(void *model, int tryLoad, int recurseChildren);
using FindChild_t = void *(__thiscall *)(void *model, int attachId);
using AttachItemVisuals_t = void(__fastcall *)(void *model, uint32_t visualID);
using GetVisibleItem_t = const void *(__thiscall *)(void *player, uint32_t slot);
using GetDescVisual_t = int(__thiscall *)(const void *visibleItem);
using DisplayError_t = void(__cdecl *)(uint32_t id, ...);
// Body slots of the two held lanes.
constexpr uint32_t kBodySlotMainHand = 0xF;
constexpr uint32_t kBodySlotOffHand = 0x10;
constexpr uint8_t kInvTypeShield = 14;
constexpr uint8_t kInvTypeRangedRight = 26;
// Retail's Enum.ItemTryOnReason.
constexpr int kTryOnSuccess = 0;
constexpr int kTryOnWrongRace = 1;
constexpr int kTryOnNotEquippable = 2;
constexpr int kTryOnDataPending = 3;
struct Lane {
uintptr_t offset;
uint32_t bodySlot;
};
constexpr Lane kLanes[2] = {
{Offsets::OFF_DRESSUPMODEL_MAIN_HAND_LANE, kBodySlotMainHand},
{Offsets::OFF_DRESSUPMODEL_OFF_HAND_LANE, kBodySlotOffHand},
};
struct PaneState {
bool autoDress = true;
bool sheathSet = false; // SetSheathed was called; weapons follow the two below
bool sheathed = false;
bool hideWeapons = false;
const void *laneDisplay[2] = {}; // ItemDisplayInfo row on each lane, or null
uint32_t laneVisual[2] = {}; // enchant ItemVisuals row on each lane, or 0
};
std::unordered_map<void *, PaneState> g_panes;
void *g_tryOnPane = nullptr; // the pane whose TryOnItem worker is running
uint32_t g_tryOnBodySlot = 0; // the hand TryOn named (kBodySlot*), or 0
uint32_t g_tryOnEnchant = 0; // the SpellItemEnchantment TryOn gave, or 0
bool g_dressing = false; // Dress() is rebuilding the pane
uintptr_t g_mainHandPath = Offsets::TRY_ON_MAIN_HAND_PATH;
uintptr_t g_offHandPath = Offsets::TRY_ON_OFF_HAND_PATH;
void *ResolvePane(void *L) {
return Game::Lua::ResolveTypedObject(L, 1, Offsets::VAR_DRESSUPMODEL_LUA_TYPE_ID);
}
void *PaneModel(void *pane) {
return Game::Read<void *>(pane, Offsets::OFF_SIMPLEMODELFFX_MODEL_INSTANCE);
}
int LaneIndex(uint32_t bodySlot) {
return bodySlot == kBodySlotOffHand ? 1 : 0;
}
// The glow `enchantID` gives an item shown with `display`: none when the
// display has its own ItemVisuals row (GetItemDescVisual 0x0062EC70).
uint32_t EnchantVisual(const void *display, uint32_t enchantID) {
if (display == nullptr ||
DBC::Record(Offsets::VAR_ITEMVISUALS_RECORDS, Offsets::VAR_ITEMVISUALS_COUNT,
Game::Read<uint32_t>(display, Offsets::OFF_ITEMDISPLAYINFO_ITEM_VISUAL)) !=
nullptr)
return 0;
const uint8_t *enchant = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
return enchant != nullptr
? Game::Read<uint32_t>(enchant, Offsets::OFF_SPELLITEMENCHANT_ITEM_VISUAL)
: 0;
}
// AttachEnchantVisual's step (0x005EED50): the glow on the item hung at
// `attachId`.
void HangVisual(void *model, int attachId, uint32_t visual) {
if (visual == 0 || attachId < 0)
return;
if (void *item = reinterpret_cast<FindChild_t>(Offsets::FUN_M2MODEL_FIND_CHILD)(model,
attachId))
reinterpret_cast<AttachItemVisuals_t>(Offsets::FUN_ATTACH_ITEM_VISUALS)(item, visual);
}
// The unit's AttachHandItem step on each occupied lane: detach it, then hang
// it in the hand or the sheath, or leave it off.
void ApplyWeapons(void *pane, const PaneState &s) {
void *model = PaneModel(pane);
if (model == nullptr)
return;
for (int i = 0; i < 2; ++i) {
const uint8_t *lane = Game::Ptr<uint8_t>(pane, kLanes[i].offset);
if (lane[0] == 0 || s.laneDisplay[i] == nullptr)
continue;
const bool shield = lane[3] == kInvTypeShield;
reinterpret_cast<DetachHeldItem_t>(Offsets::FUN_DETACH_HELD_ITEM_SLOT)(
model, kLanes[i].bodySlot, lane[4], shield);
if (s.hideWeapons)
continue;
const int attachId = reinterpret_cast<AttachItemModel_t>(Offsets::FUN_ATTACH_ITEM_MODEL)(
model, s.laneDisplay[i], kLanes[i].bodySlot, lane[4], s.sheathed, shield,
lane[3] == kInvTypeRangedRight);
HangVisual(model, attachId, s.laneVisual[i]);
}
}
// Slot 36, after the original has cloned the unit and copied its lanes.
void __fastcall SetModelInstance(void *pane, void * /*edx*/, void *model) {
reinterpret_cast<SetModelInstance_t>(Offsets::FUN_DRESSUPMODEL_SET_MODEL_INSTANCE)(pane,
model);
if (model == nullptr)
return;
PaneState &s = g_panes[pane];
s.laneDisplay[0] = s.laneDisplay[1] = nullptr;
s.laneVisual[0] = s.laneVisual[1] = 0;
// The original filled the lanes from this unit, resolved the same way.
const uint64_t guid = Game::Read<uint64_t>(pane, Offsets::OFF_PLAYERMODEL_UNIT_GUID);
if (void *unit = Object::ByGuid(Offsets::TYPEMASK_UNIT, guid)) {
const auto get = Game::Read<GetHeldItemDisplay_t>(
Game::Read<const void *>(unit, 0), Offsets::VTBL_UNIT_GET_HELD_ITEM_DISPLAY);
s.laneDisplay[0] = get(unit, 0);
s.laneDisplay[1] = get(unit, 1);
}
// AttachHandEnchant's glow (0x005EED00), for players only: the visible
// item in the lane's slot (0xF / 0x10, the same numbers as the body
// slots), and its visual unless the display hangs its own (-1).
if (void *player = Object::ByGuid(Offsets::TYPEMASK_PLAYER, guid)) {
for (int i = 0; i < 2; ++i) {
const void *item = reinterpret_cast<GetVisibleItem_t>(
Offsets::FUN_UNIT_GET_VISIBLE_ITEM)(player, kLanes[i].bodySlot);
if (item == nullptr || Game::Read<uint32_t>(item, Offsets::OFF_VISIBLE_ITEM_ITEM_ID) == 0)
continue;
const int visual =
reinterpret_cast<GetDescVisual_t>(Offsets::FUN_VISIBLE_ITEM_GET_DESC_VISUAL)(item);
s.laneVisual[i] = visual > 0 ? static_cast<uint32_t>(visual) : 0;
}
}
if (!s.autoDress && !g_dressing) {
reinterpret_cast<ClearModel_t>(Offsets::FUN_DRESSUPMODEL_CLEAR_MODEL)(pane);
return;
}
if (s.sheathSet)
ApplyWeapons(pane, s);
}
// TryOnItem's install: record the lane's row and glow, honor SetSheathed,
// then hang the glow.
int __fastcall TryOnAttach(void *model, const void *record, uint32_t bodySlot,
uint32_t sheatheType, bool sheathed, bool shield, bool ranged) {
const auto attach = reinterpret_cast<AttachItemModel_t>(Offsets::FUN_ATTACH_ITEM_MODEL);
if (g_tryOnPane == nullptr)
return attach(model, record, bodySlot, sheatheType, sheathed, shield, ranged);
PaneState &s = g_panes[g_tryOnPane];
const int lane = LaneIndex(bodySlot);
s.laneDisplay[lane] = record;
s.laneVisual[lane] = EnchantVisual(record, g_tryOnEnchant);
if (s.sheathSet) {
if (s.hideWeapons)
return -1;
sheathed = s.sheathed;
}
const int attachId = attach(model, record, bodySlot, sheatheType, sheathed, shield, ranged);
HangVisual(model, attachId, s.laneVisual[lane]);
return attachId;
}
// TryOnItem's eviction of the other lane. The worker leaves that lane's
// record in place, so the pane drops its row to keep SetSheathed from
// hanging the evicted item again.
void __fastcall TryOnEvict(void *model, uint32_t bodySlot, uint32_t sheatheType, bool shield) {
if (g_tryOnPane != nullptr) {
PaneState &s = g_panes[g_tryOnPane];
s.laneDisplay[LaneIndex(bodySlot)] = nullptr;
s.laneVisual[LaneIndex(bodySlot)] = 0;
}
reinterpret_cast<DetachHeldItem_t>(Offsets::FUN_DETACH_HELD_ITEM_SLOT)(model, bodySlot,
sheatheType, shield);
}
// TryOnItem's hand choice. With a hand named, the worker takes that hand's
// path; otherwise the thunk does the instructions it replaced and the
// worker's `jnz` reads their flags.
__declspec(naked) void HandChoiceThunk() {
__asm {
cmp dword ptr [g_tryOnBodySlot], 0Fh
je mainHand
cmp dword ptr [g_tryOnBodySlot], 10h
je offHand
mov edx, dword ptr [edi + 2Ch]
cmp edx, 0Dh
ret
mainHand:
add esp, 4
jmp dword ptr [g_mainHandPath]
offHand:
add esp, 4
jmp dword ptr [g_offHandPath]
}
}
// ClearModel's lane step: detach the held item and zero the lane.
void ClearLane(void *pane, uintptr_t laneOffset, uint32_t bodySlot) {
uint8_t *lane = Game::Ptr<uint8_t>(pane, laneOffset);
@@ -139,23 +366,80 @@ uint32_t RecipeItem(const char *link) {
return Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_EFFECT_ITEM_TYPE);
}
// `__thiscall(pane, itemID)` as `__fastcall` with the EDX slot unused, so it
// can stand at the worker's call sites.
void __fastcall TryOnItem(void *pane, void *, uint32_t itemID) {
// The item the worker is given: a cached recipe item becomes what it makes.
uint32_t WornItem(uint32_t itemID) {
const uint8_t *record = Item::PeekRecord(itemID);
if (record != nullptr &&
Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE) == 0) {
const uint32_t made = Item::Dressable::RecipeItem(record);
if (made != 0)
itemID = made;
return made;
}
return itemID;
}
// `__thiscall(pane, itemID)` as `__fastcall` with the EDX slot unused, so it
// can stand at the worker's call sites.
void __fastcall TryOnItem(void *pane, void *, uint32_t itemID) {
g_tryOnPane = pane;
reinterpret_cast<TryOnItem_t>(Offsets::FUN_DRESSUPMODEL_TRY_ON_ITEM)(pane, WornItem(itemID));
g_tryOnPane = nullptr;
}
// The lane a hand name picks: the PaperDollItemFrame row it names
// (GetInventorySlotInfo's match), if that row is a held slot; else 0.
uint32_t NamedHand(const char *name) {
const auto *records = Game::Read<const uint8_t *>(Offsets::VAR_PAPERDOLLITEMFRAME_RECORDS);
const int count = Game::Read<int>(Offsets::VAR_PAPERDOLLITEMFRAME_COUNT);
if (records == nullptr)
return 0;
for (int i = 0; i < count; ++i) {
const uint8_t *row = records + i * Offsets::OFF_PAPERDOLLITEMFRAME_RECORD_STRIDE;
if (Game::StrCmpI(Game::Read<const char *>(row, Offsets::OFF_PAPERDOLLITEMFRAME_SLOT_NAME),
name) != 0)
continue;
switch (Game::Read<int>(row, Offsets::OFF_PAPERDOLLITEMFRAME_SLOT_NUMBER)) {
case 16: return kBodySlotMainHand;
case 17: return kBodySlotOffHand;
default: return 0;
}
}
return 0;
}
// What the worker did with `itemID`, from the gates it passes (0x00504540).
int TryOnResult(void *pane, uint32_t itemID) {
const uint8_t *record = Item::PeekRecord(itemID);
if (record == nullptr)
return kTryOnDataPending;
if (Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID) == 0 ||
PaneModel(pane) == nullptr)
return kTryOnNotEquippable;
switch (Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE)) {
case 1: case 3: case 4: case 5: case 6: case 7: case 8: case 9: case 10:
case 16: case 19: case 20:
// The armor arms: the component and the ready byte.
return Game::Read<void *>(pane, Offsets::OFF_DRESSUPMODEL_COMPONENT) != nullptr &&
Game::Read<uint8_t>(pane, Offsets::OFF_DRESSUPMODEL_READY) != 0
? kTryOnSuccess
: kTryOnNotEquippable;
case 13: case 14: case 15: case 17: case 21: case 22: case 23: case 25: case 26:
return kTryOnSuccess; // the held arm
default:
return kTryOnNotEquippable;
}
reinterpret_cast<TryOnItem_t>(Offsets::FUN_DRESSUPMODEL_TRY_ON_ITEM)(pane, itemID);
}
int __fastcall Script_TryOn(void *L) {
void *pane = ResolvePane(L);
if (pane == nullptr)
return 0;
// Read before the name search below, which stomps the Lua stack.
const uint32_t bodySlot = Game::Lua::Type(L, 3) == Game::Lua::TYPE_STRING
? NamedHand(Game::Lua::ToString(L, 3))
: 0;
const bool enchantGiven = Game::Lua::IsNumber(L, 4) != 0;
uint32_t enchant = enchantGiven ? static_cast<uint32_t>(Game::Lua::ToNumber(L, 4)) : 0;
uint32_t itemID = 0;
if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_NUMBER) {
itemID = static_cast<uint32_t>(Game::Lua::ToNumber(L, 2));
@@ -170,24 +454,91 @@ int __fastcall Script_TryOn(void *L) {
// any other string is the name of an item the player carries.
const Item::Arg::Resolved arg = Item::Arg::ResolveString(s);
Item::Location::ByGUIDResult found{};
if (arg.itemID != 0)
if (arg.itemID != 0) {
itemID = static_cast<uint32_t>(arg.itemID);
else if (arg.name != nullptr && Item::Location::FindByArg(L, arg, &found))
if (!enchantGiven)
enchant = static_cast<uint32_t>(arg.enchant);
} else if (arg.name != nullptr && Item::Location::FindByArg(L, arg, &found))
itemID = static_cast<uint32_t>(Item::ID::FromCGItem(found.item));
}
}
if (itemID == 0) {
reinterpret_cast<DisplayError_t>(Offsets::FUN_GAMEUI_DISPLAY_ERROR)(
Offsets::GAME_ERROR_NOT_EQUIPPABLE);
return 0;
Game::Lua::PushNumber(L, kTryOnNotEquippable);
return 1;
}
g_tryOnBodySlot = bodySlot;
g_tryOnEnchant = enchant;
TryOnItem(pane, nullptr, itemID);
g_tryOnBodySlot = g_tryOnEnchant = 0;
Game::Lua::PushNumber(L, TryOnResult(pane, WornItem(itemID)));
return 1;
}
int __fastcall Script_Dress(void *L) {
g_dressing = true;
const int n = reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_SCRIPT_DRESSUPMODEL_DRESS)(L);
g_dressing = false;
return n;
}
int __fastcall Script_IsGeoReady(void *L) {
void *pane = ResolvePane(L);
if (pane == nullptr)
return 0;
void *model = PaneModel(pane);
Game::Lua::PushBool(L, model != nullptr &&
reinterpret_cast<ModelLoaded_t>(Offsets::FUN_MODEL_INSTANCE_LOADED)(
model, 0, 1) != 0);
return 1;
}
int __fastcall Script_SetSheathed(void *L) {
void *pane = ResolvePane(L);
if (pane == nullptr)
return 0;
PaneState &s = g_panes[pane];
s.sheathSet = true;
s.sheathed = Game::Lua::ToBoolean(L, 2) != 0;
s.hideWeapons = Game::Lua::ToBoolean(L, 3) != 0;
ApplyWeapons(pane, s);
return 0;
}
int __fastcall Script_GetSheathed(void *L) {
void *pane = ResolvePane(L);
if (pane == nullptr)
return 0;
const auto it = g_panes.find(pane);
Game::Lua::PushBool(L, it != g_panes.end() && it->second.sheathed);
return 1;
}
int __fastcall Script_SetAutoDress(void *L) {
if (void *pane = ResolvePane(L))
g_panes[pane].autoDress = Game::Lua::ToBoolean(L, 2) != 0;
return 0;
}
int __fastcall Script_GetAutoDress(void *L) {
void *pane = ResolvePane(L);
if (pane == nullptr)
return 0;
const auto it = g_panes.find(pane);
Game::Lua::PushBool(L, it == g_panes.end() || it->second.autoDress);
return 1;
}
const Game::Lua::FrameMethodEntry g_methods[] = {
{"UndressSlot", &Script_UndressSlot},
{"TryOn", &Script_TryOn},
{"Dress", &Script_Dress},
{"IsGeoReady", &Script_IsGeoReady},
{"SetSheathed", &Script_SetSheathed},
{"GetSheathed", &Script_GetSheathed},
{"SetAutoDress", &Script_SetAutoDress},
{"GetAutoDress", &Script_GetAutoDress},
};
const Game::Doc::Field kUndressArgs[] = {
@@ -198,23 +549,90 @@ const Game::Doc::Function kUndressSlot{
const Game::Doc::Field kTryOnArgs[] = {
Game::Doc::Req("item", "ItemInfo",
"An item ID, item link, trade-skill recipe link, or the name of an item you carry."),
Game::Doc::Opt("handSlotName", "cstring", nullptr,
"MAINHANDSLOT or SECONDARYHANDSLOT: the hand that holds the item."),
Game::Doc::Opt("spellEnchantID", "number", nullptr,
"The enchant whose glow a weapon shows. Defaults to the item link's enchant."),
};
const Game::Doc::Function kTryOn{"Puts an item on the model.", kTryOnArgs, {}};
const Game::Doc::Field kTryOnRets[] = {
Game::Doc::Req("result", "ItemTryOnReason"),
};
const Game::Doc::Function kTryOn{"Puts an item on the model.", kTryOnArgs, kTryOnRets};
const Game::Doc::Function kDress{"Dresses the model in its unit's equipment.", {}, {}};
const Game::Doc::Field kGeoReadyRets[] = {
Game::Doc::Req("ready", "bool"),
};
const Game::Doc::Function kIsGeoReady{
"Returns whether the model and the item models on it have loaded.", {}, kGeoReadyRets};
const Game::Doc::Field kSheathedArgs[] = {
Game::Doc::Opt("sheathed", "bool", "false"),
Game::Doc::Opt("hideWeapons", "bool", "false"),
};
const Game::Doc::Function kSetSheathed{
"Puts the model's weapons in its hands, in their sheaths, or hides them.", kSheathedArgs,
{}};
const Game::Doc::Field kSheathedRets[] = {
Game::Doc::Req("sheathed", "bool"),
};
const Game::Doc::Function kGetSheathed{"Returns whether the model's weapons are sheathed.", {},
kSheathedRets};
const Game::Doc::Field kEnabledArgs[] = {
Game::Doc::Opt("enabled", "bool", "false"),
};
const Game::Doc::Function kSetAutoDress{
"Sets whether the model wears its unit's equipment when the unit is set.", kEnabledArgs, {}};
const Game::Doc::Field kEnabledRets[] = {
Game::Doc::Req("enabled", "bool"),
};
const Game::Doc::Function kGetAutoDress{
"Returns whether the model wears its unit's equipment when the unit is set.", {},
kEnabledRets};
const Game::Doc::Method g_methodDocs[] = {
{"UndressSlot", &kUndressSlot},
{"TryOn", &kTryOn},
{"UndressSlot", &kUndressSlot}, {"TryOn", &kTryOn},
{"Dress", &kDress}, {"IsGeoReady", &kIsGeoReady},
{"SetSheathed", &kSetSheathed}, {"GetSheathed", &kGetSheathed},
{"SetAutoDress", &kSetAutoDress}, {"GetAutoDress", &kGetAutoDress},
};
const Game::Lua::EnumIntegerEntry kItemTryOnReason[] = {
{"Success", kTryOnSuccess},
{"WrongRace", kTryOnWrongRace},
{"NotEquippable", kTryOnNotEquippable},
{"DataPending", kTryOnDataPending},
};
void RegisterLuaFunctions() {
Common::RedirectCall(Offsets::CALL_TRY_ON_ITEM_CACHED, Offsets::FUN_DRESSUPMODEL_TRY_ON_ITEM,
reinterpret_cast<const void *>(&TryOnItem));
Common::RedirectCall(Offsets::CALL_TRY_ON_ATTACH_ITEM, Offsets::FUN_ATTACH_ITEM_MODEL,
reinterpret_cast<const void *>(&TryOnAttach));
Common::RedirectCall(Offsets::CALL_TRY_ON_EVICT_OFF_HAND, Offsets::FUN_DETACH_HELD_ITEM_SLOT,
reinterpret_cast<const void *>(&TryOnEvict));
Common::RedirectCall(Offsets::CALL_TRY_ON_EVICT_MAIN_HAND, Offsets::FUN_DETACH_HELD_ITEM_SLOT,
reinterpret_cast<const void *>(&TryOnEvict));
static const uint8_t kHandChoice[6] = {0x8B, 0x57, 0x2C, 0x83, 0xFA, 0x0D};
Common::ReplaceWithCall(Offsets::SITE_TRY_ON_HAND_CHOICE, kHandChoice, sizeof kHandChoice,
reinterpret_cast<const void *>(&HandChoiceThunk));
Common::RedirectSlot(Offsets::SLOT_DRESSUPMODEL_SET_MODEL_INSTANCE,
Offsets::FUN_DRESSUPMODEL_SET_MODEL_INSTANCE,
reinterpret_cast<const void *>(&SetModelInstance));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_DRESSUPMODEL_METHOD_REGISTRY), g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])), g_methodDocs,
static_cast<int>(sizeof(g_methodDocs) / sizeof(g_methodDocs[0])));
Game::Lua::RegisterIntegerEnum(
"Enum", "ItemTryOnReason", kItemTryOnReason,
static_cast<int>(sizeof(kItemTryOnReason) / sizeof(kItemTryOnReason[0])), "UIModelInfo");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
// Frames are rebuilt on /reload and a new frame can reuse an old address.
void PrepareForReload() {
g_panes.clear();
}
const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace
} // namespace Model::DressUp