feat(model): add PlayerModel:ApplySpellVisualKit

PlayerModel:ApplySpellVisualKit(spellVisualKitID [, oneShot]) shows a
SpellVisualKit's effects on the pane's model.

In the world, 1.12.1 plays a kit on a unit (0x0060EDF0), through effect
objects, spell durations and the unit's own animation, and a pane has
none of these. The engine's own kit on a bare model is the
char-select ghost (0x004727F0), and this is that routine for any kit on
the pane's model:
- Each of the nine effect columns (+0x0C) is created as a model from its
  SpellVisualEffectName file, in the pane's scene. It is attached at
  the column's attachment (20, 34, 19, 21, 22, 17, 23, 24, 25) when the
  model has that attachment (0x00712CB0, 0x00712F70), as in 0x00472780.
  The effects get the frame's light through LightChildren.
- Procedure kind 1 sets the tint (an 0xRRGGBB colour stored as a float,
  scaled by 1/255) and kind 14 the alpha, clamped to 0..1. Both are
  plain stores (0x00710CF0, 0x00710CB0), as the ghost kit makes them.
The kit's animation and sound do not play, which matches retail.

An effect model's sequence plays one time. Without oneShot, each effect
gets the world's loop callback (KitEffectLoopCallback, 0x0060ED00): a
clean end (reason 0) plays the animation again. With oneShot, each
effect gets EffectDoneCallback's test (0x005FBF50) and CEffect::Stop's
model steps (0x006203E0): clear the callback and hide the model, so
its emitters stop and the particles already out finish. A FrameTick
drain, which runs after the strata walk, then mirrors
DrainDyingEffects (0x0061F680). It detaches a stopped effect (0x00713020)
once HasLiveParticles (0x007135C0) is false, or at once when its
model has already dropped it. A model's destructor detaches its
children first (0x0070E3F5), so the drain never reaches a freed parent.

The effects are children of the pane's model, so SetUnit, SetItem or
a hide and show clears them.
This commit is contained in:
Brues
2026-11-04 15:32:23 -06:00
parent f6b98bc3ec
commit c0b403ba05
4 changed files with 299 additions and 1 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, hand, enchant glow, result), `DressUpModel:UndressSlot`, `DressUpModel:IsGeoReady`, `DressUpModel:SetSheathed` / `GetSheathed`, `DressUpModel:SetAutoDress` / `GetAutoDress`, `Model:SetGlow`, `PlayerModel:RefreshCamera`, `PlayerModel:SetCamDistanceScale`, `PlayerModel:SetCreature` (`displayID`), `PlayerModel:SetDisplayInfo`, `PlayerModel:SetItem`, `PlayerModel:SetPortraitZoom`, `PlayerModel:SetRotation` (`animate`), `PlayerModel:SetUnit` (result), `PlayerModel:CanSetUnit`, `PlayerModel:GetDisplayInfo`, `PlayerModel:SetAnimation`, `PlayerModel:FreezeAnimation`, `PlayerModel:HasAnimation`, `PlayerModel:SetDoBlend` / `GetDoBlend`, `PlayerModel:SetKeepModelOnHide` / `GetKeepModelOnHide` |
| [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` (`displayID`), `PlayerModel:SetDisplayInfo`, `PlayerModel:SetItem`, `PlayerModel:SetPortraitZoom`, `PlayerModel:SetRotation` (`animate`), `PlayerModel:SetUnit` (result), `PlayerModel:CanSetUnit`, `PlayerModel:GetDisplayInfo`, `PlayerModel:SetAnimation`, `PlayerModel:FreezeAnimation`, `PlayerModel:HasAnimation`, `PlayerModel:SetDoBlend` / `GetDoBlend`, `PlayerModel:SetKeepModelOnHide` / `GetKeepModelOnHide`, `PlayerModel:ApplySpellVisualKit` |
| [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` |
+24
View File
@@ -796,6 +796,7 @@ build instructions.
- [`model:GetDoBlend()`](#modelgetdoblend)
- [`model:SetKeepModelOnHide(keepModelOnHide)`](#modelsetkeepmodelonhidekeepmodelonhide)
- [`model:GetKeepModelOnHide()`](#modelgetkeepmodelonhide)
- [`model:ApplySpellVisualKit(spellVisualKitID [, oneShot])`](#modelapplyspellvisualkitspellvisualkitid--oneshot)
- [`DressUpModel:UndressSlot(slot)`](#dressupmodelundressslotslot)
- [`DressUpModel:TryOn(item [, handSlotName, spellEnchantID])`](#dressupmodeltryonitem--handslotname-spellenchantid)
- [`DressUpModel:IsGeoReady()`](#dressupmodelisgeoready)
@@ -18736,6 +18737,29 @@ DressUpModel:SetKeepModelOnHide(true)
Returns the value that `SetKeepModelOnHide` set.
### `model:ApplySpellVisualKit(spellVisualKitID [, oneShot])`
Shows the effects of a spell visual kit (a `SpellVisualKit` ID) on the
model, for example the glow on the hands while a spell is cast. Each effect
attaches to its point on the model: head, chest, base, hands, breath, or
weapons. An effect whose point the model does not have does not show.
If `oneShot` is `true`, each effect plays one time and then fades out. If
`oneShot` is `false` or not given, the effects play again and again until
the model changes. The model changes after `SetUnit`, `SetDisplayInfo`,
`SetCreature`, `SetItem`, and when the frame shows again after it was
hidden.
A kit can also change the color or the transparency of the model. This
change stays until the model changes. The kit does not play its animation
or its sound. A kit ID that is not in `SpellVisualKit` does nothing.
```lua
PreviewModel:SetUnit("player")
PreviewModel:ApplySpellVisualKit(99) -- holy glow on both hands
PreviewModel:ApplySpellVisualKit(154, true) -- Holy Light flash, one time
```
### `DressUpModel:UndressSlot(slot)`
Removes the item in one inventory slot from a `DressUpModel`. `slot` is an
+39
View File
@@ -5991,6 +5991,23 @@ enum Offsets {
VAR_ITEMVISUALS_RECORDS = 0x00C0DB70,
VAR_ITEMVISUALS_COUNT = 0x00C0DB74,
// SpellVisualKit.dbc (35 columns, 0x8C-byte rows). The char-select ghost
// kit (0x004727F0) reads the store at 0x004727F6/0x0047280A: nine effect
// IDs from +0x0C (attachments 20, 34, 19, 21, 22, 17, 23, 24, 25, pushed
// at 0x00472821..0x0047287F), then four procedure kinds from +0x3C with
// their first values from +0x4C (the loop at 0x00472888). Kind 1 is a
// tint, the value an 0xRRGGBB colour stored as a float; kind 14 an alpha.
VAR_SPELLVISUALKIT_RECORDS = 0x00C0D74C,
VAR_SPELLVISUALKIT_COUNT = 0x00C0D750,
OFF_SPELLVISUALKIT_EFFECTS = 0x0C,
OFF_SPELLVISUALKIT_PROC_KIND = 0x3C,
OFF_SPELLVISUALKIT_PROC_VALUE = 0x4C,
// SpellVisualEffectName.dbc: the model file at +0x08, which the ghost
// kit's effect step (0x00472780) creates (0x004727AF).
VAR_SPELLVISUALEFFECTNAME_RECORDS = 0x00C0D760,
VAR_SPELLVISUALEFFECTNAME_COUNT = 0x00C0D764,
OFF_SPELLVISUALEFFECTNAME_FILE = 0x08,
// ItemSet.dbc — armor/equipment set definitions referenced by
// `ItemStats_C::m_itemSet`. Standard 5-DWORD class shape at
// 0x00C0DBB8 (records-ptr at +0x08, count at +0x0C). 45 columns,
@@ -12925,6 +12942,28 @@ enum Offsets {
FUN_MODEL_INSTANCE_READY = 0x00710450,
// CM2Model::SetVisible — `__thiscall(model, bool)`.
FUN_M2MODEL_SET_VISIBLE = 0x00710C50,
// CM2Model::SetAlpha — `__thiscall(model, float)`; SetDiffuseColor —
// `__thiscall(model, const float rgb[3])`. Plain stores, which the
// ghost kit applies (0x004728C1, 0x00472939). Attached children take
// them through the per-frame combine.
FUN_M2MODEL_SET_ALPHA = 0x00710CB0,
FUN_M2MODEL_SET_DIFFUSE_COLOR = 0x00710CF0,
// CM2Model::HasAttachment — `__thiscall(model, id)`, ret 4. Waits for
// the model file (0x00710520) when it has not loaded.
FUN_M2MODEL_HAS_ATTACHMENT = 0x00712CB0,
// CM2Model::Attach — `__thiscall(child, parent, id)`, ret 8. The
// parent takes a reference; the attach index resolves once the parent
// loads.
FUN_M2MODEL_ATTACH = 0x00712F70,
// CM2Model::Detach — `__thiscall(child)`: unlinks the child from its
// parent, clears its parent (OFF_M2MODEL_PARENT) and drops the parent's
// reference. The destructor (0x0070E3F5) detaches every child this way.
FUN_M2MODEL_DETACH = 0x00713020,
OFF_M2MODEL_PARENT = 0x1CC,
// CM2Model::HasLiveParticles — `__thiscall(model)`: +0x3D8, which
// Animate sets while an emitter emits or holds a live particle. The
// dying-effect drain (0x0061F6CC) releases a stopped effect once false.
FUN_M2MODEL_HAS_LIVE_PARTICLES = 0x007135C0,
// CM2Model::PlayAnimation — `__thiscall(model, 7 args)`, `ret 0x1C`. The
// bake passes (-1, 0, 0, 0, 1.0f, 0, 1).
FUN_M2MODEL_PLAY_ANIMATION = 0x007121A0,
+235
View File
@@ -0,0 +1,235 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `PlayerModel:ApplySpellVisualKit(spellVisualKitID [, oneShot])`: a spell's
// visual kit on the pane's model.
//
// The world plays a kit on a unit (0x0060EDF0) through effect objects, spell
// durations and the unit's own animation. A pane has none of these. The
// engine's own kit on a bare model is the char-select ghost
// (0x004727F0), and this is that routine for any kit, on the pane's model:
// - Each of the nine effect columns: a model of the SpellVisualEffectName
// file, made in the pane's scene and attached at the column's attachment
// when the model has it (0x00472780). The children get the frame's light
// like the item glows (Model::DisplayInfo::LightChildren).
// - The procedures the ghost applies: kind 1 sets the tint, kind 14 the
// alpha clamped to 0..1, as plain stores on the model.
// The kit's animation, sound and the world-only procedures are not played,
// as retail does not play them on a model frame either (probed: no cast
// pose, no sound; the tint applies).
//
// oneShot is the world's one-shot kind: CEffect::AddEffect (0x0061FDD0)
// gives the effect model a done callback, and EffectDoneCallback
// (0x005FBF50) stops the effect when the sequence ends cleanly (reason 0).
// CEffect::Stop (0x006203E0) clears the model's callbacks and hides it, so
// its emitters stop and the particles already out finish; each frame the
// drain (0x0061F680) releases a stopped effect once HasLiveParticles is
// false. Here the release is the detach from the pane's model, run on
// FrameTick's tail where no model is mid-draw. Without oneShot an effect
// loops until the model is replaced, as on retail: an effect model's sequence
// does not loop by itself, so it gets the world's loop callback
// (KitEffectLoopCallback, 0x0060ED00), which plays the ended animation again.
//
// The effects are children of the pane's model, so they go when the model
// does: SetUnit, SetItem, a hide and show, and so on. A model destroyed with
// stopped effects still on it detaches them first (0x0070E3F5).
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "model/DisplayInfo.h"
#include "tick/FrameTick.h"
#include <cstdint>
#include <vector>
namespace Model::VisualKit {
namespace {
using GetScene_t = void *(__thiscall *)(void *frame);
using CreateModel_t = void *(__thiscall *)(void *scene, const char *path, uint32_t flags);
using ModelFn_t = void(__fastcall *)(void *model);
using HasAttachment_t = int(__thiscall *)(void *model, uint32_t id);
using Attach_t = void(__thiscall *)(void *child, void *parent, uint32_t id);
using SetVisible_t = void(__thiscall *)(void *model, int visible);
using SetAlpha_t = void(__thiscall *)(void *model, float alpha);
using SetDiffuse_t = void(__thiscall *)(void *model, const float *rgb);
using HasLiveParticles_t = int(__thiscall *)(void *model);
using SetAnimDone_t = void(__thiscall *)(void *model, const void *callback, uint32_t userLow,
uint32_t userHigh);
using PlayAnimation_t = void(__thiscall *)(void *model, int keyBone, uint32_t id, int variation,
int offset, float speed, int blend, int primary);
// The attachment of each effect column, in the ghost kit's order.
constexpr uint32_t kAttachments[] = {20, 34, 19, 21, 22, 17, 23, 24, 25};
constexpr int kProcedures = 4;
constexpr int32_t kProcTint = 1;
constexpr int32_t kProcAlpha = 14;
// The ghost kit's byte scale (0x008026C8).
constexpr float kByteScale = 1.0f / 255.0f;
// Stopped one-shot effects, each with one reference of ours.
std::vector<void *> g_stopped;
void Release(void *model) {
reinterpret_cast<ModelFn_t>(Offsets::FUN_MODEL_INSTANCE_RELEASE)(model);
}
void SetDoneCallback(void *model, const void *callback) {
reinterpret_cast<SetAnimDone_t>(Offsets::FUN_M2MODEL_SET_ANIM_DONE_CALLBACK)(model, callback,
0, 0);
}
// EffectDoneCallback's test, then CEffect::Stop's model steps.
void __fastcall OnEffectDone(void *model, uint32_t /*keyBone*/, uint32_t /*animID*/,
uint32_t reason, uint32_t /*overshoot*/, uint32_t /*userLow*/,
uint32_t /*userHigh*/) {
if (reason != 0)
return;
SetDoneCallback(model, nullptr);
reinterpret_cast<SetVisible_t>(Offsets::FUN_M2MODEL_SET_VISIBLE)(model, 0);
reinterpret_cast<ModelFn_t>(Offsets::FUN_MODEL_INSTANCE_ADDREF)(model);
g_stopped.push_back(model);
}
// KitEffectLoopCallback: a sequence that ended cleanly plays again.
void __fastcall OnEffectLoop(void *model, uint32_t /*keyBone*/, uint32_t animID, uint32_t reason,
uint32_t /*overshoot*/, uint32_t /*userLow*/,
uint32_t /*userHigh*/) {
if (reason != 0)
return;
reinterpret_cast<PlayAnimation_t>(Offsets::FUN_M2MODEL_PLAY_ANIMATION)(model, -1, animID, -1,
0, 1.0f, 1, 1);
}
// Detaches a stopped effect that is still on its model, then drops our
// reference.
void Discard(void *model) {
if (Game::Read<void *>(model, Offsets::OFF_M2MODEL_PARENT) != nullptr)
reinterpret_cast<ModelFn_t>(Offsets::FUN_M2MODEL_DETACH)(model);
Release(model);
}
// The dying-effect drain: a stopped effect goes once its particles are gone,
// or at once when its model has already dropped it.
void Drain() {
for (size_t i = 0; i < g_stopped.size();) {
void *model = g_stopped[i];
if (Game::Read<void *>(model, Offsets::OFF_M2MODEL_PARENT) != nullptr &&
reinterpret_cast<HasLiveParticles_t>(Offsets::FUN_M2MODEL_HAS_LIVE_PARTICLES)(model)) {
++i;
continue;
}
Discard(model);
g_stopped[i] = g_stopped.back();
g_stopped.pop_back();
}
}
const Tick::FrameTick::AutoSubscribe g_drain{&Drain};
// The ghost kit's procedure step: kind 1 tints, kind 14 sets the alpha.
void ApplyProcedures(void *model, const uint8_t *kit) {
for (int i = 0; i < kProcedures; ++i) {
const auto kind = Game::Read<int32_t>(kit, Offsets::OFF_SPELLVISUALKIT_PROC_KIND + i * 4);
const float value = Game::Read<float>(kit, Offsets::OFF_SPELLVISUALKIT_PROC_VALUE + i * 4);
if (kind == kProcTint) {
const auto color = static_cast<uint32_t>(static_cast<int32_t>(value));
const float rgb[3] = {static_cast<float>((color >> 16) & 0xFF) * kByteScale,
static_cast<float>((color >> 8) & 0xFF) * kByteScale,
static_cast<float>(color & 0xFF) * kByteScale};
reinterpret_cast<SetDiffuse_t>(Offsets::FUN_M2MODEL_SET_DIFFUSE_COLOR)(model, rgb);
} else if (kind == kProcAlpha) {
const float alpha = value < 0.0f ? 0.0f : value > 1.0f ? 1.0f : value;
reinterpret_cast<SetAlpha_t>(Offsets::FUN_M2MODEL_SET_ALPHA)(model, alpha);
}
}
}
void Apply(void *pane, const uint8_t *kit, bool oneShot) {
void *model = Game::Read<void *>(pane, Offsets::OFF_SIMPLEMODELFFX_MODEL_INSTANCE);
if (model == nullptr)
return;
void *scene = reinterpret_cast<GetScene_t>(Offsets::FUN_SIMPLEMODEL_GET_SCENE)(pane);
for (int i = 0; i < static_cast<int>(sizeof(kAttachments) / sizeof(kAttachments[0])); ++i) {
const uint8_t *effect = DBC::Record(
Offsets::VAR_SPELLVISUALEFFECTNAME_RECORDS, Offsets::VAR_SPELLVISUALEFFECTNAME_COUNT,
Game::Read<uint32_t>(kit, Offsets::OFF_SPELLVISUALKIT_EFFECTS + i * 4));
if (effect == nullptr ||
!reinterpret_cast<HasAttachment_t>(Offsets::FUN_M2MODEL_HAS_ATTACHMENT)(
model, kAttachments[i]))
continue;
void *child = reinterpret_cast<CreateModel_t>(Offsets::FUN_M2SCENE_CREATE_MODEL)(
scene, Game::Read<const char *>(effect, Offsets::OFF_SPELLVISUALEFFECTNAME_FILE), 0);
if (child == nullptr)
continue;
SetDoneCallback(child, oneShot ? reinterpret_cast<const void *>(&OnEffectDone)
: reinterpret_cast<const void *>(&OnEffectLoop));
reinterpret_cast<Attach_t>(Offsets::FUN_M2MODEL_ATTACH)(child, model, kAttachments[i]);
Release(child);
}
Model::DisplayInfo::LightChildren(pane, model);
ApplyProcedures(model, kit);
}
int __fastcall Script_ApplySpellVisualKit(void *L) {
void *pane = Game::Lua::ResolveTypedObject(L, 1, Offsets::VAR_PLAYERMODEL_LUA_TYPE_ID);
if (pane == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: ApplySpellVisualKit(spellVisualKitID [, oneShot])");
return 0;
}
const uint8_t *kit =
DBC::Record(Offsets::VAR_SPELLVISUALKIT_RECORDS, Offsets::VAR_SPELLVISUALKIT_COUNT,
static_cast<uint32_t>(Game::Lua::ToNumber(L, 2)));
if (kit != nullptr)
Apply(pane, kit, Game::Lua::ToBoolean(L, 3) != 0);
return 0;
}
const Game::Lua::FrameMethodEntry g_methods[] = {
{"ApplySpellVisualKit", &Script_ApplySpellVisualKit},
};
const Game::Doc::Field kApplyArgs[] = {
Game::Doc::Req("spellVisualKitID", "number", "A SpellVisualKit ID."),
Game::Doc::Opt("oneShot", "boolean", "false",
"True plays the effects once; false keeps them until the model changes."),
};
const Game::Doc::Function kApply{"Shows a spell visual kit's effects on the model.", kApplyArgs,
{}};
const Game::Doc::Method g_methodDocs[] = {
{"ApplySpellVisualKit", &kApply},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_PLAYERMODEL_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])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
// Drop every stopped effect before the reload tears the panes down.
void PrepareForReload() {
for (void *model : g_stopped)
Discard(model);
g_stopped.clear();
}
const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace
} // namespace Model::VisualKit