Compare commits

...
342 Commits
Author SHA1 Message Date
Brues 4477749bd6 feat(broodwatch): eight new spells, rank V masteries, client names
New spells, tanky and fun, each on the scene features it shows off:
Power Word: Shield soaks the bites into a share of your health with its
bubble kit on; Thorns burns the biters each second; Frost Nova freezes
the brood around you in Frost Nova's ice (state kit 285); Psychic Scream
sends it fleeing under its terror kit; Demoralizing Shout softens its
bites; Stoneclaw Totem pulls it onto an earth totem with the Battle
Chicken's taunt; Blast Wave hurls it away with KnockBack; and Summon Imp
brings an imp that follows you and casts the real Firebolt. Brood.lua
gains the holds, fears and weaknesses (Hold, Fear, Weaken, UpdateStates)
with their kits, and elites shrug off half of a hold or a fear.

At rank V every spell masters and does something new: Frostbite,
Shadowburn, Overload, leaps that grow, Corruption that amplifies, Echo,
dazes, an Aftershock, a totem's eruption, Deep Freeze, a second Rain of
Fire, Consecration that follows you, a shield that bursts, Thorns that
heals, Shatter, Terror, a battle cry, a Stoneclaw stun and two imp
bolts. A new spell's card teases its mastery; the rank V card names it.

The level-up cards grow to their text (GetStringHeight) and hang below
the hint, all three at the tallest one's height, so nothing is cut off.
Spell names and the passives named after a spell come from
C_Spell.GetSpellName, and the items from the Item mixin by their real
item IDs, so the client's language shows.
2026-11-11 13:02:49 -06:00
Brues 99fde6e1bb feat(model): a kit's world effect on scene actors; lasting effects hold
A 1.12.1 SpellVisualKit has nine effect columns that hang on attachments
(+0x0C to +0x2C) and a tenth, +0x30, that the world plants on the
ground. PlayUnitSpellVisualKit (0x0060EDF0) makes a CEffect for it and
PlantEffect (0x0061FCF0) puts it at the unit's position (the unit's
vtable +0x14) or at the point it is given; with a spell that has a root
aura (26) in one of its three effects the effect gets flag 0x4000 and
follows the unit. Frost Nova's state kit 285 keeps its ice there
(Frost_Nova_state.mdx), so a frozen actor showed only the head glow.

A scene actor's kit now plants that effect: a model of its own in the
scene at the actor's position, lit as an entity like the area shards
(LightAsEntity), shown and animated in the actor's link, placed in its
place pass, its light settled in its update, and released with the
kit. SetSpellVisualKit carries no spell, so the effect stays where it
was planted, as the world's does without flag 0x4000.

A lasting effect also ended up re-forming. The world gives a state
kit's effects (kind 2) KitEffectHoldCallback (0x005FF170): when the
first sequence ends cleanly, a model with animation 158, Hold, plays it
under KitEffectDecayCallback (0x005FF1D0), which plays Hold again until
the effect's end time; a state kit leaves that time at 0, so the effect
holds until its aura goes. Our kits used only the channel kits' loop
(KitEffectLoopCallback 0x0060ED00), which replays the first sequence.
A non-one-shot kit effect now holds Hold when its model has it and
replays its sequence otherwise, so Frost Nova's ice forms once and
stays, while a precast glow without Hold loops as before.

Animation 158 was named ANIM_DYNAMIC_OBJECT_LOOP for the ground areas'
use; it is ANIM_HOLD now, its AnimationData name, with both readers in
its comment. docs/API.md describes the ground effect and the hold under
SetSpellVisualKit.
2026-11-11 13:02:39 -06:00
Brues ea7e215a0c feat(addons): Broodwatch, a survivors game on the ModelScene backport
Broodwatch holds Onyxia's Lair against an endless brood: the lair is a
scene's WMO (scene:SetWorldModelByMapID(249)), the hero and every whelp
are actors with the client's mover, and spells, items and hits are the
client's own spell visuals. It doubles as a load test and a showcase for
the scene features it uses: MoveTo and SetMovementSpeed for the chase,
PlaySpellVisual with OnSpellVisualImpact for the spells, Chain
Lightning's bolts, SetModelBySpellArea and SetGroundReticle for the
ground areas, KnockBack for the explosions, FadeOut and the Dead hold for
the corpses, Project3DPointTo2D for the hit numbers and item icons, and
Cooldown frames and eased StatusBars for the HUD.

The addon lives at the repository's root AddOns/, outside src/embed, so
it is not embedded in the DLL; the client links to it with a junction
from Interface\AddOns\Broodwatch. /broodwatch starts a run.

The files share the addon table (local _, BW = ...): Data holds the
tuning, Core the shared state and helpers, then Brood, Spells, Hud and
Items, and Broodwatch.lua the run, the keys and the setup.
2026-11-11 11:35:04 -06:00
Brues f5b240de77 feat(model): actor:FadeOut, the world's model fade; dead actors lie Dead
When the 1.12.1 client removes an object (deactivate 0x006145E0), it
hands the model to FadeOutModel (0x00672DF0) at its current alpha. A
ready model (0x00710450 with 0, 0) at an alpha of 0.01 or more, or NaN
(fcomp [0x008029D0] at 0x00672E21, test ah,5 / jnp), is kept with the
time and that alpha; any other is released at once. Each frame the pass
0x00672EF0 shows (3 - 2x)x^2 with x = (1 - age x 0.0005) x the alpha,
held to 0..1 (0x00672FD3 to 0x00673025, 3.0 at 0x0080297C), and releases
the model when the age passes 2000 ms (cmp eax,0x7D0; jle at 0x00672F36).

actor:FadeOut applies that curve on top of the actor's alpha in
ApplyAlpha and hides the actor where the world releases the model; an
actor below 0.01 or with no ready model hides at once. The world times
the fade with the OS tick; here it runs on the scene clock, so a paused
scene holds it. Show, Hide and SetShown end it. actor:IsFadingOut
reports it. The 0.0005 is the mover's VAR_MOVER_STALL, named again as
VAR_MODEL_FADE_RATE where FadeOutModel reads it.

A unit's death in the world ends in a hold: PlayFollowOnAnimation
(0x005FC3F0) sends Death (1) and Dead (6) through its byte table
0x005FC848 to 0x005FC52D, and Drown (131) and Drowned (132) to
0x005FC573. While the unit is dead (0x00605F90), each plays Dead or
Drowned through 0x007121A0 in the variation the ended sequence played
(GetKeyBoneAnimState 0x00711FE0); otherwise it picks again. Locomotion's
FollowOn now has that arm for an actor with the mover, which is dead
while it plays a death animation, so it lies where it fell instead of
playing Death again. An actor without the mover repeats its animation,
as before.

docs/API.md documents FadeOut and IsFadingOut, and the death hold under
SetMovementEnabled.
2026-11-11 11:08:27 -06:00
Brues bb5acbbdfb feat(model): actor:KnockBack, the client's knockback launch on scene actors
A 1.12.1 server knocks a unit back with SMSG_MOVE_KNOCK_BACK (0xEF, to
its controller) or MSG_MOVE_KNOCK_BACK (0xF1, relayed): a direction
pair, a horizontal speed and a vertical speed that the server negates
into the client's down-positive fall speed. Both paths end in the same
launch on the unit's CMovement, 0x006179C0: swimming ends (0x007C6E80),
a fall starts at the vertical speed (0x007C61F0, which re-anchors with
the old flags), the direction pair overwrites the heading +0x68 and the
direction +0x5C with 0 in z, flag 0x2 clears and 0x8001 sets whatever
the speeds, and the horizontal speed goes to +0x84. Nothing about the
unit refuses it. The direction is world-absolute.

Mover::KnockBack transcribes that launch on a scene body. While the body
falls, the direction is not recomputed (0x007C5A20 returns early), so the
flight keeps its line; the 0x8000 latch, now named FLAG_STOP_ON_LAND,
makes the landing stop the move (ApplyLatches 0x0061AB20), as it does a
knocked-back character's.

actor:KnockBack(fromX, fromY, horizontalSpeed, verticalSpeed) stands in
for the server's KnockBackFrom: the direction from the point to the
actor, or the facing turned half a circle (the client's pi, 0x0081052C)
on the point itself, and the actor's preparing spells stop, as the
server interrupts non-melee spells. As every move event that is not the
player's own, it ends a MoveTo first (ClearSpline). The acknowledgement's
animation follows: AnimateMoveOpcode (0x0060E480) plays 40, Fall, for
0xF0 (0x0060E4FC, PlayAnimation 0x005FE2F0 with 0x28). A dead actor keeps
its death animation, so a corpse flies in its death pose.

docs/API.md documents the method next to MoveTo.
2026-11-11 10:41:33 -06:00
Brues 0825491af2 feat(model): chain-lightning bolts on scene casts; quiet missiles when paused; docs
The 1.12.1 client draws Chain Lightning's arcs from a spell visual kit
procedure of kind 0 or 12 (0x0060D7FC, through the byte table at
0x0060DC20 to 0x006ECB90). The procedure's values decode, as the area kit
does (bits of value + 512.0, shifted right 14), to a SpellChainEffects id,
a strand count capped at 3 (0x006ECC65) and a persistent flag. Chain
Lightning's cast kit 321 has kind 12, chain 1, one strand; row 1 of
SpellChainEffects.dbc is segment 2.78, width 0.5, jitter 0.04, texture
period 1.0, life 1000 ms, Textures\SpellChainEffects\Lightning.blp.

Each hop of the world's LightningObject gets a bolt from the client's
lightning renderer (Lightning.cpp, a 32-byte __thiscall object): Init
0x007B0760, Destroy 0x007B0780, CreateBolt 0x007B07F0 (ret 0x2C),
MoveBolt 0x007B0950, ShowBolt 0x007B0BD0, DestroyBolt 0x007B0AC0, Update
0x007B0A40 and Render 0x007B0A80. The hop update (0x006EC460) creates the
bolt with the row's shape, then moves it between its ends each frame; the
caster's end is its $CSL event point, else its position raised by 0.75 of
its height (0x006EC6F0), and the target's is the attachment the spell
visual's +0x24 names, else its position (0x006EC780).

A scene now owns one renderer and drives it through those functions
(model/SceneChain.cpp). A scene cast has one target, so a chain is one hop
from the caster to the target, timed on the scene clock for the row's
life, hidden while either actor is hidden. The bolts update after the
actors and draw after the translucent models. The per-bolt draw
(0x007AFCB0) pushes and pops its render states and restores the device
view and the transforms it changes, so the frames drawn after the scene
are not affected. The kit-byte decode is shared with the area emitter as
Model::Scene::DecodeKitByte.

The world has no pause, so a missile's flight loop ran on through a paused
scene. A paused scene or caster now stops the loop at once, and the loop
starts again from the spell visual's missile sound when the pause ends; a
hidden scene's missiles get their sound back the same way.

docs/API.md gains the scene actor methods of this branch that had no
entry: actor:SetModelBySpellArea, actor:SetGroundReticle /
ClearGroundReticle / GetGroundReticle and actor:SetMovementSpeed /
GetMovementSpeed, with the chain bolts and the paused sounds in the
PlaySpellVisual, scene:SetPaused and actor:SetPaused entries.
2026-11-11 09:49:41 -06:00
Brues 2a4731f9e5 feat(model): ground-target reticles on actors; quiet areas when paused
actor:SetGroundReticle(radius [, valid]) / ClearGroundReticle() /
GetGroundReticle() show the world's ground-target reticle at the
actor's position. The world draws one at the pointer while a spell
waits for a ground position (WorldFrame, 0x004837B0, the path without
a placement model); any actor can show one here, so a scene can mark
several spots, and an actor with no model serves as a marker.

The recipe is the world's: the box is the point +- the radius in x and
y and +- 2.0 ([0x00801628]) in z, a radius of 0 taking 1.3888889
(0x3FB1C71C, 0x00483858); inside a state push, lighting (0xE) and depth
write (0x12) off and blend 2; the in-range or out-of-range texture at
stage 0x17 (0x00B4B344 / 0x00B4B348, Spell-Shadow-Acceptable and
-Unacceptable, loaded when the world frame is built at 0x00481C00,
bound through 0x0044ACF0); then DrawDecal (0x006D7330) in white, with
no texture matrix and fade 0.5, onto the receiver class 0x200122. The
world draws it after the opaque models and before the water and the
translucent models (0x004836B0), in its fog; the scene draws it after
M2 pass 0, in the scene's fog (World::CommitDecalFog), or flat at the
actor's height without a WMO. The liquid pass (class 0xF0000) is not
mirrored. Before the world frame exists the textures are null and no
reticle draws.

A spell area's loop sound went on while the scene was paused: the
paused scene still runs each actor's update with no time, which the
area kit took for running. The world has no pause; here a paused scene
(SetPaused) or actor silences the loop, which starts again when the
pause ends. The shards already held, on the scene clock.
2026-11-11 08:07:49 -06:00
Brues 0d639a0a35 feat(model): the area kit of a spell ground area: shards and loop
An actor that shows a spell's ground area (SetModelBySpellArea) now
runs the area kit, as the world's dynamic object starts it when it is
created (CGDynamicObject_C, 0x005D55C0) and stops it when it is
deactivated or destroyed. Nothing new is exposed to Lua.

The kit is the visual's +0x34 SpellVisualKit, read only with the area
flag +0x2C set. Its first kind-9 procedure (cmp at 0x005D5640) starts
the shard emitter (0x006ECE30 -> 0x006EB930): the shard file is the
type's entry in the char *[7] at 0x00870E24 (the low byte of the bits
of the procedure's first value + 512.0, shifted right 14; read there
with no bound, here bounded), and the rate is the procedure's second
value (+0x5C), scaled by the spell detail record [0x00CEE9A8] +0x28:
x0.33 at 0 ([0x00808300]), x0.66 at 1 ([0x0081199C]).

Each update the emitter adds the scene seconds times the rate and
spawns a shard per whole 1.0 (0x006EBAD0). A shard mirrors 0x006EBB64
to 0x006EBDA9: a draw u in [0, 1) and a unit vector from the client's
own random state (0x004531E0 and 0x0044FD00 on 0x00882664), a point
u * radius out and 16.666666 up ([0x00810E20]), a trace to it from
1/6 over the centre that keeps 0.95 of a hit's fraction ([0x008119A0]),
then a trace 33.333332 down ([0x0080FEE4]) whose hit is the shard's
place, else the area's centre. The traces are the scene WMO's
(World::Trace); without a WMO the shard lands at the area's height.
The shard's model plays its animation once after 2 * b ms, b the byte
of a draw * 255.0 + 512.0, and the done callback's natural end (reason
0, 0x006EBE70) frees it; every fourth event of the shards plays when
it is a $SND (0x006EBE30). The scene clock times them, so they hold
while the scene is paused, and they play out after the area goes.

The kit's sound (+0x34) loops at the area and stops with the 3.0 fade
of 0x005D56D0; a hidden scene silences it and the next update loops it
again.

The entity-light helpers (LightAsEntity, ReleaseLit, SettleLight) move
from SceneSpell.cpp to Scene.h so the shards light as missiles do.
2026-11-11 08:07:49 -06:00
Brues 8dc7cf8ace feat(model): actor movement speeds and spell ground areas
actor:SetMovementSpeed(speedType, speed) / GetMovementSpeed(speedType)
set the walk, run, run back, swim, swim back and turn speeds of an
actor's mover, as a server changes a creature's speed. The client's
spline speed opcodes (0x2FE to 0x303, switch 0x00601360) call the
CMovement setter at once: SetWalkSpeed 0x007C70D0, SetRunSpeed
0x007C7030, SetRunBackSpeed 0x007C7080, SetSwimSpeed 0x007C7120,
SetSwimBackSpeed 0x007C7170, SetTurnRate 0x007C6FF0. Each stores the
value unless it is within 2^-22 of the field ([0x008029D4]; a NaN
stores), re-anchors, and refreshes the current speed except for the
turn rate. Mover::SetSpeed mirrors that. The switch then calls
AnimateMoveOpcode (0x0060E480, nothing for these opcodes) and
UpdateBaseAnimation(-1) (0x005FD9E0), so the actor re-picks its
animation and rate (Locomotion::Repick). The setter leaves a spline
alone, so a MoveTo keeps the speed it started with. Speeds live on the
mover and go back to the defaults when the movement turns off, as the
walk mode does; a speed of 0 or less is a usage error.

actor:SetModelBySpellArea(spellID, radius) shows a spell's ground area
as the world shows a dynamic object (CGDynamicObject_C). The model is
the one slot 10 names (0x005D5890 -> 0x005D57C0): the spell's visual
(GetSpellVisual 0x006EC1E0) must have the area flag (+0x2C), and its
+0x30 SpellVisualEffectName row gives the file. Once loaded, the actor
runs the loaded hook (slot 13, 0x005D52F0): the scale is the radius
over the bounding-sphere radius when that is above 0.001 (the double at
0x00801608), else over the effect row's +0x10 size when that is above
0; a model with animation 158 and none for 0 loops 158, as the done
callback (0x005D5580) replays it. The area's model events follow the
dynamic object's (0x005D58C0): $SND plays its payload at the area,
always; $SHK shakes the world camera, which a scene does not have.
The area kit (+0x34: the kind-9 shard emitter and the looping sound)
is not part of this yet.
2026-11-11 08:07:49 -06:00
Brues 1e0871b639 feat(model): names over scene actors, as the world draws a unit's
actor:SetNameText(text [, r, g, b]) / ClearNameText / GetNameText show
a name over the head. It is drawn the way the 1.12.1 world draws a
unit's name (PLAYERNAMEDESC::UpdatePlate, 0x6C6E90):

- It runs from the body model's draw callback, installed with
  SetDrawCallback (0x710DB0, list0 0) as the unit's is at 0x6C7756.
  It draws only when CM2Model::IsReady (0x710450), as
  PlayerNameDrawCallback (0x6C7780) checks.
- The plate scale is 0.2, or d / 4 * 1.5 * 0.2 above 4 yards, where d
  is the name height over the feet (0x6C6F10..0x6C6F3C, reading the
  constants at 0x8112A8, 0x8112AC and 0x80679C).
- The text is made with GxuFontCreateString (0x5C1C30) on the world's
  name font (0xCE8738), with the grammar pass (0x7716F0), a 100000 box,
  justification 2/1, flags 0xC8 and the colour. The block height is
  the extra lines times the scale.
- Each draw adds the string to the world's name batch (0xCE8734,
  0x5C1E60), places it at the name position raised by the block height
  (0x5C2770) and draws the batch (0x5C1EF0). A colour change alone goes
  to GxuStringSetColor (0x5C2750), as the colour refetch does.

A custom draw runs under the scene's rebased view with a zero offset
(0x70D96D). The paint gives the scene its eye as the origin, so the
callback places the name at its absolute scene position, as the world
does.

actor:GetNamePosition() mirrors CGUnit_C::GetNamePosition (0x608640).
It uses attachment 18 on the model. A seated rider counts as mounted,
so it uses attachment 29 on its mount's model. Without the attachment,
the point is 1.25 times the scaled box height above the feet.

The name font and batch exist only in the world: world entry makes them
(0x6C7470), and PlayerNameShutdown (0x6C7630) frees them in
ClientDestroyGame (0x40202F), before CGGameUI::ShutdownGame. Each actor
therefore keeps the font its string was made with, and frees the
string only while that font still exists. At the glue screens no name
draws.
2026-11-11 08:07:49 -06:00
Brues 70a6b99bfe feat(model): a paused scene stops movement and spell visuals too
scene:SetPaused stopped only what runs on the M2 scene clock (scene
+0x0C): the animations, GetSceneTime and the scene timers. The mover
ticked on the OS clock, as the client's does, and the casts stepped by
the frame's elapsed time. A paused scene therefore still moved its
actors in a frozen pose and flew its missiles.

The scene clock is the scene's world time, so the actors now run on
it:

- The mover (Mover::Init, Mover::Tick) and the body-facing stamps
  (the catch-up turn of 0x600CD0) read the scene clock through the new
  Model::Scene::ClockMs. The spline start (b.clock) stays in the same
  base. Mover::Tick does nothing for a zero window, so a paused scene
  holds every body. On unpause the body sees only the time the scene
  ran, so nothing jumps ahead.
- Actor::Update takes the ms the scene clock moved this frame, as
  seconds, for Casts::Update (precasts, missiles, impacts, stuck
  arrows). That is 0 while paused. OnUpdate keeps the frame's elapsed
  time.
- The move marker's pulse (0x611400's sine over 0x860AE0 ms) reads the
  scene clock where the world's reads the OS clock, so it holds too.

FUN_SIMPLEMODEL_GET_SCENE (0x76CFC0) makes the scene on first use, so
a scene frame always has a clock to read. A hidden scene runs no
update, so its clock and its actors stop as before.
2026-11-11 08:07:49 -06:00
Brues edd7fb8577 docs(model): document the actor highlight, selection circle and move marker 2026-11-11 08:07:48 -06:00
Brues 34b061e3da feat(model): actor highlight, selection circle and the move marker
Three things the 1.12.1 world draws for units, now on scene actors.

actor:SetHighlight(kind, highlighted) / IsHighlighted([kind]) keep the
two light bits of a unit: "target" is bit 0 and "mouseover" bit 1, as
SetLight (0x614550) and ClearLight (0x6144F0) use them. Setting a bit
gives the model the emissive colour through 0x710D40 (__thiscall(model,
float rgb[3])). The world takes it from the ambient band of the light
state; the scene takes it from the scene light's ambient, the light that
stands in for the sun. Clearing makes the model black only when no bit
is left, as ClearLight does. A new model gets the glow again.

actor:SetSelectionCircle(r, g, b [, a]) / ClearSelectionCircle /
GetSelectionCircle replay CGUnit_C::DrawSelectionCircle (0x608E00).
The radius is the one 0x60AEE0 computes at model load (sqrt of half the
xy diagonal of animation 0's bounds, the model bounds without it, 1.2
when flat) times the scale. The box is the feet +-(r, r, 2r). The yaw
matrix (0x615070) turns about z by minus HeadingBetweenPoints (0x47F220)
from the scene eye. The state is SetSelectionCircleState (0x614E00).
The decal is fade 0.5 with flags 0x200122. It is drawn for each actor
before its shadow, as DrawEntityModels draws the circle before the blob.
It is gated by the ObjectSelectionCircle cvar.

scene:SetMoveMarker(x, y, z) / ClearMoveMarker / GetMoveMarker replay
the auto-move marker (0x611400): the box is the point +-s, s = min +
(max - min) * (sin(2pi t / period) + 1) / 2 on the OS clock, from the
static .data values at 0x860AE0..0x860AE8 (500 ms, 0.5, 0.75), colour
0xFFFFFF7F. It uses the same yaw matrix, state and decal.

With a WMO the decals land on its faces through DrawDecal (0x6D7330)'s
transforms and DrawDecalBatches (0x6D7480)'s states. Without one they
land on one flat quad at the feet. The shared decal code in
SceneWorld.cpp is split into WithDecalStates / WithDecalTransforms for
the two paths.

Fog: 0x614E00 turns off lighting (state 0xE), not fog. The world draws
these decals in the fog CWorld::SetFogState sets for the whole pass.
The scene paint has no fog of its own at that point, so the device's
default (0 to 1 in 0xFFFF0080) made every circle pink.
World::CommitDecalFog commits the scene's fog first: the WMO's lighting
record, as the WMO liquids get theirs, or the frame's light and fog
without a WMO.

The decal fade and flags move to Offsets.h, because the soft-target
circle uses them too.
2026-11-11 08:07:48 -06:00
Brues ddffa12f2f docs(model): document the scene clock and scene timers 2026-11-11 08:07:48 -06:00
Brues d7ff5d8a5c feat(model): the scene clock, GetSceneTime and scene timers
A ModelScene's M2 scene already keeps a clock: uint32 milliseconds at
scene +0x0C, which SetTime (0x00707490) stores and M2SceneAdvance
(0x007074B0) moves forward. The Model frame's update (slot 14,
0x0076D7F0) advances it by the truncated milliseconds of each frame.
SetPaused already skips that advance, and a hidden frame does not
update, so this is the clock every actor animates by, and it stands
still exactly when they do.

- scene:GetSceneTime() returns the seconds that clock has run since the
  frame was made. OnFrameUpdate reads +0x0C before and after the base
  update and adds the uint32 difference, so the sum starts at 0 and
  never wraps. OFF_M2SCENE_TIME added to Offsets.h.
- scene:After / NewTimer / NewTicker are C_Timer's contract on that
  clock. They share C_Timer's queue, cancel handles and registry
  callbacks: each entry now has an owner (null for C_Timer). The scene
  fires its own entries from OnFrameUpdate right after the clock moves,
  before the actors update. Its destructor drops them (CancelOwned).
  C_Timer's WorldTick fires only owner-null entries, and its next-
  deadline early-out now covers only those. The argument checks of the
  three C_Timer functions moved into the shared Time::Timer::Script.
- SetPaused's descriptor is unchanged; GetSceneTime and the three timer
  methods have descriptors marked as ClassicAPI extensions.
2026-11-11 08:07:48 -06:00
Brues 3df06c056d docs(model): document scene:GetActorsInRadius 2026-11-11 08:07:48 -06:00
Brues 67e833557a feat(model): scene:GetActorsInRadius, the nearest-unit collector's test
scene:GetActorsInRadius(x, y, z, radius) returns a table of the scene's
actors near a point, nearest first. It applies the test of the 1.12.1
client's nearest-unit collector (0x00494200, the enumeration callback
behind TargetNearestEnemy and its kin) to the scene's own actor list:

- A unit counts only when its scene node is drawn (0x006704C0: node not
  null and +0x94 clear). The scene's counterpart is a shown actor with
  a model; scene info actors without one (scene 4's "unwrapped",
  "wrapped", "player-rider") do not count.
- The distance is the squared difference of the two positions in all
  three axes, kept when it is not more than the squared radius
  (0x0049435B..0x0049436B: fcompp, test ah,0x41).
- The list is sorted by squared distance ascending, as the collector's
  compare (0x00494450) orders entries of one kind; ties keep creation
  order (stable sort).

Pure reads of the actor vector and positions; no engine call, no state
change.
2026-11-11 08:07:48 -06:00
Brues 5a78289124 docs(model): document scene:GetCursorRay
API.md gets a GetCursorRay entry (the two points, when it returns
nothing, and how TraceSegment finds the WMO point under the cursor, with
a right-click move example). README lists it.
2026-11-11 08:07:48 -06:00
Brues 9f166b6770 feat(model): scene:GetCursorRay, the world frame's pick ray
The world frame builds its pick ray in ScreenPointToRay (0x004813B0,
called by PickAtCursor 0x00481190): the cursor's fractions across and
up its rect, then UnprojectScreenPoint (0x007AD2C0), which reads the
device view and projection, builds the frustum corners and interpolates
the near and far planes, then the camera's eye added to both points
because the view leaves it out.

The scene does the same with the transforms it paints with: the cursor's
fractions across the paint's viewport (the frame's rect clamped to the
screen), CameraTransforms (the camera's CameraSetDeviceTransforms
0x007ADA40, fitted to the view insets) under SavedTransforms,
UnprojectScreenPoint, then the scene camera's eye. It returns the two
points in scene coordinates, or nothing when the cursor is off the
picture or the frame is hidden.

The world traces that ray with class 0x1000114, whose WMO face mask
(0x006A3839) is 0x84, the mask TraceSegment uses by default, and which
skips doodads as TraceSegment does. So GetCursorRay into TraceSegment is
the world's point pick on a scene.

CursorClip becomes CursorFraction (0 to 1) so both pick paths share it;
GetActorAtCursor maps the fractions into clip space itself, the same
arithmetic as before. Offsets.h gains FUN_UNPROJECT_SCREEN_POINT and
gx/Device.h a wrapper for it.
2026-11-11 08:07:48 -06:00
Brues 6b4224d281 docs(model): document FollowCamera and GetCameraPivotHeight
API.md gets a Follow camera section (SetFollowActor / GetFollowActor,
the pivot, the smooth height change at cameraHeightSmoothSpeed, the stop
in front of walls, the wait for the model, and how to add the camera to
a ModelSceneMixinTemplate scene) and an actor:GetCameraPivotHeight
entry (the mouth or collision-box base, the stance animations near the
actor, the swim drop, the 5/6 to 15 yard range). README lists both.
2026-11-11 08:07:48 -06:00
Brues 79d4571f8b feat(model): FollowCamera takes the world camera's pivot height
actor:GetCameraPivotHeight(distance) gives the height the world's
follow camera eases to for a unit, read from the actor's model as
CCamera::BuildPivotHeights (0x0050CA90) reads a unit's. The camera's
target setter (0x0050D0F0) rebuilds the presets every frame once the
model has loaded, so the actor's are read at each call:

- base: the unposed offset of attachment 17 (GetAttachmentOffset
  0x00712CF0) plus 7/72 yard (0x00808AB0), or 0.9 (0x00804588) of the
  collision box (0x00713700), times the scale;
- near: lowered by the stand-state animation's sequence bounds top
  under Stand's (0x0050CDDF on);
- swimming: lowered the same way by Swim (42) when the model has both
  (0x0050CCE3 on);
- each kept from 5/6 (0x008089CC) to 15 (0x008089C8).

ArmPivotHeight (0x0050F880) picks swimming for a swimmer, else near
under 1.8315 yards of target distance (0x008089B0), else far. The
constants are read live. An actor has no stand state: the animation it
plays stands in when it is one the jump table at 0x0050D0CC gives, and
cameraHeightIgnoreStandState (its CVar* at 0x00BE0CF4, stored at
0x0050BD9E) ignores it. A mount is left out. Until the model has
loaded the method returns nothing and the camera waits, as the world's
update does (0x0050E900).

FollowCamera drops its height argument and eases to that height as the
camera's height channel does: the first one is taken at once
(BeginHeightTransition 0x00512790), each new one is armed
(ArmHeight 0x005126B0) for |change| / cameraHeightSmoothSpeed seconds
and eased with the cosine curve of 0x0050F160.

M2 gains HasAnimation, GetCollisionBox and GetAttachmentOffset
wrappers; SceneSpell and SceneWorld use them in place of their local
function pointers.
2026-11-11 08:07:47 -06:00
Brues 8982ec757b feat(model): FollowCamera, an orbit camera that follows an actor
A camera type in the camera registry, built on OrbitCameraMixin. Its
target is an actor's position plus a height, set each frame before the
orbit camera's own update.

The eye is placed as the world's follow camera places it: the boom
sweep of the solver at 0x0050E570. The sweep is skipped when the
distance is not past the near clip by more than 2^-20 (0x008026BC).
Otherwise a trace of the camera's faces from the target to the eye
(scene:TraceSegment with camera set, the ray query of 0x0050E816)
shortens the distance. A distance shortened by more than 2^-22
(0x008029D4) backs off by 1/9 yard (0x00808A00, 0x0050E871 to
0x0050E8A4), never below 0. The vertical probe and the near box clip
of the solver are left out. The game gives the pivot height.

With no actor to follow, it is a plain orbit camera.
2026-11-11 08:07:47 -06:00
Brues b44b0d2f59 refactor(model): share the compositor calls and call WrapAngleToPi
Outfit declared its own casts of the character compositor's create,
clone, unequip-slot and set-item calls (FUN_CHARCOMP_*), which
DisplayInfo also casts. They are now DisplayInfo's NewDresser,
CloneDresser, UnequipDresserSlot and SetDresserItem, beside the pump and
destroy it already exported.

The actor's body-turn pass copied WrapAngleToPi (0x006084F0). That
function is `double __stdcall(float)`, ret 4, and reads only .rdata and
the CRT's fmod (Ghidra), so the pass calls it; the pi and 2pi constants
it was copied with leave Offsets.h.

Also drops VAR_MOVER_MINUS_SKIN, unused since ClipPolygonByPlane is
called, and two unused locals in Cooldown's AnimateVerts.
2026-11-11 08:07:47 -06:00
Brues 76eb5d9135 refactor(model): call the engine's math and read its constants live
The ModelScene mover and contact sweep carried their own copies of engine
code and data. They now use the client's.

Constants: every float the transcribed mover reads from 1.12.1's .rdata
(and the two fall-speed caps in .data, the 2pi the static initializer
fills at 0x00CF5D00) is an Offsets.h entry read where it is used, through
Game::Live<T>. Each value was checked against WoW.exe's bytes. The pooled
floats other modules already named for one use (0x00801360, 0x008026BC,
0x008029D4, 0x00803568, 0x008022CC, 0x00801620, 0x0080306C, 0x00801628)
are renamed by value, since the mover reads them too.

Leaf math: the pure geometry helpers the mover calls are now called, not
copied: DotVector3 0x00602630, PlaneFromTriangle 0x00637480,
PlaneDistanceAlongRay 0x006329E0, ClipPolygonByPlane(s/Except) 0x006318C0
/ 0x00631870 / 0x00632980, IntersectThreePlanes 0x00634FC0 and
PointToLineDistanceSquared 0x00634F40. Each is __fastcall with callee
cleanup and reads only its arguments and .rdata (Ghidra). Two stay
transcribed: FindNearestTriangleEdge 0x00634DA0 reads the client's facet
list at 0x00C4E534, and SelectBevelContactNormal 0x00636610 reads an
unwritten stack slot as an index when three bevels have no odd one out.

Wrappers: engine calls that several files cast themselves get one typed
home: M2.h (key-bone animation state, sequence info, base transform,
scene draw, model lights, the IsXAnimation tests, whose death test is
__stdcall and the rest __fastcall), gx/Matrix.h (the 0x007BxXXX matrix
helpers), gx/Device.h (render-state journal, state setters, transform
stacks, clear), Frame::Layout (DDC/NDC conversion), Texture::DeviceTexture,
Xml::ReadColor and Frame::Subclass's slot types.
2026-11-11 08:07:47 -06:00
Brues aa88e776e9 docs(model): document ModelScene movement, traces, liquids and listener
API.md gets the scene functions that had no entry: TraceSegment (with
its camera faces), GetLiquidAtPoint, SweepCollider and
SetSoundListenerActor / GetSoundListenerActor. Scene sounds now names the
listener actor.

It also gets the actor's movement: SetMovementEnabled /
IsMovementEnabled (the WMO it moves on, a character's speeds, the body
size from the creature display, the drop to the floor, and the
position, yaw, tilt and animations that follow the movement), a table of
the movement commands and their applied result, IsFalling, IsSwimming,
MoveTo with a two-point patrol on OnMoveFinished, and SetWalking /
IsWalking. The actor scripts table gains OnSpellVisualImpact and
OnMoveFinished.

The README's model scene summary and its ModelScene and ModelSceneActor
rows list the same names.
2026-11-11 08:07:47 -06:00
Brues d74952c9ac feat(model): ModelScene actors walk to a point along the client's spline
actor:MoveTo(x, y, z [, facing]) moves an actor with the mover on as a
server's SMSG_MONSTER_MOVE moves a unit, through our own copy of the
1.12.1 client's spline path. The live movers are not touched.

The mover side (SceneMover.cpp), one client function each:
- CGUnit_C::OnMonsterMove 0x006018F0: the ground path from where the body
  stands (a lone point within 1/36 yd is dropped; AnchorSplinePath
  0x006021C0 leaves a path that starts at the body as it is), the guard
  points (0x00601E66 to 0x00601ECA), the length (0x00601EF4), the speed
  capped at 4x the run speed (0x00601F3D) and the fistp duration of at
  least 1 ms (0x00601F9F). A path of 1/6 yd or less stops the body where
  it stands (FlushAndStopSpline 0x00618750). The duration the scene sends
  is the path at the body's own swim, walk or run speed.
- StartSplinePath 0x006187A0: StopAllMotion 0x0061AA70, InitSpline
  0x007C62C0, StartSpline 0x007C6A50 (run mode from spline flag 0x100,
  then StartMove), the final angle (SetSplineFacingAngle 0x006188A0).
- The curve in mode 0 (straight chords by arc length): the segment
  locator 0x00453840, the chord point 0x004541B0 and length 0x00454320,
  the totals 0x004543D0 / 0x00453920, the frame 0x00453480 / 0x00454580.
- FollowSpline 0x007C5490 (progress by fild/fidiv, facing by fpatan, the
  swimmer's pitch by asin), the warp gate GetSplineStep 0x00616AF0 (past
  3 yd or 60 yd/s the point is taken without collision), and SnapIfFar
  0x00616D60 after the substeps (0x00616DE0).
- The arrival in BroadcastStateChange 0x0061A820: stop, snap, final
  facing, CompleteSpline 0x00619DE0. The substep loop 0x00634040 skips
  its speed test while a spline is live, and GetCurrentSpeed 0x007C4C90
  gives the spline's length over its duration.

The actor is the client's active mover, so a finished spline is reported
(CMSG 0x2C9) and freed. That report is the actor's new OnMoveFinished
script. The script plays the server's part, so every other movement
command, SetYaw, SetPosition and the new SetWalking end the move first
(ClearSpline 0x007C6310, as UpdatePlayerMovement 0x00615C30 does for a
move event that is not the player's own). SetWalking / IsWalking set the
run mode (SetRunMode 0x007C71C0); a moving actor re-picks its animation
on the change (MSG_MOVE_SET_RUN_MODE / WALK_MODE in AnimateMoveOpcode
0x0060E480). The arrival plays MSG_MOVE_STOP's pick, and the swim end is
not sent along a spline.

Left out: flying splines (flag 0x200), facing a point or a target at the
end, and the precast clear (UnitClearPrecastSpell 0x0060D040), which an
actor's casts have no part for.
2026-11-11 08:07:46 -06:00
Brues 2567ac1bd8 feat(model): ModelScene casts report when they reach their target
actor:PlaySpellVisual now returns a cast ID (nil when the spell has no
visual, the actor shows no model or the target is in another scene),
and the caster's new OnSpellVisualImpact script runs with (spellID,
target, castID) when the cast reaches its target. A game can apply a
spell's result at the moment its impact shows, without timing the
flight itself.

The moment is the one the scene's cast chain already plays the impact
at, which follows the 1.12.1 client: a missile's arrival (the frame
whose step passes the remaining distance, as CMissile's update at
0x0061E3E8 tests it, then ArriveHit 0x0061DC50), or the cast itself for
a spell with no missile, a speed of 0, or the caster as target (the
immediate impact 0x006E8BF0). A spell interrupted while it prepares
(StopSpellVisual, or a new cast ending the precast) never arrives, so
its script does not run.

Safety: Impact only queues the event in Actor::spellImpacts. The actor's
update runs the scripts after Casts::Update has stepped every cast, from
a moved-out copy of the queue. A handler that casts or stops a spell
then changes spellCasts outside the step that iterates it (and holds a
reference into it), and an impact it causes waits for the next update.
The script slot is the seventh of the eight an animation object has
(a static_assert holds the list to Animation::kMaxScripts).

docs/API.md describes the cast ID and the script, with an example.
2026-11-11 08:07:46 -06:00
Brues d5f93a1e7f feat(model): ModelScene actors play a unit's movement animations
An actor with the mover on now picks and plays its animations as the
1.12.1 client does for a unit from its own mover. The chain lives on
CGUnit_C, which an actor is not, so model/SceneLocomotion.cpp is that
chain on the actor:

- The pick, SelectBaseAnimation (0x005FD8B0, mask -1), for what a scene
  body can claim: the airborne hold (falling far, or a fall with a
  start speed that is not 0 or NaN), ResolveMoveAnimation (0x005FD100:
  swim 41 to 45, back 13, sprint 143 from 11 yd/s at 0x0080C484, run 5
  above twice the walk speed, else walk 4), ResolveShuffleAnimation
  (0x005FD3F0) behind CanShuffle (0x005FCE30), and the idle
  (0x005FD830: 41 swimming, else 0, nothing while JumpEnd's +0xD58 0x4
  lock holds).
- The play, CGUnit_C::PlayAnimation (0x005FE2F0): a move animation
  (IsMoveAnimation 0x005FEE80) at GetCurrentSpeed (0x007C4C90) over the
  sequence's speed (GetSequenceInfo 0x00711A20 +0x08), starting at the
  phase of the move animation it replaces, in the client's 32-bit
  arithmetic (the imul and two divs at 0x005FE606); and nothing when
  the track already plays it (PlayTrack 0x005FDBA0).
- The triggers. Each movement command the mover takes animates its
  message, as UpdatePlayerMovement (0x00615C30) sends it through
  ProcessLocalMoveEvent (0x0060E0A0) to AnimateMoveOpcode (0x0060E480):
  starts end the ranged pose and pick, a jump plays 37, stops and turns
  pick. The swim decision's start and stop, and a position reset
  (MSG_MOVE_TELEPORT_ACK), pick too. A new Body::stepped hook runs where
  the substep loop (0x00634040) calls BroadcastStateChange (0x0061A820),
  with the flags before the substep and its jump test: a landing runs
  UpdateLandingAnimation (0x00602C60: 39 standing, 187 ahead or aside
  after a jump or far fall), a fall start or far fall runs
  PlayFallAnimation (0x00602C40), a swim's end picks. The body pass's
  tail (CGUnit_C::Update after 0x006083E6) sets the 0x800 / 0x1000
  shuffle bits from the turn it made and picks when the shuffle should
  start or end. A finished sequence plays PlayFollowOnAnimation's
  (0x005FC3F0) follow-on: 37 and 38 go to 38, 40 to 40, the rest pick.

The gate's animation sets are the engine's own (IsEmoteAnimation
0x005FCED0, IsSpellCastAnimation 0x005FCBB0, IsThrownAnimation
0x005FCF90, IsBowAnimation, IsRifleAnimation), all `int __fastcall(int)`
with a plain ret, read from the bytes. EndRangedPose (0x0060F530) moves
out of the cast module's private namespace for the move starts.

Left out, as an actor has no unit state: the death, special stand,
loot, cast, ready, ranged, stand state and emote resolvers, stealth,
splines and the walk mode. A spell kit's animation stays on top of the
pick, and an actor whose own animation is a death animation is left to
its script.
2026-11-11 08:07:46 -06:00
Brues 3132eb80d8 feat(model): ModelScene walks a WMO again through its windows
The 1.12.1 interior seed (0x006B3B20) walks in two phases. The first
walk from the camera's groups runs with the inside leg (0x00CDE5AC)
set, so WalkPortals (0x006B41C0) records a window for each portal into
an exterior group it crosses: five floats at 0x00CBE324 + 0x14 * i, the
overlap rectangle mapped to [0, 1] and the portal's reach, counted at
0x00CBE320. The second phase clears the inside leg and, for each
window, builds the window's volume (BuildPortalVolume 0x00682930,
__fastcall(corners, rect), plain ret) and walks from every MOGI group
flagged 0x8 and not 0x10000 whose box that volume shows (0x00682F60),
over the window as (2r - 1). That walk can reach an exterior group that
no portal joins to the camera's groups, and so a group flagged 0x40000
that puts the WMO's MOSB skybox into the skybox state.

The scene now runs both phases from its own camera. The exterior seed
(0x006B3DD0) builds its volume the same way from the rectangle
(0, 0, 1, 1), so the outside walk shares the same code.

BuildPortalVolume lerps the camera corner rays at 0x00C7BCD8, which the
world fills from its own camera (FUN_FRUSTUM_CORNERS at 0x00680E3C);
the call at 0x006B3CA4 passes them in ecx and the record in edx. The
scene writes its own camera's corners there for the walk, from the
same corner builder.

Safety: the corner rays, the window count and the 16 window records
(0x006B37B0 constructs 16) are saved before the walk and restored
after it, with the globals the first walk already kept. The seed's
last loop (RunGroupCallback 0x006B4160 for 0x10000 groups) changes
only the liquid state and calls the callback, which is null here, so
it is not run.
2026-11-11 08:07:46 -06:00
Brues cf6593aa7b feat(model): ModelScene draws the sky through a WMO's portals
From inside a WMO, the 1.12.1 world draws the sky only when its portal
walk (WalkPortals 0x006B41C0) from the camera's groups reaches an
exterior group: the walk sets the weather byte 0x00CA80C4 for a group
flagged 0x148, and DrawWorldScene (0x00681070) then clears to black and
runs DrawSky. With the byte clear it clears to the drawn fog colour. A
group flagged 0x40000 also puts the WMO's MOSB name into the skybox
state 0x00CA8080, which DrawWorldScene loads into skybox slot 0 at the
camera's WMO fog weight (SetSkyboxSlot 0x006D4810).

The scene now runs the same walks for its WMO from its own camera:

- Inside, the interior seed's (0x006B3B20) walk: a new pass
  (0x00CA8014), the group fog flag set, the liquid-group state cleared,
  object-to-clip = the world stack's slot (0x0058B280) times the
  device's view-projection (0x0058B0C0), and the camera point in model
  space; each claimed group walks over (-1,-1,1,1) at depth 0, passing
  itself as its interior group unless it is flagged 0x48. The backdrop
  now keeps the claimed group indices, not only a flag.
- Outside, the exterior seed's (0x006B3DD0) walk from each MOGI group
  flagged 0x8 and not 0x10000 whose box the view volume does not cull
  (0x00682F60), which can still name a skybox.

Safety: WalkPortals runs with a null group callback (0x00CDE5B0) and
the inside leg (0x00CDE5AC) clear, so it links nothing into the world's
frame and records no windows into the world's 16-entry window array.
Every global it reads or writes (callback, inside leg, camera point,
object-to-clip, fog flag, liquid seen/parity, weather byte, skybox
state) is saved before the walk and restored after it.

The world's second pass, which walks the WMO again through each
recorded window, is not run: it can only name a skybox that the first
walk did not reach, since the weather byte is already set by then.
2026-11-11 08:07:46 -06:00
Brues 0cb101ef97 feat(model): ModelScene draws the world's sky behind its WMO
A scene with a WMO backdrop now clears its frame and draws its sky as
DrawWorldScene (0x00681070) does before its terrain and map objects:

- No interior group holds the camera and the camera is dry: clear to
  black (mask 3), then DrawSky (0x006D4940). The pass draws the stars,
  the sun disc, the white moon, moon02, the gradient dome, the clouds
  and the skybox slots, in the depth range [0.975, 0.98] of the device
  viewport, around the eye. The scene's view has no translation, as the
  world's has none, so the same pass fits.
- An interior group holds the camera: clear to the drawn fog's colour
  (light state +0x80) and draw no sky. Under liquid outside: the zone
  fog's colour.

The pass draws the world's sky objects, which BuildLightTable
(0x006D2260, from 0x006D4530 each world frame) makes from the world's
camera and light. The scene makes the light-derived parts again, as the
build makes them, then puts the world's back:

- the light state's heading (+0x30) from the scene camera's forward:
  atan2(y, x), or atan2(z, x) when x*x + y*y is not above the double
  0.0001 (0x00811658), plus the float 2pi (0x0080295C) below 0;
- for a backdrop in a map, that map's light at the scene camera
  (BuildMapLight, as the outdoor water already takes it) in the live
  table (0x00CE9C0C), with no screen tint (+0x44, the player's) and the
  override and slots (+0x54..+0x6B) cleared;
- the dome's colours (ColourSkyDome 0x006D0F50, 5 x columns + 2 at
  [0x00CE99A0+0x14]);
- colour band 9 (record +0x20) into the sun disc and white moon
  (0x00CE9858, +0x0C, 0x20 apart);
- the skybox slots (0x006D2C79): each {LightSkybox id, weight} pair at
  record +0x78 with an id, a weight above 0 and an id below the
  LightSkybox count (0x00CE9DB4) interns its row's path (InternSkybox
  0x006D4640) into slot +0x5C, weight +0x64. A null row, which the
  engine would read through, is skipped.

The pass runs only while the sky flag (+0x198) is set and the dome has
its colours, so a scene at the glue screen keeps a plain clear. World
stack 8 is identity around the pass and the render states are pushed.

The camera claim (ClaimCameraMapObject 0x006821F0) now runs for every
WMO, not only for those with their own fog. The world claims the camera
whatever the fog, and its liquid probe reads the claim.

Not done: the cloud texture stays the world's, because the world builds
it a band at a time from its own light. From inside, the world also
draws the sky when its portal walk (WalkPortals 0x006B41C0) reaches an
exterior group; the scene does not. The sun and moon glares (0x006D48C0)
are not drawn.
2026-11-11 08:07:46 -06:00
Brues 545cc70a9d feat(model): ModelScene moving actors turn their body as a unit does
A moving actor's model is now drawn at a body facing that follows the
mover's, through the body pass of CGUnit_C::Update (0x00607ED0), while
the actor's yaw stays the mover's facing.

The actor is the active mover, so its display facing (+0xC98) is the
mover's own (UpdateDisplayFacing 0x00600CD0), with the turning bit 0x1
and the time it last stood still (+0xCB0). The world's camera sets these
at the render (MakeViewAngles 0x0050F7A1), after the units' update, so
the body pass reads the ones the last frame set, and the actor keeps
that order: body pass, then display.

The body facing (+0xC94): a swimmer's is the display's; strafing aims a
quarter turn aside, an eighth when also moving forward or back, the side
by strafe direction and backing up, and closes a quarter of the gap each
frame; moving straight or falling snaps it. Past a quarter turn the body
follows at once, and standing, not turning, it catches up at eight times
the turn rate over the time since the display stood still. Each wrap is
WrapAngleToPi (0x006084F0). The rest turns key bones 4 and 6 about z,
half then what remains, an eighth of a turn each at most, through
SetKeyBoneFlags (0x00711F10), BuildAxisRotation4x4 (0x007BDB00) and
SetKeyBoneOverride (0x00712B00); the bones are marked when the model
loads, as OnModelLoaded (0x0060CE70) marks them with HasKeyBone
(0x00711D20), and touched only on a loaded model. A rider leaves bone 4
to its mount. Turning the mover off clears the overrides.

The shuffle animations the pass would pick standing (11, 12) are the
script's, as the actor's animation is.
2026-11-11 08:07:46 -06:00
Brues 2fda1cdbcd feat(model): ModelScene doodads are solid to moving actors
A scene's WMO doodads now block the mover and SweepCollider as the
world's doodads block a character.

The facet query follows the world gather for a map object
(GatherMapObjectFacets 0x006AAAB0) over the scene's one WMO: the
placement's box (CMapObj::GetBoundingBox 0x006A4420 through the
placement) must touch the query box; then the group faces as before;
then each group, in index order as CreateGroupDefs (0x00695960) links
them, whose MOGI bounds through the placement touch the box gives its
doodads by GatherDoodadFacets' rules (0x006ABC40): each doodad once per
query (the s_mapQueryId stamp, advanced by GatherWorldFacets 0x006AA8B0),
its collision box (+0x14C) tested closed. A group not yet parsed or a
doodad whose model file has not loaded fails the query, as in the
client, and the facets gathered so far stay in the list.

The doodad's facets transcribe CM2Model::GatherCollisionFacets
(0x007137C0) into our own code, so the client's facet list and the
model's vertex caches are not touched: the M2 collision mesh (shared
data +0xF0 to +0x100) through the doodad's matrix (0x007BCA80, the same
arithmetic as our 0x007BCC60 transform), a triangle kept unless all
three vertices lie past one face of the box, its plane from the mesh
normal turned by the matrix rows normalized above 2^-22, d = -n.v0.

A doodad's bounds now follow SetDoodadDefBounds (0x006952A0) and are set
when its model file loads (0x007103D0 with 0, 0, as the pending-def
finish 0x00698C50 sets the def's loaded bit): the sphere and the
collision box (0x00713700) through TransformBox (0x006DC540). A shared
doodad sits in each of its groups' lists, as CreateGroupDoodadDef
(0x00694E90) returns the existing def.
2026-11-11 08:07:45 -06:00
Brues 13ccfb0865 feat(model): ModelScene actors swim with the client's mover
A moving actor now swims in the scene WMO's liquids, through the
client's own swim path, transcribed in its order and precision.

The decision is CGUnit_C::UpdateSwimState (0x006030C0), run after each
tick as OnMoverStepped (0x005FAC70) runs it: the depth is the liquid
surface (World::LiquidAt) less the feet's z. The body starts swimming
deeper than 0.75 of its height unless IsRising (0x007C5DE0) holds, and
stops out of liquid or below 0.75 h - 1/36. Swimming on is EnterSwim
(0x007C6E50 with the latches 0x0061AB20), as the active mover's request
applies it; off is 0x007C6E80, which levels the pitch and starts a fall.

A swimmer resolves through the free-move resolver (0x00634640) and its
slide (0x00634300): no gravity, no steps, the solid list swept first and,
until a solid facet stops it, the liquid list for the rest; it re-anchors
at each end but a failed sweep, as the client does. The swept query
(0x00633840) takes its swim box about the move's midpoint (0x00633B5E)
and, for a swimmer, the liquid list in the same box. The contact gather
(0x00631E70) refills that list too, and a sweep of it uses 0.75 of the
height (0x00632C64), so the swimmer meets the surface as a ceiling with
its head above it. The liquid planes are reversed as 0x00633E90 and
0x006320E0 reverse them.

The liquid facets come from the client's own per-group liquid gather:
World::QueryLiquidFacets walks the groups whose MOGI bounds touch the box,
as CollectBoxLiquid (0x006A3C20) does, and for each loaded one resets the
batches and calls CollectLiquidBox (0x006BB170) with the swimmer's class
0x30000 (water and ocean, not magma or slime), then the facet emitter.
Nothing is patched; the client's lists and cached box are not touched.

The pitch: the rate (0x007C5C90), its integration and +-pi/2 clamp
(0x007C4F80), the pitch arc (0x007C5180) and the turn and pitch arc
(0x007C4FD0) with their clamp climb, and every displacement mode of
0x007C5360. Commands StartPitch (0x007C6DF0, only while swimming) and
StopPitch (0x007C6E30). A swimmer moving forward or back is posed by
2pi less its pitch about its y axis, as the unit pose (0x00608560,
0x0060A110) feeds the euler builder 0x007BF4B0; the actor's rotate about
y (0x007BDD60) has the same sense.

New actor methods: StartPitchingUp, StartPitchingDown, StopPitching and
IsSwimming. SetMovementEnabled's description covers swimming and the
pitch.
2026-11-11 08:07:45 -06:00
Brues 7fa9ad9787 feat(model): ModelScene actors move with the client's mover
actor:SetMovementEnabled(true) gives a scene actor the client's own
mover: it walks and runs over the scene's WMO, slides along walls, steps
up and down ledges, falls and jumps as a character does. The commands
StartMovingForward/Backward, StopMoving, StartStrafingLeft/Right,
StopStrafing, StartTurningLeft/Right, StopTurning and Jump, and
IsFalling, are ClassicAPI extensions.

SceneMover.cpp transcribes CMovement for a scene body, one client
function per function, with the client's 53-bit sums and float stores:
- the tick (0x00616800, 0x00616620; a 250 ms window), the integrator
  (0x00616DE0, 0x00616CB0), the displacement from the anchors
  (0x007C5360, straight line 0x007C4ED0, turn arc 0x007C52C0), the
  facing (0x007C4F30), the heading basis and direction (0x007C5880,
  0x007C5A20), the anchors (0x007C5CD0) and the speed (0x007C4C90);
- the substep loop 0x00634040 and its swept query 0x00633840;
- the walk resolver 0x006367B0 with the face rise 0x00635C00, the wall
  push-out 0x00635D80, the bevel direction 0x00636610, the triangle
  class tests (0x006333D0, 0x006334A0, 0x006336A0, 0x00633720), the step
  probe 0x00636100 and the fall-away lookahead 0x00635F80;
- the fall step 0x00635B00 and the airborne resolver 0x00635600 with its
  step sweep 0x006351A0, step time 0x00635450, wall push 0x00635090 and
  wall normal 0x00634AE0 (three-plane solve 0x00634FC0 over the 3x3
  determinant and inverse 0x007BE0E0, 0x007BE2A0);
- the fall arc (0x007C5D20 to 0x007C6290: distance, time for a drop,
  apex, start, jump at -7.955547, landing, the far-fall latch 0x00633240,
  the deferred direction latches 0x0061AB20) and the movement commands
  (0x007C6AE0 to 0x007C6DD0, position reset 0x007C69A0, facing
  0x007C6F30, fall gate 0x007C61C0).
Two places follow the binary over benilla-re's transcription: the
fall-away lookahead starts a real fall and sweeps (+0x68, +0x6C, -D)
(0x00635FF7, 0x00636014), and stopping runs the fall gate only on a
step edge, flag 0x4000000 (0x007C6BBD).

The body's box is the one the client gives a unit of the actor's
display (CGUnit_C::UpdateCollisionSize 0x0060B270): CreatureModelData
collision width +0x38 and height +0x3C, now in Offsets.h, at the larger
of the actor's scale and the display's CreatureModelScale. The facets
come from the scene's WMO through World::QueryFacets, which fails before
the WMO has loaded, so the body waits. PointOverFacet (0x006335D0) joins
the contact sweep.

Safety: the client's movers, their manager, its lists (0x00C4E52C) and
its cached box are never read or written; transports, splines, the
event queue, packets, hover and pitch are left out. The substep loops
carry a pass cap the client lacks, so a fault in the port cannot hang
the game. Swimming (the free-move resolver 0x00634640) is not ported.
2026-11-11 08:07:45 -06:00
Brues 2cb0e4b5b7 feat(model): ModelScene SweepCollider on the client's contact sweep
scene:SweepCollider(feetX, feetY, feetZ, radius, height, moveX, moveY,
moveZ) sweeps a body to its first contact with the scene's WMO and
returns where its feet stop and the normal of the facet it met. It is a
ClassicAPI extension.

The sweep is the client mover's own, transcribed in SceneContact.cpp
one function at a time: CMovement::SweepVolume 0x00632BA0, with the
volume's planes and vertices (0x00631440, 0x00631BE0), the polygon clip
and its 1/720 band (0x006318C0, 0x00631870, 0x00632980), the swept face
prisms (0x00632460, 0x00632F80, PlaneFromTriangle 0x00637480), the
contact distance (0x006329E0, 0x00632830), the nearest facet per face
(0x00632700), the bevel faces and the band rule (0x00632280), and the
facet gather with its cached box (0x00631E70). The arithmetic keeps the
client's 53-bit sums, float stores and operand order, so a body meets a
face where the client's mover would. The body is the client's volume:
a square box over a downward point at the feet, sides at 61.6 degrees.

The facets come from the same WMO box query as the decals (CollectBox,
TestMapObjectBox 0x006AD330, class 0x100111), read out of the facet
batches up to each batch's triangle count (+0x16, the loop bound of the
facet emitter 0x00671CC0). Each facet is built as that emitter builds
it on its SSE path: the vertices through the batch matrix, the normal
(v1 - v0) x (v2 - v0) scaled by rsqrtss, d = -n.v0. So a face blocks
only from its front, as in the world.

Safety: nothing here calls or patches the client's mover. Its facet
lists (0x00C4E52C, 0x00C4E564) and cached box (0x00C4E5A0) are never
read or written; the sweep keeps its own list and box per call. The
swimmer and hover branches of 0x00632BA0 are not taken, since a scene
body has neither flag.
2026-11-11 08:07:45 -06:00
Brues 9f77a40235 feat(model): ModelScene GetLiquidAtPoint
scene:GetLiquidAtPoint(x, y, z) returns the liquid of the scene's WMO at
a point, its type (0 water, 1 ocean, 2 magma, 3 slime) and the height of
its surface, found the way the world finds a unit's. The group is the one
whose face lies nearest below the point, the probe AttachEntityToGround
(0x006A8A20) runs from a unit; CMapObjGroup::LiquidHeight (0x006B9F10)
then answers at the point in WMO space. It reads the MLIQ tile under the
point and answers only below the surface (water 0.01 above it); a group
whose MOGP liquid word is set answers that type everywhere with FLT_MAX,
returned as a nil surface. The type is masked with 3, as the camera's fog
reads the same answer, and the surface goes back to scene space through
the placement.

With it a scene can run the client's swim decision (0x006030C0): swim
once the surface stands more than 0.75 of the collision height over the
feet, stop below that less 1/36 yard.
2026-11-11 08:07:45 -06:00
Brues 76c3cd3a37 feat(model): ModelScene TraceSegment camera face set
TraceSegment takes an optional camera flag that traces the faces the
world's camera collides with. The camera solver (0x0050E570) traces with
class 0x100171, whose mask builder (0x006A3839) gives face mask 0x82:
detail faces block it and the no-camera-collision faces (MOPY 0x02) do
not, where the line-of-sight set (0x84) is the other way round. The flag
passes mask 2 to CMapObjGroup::IntersectSegment (0x006B92B0) in place of
4; the call ORs in the visited bit.

With it an orbit camera can do the solver's boom sweep: trace from the
target toward the eye and stop 1/9 yard (0x00808A04) short of the hit.
2026-11-11 08:07:44 -06:00
Brues 7f05e88eaf feat(model): ModelScene TraceSegment and a sound listener actor
scene:TraceSegment(start, end) returns the first face of the scene's WMO
on a segment: the hit point, the face's unit normal turned toward the
start, and the fraction. It is the scene's per-group trace (Nearest,
TraceMapObjectsNearest 0x006A8840 over the one WMO) with face mask 4,
which CMapObjGroup::IntersectSegment (0x006B92B0) widens to 0x84: every
face but the detail faces, the set the world's line of sight and the mover
test (class 0x100111 builds the same mask). The normal is crossed from the
hit triangle as CMapObj::IntersectSegment (0x006A37B0) reads it: facet
batch 0's first three indices into its vertices, read right after the
group's call, then turned into scene space by the placement. Only loaded
groups answer. The class word moves to Offsets.h as WORLD_TRACE_CLASS_LOS,
which LineOfSight.cpp used as a local constant.

scene:SetSoundListenerActor(actor) hears the scene's sounds at an actor.
The world's listener (0x00482EA0) stands at the unit the player drives
while SoundListenerAtCharacter is on: its position raised by scale times
box height (+0x90 * +0xBC), facing the unit's facing with z 0, up
(0, 0, 1); otherwise at the camera. The scene mirrors both branches, with
the listener actor in place of the unit and the scene camera in place of
the world's.
2026-11-11 08:07:44 -06:00
Brues 229a5d51ae docs: ModelScene SetWorldModel mapID and outdoor water light
SetWorldModel takes an optional mapID: with it, the WMO's outdoor water
takes that map's light at the camera and the world's time of day, with
the nearby zone lights blended in; without it, the light where the
player is. SetWorldModelByMapID's water takes its map's light.
2026-11-11 08:07:44 -06:00
Brues 12eeac5050 fix(model): ModelScene WMOs no longer draw pink outside their own fog
A WMO's exterior group drawer (0x006B4F10), and the interior one too,
turns the device fog on for every material without flag 0x2 and keeps
the fog range the device holds: the world always commits a fog
(M2LightingSetDeviceLights 0x0071C730) just before. A scene without fog,
outside a WMO's own fog, committed a record with none, which turns the
fog off and leaves the range alone, so the drawers fogged with the
device's starting state (0x00593BF0): 0 to 1.0, in 0xFFFF0080. Every
exterior surface past a yard drew solid pink: Onyxia's Lair from
outside, all of Stormwind.

After the commit, a scene without fog now sets the device fog range from
the far clip to 1e10, the fog end of a world without light (the
immediate at 0x006D2301), under a render-state scope around the WMO's
draw and its liquids' draw: the drawers' fog then covers nothing the
scene draws, as its models show none.
2026-11-11 08:07:44 -06:00
Brues f5f9485375 feat(model): ModelScene outdoor WMO water takes its map's light
A 1.12.1 WMO's exterior water colours its vertices from the world's light
table: the deep river colour (colour band 17, table +0x40) in
DrawGroupLiquid's exterior arm, and the river alphas (LightParams columns
5 and 6, +0x68 / +0x6C) that BuildGroupDepthRamp (0x006B6B60) ramps
between. BuildLightTable (0x006D2260) makes that table each frame at the
camera from the current map's light list (0x006D6170: the map's Light
rows, slot 0 the one at the origin, else Light id 1): slot 0 gathered at
the half-minute of the day (GatherLightRecord 0x006D64D0 on the
LightParams of reference 0, or 1 under water), then each area light the
eye is inside of blended in farthest first by its falloff
(BlendAreaLights 0x006D2D00, OverblendLightRecord 0x006D30E0); in magma
LightParams 7, in slime 6. A scene's WMO showed the player's zone.

The backdrop now knows its map: SetWorldModelByMapID's, or a new
optional mapID argument to SetWorldModel. With one, the scene builds
that map's table at its own camera through the engine's gather and blend
(the storm blend, which follows the player's weather, is left out) and
puts its three water fields into the world's table only while its
liquids draw, then puts the world's back.
2026-11-11 08:07:44 -06:00
Brues aaa033bb4f docs: ModelScene ranged slot, bowstring, eased WMO light
TryOn puts ranged weapons in slot 18; the ranged weapon shows only while
the actor shoots, in the hand the world uses. The slot methods, the
transmog lists, SetSheathed, SetSheathedCategory and PlaySpellVisual say
so. A bow has a string. An actor's light in a WMO changes over a short
time, and missiles and impact effects take the light of their place.
2026-11-11 08:07:44 -06:00
Brues 39a774930b feat(model): ModelScene actors hold a ranged weapon in its own slot
A 1.12.1 unit holds its ranged weapon apart from its two hands: hand 2 of
the held-lane and display virtuals, inventory slot 18, its model at
+0xD24. The model is hung only in the ranged sheathe state.
ApplySheatheChange (0x00611770) entering state 2 moves the melee items to
their sheath points, then RefreshRangedWeaponModel (0x00611E10, arg 0)
empties attachments 1, 2 and 0 and hangs the weapon in hand through
AttachItemModel with body slot 0x11: attachment 1 for INVTYPE_RANGEDRIGHT
and THROWN, else 2. Leaving state 2 for 0 detaches attachment 35 and the
weapon's hand, then opens that hand (OpenHand 0x004796B0); for 1,
RefreshRangedWeaponModel(1) hangs it at its sheath point, which a bow or
gun does not have. Outside state 2 the weapon shows nowhere.

Actors now do the same, as a ClassicAPI extension (retail has no ranged
slot). TryOn puts every bow, gun, crossbow, wand and thrown weapon in the
ranged slot, whatever hand is named, so a hunter can hold two melee
weapons and a bow. A ranged spell draws the weapon (DrawRanged);
SetSheathed puts it away (SheatheRanged). SetModelByUnit reads the unit's
hand 2 (Outfit::ReadRanged), and UndressSlot, DressPlayerSlot,
IsSlotAllowed and the transmog lists know slot 18. Shots, ammo, the bow
pull, stuck missiles and the bowstring use the ranged slot. A bow in it
is strung when hung, and the callback is cleared before its reference
goes, as RefreshRangedWeaponModel does. DressUpModel is unchanged.
2026-11-11 08:07:44 -06:00
Brues 5ee83e5427 feat(model): ModelScene bows draw their string
A 1.12.1 bow's string is not part of its model. RefreshRangedWeaponModel
(0x00611E10) gives the ranged model of a bow (class 2, subclass 2) the
draw callback DrawBowstring (0x00611FF0) through SetDrawCallback
(0x00710DB0), with the unit as its user data, and sets a null callback
before it closes that model. The M2 draw runs the callback as one of the
model's elements. It draws two lines in the lighting record's colour
from the bow's $WTT event to its $WTB event: through the body's
attachment 35 while the bow is drawn (+0xD58 bit 0x4000, set by $BWP),
else through their midpoint.

The callback reads only the unit's model (+0xD8) and that bit, so an
actor keeps a block shaped like those two unit fields and hands it to
the engine's own DrawBowstring. Each update the actor finds the bow in
its ranged lane where the lane hangs it, in the hand or at the sheath
point the weapon trail's lookup gives (GetSheathAttachment 0x0047A070,
then FindChild), holds a reference on it, and strings it. A bow no
longer hung, or a released model, is unstrung first. The drawn bit
follows the actor's bow pull and release.
2026-11-11 08:07:44 -06:00
Brues 3791981873 fix(model): ModelScene missile landing does not end the next precast
A mage's Fireball is still in flight when the next cast's precast
begins. When the missile landed, its finished cast was dropped through
EndPrecast, which also ends the caster's looping precast animation. That
loop now belonged to the newer cast, so the caster's hands fell to its
sides while the precast glow went on, and the cast threw normally at its
end. A cast ends its own precast once, when its cast phase starts, so
dropping a cast now ends the precast only while the cast still prepares.
2026-11-11 08:07:43 -06:00
Brues 8f537226e9 fix(model): ModelScene casts keep their pose through a wound
A wound or an impact animation on a casting actor replaced the precast's
looping animation, and when it ended the actor went back to its script
animation: the hands dropped while the precast glow went on, or a
cowering pose showed through a later cast. A 1.12.1 unit replays its
spell's looping kit animation after a one-shot ends. The actor now keeps
the precast's loop under a one-shot and plays it again when the one-shot
ends. The loop ends with the precast, a death, a new model or a script
animation.

The anim-done callback also took the end of any key bone's track as the
end of the actor's animation, so a finger track ending mid-cast restarted
a loop or cut a cast short. It now makes the character pane's test
(0x005062A0, the same as the unit's 0x005FCA28): only the body track
(key bone -1 or the root, 26) ends the animation. Another bone's track
is stopped when it ends, except HandsClosed (15), which keeps a weapon's
grip.
2026-11-11 08:07:43 -06:00
Brues 6532eb76eb feat(model): ModelScene WMO light eases and lights spell models
An actor's light in the scene's WMO now changes the way a 1.12.1 unit's
entity light does. AnimateMapEntityLight (0x0069E770) moves each byte of
the ambient toward its target by max(1, trunc(2.0 x dt x 255)) per tick.
It moves the intensity, which scales the directional light in
ApplyMapLighting (0x006A7300), by 3.3333 x dt. The bake
(BakeMapEntityGroupLight 0x0069E4C0) writes the ambient target, not the
ambient. The target intensity is 1.0 indoors and 2.5 (0x00810A68) on an
exterior group. A new node starts at 1.0 with the day ambient
(0x00670DB0). Outside, with no channel moving, the ambient follows the
day's. World::Settle now takes the frame's elapsed time and eases each
tick, and places the node again only when the model moved.

A missile in flight gets its own entity light node, as the world gives
one (CreateLightObjectB 0x00671010 from 0x0061E655, placed by
AttachEntityToGround). Impact models left behind get one too. Each model
releases its callback back to the frame's before the light goes.

LightChildren now copies the parent model's own lighting callback pair
(+0x3BC / +0x3C0), so a nocked arrow and a stuck missile are lit like
the actor that holds them, not with the frame's flat light.
2026-11-11 08:07:43 -06:00
Brues 098432bee1 docs: ModelScene ranged pose, stuck arrows, camera liquid fog
PlaySpellVisual: the ranged weapon comes to hand at the spell's start;
the arrow in hand, the draw and the release; instant shots do not draw;
arrows and thrown weapons stay in the target for 5 seconds; no spell
animation plays over a death animation. StopSpellVisual lowers a drawn
bow. TryOn: an actor without a unit holds two one-hand weapons.
SetWorldModel: the camera in the WMO's water takes its underwater fog,
in lava or slime the scene's fog unless the WMO has one for it.
2026-11-11 08:07:43 -06:00
Brues 220fbff57a feat(model): ModelScene actors draw the ranged weapon and dual wield
A ranged or auto-repeat spell's start now sets the caster's sheathe
state 2 at once, as HandleSpellStart (0x006E78F3) does through
SetSheatheState (0x00611CF0, (2, 1, 1)): the ranged lane hangs in its
hand and the other held item at its sheath point. A unit that is not
the active player keeps state 2 through its later animations
(0x005FDF80 asks for 2 again), so the actor keeps it until SetSheathed,
SetModelByUnit or a new model. Every re-hang (SetSheathed, the sheathe
categories, a try-on) honours it per lane, so a sheathed bow now comes
to hand for the shot and BowPull finds it there.

An actor that shows a creature display or a model file now holds two
one-hand weapons. DressUpModel's TryOnItem asks the active player's
dual-wield skill (0x005EAB70) only when it shows that player and
refuses a second one-hander on any other model; the world draws a
creature's two weapons regardless. An actor that shows a unit keeps the
dressing room's rule.
2026-11-11 08:07:43 -06:00
Brues bddd6d91fa feat(model): ModelScene WMO fog follows the camera into liquid
The camera fog now asks the liquid around the camera as the world does.

CameraEnvironment (0x006809C0) probes at the camera's x and y and the
lowest z of the camera and the four near corners of its frustum (a
corner at or below the lowest, or NaN, replaces it); with the claim it
asks LiquidHeight (0x006B9F10) of the claim's first group in model
space, the type starting as 0xF. The scene keeps the drop below the eye
from the frustum volume its WMO draw builds.

BuildSceneFog (0x006CEE30) picks the target from it: water or ocean
take the underwater sub-record (+0x24); magma with fog flag 0x100 and
slime with 0x10 take it too and make the zone fog the target; magma or
slime without the flag take the zone fog as the target. GetCameraInteriorFog
now blends both sub-records and keeps the flags of record 0 or of the
nearest candidate. With the camera in liquid the paint draws the WMO's
liquids after the translucent models, as the frame does (0x004836D6).

Not mirrored: the zone fog the camera's liquid makes the target changes
only the WMO, its doodads and the indoor actors, not every model; a
scene has no terrain, so outside the WMO the camera is always dry.
2026-11-11 08:07:43 -06:00
Brues 4a72ca1138 feat(model): ModelScene arrows stick in their target
A missile shot from a bow, crossbow or thrown weapon now stays in the
actor it hits for 5 seconds, as a unit's does.

The launcher (0x0060A509) flags a ranged spell's missile when the ranged
item is a bow, crossbow or thrown weapon (0x0060A710: class 2, subclass
2, 18 or 16). Launch (0x0061CEB0) settles the target attachment: the
visual's, else 15, else 19, else none; Aim and the stick share that
choice. ArriveHit (0x0061DC50) with the flag, an attachment and a target
model runs StickInUnit (0x0061E7C0): the missile, snapped to the
destination with the turn of its flight (Arrive 0x0061E1D0), plays 191
Impact and is attached at the attachment, its base set to its world pose
times the inverse of GetAttachmentMatrix (0x00712DE0, scene space) at
the target's scale (InvertAffineMatrix). The target's stuck list frees
each one once its 5000 ms pass (object slot 14); a paused actor holds
them, and they go with its model. The wound and the impact kit play
first, as before.

The missile flies at one point, the attachment, as in the world: on a
large model such as Onyxia's (chest 34 at (2.77, 0, 4.20)) the arrows
all stick at that point inside the body.
2026-11-11 08:07:43 -06:00
Brues 75653b9485 fix(model): ModelScene spell animations do not play over a death
A unit without health plays only death animations: CGUnit_C::
PlayAnimation (0x005FE2F0) returns unless IsDeathAnimation (0x005FDA90,
__stdcall) holds for the animation. An actor had no such gate, so a
missile already in flight when the target died still played its wound
(and its impact kit's animation) on arrival and replaced the death
animation; StopSpellVisual only drops the casts still preparing.

An actor whose own animation is a death animation now takes no kit
animation over it, and setting a death drops the kit animation that
plays. The missiles still land and show their impact effects.
2026-11-11 08:07:42 -06:00
Brues dfa29376f2 feat(model): ModelScene actors draw the bow and nock the ammo
An actor that casts a ranged spell now poses with its ranged weapon as a
unit does.

At the spell's start (HandleSpellStart 0x006E78A1, for Attributes 0x2 or
the auto-repeat flag), SetAmmoDisplay (0x0060BA30) is mirrored: the ammo
in hand is made with CreateLooseItemModel (kind 0x19 for a thrown
ranged item) and nocked at attachment 35 when the bow is already drawn.

$BWP (OnCombatModelEvent 0x006247D0) with a ranged spell under way runs
PlayBowPull (0x00624CC0): the weapon model, the item AttachItemModel
hung in its lane's hand (attachment 1 or 2), plays 160 BowPull at its
sequence length over the time left in the body's animation, from the
key bone state (hi - ftol(position / speed) - lo; nothing with the latch
set). The ammo then goes on the string unless a gun or crossbow shoots,
and the bow is drawn. $BWR with a missile waiting plays 0 on the weapon
for a bow animation and 161 BowRelease for a rifle one, then takes the
ammo off. EndRangedPose (0x0060F530) runs where the engine runs it, at
the missile release and when an interrupt drops a precast: the bow is no
longer drawn, the ammo leaves the hand and the weapon stands unless it
plays 161.

The models carry $BWP only in 105 LoadBow, so the precast's load must
not repeat. The sequence-end handler's ranged cases (0x005FC3F0) are
mirrored for kit animations: 105 goes on to 109 HoldBow and 106 to 110,
always, and a hold replays while the weapon is held and the spell still
prepares, so a long precast draws once and holds.
2026-11-11 08:07:42 -06:00
Brues 7be139fe32 docs: ModelScene WMO liquid and camera fog
SetWorldModel: the WMO's own fog holds while the camera is above an
indoor part, fades in and out over 4 seconds, blends nearby fog zones,
comes in from the far clip in a scene without fog, and does not change
for a camera in liquid. The WMO's water, lava and slime show and
animate; outdoor water takes the colors of the player's zone.
2026-11-11 08:07:42 -06:00
Brues c61e3b6c52 feat(model): ModelScene WMO group liquid and camera fog
A scene's WMO now draws the liquids of its groups and fogs as the world
fogs a WMO around its camera.

Liquid: the world's group liquid pass (DrawGroupLiquidPass 0x00684CD0)
sets matrix 8 to the placement moved by the camera, commits a lighting
record with fog and calls DrawMapObjectGroupLiquid (0x006B4060). The
scene does the same for each parsed group with flag 0x1000, after its
opaque list and before its translucent one, as the world draws the pass
between its model lists. The engine's drawer gives the animated frames
(0x0068AAC0), the magma scroll and, with BuildGroupDepthRamp (0x006B6B60)
first, the water's depth ramp. A group whose first liquid type is past 8
is not drawn: type 0xF asks GetLiquidFrame for a frame past its table.
The parity flag is 1, the value every group gets from outside the portal
walk (RunGroupCallback 0x006B4160), so all tiles draw.

Camera fog: each scene update traces 1760 down from the scene's camera
as ClaimCameraMapObject (0x006821F0) traces from the world's: the nearest
floor of the groups, then CrossPortals (0x006A3F80) with the fraction
1.05, and no claim for a group whose MOGI flags have 0x8.
GetCameraInteriorFog (0x0069DE20) is mirrored for the claim (it reads the
world camera's globals): record 0 blended with the first group's
candidate fogs, farthest first, by the engine's LightFalloff (0x0069E1C0)
and LerpFogRecord (0x0069EFD0). BuildSceneFog (0x006CEE30) gives the
rest: the target's end is capped at the far clip, the weight moves 0.25
a second (0x008115B0) toward 1 while the camera is over an interior
group and toward 0 otherwise, and the drawn fog is the scene's fog
moved toward the target by it. The WMO, its interior doodads and the
interior actors take the drawn fog (ApplyMapObjectFog 0x006C31E0). This
replaces the fixed record 0 fog. The first update takes the weight as
it stands; the camera's own liquid is not asked.
2026-11-11 08:07:42 -06:00
Brues 5783814dc5 docs: ModelScene floor shadows and footsteps, ranged shots, StopSpellVisual
API.md documents what the last three commits added to ModelScene actors:

- actor:StopSpellVisual(), which interrupts the spells an actor still
  prepares (a cast already made, and its missile, go on).
- actor:PlaySpellVisual for ranged spells: the ranged weapon gives what
  the spell's visual lacks (all of Auto Shot's), the actor's ammo is the
  missile (a plain arrow or bullet without one, a thrown weapon throws
  itself), bullets do not show in flight.
- actor:TryOn of arrows and bullets as the actor's ammo.
- actor:SetShadowShown on a WMO floor: it follows slopes and steps and
  fades with height; no floor, no shadow.
- scene:SetGroundTerrain and actor:SetPlayAnimationSounds: without a set
  ground, the WMO floor under an actor picks its footstep and death thud
  sounds.
- scene:SetWorldModel: only the lights of the part an actor stands on
  light it, and the floor picks its sounds and takes its shadow.

README lists :StopSpellVisual with the other actor methods.
2026-11-11 08:07:42 -06:00
Brues afd5127f50 feat(model): ModelScene ranged shots (Auto Shot) and actor ammo
1.12.1 builds a ranged shot from the shooter, not from the spell. Auto
Shot (75) has no SpellVisual; UnitGetSpellVisual (0x0060D450) fills a
ranged spell's (Attributes 0x2) empty visual fields (the precast, cast and
impact kits, the missile flag and model, path, attachment, flight sound
and precast sound) from the SpellVisual of the ranged item's
ItemDisplayInfo (+0x28). A bow's (visual 5) gives LoadBow, AttackBow and
the arrow whoosh 4222. PlaySpellVisual now builds that 64-byte visual per
cast from the actor's ranged lane (INVTYPE_RANGED, THROWN or RANGEDRIGHT),
so it no longer needs the spell to have a visual of its own.

The arrow is the ammo. SMSG_SPELL_START / GO carry an ammo display for a
ranged spell; the launcher (CGUnit_C::LaunchSpellMissile 0x0060A3D0) makes
the missile from the visual's +0x1C when it is at least 1, else from the
ammo through CreateLooseItemModel (0x00479F40, the record's second model
and texture in the body model's scene), else the error cube, and draws a
missile launched during a rifle animation at alpha 0. The launch needs the
visual's missile flag or an ammo display (0x006E8A50). The ammo follows
the server's rule: a thrown weapon is its own ammo; a bow, crossbow or gun
shoots the actor's ammo, else what the server gives an NPC (5996 Rough
Arrow, 5998 Light Shot).

actor:TryOn(ammoItemID) (an extension) sets that ammo. The dress-up
worker refuses INVTYPE_AMMO, so the actor takes it before the worker's
dispatch; Outfit::ArmOf stays the worker's mirror. The held lane's byte 1
is now named subclass (UNIT_VIRTUAL_ITEM_INFO's layout).

$BWR releases a queued missile as the unit's model event does
(0x00600159): from the body's attachment 35 during a bow animation
(IsBowAnimation 0x005FCFB0), 36 during a rifle animation (IsRifleAnimation
0x005FCFD0), else from the event's point.

Not mirrored yet: BowPull and the nocked ammo on $BWP, the bowstring,
arrows stuck in the target, and drawing the ranged weapon at the shot.
2026-11-11 08:07:42 -06:00
Brues cfa4645cad feat(model): ModelScene shadows and footsteps on the WMO floor
Actors in a WMO scene now stand on the WMO the way 1.12.1 places a unit.
A unit's world entity is made with factory flags 0 (CGObject_C::CreateNode
0x00613E10 -> 0x00670DB0), so it lacks +0x90 bit 0x2000 (flag bit 3,
0x00670E9C) and ReattachEntity 0x006A86D0 sends it to AttachEntityToGround
0x006A8A20, not PlaceEntity 0x006A8C10 (game objects), which the actor
light mirrored before. Place now follows the unit's path: the probe starts
at the model's collision box centre through its matrix (UpdateNode
0x00613F80, GetCollisionBox 0x00713700) and finds the nearest group face
1000 yards down, else up past the sphere (TraceMapObjectsNearest 0x006A8840:
SegmentHitsGroupBox 0x006A47E0, IntersectSegment 0x006B92B0 mask 4). Only
that group links the actor and adds its MOLT lights. SetEntityLightFlags
picks the sun or interior, TraceGroupFace 0x006A2600 finds the face and
GetFaceTerrainType 0x006A26C0 its material's terrain type.

Footsteps and the death thud use that terrain type when the scene has no
SetGroundTerrain (the unit's +0xC60).

SetShadowShown on a WMO scene lays the blob as the world's decal.
DrawBlobShadow 0x006D7920 is mirrored up to DrawDecal: the box from 5/3 of
the height below the feet to 1 above it (0xCEA610 / 0xCEA60C, ThirdOf
0x006D6E50), the texture matrix, white with the model's alpha, depth
function 1 when shadowBias is 0. DrawDecal's world reads are replaced:
BuildDecalTransforms 0x006D6FA0 runs in its absolute mode, because the
other mode reads the world camera (null at the glue screen), and is moved
to the eye. DrawDecalBatches 0x006D7480 is mirrored with the WMO's own
facets (TestMapObjectBox 0x006AD330 -> CollectBoxFacets 0x006A3B10, mask
0x2F0122) and the engine's vertex, index and draw calls
(0x006D7810, 0x006D78A0, 0x0058A830). A scene without a WMO keeps the flat
quad.

The engine's queries take a map object def but read only its placement
(+0x94), inverse (+0xD4) and object (+0x118); the backdrop keeps a block
with those three.

actor:StopSpellVisual() (an extension) interrupts the casts still being
prepared, the drop a new cast already did (0x0060D1C0 stops the old
spell's kits). Without it a precast that ended after a death played its
cast animation over the death.
2026-11-11 08:07:42 -06:00
Brues 8640cc02da docs: ModelScene WMO backdrops, scene sounds and actor:PlaySound
docs/API.md gains the WMO backdrop methods (SetWorldModel,
SetWorldModelByMapID, ClearWorldModel), SetPlayWorldModelSounds /
GetPlayWorldModelSounds, a Scene sounds section on hearing a scene from
its camera, and actor:PlaySound. SetWorldModel covers the props, their
fade, and the light the WMO gives the actors. SetPlayAnimationSounds and
PlaySpellVisual now say where their sounds play. README lists the new
methods.
2026-11-11 08:07:42 -06:00
Brues bc32b9b645 feat(model): ModelScene actors take the WMO's light
An actor standing in the scene's WMO is lit the way the world lights a
unit standing in it, instead of by the scene light alone.

Placement (World::Settle, from the scene's update) mirrors PlaceEntity
(0x006A8C10). LinkEntityToMapObject (0x006A8ED0): the model's bounds
through its base matrix give the world box and its centre (SyncLightNode
0x006717D0); the box goes into WMO space (TransformBox 0x006DC540) and is
tested against the WMO (BoxOverlaps 0x006A4670) and each group
(BoxOverlapsGroup 0x006A4830, GetLoadedGroup, GroupBoxAnyHit 0x006A3EE0 or
GroupSegmentAnyHit 0x006A3F10). In each non-exterior group,
TraceGroupMasked (0x006A3A40, mask 0x20) probes 1000 yards down from the
centre (0x008022CC), else 1000 up; t is shared, so the nearest face of all
groups wins, and its point is taken along the down probe as the engine
takes it. SetEntityLightFlags (0x006A87F0): no face, or a group with flag
0x40, means the sun. Else BakeGroupLight's face path (0x006A8410):
SampleGroupColor (0x006A4DC0) reads the face's MOCV colour and
SplitLightColor splits it (directional floor 0xA8, ambient cap 0x60); a
day face blends both toward the sun by the colour's alpha. Every linked
group adds its MOLT lights through AddMapLight (GatherMapObjectLights
0x006A79C0), so torches light the actors near them. The actor is placed
again when its translation moved a squared 0.001 or more (0x00801360),
when its model changed, or when a new group of the WMO was made.

The light (World::ActorLight, the actor model's new lighting callback)
mirrors ApplyMapLighting (0x006A7300): an interior actor gets the WMO fog,
the ambient bytes and the directional bytes along the fixed indoor
direction (0x00CA816C, blended toward the sun's by a day face); a sun-lit
one the frame's light and fog; both then their map lights. The scene light
is the sun throughout. Without a WMO the callback is the frame's own, as
before.

The doodads' map lights move to a per-group list on the backdrop, shared
with the actors, and the interior light slots to one helper.

Not mirrored: AnimateMapEntityLight's ramp (0x0069E770), so the light
changes at once. A model with an M2 parent is not placed.
2026-11-11 08:07:41 -06:00
Brues 5bb14c036e feat(model): ModelScene WMO doodads and positional scene sounds
A WMO backdrop now shows its doodads the way the world builds them, and
every sound a scene plays is heard from the scene's camera at its distance.

Doodads (model/SceneWorld.cpp). Each parsed group makes its doodads as
CreateGroupDoodads (0x00695AA0) does: the group's MODR indices, kept when
FindDoodadSet (0x006A4FB0) gives set 0 or the instance's set; a doodad two
groups list is one def, sun-lit when any of them is exterior. Each def
mirrors CreateGroupDoodadDef (0x00694E90) and CreateDoodadModel
(0x00695100): the MODD record's translate / quaternion / scale matrix
times the placement, SplitLightColor (0x006A77E0) into the ambient and
directional bytes, the model from the MODN name, SetBaseTransform, the
engine's own done callback (0x006951B0) and PlayAnimation(-1, 0, -1, ...).

Lighting mirrors AddLights (0x006A8050): an exterior doodad takes the
frame's sun (the frame light callback 0x0076D680); an interior one its
ambient bytes and its directional bytes along the fixed direction at
0x00CA816C, then the group's MOLT point lights built as CreateGroupLights
(0x00695C00) and AddMapLight (0x006A7AC0) build them. An interior doodad
in a WMO with more than one MFOG takes MFOG[0], else the scene's fog.

Each frame mirrors DrawDoodadModels (0x00683F80): the world shows every
doodad it draws again (SetAnimated + SetVisible), faded by its sphere
(SetDoodadDefBounds 0x006952A0, CM2Model::GetBoundingSphere 0x00713680)
and the camera's ground distance, with the radius classes, fade ends and
ranges read live from 0x00810188..0x008101A8 and the large end capped by
the far clip.

Doodad sounds are opt-in, scene:SetPlayWorldModelSounds(enabled) /
GetPlayWorldModelSounds (a ClassicAPI extension, off by default): the
$DSO / $DSL / $DSE events of DoodadModelEvent (0x006951E0) through
PlaySoundAt (0x00458870) and the world's loop emitters (0x00461D80 add,
0x00462000 move, 0x00461F80 remove). The emitter pump runs only in the
world, so they play only there. A hidden scene (CSimpleFrame::Hide,
0x0076AD50) drops its loops.

Positional scene sounds (model/SceneSound.cpp). The engine has one
listener, placed by the world camera. A scene point is played where it
lies from the scene camera, its offsets along the camera's forward, up
and side laid along the listener's, read back through
FSOUND_3D_Listener_GetAttributes (0x007FDB50) in the engine's axes. The
engine then applies the row's cutoff, minimum distance and panning as for
a unit that far away. Sound::Play gains the world's 3D players:
PlayKitIDAt (0x0045CF00, RET 0x18, NULL past the cutoff), PlayKitLoopedAt
(0x004589A0) and Move (Sound::SetPosition 0x007A5B10), all owned so
StopSound stops them.

The actors use it: spell kit sounds at the actor, the missile's loop
following the missile (re-mapped each paint) and stopped when the scene
hides, and the animation sounds of SetPlayAnimationSounds at the actor.
actor:PlaySound(soundKitID) is a ClassicAPI extension: a SoundKitID once at
the actor, returning willPlay, soundHandle as PlaySound does.
2026-11-11 08:07:41 -06:00
Brues e5227157b9 feat(model): ModelScene WMO backdrops (SetWorldModel)
A ClassicAPI extension: scene:SetWorldModel(path [, x, y, z [, yaw
[, doodadSet]]]), scene:SetWorldModelByMapID(mapID) and
scene:ClearWorldModel() show a WMO around the scene's actors. Both
setters return whether a WMO is shown; a set that fails clears it.
ModelSceneActor:SetModelByPath refuses .wmo paths, which only the scene
shows.

The map object is the world's own. CMapObj::Get (0x006A3230) loads it
into the engine's cache with one reference ours, released by CountDown
(0x006A33F0). Each paint keeps every group alive for 30 s (group
+0x15C) and queues LoadGroup (0x006A4A30) for a group that is neither
parsed (+0x164) nor being read (+0x160), so a group the world frame has
freed comes back. The pools, shaders and cache come from
CMap::Initialize at client start, so a WMO also shows at the glue
screen.

The engine's reader ends the program when a file does not open
(CMap::SafeOpen) and parses the root's chunks in a fixed order without
reading their tags. A root is refused unless MVER and the 16 chunks from
MOHD to MFOG are in that order inside the file, it has at most 384
groups, and each group file opens.

The paint draws the WMO after the animate pass, the way
DrawMapObjectPass (0x00684840) draws one instance:

- render states pushed; world stack 8 = placement x Translate(-eye);
- the frustum stack pushed (0x00682380), a volume of the scene's view
  stored on it (BuildFrustumCorners 0x005C43B0 over the device view
  moved by -eye and the device projection, planes by 0x00686640), then
  moved into the object's space with the inverse placement
  (0x00682ED0), which the drawers cull each batch against;
- a lighting record at the object's bounding sphere
  (M2LightingInitialize 0x0071BC30, the scene's M2 lights for that point
  by 0x0070A0F0, the scene light as the sun by 0x0071BF90, then the fog),
  committed at the eye with fog by 0x0071C730;
- each parsed group: the portal vertex fade once (0x006C43D0), then the
  interior (0x006B5190) or exterior (0x006B4F10) drawer, called directly,
  because the world sets its drawer pointers only in its own frame.

Fog: the world fogs a WMO's insides with its MFOG records only for a WMO
with more than one record (0x0069DE20). With no candidate the fog is
record 0, its end capped at the far clip and its start that record's
fraction of the end. Other WMOs take the scene's own fog, staged with
the scene light by the frame's light callback (0x0076D680).

The drawers read two world globals. The group fog flag (0x00CA7F00)
would replace the fog with the light state's interior fog, so it is 0
while the scene draws. Window materials write the sun's colours through
the sun pointer (0x00C89EF0), which is NULL at the glue screen, so while
the scene draws it points at a copy of the scene light.

SetWorldModelByMapID reads the map's directory from Map.dbc, then the
WDT's MPHD, MAIN, MWMO and MODF. Only a map whose WDT holds one WMO and
no terrain has an answer. The placement is the world's own for a WDT
object: translate (-pos[2], -pos[0], pos[1]), then rotate z by
rot[1] + pi, y by rot[0] and x by rot[2] (degrees by 0x007FFAAC), with
the record's doodad set. Scene positions then are the map's positions,
so a creature's spawn point places its actor in the room.

Not drawn yet: the doodads, the room's light on the actors, liquids and
the camera's fog inside the WMO.
2026-11-11 08:07:41 -06:00
Brues be6a3c5bef feat(model): ModelSceneActor SetShadowShown blob shadows
A ClassicAPI extension, off by default: actor:SetShadowShown(shown) /
IsShadowShown() put the world's blob shadow under the actor.

The shadow is 1.12's own (DrawBlobShadow 0x006D7920, reached from the
entity draw through 0x006D78F0): drawn only while shadowLOD
(0x0086F5B4) is 1 and the model is ready; the bounds of sequence (0, 0),
skipped when the x or y extent is under the range epsilon, clamped to
+-5 and multiplied by the length of the base transform's row 0
(GetBaseTransform 0x00710600); the four corners turned by the base
rotation and moved by the base translation. The texture is the one the
unit setup loads (Textures\ShadowBlob.blp, handle 0x00CE9DFC, device
object through 0x0044ACF0), with the decal's states: blend 4
(DST_COLOR / ZERO), lighting, fog and depth write off, combiner preset 5,
and the colour white with the model's alpha (GetAlpha 0x00710CA0;
0x004A2C10 packs that byte on top).

The world lays the shadow onto the ground's batches with a decal
(0x006D7330). A scene has no ground, so the paint draws the turned box
itself as a flat triangle strip on the plane of the base translation,
after the animate pass and before the draw passes, so every model draws
over the shadows. Culling is off, as for the engine's lightning quad
(0x007AFCB0). The decal's height fade and its shadowBias depth test
EQUAL are not drawn: both exist to match real ground. A model hung on
another casts none.
2026-11-11 08:07:41 -06:00
Brues 4e8dc39e65 feat(model): ModelScene GetActorAtCursor and OnActorEnter/OnActorLeave
A ClassicAPI extension: scene:GetActorAtCursor() returns the shown actor
under the cursor, and the scene scripts OnActorEnter / OnActorLeave
(self, actor) run when that actor changes while the scene is the mouse
focus.

The pick is the world's own object pick (0x00480A50) on the scene's M2
scene, not a bounding-box test: EnableRayHits (0x007089B0), then
CM2Model::SetHitTestMode(model, 1, 0, actor) (0x00713CB0, ret 0xC) on
each shown actor's model and its children (held items, kit effects), as
0x00480D90 does for a unit; a child that is another actor's model (a
seated rider) is left to that actor. M2SceneRayHit (0x007089C0, ret 0x10,
retry 1 as the world passes) skins each instance animated in the latest
collect on the CPU and returns the value of the nearest triangle. The
ray empties the hit list on every call.

The ray is in view space, where the collect leaves the instance
matrices: the cursor (UI context +0x1118, NDC) in the paint's viewport
(the frame's rect clamped to the screen), unprojected through the
paint's own projection at view depth near and far. The paint's camera
leg is now CameraTransforms, shared with the pick, so view insets and
translation move the pick with the picture. CCamera::SetDeviceTransforms
(0x007AC640) reads only the rect's aspect, so the clamped viewport is
the space the cursor maps into.

The hover runs from the scene's update walk, only while one of the two
scripts is set (Frame::ExtraScripts cells) and the scene is the mouse
focus (UI context +0x7C).
2026-11-11 08:07:41 -06:00
Brues d53e15ff45 feat(model): ModelSceneActor ApplySpellVisualKit
A ClassicAPI extension with the contract of retail's PlayerModel
(CharacterModelBase) method of the same name. The kit is added next to
the kits that the actor already shows. The actor's SetSpellVisualKit
keeps retail's behaviour: it replaces the actor's one kit.

The kit goes through Model::VisualKit::Apply, the same routine
PlayerModel:ApplySpellVisualKit uses. This is the char-select ghost's
kit step (0x004727F0) for any kit: the effect models hang at their
attachments, and the tint and alpha procedures apply. The model owns
the effects, so a looping kit stays until the model changes, and a
oneShot kit stops when its sequence ends. HasAttachment waits for an
unloaded model file, so a call made before the model has loaded is
held in Actor::pendingKits and applied with SetSpellVisualKit's kit
once the model loads.
2026-11-11 08:07:41 -06:00
Brues 5c5bedece6 feat(model): ModelSceneActor animation event sounds
ClassicAPI extensions: actor:SetPlayAnimationSounds(enabled) and
scene:SetGroundTerrain([terrainTypeID]), each with its getter.

Each actor model now gets the engine's event callback
(CM2Model::SetEventCallback, 0x007133A0), as a unit's model does
(0x00607800). model/SceneEvents.cpp sorts the events by their
four-character tag, as the unit dispatcher CGUnit_C::OnModelEvent
(0x005FFBD0) does. It receives the $CSL, $CSR and $CST missile
releases, which moved there from SceneSpell.cpp. When the actor opts
in, it also plays the sounds that a unit plays. They come from the
CreatureSoundData of the display: its own row, else its model data's,
as GetSoundData (0x0060AFB0) picks it.

- $FD1 to $FD4: the fidgets at +0x38 (0x006232C0).
- $FDX: the stand vocal at +0x20, with the world's 41-in-101 chance
  and a 10 s limit shared by all actors. It never plays for the
  active player's copy (0x006236B0, 0x00623490).
- $WNG and $WGG: the wing flap at +0x2C and the glide at +0x30. The
  dispatcher plays them as vocal kinds 7 and 10, through slot 34
  (push 7 / push 0xA, call [eax+0x88]).
- $FSD: the engine's GetFootstepSound (0x00458450), by the scene's
  ground terrain, with FootstepSounds on.
- $DTH: the dry thud from the engine's death-thud table (0x00C4E0F4),
  by size class (GetUnitSoundSize 0x00625500) and the ground.
- $CSD: the event's own SoundEntries id, with EmoteSounds on.
- $TRD: the precast sound (SpellVisual +0x38) of a PlaySpellVisual
  that is still preparing (0x0062FAA0).

The sounds are off by default, so stock scenes such as the dressing
room stay silent. They play in 2D through the by-ID player. A scene
without a ground terrain has no footsteps and no death thud. $CSS,
$CAH and $AH0 to $AH3 play nothing: they present a pending swing
(0x006247D0), and an actor never swings at anything. The unit's
display ID is kept on the actor so that its sound data can be found.
2026-11-11 08:07:40 -06:00
Brues a258c856d1 feat(model): ModelSceneActor PlaySpellVisual
A ClassicAPI extension. actor:PlaySpellVisual(spellID [, target
[, castTimeMs]]) plays the visual of a spell cast between two actors. It
follows the steps of the world chain between two units. That chain
depends on units, missile objects and world objects, and an actor has
none of them, so model/SceneSpell.cpp repeats each step on actor models:

- The visual comes from GetSpellVisualRecord (0x006EC1E0), which also
  makes the violenceLevel swap. The cast time comes from
  FUN_GET_CAST_TIME, unless the caller gives one. The speed is
  Spell +0x94.
- Precast (0x006EC220): the visual's +0x04 kit loops on the caster for
  the cast time.
- Cast: the +0x08 kit plays once. A visual with a missile (+0x18 set,
  speed above 0, a target other than the caster) makes the +0x1C model,
  or Spells\ErrorCube.mdx, as the launcher 0x0060A3D0 does. Without a
  missile, the impact plays at once (0x006E8BF0).
- Release: at the caster model's $CSL, $CSR or $CST event. The actor
  model gets the engine's event callback (CM2Model::SetEventCallback,
  0x007133A0), as a unit's model does (0x00607800). Without the event,
  the release follows ReleaseMissilesFromHand (0x0060C9B0): it uses the
  first of those events that the model has, else the caster's position.
  The release then happens when the cast kit has no animation, when the
  cast animation ends, or 2000 ms after the queue.
- Flight (0x0061CEB0, 0x0061E2A0): the speed is fitted to the time the
  missile was queued (t = d/speed - queued, capped at 100). The missile
  plays 144 InFlight and turns to its direction of travel. It aims each
  frame at the target's attachment: +0x24 through
  s_spellVisualAttachments (0x00860A18), else 15, else 19, else 0.75 of
  the target's height. The +0x28 sound loops through the 2D loop player
  0x00458900 until arrival, then fades over 0.15 s.
- Arrival (0x0061DC50): the target plays its wound animation, then the
  +0x0C impact kit. A +0x30 impact model stays at the point until it
  has no live particles (the impact list, 0x0061E930).

A kit's animation plays in place of the actor's animation. It does not
fire OnAnimFinished, and SetAnimation cancels it. Kit sounds play
through the by-ID player. Sound::Play gets a small header (PlayKitID,
PlayKitLooped, Stop) so that other modules can start a sound and stop
it safely. M2.h gets the event, particle and attachment helpers, and
VisualKit exposes its loop callback.
2026-11-11 08:07:40 -06:00
Brues e9340786c9 feat(model): ModelSceneActor attachment points and AttachToActor
Three ClassicAPI extensions on the actor:

- GetAttachmentPosition(attachmentID) calls the engine's
  CM2Model::GetAttachmentPosition (0x00712D50). It takes the
  attachment's offset through its bone matrix and then through the
  scene matrix (+0xDC, the inverse of the view the bones were built
  under). The result is in the space of the root's base matrix, which
  is the space of SetPosition. The engine does not check the id, and a
  missing id (lookup 0xFFFF) reads past the attachment table. So the
  method checks HasAttachment (0x00712CB0) first. It also needs every
  model in the parent chain to be loaded, because the call waits for
  the file and animates the parents first (EnsureAnimated, 0x00714000).
- AttachToActor(parent, attachmentID) hangs the actor on any
  attachment through CM2Model::Attach (0x00712F70). It works like
  AttachToMount without the seat animation. An id that the parent does
  not have hangs the child at the parent's origin, which is the
  unresolved-index path of EnsureAnimated.
- DetachFromActor() takes the actor off its parent (CM2Model::Detach,
  0x00713020).

EnsureAnimated recurses through the parent chain, so an attach cycle
never ends. M2::CanAttach now refuses a cycle for both AttachToActor and
AttachToMount. HasAttachment moves into M2.h, and VisualKit uses it.
2026-11-11 08:07:40 -06:00
Brues 9101de7f78 feat(model): ModelSceneActor SetSheathedCategory and UseUnitSheatheCategory
SetSheathedCategory(slot, category) picks where a sheathed hand item
hangs: Default (its own place), Back, Side, or Hide. It also adds
Enum.TransmogOutfitSlotOptionSheatheCategory.

1.12.1 finds the sheath attachment from the item's ItemStats sheath
type through GetSheathAttachment (0x0047A070): 1 is the back pair
(26/27), 2 is a large weapon on the back (30/31), 3 is the hip (32/33),
4 is the shield (28), and 0 has no sheath point. AttachItemModel
(0x0047A0C0) and DetachHeldItemSlot (0x0047A310) both use the type they
are given. So a category comes down to a different type for the lane:
Back turns 3 into 1, Side turns 1 or 2 into 3, and Hide gives 0.
Shields and held off-hand items keep their own type.

The lane holds the type it hangs with, so every engine detach (Undress,
UndressSlot, a TryOn that replaces the hand) removes the model from the
place it hangs at. The actor keeps each lane's own type and category,
and re-hangs a lane after each write (SetModelByUnit, TryOn,
DressPlayerSlot). UseUnitSheatheCategory(true) uses every item's own
type, because 1.12.1 units have no category.
2026-11-11 08:07:40 -06:00
Brues 8b6b6ce4ce feat(model): DressUpModel IsSlotAllowed and IsSlotVisible
IsSlotAllowed answers from the gates of 1.12.1's DressUpModel::TryOnItem
worker (0x00504540): a hand takes any held item, and an armor slot needs
the pane's character component (+0x3F8) with its ready byte (+0x40C) set.
A shapeshifted unit has neither, so its armor slots are not allowed. The
neck, rings, trinkets and ranged slot have no arm in the worker. A pane
without a model allows nothing. No 1.12 model hides an item in a slot it
takes, so IsSlotVisible gives the same answer.

The slot rule moves into Outfit::SlotAllowed, which the ModelScene actor
now shares, and TryOnResult reads the armor gate through the same
ArmorReady helper.
2026-11-11 08:07:40 -06:00
Brues ea6b3714ed feat(model): DressUpModel ItemTransmogInfo methods
DressUpModel:GetItemTransmogInfo(slot), GetItemTransmogInfoList() and
SetItemTransmogInfo(info [, slot, ignoreChildItems]), the same contract as
the ModelSceneActor's: appearanceID is the item ID, illusionID the enchant
whose glow a weapon shows, 19 slots in the list.

The pane keeps its own Outfit::Worn. SetModelInstance reads the unit's
visible items into it after the original clones the unit. Then the engine's
TryOnItem worker (0x00504540) fills it from its own arms: the armor slot of
the component slot it set, the hand that TryOnAttach hangs (with the enchant
only when a glow shows), and nothing for the lane that TryOnEvict detaches,
because the worker leaves that lane's bytes in place. A read trims every slot
the pane no longer shows, which also covers the engine's Undress
(ClearModel 0x00504490).

SetItemTransmogInfo is TryOn without ERR_NOT_EQUIPPABLE. The worker shows
it only in its default arm (DisplayError 0x13), after the record is cached,
so a quiet try-on skips the worker for that arm. An uncached item is
remembered per pane and skipped the same way when the worker's cache
callback runs it again.

The ItemTransmogInfo argument reader and the ItemUtil.CreateItemTransmogInfo
pushers move from SceneActor.cpp to Model::TryOn, so both frame types share
them. Outfit gains InvSlotOfComponent for the armor arm's slot.
2026-11-11 08:07:39 -06:00
Brues 2e87d286ff feat(model): ModelSceneActor ItemTransmogInfo methods
GetItemTransmogInfo(slot), GetItemTransmogInfoList() and
SetItemTransmogInfo(info [, slot, ignoreChildItems]) on the actor. On
1.12.1 the appearance is the item ID and the illusion is the
SpellItemEnchantment whose glow a weapon shows.

The actor keeps what each inventory slot 1..19 wears (Outfit::Worn).
SetModelByUnit reads it from the player's visible items
(FUN_UNIT_GET_VISIBLE_ITEM 0x005F0D60, item ID at +0x08), only for slots
the model shows: an armor slot while the unit's character component
holds a record for it (+0x4A8 + slot*4, so a hidden helm or cloak is 0),
a hand while its lane holds an item. A weapon's enchant is the first of
the visible item's 7 enchants (+0x0C) with an ItemVisual, the walk
GetItemDescVisual (0x0062EC70) makes, and only when a glow shows.
Non-player units have no visible items (the table is uninitialized), so
NPCs and creature displays read 0.

Outfit::TryOn now returns the inventory slot it filled; TrimWorn zeroes
every slot the clone component or lanes no longer show after TryOn,
Undress, UndressSlot and DressPlayerSlot, so evictions follow the model.

SetItemTransmogInfo is TryOn on the info's IDs without ERR_NOT_EQUIPPABLE,
which the callers show themselves. An appearanceID of 0 changes nothing.
The list entries are built with ItemUtil.CreateItemTransmogInfo, now
ported with ItemTransmogInfoMixin, and Constants.Transmog is registered.
2026-11-11 08:07:39 -06:00
Brues 03afa419f2 feat(model): ModelSceneActor DressPlayerSlot, IsSlotAllowed, IsSlotVisible
DressPlayerSlot puts one inventory slot of a SetModelByUnit actor back
to what its unit wears. An armor slot copies the unit's own character
component record for the body slot (the dword array at component+0x4A8
that UnequipCharacterSlot 0x00478960 reads and clears) onto the actor's
clone with UnequipCharacterSlot + EquipBodySlotById (0x00478AA0), so a
helm or cloak the unit hides stays off: the unit's component has no
record there. A hand slot re-reads the unit's held-item lane, display
row and enchant glow and hangs only that lane, the other hand untouched.

IsSlotAllowed answers with TryOn's arms: the hands on any model, armor
slots when the actor has a component or a dressed display and is not
shapeshifted. IsSlotVisible gives the same answer; no 1.12 model hides
a slot it allows.

Outfit's slot map is now public (ComponentSlotOf) and HangWeapons hangs
each lane through one HangLane step that DressSlot shares.
2026-11-11 08:07:39 -06:00
Brues 5d5682cbdd docs(model): ModelSceneActor:TryOn
API.md gets an entry for the actor's TryOn: which models take armor and
which take held items, the dual-wield rule, the wait for the model and
for the item data (with the hand and enchant kept), and what removes the
items. SetSheathed now says it applies to any actor and to items that
TryOn adds later. The README's ModelSceneActor row lists :TryOn.
2026-11-11 08:07:39 -06:00
Brues 69665cdadf feat(model): ModelSceneActor:TryOn
A scene actor can now put an item on its model, as DressUpModel:TryOn
does on the dressing-room pane. Creature displays carry no weapons (a
creature's are server-side equipment), so this is how an actor holds a
sword, shield or staff.

The pane's worker, TryOnItem (FUN_DRESSUPMODEL_TRY_ON_ITEM, 0x00504540),
has two arms, and Outfit::TryOn now holds both for the pane and the
actor:

- armor: UnequipCharacterSlot (0x00478960), then EquipBodySlotById
  (FUN_CHARCOMP_SET_ITEM, 0x00478AA0) on the inventory type's component
  slot;
- held items: pick the hand, keep the other lane only when CanHoldBoth
  (FUN_DRESSUPMODEL_CAN_HOLD_BOTH, 0x00504BC0) allows the pair, with
  dual wield from FUN_PLAYER_CAN_DUAL_WIELD (0x005EAB70) when the model
  shows the active player, detach the target lane, fill it and attach
  the item model (0x0047A0C0).

The pane takes armor only while its ready byte is set, which is
DISPLAYID == NATIVEDISPLAYID (0x00504450); the actor reads the same two
fields (OFF_UNIT_FIELD_NATIVEDISPLAYID = 0x1F8 added), so a shapeshifted
unit's copy takes held items only. A creature display takes held items
only, as it has no character component.

Model::TryOn holds the shared argument read (item ID, link, recipe link,
carried item name, hand slot name, enchant), the worn-item lookup and
ERR_NOT_EQUIPPABLE. DressUpModel:TryOn now uses it and Outfit::TryOn;
its own copies are gone.

The pane's worker waits for an uncached item record through the item
cache callback. The actor waits for the record and for its model to
load, and puts the waiting items on in Update, before OnModelLoaded
runs. It returns Enum.ItemTryOnReason: DataPending while the record
loads, NotEquippable for a type with no arm or armor on a model that
cannot take it, else Success.
2026-11-11 08:07:39 -06:00
Brues 81d63bc11c fix(model): register Enum.ModelSceneSetting
OrbitCameraMixin tests the camera row's flags against
Enum.ModelSceneSetting.AlignLightToOrbitDelta on every update
(ShouldAlignLightToOrbitDelta), so a scene with an orbit camera threw
"attempt to index field 'ModelSceneSetting'" each frame. C_ModelInfo's
module now registers the enum with its one value, 1, under the
UIModelInfo system.
2026-11-11 08:07:39 -06:00
Brues ce87b64ab2 docs(model): Project3DPointTo2D ignores the view insets and translation
The ModelScene paint frames the camera over the view rect (the frame's
rect less its insets, moved by its translation), and Project3DPointTo2D
frames it over the whole rect. That looked like a gap, so retail was
probed: a 400x200 scene with its camera at (-10, 0, 0) projected the
origin to (142.22, 71.11) plainly, with SetViewInsets(100, 0, 0, 0) and
with SetViewTranslation(50, 0). The comment above Project now records
this, so the whole-rect framing stays.
2026-11-11 08:07:39 -06:00
Brues af01cda9b6 refactor(storm): one wrapper for SMemAlloc and SMemFree
Storm's SMemAlloc (FUN_STORM_SMEM_ALLOC, 0x006462E0) and SMemFree
(FUN_STORM_SMEM_FREE, 0x00646430), both __stdcall with four arguments,
were called by hand in thirteen files, through AddOns::EngineIO's
typedefs, local typedefs, Event::Custom's own pair and
Frame::Subclass::Allocate.

storm/Memory.h now holds Storm::Alloc, with a zero-fill option for the
flag bit 3 (Offsets::STORM_SMEM_FLAG_ZERO_FILL), and Storm::Free. Every
caller uses them, with the same file and line labels as before; the
EngineIO typedefs, Custom's pair and Subclass::Allocate are gone.
2026-11-11 08:07:38 -06:00
Brues a878407ed6 refactor(frame): one wrapper for the layout sub-object's calls
Every region and frame carries a layout sub-object at +0x24 (4.3.4's
CLayoutFrame; OFF_REGION_ANCHOR, OFF_FRAME_LAYOUT_SUBOBJECT for a frame),
and three engine calls on it were wrapped by hand in sixteen files, each
with its own typedef and often its own AnchorOf / LayoutOf / FrameOf
helper:

- CLayoutFrame::GetRect (FUN_REGION_GET_RECT, 0x00768320), the
  {bottom, left, top, right} rect, false before layout;
- IsResizePending (FUN_REGION_LAYOUT_DIRTY, 0x00768310);
- CLayoutFrame::Resize (FUN_LAYOUT_RESIZE, 0x007680E0), 1 to lay out now,
  0 to mark for the layout pass.

frame/Layout.h now holds them, taking the region or frame itself, with
Of and OwnerOf for the sub-object and back, and GetResolvedRect for the
read the edge getters make (Script GetLeft 0x007A1A19: a pending resize
run now, then the rect). Frame::Subclass::FrameOfLayout is replaced by
OwnerOf.
2026-11-11 08:07:38 -06:00
Brues 8ed9c0f707 refactor(gx): one save and restore of the device transforms
The draws that leave the UI pass's transforms saved and restored the
device projection, view and viewport by hand, each with its own
typedefs: the ModelScene paint (as SimpleModelDraw 0x0076D240 does), the
portrait bake, ClipChildren's ScrollFrame-shaped draw (0x00787220), and
the ModelScene projection, which reads the camera's matrices back.
AlphaGradient read the projection and viewport the same way, and the
scene and the bake each wrapped CameraSetDeviceTransforms
(FUN_CAMERA_SET_DEVICE_TRANSFORMS, 0x007ADA40).

gx/Device.h now wraps FUN_GX_GET/SET_PROJECTION, FUN_GX_GET/SET_VIEW and
FUN_GX_GET/SET_VIEWPORT, and Gx::SavedTransforms saves all three for a
scope and restores them at its end. Model::Camera::SetDeviceTransforms
wraps the camera call. Only AlphaGradient's hooks on the two setters
still name the addresses.

The projection now also puts back the viewport, unchanged. The bake
reads and disables fog just before it saves the transforms; the restore
order is the same as before.
2026-11-11 08:07:38 -06:00
Brues 06c9594e2a refactor(model): one copy of a unit's model without its items
The portrait bake and the ModelScene actor's SetModelByUnit both copied a
unit's model with CreateModelFromModel (FUN_M2SCENE_COPY_MODEL,
0x00707400, flags 0) and stripped the copy's helm, shoulders and held
items with DetachAllItemModels (FUN_DETACH_ALL_ITEM_MODELS, 0x0047A230),
each with its own typedefs.

M2::CopyWithoutItems does the two steps; both callers use it.
2026-11-11 08:07:38 -06:00
Brues 83298e03d7 refactor(model): call the M2 tint, alpha and child lookup through M2.h
CM2Model's SetDiffuseColor (FUN_M2MODEL_SET_DIFFUSE_COLOR, 0x00710CF0)
was wrapped by hand in VisualKit and SceneActor, FindChild
(FUN_M2MODEL_FIND_CHILD, 0x00712F00) in Outfit and SceneActor, and
VisualKit called SetAlpha (FUN_M2MODEL_SET_ALPHA, 0x00710CB0) directly
next to the M2::SetAlpha wrapper.

M2.h gains SetDiffuse and FindChild, and the three files call M2::
for all three.
2026-11-11 08:07:38 -06:00
Brues 849b9b2c92 refactor(model): share the ModelScene argument checks
Scene.cpp and SceneActor.cpp each had NumberArgs and FloatArg, the same
loop that raises the engine's "Usage: <owner>:<usage>" error when an
argument is not a number, with the frame and actor arguments in a
different order.

One NumberArgs, which takes the owner's display name, and one FloatArg
are now declared in model/Scene.h and used by both files.
2026-11-11 08:07:38 -06:00
Brues e27d1e5849 refactor(frame): one wrapper for each draw-layer invalidate
CSimpleFrame::NotifyLayerChanged (FUN_FRAME_NOTIFY_LAYER_CHANGED,
0x0076A990, __thiscall (frame, layer)) was wrapped by hand in Cooldown,
ModelScene and ClipChildren, and CScriptRegion::NotifyLayerChanged
(FUN_REGION_NOTIFY_LAYER_CHANGED, 0x0077FDF0, __thiscall (region)) in the
inline-texture pool and the texture wrap. Each file carried its own
typedef for the same engine call.

frame/LayerChange.h now holds the two: NotifyFrame and NotifyRegion. All
five files call them.
2026-11-11 08:07:38 -06:00
Brues 2ba1818a6e refactor(frame): share the frame-class plumbing in Frame::Subclass
Cooldown and ModelScene each carried their own copy of the parts every
frame class this DLL adds needs: a type id, IsA, IsObjectType,
GetObjectType, GetScriptMethod, the deleting destructor and its layout
thunk, the vtable build, and a factory body that allocates the block with
SMemAlloc (FUN_STORM_SMEM_ALLOC, zero-filled), runs the engine class's
constructor and installs the vtables. The copies had already drifted:
Cooldown compared type names with _stricmp, ModelScene with Storm's
SStrCmpI.

Frame::Subclass::Class now holds a class's name, the engine class it
extends (vtable, layout vtable, constructor), its size and a function that
releases its own fields, plus its type id, method hash and vtable copies.
Templates on that object give the shared overrides, BuildVTables, New and
RegisterFactory (FUN_FRAMEXML_REGISTER_FACTORY). Both classes now answer
IsObjectType through Game::StrCmpI and take CSimpleFrame's script resolver
(+0x0C), which Cooldown's copied vtable already held.
2026-11-11 08:07:37 -06:00
Brues 8ec08a43b1 fix(dressup): look a link's mount up once
DressUpItemLink went on to DressUpMountLink for an item the model cannot
wear, and DressUpLink called DressUpMountLink again when that returned
false. A link that was neither wearable nor a mount ran the mount lookup
twice, and an uncached item started its cache load and wrote the pending
mount twice.

The wearable-item step is now one local function. DressUpItemLink, which
1.12.1's FrameXML (ItemRef, bags, loot, the auction house) calls alone,
tries the item and then the mount. DressUpLink keeps retail's shape: the
item step for item links, then one DressUpMountLink call that gets its
forcedFrame.
2026-11-11 08:07:37 -06:00
Brues 82756a9d25 fix(dressup): keep a pending mount preview until its own creature loads
When a mount's creature is not cached, DressUpMount records the mount and
waits for CREATURE_DATA_LOAD_RESULT. The handler cleared that record on
any creature's result. A failed or successful load of an unrelated
creature (another addon's query, a world creature) consumed it, and the
preview never opened when the mount's creature arrived.

The pending record now holds the creature GetMountInfoExtraByID loads:
the misc value of the mount spell's SPELL_AURA_MOUNTED effect, read with
C_Spell.GetSpellEffectInfo. Only that creature's result clears the
record, and only a successful one opens the preview.
2026-11-11 08:07:37 -06:00
Brues 38771147d0 fix(model): UndressSlot does nothing without a character component
Model::Outfit::UndressSlot cleared and detached a held lane even when the
pane or actor had no character component. It called DetachHeldItemSlot
and zeroed the lane on a creature model, or on a pane whose component at
OFF_DRESSUPMODEL_COMPONENT was not built yet. Before the Outfit split,
DressUpModel:UndressSlot returned early in that case.

The check is back in the shared helper, so DressUpModel and the
ModelScene actor both return before they touch a lane or a component
slot. Undress (ClearModel, 0x00504490) still clears both lanes without a
component, as vanilla's own does.
2026-11-11 08:07:37 -06:00
Brues ce1ac37964 docs: document the ModelScene frame, its actors and C_ModelInfo
API.md gains a ModelScene section: CreateFrame("ModelScene") and its XML
attributes with the scene's starting camera, light and fog; the camera,
view inset and translation, Project3DPointTo2D, light, fog, draw layer,
pause and overlapped-model methods; every actor method (model sources,
unit dressing, placement, bounding boxes, animation and pause, spell
visual kits, mount seating), the actor scripts and <Actor> templates;
and the four C_ModelInfo getters. C_MountJournal.GetMountFromItem joins
the Mount section and GetCursorDelta the Input section.

README.md adds a Model scenes highlight, the ModelScene, ModelSceneActor,
ModelInfo and Mount rows, GetCursorDelta in the in-game and glue tables,
and Enum.ModelBlendOperation.
2026-11-11 08:07:37 -06:00
Brues 1c70be0e3d fix(model): end one-shot actor animations, drop a replaced one's end
M2SceneAdvance (0x007074D8) updates only the models on the scene's animate
list, and that update (CM2Model::Update, 0x00719370) is what queues a
sequence's end callback; the paint's animate pass then empties the list.
Actors put their models on the list after the scene frame's clock had
advanced, so no actor model was ever updated: a looping animation still
played (its pose comes from the scene clock), but a one-shot one such as
AttackUnarmed stopped on its last frame and OnAnimFinished never ran. The
scene's update now links every ready actor model (Link) before the clock
runs, the order the Model frame's own clock (0x0076D7F0) uses.

A new animation also drops an end still pending for the one it replaces.
The engine reports a replaced play to the done callback as reason 1, which
the actor ignores; an end queued in the same advance reached the actor as
if the new animation had ended. Two actors whose animations ended in one
advance then saw OnAnimFinished for the animation the other's script had
just given them.
2026-11-11 08:07:37 -06:00
Brues 56d618b1d4 feat(model): pause a ModelScene actor, show a unit's creature display
SetPaused(paused[, affectsGlobalPause]) holds one actor's animation while
the rest of the scene plays on. It uses the engine's own resume: the key
bone's place in its sequence is read once the model has loaded with
CM2Model::GetKeyBoneAnimState (0x00711FE0) and set back each update with
SetKeyBoneAnimTime (0x007127F0). The model stays on the scene's animate
list, so a paused actor still moves, turns and takes the light, and its
sequence never reaches its end. Unpaused, it plays on from that frame.
GetPaused returns the actor's own flag and the global one, set as the
retail probe read them back: the default sets both, and
affectsGlobalPause false sets only the actor's own.

SetModelByUnitCreatureDisplayID(unit) shows the display the unit shows
now (UNIT_FIELD_DISPLAYID) the way SetModelByCreatureDisplayID shows one.
Retail's pet tab calls it with "pet".
2026-11-11 08:07:37 -06:00
Brues ac09c59d90 feat(model): frame a scene's picture with view insets and translation
SetViewInsets(left, right, top, bottom) and SetViewTranslation(x, y), with
their getters and the XML <ViewInsets left right top bottom> child, frame
the camera's picture inside the ModelScene. Both are in the frame's own
units, with y up.

The paint gives the camera's CameraSetDeviceTransforms (0x007ADA40) the
frame's rect less the insets and moved by the translation, converted to
layout units with PixelToInternal(length * effective scale). The camera
takes its aspect from that rect, as it does from the frame's. The device
projection is then read back (0x0058B090), scaled and shifted in clip space
so that the view rect lands where it lies in the frame, and set again
(0x0058B040). The viewport stays the frame's rect, so drawing still stops
at the frame's edges, and negative insets let the view reach past them.

Project3DPointTo2D keeps projecting over the frame's rect and ignores both
settings, which is what retail does.
2026-11-11 08:07:37 -06:00
Brues b6fa913732 feat(model): project scene points to the frame, store overlapped models
The ModelScene mixin calls two scene methods the frame did not have: OnLoad
calls SetAllowOverlappedModels(true) for StoreModelSceneMixinTemplate, and
Transform3DPointTo2D calls Project3DPointTo2D.

Project3DPointTo2D runs the point through the matrices the paint draws with.
It saves the device projection and view (0x0058B090 / 0x0058B0B0), runs
CameraSetDeviceTransforms (0x007ADA40) over the frame's rect, reads both back
and restores them. The view looks from the origin, so the point goes in
relative to the camera eye. The matrices are row-vector, and the perspective
matrix (0x005C3CC0) puts 1 at m11, so w' is the view depth. A |w'| under
0.001 projects nothing, the rule of the engine's own projector (0x005C4F30).

The result is in the frame's units times its effective scale, from the
bottom-left of the frame, as InternalToPixel gives the rect's extent. A point
behind the camera still projects. Depth is 1 at the near clip and 0 at the
far clip, by distance along the camera's forward axis.

SetAllowOverlappedModels stores its flag (default false). 1.12 draws a model
frame whatever overlaps it, so the flag has nothing to gate.
2026-11-11 08:07:37 -06:00
Brues cf76b699a2 feat(model): seat the player on a mount in the dressing room
A ModelScene mount actor now seats a rider the way 1.12's world mount
(0x00607A00) does: CM2Model::Attach (0x00712F70) puts the rider's model on
the mount's attachment 0, and the rider plays the seat animation (91).
Attach takes the rider off the scene's animate list and the mount holds a
reference on it, so the actor update no longer re-links a seated rider, a
rider that drops its model detaches first, and a mount that drops its
model unseats the rider through the CM2Model destructor (HandleClose2
0x007103A0 destroys at zero). The seated rider keeps its placement, now
relative to the seat, as the world mount sets the attached rider's base
transform.

CalculateMountScale is the mount display's CreatureModelScale
(CreatureDisplayInfo col 4, OFF_CREATUREDISPLAYINFO_SCALE): the world
seats the rider at +0x98 over that scale, and +0x98 is always 1.0.
DetachFromMount only detaches, leaving the seat animation, unlike the
world's Dismount (0x00607CE0). M2::Attach / M2::Detach wrap the two calls
for the actor and the visual kits; Detach tests the parent first, since
the engine call drops a reference unconditionally.

C_MountJournal.GetMountFromItem returns the mount of an item whose use
spell applies SPELL_AURA_MOUNTED; an uncached item starts its load.
Enum.ModelBlendOperation is registered.

The dressing room gains retail's mount mode: DressUpLink,
DressUpMountLink and DressUpMount show a ModelScene over DressUpFrame's
backdrop, above the button row, in place of DressUpModel (whose scale 2
rect reaches over the buttons). DressUpItem returns to the player model.
A mount whose creature is not cached opens when CREATURE_DATA_LOAD_RESULT
arrives. Control-click previews a mount from a spell link (Turtle-shaped
links too), from a cached item that is not wearable, and from the
spellbook, where a mount spell then never casts.

Retail's ModelSceneControlFrame is ported with its strings in every
locale and GameTooltip_AddBodyLine. Its seven atlases are built in: the
sprites cut from retail's CommonIcons / CommonButtons into one 128x128
palettized BLP2 near their drawn size, since a built-in texture has one
mip. Held rotation counts retail's per-frame 0.05 per 1/60 s, the speed
of 1.12's own rotate buttons, instead of per frame at 1.12's uncapped
frame rates.
2026-11-11 08:07:36 -06:00
Brues dc394ab046 feat(model): show a unit on a ModelScene actor
ModelSceneActor:SetModelByUnit copies the unit's model the way 1.12.1's
character panes do in AttachUnitModel (0x005059A0): the unit's own
character component is pumped forced (UpdateCharacterComponent 0x00477860),
its model (+0xD8) is copied into the scene's M2 scene with
CreateModelFromModel (0x00707400, which carries the helm, shoulders and
held items), the effect and spell attachments come off
(DetachAllItemModels 0x0047A230), and the hand grips close (CloseHand
0x00479660). What DressUpModel's SetModelInstance (0x00504350) adds comes
next: a clone of the unit's component (CloneCharacterComponent 0x00476CB0,
on a pool component with the model AddRef'd) and the two held-item lanes
from unit vtable +0x98. The actor pumps the clone on every update, as the
DressUpModel paint (0x00504470) pumps its own, so outfit changes show at
once; OnModelLoaded waits for its first composite.

On top: retail's Dress, Undress(includeWeapons), UndressSlot, SetSheathed /
GetSheathed, SetAutoDress / GetAutoDress, IsGeoReady and GetModelUnitGUID.
Undress mirrors ClearModel (0x00504490); sheathing is the unit's
AttachHandItem step (AttachItemModel 0x0047A0C0 with the sheathed flag).

The lane, weapon and undress steps move out of Model::DressUp into
Model::Outfit, which the DressUpModel pane and the actor share. One change
on the pane: UndressSlot on a held slot no longer needs the component.
PumpDresser takes the engine's force argument, which the old typedef
called an unused pointer.
2026-11-11 08:07:36 -06:00
Brues 12b3784333 feat(model): run retail's ModelScene Lua on the scene frame
Retail's camera registry, camera base, orbit camera, scene mixin and
actor mixin come over verbatim, with their XML templates, so a scene
built with SetFromModelSceneID frames, scales and orbits its actors the
way retail's does. What they need from the client:

Actor templates. 1.12's XML loader registers every virtual top-level
node whatever its tag, so <Actor name="ModelSceneActorTemplate"> is a
template that Xml::Template finds. CreateActor(name, template) loads it
the way an animation group loads its own: the whole inherits chain's
mixins first, then each node from the base up, then OnLoad. The
parentKey, parentArray, <Scripts> and <KeyValues> readers move out of
the animation loader so both use them.

Scene state. Light setters write the frame's M2Light through the
engine's own setters (M2LightSetType 0x0071B620, SetDirection
0x0071B6A0, SetPosition 0x0071B650, SetEnabled 0x0071B780). Fog uses
the Model frame's fields (+0x3A4..+0x3B0), stored as Model's own
SetFogColor, SetFogNear, SetFogFar and ClearFog store them, and starts
at retail's values. The draw layer goes through the engine's layer-name
functions (0x006F18B0, 0x006F1900) and marks both layers changed.
SetPaused keeps the frame update but skips Model's M2SceneAdvance. The
XML loader adds drawLayer, fogNear, fogFar and <FogColor> after the
frame's own loader.

Actor state. A blend operation for PlayAnimation. Spell visual kits use
the routine behind PlayerModel:ApplySpellVisualKit, which now takes a
model instead of a pane; the actor applies a kit once its model loads
and takes the effects off when the kit changes. 1.12 has no animation
kits, so PlayAnimationKit and StopAnimationKit change nothing.

GetCursorDelta, which the orbit camera drags with, samples the top's
cursor (+0x1118) once a frame at the head of the UI render callback and
converts it the way GetCursorPosition (0x0048B820) does, so the delta is
the difference of two GetCursorPosition values. C_PlayerInfo.
GetAlternateFormInfo answers false, false: 1.12 has no alternate forms.
2026-11-11 08:07:36 -06:00
Brues f6b37d5fa1 refactor(coord): push script coordinates through one engine chain
Every script coordinate the engine pushes (GetLeft, GetStringWidth,
GetFont's height, GetCursorPosition, OnCursorChanged) is
DivideByScreenScaleX(GetScaledScreenAspect() * [0x007FFD68] * value):
0x0041AD70, then the 1024.0f read from .rdata, then 0x0041AE40, with
the quotient stored from ST0 as a double.

Game::InternalToPixel did the same arithmetic from the globals in double
precision, while the edit-box caret, the font height getters and texture
tiling each kept a private copy of the engine chain. InternalToPixel now
makes the engine's calls in the engine's order, rounding the float
product once as the engine's fstp dword does, and the three modules use
it. PixelToInternal reads the 1024 from the same .rdata float, so the
UI_COORD_SCALE_UNIT constant is gone.
2026-11-11 08:07:36 -06:00
Brues 754997ea51 feat(model): add the ModelScene frame type and C_ModelInfo
ModelScene is a 3D stage of model actors with its own camera. 1.12.1's
Model frame is already one: every Model frame owns an M2 scene and a
clock, and its paint (SimpleModelDraw 0x0076D240) animates and draws the
whole scene, each unattached model with its own placement matrix. The
stock frame only ever places one model.

So a ModelScene is a CSimpleModel: the Model factory's allocation plus
our fields, CSimpleModel's constructor (0x0076C8E0), then copies of its
two vtables with the Model half hidden from Lua. Lookup goes to our
method hash and then CSimpleFrame's, the type answers ModelScene, Frame
and Region but not Model, and Model's script names and XML loader give
way to the Frame ones. The layer emit queues our paint on ARTWORK. The
paint repeats SimpleModelDraw call for call: save the device transforms,
project through the frame's camera over its rect, clamp the viewport,
clear depth, place each actor where slot 0x98 would place the one
model, animate the scene from the camera eye, draw passes 0 and 1, and
restore.

The camera is a CCamera made and installed the way the PlayerModel
pane makes its fallback camera (CameraCreate 0x007AC930,
SimpleModelInstallCamera 0x0076CE80). Eye and target come from the
position and forward axis. The yaw and roll properties are solved so
that the view's up vector equals the requested one. The default light is
set the way PlayerModel's constructor sets its own.

An actor is a script object holding one model in the frame's scene. It
is lit through the frame's light callback, placed with
CM2Model::SetBaseTransformTRS (0x00710650), and polled for load and
dressing from the frame's update, never from the paint. A display with
an Extra record is dressed through the engine compositor. The bounding
boxes come from GetSequenceInfo (0x00711A20) and GetBoundingBox
(0x00713640).

C_ModelInfo's getters read the rows of scenes 4, 44 and 596, embedded
from the retail 12.1.7 exports. The four active-scene functions do
nothing, as retail's own documentation says, and live in Compat.lua.

The same calls on Turtle and on retail draw the same picture (scene
4's camera on the Brown Horse, centred on Z, scaled by retail's
normalization).

Frame::Subclass now holds the vtable copy and the per-class method
hash, which Cooldown uses too. Model::DisplayInfo::CreateDisplayModel
builds a display's model for both portraits and actors.
2026-11-11 08:07:35 -06:00
Brues a16b99073a fix(spell): stop gating notInterruptible on the cast's InterruptFlags
notInterruptible required InterruptFlags 0x02 (channels:
ChannelInterruptFlags 0x04) before counting the player's interrupt
spells, because the server's interrupt effect tests that bit. But the
server tests its own spell_template, not the client's Spell.dbc, and a
server can patch the one without the other. Stock 1.12.1 ships every
Healing Ward rank (5605, 6274, 11899) with InterruptFlags 0x0D; Turtle's
DBC patches them to 0x0F. On a stock client the gate read 0x0D and
reported Healing Ward as uninterruptible for warriors and rogues, while
Pummel stopped it on the reporter's realm. 171 timed SILENCE casts in
the stock DBC lack the bit.

The verdict is now PreventionType SILENCE plus the caster's immunity
auras, against the player's spellbook interrupt / silence masks. The
isChannel parameter existed only to pick the flag column, so it is gone
from NotInterruptible and from CurrentCastOf, along with the now-unused
SPELL_INTERRUPT_FLAG_DAMAGE, CHANNEL_FLAG_INTERRUPT and
OFF_SPELL_RECORD_CHANNEL_INTERRUPT_FLAGS.

Fixes #137
2026-11-11 00:35:48 -06:00
Brues d7103de564 feat(spell): count set bonuses in GetSpellBonusHealing
Set-bonus spells are applied server-side as auras the client never sees
as buffs or known spells, so the derived healing sum missed them —
Lawbringer (3) is "Increase Healing 31" (9317), Judgement (3) is 57
(18037). Add a pass that counts the equipped members of each set from
the item record's set id (OFF_ITEMSTATS_ITEM_SET), reads ItemSet.dbc's
bonus spells and member thresholds, and runs every met bonus through
the same aura decoder — the rule the item tooltip's set-bonus lines use
at 0x52DFB9.

The Ironclad (aura 199) armor conversion stays on
UNIT_FIELD_RESISTANCES[0]: the server's SpellBaseHealingBonusDone uses
GetArmor(), the full total, even though the talent text says "Armor
from items".

Refs #135
2026-11-09 11:28:51 -06:00
Brues c00eb24a8d fix(event): refuse glue's event table and re-validate cached claim slots
CGGameUI::Initialize (0x0048FE00) calls RegisterScriptFunctions — where
our LoadScriptFunctions hook enables claim writes — and only then
SetScriptEventTable(0xBE1198, 0x225), which builds the in-game table.
Between the two the live table at [0xCEEF68] is still glue's 26-entry
one. A third-party DLL that registers a frame from its own
LoadScriptFunctions hook fires our Frame::RegisterEvent (0x00702140)
hook in that window: Grow enlarged the glue table (26 -> 142), the
reservations claimed slots 26..125 in it, the engine freed that buffer
and built the real table, and every cached slot stayed non-negative —
never retried, invisible to IsEventValid, and a Fire would have
signalled whatever engine event sat at that id.

Two guards, both read off the engine's own build:

- GameTableInstalled: the in-game build always passes 0x225 names and
  the other DLLs' SetEventCount clamps only enlarge, so a table smaller
  than EVENT_NAME_TABLE_STATIC_COUNT is glue's. Claim and Grow refuse it.
- Stale: each reservation keeps the SStrDup'd pointer it wrote; when the
  live entry at its slot no longer holds that pointer (or name), the
  table was rebuilt behind us and every slot is forgotten and re-claimed
  before the original RegisterEvent populates any chain.

Fixes #134
2026-11-09 11:05:32 -06:00
Brues b65c9e0319 fix(spell): never auto-unshift out of Spirit of Redemption
3.3.5's and 4.4.2's SpellShapeshiftForm.dbc give Spirit of Redemption
(form 32) flags 0x4100, which include SHAPESHIFT_FORM_FLAG_NO_AUTO_UNSHIFT
(0x100). So the later clients' CGUnit_C::CanAutoUnshift refuses it, and no
cast, interaction or item use cancels it. 1.12's row has flags 0, so with
autoUnshift on, our CanAutoUnshift allowed it. A priest in Spirit of
Redemption who cast a NOT_SHAPESHIFT spell, right-clicked an NPC or object,
used an item or opened the flight map sent CMSG_CANCEL_AURA for it.

The later-client flags that 1.12's rows lack now sit in one table:
CAN_INTERACT on the druid forms and Ghost Wolf (read only while
autoUnshift is on, as before), and NO_AUTO_UNSHIFT on form 32, which
CanAutoUnshift reads unconditionally, as 4.3.4 does. With it, every
autoUnshift site leaves Spirit of Redemption on and keeps 1.12's own
refusal.

The comment on FormCanInteract also records why a locked object still
unshifts. It opens by a spell (Opening 6478 for chests and barrels), and
the server's CheckCast form test refuses that spell on 1.12's spell data,
which keeps SPELL_ATTR_NOT_SHAPESHIFT (3.3.5 dropped it).
2026-11-08 11:29:09 -06:00
Brues de033896b1 feat(spell): let autoUnshift talk and use objects in the druid forms
1.12's SpellShapeshiftForm.dbc gives SHAPESHIFT_FORM_FLAG_CAN_INTERACT
(0x8) only to Shadowform. 3.3.5 and 4.4.2 also give it to Cat, Travel,
Aquatic, Bear, Dire Bear and Ghost Wolf, and Turtle's id 11 (Swift
Travel Form) has it there too. So in the later clients, these forms pass
the NPC right-click gate (CGPlayer_C::HandleOnRightClick, 0x005F0130)
and the game-object use check (0x005F3130) as they are. The form cancel
that comes before the range check almost never runs.

In 1.12, autoUnshift cancelled every druid form at that gate, even when
the NPC was out of interact range. With autoUnshift on, the redirected
IsShapeShifted calls at both gates and at CanInteractNow (0x00606880)
now read a form in that set as able to interact. They report "not
shifted", so the engine's own 0x8 test that follows does not refuse it,
and the cursor shows the NPC's service icon. The cancel stays for a form
that cannot interact in either client.

Neither vmangos nor tortoise-wow tests the form on an NPC or object
interaction. CanInteractWithNPC and the use handlers have no form check;
the only one is item use. So the client gates are the only refusal.
2026-11-07 20:46:48 -06:00
MrbcatandBrues dc8d4caf12 fix(loot): skip corpses that stay lootable after LootAllCorpses (#133)
* fix(loot): skip corpses that stay lootable after LootAllCorpses

A corpse whose remaining loot this player can never take keeps
UNIT_DYNFLAG_LOOTABLE set: quest items for quests they don't have,
condition-gated loot, and the like. The per-corpse re-check still
passes, so a caller that runs C_Loot.LootAllCorpses from a timer
re-opens the same corpse on every walk and re-sends the refused
CMSG_LOOT_ITEM, which never clears.

Loot mode now remembers every corpse it opened and released. Later
walks skip a remembered corpse while it is still visible and
lootable; the entry is dropped once the corpse stops being lootable or
leaves the object manager. A corpse that merely left render range is
dropped too, so it gets one retry when the player comes back.
ScanNearbyLoot stays read-only and keeps reporting the corpse.

1.12 has no per-slot "cannot loot" flag (GetLootSlotInfo has no
`locked` return), so one refused request per corpse is unavoidable;
this only stops it from repeating on every walk.

* docs(loot): describe the LootAllCorpses corpse skip

Explain why a corpse that stays lootable after the walk is skipped by
later walks, and when the skip entry is dropped.

* fix(loot): skip only corpses that open empty in LootAllCorpses

The previous change remembered every corpse the loot walk opened. The
most common reason a corpse stays UNIT_DYNFLAG_LOOTABLE is full bags,
so those corpses were never looted again by the walk, even after the
player freed bag space. Rolls and master-loot assignments in progress
had the same problem.

The server never sends a player a loot slot that player cannot take:
another member's quest item and condition-gated loot are left out of
SMSG_LOOT_RESPONSE, while the corpse stays lootable for as long as any
loot remains. So the stuck corpse opens to an EMPTY window, with no
coin and no populated VAR_LOOT_SLOTS entry. Loot mode now remembers a
corpse only when the scraped window is empty. A window with anything
in it (full bags, a roll, a master-loot slot) is retried.

An entry is forgotten:
- on the first world tick where its unit is gone or no longer
  lootable. This replaces the prune at walk start, which missed a
  corpse that left view and came back, and a creature that respawned
  with the same GUID and died again, between two walks;
- all at once on QUEST_LOG_UPDATE (event 0x134, observed through the
  shared FUN_FIRE_EVENT_NO_ARGS interceptor and never swallowed),
  because quest items are per-player loot and a quest change can put
  an item into a window that was empty.

The memory moves out of the per-walk ScanCtx into its own vector. The
API reference now describes the skip as a contract only.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-11-06 23:25:23 -06:00
Brues 20069f3815 docs(api): sort the reference's sections alphabetically
Seven sections were out of place in the body: ChatBubbles (after Mount),
Bindings (after Focus), APIDocumentation, CVar, MerchantFrame, NamePlate
and PvP. The contents list shared the last four. Both are now in the same
case-insensitive order, with one blank line between contents groups
(DeathInfo and Sound had none). The ClassicAPI namespace section had no
contents entry; it has one now.

The PVP_TIMER_UPDATE entry moves from the PvP section to Events, between
PLAYER_SWING_RANGE_UPDATE and QUEST_ACCEPTED. It now names the PvP timer
and links the two timer functions, and the PvP section links to it.
2026-11-06 17:35:16 -06:00
Brues f2fa112f0a feat(mount): add C_MountJournal.GetMountFromSpell and GetMountInfoExtraByID
1.12.1 has no mount table, so a mount's ID is the ID of the spell that
summons it: GetMountFromSpell(458) returns 458. A spell is a mount when
one of its effects applies SPELL_AURA_MOUNTED (78); Spell.dbc is read
directly, so mounts the player has not learned resolve too. On Turtle
every mount item carries spell 46499 "Add Mount to Collection" instead of
its mount spell (the server maps item to spell from its own table), so
only the mount spell gives a mount ID.

GetMountInfoExtraByID returns the nine retail values. The display ID is
the one the server mounts the player on: the mount aura's misc value is a
creature entry, and the client's creature cache holds that creature's
display (SMSG_CREATURE_QUERY_RESPONSE carries the template's first
display, and every mount creature has exactly one). An uncached creature
returns nil and starts the cache load through the new
Creature::Info::RequestLoad, which C_CreatureInfo.RequestLoadCreatureByID
now uses too; CREATURE_DATA_LOAD_RESULT fires when the data lands.
Verified in game: 23219 (Swift Mistsaber) returns 14332.

Mount type and model scene default to 230 and 4. Seven mounts take
retail's values instead (the six Qiraji battle tanks use scene 44, the
Riding Turtle type 231), each checked by name against Turtle's
Spell.dbc. The seat animation is 91 (Mount), the animation 1.12.1's
world mount plays on the rider.
2026-11-06 17:29:11 -06:00
Brues fca967c827 refactor(spell): test a spell's auras through one HasAuraEffect
Six places asked the same question of a Spell.dbc record: does one of its
three effects apply aura N (EffectApplyAuraName, +0x16C, int32[3])? Each
had its own loop: the combat log's damage-shield lookup, crowd-control
classification and its snare test, tracking-spell detection,
C_SpellBook.GetPlayerSpellsByAura, and the aura-source periodic-damage
gate (which also hard-coded 3 for the effect count).

Spell::Lookup now has FindAuraEffect (the index of the first effect that
applies the aura, or -1, for callers that read that effect's other
columns) and HasAuraEffect on top of it, next to the existing HasEffect.
All six call it. Code that pairs an aura with the same effect's misc
value, amount or effect code (Cancel, Interruptible, BonusDamage,
StatAccum, EffectInfo) keeps its own per-effect reads.
2026-11-06 17:29:02 -06:00
Brues 24404b5a23 fix(swing): report only a held main-hand reset at attack start
SMSG_ATTACKSTART re-fired PLAYER_SWING(MainHand) whenever the main-hand
swing was still running. That was meant for a cast reset recorded before
the attack (held back because no swing was coming yet), but the server
also sends SMSG_ATTACKSTART on a target switch mid-attack. Unit::Attack
then resets only the off-hand timer; the main-hand swing keeps going.
The event carried just the time left in that swing, so listeners read a
nearly finished swing as a fresh, short one.

Each swing type now carries a held flag, set only by a reset recorded
without notify and cleared when that type's PLAYER_SWING fires. The
attack-start path reports the main hand only while it is held.

Fixes brues-code/SuperCleveRoidMacros#11
2026-11-06 12:58:12 -06:00
Brues a106d86969 fix(unit): consult the name cache only when no unit object is held
UnitClassBase and UnitRaceBase now follow stock Script_UnitClass
(0x00518350) and Script_UnitRace (0x00518200) branch for branch.
"player" reads the selected-character record; any other token goes
through GetGUIDFromName + ClntObjMgrObjectPtr(TYPEMASK_UNIT) and reads
the held object's descriptor byte (+0x79 class, +0x78 race). The
NameCache record (+0x140 / +0x138, null callback) is peeked only when
no object is held.

Before, the NameCache was also peeked when a held object's byte was 0.
That never changed a result (creatures are not in the NameCache) but
resolved the token twice and did not match the engine's branch.

The comments and API.md also claimed creatures return nil, that class
IDs run 1-9, and that UnitClass has a third return. None of that is
true: creatures go through the same ChrClasses/ChrRaces lookup as in
UnitClass/UnitRace, Druid is 11, and stock returns exactly two values.
2026-11-06 11:52:58 -06:00
Brues fac3be3a0b refactor(lua): take integer enum and constants entries as arrays
RegisterIntegerEnum and RegisterConstants took their entries with a
count beside them, written as sizeof(t) / sizeof(t[0]), std::size, or
a literal. They now take Game::ArrayRef<EnumIntegerEntry>, as
RegisterFrameMethods does, so the count comes from the array's type.
The documentation recorders RecordEnum and RecordConstants take the
same view.

All 34 call sites pass only the array. The six literal counts were
checked against their arrays first: each was the full length, so every
Enum.* and Constants.* table gets the same entries as before. Five
files that included <iterator> only for std::size drop it.
2026-11-06 11:34:27 -06:00
Brues b49c107778 refactor(lua): take frame-method tables as arrays, not pointer and count
Every RegisterFrameMethods call passed its method table and doc table
with a count beside each: sizeof(t) / sizeof(t[0]), std::size, or a
literal. The literals had to be kept in step with the tables by hand.

Game::ArrayRef<T> is a view over a file-scope array that takes the
count from the array's type, the way Doc::FieldList already did for a
function's arguments and returns. FieldList is now ArrayRef<Field>,
Doc::MethodList is ArrayRef<Method>, and RegisterFrameMethods takes
(context, methods, docs). The table and count still go to the engine's
frame-method iterator (FUN_REGISTER_FRAME_METHODS) unchanged, and to
the documentation recorder.

All 84 call sites pass only the arrays. The 16 literal counts were
checked against their tables first: each was the full length, so every
registry gets the same methods as before.
2026-11-06 11:27:22 -06:00
Brues 53b7f79440 fix(model): release dress builders and stopped effects with their frame
/reload crashed with an access violation in the model release
(0x007103A3, inside FUN_MODEL_INSTANCE_RELEASE 0x007103A0) when
SetDisplayInfo had dressed a few panes.

1.12.1 reloads in two steps. CGGameUI::Shutdown (0x00490BD0) deletes
the CSimpleTop, which destroys every frame. A Model frame's teardown,
DestroyModelFrame (0x0076CA50, from the CSimpleModel and
CGCharacterModelBase destructors), releases the frame's M2 scene at
+0x314, and the scene frees every model in it, even one that something
else still holds a reference on. Only then does CGGameUI::Initialize
(0x0048FBF0) reset the Lua state, which runs FrameScript_Initialize
and our reload cleanups. DisplayInfo's cleanup destroyed each kept
compositor there (FUN_CHARCOMP_DESTROY 0x00476000), and the compositor
released its reference to a model that was already freed. VisualKit's
cleanup detached and released stopped one-shot effects that live in
the same freed scene.

Model::Teardown hooks DestroyModelFrame and runs its subscribers
before the original, while the frame's scene and models are intact,
which is where the engine frees what its own frames hold. DisplayInfo
drops the frame's dress job there, and VisualKit records each stopped
effect's pane (in the done callback's user data) and discards that
pane's effects there. Their reload cleanups keep only the
pointer-keyed state.

The ReloadAutoRegister doc now says that the cleanups run after the
frames are gone, so they may forget pointers but never release an
engine object tied to a frame.
2026-11-06 11:16:47 -06:00
Brues 7b21453469 refactor(model): share the engine model calls across the model modules
The model modules each declared their own pointers to the same 1.12.1
engine functions: the instance gates FUN_MODEL_INSTANCE_LOADED
(0x007103D0) and FUN_MODEL_INSTANCE_READY, AddRef / Release
(0x00710390 / 0x007103A0), FUN_M2SCENE_CREATE_MODEL,
FUN_M2MODEL_PLAY_ANIMATION, the done callback, the item visuals, the
render callback, the M2 data lookup FUN_M2_LOOKUP_INDEX (0x00710310),
and the pane's model, scene, SetModelInstance virtual (vtable +0x90)
and SetDisplay. model/M2.h now holds one typed wrapper for each, and
every module calls through it.

- Game::Lua::ResolvePlayerModel / ResolveDressUpModel join the
  per-type resolvers, replacing four ResolvePane copies and five
  inline ResolveTypedObject calls. Game::Lua::TYPE_NONE replaces the
  local kTypeNone in Rotation and frame/CreateFrame.
- Offsets.h has the full INVTYPE list, 0..28, named by the engine's
  own key table at 0x0083DDB0. It replaces the four scattered
  INVTYPE_* entries and the local copies in DressUp and ItemDisplay,
  and the try-on result switch reads by name.
- DisplayInfo keeps its dress jobs in a map by frame, like the other
  modules, in place of the fixed 8-slot pool and its eviction.
- DressUp names the held lane's bytes (item class, inventory type,
  sheathe type) and detaches a lane in one place.
- ItemDisplay::OwnVisual is the one test for a display's own
  ItemVisuals row (GetItemDescVisual 0x0062EC70), used by SetItem and
  by TryOn's enchant glow. Both own-record panes share
  Pane::DisplayRecord, and the SetDisplayInfo records are cleared on
  /reload as the SetItem records are.
2026-11-06 11:11:18 -06:00
shikuljaandBrues ebfd9d034d feat(localization): complete and correct the Russian locale (#130)
* feat(localization): complete and correct the Russian locale

The Russian locale was 99 keys behind enUS, so a Russian client kept the
English strings for ClassicAPI's own options: the AddOn Options title and
its game-menu button, every page name, header, control label and tooltip,
the AoE Loot binding, and the generic strings the options pages gained
(FOCUS_TOKEN_NOT_FOUND, LUA_ERROR, RELOADUI, PLAYER_LIST_DELIMITER,
SYMBOLIC_NEGATIVE_NUMBER, SYMBOLIC_POSITIVE_NUMBER and the money texture
formats). All 327 keys are now present, in enUS order, so the two files
diff key by key.

Focus follows the client's modern terms — «Взять в фокус», «Сбросить
фокус», «Выбрать фокус» and «Взять цель в фокус» — matching the existing
/фокус and /сбросфокуса aliases. The rest uses the 1.12 Russian client's
vocabulary where it has one (модификация, цель, панель команд, время
восстановления, табличка с именем) and «динамическое выделение» /
«мягкая цель» for action targeting. The money strings keep their |4
plural forms, which GetCoinText expects.

Fixes:

- LARGE_NUMBER_SEPERATOR was "", which turned digit grouping off. It is
  now a space, as in the tooltip's 1 234 567.
- FIRST/SECOND_NUMBER_CAP and their _NO_SPACE twins carry the leading
  space, because Abbreviate concatenates the number and the suffix
  directly.
- EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP printed a slot number where
  the missing count belongs; it now takes the missing count.
- SETTINGS_BIND_KEY_TO_COMMAND_OR_CANCEL was missing a space before
  «или нажмите».
- SET_FRIENDNOTE_LABEL had a |3-5 declension token, which 1.12 renders
  literally.
- ITEM_SLOTS_IGNORED and ITEMS_NOT_IN_INVENTORY had wrong third plural
  forms, ITEM_MOD_HIT_MELEE_RATING_SHORT had a double space, and the flat
  stat labels (Защита, Уклонение, Парирование, Блок) read wrong after a
  signed value, so they use the same «к …» form as the other ITEM_MOD
  labels.

The TOC gains ## Notes-ruRU next to ## Notes.

* fix(localization): restore retail ruRU focus, number and log strings

Follow-up to the Russian locale completion, which had changed several
strings that the retail ruRU client already translates. Each is now
the retail text, or a correction where the new wording was wrong:

- Focus: the FOCUSTARGET / TARGETFOCUS binding labels were swapped
  (FOCUSTARGET runs FocusUnit("target"), TARGETFOCUS runs
  TargetUnit("focus")). All focus strings use retail ruRU's terms
  again: Запомнить цель, Восстановить цель, Отменить запоминание цели,
  Запомненная цель, <фокус отсутствует>.
- EVENTTRACE_LOG_DISCARD is the message logged after the log is
  discarded; it had become an option-style label. Retail: Журнал
  сброшен. EVENTTRACE_BUTTON_DISCARD_FILTER read "cancel all"; retail:
  Сбросить все.
- FIRST/SECOND_NUMBER_CAP and their _NO_SPACE variants go back to
  retail Т / М. AbbreviateNumbers appends the _NO_SPACE suffix
  directly by design ("1.2K"), so " тыс." widened compact numbers.
- LARGE_NUMBER_SEPERATOR goes back to retail "" (as frFR). A plain
  space also let a number wrap across lines. The separate-large-numbers
  tooltip drops its "1 234 567" example, which no longer applies.
- The loose-target option read "whom the soft target follows". The
  normal target is the one that follows, so it is now "обычная цель",
  and the choices name the soft enemy / soft friend.

* fix(localization): pick language rules by locale name, not 4.3.4's index

1.12.1's locale index (VAR_LOCALE_INDEX, 0xC0E080) is the Wow.ini
Language+Country tag's position in the eight-entry name table at
0x8558A4 (enUS koKR frFR deDE zhCN zhTW esES xxYY), stored once at
0x402562 and 0 when nothing matches. It is never 8. The Russian branches
ported from 4.3.4 switched on 4.3.4's own slot numbers (esMX 7, ruRU 8),
so they could never run: a client whose GetLocale() is "ruRU" fell
through to the default plural rule ("5 предмета") and got no ё/е folding
in bag search.

Localization::Locale::Client() reads the same entry Script_GetLocale
(0x48D8B0) pushes, table[index], and Is(code) compares against it. The
language-rule sites now go through it:

- Text::Grammar's plural index (|4 expansion) keys koKR/frFR/zhCN/zhTW
  and ruRU by name.
- Container::Search picks its accent-folding set by name, as a Folding
  value instead of an index switch.
- Localization::Numbers' ClientIsAsian reuses IsAsianLocale on the name.
- AddOns::TocRewrite drops its private copy of the reader, and the
  slot-8 clamp, which guarded an index that cannot occur.

The DBC column reads keep using the index, which is what it is for.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-11-06 10:25:00 -06:00
Brues d825bcbdda fix(spell): stand, dismount and unshift only when the cast is sent
autoStand / autoDismount / autoUnshift ran their actions as soon as the
caster-state check (CheckCasterStateForSpell, 0x006094F0) passed. In the
1.12.1 cast request (TryCastSpell, 0x006E4B60) that check comes before
the target arm (0x006E5250), so a cast that then found no target still
dismounted the player and showed "You have no target" (Charge with
nothing targeted). A targeting cursor the player cancelled did the same.

The caster check now only keeps what its rungs noted, and the actions run
at the send. SendCast (0x006E54F0) builds the cast packet; its five
callers all pass the global pending cast: the arm's two immediate sends
and the object / location / trade-slot binders, which the targeting
cursor's click also runs. The arm's only caller is the cast request,
after the caster check, so every send follows a check of the same cast.
Each action runs only if its state still holds, since the player can
stand, dismount or leave the form while the cursor is up. The send
redirects must all apply before the rungs are patched.

Item use had the same ordering gap. Its form refusal (0x005D914D) comes
before the toggle test, which turns off a use aura that is already up
instead of casting (0x005D9157..0x005D9237). Re-using such an item while
shapeshifted dropped the form as well. The refusal now only notes the
unshift; it runs at the bind-on-use test (0x005D91D3), which every path
past the toggle reaches, and the toggle's cancel call clears it.

Fixes #132
2026-11-06 09:56:14 -06:00
Brues 6b36bb10e6 fix(spell): report GetSpellPowerCost in displayed power units
The engine's cost helper (FUN_006E31B0) answers in stored units, and
1.12.1 stores rage in tenths, so Shield Slam's 20 rage came back as
cost = 200. Divide by the engine's own display divisor
(GetPowerDisplayDivisor, 0x006E7130: [0x0086F978 + 4*type], 1 for a
negative type; table {1, 10, 1, 1, 1000}) before pushing, the same way
the engine and our UnitPower / GetSpellInfo / combat-log paths do.

Every power amount surfaced to Lua now goes through one helper,
Unit::Power::DisplayDivisor, which calls that engine function. That
retires the duplicate FUN_GET_POWER_DISPLAY_DIVISOR offset, the two
local typedef copies, and UnitPower's direct read of the divisor table
(VAR_UNIT_POWER_DIVISOR_TABLE, now removed).

Fixes #129
2026-11-05 08:51:59 -06:00
Brues 6628dcca19 fix(loot): keep LootAllCorpses walking past refused and stale corpses
The walk ignored the server's refusal. A failed CMSG_LOOT ("too far
away", not ours, ...) arrives as SMSG_LOOT_RESPONSE with loot type 0;
CGPlayer_C::ReadLootResponse (0x005EB900) shows the error and clears the
held loot target (+0x1D28) without opening a window, and the release
response (0x005EC090) clears it the same way. The walk only advanced on
the loot controller hook or a 180-frame timeout, so each refusal stalled
it for seconds. It now advances as soon as the held target is no longer
the corpse it sent for.

A send the engine refused dropped the whole queue. LootUnit (0x005DF2A0)
sends nothing while the mover flags & 0x20FF are set (moving, turning,
falling), and the walk treated each refusal as "skip this corpse", so
moving during a walk skipped every corpse left. A refused send now stays
the current corpse and is retried each tick; after 3 s the walk ends.

The queue's range check went stale. Range was tested once when the walk
began, but each corpse costs a round trip and the player can move in
between. With CanAutoInteract set, CanLootNow (0x005EC110) skips its own
reach test, so the request went out and the server refused it. Each
corpse is now checked for existence, DYNFLAG_LOOTABLE and reach right
before its send, and skipped when it fails.

The reach test is CanLootNow's own: both units' +0x1F0 reach plus the
engine's leeway, at least its minimum, with both constants read live
(VAR_MELEE_REACH_LEEWAY / VAR_MELEE_REACH_MIN) instead of local copies.
It and the lootable test move to Loot::Request, shared by
GetNearbyLootableUnits and the walk. The step timeout uses Time::Clock
instead of a frame count.
2026-11-04 20:04:35 -06:00
Brues 55d94c80d6 feat(spell): apply autoStand/autoDismount/autoUnshift to interaction, loot, items, attacks and taxis
The three settings only covered casts. Each 1.12.1 gate that refuses an
action for the player's stand state, mount or form now passes when the
matching setting allows it, and the engine's own action (the stand setter
FUN_005ED430, or CMSG_CANCEL_AURA for the mount / form aura) runs just
before the action is sent. With the settings at 0 nothing changes.

- NPC right-click (0x005F0130): the IsShapeShifted call at 0x005F0150
  cancels a form without SpellShapeshiftForm flag 0x8 instead of showing
  ERR_CANT_INTERACT_SHAPESHIFTED. CanInteractNow (0x00606880), whose only
  caller is the hover cursor, passes the same form, so the NPC's service
  cursor shows.
- Game objects: the use check (vtable slot 6, 0x005F3130), also the hover
  cursor's query, passes the mount (0x005F31A8) and the form (0x005F31E1)
  for an object with no Lock row. The default use's no-spell branch
  (0x005F35DF, before CMSG_GAMEOBJ_USE) and the mailbox and text use slots
  dismount (except at a mailbox, FUN_0047CFF0) and cancel the form. A
  locked object opens by a spell, which the cast path already handles.
- Loot: the GetStandState calls of the unit (0x0060BFB7) and corpse
  (0x005D6C3B) right-clicks stand the player up instead of showing
  ERR_LOOT_NOTSTANDING. The unit loot step's mount read (0x0060BF98)
  passes, and LootUnit dismounts before CMSG_LOOT (0x005DF3B3).
- Item use (0x005D8D00): the ERR_NO_ITEMS_WHILE_SHAPESHIFTED refusal at
  0x005D914D cancels the form and continues. Turtle's exe already skips
  that test; the stock exe reaches it.
- Attack: the mount reads in the attack cursor (0x00482659), the
  right-click attack (0x0060C1B4), StartAttack (0x005ECC06) and both tests
  of the attack resolver (0x00613039, ERR_ATTACK_MOUNTED; 0x00613142)
  pass. StartAttack, after all its tests (0x005ECC56), dismounts when the
  target is in melee reach (Combat::Swing::InMeleeRange). Otherwise it
  returns without attacking and shows ERR_OUT_OF_RANGE, except while
  click-to-move (FUN_0060F900) walks the player there.
- Taxi: after the taxi map opener's TAXIMAP_OPENED tutorial call
  (0x004DBAA2), a map with more than one node stands the player up,
  dismounts and cancels the form, each when its setting is on.

Every site is patched only when it holds the expected stock bytes, which
both the Octo and stock 1.12.1 exes do.
2026-11-04 16:44:44 -06:00
Brues c0b403ba05 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.
2026-11-04 15:32:23 -06:00
Brues f6b98bc3ec fix(dbc): reject negative IDs in DBC::Record
DBC::Record checked the ID against the store's count as a signed int.
A DBC column uses -1 for "none", and -1 passed that check, so the
helper returned records[0xFFFFFFFF]. On 32-bit that wraps to
records[-1], the heap word in front of the index array. A caller that
passed an unchecked column read a garbage "record": an empty
SpellVisualKit effect column gave the pointer 0xF and crashed.

The engine rejects these IDs before it compares with the count: the
char-select ghost kit's effect step tests the ID with a signed jl at
0x00472797, then a jg against the count. Record now compares
unsigned, so it rejects 0, every negative ID, an ID above the count,
and an unloaded store, whose count is -1.

PvP::Arena had its own copy of the lookup with the same signed
compare. It now calls DBC::Record.
2026-11-04 15:32:22 -06:00
Brues 8f4f574174 feat(model): add PlayerModel:SetItem
PlayerModel:SetItem(itemID [, appearanceModID, itemVisualID]) shows one
item's own model in the pane: a weapon, shield, helm, shoulder, quiver or
ammo, with its skin texture and its glow.

A 1.12.1 pane shows whatever its display record names (+0x3F0).
SetDisplay (0x00505CB0) stores the record and builds it when the pane is
visible, the show path (0x00505D49) builds it again, and the SetUnit
helper (0x00505B50) clears it. SetItem gives the pane a record of its
own and passes it to SetDisplay. The AttachDisplayModel (0x00505A70)
co-hook in Model::DisplayInfo then builds the item instead of a
creature. So hide, show, SetUnit, SetDisplayInfo and SetKeepModelOnHide
handle an item with no new hooks.

The model is built from 1.12.1's own steps:
- the item folder by inventory type, from the engine's own
  Item\ObjectComponents\ path strings (0x00838CD8 on);
- model name 0, else name 1, with the texture of the same index;
- for a head item, the helm builder's (0x004799A0) path, with the
  player's race prefix (ChrRaces +0x18) and sex letter (0x00838AB4);
- AttachItemModelFile's (0x004798C0) steps: create the model in the
  pane's scene, load the skin with the item-model status (0x00B424F0)
  and filter 3, bind it as texture type 2 (0x00710EC0), and attach the
  item visuals (0x00479700);
- AttachDisplayModel's install: SetModelInstance (vtable +0x90), the
  pane's done callback (0x005062A0 through 0x00711BB0), Stand, and the
  release of the creation reference.

The display's own ItemVisuals row wins over itemVisualID, the rule
GetItemDescVisual (0x0062EC70) applies to enchants. The visual's child
models get the frame's light and fog callback, now a shared
LightChildren helper, so they are not drawn black. An uncached item is
queued through Item::Data::WarmCache, and each visible pane that still
shows it is built again when the item loads.
2026-11-04 14:21:58 -06:00
Brues 43a989dd56 feat(model): add PlayerModel:FreezeAnimation
FreezeAnimation(animation, variation, frame) holds the model in one
pose of an animation until the pane loads a new model, as SetAnimation
does. frame is the time into the animation in milliseconds, the unit
of M2 sequence times.

The engine's sequence time is start + (ftol(elapsed * speed) + offset)
mod length. So the pane's arm plays the animation through
PlayAnimation (0x007121A0) at speed 0 and writes frame to bone 0's
slot offset (+0xB8, bone states 0x118 bytes each at [model+0x90]).
Speed 0 also puts the end stamp on the start stamp. Update
(0x00719370) would then queue the done callback on the next frame, and
the pane's done callback (0x005062A0) would arm Stand. The arm
therefore sets the slot's done latch (+0xC0), the flag Update itself
sets once it has reported done. A held animation never ends.

SetAnimation and FreezeAnimation now also mark the pane for one re-arm.
A call with the animation that already plays then still starts or ends
a freeze, where the per-paint compare alone would skip it.
2026-11-04 13:52:02 -06:00
Brues 0caddb8fe6 feat(model): add PlayerModel animation, keep-on-hide and unit methods
Adds SetAnimation, SetDoBlend/GetDoBlend, HasAnimation,
SetKeepModelOnHide/GetKeepModelOnHide, CanSetUnit and GetDisplayInfo
to PlayerModel (and so to DressUpModel and TabardModel). SetUnit now
returns whether the unit exists, and SetCreature takes an optional
display ID.

Animation. A 1.12.1 pane rests on Stand: its per-paint UpdateModel
(0x00505C50) arms Stand (PlayAnimation 0x007121A0 with id 0) whenever
key bone -1 plays anything else and no turn is pending. A deadline of 0
counts as passed, so any other animation lasted one frame. SetAnimation
gives the pane a resting animation and variation of its own. With one
set, UpdateModel is hooked to arm that animation in Stand's place, using
the pane's blend setting. Every other pane runs the stock function. The
pane's done callback (0x005062A0) arms Stand when the animation ends,
so the next paint starts it again and it loops. CSimpleModelFFX's
SetModelInstance (0x0076CD30), which every pane model build and hide
goes through, clears it, so a new model rests on Stand again.
SetRotation turns without the shuffle while a resting animation other
than Stand is set. HasAnimation is PlayAnimation's own test on the raw
id: the lookup at shared data +0x24 must give an index below the
sequence count at +0x1C. It is false until the model has loaded.

Keep on hide. The pane's Hide (0x00505CE0) drops its model through
slot 36, and its Show (0x00505D00) builds a new one from the unit or
display record. With keep on, Hide runs only the base hide (0x0076AD50)
and Show runs only the base show (0x0076AE10) while the pane still
holds its model. The model then comes back with its try-ons and
animation. The SetUnit helper (0x00505B50, behind SetUnit, RefreshUnit
and Dress) and SetDisplay (0x00505CB0) only store the new subject while
the pane is hidden, so they drop a kept model first. The next Show then
builds the new one. Off is the default and matches the stock client.

Units and displays. SetUnit runs the engine's method (0x00505D70,
which Turtle patches) unchanged. It then reports whether the GUID it
stored resolves to a unit (type mask 8), the same test the helper uses
before it builds. CanSetUnit is that resolve without the set.
GetDisplayInfo reads the display record's ID, or the unit's
UNIT_FIELD_DISPLAYID (+0x1F4, field 0x7D). SetCreature with a display
ID shows that display through the SetDisplayInfo record.
2026-11-04 13:11:45 -06:00
Brues 8e603a7a0b fix(player): make IsLoggedIn false until PLAYER_LOGIN fires
IsLoggedIn returned whether the player GUID was set. That GUID falls
back to the char-select GUID before the world loads, and on a /reload
the live GUID is already set while the addons load. So IsLoggedIn was
true while every addon's files ran, and EventUtil.ContinueOnPlayerLogin
ran its callback at once instead of waiting for PLAYER_LOGIN. An addon
that hooked another addon this way hooked it before that addon had set
itself up.

1.12.1 fires PLAYER_LOGIN from one place: the enter cascade
(0x004908C0) calls FUN_FIRE_EVENT_NO_ARGS with 0x10E at 0x00490959,
once per interface initializer run (login and reload). Its arm byte
0x00B4E260 cannot answer the question directly: the initializer sets
it after the addons load (0x0049011D) and the cascade clears it after
the fire (0x0049095E). That call is now redirected to set a flag just
before the event goes out, and the FrameScript_Initialize cleanup,
which runs before every /reload and /logout, clears it.

IsLoggedIn is now false on glue and during addon load, and true from
the PLAYER_LOGIN handlers on, through world transfers.
UnitGUID("player") keeps its char-select fallback.
2026-11-04 13:02:26 -06:00
Brues 4726fd7c85 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.
2026-11-04 10:16:07 -06:00
Brues 3e0c40184b feat(cursor): add expanded cursors for NPCs and the fishing bobber
Adds the expandedCursors cvar (default 0; AddOn Options > ClassicAPI >
Interface > Cursor). At 1 the hover cursor shows what an NPC offers: an
anvil for a repairer, a hearthstone for an innkeeper, a horseshoe for a
stable master, and a quest giver's ! or ? (a blue ? for a repeatable
reward). The player's fishing bobber shows a fish hook in place of the
Interact gear. Each has a gray form beyond reach or when not usable.

NPCs: ClassifyHoveredUnit (0x00482200) reaches its NPC flag chain at
0x00482336 for a unit the player can interact with, the unit in ESI and
the beyond-reach flag in EBX. The chain's `mov ecx, [esi+0x110]` becomes
a call to a thunk. At 0 it replays the instruction and the stock chain
runs. At 1 it picks from the quest giver status first (CGUnit_C +0xCB8,
read by the meaning of the client's status-to-marker table at
0x0080C454), then the NPC flags in service order: repair, innkeeper,
flight master, trainer, spirit healer, banker, registrar/tabard/
battlemaster, auctioneer, stable master, vendor, then gossip or a quest
giver IsQuestGiverToTalk (0x005DF490) accepts. It leaves through the
chain's own epilogue (0x00482356).

Bobber: the fishing node handler's cursor slot (vtable 0x0080BC80, slot
4; GetCursor 0x005F8760 is its one reader) points at a wrapper that runs
BaseHandlerGetCursor (0x005F3070) and maps Interact/UnableInteract to
the hook. The engine still decides whether the bobber is interactable
and usable.

Showing a mode past 41: SetCursorMode indexes a 42-slot image table, so
its existing hook now sends the new modes to Cursor::Extended::Show. It
calls SetCustomCursor (0x00523C40) with the mode's path, so a real file
wins, and otherwise does what that function does after its load: copy
the 32x32 image into slot 41 (0x00C0BD80), store mode 41, send it
(SendCursorImage 0x00523790) and keep the name. The hook also records
the new modes while Hover::Mode computes, so the soft-interact pick, its
icon and SetUnitCursorTexture follow; a quest cursor has no icon image.

Images: the 14 BLPs ship inside the DLL (src/embed/Textures, a new
TEXTURES_ROOT pack). Texture::Builtin decodes each once with the
engine's BLP reader from memory: BlpParse (0x005A83B0) is BlpOpen
without the file read, and BlpReset never frees the file buffer. The
reader moves to Texture::BlpReader, shared with the map overlays.
Texture loads of the same paths, such as the option label's inline
icons, get the same images.

The reader refuses a DXT5 file. BlpDecodeLevel (0x005A7F00) converts a
DXT level through the matrix InitConvertMatrix (0x005A4FC0) fills, and
that matrix has DXT1 and DXT3 converters to BGRA8888 but none for DXT5:
BlpDecodeLevel would return 1 with its SMemAlloc buffer unwritten. No
shipped cursor is DXT5; the check keeps a later embedded DXT5 file, or a
DXT5 map overlay, from being read as heap memory.

Game paths now compare through one rule, Common::SamePath (any case,
either slash), and the embedded manifests through Embed::FindFile. The
embedded addons, the bindings splice and sound muting each had their own
copy of the rule; they and Texture::Builtin now share it.

The option's header, label and tooltip are translated for every client
locale, in Blizzard's own terms for each (NSC, PNJ, 物体, 物件, ...).

Release Win32 build is clean. In game, only the soft-interact quest
icon was checked so far.
2026-11-04 02:36:18 -06:00
MrbcatandBrues b3e8d92b03 feat(localization): translate the addon options panel into zhCN (#126)
* feat(localization): translate the addon options panel into zhCN

A Chinese client kept the English strings for ClassicAPI's own options:
the AddOn Options window title and its game-menu button, every page
name, header, control label and tooltip, and the AoE Loot binding name,
which zhCN only had as a commented-out line. The generic strings enUS
gained with those pages were missing from zhCN too: FOCUS_TOKEN_NOT_FOUND,
LUA_ERROR, RELOADUI, PLAYER_LIST_DELIMITER, SYMBOLIC_NEGATIVE_NUMBER,
SYMBOLIC_POSITIVE_NUMBER and the money texture formats.

The locale files load enUS first and the file that matches GetLocale()
overrides it, so English clients are unchanged and only zhCN is filled
in. Terminology follows the client's own: 插件, 界面, 姓名板, 动作条 and
动作瞄准 for action targeting.

The Release Win32 build embeds the new zhCN.lua in the !!!ClassicAPI
pack (the packed strings were checked). Not tested in-game.

* fix(localization): use Blizzard's zhCN terms in the options panel

The backpack options said 背包, which zhCN uses for bags in general,
so "ignore backpack" read as "ignore bags" and its tooltip contradicted
itself. Use 行囊 for bag 0, as BACKPACK_TOOLTIP and
BINDING_NAME_TOGGLEBACKPACK do. Junk is 垃圾 (BAG_FILTER_JUNK), and
helpful spells are 有益法术, as in the self-cast option tooltips.

Drop the coin-texture and signed-number format strings: they were
byte-for-byte copies of enUS.lua, which always loads first.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-11-03 21:29:40 -06:00
Brues 0a3f8324ea fix(frame): stop a re-anchor during layout from freeing the point being read
Crash #132 at 0x00768323 (FUN_REGION_GET_RECT reading [5+0x3C]): Show lays
a frame out before its children leave their layout deferral, so its
OnSizeChanged ran; the ScrollBox handler read a region's size, and laying
that region out reached an anchor point on a still-deferred child. The
point getter (0x007A2F90 / 0x007A3070) ends the relative's deferral with
FUN_REGION_LAYOUT_REALIZE(0), which ran the child's OnSizeChanged; that
script re-anchored the region, freeing the point, and the getter then read
the point again (0x007A2FC5) from freed heap.

The getters now end the deferral themselves with the point marked busy.
The point's deleting destructor (0x00767D80, the only free path) flags a
busy point; a flagged getter returns the layout's undefined value without
touching the point, and the point walk (0x007671A0 / 0x007671F0) is run
again with the current anchors, up to ten times. Otherwise the engine's
own getter reads the value, with the deferred bit hidden so a script that
hid the relative again cannot start a second resolve.

No hooks: the three getters/destructor are patched in the relative-point
vtable (0x0081C44C, their only reference), and the six edge resolvers'
calls to the walks are redirected. Adds Common::RedirectSlot for the
vtable entries.
2026-11-03 20:47:46 -06:00
6f3a8ffc1d feat(texture): add static creature portraits by display ID (#123)
* feat(texture): add static creature portraits by display ID

* docs(api): document static creature portrait functions

* Update API.md

* refactor(model): bake display-ID portraits on the engine's own path

SetPortraitTextureFromCreatureDisplayID now replays 1.12.1's unit
portrait bake, BakePortrait (0x00524F60), call for call on a display
model of its own, instead of driving a hidden PlayerModel through Lua.

The display model is built the way AttachDisplayModel (0x00505A70)
builds one, without a frame: M2CreateScene (0x00706E10), then
CreateModelFromPath (0x00707350) on the CreatureModelData path, then
ApplyCreatureDisplay (0x004797B0). A character display is dressed
through the engine compositor with the same code that dresses
PlayerModel:SetDisplayInfo, now shared as Model::DisplayInfo::
CreateDresser / PumpDresser / DestroyDresser.

The bake's own gate, IsReady(model, 0, 1) (0x00710450), only passes for
a model that something has drawn: a BLP texture gets its device texture
only when GetTextureDeviceObject (0x0044ACF0) is asked with wait set.
The shared M2 still loads on its own, so the gate here is
IsLoaded(model, 0, 1) (0x007103D0) plus the dress, and the bake's
IsReady(copy, 1, 1) makes the device textures, as it does for the copy
of a unit's model.

The bake itself is the engine's sequence: copy into a fresh scene,
DetachAllItemModels, camera 0, PlayAnimation, save projection, view,
viewport and fog (GxMasterEnable 0x0058A210 / 0x0058A1E0), the 64x64
DDC box in the bake's own float order, CameraSetDeviceTransforms, clear
colour 0, ClearDevice(3), PortraitLighting, both scene passes, read back
through 0x00589CF0, clear, cut the alpha mask in (0x005258A0), restore.
It runs at the head of the UI render callback, the same point as an
OnUpdate script that calls SetPortraitTexture, behind the bake's device
gate (0x0058A260 window active, 0x0058A280 device alive).

Each display ID owns one callback texture (TextureCreateWithCallback,
which makes its device texture at creation). It is bound with SetImage
(0x00770300) at call time and filled in place with GxTexMarkRectDirty
(0x0058ACD0), the bake's own path for re-baking a cached portrait. A
region the caller re-points before the bake finishes keeps what it was
given, so no region is tracked: the request map, the region-destruction
subscription and ClassicAPI_CancelPortraitTextureRequest are gone. The
function returns nothing, as retail's does.

Portraits are kept until logout, the lifetime of the engine's display-ID
portrait cache (cleared in ShutdownGame). A display with no portrait
camera is done once and stays transparent.

Offsets.h: the portrait block is renamed to engine names with calling
conventions and sources; the duplicates of FUN_M2SCENE_ANIMATE,
FUN_M2MODEL_SET_ANIMATED and GetCameraById are removed, as are the
made-up draw-flag and scene-count fields and the raw device-vtable
slots. Model::Camera::CameraById is exposed instead of being copied.

---------

Co-authored-by: z1xel <ramirezthiago@71gmail.com>
Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-11-03 20:00:22 -06:00
Brues 3862408e1a docs(readme): list the new base-library functions
The Lua row gains debuglocals, debugstack's coroutine argument,
difftime, gmatch, math.cosh/sinh/tanh, newproxy, scrub, string.byte and
strbyte ranges, string.format's two-digit %n$, %0 in string.gsub and
table.removemulti. The Lua errors section says the window shows each
error's local variables.
2026-11-03 15:36:32 -06:00
Brues 12a81f7f89 feat(baselib): debugstack takes a coroutine
debugstack([thread,] [start [, count1 [, count2]]]) returns the stack
of a coroutine when one is passed first, with start defaulting to 0
(the coroutine's innermost level), as 4.3.4's Script_debugstack does.

1.12.1's debugstack (0x703760) has no thread argument. A thread fails
its lua_isnumber and lua_isstring checks, so it returns the thread
itself, and msg .. debugstack(co) raises "attempt to concatenate a
thread value" in the coroutine error paths of WeakAuras, Questie's
ThreadLib, MRT and Zygor.

The engine function still builds the text, so its line format and its
12/10-level head and tail windows are unchanged, which keeps addons
that parse debugstack() for their folder working. It reads levels
through six calls on the state it runs on: lua_getstack (0x6FBAA0) at
0x70383F, 0x70386E, 0x703896, 0x7038AD and 0x703998, and
lua_getinfo (0x6FBC70) at 0x7038CD. Those calls are redirected to
read from the state the new global names for the call: the thread, or
the calling state. The engine function has no callers of its own, so
the global is its only way in and sets that state for every call; an
error part way cannot leave a thread behind for the next call.

The global removes the thread argument, inserts 0 as start when none
is given, and calls the engine function directly in its own frame, so
levels without a thread count exactly as before.
2026-11-03 15:33:30 -06:00
Brues 05fa5845b7 feat(baselib): debuglocals, and the error window shows locals
debuglocals([level [, skipFunctionsAndUserdata]]) returns the locals and
then the upvalues of a call level as text, one name=value line each.

It runs only while an error handler runs, as in 4.3.4. 1.12.1's error
function sets no flag for that, but while a handler runs that function
(FUN_FRAMESCRIPT_CALL_ERROR_HANDLER) is a C closure level on the stack;
ErrorCallstack's search for it moves into a header so both modules use
it. level counts as lua_getstack and debugstack do.

Each local comes from lua_getlocal (0x6FBB20, fastcall L/ar + n), then
each upvalue of the level's function (lua_getinfo "f") from
lua_getupvalue (0x6F4660, fastcall L/funcindex + n). Values are
formatted as 4.3.4's FrameScript_PackVariable does, with retail's
spacing (probed: n=7, s="hi", t=<table>{ / k=1 / }): a frame table shows
its name, read from t[0] as FrameScript_GetObject does; a table's fields
show one space in at the top level only; a function shows where it is
defined (lua_getinfo ">nS"). The text stops at 4095 bytes. The second
argument, which retail's error handler passes as true, leaves out
function and userdata values.

Where 4.3.4 would fail inside the handler, this does not: a number key
outside int range is formatted from a copy, not converted in place
(which breaks lua_next), and a key that is not a number or string prints
as its type.

Retail also lists (*temporary) lines, 5.1's names for a level's stack
slots past its locals, including a C level's arguments. 1.12.1's
lua_getlocal is 5.0's and names declared locals only; those slots are
compiler registers that 5.0 and 5.1 allocate differently, so they are
not imitated.

The error window takes the locals as retail's GetErrorData does,
debuglocals(level, true) at the stack's level, and shows them under
Locals ("<none>" when there are none).

LuaCAPI.md had lua_pushupvalues, lua_getupvalue and aux_upvalue one row
off; corrected.
2026-11-03 15:04:55 -06:00
Brues 6ed0edc947 feat(baselib): gmatch, table.removemulti, math hyperbolics, difftime, newproxy, scrub
The base-library names that 1.12.1's Lua 5.0 and its compat.lua do not
have:

- gmatch: the global alias of string.gmatch (5.0's str_gfind,
  0x7FCFA0), set by value like strmatch and strrev.
- table.removemulti(t [, pos [, count]]): removes count elements from
  pos, shifts the rest down with lua_rawgeti/lua_rawseti and returns the
  removed elements. 5.0 keeps the length apart from the keys, so like
  the engine's table.remove (0x7FB770) it records the new length with
  luaL_setn (0x6F4EA0).
- math.cosh, math.sinh, math.tanh: luaL_checknumber (0x6F4C80) and the
  C function.
- difftime(t2 [, t1]): C difftime over the two times, next to the
  engine's date and time globals.
- newproxy([false | true | proxy]): an empty userdata from
  lua_newuserdata. The closure's upvalue is a weak table (its own
  metatable, __mode "kv") that marks the metatables newproxy(true)
  makes, so only those can be shared.
- scrub(...): replaces each argument that is not a boolean, number or
  string with nil, in place with lua_replace (0x6F32B0).

All are registered on both Lua states.

Game::Lua gains RawSetI (lua_rawseti, 0x6F3F60), Replace, CheckLString
(luaL_checklstring, 0x6F4BE0), CheckNumber and OptNumber (luaL_optnumber,
0x6F4CD0); string.byte now uses them instead of local typedefs.

issecure, issecurevariable and forceinsecure are left out. 1.12.1 has
no taint, so they could only return constants, and a constant
"secure" from issecurevariable makes AceHook-3.0 refuse every plain
hook.
2026-11-03 14:23:25 -06:00
Brues 1645e6b43c feat(baselib): string.byte returns a range of byte codes
1.12.1's str_byte (0x7FC210) is Lua 5.0's: it reads only the start
position and pushes one code, so string.byte(s, 1, -1) silently returns
the first byte alone.

string.byte and the strbyte global now take (s [, i [, j]]) and push the
codes of bytes i..j, with j defaulting to i. Negative positions count
from the end and the range is clamped to the string. The arguments are
read with the engine's luaL_checklstring (0x6F4BE0) and luaL_optnumber
(0x6F4CD0), and positions truncate toward zero as the engine's str_byte
and str_sub do, so a call without j returns exactly what it did before.

Each code takes a stack slot, reserved with lua_checkstack (0x6F2F30).
The engine refuses more than 2048 slots, so a range longer than about
2000 bytes raises "stack overflow (string slice too long)".

compat.lua set strbyte from the old string.byte when the state opened,
so strbyte is re-aliased to the new table entry.
2026-11-03 14:00:10 -06:00
Brues efcdc54a50 feat(baselib): string.gsub takes 5.1 string and table replacements
1.12.1's str_gsub (0x7FD0E0) is Lua 5.0's. Its replacement argument
differs from 5.1 in four ways: a table is rejected ("string or function
expected"); in a replacement string, %0, and %1 when the pattern has no
captures, raise "invalid capture index" (add_s checks every %n with
check_capture, 0x7FCE20); a function that returns nil or false deletes
the match instead of keeping it; and one that returns a table deletes
it too instead of raising an error.

The first two only ever raised errors, so the 5.1 behavior cannot break
an existing addon. The function rules can: SuperAPI's
CombatText_AddMessage returns nil to delete the player's own " [0x...]"
GUID from heal text. So function replacements keep 1.12.1's behavior,
and only string and table replacements change. A scan of every gsub
call in the installed addons and the stock FrameXML found no other code
that depends on the old string or function results.

str_gsub's match loop, anchor, count and buffer stay the engine's. Two
parts are replaced:

- The 60-byte replacement-type gate at 0x7FD15E (lua_gettop / isstring /
  lua_type == function, then luaL_argerror) becomes a call to a
  __fastcall check with ECX = L that accepts a number, string, function
  or table and raises "string/function/table expected" otherwise.
- The one call to add_s (0x7FD203 -> 0x7FD2A0, __fastcall ms/b + s/e on
  the stack, ret 8) goes to a function with the same signature. A
  function replacement calls the engine's add_s unchanged. A string or
  number gets 5.1's add_s, and a table gets 5.1's lookup: the first
  capture or whole match as the key, a nil or false value keeps the
  match, and any other non-string value raises "invalid replacement
  value". Both use the engine's push_onecapture (0x7FCF40, plus 5.1's
  whole-match case for index 0 with no captures), luaL_addlstring
  (0x6F51F0) and luaL_addvalue (0x6F5270).

Both patches check the original bytes or call target and are skipped
when they differ.

The table-replacement shim in StringLib is removed. It wrapped the
pattern in an outer capture so a closure could return the whole match,
and deleted the match for a missing key when the pattern had a
back-reference. string.gsub and gsub are the engine's function again.
2026-11-03 14:00:04 -06:00
Brues 6a24271cd1 feat(baselib): two-digit %n$ argument numbers in string.format
1.12.1's str_format (0x7FD3D0) only reads a one-digit argument number:
isdigit(p[0]) && p[1] == '$'. In "%10$s" the "10" is read as a width and
the '$' as the conversion, which raises "invalid option in `format'".
Combat log strings such as ACTION_SPELL_PERIODIC_LEECH_RESULT,
"(%10$s %8$s Gained)", cannot be formatted.

Replace the engine's 18-byte one-digit branch at 0x7FD4AC with a call to
a naked thunk that sets the argument number ([ebp-8]) and the flags scan
start (EBX) for both "n$" and "nn$". EAX/ECX/EDX are dead at the site.
The engine still tests the first digit with its own isdigit, increments
the argument after the site, and does every conversion, so string.format,
format and the C callers of str_format all get the change. The patch
checks the original bytes and is skipped when they differ.
2026-11-03 13:07:32 -06:00
MrBCatandBrues a7ce7555f0 fix(softinteract): anyinteract yields when the target is out of reach (#121)
* fix(softinteract): anyinteract yields when the target is out of reach

GetAny treated Reach::Out — the cursor's Unable form — as a usable
target, so with a target too far away the Interact Key pressed the inert
target instead of the nearest object it could interact with.

Prefer the target only while the cursor says it can be used now
(Reach::Now), and fall back to softinteract otherwise. A target behind
the camera still wins while it is in reach: the cursor verdict does not
look at facing.

* docs(api): anyinteract needs the target to be in reach now

The token section said a target that is too far away still counts, which
the implementation no longer does.

* fix(softinteract): anyinteract keeps an out-of-reach target when nothing is picked

Retail 12.1.5, targeting an NPC that is out of reach: facing away from
everything (softinteract nil), anyinteract is the target; facing a nearer
NPC, it is that NPC. The previous change returned 0 in the first case,
so the Interact Key lost the engine's own out-of-reach handling on the
target (the "need to be closer" error, or the click-to-move walk).

GetAny now reads the target's cursor verdict once: in reach wins, then
softinteract, then the target while it is interactable but out of reach.

* feat(interact): honor looseTargeting in C_PlayerInteractionManager.InteractUnit

On retail the Interact Key on a nearer NPC opens its dialog while a
farther unit stays targeted; with no target it targets the NPC. The key
calls InteractUnit("anyinteract") with looseTargeting at its default,
true, which ClassicAPI ignored, so the right-click's select replaced the
target.

FUN_ON_SPRITE_RIGHT_CLICK (0x00492820) is the active-player gate, the
select (FUN_SET_SELECTION_BY_GUID, 0x00493540), then the object's
right-click virtual (vtable +0x60, call at 0x00492881) with
FUN_GAMEUI_IS_KEY_DOWN(0). The select stores only units, so only a unit
other than the current target would move it. With looseTargeting and a
target, that one case runs the gate and the virtual without the select;
every other case still calls the engine function, which nampower also
detours for its right-click guards. The global InteractUnit keeps
targeting.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-11-03 12:39:54 -06:00
Brues 04790cb6c1 feat(options): name the page scroll bars for skins
Each options page's MinimalScrollBar is now ClassicAPIOptionsScrollBarN,
numbered like the ClassicAPIOptionsControlN controls, so a skin can find
the scroll bars the same way it finds the controls.
2026-11-03 00:54:42 -06:00
Brues e7ecae56da feat(options): key binding rows for ClassicAPI's bindings
The Interact Key page gets rows for Interact With Target and Interact
With Mouseover, and Bags and Items gets AoE Loot under a Loot header.
A row is retail's settings-page binding row: the binding's name, then a
button for each of its two keys. Left click a button and press a key,
mouse button 3-5 or the wheel to bind it; Escape cancels; right click
unbinds that key. The tooltip on a bound key and the page's output line
use retail's strings, added to every locale with Blizzard's translations.

The binding is 1.12's Key Bindings window's (Blizzard_BindingUI): the
same ALT-CTRL-SHIFT chord order, the same skipped modifier keys, the same
KEY_UNBOUND_ERROR check for an action that loses its last key, and the
same put-back when SetBinding refuses a key. A change saves at once with
SaveBindings(GetCurrentBindingSet()), so it lands in the account or
character set in use, as every other control on these pages writes at
once.

While a button listens, a full-screen button on FULLSCREEN_DIALOG takes
keys, clicks and the wheel, as retail's KeybindListener and its input
blocker do. A left or right click on it cancels, and so does hiding the
page.
2026-11-03 00:54:35 -06:00
Brues 65c6b0b271 feat(action): spell search, slot item and #showtooltip action queries
C_ActionBar.FindSpellActionButtons, HasSpellActionButtons and
IsOnBarOrSpecialBar find a spell on the bars. A slot matches on the
spell ID stored in VAR_ACTION_TABLE under the spell tag, the same test
4.3.4's PutActionInSlot makes, so a macro or item that casts the spell
does not count and a rank matches only itself. A name resolves through
the engine's spellbook resolver. IsOnBarOrSpecialBar also reads the
shapeshift bar's spell array (pointer at 0x00B71108, count at
0x00B71104, as Script_GetShapeshiftFormInfo 0x004B45C0 reads them) and
the pet bar's spell entries (types 1-5, spell ID in the low 16 bits, as
Script_GetPetActionInfo 0x004BDC50 reads them, gated on the pet GUID).
Enum.ActionBarSet is registered; all 120 slots are its Mkb set.

IsStackableAction (global, 1/nil) and C_ActionBar.IsStackableAction
(bool) answer 4.3.4's IsStackableAucItem: the slot's item has a max stack
above 1. C_ActionBar.GetItemActionOnEquipSpellID returns the item's
first spell with trigger 1 (on equip). The item is the item-by-ID
action's, or the one a macro's #showtooltip / #show resolved to, as
4.3.4's CGActionBar::GetItem returns it. Both peek the item cache and
never query it.

C_ActionBar.IsMacroActionWithShowTooltip parses the macro's body with
the parse Macro::ShowTooltip hands the tooltip over on, so it answers
while that module yields or a publisher owns the macro.
2026-11-03 00:37:16 -06:00
Brues 5f94fb98bd feat(action): C_ActionBar.GetSpell
C_ActionBar.GetSpell(actionID) returns the spell an action-bar slot
casts, or 0. It is the engine's own slot resolver (0x004E5A50), which
the cooldown, usable and range readers already go through: a spell
action is its spell, a macro is its cached primary spell (+0x564, the
one #showtooltip sets), and an item-by-ID action is the first on-use
spell in its item cache record. An empty slot, a slot outside 1..120,
an equipment set or a macro whose macro is gone gives 0. The spellbook
flag the resolver also hands back is not part of the answer.
2026-11-03 00:16:02 -06:00
Brues 426f6b6ec9 fix(container): free slots count the bank and use the engine's counter
GetContainerNumFreeSlots and GetContainerFreeSlots now take the main bank
(-1) and the bank bags (5..10) while a banker is open. The main bank is
gated on the banker GUID (0x00BDD038); bank bags get the gate from the
engine's container-GUID getter (0x004F93E0), which answers 0 for them
while the bank is closed.

A bag's empty slots are counted by the engine's own CountFreeSlots
(0x00622910, __thiscall on the bag's inventory list), and its slot count
comes from that live list instead of the item record. The backpack and
main bank are ranges of the player's list, walked slot by slot. Nothing
walks PackBagSlot per slot any more, so the Lua stack is left alone.

A container that is not there (keyring, empty bag slot, closed bank) now
returns only 0, leaving bagFamily nil.

The bag view SortBags kept for itself moves to Container::Bag so both
modules share it, and Item::Location::EquippedBagInventory becomes
BagInventory (1..10) beside a new BagContainer.

C_Item.GetItemData's bagFamily now goes through BitmaskForRecord, so a
bag whose family field is empty reports the same family there as it does
from GetItemFamily (a Soul Pouch was 0 there, 0x4 here).
2026-11-02 21:03:01 -06:00
Brues 3a4d291748 fix(link): split links with string.split captured at load
LinkUtil.SplitLinkData and SplitLinkOptions looked up string.split on
every call. BigWigs (pepopo978 fork) defines a table-returning
string:split in Plugins/SpellRequests.lua, which replaces the global, so
with it loaded every custom link type resolved to a table, no handler
matched, and SetItemRef rejected the link as unknown.

string.split is registered in C by the DLL before any addon loads, so a
file-local captured when LinkUtil.lua loads always holds the real one.

Fixes brues-code/pfUI#73
2026-11-02 16:23:36 -06:00
Brues 1cdd46958f feat(combatlog): 4.3.4 combat log lines in retail-named log files
While combat logging is on, each combat log entry is also written as one
CSV line in 4.3.4's PackEvent layout: the subevent, source and dest
(GUID, quoted name or nil, flags as hex), the spell prefix, then the
suffix blocks the entry carries. Fields are written as 1 or nil, damage
values as numbers, and there is no hideCaster, isOffHand or alternate
power type. The line is written from Queue before the events fire, so
it is written even when no frame listens.

The switch is LoggingCombat's own flag (0x00B4FDB8), so /combatlog and
LoggingCombat() control both files. Each time the flag turns on, a new
file starts, named as retail names it:
Logs\WoWCombatLog-MMDDYY_HHMMSS.txt, the local time logging started.
The file is a Storm log opened as LoggingCombat opens its own:
SLogCreate (0x0065A0C0) with flag 4 (append). Lines go through SLogWrite
(0x0065ACE0), which puts the "M/D HH:MM:SS.mmm  " stamp in front.

The flag is read once a frame on FrameTick and before each line. When it
turns off, SLogClose (0x00659EC0) writes the buffered lines and closes
the file. This also covers logout: the chat teardown in
CGGameUI::ShutdownGame (0x00498700, its only caller) clears the flag,
and FrameTick runs on the glue screen too. Storm closes what is still
open at exit (0x0065A4B0). LoggingCombat itself is not hooked: it is a
jmp into the enable code the chat log shares, and other DLLs add their
own logs to it.

1.12's Logs\WoWCombatLog.txt keeps its chat-text lines, written by
0x0049B1E8, for the log readers that parse them.
2026-11-02 15:44:06 -06:00
Brues 72f0e5d98f docs(action): range and usable events move to the Events section 2026-11-02 14:06:41 -06:00
Brues ceb6aaf46b feat(action): action range and usable events
ACTION_RANGE_CHECK_UPDATE(slot, isInRange, checksRange) and
C_ActionBar.EnableActionRangeCheck(slot, enable). Enabled slots are
checked on each world tick while a frame is registered for the event,
with IsActionInRange's own answer (Action::Range::Answer, the macro
unit redirect included), and fire on change. Enabling is counted per
slot, and each enable makes the slot's next check fire, because retail
buttons take their first state from the event. A new Lua state starts
with no slot enabled.

ACTION_USABLE_CHANGED(changes) carries { slot, usable, noMana } for
each slot that changed. The engine's bulk refresh 0x004E5C00 already
compares each slot's pair against the caches IsUsableAction reads
(0x00BC6B60 / 0x00BC67A0). Its per-slot call at 0x004E5C15 is
redirected to record the slots whose pair differs, and its
ACTIONBAR_UPDATE_USABLE signal at 0x004E5C53 to send that event
unchanged and then the new one. No hook.
2026-11-02 14:03:27 -06:00
Brues 84c936faa4 fix(compat): hooks wrap the engine's body when wow_turbo got there first
wow_turbo detours about 380 engine functions to its own reimplementations
from its DllMain. It yields any function whose prologue another mod has
already patched, but ClassicAPI installs from the Load export, which
VanillaFixes calls after every DllMain. So wow_turbo always patches first,
and MinHook copies its jmp into our trampoline: the hook then calls the
reimplementation, not the engine's body. Everything that lives in that body
was lost. The script runners (0x00704D50, 0x00704F10) never reached the
pcall redirects at 0x00704E79 / 0x00705199, so every handler saw self ==
nil. The text builder (0x005CDC20) calls its own emitter, so the inline
texture emitter hook (0x005CCBE0) and the word-wrap redirects at
0x005CDDD3 / 0x005CDE23 never ran.

Before each HookAutoRegister hook is created, a prologue that is a jmp
rel32 into wow_turbo.dll gets its five stock bytes back. The bytes come from
WoW.exe on disk, found through the loaded image's section table. Then the
hook wraps the engine's body. wow_turbo's own rule produces the same state
when it loads after a mod that hooked first. It keeps its original in
place, logs the overwrite and does not re-patch these entries. Release
builds reclaim six: the two runners, the text builder and emitter,
GxSetViewport (0x0058AF60) and the selection-circle update (0x00614A90).
Nothing changes when wow_turbo is not loaded.
2026-11-02 13:00:50 -06:00
Brues 957d66af49 fix(addons): memory usage walks the live objects, as luaC_sizeall does
UpdateAddOnMemoryUsage summed Gc::Tag::SizeOf over the keys of the tag
map. That is only safe while every free reaches luaC_freeobj (0x006F7260),
whose co-hook erases the key. wow_turbo replaces luaC_collectgarbage
(0x006F7340) with a parallel sweep that frees strings, tables, closures,
userdata and upvalues itself and sends only threads and protos through
freeobj. Dead keys stayed in the map, and the recount read freed blocks:
crash 132.

The recount now walks the lists the engine sweeps: rootgc (G+0x10),
rootudata (G+0x14) and every string-table bucket (G+0x04, size G+0x0C),
and looks up the tag of each live object. 3.3.5's luaC_sizeall walks the
same lists. A create with no context word now erases its address's entry,
so a reused address never carries a dead object's tag, whoever freed it.
Dead entries a foreign sweep leaves are never read, and the pool's address
reuse bounds them. _classicapi_LuaHeapAudit uses the same walk.

Fixes #120
2026-11-02 13:00:50 -06:00
Brues 3849b7513b feat(statusbar): the standard fill moves the bar texture's rect
1.12's CSimpleStatusBar::OnFrameUpdate (0x007833C0) draws the fill by
cropping. InstallBarTexture (0x007831F0) sets the texture's
SetTexCoordModifiesRect flag (+0x124), and the update writes the filled
part into the coordinates. The crop shrinks the drawn quad but leaves the
texture's rect at the full bar, so a region anchored to
GetStatusBarTexture() stays on the bar's far edge. Reverse, center,
rotated and tiled bars already moved the texture's points.

The OnFrameUpdate hook now replaces the engine's update for every bar. It
runs the base frame update (0x0076B2C0), clears the crop flag, and moves
the texture's four points in from the bar's edges with CLayoutFrame
SetPoint and doResize, as 4.3.4's OnLayerUpdate does. The fraction comes
from the bar's fill-fraction virtual (+0x98), so SetValue, the ease and
SetTimerDuration all move the rect. doResize queues the texture for the
layout pass before the draw, so anchored regions follow in the same frame.
An empty fill keeps a zero-width rect on the start edge (CalculateRect
0x767A20 has no size test), so its anchors still resolve.

The crop path and the code that handed a bar back to it are removed. A
standard vertical bar now shows the bottom part of its image.
2026-11-02 11:25:46 -06:00
Brues 1cdd443e53 refactor(text): inline icons take their line's fade when its colour changes
The icon textures have no frame, so the frame alpha fold (0x0077FAC0)
skips them and their only alpha is the one copied from their fontstring's
colour (colors[0], OFF_FONTSTRING_COLOR_ARRAY). The tick read that colour
for every fontstring with icons, every frame, and re-coloured the icons
when it changed.

Every change of that colour ends in the fontstring's region slot 8,
CSimpleFontString::OnColorChanged (0x00772180, `ret 4`): SetVertexColor
(0x0077F750) calls it after storing a colour, which is how the chat fader
and SetTextColor reach it, and the frame alpha setter (0x0076A690) calls it
on each region of the frame. It folds the colour (0x0077FAC0) before it
hands it to the built string, so the value is final when it returns.

A co-hook on it now copies the colour onto the line's icons in the same
call. No other loaded DLL refers to that address. The batch reads a
texture's colour through a pointer, so no layer rebuild is needed. The glue
value-follow (FadedColor) is unchanged. The tick only deletes orphaned
textures now; the per-fontstring applied-colour stamp is gone.
2026-11-02 10:26:26 -06:00
Brues ada33825ad refactor(text): inline icons are placed at build and at move only
Inline icons were placed on three paths: the RebuildString refresh, the
paint flush (FUN_TEXT_PAINT co-hook), which recomputed every icon line's
placements after every paint and queued any change, and the tick, which
applied the queue. Measured over normal play, the paint flush never
supplied an icon the rebuild had missed. All it did was re-place icons
that had moved, 456 times: almost all by float noise (1e-5 px), each one
re-anchoring the icon and rebuilding the fontstring's layer; the rest by up
to 0.9 px.

That 0.9 px is a move. A fontstring whose size does not change keeps its
built string: the rect callback (0x00772D40), SetTextOffset (0x00772100)
and the animation translation only move it, through TextBlockSetStringPos
(0x0044D600) -> CGxString::SetPosition (0x005CCB10), which re-finalizes the
node origin (0x005CDF70) before it returns. The origin is floored to whole
render-target pixels, so an icon anchored to the unrounded fontstring rect
drifts off its glyphs by up to a pixel.

The icons are now placed when the string is built (the RebuildString
refresh) and after each TextBlockSetStringPos, from a co-hook on it. No
other loaded DLL refers to that address. The move lays the node out first
(FUN_TEXT_ENSURE_BUILT, as the rebuild does): a colour change can leave the
node unbuilt until the next paint, and with no built lines (+0x9C) the
origin finalize returns early with the raw normalized position in the
origin, which placed those icons at the bottom-left of the screen. The
placement now refuses an unbuilt node. Only nodes with icon records are
laid out early.

Removed: the paint flush's placement queue, the pool's queue and dirty
flag, and the tick's apply. The pool has one entry point, Apply, which
skips an icon set equal to the one applied. The paint flush keeps its
other jobs (the stuck-blockless re-dirty, the stale-record guard and the
baked-alpha mirror); the tick keeps the orphan deletes and the fade mirror.
2026-11-02 10:15:54 -06:00
Brues dd584703a2 refactor(text): place inline icons once, at scale 1
An inline icon texture was given its fontstring's layout scale (layout
slot 0x10, as AddRegionToLayer gives a region its frame's), its anchor
offsets were divided by that same scale, and every tick read each drawn
icon's resolved width back and folded the error into a per-icon
correction, re-placing the icon until it settled within 0.5%.

The textures have no frame, so that machinery has nothing to correct. The
layout constructor (0x00767680) sets the scale (layout +0x58, region +0x7C)
to 1.0 and nothing else changes it without a parent. RelativePointGetX/Y
(0x007A2F90 / 0x007A3070) place a point at the positioned region's own
scale times the stored offset plus the relative edge, so with scale 1 the
fontstring-relative edges land exactly. The scale copy and the divide
cancelled, and the correction stayed at 1.

The icon is now placed with two SetPoints at the raw offsets and the
layout realize (0x00768060, SetDeferResize(0), which resizes at once when
a resize is pending). Removed: the scale copy, the correction and the
per-tick rect readback, and VTBL_LAYOUT_SET_LAYOUT_SCALE, which named the
same slot as VTBL_LAYOUT_SET_SCALE.
2026-11-02 10:04:13 -06:00
Brues 5911298acd fix(text): inline icons are drawn by their line, as 4.3.4 does
Inline icons were CSimpleTextures registered on the fontstring's parent
frame, with their visibility copied from the line once per tick, after the
draw. Any line that hid kept its icons for one more frame; once chat lines
moved with their text, a line scrolled off the top flashed its icons above
the chat frame.

4.3.4's CSimpleEmbeddedTexture has no frame, and the fontstring draws it:
CSimpleFontString::Draw queues the string, then QueueTextures. 1.12's
equivalent is CSimpleFontString::Emit (0x00772E50), the only caller of
AppendToBatch (0x00773080). The icon textures are now made with no frame
(SimpleRegionConstruct skips SetRegionParent for a null parent; the
frame-alpha fold 0x0077FAC0 tests the parent), anchored to the fontstring
and given its scale through layout slot 0x10, as AddRegionToLayer does. A
hook on Emit adds a line's icons to the layer batch right after its text,
and only when the text went in (the batch count at +0x4 grew), so an icon
draws exactly when its line does, in the same frame.

The batch copies each texture's handle, blend and colour count when built
(0x00772FD0) and reads vertices, texcoords and colours through pointers, so
a change to a line's icon set marks the fontstring's layer
(NotifyLayerChanged 0x0077FDF0), and moves and fades need no rebuild.

4.3.4 sets the textures up in UpdateString. RebuildString (0x007724A0) now
does the same: when its gate passes (layout flags +0x60 bit 1, `test byte
[esi+0x60],1`) it replaces the built string, so the line's icons are
dropped, the new node is laid out (FUN_TEXT_ENSURE_BUILT) and its
placements applied at once. The paint flush stays the backstop.

Removed: the per-tick visibility mirror, the freshness expiry, region
Show/Hide and re-parenting, and the SMF early apply with its
_classicapi_InlineTexEarly toggle; the rebuild refresh covers what it did,
and it rebuilt every icon line on every layout. The textures are deleted
through their vtable slot 0 (as the frame destructor deletes regions) one
tick after their fontstring dies or a reload, once the batches holding them
were rebuilt.
2026-11-02 00:53:10 -06:00
Brues a6fa06faa0 perf(smf): lines a layout keeps on screen keep their font strings
The line layout (0x00788750) shows record (position - i) in line i and sets
each line's text through ShowLine (0x00788AF0). When the position moves, by
a message at the bottom or a scroll, every line got the text of its
neighbour: a copy, a freed built string, a new measure and a new shaping.

Before a layout of a shown frame, the line entries are now turned by the
distance the position moves (the layout's record minus the scroll record
+0x348, the record line 0 shows), so each line that stays on screen is the
font string that already holds its text. The layout itself runs unchanged.
SetText (0x00771D80) returns at its SStrCmp when the text is the one the
line holds, so the line keeps its built string and measured height;
SetVertexColor (0x0077F750) skips a repeated colour; the forced Resize only
moves the built string, since the rect callback 0x00772D40 rebuilds only on
a size change. Only lines that come round get new text. Hyperlink buttons
are rebuilt from the lines after every layout, and inline textures are
anchored to their line, so both follow.

The 0x10-byte entries move whole, as the engine's own array grow 0x00789060
moves them with SMemReAlloc. Two lines get new anchors: the new line 0 goes
on the frame, then the old line 0 on the line below it, in that order so no
line is anchored to one that depends on it. Both are anchored as the layout
anchors a new line, in the frame's insert mode (InsertMode::AnchorLine, now
shared). A frame whose last layout placed no line (+0x34C = 0, a hidden
frame's layout) is laid out as before, as is a distance as large as the
lines. A wrong distance costs only the reuse: the layout still sets every
text it shows.

The layout's before-listener now receives the record it runs from.
2026-11-02 00:24:28 -06:00
Brues 045ab77508 perf(smf): one line layout per update for messages added at the bottom
The ring writer 0x00788150, AddMessage's only callee, lays the lines out
(0x00788750) after every message at the bottom, and the layout sets the text
of every line it shows. k messages in one update cost k layouts of every
shown line, and only the last is drawn.

The writer's layout call (0x00788234) is redirected. The layout is put off
when its result is known apart from line heights: the frame is shown, it had
a message before this one, and its top flag +0x350 is clear (the lines do
not reach the oldest message, and a newer message cannot make them). The
layout's first store, the scroll record +0x348 = the newest record, is made
at once, so GetCurrentScroll and the writer's next check read as before.

The put-off layout runs at the first of:
- the frame's update, between its OnUpdate script and its fader (the call
  to 0x0076B2C0 at 0x00788471). The fader, RearmLines and the link-button
  rebuild read each line's record through node+0xC, and the writer may have
  reused those records.
- the end of the top's update pass, before the second layout-queue drain
  (0x00768ED0 at 0x00765799) and the draw, so a message added by any
  OnUpdate draws in the same frame.
- RearmLines 0x00788B80 (hooked), which ScrollDown, ScrollToBottom and
  ScrollToOffset run at the bottom instead of a layout.
- GetNumLinesDisplayed (0x00793590, reads +0x34C), overridden on the method
  registry to lay out first.
- any other layout of the frame, which replaces it. Layout gains a
  before-listener for this, since a layout can run Lua.

OnMessageScrollChanged now runs once for the messages a layout covers: the
put-off layout gets the scroll record from before the first of them. The
writer is put off only when both flush redirects apply.
2026-11-02 00:14:18 -06:00
Brues ac5df0fab8 perf(smf): BackFillMessage shifts in place and skips layouts it cannot change
BackFillMessage made every older message move up one record. It copied the
record span into a vector and back, built a new Lua table for the extra
arguments, and laid the lines out again on every call. pfUI's combat-log
refilter calls it once per frame for up to 300 lines, and its chat history
up to 30 times per frame at login.

The records now rotate in place (std::rotate over the 0x18-byte records),
and the extra-argument tables follow each cycle of the rotation in the same
table, so a call makes no garbage. RemoveMessagesByPredicate shares the
rotation.

A shown frame whose lines do not reach the oldest message (+0x350, the top
flag the layout 0x00788750 sets) cannot show the new oldest one. The layout
would place the same lines again, so it is skipped. The one thing it would
change, each laid-out node's record pointer (node+0xC, written by ShowLine
0x00788AF0), moves one record up with its message. The fader 0x00788460,
RearmLines 0x00788B80 and RebuildLinkButtons 0x00788C00 read records only
through that pointer, for nodes 0..+0x34C-1. Clear leaves +0x350 at 0 on an
empty frame, so the skip also needs existing messages. An empty or hidden
frame is laid out as before.
2026-11-01 23:52:22 -06:00
Brues 48822978c6 feat(statusbar): ExponentialEaseOut interpolation
bar:SetValue(value, interpolation) and SetMinMaxValues(min, max,
interpolation) take Enum.StatusBarInterpolation. With
ExponentialEaseOut the fill eases from what it showed to the new value;
GetInterpolatedValue, IsInterpolating and SetToTargetValue read and end
the ease, and SetTimerDuration's interpolation argument now chases the
timer the same way. The curve was measured in a retail 12.1.5 client
at 77 and 30 frames per second and fits to under 1%: each frame closes
the gap in the fill FRACTION by exp(-18 * elapsed), whatever the range,
and the frame whose gap before its step is under 0.001 snaps to the
target. The same probe on 1.12 matches: k = 18.0, a full-bar ease done
in 0.38 s against retail's 0.39.

SetValue stores the target at once (GetValue returns it, OnValueChanged
runs once, with it); a new eased value goes on from the shown fill, and
an Immediate one, or SetToTargetValue, snaps. SetMinMaxValues with the
ease keeps the fraction shown and eases to the new one. A hidden bar
does not ease. Only a target that held still since the last frame may
snap: an eased timer starts at a near-zero gap, and retail chases it
until the timer ends.

The ease lives where the timer does, so StatusBar::Timer becomes
StatusBar::Display: one state per bar holding the timer, the ease and
the fraction drawn. The co-hook on the bar's GetFillFraction virtual
(0x783380) answers that fraction, so the ease keeps 1.12's texture crop
and StatusBar::Fill's styles. Advance runs at the head of
CSimpleStatusBar::OnFrameUpdate (0x7833C0) for shown bars and now takes
the pass's elapsed, which is the step retail takes.

SetValue and SetMinMaxValues stay the engine's Lua methods (0x78F690,
0x78F4B0, read from the method table at 0x87B010), registered over by
wrappers that read the interpolation argument and the fraction shown,
call the engine method for its checks, errors and setter, then start or
stop the ease. Both engine methods read no argument past their own.
2026-11-01 23:34:36 -06:00
Brues f061179646 refactor(region): one region destruction signal; inline icons drop the vtable guess
Frame::RegionDestruction co-hooks SimpleRegionDestruct (0x77F6F0), the
destructor body every region ends in, and runs its listeners first. It
has exactly three callers: the base region's deleting destructor
(0x77F6C6), ~CSimpleTexture (0x76FD40, a tail JMP; its only caller is
the deleting destructor 0x76FD10) and ~CSimpleFontString (0x770E50, a
tail JMP; called by its deleting destructor 0x770E20 and by a
subclass's, 0x789320). The texture and font string vtables (0x81C718,
0x81C778) are written only by their constructor and destructor, so no
inlined destructor skips the hook. It replaces FontString::Destruction,
the font-string-only hook from the previous commit; the duration text
binding now listens here.

InlineTexturePool kept icon state keyed by font string pointer and
judged a key alive by its first word matching a vtable it had seen
painted. That guess had a hole: a destroyed font string's block goes
back to the region pool and can come back as another font string,
vtable and all, and the dead key's icons then served the new one. It
also read possibly-freed memory on every tick. Now a destroyed key
takes its entry with it at once, so the address starts clean; its icon
regions live on with their parent frame and are hidden on the next
tick, never inside the destructor that reported the death. A destroyed
icon region is dropped wherever it is held, and a live key that loses
one is placed again. The learned-vtable sets, IsLiveFontString and
IsLiveRegion are gone. The SEH latch stays, for the engine region calls
the tick makes from paint-time data.
2026-11-01 23:03:11 -06:00
Brues f8a796ae04 feat(duration): duration text bindings, Format methods, GetTotemDuration
A font string can now count down by itself. A binding from
C_DurationUtil.CreateDurationTextBinding() takes a font string
(SetFontString), a duration object (SetDuration) and a format: either
SetFormatter(formatter), which writes a NumericRuleFormatter's text for
the remaining seconds, or SetTextFormat(formatString, components), where
each "{}" takes one component's property (Enum.DurationTextBindingProperty)
through its own formatter. SetExpiredText and SetZeroDurationText give
fixed texts, SetUpdateInterval spaces the updates out, and SetEnabled /
Enable / Disable start and stop it. The getters, Copy, Assign,
SetToDefaults, UpdateFontString, GetFormattedText and the Can* checks
are there too; the color curves are not. Duration objects gain
FormatElapsedDuration, FormatRemainingDuration and FormatTotalDuration
on the same formatter. Each answer was probed in a retail 12.1.5 client,
and the same probe matches on 1.12.

A binding is a userdata over a reference-counted native object, like the
duration object and the formatter, and it keeps both of those by
reference, so a change to the duration shows at the next frame. An
enabled binding takes a reference of its own, because it must go on
updating with no Lua object left; a Game::ReloadAutoRegister disables
every binding before the Lua state goes. The text is set through
CSimpleFontString::SetText (0x771D80), only when it differs from the
font string's own text buffer (+0xF0).

The updates run at the head of the UI render root (0x764330), before
the frame pass runs any OnUpdate or draws: Tick::FrameTick gains
AutoSubscribeBefore for this. At the tail, where FrameTick and
WorldTick subscribers run, the text would draw a frame after a timer
bar's fill, which the same pass computes; the probe caught that as a
"3.0" where retail showed "2.9". Nothing is mid-draw at the head, so the
formatter's call into str_format is safe there.

A binding keeps a font string pointer, and 1.12 hands a destroyed font
string's block back to the region pool, where it can come back as
another font string. The new FontString::Destruction co-hooks
CSimpleFontString::~CSimpleFontString (0x770E50, decompiled; its
deleting destructor 0x770E20 calls it, then frees the block) and runs
its listeners first, like Frame::Destruction does for frames. The
binding drops the pointer there and writes nothing after.

GetTotemDuration(slot) now returns a duration object, as retail's does:
the totem's span from its spawn stamp to its expiry, in exact
GetTime() seconds rather than GetTotemInfo's whole seconds, or a zero
span for an empty slot, since retail's return is never nil. Each call
returns a new object.
2026-11-01 22:48:32 -06:00
Brues aff52e0ef6 feat(statusbar): timer bars, duration objects and Unit*Duration
A status bar can now fill by itself from a duration, with no Lua on each
frame. bar:SetTimerDuration(duration [, interpolation [, direction]])
takes a duration object from C_DurationUtil.CreateDuration(), and
Enum.StatusBarTimerDirection picks ElapsedTime (fills) or RemainingTime
(empties). GetTimerDuration() returns it. UnitCastingDuration(unit) and
UnitChannelDuration(unit) return the time span of a unit's cast or
channel as a duration object.

The duration object is a userdata like the NumericRuleFormatter: a
pointer to a reference-counted span (start, total, mod rate) that every
Lua object and every bar holding it own a reference to. Times are
GetTime() seconds read through Time::Clock::NowSeconds, so they agree
with GetTime() exactly. Its methods set the span from a start, an end
or both, and read the start, end, total, elapsed and remaining time,
the two percents and the started / expired / active state, in real or
base time (Enum.DurationTimeModifier). The answers were probed in a
retail 12.1.5 client and the same probe matches on 1.12.

1.12 already draws a bar's fill from a virtual, not from its stored
value: CSimpleStatusBar::OnFrameUpdate (0x7833C0) crops the bar texture
to the fraction its vtable slot +0x98 returns (0x783380,
(value - min) / (max - min), or 0 for an empty range; disassembled).
The timer co-hooks that function, so a timed bar keeps 1.12's texture
crop. StatusBar::Fill's reversed, centered, turned and tiled layouts
now take their fraction from the same virtual, so they follow a timer
too. Fill's OnFrameUpdate co-hook calls Timer::Advance first, which
marks the fill changed when the share moved and caches the share it
drew. Shown bars update on every frame; hidden bars do nothing and are
right on their first frame shown.

As in retail, the timer stores 0 for the value and the range (written
directly, so no OnValueChanged runs), SetValue leaves it running, and
SetMinMaxValues ends it: Frame::MinMaxChanged's co-hook on the range
setter (0x783270) clears the timer first. The bar keeps the duration
object, not a copy, so a change to the object shows on the next frame.
ExponentialEaseOut is accepted and draws as Immediate for now.

UnitCastingDuration and UnitChannelDuration share one per-unit lookup
with C_Spell.UnitCastingInfo / UnitChannelInfo, so they take the same
unit tokens and have the same limits. Each call returns a new snapshot;
it does not follow pushback or the end of the cast.
2026-11-01 22:28:01 -06:00
Brues aef68d5ea4 fix(nameplate): fire REMOVED and ADDED when a unit moves to another plate
The name-query callback (0x5FB86E) and the creature-cache reply (0x6046C1)
call CGUnit_C::ReleaseNamePlate (0x608A10) so the plate is rebuilt with the
name, and the next tick takes a plate again through AllocNamePlate
(0x6087F0). The free list is FIFO: release links the frame in at the tail
and alloc takes s_freeNamePlates[2], the head. So unless the list was empty,
the unit gets a different CGNamePlateFrame. The per-tick diff compared GUIDs
only. The GUID was in both snapshots, so no event fired, and an addon that
keys its state by frame kept the old, hidden frame.

A binding is now the (GUID, frame) pair. A unit on a new frame gets
NAME_PLATE_UNIT_REMOVED for the old binding, then NAME_PLATE_UNIT_ADDED for
the new one. The REMOVED pass now runs before the ADDED pass, so the old
binding's slot is free before the new one is assigned. A re-acquire that
hands back the same frame still fires nothing.

During REMOVED, GetNamePlateForUnit / GetNamePlateForGUID now return the
frame being removed before they read unit+0xE60. After a rebind, that field
already names the new frame, and the getters would have given the handler
the wrong plate.
2026-11-01 21:46:34 -06:00
Brues cc1e70a641 feat(debugtools): modern /etrace
/etrace and /eventtrace now open a backport of retail's
Blizzard_EventTrace, the load-on-demand addon ClassicAPI_EventTrace
embedded in the DLL. It replaces the old event trace frame in
ClassicAPI_DebugTools, and DebugToolsSavedVars with it; the panel saves
EventTraceSavedVars.

What differs on 1.12.1:

- RegisterAllEvents adds the frame to every event slot, so it opens the
  listener gate of each ClassicAPI event that builds its arguments only
  for a listener (UNIT_AURA_DELTA, the combat log and cast events). The
  panel registers all events only while it is shown or logging while
  hidden; otherwise it keeps PLAYER_LOGOUT, which saves its variables,
  since this client has no ADDONS_UNLOADING.
- An engine event signalled by a number with no name arrives with a nil
  event and is not logged.
- An event's table arguments are copied when logged, and the tooltip
  lists their contents. UNIT_AURA_DELTA refills the same tables on every
  fire, so a kept reference shows only the latest delta.
- No tool windows, ImGui debug tools or secret values.

The window's templates are new shared templates in !!!ClassicAPI:
ButtonFrameTemplate and PortraitFrameBaseTemplate (with
PortraitFrameMixin / TitledPanelMixin), UIPanelCloseButtonNoScripts,
PanelResizeButtonTemplate, InputBoxInstructionsTemplate,
SharedTooltipTemplate, VisibleWhilePlayingAnimGroupTemplate, and
GameTooltip_AddHighlightLine / GameTooltip_AddColoredDoubleLine. Art
this client lacks is drawn with its own textures: tooltip border
chrome, the world map magnifier, the cancel button, a turned chat
expand arrow for the resize grip, and the panel button in three slices.
The scroll bars are the 1.12 bar, whose art is wider than its frame,
so the lists leave room for it.

Retail's search box is ClassicAPISearchBoxTemplate. Turtle's FrameXML
registers its own SearchBoxTemplate first, and 1.12 keeps the first
template of a name, so the retail one takes that name only through a
TOC line marked [ExcludeLoadGameType turtle].

The EVENTTRACE_* strings and SLASH_EVENTTRACE1-4 come from Classic
Era's GlobalStrings for each client locale but koKR.
2026-11-01 21:29:44 -06:00
Brues f1843afc0f feat(addons): ExcludeLoadGameType and the turtle game type in TOC lines
A TOC file line can now end in [ExcludeLoadGameType ...] as well as
[AllowLoadGameType ...]. An unknown condition drops its line, so a
line excluded for some other game type used to be dropped here too.

Both lists may name "turtle", a ClassicAPI extension like the
_Turtle.toc flavor: it matches when Turtle::Detected() (the engine's
TURTLE_WOW_VERSION or the glue TURTLE_* globals), and "vanilla" matches
on every client. So [ExcludeLoadGameType turtle] loads a line
everywhere but Turtle.

The TOCs of the addons embedded in the DLL now go through the same
rewrite. The FUN_FILE_READ hook serves an embedded addon's file from
disk or from the DLL as before, now in ReadAddonFile, and passes a read
that succeeded to TocRewrite::Transform, which rewrites only TOCs.
2026-11-01 21:25:47 -06:00
Brues f8304c06f7 fix(xml): frames loaded from XML lay out after OnLoad
When a frame finishes loading from XML, CSimpleFrame::PostLoadXML
(0x0076A2F0) clears the loading flag and, for a visible frame, ends the
layout deferral of the frame, its title region and its registered
regions with 0x00768060(0). That call resolves a pending layout at once
(0x007680E0 with force 1), so OnSizeChanged runs before the frame's
OnLoad, and before its children, built next, run theirs. Building the
children first does not help: resolving a child ends the deferral of the
frame it anchors to and lays that frame out too (0x007A2F90).

Retail lays these frames out later, from its end-of-frame resize list.
Its EventFrame, ScrollBox and scroll bars trigger callbacks from
OnSizeChanged on the registry their OnLoad creates, so a retail window
that loads visible raised "executingEvents (a nil value)" from each of
them.

The three calls (0x0076A310, 0x0076A324, 0x0076A35B) now go to
EndLoadDeferral: it clears the deferred bit (0x02 of the layout flags
at +0x3C) the same way, and sends a pending layout (0x00768310) to the
engine's layout queue with 0x007680E0(0), the path SetPoint takes. The
queue drains on the top's update and in the interface build, after
every OnLoad of the load. A script that reads a size or an edge still
lays its frame out at once. The minimap and tooltip finalizers call the
base first and read no rect after it. Each redirect checks the call's
target first, so a site another DLL has patched keeps its code.
2026-11-01 21:25:33 -06:00
Brues 2396d44f10 fix(frame): GetWidth / GetHeight / GetSize keep 1.12's scaled size
For a region with no set size, 1.12's Script_GetWidth (0x007A1E00) and
Script_GetHeight (0x007A2030) return the rect span without dividing by
the region's layout scale. The value is the size times the effective
scale: a 160-high track at a UI scale of 0.91 answers 145.6.

dceec8b replaced both getters with the divided form. That broke vanilla
addons tuned to the scaled value. BetterCharacterPanel sets its paperdoll
column from PaperDollFrame:GetHeight() with padding tuned at one UI
scale, so the resist icons and the stat list left the frame.

- GetWidth / GetHeight / GetSize call the engine getters again. With
  ignoreRect, a region with no set size still answers 0.
- New region methods GetUnscaledWidth / GetUnscaledHeight /
  GetUnscaledSize give the size in GetTop / GetBottom units: the explicit
  size, else the rect span over OFF_REGION_EFFECTIVE_SCALE after a
  pending resize. GetRect and GetScaledRect keep using these.
- The ported Lua in !!!ClassicAPI (ScrollBox, Menu, LayoutFrame, Util)
  calls the GetUnscaled* names wherever it measures a region.

Fixes #118
2026-11-01 19:12:04 -06:00
Brues 1ee80b74e9 docs(readme): texture wrap modes, tiling and the element XML attributes
The Texture row lists the SetTexture wrap modes and the tile methods. The
XML attributes row now names the element attributes that ClassicAPI reads,
for frames, buttons, textures, font strings, status bars and edit boxes,
and intrinsic frame types.
2026-11-01 18:35:11 -06:00
Brues 932e6ed82f feat(texture): wrap modes and tiling
texture:SetTexture(file, wrapModeHorizontal, wrapModeVertical), the XML
hWrapMode / vWrapMode attributes, texture:SetHorizTile / GetHorizTile /
SetVertTile / GetVertTile and the XML horizTile / vertTile attributes.

Wrap. 1.12 fixes a texture's wrap when its file loads.
CSimpleTexture::SetTexture (0x00770200) builds the flags word with
CGxTexFlags (0x0058A980) from one wrap argument for both axes, and
TextureCreate (0x00449D90) caches the handle under the name and that
word, so a repeating copy of a file is its own shared texture. The device
reads one bit per axis (3 for U, 4 for V), repeat or clamp, so the mirror
and border modes clamp. A texture with a mode or a tile flag loads
through a co-hook on that SetTexture: the same body, with a repeat per
axis. The engine's same-file early-out (0x0044AC70) ignores flags, so the
co-hook also compares the held image's wrap (its device texture's flags
at +0x3C, or a loading BLP handle's at +0x150), and for the same file it
swaps in the other copy and keeps the pixel shader. Changing a mode or a
tile flag reloads the held file the same way. The Lua method is
registered again so it stores the wrap arguments before the engine
handler (0x0079BB40) runs: true or "REPEAT" repeats, anything else clamps.

Tiling. A tiled axis repeats, and each non-zero coordinate on it becomes
the texture's length in script units over the image's size in texels.
The script units are the engine's own coordinate push: DivideByScreenScaleX
(0x0041AE40) of aspect (0x0041AD70) x 1024 x length, over the effective
scale. The texels are the device texture's width and height (+0x24 /
+0x28): the file's size, halved only past the GPU limit and untouched by
BaseMip. The layer batch keeps a pointer to the eight coordinates
(0x00772FD0), so the count is written whenever an input changes: after
each corner store, after both native SetTexCoord forms (0x00770410,
0x007704C0), on a tile change, and after CSimpleTexture::Emit
(0x007706E0), whose device lookup loads the image. A tile change counts
against the held image before the repeating copy of the same file
replaces it, since the copy has no size until it is drawn.

A texture showing an atlas does not tile (the atlases are parts of their
files), and neither does one with SetTexCoordModifiesRect on. SetAtlas
clears modes and tiling. A tiled status bar texture takes
StatusBar::Fill's anchored layout instead of 1.12's crop, which would size
it by the count, and its fill sets no coordinates. An XML <TexCoords> that
moves a tiled axis off 0..1 logs "Invalid TexCoords value (horizTile is
on)" at error level (new Xml::Error) and falls back to the whole image.

Also corrects the AtlasData.h note on the atlas tile flags: 4 is
horizontal and 2 is vertical.
2026-11-01 18:34:36 -06:00
Brues b032dc09ac refactor(texture): shared hooks for the texture constructor and corner store
MinHook takes one hook per address. Texture::Mask hooked the
CSimpleTexture constructor (0x0076FC40) to drop its state when pool memory
is reused, and Texture::Transform hooked the corner store (0x007705B0) to
turn the quad again after each layout. Texture wrap modes and tiling need
both hooks too, so each hook now has an owner that fans out to listeners:

- Texture::Construct runs its listeners before the constructor, on the
  memory it is about to build. Mask listens.
- Texture::CornerStore runs its listeners after the store. Transform
  listens.

The store writes only the corners. The layer batch reads them through a
pointer on every draw, so nothing marks the layer changed.

The XML number parse through SStrToFloat (0x0064AAA0) moves into
Xml::FloatAttr, which NativeSetters and Transform's rotation attributes
now share.
2026-11-01 18:30:32 -06:00
Brues eb0b70bc5e feat(xml): rotation, degrees, registerForDrag and group parentArray
Four more later-client XML attributes, each applied only when present and
non-empty, after the engine has loaded the node.

- Texture `rotation` (degrees) and FontString `degrees` / `radians`
  (`radians` wins when both are given), parsed with SStrToFloat
  (FUN_SSTR_TO_FLOAT): Texture::Transform's rotation about the default
  pivot. The bodies of texture:SetRotation and fontstring:SetRotation
  move into SetTextureRotation / SetFontStringRotation, which the Lua
  methods and the Xml::TypeLoad subscribers both call.
- Frame `registerForDrag`: the names, split on spaces and commas with
  Storm's tokenizer (FUN_STORM_STR_TOKENIZE) as registerForClicks is,
  passed to Frame:RegisterForDrag itself (FUN_SCRIPT_FRAME_REGISTERFORDRAG
  0x00776D60) as its own Lua call. That function ORs LeftButton 1,
  MiddleButton 2 and RightButton 4 and stores the mask at frame+0xFC;
  there is no setter to call instead.
- AnimationGroup `parentArray`: the group is appended to region[key].
  The append moves out of Xml::ParentKey into Xml::AppendToArray, which
  frames, regions and groups now share.

Tested in game with an XML test addon: rotation="90" and degrees="90"
read back as pi/2, two groups land in parentArray in load order, and a
frame with registerForDrag="LeftButton" drags.
2026-11-01 17:25:28 -06:00
Brues 238e35ae53 feat(animation): from/to alpha, setToFinalAlpha, target keys, scale from/to
Later clients' animation attributes and the methods behind them, on the
animation port's own objects.

Alpha fromAlpha / toAlpha (SetFromAlpha / SetToAlpha / GetFromAlpha /
GetToAlpha): an absolute alpha. Each update the target's alpha becomes
from + (to - from) x progress, through Region::SetAnimAlpha, which takes
AddAnimAlpha's target rule but sets instead of adding: a frame's own
alpha through its vtable setter, a texture's or font string's vertex
alpha through FUN_REGION_GET_COLOR / FUN_FONTSTRING_SET_COLOR. Earlier
orders apply at full progress before the current order updates, so the
last write wins. Setting either end turns the mode on, an unset end is 0,
and SetChange returns to the relative change. Absolute values and the 0
default follow Blizzard's own code: it starts fades with
SetFromAlpha(region:GetAlpha()) and fades in with toAlpha="1" alone.

AnimationGroup setToFinalAlpha (SetToFinalAlpha / IsSetToFinalAlpha):
when the group finishes (Finish included), Region::KeepAnimAlpha makes
the alpha each alpha animation's target ended on the stored alpha, so the
end restores that. Stop restores the stored alpha as before.

targetKey / childKey (SetTargetKey / SetChildKey), with SetTarget,
SetTargetName, SetTargetParent and GetTarget: a key path from the
animation, where $parent steps to the group, then its region, then that
region's parent; childKey "X" is "$parent.$parent.X". The steps among the
animation objects are taken by Region::FindRegionByKey, and the rest goes
to the relativeKey resolver in Xml::Anchors, now public with a switch for
its leading-global rule. A name or key is looked up again on Play, as
4.3.4 looks up `target`; the setters also look it up at once and return
whether they found a region. GetTarget returns the group's region when
there is no target.

Scale fromScaleX / fromScaleY / toScaleX / toScaleY (SetScaleFrom /
SetScaleTo / GetScaleFrom / GetScaleTo): the scale goes from `from` to
`to`, passed to AddAnimScale as 1 - scale. SetScale sets the end, so
4.3.4's scale is the case from = 1 and its results do not change.

Tested in game with an XML test addon: childKey and targetKey resolve,
the absolute alpha and the last-write-wins rule give each order's end
value, setToFinalAlpha keeps 0.5 after the group finishes, a group without
it restores alpha 1, and the from/to scale values load.
2026-11-01 17:25:18 -06:00
Brues 5b7fe94d4d feat(xml): alpha, scale, desaturated, indented and other XML attributes
Later clients read these attributes in their XML loaders; 1.12's do not.
Each is now read after the engine has loaded the node (Xml::FrameLoad for
frames, Xml::TypeLoad for the rest) and goes through the setter the
matching Lua method uses. Each applies only when present and non-empty,
so an element's own value overrides its template's.

Frames:
- scale: Frame:SetScale's path (0x00775140). A negative scale is refused,
  anything else goes to SetFrameScale (FUN_FRAME_SET_SCALE 0x0076AC10,
  __thiscall(frame, float, force), ret 8) without force. A Model keeps
  `scale` as its model scale, which its own loader reads.
- dontSavePosition, propagateKeyboardInput: the existing frame flags,
  through FUN_FRAME_SET_FRAME_FLAG.

Textures and font strings:
- alpha: parsed as the frame loader parses its own `alpha` (SStrToFloat,
  clamped to 0..1, truncated to a byte), then set as Texture:SetAlpha and
  FontString:SetAlpha set it (0x0079B580, 0x0079CB70): the region color
  from FUN_REGION_GET_COLOR with its alpha byte replaced, through
  FUN_FONTSTRING_SET_COLOR. It applies after the node's <Color>.
- desaturated (textures): Texture::Desaturation's full amount, the same
  as SetDesaturated. It applies whenever present; an empty value is false.
- indented (font strings): the style bit SetIndentedWordWrap sets.

Status bars:
- reverseFill, rotatesTexture, fillStyle: StatusBar::Fill's modes, read
  after the status bar loader has set the BarTexture. An unknown
  fillStyle name changes nothing.

Tested in game with an XML test addon: every attribute set its value, and
parentKey, wordwrap and countInvisibleLetters still load.
2026-11-01 16:45:11 -06:00
Brues 43594398f0 refactor(xml): one observer owns the per-type XML loader hooks
MinHook allows one hook per address, so the texture, font string and
button loader hooks lived in Xml::ParentKey, which then read every other
module's attribute for it (mask, atlas, wordwrap, registerForClicks), and
CountInvisibleLetters hooked the EditBox loader on its own.

Xml::TypeLoad now owns the hooks on the engine's per-type node loaders,
the way Xml::FrameLoad owns FUN_XML_FRAME_LOAD's, and fans out to
subscribers after the engine has loaded each node:

- texture        FUN_XML_TEXTURE_LOAD    0x0076FE20
- font string    FUN_XML_FONTSTRING_LOAD 0x00770F40
- button         FUN_XML_BUTTON_LOAD     0x007788C0 (CheckButton's loader
                 calls it first)
- edit box       FUN_XML_EDITBOX_LOAD    0x00779FB0
- status bar     FUN_XML_STATUSBAR_LOAD  0x00782EF0 (new; calls
                 FUN_XML_FRAME_LOAD first at 0x00782F06, ret 8)

All five share the sub-object convention __thiscall(loader, node,
status), ret 8, object = loader - OFF_XML_LOADER_SUBOBJECT. An inherited
template goes through the same loader first, so a node's subscribers run
after its templates'. The texture and font string loads keep running in
the node's mixin scope (Xml::Mixin::Begin / End), as before.

Each attribute is now read in the module that owns its setter:
Texture::Mask (mask), Texture::Atlas (atlas, useAtlasSize),
FontString::WordWrap (wordwrap), Frame::ClickMask (registerForClicks) and
EditBox::CountInvisibleLetters (countInvisibleLetters). The XML-only
entry points Atlas::SetFromXml, WordWrap::Set and ClickMask::SetFromXml
become private, and ClickMask.h and WordWrap.h go. ParentKey keeps only
its own binding work. Same hook addresses, same order around the engine
call, no behavior change.
2026-11-01 16:44:59 -06:00
Brues 2eef939b5a feat(xml): registerForClicks and wordwrap attributes
1.12's loaders read neither attribute: the Button loader (0x007788C0)
has no registerForClicks, and the font string loader (0x00770F40) reads
nonspacewrap but not wordwrap. Both were silently ignored, so ported
templates got the default click mask and wrapping text.

Both are read in the loader hooks Xml::ParentKey already owns, after
the engine's loader and before OnLoad, and only when present and
non-empty, so a node's value beats its template's and an OnLoad call
still overrides it. No new hooks.

registerForClicks follows 3.3.5's CSimpleButton::LoadXML. Storm's
tokenizer (0x0064AE50, the one 3.3.5 calls) splits the value on spaces
and commas. Each name ORs in its bits through
Frame::MouseRegistration::MaskForName, the RegisterForMouse name table
(3.3.5's MouseButtonStringToBitmask, AnyUp / AnyDown included). The
result replaces the mask through the engine's own setter 0x00779730
(__thiscall(button, mask), ret 4, writes only +0x330).

wordwrap goes through FontString::WordWrap::Set, the body of
fontstring:SetWordWrap: it clears the font's override of the no-wrap
style bit and lays the string out again when the value changes.

Tested in game: WowStyle1DropdownTemplate's Text (wordwrap="false")
now reports CanWordWrap() = nil.
2026-11-01 16:03:33 -06:00
Brues 36f3661b98 feat(lua): GetCallstackHeight, GetErrorCallstackHeight, SetErrorCallstackHeight
Adds the three call stack heights an error handler uses to start its
stack at the code that failed:
    debugstack(GetCallstackHeight() - (GetErrorCallstackHeight() - 1))
A height counts levels as lua_getstack (0x006FBAA0) does, and so as
debugstack does: C functions and lost tail calls count, and level 1 is
the caller.

1.12.1 records no error height, but every error report runs one C
function, FrameScriptCallErrorHandler (0x00703B40, __fastcall(L)). It
pushes the seterrorhandler function (registry ref 0x008722CC), moves it
under the message and lua_calls it. Nothing calls it directly: state
open pushes it as a C closure and refs it (0x008722C8). Every script
pcall passes that closure as the errfunc, so Lua calls it at the point
of the error before the stack unwinds; a chunk that fails to compile is
reported by a pcall of it (0x00704AE0). ClassicAPI's securecall,
hooksecurefunc and script runners pass the same ref.

So while any handler runs, that closure's level sits directly above the
code that failed. GetErrorCallstackHeight walks the levels with
lua_getstack and lua_getinfo "f", finds the innermost one whose function
is 0x00703B40 (lua_tocfunction, 0x006F3720) and answers the height below
it, or nil when no error is being reported. There is no hook, and
nothing is recorded, so a handler that fails part way leaves nothing
behind.

SetErrorCallstackHeight sets the height for a handler called with no
error raised. It applies only while no 0x00703B40 level is on the stack,
so a value left set by a failed handler cannot move a later error's
stack. A new Lua state starts with no value set.

Users:
- The Lua error window takes its stack this way, so it starts at the
  failing code even when another addon wraps the handler.
- CallErrorHandler and assertsafe set the height around their direct
  handler call, so they report from their caller.
- Game::Lua gains GetStack, GetInfo and ToCFunction; SourceLocation uses
  them in place of its own copies.

Docs: the xpcall and coroutine notes said error() returns without
raising. Stock error (0x00702970) ends in lua_error (0x006F4440) and
raises. pfUI replaces the global error with one that prints and
returns, which is what those notes described.

Tested in game: with seterrorhandler wrapping the old handler, a nil
index in a local function f showed a stack that starts at f.
2026-11-01 15:37:11 -06:00
Brues 4f3ee595e7 feat(ui): Lua error window replaces the stock error box
The Lua error window (/luaerrors) moves from the load-on-demand
ClassicAPI_DebugTools into !!!ClassicAPI and becomes the client's error
display. Each error opens it with the message, time, count and stack, and
the arrows page through every error so far.

1.12.1's error handler is FrameXML's _ERRORMESSAGE (BasicControls.xml). It
reports an error as ScriptErrors_Message:SetText(message) and then
ScriptErrors:Show(), and only while the stock box is hidden. The window's
OnLoad takes those two slots, the same two !BugGrabber 2.1.1 takes: SetText
records the error and Show does nothing, so the stock box stays hidden and
every error arrives. Errors still pass through the handler first, so:
- !BugGrabber loads after !!!ClassicAPI and takes the slots back. BugSack
  gets the errors and the window does not open.
- An addon that calls seterrorhandler (pfUI) replaces _ERRORMESSAGE, so
  neither shows. One that wraps the old handler still ends here.

The engine's error function (0x00703B40) is C. It runs at the point of the
error, before the stack unwinds, and calls the registered handler. From the
SetText slot, level 1 is the slot, 2 is _ERRORMESSAGE, 3 is that C function
and 4 is the code that failed, so the stack starts at debugstack(4), the
level !BugGrabber's GrabError reads from the same slot.

The text is ClassicAPI's ScrollingEditBoxTemplate with a MinimalScrollBar.
The bottom row has Reload UI, arrows that wrap around the index label, and
Close. /luaerrors opens or closes the window and /luaerrors clear empties
it; the old on/off handler install is gone.

Not tested in game yet.
2026-11-01 15:15:52 -06:00
Brues 99f72aa631 feat(ui): UI_SCALE_CHANGED event
Fires (no payload) each time the game applies the interface scale to
UIParent.

In 1.12 every scale change goes through one function, SetUiParentScale
(0x00494550, void __stdcall(float), ret 4). It raises the value to 0.64,
calls SetScale with force (0x0076AC10) and drains the layout queue
(0x00768ED0), and does nothing while UIParent (0x00B4B44C) is null. It
has six callers:
- the uiScale cvar callback (0x00490770, two sites)
- the useUiScale cvar callback (0x004907F0, two sites)
- OnDisplaySizeChanged (0x00492E90) when useUiScale is on
- the automatic scale ApplyAutoUiScale (0x00492F70)

One MinHook on it fires the custom event after the original returns,
only while UIParent is set. The game UI's Initialize stores UIParent
(0x00490077) and its Shutdown clears it (0x00490CA4), so the event never
fires on the glue screen. Each path calls the setter once, so one change
fires one event; a resize fires it before DISPLAY_SIZE_CHANGED.

Tested in game: useUiScale on, uiScale 0.8 and useUiScale off each fired
one event with the new scale (0.91, 0.8, the automatic 0.9).
2026-11-01 14:38:33 -06:00
Brues 9e5c0c558a feat(autocomplete): C_AutoComplete name completion (API only)
Adds C_AutoComplete.GetAutoCompleteResults, C_AutoComplete.IsRecognizedName,
Enum.AutoCompleteEntryFlag and Enum.AutoCompletePriority, and the cvars
autoCompleteResortNamesOnRecency, autoCompleteWhenEditingFromCenter and
autoCompleteUseContext. ClassicAPI only answers the queries; an addon draws
the list of names.

1.12.1 keeps no autocomplete list, but it keeps every list the names come
from, so each query reads them where the client keeps them:
- party: the four GUIDs at VAR_PARTY_GUIDS (0x00BC6F48), names from the
  name cache, online from the member stats block
- raid: the VAR_RAID_MEMBER_PTRS slots (0x00B712A8) and their online bit
- guild: the GuildMemberInfo roster at 0x00B72704, GUID at +0x00 and the
  online word at +0x48 (stored at 0x004D0C22, tested by GetGuildRosterInfo
  at 0x004D13C5). Read only while the player's guild key is set, the test
  IsInGuild (0x00516DE0) makes, because the client keeps the old roster
  after a /gquit
- friends: the 50 slots of the social singleton

Whisper partners are the one list the client does not keep. The chat
dispatch hook (FUN_0049A870) reports each whisper and whisper inform
(types 6 and 7, not the addon language -1) with the sender GUID it already
resolves. Logging out clears the list.

Names match case-insensitively by UTF-8 code point; Text::Utf8::CompareI
now takes a character limit. Results come most recent whisper first, then
by name. A result's priority is its closest tie: friend, guild, group,
whisper.

GetAutoCompletePresenceID and GetAutoCompleteRealms are constant answers in
Compat.lua. The three cvars are on a new Social page of the options panel.
2026-11-01 14:15:25 -06:00
Brues b3b950f2d8 feat(spell): spell alerts and proc glows (API only)
Adds C_SpellActivationOverlay.IsSpellOverlayed (and the global
IsSpellOverlayed), Enum.ScreenLocationType, and the four
SPELL_ACTIVATION_OVERLAY_* events. Nothing is drawn: an action-bar or
UI addon draws the alerts and the glows from these.

Glows come from the engine's own reactive-spell list. AddSpellToBook
puts every learned spell whose record has AttributesEx4 0x200 on the
list at 0x00B71150 (count 0x00B7114C), one 8-byte {spellID, usable}
entry per name. FireUsableEvents (0x004B31C0) retests the list with
the engine's usable check, stores each answer, then signals
SPELL_UPDATE_USABLE (0x107). The module subscribes to that signal
through Event::SignalHook and reads the stored answers, so the verdict
is the engine's (rage, target health, aura states, and the hidden
combo point behind Overpower) and no new hook is needed. A usable entry
glows every spellbook spell with the same name, which is the list's own
identity rule, so a bar slot with any rank glows. On stock 1.12.1 this
is Execute, Overpower, Revenge, Riposte, Counterattack, Mongoose Bite
and Hammer of Wrath; Turtle's Spell.dbc changes the set by itself.

Alerts come from two Blizzard rows whose aura exists unchanged in 1.12:
Shadow Trance (Nightfall art, glows Shadow Bolt through its class mask
via Spell::Lookup::IsFitToFamily) and druid Clearcasting
(Natures_Grace art). An Aura::Source observer reconciles on the
player's aura add/remove, rescanning the descriptor so counts can't
drift. The texture is a path; 1.12 has no file data IDs.

On world entry, HIDE fires with no spell ID and every active alert
fires SHOW again, so a /reload redraws them. Glows already lit before a
/reload do not re-fire; a button reads IsSpellOverlayed when it draws.
Logging out clears the state without events.

Event::EnteringWorld gains an observer list, because it claims
PLAYER_ENTERING_WORLD in the signal chain and later subscribers never
see it. Event::Custom's reservation cap goes from 96 to 128: the four
new events filled the old cap exactly, and a name past the cap is
dropped silently.
2026-11-01 13:35:12 -06:00
Brues 5dcf10a9fd fix(player): name cache keeps one copy of each player and honors renames
The persistent name cache copied every name reply into its own map, so
each player the engine named this session was held twice. The copy also
went stale. On a rename the server sends SMSG_INVALIDATE_PLAYER (0x31C)
and HandleNameInvalidate 0x00555600 frees the engine's record, but our
copy kept the old name, and GetPlayerInfoByGUID / UnitNameFromGUID fell
through to it.

The store now holds only the players the engine's name cache
(0x00C0E228) does not:

- A Net::PacketDispatch subscriber reads SMSG_NAME_QUERY_RESPONSE (0x51)
  in the same order as HandleNameQueryResponse 0x005551A0 and drops our
  entry just before the engine stores its record. This replaces the hook
  on NameCacheStore 0x0055F310. The backstop save runs before the drop,
  so no save misses the player.
- The same subscriber drops our entry on SMSG_INVALIDATE_PLAYER.
- The engine empties its cache only at world entry: NameCacheClear
  0x00560400, called from CGGameUI::InitializeGame through 0x00555740.
  A hook there copies the engine's records into the store first, while
  the store still holds the realm they came from.
- At exit the engine frees the cache from its atexit handler
  (NameCacheSave 0x0055EC70). Storm's exit handler 0x0064CC30 runs every
  atexit handler before ExitProcess, so DLL_PROCESS_DETACH was too late
  to read it. At the first world entry the clear hook registers our save
  with the exe's own _atexit (0x00409AEF). Handlers run newest first, so
  ours runs before the engine's. Detach now calls Shutdown, which never
  reads the engine's cache.
- A save writes our store, then the engine's records, so a load applies
  the engine's data over ours.
- C_PlayerCache.RememberPlayer leaves alone a player the engine holds.

One hook removed (0x0055F310), one added (0x00560400).
2026-11-01 13:16:56 -06:00
Brues 3ef6c4ffed fix(player): name cache keeps each player's realm and whole UTF-8 names
The persistent name cache cut names at 16 bytes. A Chinese character is
3 bytes in UTF-8, so a 6-character name lost part of its last
character. The limit is now the engine's own: the name cache store
(0x0055F310) copies the name with SStrCopy(dst, src, 0x30) and the
realm with SStrCopy(dst, src, 0x100), now PLAYER_INFO_NAME_SIZE and
PLAYER_INFO_REALM_SIZE in Offsets.h. A longer name from Lua is cut on
a UTF-8 lead byte, never inside a character.

Entries are now keyed by GUID, the engine name cache's own key, and
keep the record's server name (+0x30, empty for a player on this
realm). The by-name index uses the form the engine itself shows for a
player from another realm: ClientChatHandler (0x0049D560) builds the
sender as name, "-", and the realm with spaces and tabs removed. A
bare name stands for this realm. Player::FullName builds and parses
that form. Two players with one name from two realms no longer replace
each other, C_PlayerCache.GetPlayerInfoByName takes "Name-Realm" (a
chat sender name as it comes), and the persistent fallbacks of
GetPlayerInfoByGUID and UnitNameFromGUID return the stored realm. A
"Name-Realm" skips the by-name search and the friends list, which keep
no realm. The engine name cache walk moved from Unit::Identity into
Player::Info, its one caller.

The cache is one flat file per realm,
WTF\ClassicAPI\NameCache-<realm>@<realmList>.txt, in a v5 format split
on tabs (a realm can hold spaces): guid, name, realm, class, race, sex.
The v4 file under WTF\ClassicAPI\<realmList>\<realm>\ is migrated on
first load and deleted, with its emptied folders, once v5 is saved.
The old import from the per-account path is gone.

The store loaded once per process and saved to whatever realm was
current, so logging out into another realm wrote the first realm's
players into the second realm's file. It now records the file it
holds: every access compares that with the current realm's file, and
on a change saves the old realm's entries to their own file before
loading the new one. A failed rename leaves the cache dirty for the
next flush.

Verified on Octo: all 63,178 N'Zoth v4 entries migrated unchanged,
13 of 13 C'Thun entries migrated, the v4 folders were removed, and a
N'Zoth to C'Thun switch in one session kept the two files apart.

Fixes #119
2026-11-01 12:34:31 -06:00
Brues 1ef8c5597a fix(player): UnitNameFromGUID returns the name cache's realm
CGUnit_C::GetUnitName (0x00609210) takes a realm out-pointer as its
stack argument, not the flags word Offsets.h called it. For a player
whose name-cache record has a non-empty server name (record +0x30) it
stores a pointer to that string, and otherwise leaves the pointer alone
(0x0060926B-0x00609283). Vanilla's Script_UnitName (0x00517020) passes
&realm there and returns it as UnitName's second value. The record's
server name is the realm string SMSG_NAME_QUERY_RESPONSE carries:
Tortoise sends it empty, a cross-realm server fills it.

UnitNameFromGUID pushed a hard-coded "" instead. NameFromGuid now passes
the out-pointer through, as Script_UnitName does, and gains a step
between the friends list and the persistent cache: the engine name
cache record itself (FUN_PLAYER_INFO_LOOKUP with no callback, a passive
peek), which also gives the realm for players out of range. Before, a
player who whispered from far away resolved only with the persistent
cache on. UnitSpellTargetName shares NameFromGuid and finds those
players too. The realm is "" when the record has none, or when the name
came from the friends list or the persistent cache, which stores no
realm. Retail documents both returns as non-nil strings.

GetNameAndServerNameFromGUID in the addon's PlayerUtil returned
GetRealmName(), the local realm, for every GUID. It now returns
GetPlayerInfoByGUID's realm, as retail's UnitUtil does, so a player on
the local realm gets "" and code that appends "-" .. realm only when it
is non-empty works.

Seven files declared their own function-pointer type for 0x00609210,
all with the wrong int *outFlags argument. Unit::Identity::NameForUnit
is now the one wrapper. The other six callers pass no realm and behave
as before.

Offsets.h gives the getter's real signature (RET 4) and what it stores.
API.md's GetPlayerInfoByGUID and UnitNameFromGUID entries describe the
realm and the four sources.
2026-11-01 11:05:24 -06:00
Brues 9b0aa46ff0 fix(tts): a stopped utterance no longer finishes the next one
StopSpeakingText purges SAPI's queue, but the purge still queues
START/END events for the streams it cut off, and SAPI numbers streams
from 1 again afterwards. The next SpeakText then reused stream 1, so
the stale events were read as the new utterance's:
VOICE_CHAT_TTS_PLAYBACK_STARTED and _FINISHED fired for it before it
played, and its real end fired a FINISHED with utteranceID 0.

The purge is synchronous and has queued all of those events by the
time it returns, so StopAll now drains and discards them right after
it. That covers stop and speak in the same Lua call too, where the
stale START is still queued and a "require START first" check on the
END handler would not have been enough. An END for a stream we do not
track no longer fires FINISHED with utteranceID 0. Purged utterances
now signal nothing, which is retail's contract.

Verified with a standalone SAPI probe: speak/stop/speak, with and
without a message pump between the calls.
2026-11-01 00:00:27 -05:00
Brues 84171bef2d fix(unit): mirror timers clear on leaving the world, empty slots say UNKNOWN
The engine's handler for the three mirror-timer SMSGs (0x005E7990) only
reads each packet and signals MIRROR_TIMER_START / _PAUSE / _STOP, so
GetMirrorTimerInfo and GetMirrorTimerProgress read a table of our own.
A MinHook on that handler fed it by reading the packet and putting the
cursor back. The table now reads START (0x1D9) and STOP (0x1DB) as a
Net::PacketDispatch subscriber, and the hook is gone.

CGPlayer_C::Release (0x005E9AA0) runs StopMirrorTimers (0x005E9B90) for
the active player: MIRROR_TIMER_STOP for all three types, with no
packet. The table never saw it, so a breath timer stayed after a logout
or a loading screen. Release then calls LeaveWorldUI (0x00490A80),
which fires PLAYER_LEAVING_WORLD through the no-arg dispatcher. An
Event::SignalHook subscriber clears the table on that event.

What the functions return:

- GetMirrorTimerInfo returns an empty slot as "UNKNOWN" with zeros, the
  name helper 0x005E7AE0 gives for no type, instead of nothing. Code
  that tests for "UNKNOWN" on PLAYER_ENTERING_WORLD called its setup
  with nil before. paused is a number, as the event's %d is, and
  spellID is a seventh value.
- A PAUSE packet no longer changes the table. Tortoise never sends one:
  it resends START with the paused byte, which the table keeps.
- GetMirrorTimerProgress is value + scale * elapsed with no clamp. A
  timer started paused holds its value. Names match through SStrCmpI,
  and a name that is not a timer type returns nil.
- The time comes from Time::Clock. 0x0042B790 truncates the tick
  itself and returns it in EAX, so the GetTickCount workaround for an
  FPU return had no cause.

Offsets.h drops the CDataStore readers, the handler and the PAUSE
opcode, which nothing reads now, and its comment gives the verified
signatures and packet layout. Both functions get API descriptors under
the MirrorTimer system. API.md's section is rewritten to match.
2026-10-31 23:34:39 -05:00
Brues f19f13d44c feat(debugtools): fold pfDebug in as /profiler
pfDebug was a separate addon that profiled through ClassicAPI's own
APIs: GetAddOnMemoryUsage and GetAddOnCPUUsage, GetFrameCPUUsage,
GetFunctionCPUUsage, GetEventCPUUsage, and the intrinsic precall and
postcall script hooks. Its code now lives in ClassicAPI_DebugTools
under Profiler/, with its MIT notice (Copyright (c) 2018 Shagu). The
analyzer tabs (Addons, Frames, Events, Globals) and the widget are
pfDebug's, with the frame and template names renamed to
ClassicAPIProfiler*.

/profiler and /prof load DebugTools on demand through the same stub as
/dump, then toggle the analyzer. The subcommands widget, reset and
profile on|off are pfDebug's.

What changed in the move:

- The sampler starts only when the widget or the analyzer first opens,
  so a load for /dump costs nothing. It first takes every counter as it
  is, so the first CPU/s and KB/s cover one second, not the session.
- Whether profiling runs is probed, not read from the cvar: a function
  is called once and GetFunctionCPUUsage must count that call. The
  scriptProfile cvar can already hold the next session's value.
- A frame record starts at the frame's current CPU time and call count,
  and opening the analyzer takes every frame and event counter again,
  because the frame totals and the deep hooks keep counting while it is
  closed. pfDebug started records at 0, so the first scan and each
  reopen showed a frame's whole session as one second.
- The /fstack lines (owner, OnUpdate and OnEvent owner and CPU, all
  scripts, deep allocations) are added at the end of
  FrameStackTooltip_OnTooltipSetFrameStack, not by hooking
  OnTooltipSetFrameStack.
- No pfUI skin and no SavedVariables. The plain backdrop is pfDebug's
  own fallback. The widget no longer opens at login, because that would
  load DebugTools at login.

README's Debug tools table gains /profiler and the new /fstack lines.
2026-10-31 23:16:01 -05:00
Brues 4509da0966 build: pack each embedded addon into one gzip stream
Each embedded file was its own gzipped RCDATA resource, 175 in all.
Every stream started with an empty deflate window, so no file could
match text it shares with its neighbours, and 97 of the files are under
4 KB, where that costs the most. Each resource also cost about 50 bytes
of resource directory and padding.

cmake/pack_files.cmake, which replaces gzip_file.cmake, now joins each
addon's files at build time into one gzip stream. It holds an index,
each file's size as a decimal line and then an empty line, followed by
the files' bytes in path order. `cmake -E cat` joins them byte for
byte, in batches of 100 so the Windows command-line limit cannot fail
the build. The index goes through file(CONFIGURE NEWLINE_STYLE UNIX)
because file(WRITE) writes \r\n on Windows. The resources are now three:
Bindings.xml as a pack of one, !!!ClassicAPI, and ClassicAPI_DebugTools.

The manifest entries become {path, resourceId, index}, and
Embed::Resource(id) becomes Embed::File(id, index). On first use it
inflates the whole pack with the engine's zlib, splits it into views,
and keeps it for the life of the DLL, as before. A pack whose sizes do
not cover its bytes exactly is rejected. The load-on-demand DebugTools
pack is still inflated only when it loads.

Embedded data 323,947 -> 272,825 bytes; .rsrc 334,336 -> 274,432;
DLL 1,632,768 -> 1,573,888 bytes.
2026-10-31 23:06:40 -05:00
Brues 38d5c6c7b6 fix(unit): ClassicAPI's unit events reach markN and the soft tokens
ClassicAPI fires its own unit events (UNIT_AURA_DELTA, the remote
UNIT_SPELLCAST_*, the power, group-member, distance, incoming-resurrection
and targetable events) once per token through
Unit::Identity::TokensForGUID. That took the engine's tokens from its
reverse map FUN_UNIT_TOKENS_FROM_GUID (0x00515C50) and then added its own
short list: focus and nameplateN. The engine's unit events get their
ClassicAPI tokens from a second list in Unit::TokenEvents, which also has
markN, softenemy and softfriend. So a soft enemy got UNIT_HEALTH but never
UNIT_SPELLCAST_START.

That list is now the public Unit::TokenEvents::TokensOf, capped at the
caller's room and empty before the player object exists, as before.
TokensForGUID appends it to the engine's tokens, so both kinds of event
reach the same tokens, and a new token goes in one place. nameplateN now
comes before focus, the order TokensOf already used.

API.md's soft enemy and soft friend section now says that their tokens
fire unit events, as the focus, nameplateN and markN sections do.
2026-10-31 22:46:51 -05:00
Brues be5ebd4017 docs(unit): name softenemy and softfriend in the token comments
44f9bcd added softenemy and softfriend to Unit::TokenEvents, but the
comments that list its tokens still named only focus, nameplateN and
markN. They now name all five. The call-site mismatch log no longer
lists them at all: it says ClassicAPI tokens get no engine unit events.

The token resolver's header in TokenExtensions.cpp had drifted too. It
said markN unit events go through TokenObserver (TokenEvents fires them
now), spoke of "both" families where there are six, and gave a
resolution order of focus then nameplate. The order in the hook is raw
GUID, focus, the soft targets, nameplateN, markN. UnitCreatureID's
comment now points at that header instead of keeping its own list.

UnitTokenFromGUID's comment called the soft targets' place in retail's
order unverified. Retail's list puts them last, as the code does, and
has no markN, so the comment now says markN is never returned.
2026-10-31 22:37:48 -05:00
Brues 44f9bcd9af feat(unit): softenemy and softfriend fire unit events
Unit::TokenEvents already redirects the 38 broadcast and 4 combat
feedback call sites so a unit holding focus / nameplateN / markN gets
every engine unit event under that token. softenemy and softfriend join
that token list, read through SoftUnit::Current (the last pick, no scan)
because TokensOf runs on every unit event.

The registration bit (UNIT_SCRIPT_REG_TOKENS) is what makes the engine
watch a unit, so SoftUnit::Refresh re-runs FUN_UNIT_SCRIPT_RECOMPUTE
through TokenEvents::Refresh for the old and new guid before the
PLAYER_SOFT_*_CHANGED event fires. The frame tick now picks every frame
rather than only while that event has a listener: a UNIT_HEALTH listener
never registers for it, and the pick is cached per scene drain anyway.
2026-10-31 22:25:18 -05:00
Brues f9ec92014c feat(target): show softenemy and softfriend names as the target's
Retail shows a soft unit's overhead name the way it shows the target's:
a soft enemy past nameplate range keeps its name, whatever the UnitName
cvars say.

1.12 decides an overhead name in CGUnit_C::ShouldShowName (0x6070A0),
which its only caller, the name draw PLAYERNAMEDESC::UpdatePlate
(0x6C6EE2), asks every frame. In order: no name when CanSelect (vtable
+0x58) refuses the unit, or when it has a nameplate (+0xE60) or a chat
bubble (+0xE64); the active player by UnitNameOwn; the target always;
then the UnitName mask. A hook gives a soft unit the target's branch:
the first three tests, then 1. The player is never a soft unit, so the
own-unit test does not apply. In nameplate range the plate shows and
the name does not, as for the target.

The name draw runs inside the scene (the model draw callback 0x6C7780),
so the hook reads the soft GUIDs cached on the UI frame tick, as the
selection circle does, and never picks there. Nothing invalidates: the
verdict is asked fresh each draw.
2026-10-31 20:38:51 -05:00
Brues 264af04ee3 feat(cooldown): countdown formatter and C_StringUtil.CreateNumericRuleFormatter
Addons can now choose the countdown text and its color for each range of
time left. Cooldown:SetCountdownFormatter(formatter) and
GetCountdownFormatter() take a NumericRuleFormatter from
C_StringUtil.CreateNumericRuleFormatter(). Its breakpoints each give a
threshold, an optional step, rounding (Enum.NumericRuleFormatRounding),
min and max, a format string, and optional div/mod/step components, one
for each format argument. Color codes in the format string pass through.

The formatter is a full Lua userdata, the first in the DLL. Its block
holds a pointer to a reference-counted native object. Every Lua object
and every frame that uses it hold one reference, so the GC finalizer
(__gc) and the frame's deleting destructor can run in any order.
lua_newuserdata (0x6F4560, __fastcall, block at Udata + 0x10, read from
the disassembly) is new in Offsets.h. lua_touserdata reuses
FUN_FRAMESCRIPT_GET_OBJECT, which is that function. One metatable per
Lua state lives in the registry. Its __metatable is false, and the
identity check compares a value's metatable with it. 1.12 has no
newproxy, so the only finalizer an allocation can run is ours. Thus no
addon code can change a formatter while it formats.

The text goes through 1.12's own str_format (0x7FD3D0). Its closure is
made once per state and kept in the registry, because FormatNumber runs
from addon timers and lua_pushcclosure allocates on each push. The
rounding is x - fmod(x, step), plus a step away from zero when the
rounding needs it. Only that arithmetic matches retail to the last bit:
Up of 0.7 by 0.1 gives 0.7000000000000001 and of 0.6 gives 0.6, but
ceil(x / step) * step gives 0.6000000000000001.

The cooldown shares the formatter and gives it the unrounded seconds
left. The formatter replaces the built-in text and both thresholds, and
every gate stays. The cached countdown text is now a heap std::string,
because a formatter's text has no length limit. The frame frees it, and
its reference, in DeletingDestructor.

A probe addon recorded 229 results in retail 12.1.5. The same probe on
1.18.1 matched 197 of them. The other results come from 1.12's own
str_format (%e exponent digits, %q, %d past the int range, its error
wording), from field storage on the object (not ported), and from
addresses and times.
2026-10-31 18:50:09 -05:00
Brues 2063507fb4 fix(lua): ported files read wipe from the ClassicAPI mirror
The global wipe is ours only until an addon declares its own: whoever
writes _G.wipe last wins, with no error. RegisterGlobalFunction mirrors
every global it binds into _G.ClassicAPI at registration, before FrameXML
and every addon load, so ClassicAPI.wipe is always our table.wipe C
function.

The nine embedded files that call the bare global now take
`local wipe = ClassicAPI.wipe;` and list it as their difference from
retail: ScrollBoxListView, ScrollUtil, Menu, CallbackRegistry, MixinUtil,
SecureTypes and the three DebugTools table inspector files. Files that
read table.wipe, as their retail sources do, are unchanged. The global
stays for third-party code.
2026-10-31 16:50:14 -05:00
Brues 090cfc3bfd feat(lua): global wipe, the table.wipe alias
On a stock 1.12.1 client, ScrollBoxListView, ScrollUtil, Menu and the
DebugTools table inspector failed with "attempt to call global `wipe'":
those ports call bare wipe(), and only Turtle's client defines that
global. Stock 1.12.1 has only the table.wipe that ClassicAPI registers.

3.3.5 makes the alias in FrameScript's compat preamble, next to tinsert
and tremove (wipe = tab.wipe). 1.12's preamble is older than table.wipe,
so Wipe.cpp now registers the same C function as the global wipe as well
as table.wipe, with a LuaGlobals descriptor.

CallbackRegistry, MixinUtil and SecureTypes lose their "1.12 has no
global wipe" notes and their table.wipe locals, and now match retail.
2026-10-31 16:44:34 -05:00
Brues d1cdb47c3e feat(unit): restore UnitFacing
UnitFacing(unit) returns the unit's facing in radians (0 = north,
counter-clockwise, 0..2pi), or nothing when the token has no unit.

The facing comes from the object's GetFacing virtual, vtable slot 6.
The unit and player vtables (0x80C4F8 / 0x80AF78) both hold
CGUnit_C::GetFacing (0x5F1F30) there, read from the binary: it loads the
mover at +0x118, pushes its facing +0x1C and returns
CMovement::TransportFacingToWorld (0x7C4AE0), which adds the transport's
facing and wraps once into [0, 2pi]. Unit::Position::Facing wraps the
call next to the shared position read.

GetPlayerFacing moves to the same read. It used to load the float at
+0x9C4 (the mover's +0x1C) directly, which is the facing relative to the
transport, so on a boat or zeppelin it returned the wrong angle. The
OFF_UNIT_FACING offset goes with it.
2026-10-31 16:25:24 -05:00
Brues 237e5a7500 fix(container): sort gear by equip slot before item class
The bag sort compared item class before the equip slot rank. Shields,
held-in-off-hand items and relics are armor class (4), so they left the
weapon group and sorted first in the armor block, before head pieces.

For gear, the slot rank (Item m_inventoryType through kSlotOrder) now
comes first and item class is the next tiebreak. Those items now sort
with the weapons, where the slot order puts them. Non-gear items sort
the same as before.
2026-10-31 15:24:24 -05:00
Brues d339f4486d refactor(swing): reset on weapon swap from the inventory-slot observer
A weapon equipped in combat resets that hand's swing timer on the
server, with no packet for it. PLAYER_SWING used to catch this by
reading the main-hand, off-hand and ranged GUIDs from the player's
inventory manager (player+0x1D38) on every WorldTick and comparing
them with the previous frame.

The engine already reports these changes. CGPlayer_C::AddActiveWatchers
(0x005DD8A0) registers OnInventorySlotChanged (0x005DDCF0) on every
equipment slot, and the descriptor dispatcher calls it only when a
slot's GUID really changes. Player::Equipment already hooks that
handler for PLAYER_EQUIPMENT_CHANGED. It now also calls
Combat::Swing::OnWeaponSlotChanged, which resets the hand for slots
15/16/17 when the player is in combat.

The call runs inside SMSG_UPDATE_OBJECT, after the values block is
written, so the attack-time descriptor field already holds the new
weapon's speed. The lookups it makes (player by GUID, descriptor
reads) do not throw.

The per-frame poll and its snapshot state are removed. The WorldTick
subscriber stays: it still merges several resets in one frame into
one PLAYER_SWING per swing type.
2026-10-31 13:50:41 -05:00
Brues 92025896b5 feat(options): a control for every cvar, one cvar per control
The AddOn Options panel now has a control for each of the 38 cvars that
ClassicAPI registers, plus the player cache, on pages grouped by
feature: Interact Key, Action Targeting, Combat, Bags and Items,
Interface, Text To Speech and Advanced.

Each control reads and writes exactly one cvar, and writes it only when
the player uses that control. The "Interact Key Icons" preset dropdown
is gone: it wrote four icon cvars at once, and Cancel restored it from
a snapshot that read any mixed state as "NPCs Only", which turned
SoftTargetIconInteract back on. Okay, Cancel and Defaults no longer
write anything on these pages.

- On/off cvars are checkboxes. The soft-target enable cvars read as on
  for 2 or 3 and write 3 or 0; 1 is gamepad, which does nothing here.
- Fixed choices (the three arc cvars, SoftTargetForce) are dropdowns. A
  value outside the choices shows as its number.
- Open-ended numbers (ranges, icon size, shake strength, combat log
  retention) are number boxes, so the panel sets no maximum. Sliders
  are kept for documented ranges only.
- Each page's controls sit in a WowScrollBox with a MinimalScrollBar.
  The box clips its children, and the clip also keeps the mouse off
  controls that are scrolled out of view.

The panel's strings are globals in locales\enUS.lua, which every client
loads: CLASSICAPI_* for ClassicAPI's own, and retail's GlobalStrings
keys where retail has the string (COUNTDOWN_FOR_COOLDOWNS_TEXT and its
tooltip, with retail's translations in the other locale files).

The README cvar table gains the five cvars it was missing.
2026-10-31 12:58:22 -05:00
Brues 4eb214f7f1 fix(tick): run world-tick subscribers after the UI draw
CWorld::BeginFrame (0x0066FD50), the world tick, runs inside the UI
strata walk. Its only caller is CGWorldFrame::OnRender (0x00482EA0),
which WorldFrame's layer-0 draw callback (0x00482D70) runs. By then
0x007657D0 has already rebuilt the WORLD stratum's level batches. The
levels above 0 are not drawn yet. Nameplates sit at level 1, with
their name and level text on OVERLAY.

A batch's text run keeps a raw pointer to its font. The run's draw
(0x005C8FE0) uploads all eight of that font's atlas pages, even when
no strings are left in the run. Every subscriber ran at the hook, and
several of them run Lua (NAME_PLATE_CREATED, the C_Timer callbacks).
An addon that restyled a plate there made the plate leave level 1 and
gave its font strings a new font. The old font was destroyed while
the built batch still pointed at it. The upload then read a freed
texture's format and faulted at 0x005A09B8.

The crash dump shows this state:
- Level 1 has no frames.
- Its dirty mask is 0xFFFFFFFF, and the stratum's flags are set again.
  Both were written after the rebuild.
- Its one OVERLAY run is empty, and its font's memory holds Lua table
  nodes.

The hook now only marks that the world ran. The subscribers run from
the UI render root's tail (Tick::FrameTick), after the strata walk,
so the engine rebuilds anything they change before it is drawn.
OnUpdate and packet events still run in the same order relative to
them. GetTickTime() also reads the same pass delta as before.

Fixes #116
2026-10-31 06:06:38 -05:00
Brues 706fb1c540 docs(readme): refresh the function, namespace and event counts
The intro and the Lua-calls summary still said 550+ functions across ~60
namespaces and 50+ events, the counts from 54516ad. Counted the same way
(backticked calls in the Lua-calls table, rows in the Events table), the
README now lists 980 calls under 75 namespaces and 86 events, so the
rounded-down figures become 950+, ~75 and 80+.
2026-10-31 05:22:52 -05:00
Brues e37052c67d feat(lua): SharedColorConstants
Port of retail's Blizzard_SharedXML/SharedColorConstants.lua, in
Blizzard's own text. It loads after ColorUtil.lua, when Color.lua has
already made the GlobalColor globals it reads, and adds
PLAYER_FACTION_COLORS_HEX, PLAYER_FACTION_BRIGHT_COLORS (the PvP
scoreboard colors) and COVENANT_COLORS.

FACTION_BAR_COLORS now holds retail's color objects in place of 1.12's
plain {r, g, b} tables. The stock code that reads it (the unit tooltip
and the reputation bars) reads only .r, .g and .b, which the objects
have; the values differ by less than 1/255.

PLAYER_FACTION_COLORS moves out of Constants.lua, because retail
defines it only here, and GetFactionColor goes back to retail's
one-line body without our nil guard.

Verified in game: FACTION_BAR_COLORS[1] is ffcc4d38 and [5] is
FACTION_GREEN_COLOR, PLAYER_FACTION_COLORS_HEX[0] is ffff2934,
PLAYER_FACTION_BRIGHT_COLORS[1] is ff00adf0, GetFactionColor("Alliance")
is ff00adf0, and COVENANT_COLORS.Kyrian is ff2aa2ff.
2026-10-31 05:14:36 -05:00
Brues f8f6dc28b8 feat(ui): GlobalColor table from build 1.60.1.70707
C_UIColor.GetColors now returns the GlobalColor.dbc rows of build
1.60.1.70707 instead of the BC Classic 2.5.5 snapshot: 331 rows where
there were 187, in the table's own row order.

- 148 new colors, among them PVP_SCOREBOARD_*_CELL_COLOR, the
  EXPANSION_COLOR_* set, and the profession, threat and power-prediction
  colors.
- 10 changed values, as the newer client has them: NORMAL_FONT_COLOR
  ffd200, RED_FONT_COLOR ff2020, PLAYER_FACTION_COLOR_HORDE ff2934,
  PLAYER_FACTION_COLOR_ALLIANCE 00adf0, TALENT_LINK_COLOR 71d5ff, and
  more. The C++ tooltips that read the table (item comparison,
  equipment sets, links) take the new values. Lua globals that 1.12
  FrameXML already defines keep their own values, because Color.lua
  only creates the globals that do not exist.
- The four HEALTHBAR_*_HEAL_PREDICTION_GRADIENT_COLOR* rows are gone;
  the newer table does not have them and nothing read them.
- A name that the DBC lists twice keeps only its last row, which is
  the value the client's own Color.lua loop ends on.
  COMPACT_UNIT_FRAME_FRIENDLY_HEALTH_COLOR and
  CHARACTER_FRAME_SIDE_PANEL_SKILL_COLOR join the earlier duplicates.
- DEBUFF_TYPE_ENRAGE_COLOR stays as our own row, for
  C_UnitAuras.GetAuraDispelTypeColor("Enrage").

Verified in game: GetColors returns 331 rows,
PVP_SCOREBOARD_HORDE_CELL_COLOR is ffff2934 and the alliance one
ff00adf0, GetAuraDispelTypeColor("Enrage") is ffff8c00, and
HEALTHBAR_MY_HEAL_PREDICTION_GRADIENT_COLOR1 is nil.
2026-10-31 05:11:55 -05:00
Brues 4472c81137 feat(lua): retail SharedXML helpers, Spline, ExportUtil and MixinUtil
Ports from retail's Blizzard_ObjectAPI, SharedXML, SharedXMLBase and
FrameXMLUtil, in Blizzard's own text, from the _forever_ build:
- ObjectCache.lua and SpellBookItemLocation.lua (ObjectAPI).
- TypeUtil.lua: ValueToBoolean and StringToBoolean.
- SortUtil.lua: SortUtil.CreateSortManager and the comparators.
- Sequencer.lua, Spline.lua (Catmull-Rom splines), TaggableObject.lua,
  ObjectUpdater.lua and ExportUtil.lua (base64 export/import streams).
- AnimationSystem.lua: SetUpAnimation and CancelAnimations.
- SmoothStatusBar.lua: SmoothStatusBarMixin.
- MixinUtil.lua: DrawLine, LineMixin, SparseGridMixin,
  DoublyLinkedListMixin, DirtiableMixin, DirtyPhaseMixin and more. 1.12
  has no global wipe, so the file reads table.wipe, as the other ports do.

FrameDeltaLerp now takes retail's three arguments and reads the frame
time from GetTickTime(). The old form needed a fourth elapsed argument,
so SmoothStatusBar's per-frame ticker would have errored. Nothing else
called it.

DebugTools drops its private StringToBoolean copy and calls the global
one from TypeUtil for the /framestack arguments.

FadingFrame.lua is not ported: 1.12's own FrameXML defines the same
FadingFrame_* names with no-argument `this` signatures, and ZoneText
calls FadingFrame_OnUpdate() every frame.

Verified in game: StringToBoolean, ValueToBoolean and
SpellBookItemLocation return the expected values, a 2D spline's midpoint
is (5, 10), an export stream round-trips 200 and 9, the sort manager
orders {3,1,2}, the linked list enumerates 0 1 2, ObjectUpdater,
Sequencer and SetUpAnimation finish, a smoothed status bar reaches 100,
and /framestack still opens.
2026-10-31 05:01:37 -05:00
Brues 1e1463cf9a feat(unit): UNIT_IN_RANGE_UPDATE and UNIT_DISTANCE_CHECK_UPDATE
UNIT_IN_RANGE_UPDATE (unitTarget, isInRange). Nothing in the client
reacts to a distance: the player moves every frame while it moves, and
other units in view are moved a little each frame between their movement
packets. So a world-tick pass checks the units, only while a frame is
registered for the event. The pass walks the event's subscriber chain
(Event::Custom::ForEachListener, which FireValues now uses too): a frame
registered with RegisterUnitEvent adds its units
(Frame::UnitEvent::Units), and a plain registration adds pet and each
party and raid member and their pets. A token fires when
Unit::Range::InRange changes for it. UnitInRange now calls the same
function. A filter unit that is not a unit token is dropped after one
protected IsUnitToken check, since the engine's resolver raises a Lua
error for it.

UNIT_DISTANCE_CHECK_UPDATE (unitTarget, isInDistance) is true while the
client holds a group member's object. UpdateRaidMemberRecord (0x004BADC0)
has four E8 callers, which are exactly a player object entering the
object table (CGPlayer_C::PostInit 0x005DEC43, PostUpdate on reactivation
0x005DECAF) and leaving it (the release slot 0x005E9ACF, ~CGPlayer_C
0x005DD6AB for an object never released). The calls point at a site that
notes a party or raid member's GUID (IsRaidMemberOrPet, not the active
player), and the next world tick fires for each of its tokens with
whether the object table holds it then. The release slot runs before the
manager moves the object to the released table, so a fire in place would
still show the member to UnitIsVisible. No loaded DLL references the
function or the sites.
2026-10-31 04:54:35 -05:00
Brues c21e44e086 feat(unit): UNIT_AREA_CHANGED fires when a group member changes zone
DiffPartyRecords (0x005E5510) already sets bit 0x80 when the zone in a
party record (+0x14) differs, but SignalPartyRecordChanges (0x005E5720)
raises nothing for it. The existing SignalSite redirects (the calls in
ReadPartyMemberStats and ComparePartyRecord, and the AddPartyMember tail)
now fire UNIT_AREA_CHANGED (unitTarget) on that bit, after the engine's own
events, once per token of the member.

The server sends the zone only to members that cannot see the player.
A member in view is built with the player's own zone (BuildPartyRecord
stores s_zoneId), the same zone GetRaidRosterInfo gives for a held object,
so ComparePartyRecord sets the bit when a member comes into view from
another zone. CGPlayer_C::PostInit skips ComparePartyRecord for the active
player, so "player" gets no event.
2026-10-31 04:25:36 -05:00
Brues e76616de83 feat(player): PLAYER_MOUNT_DISPLAY_CHANGED fires when the player's mount changes
1.12.1 reacts to a unit's new UNIT_FIELD_MOUNTDISPLAYID in
CGUnit_C::OnMountDisplayChanged (0x005FFA50): dismount from the old
display, mount the new one with the mount-up effect, refresh the loop
sound, quest marker and name. The field's type watcher
OnMountDisplayFieldChanged (0x00604570) resolves the unit and calls it
with the old value from one E8 call (0x0060459B), only on a real change.

That call now reaches a function that runs the handler, then fires
PLAYER_MOUNT_DISPLAY_CHANGED (no payload) when the unit is the player.
VanillaHelpers calls the handler directly after a local mount morph and
patches neither address, so a cosmetic morph fires nothing.
2026-10-31 04:15:13 -05:00
Brues 92794a1822 feat(unit): UNIT_TARGETABLE_CHANGED fires when a unit's selectability flips
1.12.1 reacts to a changed UNIT_FIELD_FLAGS in
CGUnit_C::OnUnitFlagsChanged (0x005FF580), which the flags field's type
watcher OnFlagsFieldChanged (0x006044B0) calls with the old flags from
one E8 call (0x006044DB). It handles looting, disarm, reaction, PvP and
the aura bit, but nothing reads UNIT_FLAG_NOT_SELECTABLE (0x02000000).

Unit::FlagsChanged now owns that call: it runs the handler, then each
subscriber with the unit and the flags before and after, since a call
site can be redirected only once. Unit::Targetable subscribes and fires
UNIT_TARGETABLE_CHANGED (unitTarget) once per token of the unit when the
not-selectable bit flips.
2026-10-31 04:07:26 -05:00
Brues ab0991f054 feat(unit): UNIT_CONNECTION fires when a group member goes on or offline
A party record's status byte (+0x08) carries the online bit
UnitIsConnected reads. 1.12.1 changes it in three places, and none
signals the flip:

- The party member stats packet: ReadPartyMemberStats (0x005E5110) ends
  in SignalPartyRecordChanges (0x005E5720) with the status change bit.
  The redirected call that already adds the generic power events now
  also fires UNIT_CONNECTION (unitTarget, isConnected) when the old and
  new records differ in the online bit.
- The group list (0x7D): the server reports a logoff only there.
  AddPartyMember (0x004E82D0) copies a kept member's previous record and
  sets the online bit, then stops. Its call now diffs the previous record
  against the stored one (DiffPartyRecords, 0x005E5510) and runs the same
  SignalPartyRecordChanges path, so the engine's status events and
  UNIT_CONNECTION follow.
- A raid member outside the player's party has only a raid slot, whose
  status byte RebuildRaidRoster (0x004BA5F0) sets from the same list. Its
  call now compares each returning member's online bit before and after
  and fires UNIT_CONNECTION on a flip; party members are skipped, since
  AddPartyMember ran first.

isConnected is a real boolean, pushed through FireValues.
2026-10-31 03:50:09 -05:00
Brues 26c201b95b fix(event): a named unit field gets a watch-copy slot first
Every unit-field watcher keeps a mirror of its bytes in the object's
watch copy, at the slot UnitWatchedIndex (0x0047DB20) finds for the field
in the table at 0x00839E48. A field not in the table gets 0xB6, past the
unit's 170-slot copy. UNIT_MOD_CAST_SPEED (139), named for
UNIT_SPELL_HASTE, is such a field, so every watcher on it wrote the
field over memory past the copy: a pet's cast-speed update on its
owner's logoff put a 1.0f over a list link, and ClntObjMgrRelease
crashed on the next SMSG_DESTROY_OBJECT (ERROR #132 at 0x00464A02).

The table holds 166 fields and one spare entry (0xB6, the copy's last
slot, which no field asks for); UnitWatchedIndex is its only reader and
nothing writes it. EngineName now writes a field without a slot into
that spare before naming its slot, and refuses the name once the spare
is taken. Field 139 takes it, so UNIT_SPELL_HASTE keeps the engine's
watcher for every unit.
2026-10-31 03:47:17 -05:00
Brues 09693af5b9 feat(unit): UNIT_POWER_UPDATE, UNIT_POWER_FREQUENT and UNIT_MAXPOWER
1.12.1 calls type-wide field observers for every unit whose watched
bytes change: ClntObjMgrSetTypeMirrorHandler (0x00468070) puts a record
on the global list of a (bank, field), and CGUnit_C::RegisterFieldWatchers
(0x006041F0) uses it for the engine's own unit handlers, among them
OnPowerFieldChanged on the power block, which only refreshes usability.

Two handlers join those lists, one record per POWER1..5 and MAXPOWER1..5
slot, so the callback's field offset is the power type. They fire
UNIT_POWER_UPDATE and UNIT_POWER_FREQUENT, or UNIT_MAXPOWER, with
(unitTarget, powerType) once per token of the unit. Every client power
change is a server update, so FREQUENT fires with UPDATE.

Each in-world registration pass removes and re-adds the records through
0x00468240: the first pass of a session runs in CGGameUI::InitializeGame,
after MirrorInitialize (0x0047F840) has rebuilt the tables a record's
copy offset comes from, and the removal keeps /reload from stacking a
second record. The list array has no other writer, so nothing frees a
record behind us.

A group member the client cannot see has no unit to watch; its power
comes in the party member stats packet. ReadPartyMemberStats (0x005E5110)
and ComparePartyRecord (0x005F0B80) end in SignalPartyRecordChanges
(0x005E5720), which fires the per-type power events for the changed
record bits. Both E8 calls now reach a function that calls it and then
fires the generic events for the same bits, the member's and its pet's.
2026-10-31 03:44:28 -05:00
Brues f24b7e594b feat(unit): UNIT_SPELL_HASTE fires when a unit's spell haste changes
1.12.1 turns unit-field changes into events by index: FUN_0051bbb0
watches unit field i when slot i of the static event-name array at
0x00BE1198 is named, and FUN_0051bd50 signals event i once per token of
the unit. UNIT_MOD_CAST_SPEED, the field UnitSpellHaste reads, is index
139 (OFF_UNIT_MOD_CAST_SPEED / 4), and slot 139 has no name, so the
engine never watched it.

Event::Custom::EngineName names slot 139, and the engine's own watchers
fire the event for every token. Nothing else signals 139 by number: it
is above the glue band and not in the chat-type event table.
2026-10-31 02:33:12 -05:00
Brues 35c8ba5688 docs(readme): list UNIT_TARGET 2026-10-31 02:28:00 -05:00
Brues e2a34bd18a feat(unit): UNIT_TARGET fires when a unit's target changes
1.12.1 already turns unit-field changes into events. FUN_0051bbb0
registers a watcher on unit field i for each named slot i of the static
event-name array at 0x00BE1198, and the watcher (FUN_0051bd50) signals
event i once per token of the unit through the broadcast FUN_00515e50.
The field index is the event id. UNIT_FIELD_TARGET is index 10, and the
watcher's size selector still gives it the 8 bytes of a GUID, but slot
10 has no name, so the engine never watches the field.

Event::Custom::EngineName names such a slot. It writes the name into the
static array at load, before any world init or unit watch. World init
copies the array into the live event table (0x0048FEA1), and the
engine's own watchers then fire the event for every token, with focus,
nameplateN, markN and focustarget included. The entry joins the
reservation list with a fixed slot, so it is documented, kept across
/reload, and not counted by Grow.

Slot 10 is in the glue band. Its one by-number signal is the glue
realm-list rebuild (0x0046E73D), which only CGlueMgr::Idle reaches.
2026-10-31 02:25:04 -05:00
Brues 2f787889f7 fix(animation): a frame's alpha group keeps its textures' gradients
When an alpha group starts on a frame, every region below it stores its
vertex alpha, and every update writes that alpha back. The colour getter
(0x77F8C0) reads a gradient back as white, and the setter (0x77F750)
shrinks a gradient's four entries to that one white entry. So a gradient
under a fading frame went flat at the first update and stayed flat after
the group ended. pfUI's easteregg night lost its fade to the bottom.

SetVertexAlpha now writes nothing when the alpha is unchanged. For one
colour entry the setter already does nothing then, so only the gradient
case changes. The frame's fade still reaches the gradient: a change of
the frame's alpha refolds each region's entries (0x77FAC0).

3.3.5's and 4.3.4's PreOnAnimUpdate drop the gradient the same way. A
texture that an alpha animation itself fades still loses its gradient.

Verified in game: a black gradient on a frame fading by -0.5 over 3 s
keeps its gradient during and after the fade.
2026-10-31 01:31:29 -05:00
Brues 9967cc0839 feat(lua): Spell objects, ContinuableContainer and TableBuilder
Ports from retail's Blizzard_ObjectAPI and SharedXML, in Blizzard's own
text, from the _forever_ build (the newest interface code on disk):
- Util/Spell.lua: Spell:CreateFromSpellID and SpellMixin.
- Util/ContinuableContainer.lua: one callback for a group of item and
  spell objects.
- Util/IndexRangeDataProvider.lua: CreateIndexRangeDataProvider, which
  ScrollBoxListView already expected.
- Util/TableBuilder.lua: CreateTableBuilder, for the existing
  ScrollUtil.RegisterTableBuilder.
- FrameXML/CallbackRegistrant: CallbackRegistrantMixin and
  CallbackRegistrantTemplate.
AsyncCallbackSystem.lua gets its ASYNC_SPELL type and SpellEventListener
back, so it is the _forever_ text again.

New in the DLL for SpellMixin:
- C_Spell.IsSpellDataCached, C_Spell.RequestLoadSpellData and
  SPELL_DATA_LOAD_RESULT. Every spell is client data, so a spell that
  exists is cached, and a request fires the event before it returns:
  (spellID, 1), or (spellID, nil) for an ID with no spell. Retail marks
  the event synchronous, and AsyncCallbackSystem relies on that.
- C_Spell.GetSpellDescriptionForItemLocation. The item tooltip expands a
  spell's text with the same arguments GetSpellDescription passes, so it
  returns that text. The item location is still required.

Verified in game: IsSpellDataCached(133) and (999999) are true and false,
Spell:CreateFromSpellID(133) calls back with (133, 1), a container with
item 19019 and spell 133 calls back, CreateIndexRangeDataProvider(5)
has size 5, and GetSpellDescriptionForItemLocation(133, slot 1) equals
GetSpellDescription(133).
2026-10-31 01:26:47 -05:00
Brues e47e881ae1 fix(event): FireValues applies RegisterUnitEvent filters
FireValues walks the subscriber chain itself and runs each OnEvent
through RunSlotWithValues, so it never reached the runner where
Frame::UnitEvent's gate sits. RegisterUnitEvent("UNIT_AURA_DELTA", unit)
fired for every unit's deltas.

Frame::UnitEvent::Accepts shares the gate's match. FireValues asks it
for each frame when the first value is a string, the same rule the
engine path uses for a %s arg1.
2026-10-31 00:21:18 -05:00
Brues d9da100110 feat(tradeskill): linked windows show the crafter's name
3.3.5's interface never shows whose profession a linked window holds.
Port 4.3.4's TradeSkillLinkNameButton and TradeSkillFrame_SetLinkName
to TradeSkillFrame and CraftFrame: "[name]" after the title, and a
click on it starts a whisper. The title moves left by half the name,
from its own anchor, so Turtle's -30 offset stays.
2026-10-30 22:48:40 -05:00
Brues 00cfe0c45b fix(smf): BackFill and RemoveMessagesByPredicate keep the engine's ring
A ring that is not full holds records 0..count-1: the engine's writer
fills them in order from an empty ring, and only a full ring wraps.
ScrollOffset and ScrollToOffset wrap by the count, and ScrollToTop
(0x00788690) lays out from record 0 unless the ring is full, so each one
is right only for that layout.

BackFillMessage wrote the new oldest message to the record before the
oldest, as 4.3.4's addToTop does. In a ring that is not full, that record
wraps to the end of the array. From then on, GetScrollOffset,
SetScrollOffset, ScrollByAmount and ScrollToTop read the wrong offset.
pfUI's chat wheel scrolled up into the backfilled lines and then could
not scroll back down.

RemoveMessagesByPredicate compacted from the oldest's record, so after a
removal from a full ring the kept messages began anywhere in the array.

BackFillMessage now rotates records 0..count by one, so the free record
comes round to 0, and writes the message there. RemoveMessagesByPredicate
first turns the ring so that the oldest is record 0, then compacts. Whole
records move, so every text keeps one owner. Clear leaves a free record's
old text for the writer to free. The extra-argument tables and the scroll
record (OFF_SCROLLINGMESSAGEFRAME_SCROLL_RECORD, +0x348) move with their
records. A scrolled-up frame is laid out again from its moved scroll
record, so the view stays.

A model of the engine's three functions agrees on 377,485 random
add, backfill, remove and scroll states. Not yet verified in game.
2026-10-30 22:36:53 -05:00
Brues d92464b86c feat(tradeskill): trade links follow 3.3.5
Profession links are now built and opened the way 3.3.5's
CGTradeSkillInfo does it. A received link opens in the engine's own
TradeSkillFrame. The ClassicAPI viewer is gone.

The link is 3.3.5's format:
trade:<spellID>:<rank>:<maxRank>:<GUID>:<bits>|h[<spell name>]. The
first field is the profession spell, not the skill line. The GUID
replaces the name. A reader whose name cache does not hold the linker
sends the name query and opens the link when the answer arrives. The
bits cover every SkillLineAbility row of the profession, as 3.3.5's link
run does. 1.12's DBC is sorted by ID and does not group a profession's
rows into one run, so the rows here are taken in ID order. A link opens
only when the reader's row count matches it.

The 1.12 list build (0x004FCA20) is 3.3.5's RefreshList without the
link branch. Three call-site patches add that branch:
- the spell list comes from the link,
- the ability rows use the linker's race and class,
- the rank is the linker's.
Two patches on TryCastSpell's toggle calls record the cast spell (3.3.5's
m_spellID). They also make a cast while a link shows open the player's
own list instead of closing the window. The game shutdown drops the link,
as 3.3.5's does.

Enchanting gets the same in the Craft window. Its rows are filtered by
castUI, because its recipes have no TRADESPELL bit. GetCraftListLink and
IsCraftLinked are the Craft window's twins of the 3.3.5 functions.
GetTradeSkillLine, GetCraftDisplaySkillLine and GetCraftName give the
linker's values while a link shows. Util/TradeSkillLink.lua now ports
3.3.5's linked mode, which hides Create, Create All and the count
controls, and keeps the link buttons.

Any spell whose Effect[0] is TRADE_SKILL can be linked. 1.12's
SkillLine.dbc has no canLink column.

Removed: C_TradeSkillUI.GetTradeSkillListLink, GetCraftListLink and
GetTradeSkillListRecipes. Old links no longer open.

Not yet verified in game.
2026-10-30 22:16:16 -05:00
Brues fec127c1af docs(item): DoesItemExistByID is true for any positive ID
The entry said a cache miss returns false and starts a load. The
function returns true for any positive item ID, cached or not, and
loads nothing. ContinueOnItemLoad depends on that.
2026-10-30 21:47:34 -05:00
Brues 37ee71c663 feat(item): C_PaperDollInfo.GetTemporaryEnchantmentInfo
Retail's per-slot temporary enchant, the function its deprecated
GetWeaponEnchantInfo is now built from. It reads the same temporary
enchant slot as C_Item.GetItemTempEnchantInfo, in retail's shape: an
INVSLOT_ number in, and a TemporaryItemEnchantInfo table out (enchantID,
remainingTimeMs, chargesRemaining, hasExpirationTime), or nothing when
the slot has no temporary enchant. A slot outside 1..19 also returns
nothing, so the engine never sees it.

hasExpirationTime is the descriptor duration gate that the shared reader
already tested before it asked for the time left.

C_Item.GetItemTempEnchantInfo stays as the ClassicAPI extension for a
weapon in a bag (#47). The docs link the two.
2026-10-30 21:20:05 -05:00
Brues 6259e22480 feat(lua): ItemUtil from retail
Util/ItemUtil.lua is now the ItemUtil table of retail
Blizzard_FrameXMLUtil/ItemUtil.lua, in retail's own text. As its header
lists, it leaves out ItemButtonUtil and DoesAnyItemSlotMatchItemContext
(retail drives them from frames this client does not have), the transmog
mixin, the currency path of GetItemDetails, and GetCraftingReagentCount
(reagent and account banks).

New for what it calls:
- Enum.BagIndex in the DLL, with Classic Era's names and this client's
  indices: Keyring -2, Bank -1, Backpack 0, Bag_1..4 = 1..4 and
  BankBag_1..6 = 5..10. Era's bank bags start at 6, after its reagent
  bag. The indices live in Offsets.h now, and container/Search.cpp drops
  its local copies.
- C_PaperDollInfo.GetInventorySlotInfoForInvSlot in the DLL. It takes the
  first PaperDollItemFrame.dbc row with that SlotNumber, and walks the
  rows the way the engine's GetInventorySlotInfo (0x004C81B0) does.
- C_Container.GetContainerNumSlots and C_Container.PickupContainerItem as
  Lua aliases of the stock globals in Compat.lua, next to
  C_ActionBar.ShouldShowKeyring, which is always true.
- ContainerFrame_GetContainerNumSlots and NUM_TOTAL_BAG_FRAMES from
  retail's ContainerFrame.lua, and GameTooltip_SuppressAutomaticCompareItem.
  Nothing reads that flag, because nothing compares items automatically.

docs/API.md covers all of it, plus Item objects and
C_Item.GetItemIDForItemInfo.

Verified in game: Enum.BagIndex.Keyring is -2, the backpack has 16 slots,
ShouldShowKeyring is true, slot 18 returns the ranged row with checkRelic
true, GetEmptyEquipSlotTooltip(1) is "Head", and FilterOwnedItems({6948})
finds a Hearthstone in the bags.
2026-10-30 21:12:37 -05:00
Brues 474da1a5b5 feat(lua): Item follows retail
Util/Item.lua is now retail Blizzard_ObjectAPI/Mainline/Item.lua in
retail's own text, and its listener is retail's AsyncCallbackSystem.lua
with the item and quest listeners. There is no spell listener: every
spell is client-side data here, so there is nothing to wait for. The one
other difference is that GetItemQualityColor reads ITEM_QUALITY_COLORS,
the same entry retail's ColorManager returns.

What follows retail now:
- Load callbacks get (id, success).
- An item callback also runs when the load fails, with success nil.
  TradeSkillLink's two callbacks now stop on a failure, because
  re-rendering would request the missing item again.
- C_Item.RequestLoadItemDataByID runs once per item, not once per
  callback.
- New methods: GetStackCount, ContinueWithCancelOnRecordLoad and
  IsRecordDataCached. ContinueOnItemLoad's usage errors name the bad
  itemID or link, and GetItemGUID returns the GUID an item was made from.

C_Item.GetItemIDForItemInfo is new in the DLL for ItemMixin:GetItemID's
fallback. It uses the resolver that GetItemInfoInstant uses.

Verified in game: Item:CreateFromItemID(19019) calls back with
(19019, 1), and C_Item.GetItemIDForItemInfo("item:6948") returns 6948.
2026-10-30 21:12:22 -05:00
Brues 6cee968a8a refactor(lua): ItemUtil.lua is Item.lua
The file holds retail's Item mixin (Blizzard_ObjectAPI/Mainline/Item.lua)
and its ItemEventListener, not retail's ItemUtil, so it takes Item.lua's
name. No code changes.
2026-10-30 21:12:07 -05:00
Brues 635c74d518 fix(smf): ScrollByAmount stops where ScrollUp stops
ScrollByAmount moved the offset straight to the clamped target, and the
clamp is retail's count - 1, where the oldest message sits alone on the
bottom line under empty space. 1.12's own ScrollUp (0x788610) stops
sooner, at +0x350 (4.3.4's m_atTop), which the layout sets once every
message back to the oldest fits the height (0x788A37). A chat wheel
built on ScrollByAmount (pfUI's) scrolled chat up until it was nearly
empty.

The engine has two scroll families, in 1.12 and 4.3.4 alike: ScrollUp
and PageUp stop at m_atTop, and ScrollToTop and SetScrollOffset go to
the oldest record. Retail defines ScrollUp as ScrollByAmount(1) and
ScrollToTop as SetScrollOffset(GetMaxScrollRange()), so ScrollByAmount
now steps the engine's ScrollUp / ScrollDown (0x788610 / 0x788650), the
loop PageUp (0x7885B0) runs, and SetScrollOffset / GetMaxScrollRange
keep count - 1. The clamped target still bounds the steps: a hidden
frame never lays out, so its ScrollUp never finds the top and wraps.

Verified in game: ScrollByAmount(1000) on ChatFrame1 stops with the
oldest message on the top line and an offset below GetMaxScrollRange().
2026-10-30 19:56:21 -05:00
Brues 260d40f49b fix(frame): let the engine runner parse script formats
Frame::ScriptArgs carried its own copy of the script runner's format
switch (0x704F10, %d/%u/%f/%s) so it could pass positional args.
SuperWoWhook extends the live switch at runtime: it points 'e' at a new
jump slot (0x7052CC) that pushes registry[ref] for a 4-byte ref, and
fires CREATE_CHATBUBBLE with "%e%s". Our copy skipped 'e' without
consuming its value, so %s read the ref as a string pointer and
crashed in lua_pushstring (0x6F38A5) for any handler that declares
parameters.

Both runners now run unchanged, and only their own lua_pcall is
redirected (0x705199, 0x704E79) through a thunk that reads the handler
ref, frame and arg count from the runner's frame. RunnerPCall reads
arg1..argN back from the globals the runner set and passes them as
(self, [event,] arg1..argN [, trailing]). The format parser and both
runner reimplementations are gone; RunCore remains for value fires.

Format fires now keep the runner's 19-argument limit and its
lua_gettable saves of this/argN, as vanilla handlers always had. No
engine or ClassicAPI format has more than 12 values; value fires
(FireValues, RunWithValues) are unchanged.

Verified in game: a RegisterAllEvents frame with a modern OnEvent
crashed on a chat bubble with the old build and prints
CREATE_CHATBUBBLE and the speaker GUID with this one.
2026-10-30 15:54:53 -05:00
Brues 8f6ff33a39 refactor(lua): CallbackRegistry in retail's own text
6cc9d0d already gave CallbackRegistry retail's behavior; the file is now
retail Blizzard_SharedXMLBase/CallbackRegistry.lua in retail's own syntax
(select("#", ...), `...` forwarding, retail's comments), with three
differences noted in the header:

- No AttributeDelegate: 1.12 has no taint or SetForbidden, so
  SecureInsertEvent and GetMutableCallbacksByEvent do the delegate's
  work directly.
- TriggerEvent calls securecallfunction from its own loops instead of
  handing secureexecuterange a vararg helper, which in Lua 5.0 would
  build an `arg` table per callback per event.
- UnregisterEvents uses table.wipe; 1.12 has no global wipe.

The one behavior change: TriggerEvent on a registry with no Event table
and undefined events disallowed now fails on self.Event as retail does,
instead of with the "doesn't exist" message. EventRegistry, and registries
derived from it, allow undefined events.

Verified in game: a closure gets its bound argument and a leading nil
payload, an erroring callback goes to the error handler while the others
run, a callback registered mid-dispatch runs from the next trigger, and
UnregisterEvents clears every callback.
2026-10-30 14:01:40 -05:00
Brues 5082f3c829 feat(lua): TableUtil follows retail
The file is now retail Blizzard_SharedXMLBase/TableUtil.lua in retail's
own syntax: the length operator, `...`, select, math.huge, table.wipe and
5.1's unpack(t, i, j) all work here now.

New from retail: TableUtil.SafeCountTable / SafeCountIndexTable,
tInvertToArray and CollectTableDifferences. Fixed by following retail:
TableUtil.Transform returned after the first value, ContainsAllKeys now
also fails on a key only the second table has, and SafeUnpack honors its
startIndex. TableUtil.ExecuteOnKeys stays as the one addition retail does
not have.

Verified in game: ContainsAllKeys, Transform, SafePack / SafeUnpack from
index 2, GetTableValueListFromEnumeration over ipairs,
CollectTableDifferences, SafeCountTable and ExecuteOnKeys all return the
expected values.
2026-10-30 13:57:09 -05:00
Brues 5d0568f759 feat(lua): Pools follows retail, with securecallmethod
Pools.lua is now retail Blizzard_SharedXMLBase/Pools.lua, on the
SecureTypes and ProxyUtil ports the Menu backport already uses.
CreateObjectPool, CreateFramePool and the rest alias the secure variants
as on retail, so they return proxies that expose only the pool methods.
CreateFramePool takes retail's (frameType, parent, template, resetFunc,
forbidden, postCreate, capacity); a table specialization goes through
FrameUtil.SpecializeFrameWithMixins; CreateObjectPool works without a
reset function; Pool_HideAndSetToDefaults is new. The ClassicAPI-only
CreateUnsecuredActorPool and CreateUnsecuredFontStringPoolCollection are
gone (nothing used them).

Two differences, noted in the header: no secret-value check on release
(1.12 has no secret values), and a forbidden pool makes normal frames
(no CreateForbiddenFrame).

The TOC loads SecureTypes and ProxyUtil before Pools, as retail's does;
Pools reads both at load.

SecurePoolCollectionMixin:Release calls securecallmethod, now in
src/baselib/SecureCall.cpp next to securecallfunction, on both Lua
states: object[method](object, ...) with a raw lookup, an error goes to
the error handler and the call returns nothing. That follows retail's
API documentation; it is not probed. docs/API.md covers it.

Verified in game: securecallmethod returns the method's result, a frame
pool hides its internals behind the proxy while acquire and release keep
the count, and a frame pool collection runs a table specialization's
OnLoad, acquires, and releases through securecallmethod.
2026-10-30 13:53:24 -05:00
Brues 9a78e159e7 feat(lua): FrameUtil follows retail
The file is now retail Blizzard_SharedXMLBase/FrameUtil.lua. New from
retail: FrameUtil.RegisterUpdateFunction / UnregisterUpdateFunction,
DialogStyleGlobalMouseDown, SpecializeFrameWithMixins /
ReflectStandardScriptHandlers, RegisterForVariablesLoaded,
FrameUtil.CreateFrame, UpdateScaleForFit / UpdateScaleForFitSpecific,
GetScaledCenter, FitToParent, RegisterForTopLevelParentChanged /
UpdateTopLevelParent and AnimTransitionMixin. RegisterFrameForUnitEvents
forwards `...` as retail does.

AnimTransitionMixin runs on retail's Blizzard_SharedXML/
ScriptAnimationUtil.lua, ported verbatim. Its ShakeFrame scales by
retail's ShakeStrengthUI cvar, which src/ui/ShakeStrength.cpp registers
with retail's help, category and default ("1.000000"); nothing in the
engine reads it.

Not ported: the fade and flash functions, which 1.12's own UIParent.lua
defines and UIParent's OnUpdate drives (Turtle has also changed them);
RegisterFrameForEventCallbacks and its two siblings (no
frame:RegisterEventCallback); ApplyDefaultScale (no GetDefaultScale).
SetParentMaintainRenderLayering does not carry the window: 1.12 has one.

README.md lists FrameUtil, ScriptAnimationUtil and the cvar.

Verified in game: SpecializeFrameWithMixins sets and calls OnLoad and
OnShow with the frame, a SlideLeft StartAnimIn ends at its anchor
(CENTER 0 0), and ShakeFrameRandom with ShakeStrengthUI at 1.000000
returns its cancel function.
2026-10-30 13:53:15 -05:00
Brues 503368623f feat(localization): BreakUpLargeNumbers, AbbreviateNumbers, AbbreviateLargeNumbers
Retail's number formatters (LocalizationDocumentation.lua) and
C_StringUtil.GetDefaultAbbreviationBreakpoints. Retail moved them from
FrameXML into C in 12.0.0, so the behavior is taken from probes of an enUS
retail client, recorded in src/localization/Numbers.cpp:

- BreakUpLargeNumbers drops the fraction of a whole number or one 1000 or
  more in size and groups it with LARGE_NUMBER_SEPERATOR while the
  breakUpLargeNumbers cvar (retail's, default on) is set; other numbers
  keep one decimal, rounded half away from zero. natural writes a million
  or more in words ("1.2 million", "123 million").
- AbbreviateNumbers walks retail's default breakpoints, or
  options.breakpointData, or the Asian table for options.locale koKR /
  zhCN / zhTW. Retail's defaults name THIRD_ and FOURTH_NUMBER_CAP_NO_SPACE,
  which its clients do not define, so billions and trillions carry no
  letter there and here.
- AbbreviateLargeNumbers walks FrameXML's old thresholds, rounds to a whole
  number outside the Asian locales, and falls back to BreakUpLargeNumbers.

The asian natural-word scales (1e4 / 1e8 / 1e12) are read off the strings,
not probed. options.config (CreateAbbreviateConfig) is not backported.

Every locale file, and a new koKR.lua, gets DECIMAL_SEPERATOR, FIRST/SECOND_NUMBER_CAP[_NO_SPACE]
and FIRST..FOURTH_NUMBER from the matching Classic GlobalStrings; the
German, Spanish and French words carry |7 plural tokens, which the grammar
pass does not expand yet.

FormattingUtil gains retail's GetCostString and GetItemCostString on top.

docs/API.md documents the four functions, the cvar and
Constants.MoneyFormattingConstants; README.md lists them.

Verified in game: every probed retail value reproduces exactly (BreakUp
with and without natural, AbbreviateNumbers with defaults, Asian locale
and custom breakpoints, AbbreviateLargeNumbers), the cvar toggles the
separators, and GetItemCostString shows the amount and item icon.
2026-10-30 13:33:40 -05:00
Brues 735d304a20 feat(currency): Constants.MoneyFormattingConstants and GetMoneyString's gold threshold
Retail exposes engine constant tables as Constants.<Name>, documented with
Type "Constants" and a Values list (MoneyConstantsDocumentation.lua).
Game::Lua::RegisterConstants builds Constants[sub] with the same integer
entries RegisterIntegerEnum takes, and the documentation recorder has a
constants kind next to the enum kind, so the API browser lists the table as
constants rather than as an enumeration.

Constants.MoneyFormattingConstants.GOLD_REWARD_THRESHOLD_TO_HIDE_COPPER is
10, retail's value. FormattingUtil's GetMoneyString now applies
checkGoldThreshold through retail's FormatDisplayCopper: above 10 gold the
copper is dropped.

Verified in game: the table is present, GetMoneyString(1234567, nil, true)
shows gold and silver only, and GetMoneyString(51234, nil, true) keeps its
copper.
2026-10-30 13:17:01 -05:00
Brues 29ddd78a80 feat(lua): FormattingUtil follows retail
The file is now retail Blizzard_SharedXML/FormattingUtil.lua (identical in
retail, Classic and Classic Era), in retail's own syntax: the length and
modulo operators and string methods all work here now.

New from retail: FormatValueWithSign (with SYMBOLIC_NEGATIVE_NUMBER "-%s" /
SYMBOLIC_POSITIVE_NUMBER "+%s", the same in every locale's GlobalStrings),
MoneyStringConstants, FormatPercentageRounded,
GetHighlightedNumberDifferenceString, FormatUnreadMailTooltip,
FormattingUtil.AddLeadingZeroes, ReplaceGenderTokens, and GetMoneyString's
color argument.

GetMoneyString gives the coin-icon form, retail's default; retail uses the
text-symbol form only under its colorblind mode, which 1.12 does not have.
checkGoldThreshold is still not applied (it needs
Constants.MoneyFormattingConstants), and GetCostString / GetItemCostString
wait on BreakUpLargeNumbers / AbbreviateNumbers. GetCurrencyString and
GetCurrenciesString are not ported: 1.12 has no currencies.

Verified in game: coin-icon money strings with and without thousands
separators, signed values, FormatLargeNumber, the highlighted number
difference, and gray leading zeroes.
2026-10-30 13:05:13 -05:00
Brues b6c21216e3 fix(lua): EventUtil follows retail; queued variables-loaded callbacks are error-isolated
TriggerOnVariablesLoaded ran the ContinueOnVariablesLoaded queue in a plain
loop, so one erroring callback skipped the rest, and the queue drains only
once, so they never ran that session. It now uses secureexecuterange as
retail does: each error goes to the error handler and the walk goes on.

AreVariablesLoaded reads UIParent.variablesLoaded, as on retail, instead of
a private flag. Retail's UIParent sets that field and calls
TriggerOnVariablesLoaded on VARIABLES_LOADED; 1.12's FrameXML does
neither, so EventUtil's own frame does both.

The header no longer says 1.12 lacks select, C_AddOns or
secureexecuterange; ClassicAPI provides all three. The unknown-event
message uses :format and IsEmpty uses #, as on retail.

Verified in game: AreVariablesLoaded and UIParent.variablesLoaded are
true, a late ContinueOnVariablesLoaded callback runs at once, IsEmpty
works, and ContinueAfterAllEvents rejects an unknown event.
2026-10-30 12:58:01 -05:00
Brues 6cc9d0db90 fix(lua): CallbackRegistry isolates callback errors and defers mid-dispatch registrations
TriggerEvent called each callback directly, so one error left the frame's
OnEvent script and skipped every callback after it in that dispatch. pfUI
now runs many listeners through one EventRegistry dispatch, so one failing
listener could silence the rest. Each callback now runs through
securecallfunction: the error goes to the engine's script error handler and
the loop goes on, which is retail's secureexecuterange contract. It is
called from TriggerEvent's own loop, not through a vararg helper, because a
vararg Lua function allocates an arg table per call on Lua 5.0.

A callback registered for an event while that event is dispatching now
waits in deferredCallbacks and joins after the outermost dispatch, as on
retail. Before, its key went into the table the pairs loop was walking.
UnregisterCallback clears only owners that are present, and drops pending
deferrals.

The rest now follows retail Blizzard_SharedXMLBase/CallbackRegistry.lua:
UnregisterEvents() clears every event and UnregisterEventsByEventTable
clears the listed ones; GenerateCallbackEvents checks its argument; the
file's own GenerateClosure copy gives way to FunctionUtil's global (one
table per call instead of two, with retail's owner + 4 argument limit).

EventRegistry:OnLoad keeps all instance state, since addons derive their
own registry from the instance (pfUI: CreateFromMixins(EventRegistry)).

Verified in game: an erroring callback reports to the handler and the next
one still runs; a callback registered mid-dispatch first runs on the next
TriggerEvent.
2026-10-30 12:49:21 -05:00
Brues c6c201e575 fix(text): auto-height font strings keep their last line past 4 KB
A font string with no height of its own is as tall as its text, and that
height (0x5C2070) is a product, n * fontHeight + (n - 1) * gap. FitText's
count of what fits (0x5C21C0) adds the lines up as a running float sum and
stops at the first line where blockHeight + 2^-20 < sum + fontHeight. Over
many lines the sum drifts from the product by more than 2^-20 at most UI
scales, the last line "does not fit", and FitText cuts the text to its
first 4092 bytes plus "...", with the rest of the box blank. A multi-line
edit box hit this once its text passed 4 KB (ScrollingEditBoxTemplate,
pfUI chat copy).

4.3.4 keeps the product and grows the margin per line: blockHeight +
numLines * 2^-20 (GxuFontGetMaxCharsWithinWidthAndHeight). Text::FitHeight
replaces the loop's fadd of the engine's 2^-20 with a thunk that adds
numLines times it. The first line keeps 1.12's margin; each further line
adds a millionth of the screen height, so real overflow is still cut. A
float32 model of the chain lost the last line in 1276 of 3000 multi-line
fits before and in none after; in game a 30783-byte copy box draws all of
its text.
2026-10-30 11:39:02 -05:00
Brues 77b304ae68 feat(smf): RemoveMessagesByPredicate
ScrollingMessageFrame:RemoveMessagesByPredicate(predicate), retail's: the
predicate is called with each message's GetMessageInfo values, oldest
first, and the messages it returns true for are removed; the rest keep
their order. It covers 4.3.4's RemoveMessagesByAccessID / ByExtraData,
whose int fields the retail-shaped records here do not have.

1.12's ring has no compaction of its own; the removal is 4.3.4's
RemoveLines loop over it: every kept record moves forward in ring order to
the first free slot (freeing the text it lands on, nulling the one it
left), the position moves back by the removed count, and the
extra-argument tables move with their records. Then the engine's own
layout (0x00788750) from the record at the kept scroll offset, clamped to
the oldest, and its re-arm (0x00788B80) - a layout at the bottom keeps the
fade state, so 4.3.4 re-arms after - or its Clear (0x007882B0) when
nothing is kept.

The predicate runs for every message before anything moves, through the
engine's error handler; a predicate that errors keeps that message, as
retail's safesecurecall does. GetMessageInfo shares its record lookup and
value push with it.
2026-10-30 02:14:08 -05:00
Brues c965e49117 feat(smf): BackFillMessage
ScrollingMessageFrame:BackFillMessage(text, r, g, b, ...), retail's: the
message becomes the oldest, for rebuilding a frame from history newest to
oldest (Blizzard_CombatLog's refilter adds each entry with it).

The ring's writer 0x00788150 has one slot rule: the one after the newest.
BackFillMessage writes the slot 4.3.4's AddMessage(..., addToTop) picks —
the free one before the oldest, (pos - count + 1) mod max once the count
has grown — and fills the record as the writer does: a Storm copy of the
text, AddMessage's colour (clamped, x255 + 0.5 truncated, alpha 0xFF; the
font's without r, g, b), shown, the frame's two times, id 0, and the extra
arguments kept for GetMessageInfo. Nothing already in the ring moves, so a
scrolled-up frame keeps its view; at the bottom the lines are laid out
again from the newest, as the writer does.

A frame at its line limit drops the message, as retail's CircularBuffer
PushBack does ("Won't overwrite front"); 4.3.4 overwrites the oldest slot
there, which Blizzard_CombatLog never reaches.
2026-10-30 01:45:47 -05:00
Brues ead52e79ce fix(editbox): caret, selection and click rows follow tall inline icons
A multi-line edit box places its caret, selection pieces and a click's
cursor by rows of one height (font height + spacing, pixel-aligned), while
the render moves a line holding an icon taller than the font, and every
line after it, down by the overflow, and the box grows to match. Below the
first tall-icon line the caret and highlight sat above their text, and
OnCursorChanged reported that y, so ScrollingEditBoxMixin's scroll-into-
view stopped short (pfUI chat copy with faction emblems).

4.3.4's CSimpleEditBox has the same fault: UpdateVisibleCursor,
UpdateHighlightArea and ConvertCoordinateToIndex are icon-blind too. So
this goes past 4.3.4, as editbox/CharClass does. EditBox::IconRows co-hooks
the three placers and gives each the render's move, from the new
Text::InlineTexture::LineIconShift over the box's own line table: the
click's y is mapped onto the uniform row before the engine reads it; the
selection piece and caret are re-anchored after, with the engine's own x;
the caret's OnCursorChanged is muted for the original call and fired once
with the corrected y through the engine's forwarder, with the same packing
PlaceCaret uses (verified in the disassembly and the binary's constants).

Also fixes the "caret positioner" misnomer for 0x77DE70 (the selection-
piece placer) in Offsets.h and the docs.
2026-10-30 01:15:01 -05:00
Brues 7cb6cda101 feat(smf): TOP insert mode
SetInsertMode(SCROLLING_MESSAGE_FRAME_INSERT_MODE_TOP) now shows the
newest message at the top, older ones below it, as retail's mixin does.

1.12's line layout anchors each line once, when it makes it, through one
SetPoint call (0x00788975). That call is redirected: lines of a TOP frame
take the TOP anchors, every other line keeps the engine's own arguments.
The layout remakes the lines when the frame resizes, so the redirect
covers that too. A mode change re-anchors the existing lines with the
layout's own steps and refreshes the display.

Insert mode moves out of Scroll.cpp into InsertMode.cpp.
2026-10-30 00:20:19 -05:00
Brues 5ab497f2c7 feat(smf): scroll offset, insert mode, display-refreshed callbacks
Retail's ScrollingMessageFrame SetScrollOffset, GetScrollOffset,
GetMaxScrollRange, ScrollByAmount, SetInsertMode / GetInsertMode with the
SCROLLING_MESSAGE_FRAME_INSERT_MODE_* globals, and
AddOnDisplayRefreshedCallback, which ScrollUtil's
InitScrollingMessageFrameWithScrollBar calls.

The offset is the engine's own: SetScrollFromBottom's body (0x00788700)
is 4.3.4's SetScrollOffset step for step, and GetCurrentScroll is
GetScrollOffset under retail's name. The setters clamp to
0..GetNumMessages()-1 first, as retail does; the engine wraps. 1.12's
layout stacks lines bottom-up only, so the insert mode is BOTTOM and
SetInsertMode raises an error for anything else.

The callbacks run after the frame's own update (0x00788460, OnUpdate then
the fader), where retail's OnPostUpdate runs them, for a frame the line
layout, AddMessage or ScrollByAmount marked. The layout hook moves out of
Text::InlineTexture into ScrollingMessageFrame::Layout, which owns it and
fans out to both.
2026-10-29 23:56:24 -05:00
Brues 205d1fd43b feat(smf): ScrollingMessageFrame GetMessageInfo
Retail's smf:GetMessageInfo(index): text, r, g, b, then every argument
AddMessage took after the colour. Index 1 is the oldest message, and an
index outside 1..GetNumMessages() returns nothing.

The text and colour come from the engine's record in the frame's message
ring, so UpdateColorByID's changes read back. The record keeps only an
integer id after the colour, so AddMessage is registered again over the
engine's: Storm's hash links a new method node at the head of its bucket
and the lookup takes the first match. It runs the engine's AddMessage,
then keeps the extra arguments for the record just written, in a
registry table reused per record.
2026-10-29 23:43:45 -05:00
Brues 950c9deb76 feat(death): C_DeathInfo self-resurrect options, SELF_RES_SPELL_CHANGED
C_DeathInfo.GetSelfResurrectOptions, C_DeathInfo.UseSelfResurrectOption
and Enum.SelfResurrectOptionType over what 1.12 already serves through
HasSoulstone and UseSoulstone. Both pick one option the same way:
PLAYER_SELF_RES_SPELL when the server set it, else the first carried
item whose on-use spell is a self-resurrect. The list holds that option
under HasSoulstone's alive gate, and using it calls the engine's
UseSoulstone. Nothing in 1.12 limits a self-resurrection, so canUse is
true, isLimited false and priority 0.

SELF_RES_SPELL_CHANGED is 4.3.4's watcher on PLAYER_SELF_RES_SPELL from
SetActiveMirrorHandlers. 1.12 watches no such field, so the watcher joins
CGPlayer_C::AddActiveWatchers and leaves before its removal, the lifetime
of the engine's own player watchers.
2026-10-29 20:37:40 -05:00
Brues e7ccc11309 feat(unit): UnitHasIncomingResurrection, INCOMING_RESURRECT_CHANGED
4.3.4's CGIncomingResurrection. SMSG_SPELL_START adds a resurrect cast
under its object target, a unit or a corpse. SMSG_SPELL_GO, the failure
packets and ClearCastingSpell (through Spell::Cast, Turtle's only
interrupt signal) end it. A cast whose caster leaves view never reports
its end, so it ends on the next world tick. Each change fires the event
for every token naming the target, or the corpse's owner. 1.12's class
resurrections carry effect 113, not 4.3.4's 18, so both count.

Object::Corpse::ByOwner, lifted from spell/Range.cpp, now skips bones as
4.3.4's ClntObjMgrFindCorpseByOwner does. Turtle's bones keep their
owner, so old bones hid a ghost's live corpse, from this API and from
the range check's ghost redirect.

HasEffect moves from CombatLog to Spell::Lookup, and the SpellCastTargets
object mask 0x8A02 becomes one Offsets.h constant.
2026-10-29 19:21:47 -05:00
Brues bab31b5f7b docs(unit): bound the GetShapeshiftForm example to the bar
GetShapeshiftForm returns the bar count + 1 for a form that is not on
the stance bar, as 3.3.5 and 4.3.4 do. The example passed that index to
GetShapeshiftFormInfo, which returns nils for it.
2026-10-28 23:31:49 -05:00
Brues e0e24bab34 feat(macro): SetMacroDisplay range unit
C_Macro.SetMacroDisplay(macroSlot, value [, unit]) takes the unit the
button's range check uses, so a publisher's [@unit] reaches the range
check as SetMacroSpell's target does. The token stays a token: each
lookup resolves it again, so the range follows a new mouseover, focus or
target with no republish. A token that names no unit leaves the target
in charge.

The engine's resolver vets the unit at publish and raises its "Unknown
unit name" error for anything else, as UnitExists does. It tells a
token's family from its spelling alone, so a token that passes once
never raises at a later lookup. The unit is ignored for false and for an
empty value, and a publish that changes only the unit updates it.
2026-10-28 23:19:36 -05:00
Brues a46d823b01 feat(quest): completed quests on Turtle-lineage servers
QueryQuestsCompleted, GetQuestsCompleted, C_QuestLog.IsQuestFlaggedCompleted,
C_QuestLog.GetAllCompletedQuestIDs and QUEST_QUERY_COMPLETE, on Turtle clients
only. The server answers `.queststatuses` with TWQUEST guild addon messages
from the player, split at 2000 characters; the shorter last message is the
end of the list, so no timers are needed.

The DLL sends the request itself on SMSG_LOGIN_VERIFY_WORLD (the earliest
request the server answers), after each turn-in, and from
QueryQuestsCompleted. It reads every answer at the packet funnel, checks that
the sender is the player, replaces the list and fires QUEST_QUERY_COMPLETE.
A turned-in quest goes into the list at once; the server's next answer takes
repeatable quests out again. The list lives through /reload.

Net::OutPacket replaces the two hand-built CDataStore senders (item cache
resend, SetPVP). Replaces the closed Lua collector of PR #86.
2026-10-28 21:42:15 -05:00
Brues 38337c841d feat(container): SetItemSearch, IsContainerFiltered
Port of 4.3.4's bag search (CGContainerInfo): C_Container.SetItemSearch,
C_Container.IsContainerFiltered and INVENTORY_SEARCH_UPDATE. An item
matches on its equipment sets' names, its suffixed name, its subclass
short name or its equip slot name, case- and accent-folded per locale.
The keyring is searched; non-ASCII search text is folded whole.

C_Container.GetContainerItemInfo now reports isFiltered and takes an
optional table to fill and return, so bag addons can read every slot
without a new table per call. Item::Location::ResolveBagArgs resolves
the caller's own bag/slot args without wiping the stack.
2026-10-28 20:56:52 -05:00
Brues 5a0b4f2e31 feat: GetTradeSkillDescription, GetTrainerServiceItemLink
Both mirror 3.3.5 (0x005DBE10, 0x00596290).

GetTradeSkillDescription: nil unless the row's recipe has a description
in the client's locale, then 1.12's spell-text parser with the arguments
GetCraftDescription passes. That call moves to Spell::Description::Expand,
shared with C_Spell.GetSpellDescription.

GetTrainerServiceItemLink: a 1.12 server sends the trainer spell that
teaches the recipe, not the recipe. Follow its first LEARN_SPELL or
LEARN_PET_SPELL effect as 1.12's GetTrainerServiceIcon (0x004D8F50) does,
then 3.3.5's gate: tradeskill trainer, effect 0 CREATE_ITEM, item cached.
3.3.5's other create-item effects are dropped: 1.12's 59 is
OPEN_LOCK_ITEM and 157 does not exist.
2026-10-28 20:11:12 -05:00
Brues 7d9dafbfcf feat(gossip): GetNumGossipOptions
3.3.5 pushes m_gossipOptionsCount; 1.12 keeps no count. Count the slots
1.12's Script_GetGossipOptions (0x4E28D0) pushes: every slot with a
non-negative index, no session check, so the count matches the list.
2026-10-28 18:53:12 -05:00
Brues 79d4b7f44d feat(faction): ExpandAllFactionHeaders, CollapseAllFactionHeaders
3.3.5 and 4.3.4 call CGReputationInfo::CollapseHeader(m_numFactions,
collapse); an index at the entry count sets every header. 1.12's
CollapseHeader (0x4D6400) has the same branch, so both make that call
with 1.12's entry count.
2026-10-28 18:49:32 -05:00
Brues 9b07ad15df feat(chat): IsChatAFK, IsChatDND
IsChatAFK reads 1.12's chat AFK mirror (0xB6E5CC), 4.3.4's CGChat::m_afk,
with the same writers: SetAfk, ClearAfk, the PLAYER_FLAGS change handler
and world entry. 1.12 keeps no DND mirror; its /dnd branch tests the
PLAYER_FLAGS DND bit, so IsChatDND reads that bit.
2026-10-28 18:44:19 -05:00
Brues 36d55ab305 feat(player): IsLoggingOut, IsPlayerInWorld
IsLoggingOut reads the current connection's logout-pending byte through
the engine's own ClientServices::GetCurrent / IsLogoutPending pair, as
4.3.4's CharacterLoggingOut does. IsPlayerInWorld reads VAR_IN_WORLD,
1.12's CGGameUI::s_inWorld. Both are in-game only, as in 4.3.4.
2026-10-28 18:34:20 -05:00
Brues ebe9b9b6de feat(friendlist): AddOrRemoveFriend
4.3.4's Script_AddOrRemoveFriend: removes the friend whose name matches
without case (1.12's RemoveFriendByName scan, then the engine's
SendRemoveFriend), else adds through FriendList::AddFriend. The note goes to
the client-side store once the server adds the friend: it waits by name
until FriendList::SetFriendName names the slot, via redirects of that
function's two call sites. The add reply can't be paired with the request,
since tortoise-wow sends none for an unknown name. Also lists
CanCancelAuction in the README.
2026-10-28 18:21:41 -05:00
Brues 4df29722d2 feat(auction): CanCancelAuction
4.3.4's Script_CanCancelAuction: nothing without an auctioneer, nil for an
owner-list index with no row, else 1. Gates mirror 1.12's own CancelAuction
(0x004D0260); 1.12 owner records have no sold flag, and the cancel-fee money
check stays in CancelAuction as in 4.3.4.
2026-10-28 18:10:08 -05:00
Brues 086b9f9fbb feat(range): 4.3.4 target gate for IsSpellInRange / IsItemInRange
C_Spell.IsSpellInRange and C_Item.IsItemInRange now run through
Spell::Range::CastVsUnit, a port of 4.3.4's Spell_C_RangeCheckSelected on
1.12's own target gate (CheckSpellTargetRange 0x6E4440, mirrored branch
for branch since it folds "can't target" into "in range"):

- nil for a unit the spell cannot be cast at (reaction, creature type,
  dead/alive state)
- spells aimed at the pet (implicit target 5) measure to the pet
- a resurrect (target word 0x8000) on a ghost measures to its corpse
- pet-book spells use the pet as caster; names take the book the
  engine's name resolver matched

UnitInRange measures from the active mover (VAR_ACTIVE_MOVER_GUID), as
4.3.4 does, so a controlled unit is the origin.
2026-10-28 16:20:00 -05:00
Brues bc2481395d feat(pvp): SetPVP, GetPVPDesired, GetPVPTimer, IsPVPTimerRunning
SetPVP / C_PvP.SetPVP send CMSG_TOGGLE_PVP with the state byte (3.3.5
Script_SetPVP); tortoise-wow and vmangos both honor it. The timer mirrors
3.3.5's GetPVPToggleTimer, with "running" derived client-side since 1.12
servers never set PLAYER_FLAGS 0x40000: armed by the engine's
ERR_PVP_TOGGLE_OFF notice while flagged, disarmed by the toggle-on notice,
the active player's UNIT_FLAG_PVP drop, or glue boot. Three call-site
redirects, no MinHooks. PVP_TIMER_UPDATE("player") fires on start/stop.
2026-10-28 15:47:39 -05:00
Brues daded912c2 feat(input): F13-F24 key support
The window procedure's key translator dropped VK_F13..VK_F24, so the
keys never reached bindings or frame scripts. Translate them to codes
0x2F4..0x2FF (the free range below F1; codes must stay under the
0x30E-entry key-taker array) and name them in the engine's three
code-to-name decoders by retargeting each one's UNKNOWN branch.
2026-10-28 15:26:25 -05:00
Brues a0c5c8a30c feat(input): GetMouseButtonName
Names come from the engine's own click-name literals, so an ID outside
1..5 is "UNKNOWN", as 1.12's OnClick names it.
2026-10-28 15:13:01 -05:00
Brues e26f8fb49e feat: ConsoleAddMessage, CursorHasMacro, GetMacroBody
GetMacroBody shares the index-or-name macro argument with SetMacroSpell /
SetMacroItem through the new Macro::Arg::Slot.
2026-10-28 15:07:29 -05:00
Brues da9e9b6f55 feat(spell): IsAttackSpell, IsAutoRepeatSpell, IsConsumableSpell 2026-10-28 15:02:02 -05:00
Brues a5e8a6f5b6 feat(spell): GetSpellBaseCooldown 2026-10-28 14:50:18 -05:00
Brues f191c53c2a feat(unit): CanBeRaidTarget 2026-10-28 14:43:38 -05:00
Brues 797fea90ed fix(spell): Resolve the has-range gate with a null target, as ActionHasRange does
PlayerVsUnit passed the real target to GetMinMaxRange (0x6E3480), which folds both units' combat reach into every non-melee range row. Self-range spells (Hearthstone, totems, professions) came back with max ~3yd, passed the gate, and IsSpellInRange / IsItemInRange answered false instead of nil past melee distance. The engine's ActionSlotHasRange (via ActionHasRange 0x4E75D0) always resolves with a null target; EngineHasRange now does the same.
2026-10-28 14:32:01 -05:00
Brues 026fe20a89 feat(pet): IsAtStableMaster 2026-10-28 14:25:10 -05:00
Brues 529bacd0cb feat(pet): IsPetAttackAction 2026-10-28 14:19:33 -05:00
Brues c16c3a90dc feat(merchant): GetBuybackItemLink; buyback getters skip the vendor gate
GetBuybackItemLink(slot) builds the link with the engine's own item
link builder. It and C_MerchantFrame.GetBuybackItemID share one
buyback-slot resolver with no vendor-GUID check, as 1.12's
GetBuybackItemInfo (0x004FB310) and 4.3.4's GetBuybackItemLink do.
2026-10-28 14:12:32 -05:00
Brues ab829e98c0 feat(action): IsItemAction, HasBonusActionBar 2026-10-28 14:03:58 -05:00
Brues 602840419b feat(quest): GetQuestLogCompletionText
Returns the quest objectives with $N-style tokens expanded, the branch
4.3.4 takes when a quest has no completion text; the 1.12 query response
carries none. Takes a log index or the selected quest.

The cache field at +0x129C is the quest's EndText, not completion text:
rename OFF_COMPLETION_TEXT to OFF_END_TEXT and GetQuestDetails' key to
endText. Quest::Cache::ExpandText now owns the token-substitution call
that the quest hyperlink tooltip used to define locally.
2026-10-28 13:53:26 -05:00
Brues 40cd25315d feat(player): GetCritChance, GetRangedCritChance
Read PLAYER_CRIT_PERCENTAGE / PLAYER_RANGED_CRIT_PERCENTAGE from the
CGPlayer sub-struct, the same way the engine's GetDodgeChance reads its
neighbouring field.
2026-10-28 13:42:27 -05:00
Brues 984dc236ab feat(dressup): try on what a recipe makes
4.3.4's TryOnWithVisual swaps a recipe item (no inventory type, a spell
that teaches a CREATE_ITEM spell) for the item it makes; 1.12's worker
shows ERR_NOT_EQUIPPABLE instead. TryOnItem adds the swap in front of the
worker for DressUpModel:TryOn and, via a call redirect, for the worker's
item-cache callback, so Control-click on a pattern opens the dressing
room even before the pattern is cached. The recipe walk is shared with
IsDressableItem (Item::Dressable::RecipeItem).
2026-10-28 13:12:47 -05:00
Brues cdc3506cdd feat(item): IsEquippedItemType, IsCurrentItem, IsDressableItem, IsHelpfulItem, IsHarmfulItem
Ports of 4.3.4's item predicates. IsEquippedItemType runs the engine's
inventory search over the equipment slots by equip location, item class
or subclass name; the [equipped] macro condition now calls it instead of
a Lua rebuild. IsCurrentItem reads the in-flight cast item that 1.12's
IsCurrentAction checks. IsHelpfulItem / IsHarmfulItem classify the
on-use spell with the IsHelpfulSpell / IsHarmfulSpell classifier.

Shared: Item::Location::IDForArg (GetItemIDByName, was copied in three
files) and Item::Location::Walk over FUN_INVMGR_WALK.
2026-10-28 13:06:48 -05:00
Brues 6347bdd6c5 feat(expansion): GetExpansionLevel, GetAccountExpansionLevel, GetMaxPlayerLevel
Both expansion levels answer the shared kCurrentExpansionLevel (0), the
value LE_EXPANSION_LEVEL_CURRENT and GetClassicExpansionLevel give.
GetMaxPlayerLevel is a constant, as 4.3.4's is (85): 60, the cap 1.12's
own FrameXML keeps in MAX_PLAYER_LEVEL.
2026-10-28 12:49:57 -05:00
Brues ff4a9ffd5a feat(macro): SetMacroSpell, SetMacroItem, macro range unit
SetMacroSpell and SetMacroItem publish a spellbook spell or an item as
a macro's display, like 4.3.4: a name that resolves to nothing greys
the button, and the claim lasts until the macro's body is edited.

Each macro now keeps the unit its spell is aimed at (4.3.4's
MACRONODE::m_target): the target those functions name, or the [@unit]
of the clause a #showtooltip or /cast line matched. Action::Range
redirects IsActionInRange's call to the slot range function and, for a
macro slot with a unit, passes that GUID to the engine's range check
in place of the selection, as CGActionBar::HasValidTarget does.
2026-10-28 12:46:56 -05:00
Brues 9541dc2949 feat(macro): RunMacroText, GetRunningMacro, GetRunningMacroButton
RunMacroText copies the text into the body of a zeroed stack macro entry
and hands it to the engine's macro runner, as 4.3.4 does with a stack
MACRONODE. The runner reads only the body, so each line goes the
engine's way: EXECUTE_CHAT_LINE to MacroEditBox, with # lines skipped
and /stopmacro honored.

Macro::RunBody keeps the running entry and button per run, saved and
restored around nested runs. GetRunningMacro maps the entry's ID to its
index; GetRunningMacroButton returns the button. [button] now matches
the running macro's button as well as the clicked one.

Macro::Execute drops its Lua ChatEdit_ParseText path and the addon
RunMacro preference. The secure button's macro verb and the MACRO
binding run through the engine runner with the clicked button and
"LeftButton".
2026-10-28 12:28:29 -05:00
Brues 1340cc75c7 feat(chat): SendSystemMessage
Shows a local CHAT_MSG_SYSTEM line through the engine's chat line
function, called as the game-message display calls it for a chat-kind
message: chat type 10 and ten zero arguments.
2026-10-28 12:08:58 -05:00
Brues 9c50a90d34 feat(currency): GetCoinText
GetCoinText(amount [, separator]) and C_CurrencyInfo.GetCoinText: the
non-zero gold, silver and copper parts, each from its GOLD_AMOUNT /
SILVER_AMOUNT / COPPER_AMOUNT global string, joined by the separator
(default ", "). Zero is the empty string.

The three strings are backported to every embedded locale.
2026-10-28 12:03:24 -05:00
Brues 1942383494 feat(group): InviteUnit, UninviteUnit, PromoteToLeader
Group verbs that take a unit token or a name in one call. Each one
calls the engine's own group sender on the active player:

- InviteUnit(targetName): name only, with the ERR_INVITE_SELF check
  and the invite tutorial flag.
- UninviteUnit(name): a unit token resolves to a GUID; any other
  string is sent as a name.
- PromoteToLeader(name [, exactNameMatch]): a unit token resolves to
  a GUID; a name goes through the engine's by-name search in
  PromoteByName's mode.
2026-10-28 11:58:42 -05:00
Brues 0f37ddc48d feat(aura): CancelUnitBuff
CancelUnitBuff(unit, index [, filter]) cancels the player's own aura at
the given index. The index counts in FindNthSlot order (4.3.4's
GetSortedAuraSlot), so it matches the C_UnitAuras by-index getters; the
filter defaults to HELPFUL. The cancel gate is the engine's: the slot's
cancelable bit or the spell's AttributesEx 0x4.
2026-10-28 11:43:19 -05:00
Brues ac118d5305 feat(unit): GetShapeshiftForm
Returns the stance-bar index of the active form, walking the bar from the
last entry as 4.3.4's CGSpellBook::GetShapeshiftForm does: count + 1 for a
form not on the bar, 0 for requireForm with no form byte. Each index is
tested through the engine's Script_GetShapeshiftFormInfo, which holds
1.12's only full "bar spell is active" check.

The [stance] / [form] macro conditions now use GetShapeshiftForm(true),
as Blizzard's EvaluateStance does, so a paladin aura no longer counts as
a stance.
2026-10-28 11:35:26 -05:00
Brues cf645a30af feat(spell): CastSpellByID
Casts the exact spell ID from the player's or the pet's spellbook at the
current target, or at the unit an optional token names, through the
existing exact-ID dispatch. Returns true once dispatched and nothing for
an unknown spell or a token that names no unit. Macros calling it tag
their primary spell like CastSpellByName macros.
2026-10-28 11:22:17 -05:00
Brues fcd7e52be0 docs: SetBagPortraitTexture, SetInventoryPortraitTexture 2026-10-28 11:22:16 -05:00
Brues 886a4cb140 feat(container): SetBagPortraitTexture, SetInventoryPortraitTexture
Register the client's misspelled SetBagPortaitTexture under its correct
name and as C_Container.SetBagPortraitTexture. Fix the engine's
SetInventoryPortaitTexture, which reads the unit token from argument 1
(the texture) and so only ever cleared the texture, by patching that
index to 2, and register it as SetInventoryPortraitTexture.
2026-10-28 10:25:29 -05:00
Brues e30da939ec fix(lossofcontrol): let immunity-granting spells through their CC
GetSpellLossOfControlCooldown treated Polymorph (and every stun, fear,
confuse, charm) as blocking every spell, so a druid form reported a
lockout while polymorphed. The forms grant Polymorph immunity, and the
client's caster validator lets such a spell through via its per-arm
crowd-control scans. Blocks() now asks that arm's engine scan instead of
restating the gate.

Fixes #114
2026-10-28 02:25:06 -05:00
Brues 6e4efb39f7 feat(lua): Blizzard_Menu
Menu, MenuUtil, MenuTemplates, the Compositor and the DropdownButton
intrinsic, ported from retail with SecureTypes, ProxyUtil and RegionUtil,
and the Vanilla/Classic variants. The engine pieces are the earlier
commits: motion focus and propagation, the click/motion split,
IsMouseMotionFocus, SetTextToFit, SetToDefaults and region scale.

Port changes:
- The Compositor calls 1.12's frame __index function instead of indexing a
  method table.
- 1.12's script SetFrameLevel leaves children behind, so the menu takes its
  level before Open() raises the elements above it, and scroll mode brings
  the ScrollBox and ScrollBar up through SetParent(nil) then SetParent(menu).
- 1.12's SetScript does not turn mouse input on, so the menu, its elements
  and the auto-hide button do it where they set the mouse scripts.
- One window: no GetWindow/SetWindow. No taint: no SetForbidden or secure
  delegate.
- A submenu opens past its parent's right inset, at the parent's edge, as
  1.12's dropdowns do; retail's atlases hide the overlap that 1.12's opaque
  borders show.

The Classic atlases are 1.15 art, so the styles are drawn with 1.12's:
dropdowns with UIDropDownMenuTemplate's label frame and scroll-down button,
the filter dropdown with the stretched panel button, MenuStyle1/2 with
DropDownList's dialog and tooltip backdrops (cleared when a menu frame is
released), the dropdown check, the radio button sheet and the help frame
divider's middle slice. Insets and 10 of row padding keep arrows, swatches
and text clear of the opaque borders. ColorSwatchTemplate's texture
sublevels become layers. WowStyle2 and the highlight radio are left out
until they have 1.12 art.

Retail's global nop, assertsafe and CallErrorHandler (EnvironmentUtil,
ErrorUtil) replace the local copies in FunctionUtil, Pools and LinkUtil.
Also GameTooltip_SetTitle, LIST_DELIMITER, IsScrollingMessageFrame and
UIResetButtonTemplate.
2026-10-28 02:13:05 -05:00
Brues 8c21ab4b04 fix(region): give a region its frame's scale when it gets the frame
Vanilla gives a texture or font string its frame's scale only when it links
the region into a draw layer, which happens once the frame is visible. Until
then the region keeps scale 1.0: a region made on, or moved to, a hidden
frame lays out in the wrong units and GetLeft/GetRect report its position
divided by the wrong scale. Probed under a 0.91-scale UIParent: on a hidden
row a texture anchored to the row's left edge reported 454,446 against the
row's 499,498, the same ratio after moving the row; on a shown row the two
matched. Retail regions carry their frame's scale shown or hidden, and
Blizzard_Menu measures its rows from their regions while they are hidden.

CScriptRegion::SetRegionParent's call to CSimpleFrame::AddRegion now also
makes the call AddRegionToLayer would make later: the region layout's own
scale setter with the frame's layout scale, unforced. The draw layer is
still joined at show.
2026-10-28 02:12:52 -05:00
Brues 7ec3829eb5 feat(region): SetToDefaults
Retail's contract, probed on 5.5.4: a frame, texture or font string goes
back to the state its class constructs it in, not the one it was created
in, so template values, a font string's creation font and a button's
construction-time mouse input are lost. The parent, keyboard and wheel
input, registered events, child regions and Lua fields stay. 1.12's
constructor values agree with the probed reset (MEDIUM, parent level + 1,
no font, white text, black shadow at 0,0, CENTER/MIDDLE, gradient -1).

Values reset through each object's own methods, looked up through its
vtable's method lookup, so neither table fields nor a replaced metatable
(Blizzard_Menu's Compositor replaces them) are consulted and every backport
keeps its state in step. Engine paths where no method expresses the
default: every script slot released and emptied as SetScript(nil) does;
Frame::Attributes::ClearAll, firing no OnAttributeChanged; the text of a
font string or button label through the font string's SetText member; the
font unlinked and cleared through the engine's own no-font store; the
follower mask and alpha gradient back to their constructor values.

Verified against the retail snapshot property by property on 1.12. Not
copied: retail leaves a hidden frame IsShown() but not IsVisible() after
the reset; this one shows it.
2026-10-28 01:15:46 -05:00
Brues 86bfd881a9 feat(fontstring): SetTextToFit
Retail's contract, probed on 5.5.4: set the text, then the explicit width
becomes the text's unbounded width, whether the old width was smaller or
larger and whatever the max lines; the height is not touched; no or empty
text leaves the width unset. The text goes through the engine's own Lua
SetText, so conversions, grammar escapes and the "Font not set" error are
SetText's; the width through CLayoutFrame::SetWidth with the same substring
measure GetUnboundedStringWidth uses.
2026-10-28 01:00:07 -05:00
Brues cd55ce9139 feat(frame): separate mouse clicks and mouse motion
frame:SetMouseClickEnabled / SetMouseMotionEnabled / IsMouseClickEnabled /
IsMouseMotionEnabled and SetPropagateMouseClicks / CanPropagateMouseClicks,
with enableMouseClicks, enableMouseMotion and propagateMouseInput="Clicks"
in XML. Retail (probed on 5.5.4) routes the two separately: motion goes to
the top motion-enabled frame, a press to the top click-enabled frame, so a
click on a motion-only frame reaches the frame under it and a click-only
frame takes clicks without ever taking the motion. A frame that propagates
clicks passes the press on; each gets it and then the release, top-first.

The engine's one mouse bit stays "click or motion"; Frame::MouseInput keeps
the frames with one half off. The focus pass's probe call skips frames with
motion off. The press handler's focus read becomes the click walk, which is
that same read while no frame uses the split or click propagation; the press
and release dispatches run each extra target's button slot after the
engine's.

EnableMouse sets both halves. IsMouseEnabled is both halves and still
answers 1 or nil as the engine does: pfUI compares it with SpellIsTargeting.
2026-10-28 00:50:56 -05:00
Brues 1f8042088c feat(frame): mouse motion propagation and the motion focus list
Retail's motion focus is a list (probed on 5.5.4): the walk takes the top
mouse-enabled frame under the cursor and keeps going while the last frame
taken propagates motion; the next frame is the next one under the cursor,
not the parent. Every listed frame is focus, a frame that stays listed
across a change gets neither leave nor enter, and the ones that drop out
leave before the ones that join enter, each top-first.

The three blocks that write the UI context's focus (+0x7C) - the focus
pass's commit, the mouselook focus setter and the input removal - now call
Frame::MotionFocus, which keeps the list and keeps +0x7C as its first
frame. A propagating pick resumes the engine's walk through the same
per-frame probe. A removed frame leaves wherever it is in the list and asks
for a re-pick; hiding a frame removes it before its children, which gives
retail's outer-first leave order.

- frame:SetPropagateMouseMotion / CanPropagateMouseMotion /
  IsMouseMotionFocus, and propagateMouseInput="Motion"|"Both" in XML.
- GetMouseFoci returns the list (one frame unless frames propagate), as
  retail does, instead of every frame under the cursor.
- EnterLeaveMotion offers motion to the frame MotionFocus is notifying.
- Common::ReplaceWithCall takes up to 64 bytes; it refused anything over
  16 without a word.
2026-10-28 00:37:13 -05:00
Brues a86d5ec48f feat(lua): BackdropTemplate
Port retail's Blizzard_SharedXML/Backdrop: the BackdropTemplate template,
BackdropTemplateMixin, and the BACKDROP_* tables (verbatim). Retail draws
the backdrop with nine textures of its own because its frames lost the C
backdrop; 1.12's frames still have it and it lays out the same nine slices
(benilla-re ui/backdrop), so the mixin keeps retail's state and methods
(backdropInfo, HasBackdropInfo, ApplyBackdrop, ClearBackdrop, GetEdgeSize,
the OnLoad key values) and draws through the engine's SetBackdrop.
SetBackdrop with the table already applied keeps the colours, as retail's
does. GetBackdrop and the colour methods stay the engine's own.

SetBorderBlendMode and backdropBorderBlendMode are not ported: Lua cannot
reach the engine's edge textures.
2026-10-27 23:56:10 -05:00
Brues 70dadf0449 fix(aura): player aura duration after login
SMSG_UPDATE_AURA_DURATION carries the holder's remaining time. On login
the server sends it for auras the character already held, so their
duration read as the time left instead of the total. Flag those slots
(no player object yet on a fresh login; occupied slot with no earlier
duration on a reconnect) and report the spell's base duration instead,
or the remaining time where a caster modifier stretched it past the base.
2026-10-27 23:40:30 -05:00
Brues 5363434439 build: standard section layout, no writable code
/SECTION:.text,EWR made the code section writable and the .rdata merge
put constants in it. Nothing needs either: engine patches make their
target writable through Common::PatchBytes, and a scan of every
function in the DLL finds no write into .text or .rdata. A writable,
executable section is a common antivirus heuristic. Same file size.
2026-10-27 23:24:17 -05:00
Brues 9bac760195 build: gzip the embedded addons and compile for size
The embedded addon Lua/XML and Bindings.xml were stored as raw RCDATA,
924 KB of the 2.38 MB DLL. Each resource is now gzipped at build time
(cmake/gzip_file.cmake, CMake's own ARCHIVE_CREATE) and inflated with
the engine's zlib on first use, so Embed::Resource keeps its contract:
the same bytes, valid for the life of the DLL. Encoding::Compress now
exports the Inflate it already had for C_EncodingUtil.

/O1 replaces /O2 /Ob2: 280 KB less code. /GL + /LTCG made it larger.

2,380,288 -> 1,430,016 bytes.
2026-10-27 23:24:17 -05:00
Brues bd02983a08 feat(lua): WowTrimHorizontalScrollBar
Retail's horizontal bar is its vertical one with x and y swapped. Classic
Era has no horizontal bar and this client no horizontal scroll bar art, so
the template is WowClassicScrollBar with every anchor, size and inset
swapped, and retail's horizontal atlas names (UI-ScrollBar-EndCap-Left/
Right, _UI-ScrollBar-Center, UI-ScrollBar-ScrollLeft/RightButton-*, ids
from the 1.15 member table) bind to the Classic art transposed: the top cap
is the left cap, the up arrow the left arrow. Transposing keeps the art's
top-left lighting, which a quarter turn would move. The square knob takes
the swapped crop of the same file.

Atlas entries gain a transposed flag: SetTexCoord composition maps sprite x
down the file rect and y across it. An inline |A icon is one axis-aligned
rect, so a transposed atlas is not drawn there.
2026-10-27 23:01:36 -05:00
Brues 852cf44b7d feat: frame:SetAlphaGradient and frame:DesaturateHierarchy
SetAlphaGradient(index, gradient), ClearAlphaGradient and HasAlphaGradient,
what ScrollBox's SetEdgeFadeLength calls. As probed on retail: index 0 fades
in from the left and top edges, index 1 from the right and bottom, and the
fade shows only while the frame clips its children, its own regions
included.

Each index is one texture stage over the draw: a 2x2 ramp texture, filtered
and clamped, whose alpha multiplies in (preset 1). Coordinates come from the
camera-space position through a projected texture matrix that maps clip
space to the screen and then to the ramps, reloaded on every projection and
viewport set while a gradient draws. Textures, text (drawn in pixels under
its own projection) and clipped draws fade on the same screen spot.
ClipChildren now batches regions by nearest clipping frame and nearest
frame with a gradient; Texture::Mask takes its stages above the gradient's.
D3D only; a desaturated or masked texture does not fade.

DesaturateHierarchy(desaturation [, excludeRoot]) sets the desaturation of
every texture of the frame and of the frames under it, through
Texture::Desaturation.
2026-10-27 22:53:15 -05:00
Brues e59f9de5a8 feat(lua): SOUNDKIT constants
Verbatim port of Classic Era's Blizzard_SharedXML/Vanilla/SoundKitConstants.lua.
PlaySound already plays by SoundKitID, so the scroll bar arrows'
SOUNDKIT.U_CHAT_SCROLL_BUTTON now resolves and clicks.

109 of the 383 ids exist in 1.12's SoundEntries.dbc, with matching names
(1115 = UChatScrollButton, 850 = igMainMenuOpen); the rest are later sounds
and play nothing.
2026-10-27 21:37:51 -05:00
Brues 59164e3575 fix(lua): scroll bar knob drag and bottom end cap
ScrollBar's thumb drag and track paging read the cursor through
InputUtil.GetCursorPosition, which was never ported, so both handlers
errored on their first line. Port retail's InputUtil.lua and the
GetAppropriateTopLevelParent family it uses into FrameUtil.lua.

UI-ClassicScrollBar-EndCap-Bottom used UI-Character-ScrollBar's upper
bottom cap, whose 3-row border put the Forward stepper over the cap's
separator. Bind it to the lower cap (rows 227..255), whose 4-row border
and 17-row well fit the template's stepper exactly.
2026-10-27 21:31:47 -05:00
Brues 711c810b72 feat(lua): retail's ScrollBox, with Classic Era's scroll bar
Port Blizzard_SharedXML/Shared/Scroll (ScrollBox, its views, ScrollBar,
ScrollUtil, ScrollController, ScrollTemplates, TrimScrollBar.lua) and the
helpers it needs (Flags, FrameFactory, TemplateInfoCache,
TreeListDataProvider, ButtonStateBehavior). Verbatim except:
- TemplateInfoCache asks C_XMLUtil directly; retail's forbidden delegate
  is only taint isolation.
- Textures without <Anchors> get setAllPoints="true". Retail's loader
  fills such a texture to its parent (probed); 1.12's does not, and
  cannot be made to without breaking its own UI, whose TalentFrame
  anchors unanchored branch textures with one SetPoint.

The scroll bar is Classic Era's WowClassicScrollBar. Its atlases are
Blizzard's repack of vanilla's scroll bar art, bound in AtlasData.h to
the 1.12 files that carry it (UI-Character-ScrollBar for the track, the
UI-ScrollBar-Scroll*Button files for the steppers, each arrow measured
at the member table's 19x17). Its highlights draw with ADD, as the
client's own scroll buttons draw that art. WowTrimScrollBar and
MinimalScrollBar, whose art the client does not have, are
WowClassicScrollBar.

Also TextureKitConstants, and Pools.lua's AddSourceLocationExclude.
2026-10-27 21:22:22 -05:00
Brues dceec8b74c feat: engine pieces for retail's ScrollBox
What the ScrollBox port needs from the DLL, and three engine bugs it hit.

Fixes:
- GetWidth / GetHeight answer a rect-derived size over the layout scale,
  as 4.3.4 does. 1.12's getters leave the division out, so an anchored
  region under a UI scale read scaled (a 160-high track at 0.91 answered
  145.6, and the scroll bar thumb stopped short). GetRect and
  GetScaledRect use them.
- A <Size>, <Anchor> or <Offset> with only `x` applies. The engine's
  dimension reader decides "no attributes" from `y` alone; a co-hook
  answers as 4.3.4 does. Xml::Anchors' "0" attribute workaround goes.
- KeyValue type="global" walks a dotted path, as retail's own templates
  need (ScrollControllerMixin.Directions.Decrease).

New:
- <Texture atlas="..." useAtlasSize="...">, applied as SetAtlas.
- securecall / securecallfunction / secureexecuterange, as probed on
  retail: results returned, errors to the error handler, the range walk
  goes on after one.
- AddSourceLocationExclude(fileName): GetSourceLocation steps over that
  file's levels.
- region:AdjustPointsOffset(x, y) / ClearPointsOffset(), re-setting each
  anchor through the engine's SetPoint. Retail, probed: Clear zeroes the
  offsets rather than undoing adjustments.
- Enum.InputContext.
2026-10-27 21:22:22 -05:00
Brues 9edce4a9f9 feat(xml): intrinsic frame types, script binding types, EventFrame
A top-level frame element with intrinsic="true" defines a frame type:
<EventFrame> and CreateFrame("EventFrame") build a frame of the element's
own type with the definition applied first (its mixins, content and child
frames, then the templates and the element's own). Retail's EventFrame,
EventButton, EventEditBox and EventScrollFrame are ported verbatim;
ScrollBox and its scroll bars are EventFrames.

The builder keeps a definition instead of building it, detached from its
document the way the template registrar keeps a virtual node, and
registers its name with FrameXML_RegisterFactory. The one factory for
every intrinsic name builds the base type, for the node the builder hook
is building.

An intrinsic's scripts are its intrinsic bindings. Each script now has
retail's three (LE_SCRIPT_BINDING_TYPE_INTRINSIC_PRECALL, _EXTRINSIC,
_INTRINSIC_POSTCALL), as probed on retail:

- SetScript and GetScript read and write the extrinsic binding, so
  SetScript(script, nil) keeps the intrinsics.
- GetScript(script, binding) reads one; HookScript(script, hook) hooks
  the extrinsic, so the hook runs before the postcall, and
  HookScript(script, hook, binding) hooks an intrinsic.
- An intrinsic element without intrinsicOrder binds the precall.

A slot with intrinsic bindings holds a dispatcher closure over the three,
which runs each in its own context under the script error handler. Every
writer of a slot releases its old handler first, so after SetScript and
after each XML node load a new handler moves into the extrinsic binding.
The check compares the closure, not the ref number: luaL_ref hands the
released number straight back. Frame::ScriptArgs looks inside the
dispatcher as it does the HookScript wrapper.
2026-10-27 20:18:13 -05:00
Brues 4e3a18cdd1 feat(frame): clipChildren and SetClipsChildren/DoesClipChildren
A frame that clips its children draws every frame under it only inside
its own rect, and the mouse reaches them only there. Retail's ScrollBox,
talent tree and settings list rely on it.

Retail keeps clipped frames in their own strata and levels (the talent
tree puts a button's glow at the button's level - 1 inside a clipping
frame), so the clip applies per level instead of drawing the subtree in
one piece as 1.12's ScrollFrame does:

- The level rebuild's per-frame layer call (0x007659E7) is redirected.
  A frame under a clipping frame fills an engine batch of its own, one
  per level batch and nearest clipping frame, and the level batch gets
  a render callback that draws it right after the level's own regions.
- That callback is the ScrollFrame's draw (0x00787220) with the clip
  rect: save the transforms, intersect with the current viewport, set
  the ortho transforms, narrow the viewport, draw, restore.
- A ScrollFrame's own subtree draw calls each child at 0x0076B511; that
  call is redirected too, so a clipping frame inside a scroll child
  clips. Nested clips hold because each draw intersects with the
  viewport already in place.
- The hit test also misses outside every clipping ancestor's hit rect.
- Turning the clip on or off and SetParent mark the moved frames'
  layers changed, since SetParent does not relink a frame whose level
  and strata stay the same.

Offsets.h gains the batch, device-transform and call-site entries, and
the level-rebuild comment now says what the function actually does.
2026-10-27 19:44:43 -05:00
Brues fc8fba65cc refactor(frame): share the SetParent hook through Frame::ParentChange
MinHook allows one hook per address, and clipping needs to follow a
frame's parent as Frame::Alpha does. Frame::ParentChange now owns the
SetParent hook and runs its listeners after the engine moves the frame,
the way Frame::Destruction owns the destructor. Frame::Alpha is its first
listener, with the same behavior.
2026-10-27 19:44:42 -05:00
Brues f01a059cc0 feat(frame): useParentLevel and SetUsingParentLevel/IsUsingParentLevel
A frame that uses its parent's level draws at the parent's level instead
of one above it. Retail's ScrollBox and TrimScrollBar templates rely on it
for their scroll target and backgrounds.

4.3.4 keeps this as frame flag 0x100000, and its level setter replaces any
level it is given with the parent's while the flag is on. 1.12's setter
(0x0076A4F0) is the same function without that test; a hook adds it, so
SetParent, a parent moving its children, and SetFrameLevel all land on the
parent's level. Setting the flag moves the frame and its children to the
parent's level, as 4.3.4's SetUseParentLevel does.

The XML attribute is read after the node loads, when the frame has its
parent, and only when present, so a node's value overrides its template's.
2026-10-27 19:17:03 -05:00
Brues 8d22876c01 fix(debugtools): seat the table inspector's close button in its title bar
The stock 32x32 UIPanelCloseButton overhung the 19px title bar and sat on
the border. Size it 24x24 at the same inset as OpenParentButton so both
title-bar buttons share one centre line.
2026-10-27 18:56:18 -05:00
Brues 39a14054fe feat(frame): AnchorUtil and LayoutFrame, with their region methods
Port retail's AnchorUtil, GridLayoutUtil and LayoutFrame (Lua and XML
templates) to !!!ClassicAPI. They are verbatim except PrintAnchorGraph,
which prints to chat since 1.12 has no C_Log. GetUnscaledFrameRect and a
GMError that does nothing (a player client is never a GM client) come
along.

The region methods they call:
- GetPointByName(point) through GetPoint's own pusher.
- ClearPoint(point): ClearAllPoints (0x767620) for one slot, dropping
  the dependent bit through 0x767FA0 and deleting the node.
- IsRectValid(): the stock edge getters' steps, so it is true exactly
  when GetLeft answers a number.
- GetScaledRect() on every region.
- GetScale/GetEffectiveScale on textures and font strings: the layout
  scale the frame hands each region, and 1.
- ignoreRect on GetWidth, GetHeight and GetSize: a 0 explicit size stays
  0 instead of falling back to the rect.
2026-10-27 18:49:24 -05:00
Brues 7e1edce462 feat(xml): inherit="prepend"/"append" on <Scripts> handlers
A handler element with inherit="prepend" now runs the inherited
template's handler first and then its own; "append" runs its own
first. Vanilla's loader let the node's handler replace the template's.

Template nodes load onto the same frame before the node's own content,
so the handler a template set is captured when that template's load
returns, then joined with the node's handler (body, function= or
method=) in a C closure that calls both with the same arguments.
Elements apply in document order. The function= binding skips scripts
that ScriptInherit sets.

Also binds luaL_unref as Game::Lua::Unref and drops an unused local
typedef for it.
2026-10-27 18:23:40 -05:00
Brues e7b6c39ace feat(debugtools): port retail's table inspector
Replace the minimal /tinspect window with retail's Blizzard_TableInspector
and its anchor and attribute providers. The window lists a table's
anchors and its fields grouped by type, with child frames and regions,
editable numbers, strings and booleans, a filter, back and forward
history, open-parent, duplicate, and Show / Highlight / Dynamic Updates
toggles. The provider XML is retail's, unchanged.

1.12 changes to the main window: a backdrop and plain title bar replace
the gear-manager art, the scroll-up and plus buttons replace two retail
textures, and the scroll frame and filter box are small unnamed templates.
Windows come from a frame pool and have no names, so the stock
InputBoxTemplate's $parent-named border and UIPanelScrollFrameTemplate's
named scroll bar would be shared by every window.

/tinspect works as on retail: a table result opens on it; otherwise it
opens on the frame under the cursor, or UIParent. Ctrl in /fstack opens
TableAttributeDisplay. The frame highlight (AnchorHighlightMixin,
FrameHighlightTemplate) comes from retail's Blizzard_DebugTools.

!!!ClassicAPI gains retail's TruncatedTooltipScriptTemplate and
PanelDragBarTemplate, GetAppropriateTooltip, GameTooltip_AddNormalLine
and GameTooltip_AddColoredLine.
2026-10-27 18:06:55 -05:00
Brues 9027e22e4f feat(xml): <Anchor> x/y and relativeKey, parentArray
Retail templates write anchor offsets as attributes and name anchor
targets by key path. 1.12's anchor loader reads point, relativePoint,
relativeTo and an <Offset> child only, so those anchors landed on the
parent with no offset.

After each frame, texture and font string loads, Xml::Anchors re-sets
every <Anchor> that has relativeKey or x/y. The target is the key path,
or the one the engine's own anchor node resolved. The offsets go through
the engine's XmlReadDimension (0x006F1EB0), the reader the loader uses
for <Offset>. The point is set with the engine's setter.

A frame loads its layers before its child <Frames>, so a layer anchored
to a child frame cannot resolve at load. Such anchors are queued and
retried once a frame's children are built, before its OnLoad, and again
when the outermost build returns, which reports what is still missing.

parentArray="Name" appends the region to parent.Name in load order.
2026-10-27 18:06:45 -05:00
Brues 7add0fc88f feat(frame): region:GetDebugName([preferParentKey])
Retail's debug name for a region: its global name; else its parent's
debug name, a dot, and the key that holds it in the parent's Lua table,
or its address in hex when no key holds it; "" for an unnamed region
with no parent. preferParentKey puts the key path ahead of a global
name. The key comes from the parent's table, so a plain Lua assignment
resolves once the region is parented, as on retail.

The frame stack tooltip prints names through it, which removes the
debug tools' local _DebugName. Game::Lua::ObjectName exposes the
engine's GetName virtual for unnamed-aware callers.
2026-10-27 18:06:38 -05:00
Brues 04ce033907 feat(lua): retail SharedXML utilities and GetTickTime
Port six retail SharedXML files to !!!ClassicAPI: DataProvider,
CircularBuffer, EasingUtil, Interpolator, EnumUtil and StringUtil. Five
are verbatim. CircularBuffer drops retail's securecall and SecureTypes
wrappers, since 1.12 has no taint system. CallbackRegistry and
IconDataProvider now use EnumUtil.MakeEnum in place of inlined tables.

GetTickTime() returns the frame delta that OnUpdate receives, which
Interpolator needs. The engine keeps no copy of it after the frame
pass, so the UI render hook latches the value before any OnUpdate runs.
Events and C_Timer callbacks run earlier in the tick and see the
previous frame's value.
2026-10-27 18:06:17 -05:00
Brues 1f3ce9229d refactor: move the embedded addons into src/embed/AddOns
Users saw AddOns/!!!ClassicAPI at the repository root and downloaded it,
thinking the mod is only an addon. The addons are source code for the
DLL, so they now live next to the resource loader in src/embed/. The
README says that the DLL contains them and that the folder is not for
copying into the game.

Remove the AoELootTest dev addon.
2026-10-27 16:44:30 -05:00
Brues 091d2204a6 feat(debugtools): embed the debug tools in the DLL, load on demand
The DLL now embeds a list of addons instead of only !!!ClassicAPI. The
debug tools ship as ClassicAPI_DebugTools, a hidden, always-enabled
LoadOnDemand addon. /dump, /fstack, /etrace, /tinspect and /luaerrors
are stubs in !!!ClassicAPI that load it on first use, the way retail's
chat frame loads Blizzard_DebugTools.

The Blizzard_ name is not usable: the engine moves loose
Interface\AddOns\Blizzard* folders aside at startup and refuses a
Blizzard_* TOC without a signature.
2026-10-27 16:13:09 -05:00
Brues d93861d746 feat(tooltip): frame stack highlight, anchors, and GetSourceLocation
Retail's frame stack tooltip marks one entry and, after the list, prints
its anchors and the script that created it. Port both halves.

SetFrameStack now takes (showHidden, showRegions, highlightIndexChanged)
and returns the highlighted entry, as the current client does. The
marked line opens with "-->", regions list after their frame in
FRAMESTACK_REGION_COLOR, and ANCHORS / SOURCE sections follow the list.
The formats are the client's own, read from its string table; the
non-highlight indent and the ANCHORS header are inferred from it. Stratum
0 prints as WORLD, which 4.3.4's FrameStrataToString names and 1.12's
table does not. OnTooltipSetFrameStack now carries the highlighted entry
as its argument.

region:GetSourceLocation() (9.2.5) records where each frame was built:
a Lua CreateFrame takes the caller's "file:line" from lua_getstack /
lua_getinfo "Sl" inside the call, stepping over C levels, with the
chunk name as luaO_chunkid writes it; an XML frame takes the file
FUN_XML_LOAD_FILE is processing. Regions answer for their parent frame.
The XML file tracker moves out of Xml::ScriptPath into Xml::FileStack so
both consumers share it.

GetPoint on a screen anchor: CSimpleTop is a bare CLayoutFrame, and
Script SetPoint stores it as the relative layout for a nil relativeTo.
The stock reader forms `layout - 0x24` for it and reads the 36 bytes
before the top object as a region header. Frame::GetPointGuard now
implements GetPoint itself and pushes nil for the top, as 3.3.5 and
4.3.4 do; the frame stack's anchor lines test for it the same way. The
guard's header drops the claim that the engine never clears dependents'
anchors: CLayoutFrame::DestroyLayout does.

DebugTools: the frame stack runs the modern FrameStackTooltip on the new
method. Left/Right Alt move the mark, Ctrl (released) opens the marked
entry in the TableInspector, Shift adds texture information, fsobj is
set after an Alt press, and /fstack reads [showHidden [showRegions]]
through StringToBoolean. The modifier keys come from
MODIFIER_STATE_CHANGED instead of retail's polled KeyCommand table. The
green FrameStackHighlight cover and showAnchors are not ported: the
cover frames sit at TOOLTIP strata under the cursor and become the top
entry themselves.
2026-10-27 15:11:09 -05:00
Brues d9827852c7 docs(readme): fold Lua 5.1 into one Highlights table
Condense the six-row Lua 5.1 section into two rows of a single
Highlights table, and trim the soft targeting, combat log, and
#showtooltip rows. Full detail stays in docs/API.md.
2026-10-27 12:32:37 -05:00
Brues 2e75812339 fix(loot): stop unanswered loot requests from blocking all looting
The engine holds the player's loot target as it sends CMSG_LOOT, and
CanLootNow refuses every later loot while it is held. With no window
open only a server reply clears it, so a request the server never
answered locked looting until relog. LootAllCorpses while mounted hit
this reliably: the engine's right-click skips looting while mounted,
but FUN_CMSG_LOOT_UNIT has no such gate.

All loot senders now go through Loot::Request, which refuses while
mounted, reports whether the engine actually sent, and on timeout
releases a still-held target the way the release response does.

Fixes #113
2026-10-27 12:21:40 -05:00
Brues 1454dbe00c feat(spell): report isOnGCD from C_Spell.GetSpellCooldown
The engine's cooldown reader keeps the latest-ending of a history
record's spell, category and global legs. isOnGCD is true when a live
global leg carries exactly the start and duration it reported.
2026-10-27 12:08:47 -05:00
Brues 259071b647 fix(spell): report GetSpellInfo ranges through the engine resolver
GetSpellInfo and C_Spell.GetSpellInfo read the raw SpellRange.dbc row, so
range talents, spell mods, the ranged-item scale and the 0x404 flat range
were missing. Read min/max through FUN_GET_MIN_MAX_RANGE for the caster
(player, or pet for a "pet" book slot), and report 0/0 for a melee row,
as 4.3.4's Script_GetSpellInfo does.
2026-10-27 12:03:05 -05:00
Brues 2d887df71c fix(compat): stop Outfitter's minimap button acting as if dragged
Outfitter never initializes OutfitterMinimapButton.IsDragging, so a read
before the first drag falls through to the region:IsDragging() method
and is truthy. Its update frame then calls UpdateDragPosition every
frame with an unset CursorStartX. Set the field to false on load.
2026-10-27 10:19:15 -05:00
Brues 317539dbd8 fix(cooldown): keep a reverse frame's swipe full while its flash plays
A reverse sweep covers the icon as time passes, so at the end the icon is
fully dark. The sweep's end switched the frame to its flash state, where
UpdateColors cleared the swipe for both directions, and the flash's first
key has alpha 0. For the frames between the client-side end and the
aura's removal from the server (pfUI buffs with reverse timers), the bare
icon showed bright before the button hid.

3.3.5 and 4.3.4 never show this: they pin a reverse cooldown at
duration - 1 and never flash or hide it, so the icon stays covered until
the addon hides the button. The port took retail's timer semantics for
reverse (OnCooldownDone, flash times, hide after the flash) and dropped
the swipe with the hold.

Keep the retail state machine and 4.3.4's look: in the flash state a
reverse frame draws the full fan (what 4.3.4's flash branch draws for
reverse), set every tick so a timer already over at its first update does
not draw ResetVerts' zeroed fan, and UpdateColors keeps the swipe colour
for a reverse frame. The edge still goes with the flash, as in 4.3.4.

The docs/API.md line for SetReverse (direction corrected: the sweep starts
empty and becomes full) went in with 85b1365.
2026-10-27 01:16:47 -05:00
Brues 10d71371dd fix(equipmentset): pack main-bank locations as inventory slot ids
C_EquipmentSet.GetItemLocations encoded an item in the main bank as
BANK | bankSlot (1..24). The engine form, in both 4.3.4 and 3.3.5
Script_GetEquipmentSetLocations, is BANK | inventorySlotId: the value
BankButtonIDToInvSlotID returns (id + 39 in 1.12, verified), so bank
slot 3 is 0x40002A, not 0x400003. FrameXML relies on that form -- a
location without the BAGS bit goes straight to
GetInventoryItemID("player", slot) in
EquipmentManager_GetItemInfoByLocation, so the old code made the
shared helpers (and our EquipmentManager_GetLocationData) read the
paperdoll instead of the bank.

Add INVSLOT_BANK1 = 40 to Offsets.h, pack through it in PackMainBank
and invert it in UnpackContainer so UseEquipmentSet keeps swapping
from bag -1 with the 1-based slot Item::Swap::FromBag takes. Docs now
show the inventory-slot form and the 24-slot main bank.
2026-10-27 01:16:10 -05:00
Brues 4959c2c38c docs(sound): stop naming a file count for PlaySoundFile
API.md said only two files from PlaySoundFile can play at once. That is
the engine's on-disk cap for its bus, but it is not what the client
enforces here: SuperWoW's UncapSounds cvar lifts it, and a probe on this
client played 16 files at once. Describe the limit without a number and
keep the part that is true everywhere: a file holds its place until it
ends, a fading file still counts, and the call returns false at the limit.
2026-10-27 00:23:33 -05:00
Brues cece5a4214 feat(string): add strlenutf8, strcmputf8i, strsplittable and strconcat
The four Blizzard string globals still missing from the base library.
strlenutf8 and strconcat are 4.3.4's (a count of non-continuation bytes,
and lua_concat over the whole stack); strcmputf8i and strsplittable are
the newer clients'. Text::Utf8 mirrors 4.3.4 Storm's SStrLenUTF8 and
SStrCmpUTF8I, decoder and case fold included, since 1.12's Storm has
neither. strsplit and strsplittable share one scan; the table form never
grows the Lua stack. string.concat is the by-value twin of strconcat, as
string.split is of strsplit. Adds the lua_concat binding (0x006F44E0).
2026-10-26 23:49:10 -05:00
Brues 9daf3960c4 feat(combatlog): add SPELL_AURA_REFRESH, overkill and overhealing
SPELL_AURA_REFRESH is 4.3.4's "same spell in the slot, duration updated".
Aura::Source gains a Refreshed change: the player's SMSG_UPDATE_AURA_DURATION
for an occupied slot whose duration was already known (an application's
packet comes before the descriptor write, so its slot reads empty; the login
burst — Map::ExistingPlayerLogin, the one path that re-sends every duration
— finds no earlier duration because the table is cleared with the glue
screen), and for other units the recast the cache refreshed or a server
refresh mechanic it mirrors. Pending refreshes resolve at the world tick
against the descriptor, so a slot that changed hands in the same tick is an
application, not a refresh. The tick-speed compression nudge and duration
reductions do not count.

Overkill and overhealing follow 4.3.4's predicted health. The server sends
each damage and heal message before the tick's health update, so the target
still shows the health before it (verified live: 148, 113, 91, 63, 37, 10
under Fireballs of 34, 22, 27, 26, 27, then the kill); the messages since the
field was last read are kept as a delta so a tick's second hit counts its
first. A unit whose health the server hides (maximum 100, the stock
ShowHealthValues setting) gets 0.

Verified live: Frost Armor recast -> SPELL_AURA_REFRESH, nothing at login or
on a zone change; a 772 Fireball on a 194-health boar -> overkill 578.
2026-10-26 21:29:50 -05:00
Brues 39ab9eb1ee feat(combatlog): name the damage shield, the dispelling spell and the instakill caster
Three values 1.12's messages leave out, read from what the client already
holds at the moment each message arrives.

DAMAGE_SHIELD: the owner's one damage-shield aura (EffectApplyAuraName 15)
of the damage's school names the shield — Thorns, Fire Shield, Retribution
Aura. Two of one school on one owner, or an owner out of view, keep the
unnamed form.

SPELL_DISPEL, SPELL_DISPEL_FAILED and SPELL_INSTAKILL: the server sends each
from the spell's effect handler, in the same tick as and after that cast's
SMSG_SPELL_GO. CombatLog::RecentCasts keeps the last second of hit lists,
fed from Casts.cpp's existing SPELL_GO parse, and names the caster's newest
cast at the target with a dispel effect (38, 108), or the caster of the
newest cast of the instakill spell at the victim. A dispel from an aura
(Abolish Poison) has no cast and stays unnamed.

Verified live: Remove Lesser Curse -> SPELL_DISPEL 475 "Remove Lesser Curse"
"Curse of Tongues"; a druid's Thorns -> DAMAGE_SHIELD 9910 "Thorns".
2026-10-26 20:55:52 -05:00
Brues e8bd35d8e4 feat(combatlog): log immune damage and summons from packets 1.12 already sends
SMSG_SPELLORDAMAGE_IMMUNE (0x263) becomes DAMAGE_SHIELD_MISSED / IMMUNE
(SPELL_PERIODIC_MISSED when the debug byte is set), the mapping 4.3.4 and
3.3.5 use. 1.12 writes its IMMUNESPELL line from the handler's own
formatter, not the miss writer Miss.cpp hooks, so the event never fired.

SMSG_SPELLLOGEXECUTE: read the summoned GUID the server writes for the
summon-family effects (tortoise Spell::SendLogExecute), which 1.12's reader
leaves unread in the buffer. A unit is SPELL_SUMMON, a game object or
dynamic object SPELL_CREATE. Portals stay silent: every portal spell has
Attributes 0x100, the hide-from-log bit both the engine's lines and 4.3.4
honor.

Verified live: Healing Stream Totem -> SPELL_SUMMON, Fishing -> SPELL_CREATE
"Fishing Bobber", Fireball on an immune Fireguard Destroyer ->
DAMAGE_SHIELD_MISSED IMMUNE.
2026-10-26 20:46:09 -05:00
Brues f4d8f56c59 feat(editbox): add history lines at the end of the ring, as retail does
The engine's history ring uses one position for writing and for Up/Down
browsing, so a line sent after browsing overwrote the line the browsing
reached, and later sends overwrote the newer lines after it. Keep each
box's write position beside the engine's field and put it back before
every add, so a new line always lands at the end and the next Up starts
from it. Like retail, a line equal to the newest one (case-sensitive) is
not added again.
2026-10-26 19:29:12 -05:00
Brues 3f33320724 feat(item): add IsDualWielding and drop the OffhandHasWeapon override
IsDualWielding() ports 4.3.4's Script_IsDualWielding (0x00452A50): true
when the off-hand item is weapon class and the player can dual wield.

OffhandHasWeapon is a native 1.12 global (0x004C9570, same body as
3.3.5/4.3.4: off-hand item class 2 -> 1, else nil). Our override
returned booleans and tested inventory type instead, so remove it and
let the engine's own function stand.

Item class IDs 2 (Weapon) and 12 (Quest) are now used in more than one
file, so they move to Offsets.h as ITEM_CLASS_WEAPON / ITEM_CLASS_QUEST
(verified against ItemClass.dbc).
2026-10-26 18:55:56 -05:00
Brues 492085ac0c feat(aura): add aura instance IDs and the UNIT_AURA_DELTA event
1.12 has no aura identity: a unit's descriptor holds 48 spell IDs and
nothing that tells one application from the next. Aura::Instance gives
each visible aura an ID when it lands in a slot and keeps it until the
aura leaves; IDs are never reused. One sync step (recorded slot vs live
descriptor) drives everything: the existing Aura::Source add / remove /
stacks observers sync the slot they name, the getters sync before
answering, and a per-frame reconcile catches bulk clears and rebuilt
objects. No new engine hooks.

UNIT_AURA_DELTA(unitTarget, updateInfo) carries retail's UNIT_AURA
payload under its own name, so the engine's UNIT_AURA is untouched. It
fires once per unit per frame, per token, with instance-ID lists only,
in four reused tables, so a fire allocates nothing. With no listener,
no change sets are kept. Timing-only refreshes (cache rewrites, the
player's duration packet) are reported as updated.

Also:
- auraInstanceID on every AuraData table and on C_LossOfControl CC
  entries
- C_UnitAuras.GetAuraDataByAuraInstanceID(unit, id [, buf]); the buf
  form fills the caller's table
- Game::Lua::Ref, the shared luaL_ref binding, replacing two local
  typedefs in the animation code
2026-10-26 18:39:03 -05:00
Brues 7b3b61de15 fix(lossofcontrol): take CC timing from the server's player aura timer
The CC scan read its timing from the cast-observation cache, which holds
a client-side estimate: base duration for another caster's spell, and no
diminishing returns at all. For the local player the server sends the
applied duration in SMSG_UPDATE_AURA_DURATION, and the engine keeps its
expiry in the player buff expiration table.

Record that packet's duration per slot and expose both through
Aura::Data::PlayerAuraTiming. C_LossOfControl uses it for every CC
debuff, and C_UnitAuras now reports the applied duration for the
player instead of the base or cached estimate.
2026-10-26 18:03:12 -05:00
Brues 77efe8d796 fix(unit): read feign death from the dynamic flags, as 3.3.5 does
UnitIsFeignDeath tested UNIT_FIELD_FLAGS bit 29. tortoise-wow sets that
bit next to UNIT_DYNFLAG_DEAD, but vmangos sets only the dynamic flag,
so the getter always returned false there. 3.3.5's
Script_UnitIsFeignDeath (0x0060F3D0) tests UNIT_DYNFLAG_DEAD.
2026-10-26 17:56:10 -05:00
Brues 8378f3c350 chore(doc): drop the per-pass registration count log
Every install wrote "[doc] <env> pass: N registrations recorded, M
undocumented" to classicapi_debug.log on each glue and game pass. It
helped no one, and _classicapi_UndocumentedAPI() already answers the
question on demand. Removes the line and the counter that only fed it.
2026-10-26 17:35:54 -05:00
Brues 1a2f806f16 fix(event): skip the slots the engine fires by number
A NULL name in the event table did not mean the slot was free. The
engine fires events by id, and two NULL-named in-game ids still fire:
the glue band (ids 0..25, including ADDON_LIST_UPDATE at 6, which the
SMSG_ADDON_INFO listener fires whichever table is installed) and chat
type 89's id 540. Our low walk put the 5th custom event on slot 6.

Claimable() now requires an id past GLUE_EVENT_COUNT that is not in the
engine's chat-type id table, read live. Both the claim walk and Grow's
headroom count use it. Verified against benilla-re's fire-site census
and this exe's own name initializer; a live dump puts our 77 events at
31..110 with nothing below 26.
2026-10-26 17:33:12 -05:00
Brues 90be617700 fix(equipmentset): play the drop sound when a held set is cleared
1.12's cursor clear plays INTERFACESOUND_CURSORDROPOBJECT from a kind
table that stops at 10, so the set (13) dropped silently. Co-hook it
with 4.3.4's set arm: reset the ID and play the sound, then run the
engine's shared tail.
2026-10-26 17:18:45 -05:00
Brues 45b54e89ba fix(equipmentset): drop a held set on right-click
The world input hook's right-press clear tests the engine's payload
globals rather than the cursor type, and an equipment set has none, so
the press fell through. Co-hook it to clear and consume for our type.
2026-10-26 17:11:00 -05:00
Brues 35b8eaa376 feat(tooltip): grow the line pool on demand, as 4.3.4 does
Replace the scan-time grow to a fixed 60 lines with 4.3.4's
CGTooltip::AddLine grow: co-hook the add-line gate (FUN_00530270, which
every Lua method and native builder goes through) and, when the line
about to be added takes the last slot, build the next TextLeft/Right
pair and append it through the engine's own AddFontStrings body
(FUN_00529940). The pool keeps one spare line after every add, with no
cap, and tooltips no longer pre-build 30 unused line pairs.

New lines start hidden (the clear only hides lines below numLines) and
take the engine's line gap constant instead of a literal.
2026-10-26 16:44:34 -05:00
Brues f5082cedee fix(texture): keep NPOT out of the recycle caches, allow sides below 8
The dimension gate routes NPOT textures around the recycle pool on
allocation, but the free path filed them anyway: TexturePoolRelease
buckets by each side's power-of-two factor with no POT test, so a freed
48x48 landed in the 32x32 bucket and was handed to the next 32x32
request. Pool on free exactly what the allocator pools (POT, no larger
than the pool max), as 4.3.4's GxTexReusable does.

The decoded-buffer cache (TextureAllocMips / TextureFreeMips) buckets
the same way, so an NPOT BLP on the model/character cache path could be
given a smaller cached buffer and decode past it. NPOT sizes now take
the engine's own uncached alloc and free.

The create validator's 8px minimum is lowered to 1, so 1x1 and 4x4
textures load. A failed D3D create leaves the texture blank.
2026-10-26 16:31:10 -05:00
Brues cca8ff3c55 feat(frame): pass 3.3.5's click down and enter/leave motion
3.3.5 runs OnClick/PreClick/PostClick as (self, button, down) and
OnEnter/OnLeave as (self, motion). 1.12's runners carry only
%d/%u/%f/%s, and neither value reaches the script.

Frame::ScriptArgs gains AutoTrailingBool: a provider supplies one boolean
that becomes the last argN and positional argument of a run on the
positional path. Handlers that declare no parameters keep vanilla's
globals.

- down: the click vmethod gets (mask, fromScript) and never learns press
  or release, but its callers do. The six 3.3.5 maps onto -- the press
  handler (down = 1), the release handler (0) and Script_Click's four
  name branches (StringToBOOL(L, 3, 0), as 3.3.5's Click reads it) --
  each `call [edx+0x94]`, now replaced with a thunk that records
  {button, down} and jumps through the same slot. The click detour takes
  the record for its own button and offers it to that button's
  OnClick/PreClick/PostClick runs.
- motion: the mouse-focus pass 0x007660D0 is 3.3.5's OnMouseMove. Its
  re-pick passes the context's saved event (+0x1104), the same test
  3.3.5 uses for motion. A co-hook records {context, old focus, motion}
  for the pass; its own leave (old focus) and enter (new focus at +0x7C)
  get motion, and every other enter/leave gets false.

Verified in-game: enter/leave on a mouse move give true, and a hide and
show under a still cursor give false. A press and release on a button
registered for both give down true then false, Click("RightButton",
true) gives true, and Click() gives false.
2026-10-26 16:04:23 -05:00
Brues b4256406f5 fix(frame): match 3.3.5's script runner on arg count, global saves and event
Three differences from 3.3.5's FrameScript_Execute (0x00819EA0):

- No argument limit. The engine runner sets only arg1..arg19 (0x704F10
  stops at the 19th spec, 0x704F73), and our parser copied that limit for
  the positional args too. 3.3.5 sets every argN. The parser is replaced by
  a cursor over the format, and a fire with more than 19 values takes the
  reimplemented path even for a handler that declares no parameters.
  argN names come from snprintf; the old two-digit builder misnamed arg100
  onward.
- Raw saves. The old this/argN values and the positional `event` are read
  with rawget, as 3.3.5 does, so a `_G` __index (a strict-globals guard)
  no longer runs for an unset argN.
- `event` per handler. The engine dispatchers set `event` once for the
  whole dispatch, so a handler that assigned the global changed it for
  every listener after it. Each OnEvent run now saves `event` before the
  handler and restores it after, on both runners and RunWithValues, as
  3.3.5 does per handler. No new hooks; off while SetModernScriptArgs is
  false.

Verified in-game: two listeners that each print `event` and then set it
both print PLAYER_TARGET_CHANGED, and a `_G` __index sees no `this` read
from a modern handler's run.
2026-10-26 15:51:01 -05:00
Brues 2adabbbd70 fix(frame): pass positional script args to C-function handlers
The arity gate on Frame::ScriptArgs sent every C closure except the
HookScript wrapper through the original no-arg runner, on the premise
that C functions never read positional args. Frame methods do: they take
`self` from argument 1 and raise "Attempt to find 'this' in non-table
object" without it, never falling back to the `this` global. So the
common modern idiom

    f:SetScript("OnDragStart", f.StartMoving)

errored, where 3.3.5's FrameScript_Execute passes (self, ...) to every
handler.

Treat any C closure as wanting args. This cannot regress a working
vanilla addon that binds a frame method, since that call already errored.
The Lua-closure gate from a4d6290 stays as is.

Verified in-game: a frame with OnShow = f.Hide stays hidden, a param-less
vanilla handler still sees this/arg1, and OnDragStart/OnDragStop bound to
f.StartMoving/f.StopMovingOrSizing drag the frame.
2026-10-26 15:41:48 -05:00
brues-code 42ed0d27c7 Port the later clients' Cooldown API (#112)
* Add retail Cooldown methods and OnCooldownDone

Port the post-4.3.4 Cooldown API onto the native CGCooldown frame:
Clear, Pause/Resume/SetPaused/IsPaused, GetCooldownTimes,
GetCooldownDuration, GetCooldownDisplayDuration, SetCooldownDuration,
SetCooldownFromExpirationTime, the modRate argument, SetDrawSwipe,
SetDrawBling, SetSwipeColor, SetEdgeColor, the drawSwipe/drawBling XML
attributes, and the OnCooldownDone script (via Frame::ExtraScripts).

Behavior follows retail probes, not the generated doc strings: the
getters report the end flash once the sweep ends, modRate scales the
flash and the display duration but not the sweep, and a reverse
cooldown flashes and hides like any other.

Add CooldownFrame_Set, CooldownFrame_Clear and
CooldownFrame_SetDisplayAsPercentage to the embedded addon.

* List the new Cooldown methods in the README table

* Add Cooldown swipe, edge and bling textures

SetSwipeTexture, SetEdgeTexture and SetBlingTexture, and the
<SwipeTexture>, <EdgeTexture> and <BlingTexture> XML elements, split
4.3.4's star texture into three slots. An empty file is the engine's
solid texture tinted by the slot colour, and Interface\Cooldown\edge
maps to the built-in edge texels. A setter without r, g, b keeps the
colour, as retail does. Slots left unset get 4.3.4's textures at the
first SetCooldown, so the defaults stay 4.3.4's. The swipe's texcoords
now map its positions over the frame so a custom texture lines up.

* Add Cooldown rotation

SetRotation / GetRotation and the XML rotation attribute (degrees) move
where the sweep starts, clockwise from the top, as retail does. The
sweep is now one polygon along the frame's outline in place of 4.3.4's
four octant functions, with the same timing (each half side an eighth
of the duration) and, at rotation 0, the same shape. The one difference
is 4.3.4's reverse octant 1, which drew one triangle twice.

* Add Cooldown countdown numbers

Every Cooldown gets a countdown font string (FRIZQT__ 16 OUTLINE,
centred), shown while retail's countdownForCooldowns CVar is 1; the
CVar defaults to 0, so the default look stays 4.3.4's. The text
follows retail: seconds, then m:ss below the abbreviation threshold,
then whole minutes, hours and days rounded up, and nothing for
cooldowns no longer than the minimum duration or during the flash.
Adds SetHideCountdownNumbers / GetHideCountdownNumbers,
GetCountdownFontString, SetCountdownAbbrevThreshold /
GetCountdownAbbrevThreshold, SetMinimumCountdownDuration /
GetMinimumCountdownDuration and the hideCountdownNumbers and
minimumCountdownDuration XML attributes.

* Add the Cooldown countdown milliseconds threshold

SetCountdownMillisecondsThreshold / GetCountdownMillisecondsThreshold:
below the threshold the numbers show tenths, rounded up, as retail
does (threshold 5: 4.973 shows 5.0, 3.24 shows 3.3, 0.039 shows 0.1).
The default is 0, so the numbers stay whole seconds.

* Fix the Cooldown review findings

- A zero-length timer (a new frame, SetCooldown(t, 0)) hides without the
  bling flash or OnCooldownDone, instead of flashing and reporting
  (now, 1000) from the getters.
- Countdown text rounds up with integer math (700 ms read "0.8") and
  picks its unit on the rounded-up seconds (59.4 s read "60", not "1:00").
- SetMinimumCountdownDuration and minimumCountdownDuration clamp to the
  uint32 range.
- The edge texcoord rotation uses the guarded done fraction (0/0 gave NaN).
- Texture slots are one array with per-slot tag, default file and draw
  layer (Offsets::DRAWLAYER_*), replacing the ternaries and bare 2/3.
- The header lists where the state machine follows retail over 4.3.4.

edge.blp is now served to every texture load by texture/Builtin.cpp, a
hook on TextureCreateFromBlp that falls back to the DLL's texels only when
SFile has no such file; TextureCreate caches and ref-counts it like a file.
Cooldown loads it by path and drops its edge special case.

Frame::Region::CreateTexture / CreateFontString replace the hand-rolled
pool-alloc + ctor copies in Cooldown, LinePool, BarTexture, SoftTargetIcon
and InlineTexturePool.
2026-10-26 15:21:34 -05:00
Brues 0d19deaee0 Add ItemHasRange global and C_Item.ItemHasRange
Global returns 1/nil like 3.3.5; namespaced form returns a bool. Both resolve the item to its on-use spell and use the shared SpellIDHasRange engine check. Verified in-game: ItemHasRange(1251) -> 1, C_Item.ItemHasRange(6948) -> false.
2026-10-26 00:38:59 -05:00
Brues abd5367533 feat(nameplate): exempt plate subtrees from inherited alpha, add selected/non-selected alpha CVars
Default nameplates and anything parented under them keep vanilla alpha semantics, so third-party overlays like pfUI's are never dimmed by the engine fade or multiplied by a parent. The plate fade emulates the original setter but spares children with an explicitly-set alpha. nameplateNotSelectedAlpha (0.5) and nameplateSelectedAlpha (1) resolve the fade's legacy 0x7F/0xFF writes; both defaults are byte-identical to vanilla.
2026-10-25 19:54:51 -05:00
777 changed files with 102954 additions and 14577 deletions
+1
View File
@@ -10,3 +10,4 @@
*.png binary
*.jpg binary
*.ico binary
*.blp binary
+1 -1
View File
@@ -47,4 +47,4 @@
CLAUDE.md
/AddOns/!!!ClassicAPI/.classicapi-dev
/src/embed/AddOns/*/.classicapi-dev
-71
View File
@@ -1,71 +0,0 @@
## Interface: 11200
## Title: !!!ClassicAPI
## Notes: Library to emulate aspects of the modern WoW API on 1.12.1 / Lua 5.0.
## Version: DEV
## Author: Brues
## X-Website: https://github.com/brues-code/ClassicAPI
locales\locales.xml
FrameXML\InterfaceOptions.xml
FrameXML\InterfaceOptions.lua
SecureTemplates.xml
Compat.lua
Util\Print.lua
Util\Mixin.lua
Util\Color.lua
Util\Constants.lua
Util\TableUtil.lua
Util\MathUtil.lua
Util\PixelUtil.lua
Util\FrameUtil.lua
Util\FormattingUtil.lua
Util\TimeUtil.lua
Util\CallbackRegistry.lua
Util\GlobalCallbackRegistry.lua
Util\ColorUtil.lua
Util\ItemLocation.lua
Util\PlayerLocation.lua
Util\EventUtil.lua
Util\ItemUtil.lua
Util\AddOnCompat.lua
Util\FunctionUtil.lua
Util\CacheUtil.lua
Util\Pools.lua
Util\FrameWatcher.lua
Util\TimedCallback.lua
Util\EquipmentManager.lua
Util\PlayerUtil.lua
Util\SlashCommandsRegistry.lua
Util\LinkUtil.lua
Util\TradeSkillLink.lua
Util\IconDataProvider.lua
Util\GameTooltip.lua
Util\Rectangle.lua
Util\Vector2D.lua
Util\Vector3D.lua
Util\Vector4D.lua
Util\UiMapPoint.lua
Util\UIParent.lua
Util\ActionButton.lua
Util\Cooldown.lua
Util\WorldMap.lua
Util\AuraDurationModifiers.lua
Util\AuraUtil.lua
Util\SecureUnitMenu.lua
Util\TextureUtil.lua
Util\MacroOptions.lua
Util\SecureStateDriver.lua
Util\MacroIconSelection.lua
Options\Panel.lua
APIDocumentation\BaseAPIMixin.lua
APIDocumentation\FieldsAPIMixin.lua
APIDocumentation\FunctionsAPIMixin.lua
APIDocumentation\SystemsAPIMixin.lua
APIDocumentation\TablesAPIMixin.lua
APIDocumentation\EventsAPIMixin.lua
APIDocumentation\APIDocumentation.lua
APIDocumentation\APIDocumentation_Bootstrap.lua
-3
View File
@@ -1,3 +0,0 @@
if not _G then
_G = getfenv(0)
end
-333
View File
@@ -1,333 +0,0 @@
-- ClassicAPI's own entry in the AddOn Options window
-- (FrameXML\InterfaceOptions.lua): a ClassicAPI page with Controls, Combat
-- and Text To Speech pages under it.
--
-- Where retail's Settings shows a setting, the option copies its label,
-- tooltip and value mapping (Blizzard_SettingsDefinitions_Frame: Controls,
-- Combat, Accessibility; the TTS options use the Text To Speech window's
-- labels). Retail has no labels for the player cache, the cast settings or
-- friendly soft targeting, so those strings are ClassicAPI's own.
--
-- Changes apply at once. Cancel (and Escape) restores what each page held
-- when it was first shown since the last Okay or Cancel; Defaults restores
-- the shown page's defaults.
--
-- Lua 5.0: no `...` expression or select().
local L = {
ABOUT = "Settings for the features that ClassicAPI adds to the game.",
PLAYER_CACHE = "Player Cache",
REMEMBER_PLAYERS = "Remember players",
REMEMBER_PLAYERS_TOOLTIP = "Saves the name, class, race and sex of the players you meet, so addons can show them in later sessions. All your characters on this realm share the data.",
FRIEND_TARGETING = "Enable Friendly Action Targeting",
FRIEND_TARGETING_TOOLTIP = "Picks the nearest friendly unit where you are looking. When you have no target, your helpful spells go to it.",
AUTO_STAND = "Auto Stand",
AUTO_STAND_TOOLTIP = "Stand up when you cast a spell while you sit.",
AUTO_DISMOUNT = "Auto Dismount",
AUTO_DISMOUNT_TOOLTIP = "Dismount when you cast a spell while you are mounted.",
AUTO_UNSHIFT = "Auto Cancel Form",
AUTO_UNSHIFT_TOOLTIP = "Leave your form when you cast a spell that you cannot cast in it.",
}
-- Enum.SoftTargetEnableFlags: retail's checkboxes switch between Any (on)
-- and Gamepad (off).
local SOFT_TARGET_GAMEPAD, SOFT_TARGET_ANY = 1, 3
-- ---------------------------------------------------------------------------
-- Settings: get / set / reset over cvars or the player cache
-- ---------------------------------------------------------------------------
local function ResetCVars(cvars)
for _, cvar in ipairs(cvars) do
SetCVar(cvar, GetCVarDefault(cvar))
end
end
local function CVarSetting(cvar)
return {
get = function() return GetCVar(cvar) == "1" end,
set = function(value) SetCVar(cvar, value and "1" or "0") end,
reset = function() ResetCVars({ cvar }) end,
}
end
local function NumberCVarSetting(cvar)
return {
get = function() return tonumber(GetCVar(cvar)) end,
set = function(value) SetCVar(cvar, tostring(value)) end,
reset = function() ResetCVars({ cvar }) end,
}
end
-- Retail's "Enable Interact Key" / "Enable Action Targeting" proxies.
local function SoftTargetSetting(cvar)
return {
get = function() return tonumber(GetCVar(cvar)) == SOFT_TARGET_ANY end,
set = function(value) SetCVar(cvar, tostring(value and SOFT_TARGET_ANY or SOFT_TARGET_GAMEPAD)) end,
reset = function() ResetCVars({ cvar }) end,
}
end
-- Retail's "Interact Key Icons" proxy: 1 = NPCs only, 2 = all, 3 = none.
local ICON_CVARS = { "SoftTargetIconEnemy", "SoftTargetIconInteract", "SoftTargetIconGameObject", "SoftTargetLowPriorityIcons" }
local ICON_PRESETS = {
[1] = { "0", "1", "0", "0" },
[2] = { "1", "1", "1", "1" },
[3] = { "0", "0", "0", "0" },
}
local interactIconsSetting = {
get = function()
local on, off = true, true
for _, cvar in ipairs(ICON_CVARS) do
if GetCVar(cvar) == "1" then off = false else on = false end
end
if on then return 2 elseif off then return 3 else return 1 end
end,
set = function(value)
for i, cvar in ipairs(ICON_CVARS) do
SetCVar(cvar, ICON_PRESETS[value][i])
end
end,
reset = function() ResetCVars(ICON_CVARS) end,
}
local rememberSetting = {
get = function() return C_PlayerCache.IsEnabled() and true or false end,
set = function(value) C_PlayerCache.SetEnabled(value) end,
reset = function() C_PlayerCache.SetEnabled(false) end,
}
-- ---------------------------------------------------------------------------
-- Pages
-- ---------------------------------------------------------------------------
local numControls = 0
local function NextName()
numControls = numControls + 1
return "ClassicAPIOptionsControl" .. numControls
end
local function RefreshControls(page)
page.updating = true
for _, control in ipairs(page.controls) do
control:Update()
end
page.updating = nil
end
local function NewPage(name, parent, title, subtitle)
local page = CreateFrame("Frame")
page.name = name
page.parent = parent
page.controls = {}
local heading = page:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge")
heading:SetPoint("TOPLEFT", page, "TOPLEFT", 16, -16)
heading:SetText(title)
page.y = -40
if subtitle then
local text = page:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
text:SetPoint("TOPLEFT", heading, "BOTTOMLEFT", 0, -8)
text:SetPoint("RIGHT", page, "RIGHT", -32, 0)
text:SetJustifyH("LEFT")
text:SetText(subtitle)
page.y = -64
end
page.refresh = function()
if not page.saved then
page.saved = {}
for i, control in ipairs(page.controls) do
page.saved[i] = control.setting.get()
end
end
RefreshControls(page)
end
page.okay = function()
page.saved = nil
end
page.cancel = function()
if not page.saved then return end
for i, control in ipairs(page.controls) do
if control.setting.get() ~= page.saved[i] then
control.setting.set(page.saved[i])
end
end
page.saved = nil
end
page.default = function()
for _, control in ipairs(page.controls) do
control.setting.reset()
end
RefreshControls(page)
end
InterfaceOptions_AddCategory(page)
return page
end
local function AddControl(page, control, setting, height)
control.setting = setting
table.insert(page.controls, control)
page.y = page.y - height
end
local function AddHeader(page, text)
local header = page:CreateFontString(nil, "ARTWORK", "GameFontNormal")
header:SetPoint("TOPLEFT", page, "TOPLEFT", 16, page.y - 8)
header:SetText(text)
page.y = page.y - 28
end
-- `enabledIf`, when given, greys the checkbox out while it returns false.
local function AddCheckbox(page, label, tooltip, setting, enabledIf)
local check = CreateFrame("CheckButton", NextName(), page, "OptionsCheckButtonTemplate")
check:SetPoint("TOPLEFT", page, "TOPLEFT", 16, page.y)
local text = getglobal(check:GetName() .. "Text")
text:SetText(label)
check.tooltipText = tooltip
check:SetScript("OnClick", function()
this.setting.set(this:GetChecked() and true or false)
RefreshControls(page)
end)
check.Update = function(self)
self:SetChecked(self.setting.get())
if not enabledIf or enabledIf() then
self:Enable()
text:SetTextColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b)
else
self:Disable()
text:SetTextColor(GRAY_FONT_COLOR.r, GRAY_FONT_COLOR.g, GRAY_FONT_COLOR.b)
end
end
AddControl(page, check, setting, 30)
return check
end
-- `options()` returns the entries as { value = ..., text = ... }.
local function AddDropDown(page, label, tooltip, setting, options)
local title = page:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
title:SetPoint("TOPLEFT", page, "TOPLEFT", 20, page.y - 6)
title:SetText(label)
local dropdown = CreateFrame("Frame", NextName(), page, "UIDropDownMenuTemplate")
dropdown:SetPoint("TOPLEFT", page, "TOPLEFT", 0, page.y - 20)
UIDropDownMenu_SetWidth(180, dropdown)
dropdown:EnableMouse(true)
dropdown:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(tooltip, nil, nil, nil, nil, 1)
end)
dropdown:SetScript("OnLeave", function() GameTooltip:Hide() end)
local function Initialize()
local selected = setting.get()
for _, option in ipairs(options()) do
local info = {}
info.text = option.text
info.value = option.value
info.checked = option.value == selected and 1 or nil
info.func = function()
UIDropDownMenu_SetSelectedValue(dropdown, this.value)
setting.set(this.value)
end
UIDropDownMenu_AddButton(info)
end
end
dropdown.Update = function(self)
UIDropDownMenu_Initialize(self, Initialize)
UIDropDownMenu_SetSelectedValue(self, setting.get())
end
AddControl(page, dropdown, setting, 56)
return dropdown
end
local function AddSlider(page, label, tooltip, setting, minValue, maxValue)
local slider = CreateFrame("Slider", NextName(), page, "OptionsSliderTemplate")
slider:SetPoint("TOPLEFT", page, "TOPLEFT", 24, page.y - 20)
slider:SetWidth(200)
slider:SetMinMaxValues(minValue, maxValue)
slider:SetValueStep(1)
slider.tooltipText = tooltip
getglobal(slider:GetName() .. "Text"):SetText(label)
getglobal(slider:GetName() .. "Low"):SetText(minValue)
getglobal(slider:GetName() .. "High"):SetText(maxValue)
local value = slider:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
value:SetPoint("LEFT", slider, "RIGHT", 8, 0)
slider:SetScript("OnValueChanged", function()
local v = math.floor(this:GetValue() + 0.5)
value:SetText(v)
if not page.updating and v ~= setting.get() then
setting.set(v)
end
end)
slider.Update = function(self)
local v = setting.get()
self:SetValue(v)
value:SetText(v)
end
AddControl(page, slider, setting, 56)
return slider
end
-- ---------------------------------------------------------------------------
-- ClassicAPI
-- ---------------------------------------------------------------------------
local version = GetAddOnMetadata("!!!ClassicAPI", "Version")
local main = NewPage("ClassicAPI", nil, "ClassicAPI " .. version, L.ABOUT)
-- ---------------------------------------------------------------------------
-- Controls
-- ---------------------------------------------------------------------------
local controls = NewPage(CONTROLS_LABEL, main.name, CONTROLS_LABEL)
AddCheckbox(controls, ENABLE_INTERACT_TEXT, OPTION_TOOLTIP_ENABLE_INTERACT, SoftTargetSetting("SoftTargetInteract"))
AddDropDown(controls, INTERACT_ICONS_OPTION, OPTION_TOOLTIP_INTERACT_ICONS, interactIconsSetting, function()
return {
{ value = 1, text = INTERACT_ICONS_DEFAULT },
{ value = 2, text = INTERACT_ICONS_SHOW_ALL },
{ value = 3, text = INTERACT_ICONS_SHOW_NONE },
}
end)
AddCheckbox(controls, L.AUTO_STAND, L.AUTO_STAND_TOOLTIP, CVarSetting("autoStand"))
AddCheckbox(controls, L.AUTO_DISMOUNT, L.AUTO_DISMOUNT_TOOLTIP, CVarSetting("autoDismount"))
AddCheckbox(controls, L.AUTO_UNSHIFT, L.AUTO_UNSHIFT_TOOLTIP, CVarSetting("autoUnshift"))
-- ---------------------------------------------------------------------------
-- Combat
-- ---------------------------------------------------------------------------
local combat = NewPage(COMBAT_LABEL, main.name, COMBAT_LABEL)
AddCheckbox(combat, ACTION_TARGETING_OPTION, OPTION_TOOLTIP_ACTION_TARGETING, SoftTargetSetting("SoftTargetEnemy"))
-- Retail has no friend checkbox; same mapping as its enemy one.
AddCheckbox(combat, L.FRIEND_TARGETING, L.FRIEND_TARGETING_TOOLTIP, SoftTargetSetting("SoftTargetFriend"))
AddCheckbox(combat, ACTION_BUTTON_USE_KEY_DOWN, OPTION_TOOLTIP_ACTION_BUTTON_USE_KEY_DOWN, CVarSetting("ActionButtonUseKeyDown"))
AddCheckbox(combat, STOP_AUTO_ATTACK, OPTION_TOOLTIP_STOP_AUTO_ATTACK, CVarSetting("stopAutoAttackOnTargetChange"))
-- ---------------------------------------------------------------------------
-- Text To Speech
-- ---------------------------------------------------------------------------
local tts = NewPage(TEXT_TO_SPEECH, main.name, TEXT_TO_SPEECH)
AddDropDown(tts, TEXT_TO_SPEECH_VOICE_OPTION, TEXT_TO_SPEECH_VOICE_OPTION, NumberCVarSetting("ttsVoice"), function()
local options = {}
for _, voice in ipairs(C_VoiceChat.GetTtsVoices()) do
table.insert(options, { value = voice.voiceID, text = voice.name })
end
return options
end)
AddSlider(tts, TEXT_TO_SPEECH_ADJUST_RATE, TEXT_TO_SPEECH_ADJUST_RATE, NumberCVarSetting("ttsSpeed"), -10, 10)
AddSlider(tts, TEXT_TO_SPEECH_ADJUST_VOLUME, TEXT_TO_SPEECH_ADJUST_VOLUME, NumberCVarSetting("ttsVolume"), 0, 100)
-- ---------------------------------------------------------------------------
-- Player Cache
-- ---------------------------------------------------------------------------
local playerCache = NewPage(L.PLAYER_CACHE, main.name, L.PLAYER_CACHE)
AddCheckbox(playerCache, L.REMEMBER_PLAYERS, L.REMEMBER_PLAYERS_TOOLTIP, rememberSetting)
@@ -1,215 +0,0 @@
-- Backport of 3.3.5 FrameXML/CallbackRegistryMixin.lua to vanilla 1.12 / Lua 5.0.
--
-- Implementation differences from the 3.3.5 source:
-- - 1.12 has no taint or Secure-Templates system, so the entire
-- AttributeDelegate/SetForbidden machinery for inserting event keys
-- into the callback tables is unnecessary. Sub-tables are allocated
-- lazily inside RegisterCallback instead.
-- - securecallfunction/secureexecuterange don't exist; they reduce to
-- direct calls / explicit for-loops here.
-- - EnumUtil.MakeEnum is inlined as a plain string table.
-- - GenerateClosure doesn't exist in 1.12; provide a local one that
-- captures registration-time varargs via SafePack-style storage.
-- - Lua 5.0 has no select() or `wipe()`; we use the implicit `arg`
-- table and assign-nil to clear tables.
local generateOwnerID = CreateCounter()
local CallbackType = { Closure = "Closure", Function = "Function" }
-- Local GenerateClosure: binds `func` plus all of `...` so a later call
-- invokes func(boundArgs..., callArgs...). Mirrors 3.3.5's helper of the
-- same name. SafePack/SafeUnpack semantics so embedded nils survive.
local function GenerateClosure(func, ...)
local boundN = arg.n
local bound = {}
for i = 1, boundN do bound[i] = arg[i] end
return function(...)
local callN = arg.n
-- Forward bound + call args faithfully, embedded/leading nils and all.
-- The ONLY thing that makes unpack span holes on this Lua 5.0 build is
-- a `.n` field: unpack takes its count from getn (which returns t.n)
-- and ignores any explicit i/j, so a `.n`-less table stops dead at the
-- first hole (verified: unpack({1, 1, nil, 1}, 1, 4) yields just 1, 1).
local all = {}
local n = 0
for i = 1, boundN do n = n + 1; all[n] = bound[i] end
for i = 1, callN do n = n + 1; all[n] = arg[i] end
all.n = n
return func(unpack(all))
end
end
CallbackRegistryMixin = CallbackRegistryMixin or {}
function CallbackRegistryMixin:OnLoad()
local callbackTables = {}
for _, value in pairs(CallbackType) do
callbackTables[value] = {}
end
self.callbackTables = callbackTables
end
function CallbackRegistryMixin:SetUndefinedEventsAllowed(allowed)
self.isUndefinedEventAllowed = allowed
end
function CallbackRegistryMixin:GetCallbackTables()
return self.callbackTables
end
function CallbackRegistryMixin:GetCallbackTable(callbackType)
return self.callbackTables[callbackType]
end
function CallbackRegistryMixin:GetCallbacksByEvent(callbackType, event)
local callbackTable = self:GetCallbackTable(callbackType)
return callbackTable[event]
end
function CallbackRegistryMixin:HasRegistrantsForEvent(event)
for _, callbackTable in pairs(self:GetCallbackTables()) do
local callbacks = callbackTable[event]
if callbacks and next(callbacks) then
return true
end
end
return false
end
-- Lazy allocation of per-event sub-tables. In 3.3.5 this went through a
-- forbidden attribute frame for taint-isolation reasons; that's a no-op
-- consideration on 1.12, so we just assign here.
function CallbackRegistryMixin:SecureInsertEvent(event)
for _, callbackTable in pairs(self:GetCallbackTables()) do
if not callbackTable[event] then
callbackTable[event] = {}
end
end
end
function CallbackRegistryMixin:RegisterCallback(event, func, owner, ...)
if type(event) ~= "string" then
error("CallbackRegistryMixin::RegisterCallback 'event' requires string type.")
elseif type(func) ~= "function" then
error("CallbackRegistryMixin::RegisterCallback 'func' requires function type.")
else
if owner == nil then
owner = generateOwnerID()
elseif type(owner) == "number" then
error("CallbackRegistryMixin:RegisterCallback 'owner' as number is reserved internally.")
end
end
self:SecureInsertEvent(event)
for _, callbackTable in pairs(self:GetCallbackTables()) do
local callbacks = callbackTable[event]
callbacks[owner] = nil
end
if arg.n > 0 then
local callbacks = self:GetCallbacksByEvent(CallbackType.Closure, event)
callbacks[owner] = GenerateClosure(func, owner, unpack(arg))
else
local callbacks = self:GetCallbacksByEvent(CallbackType.Function, event)
callbacks[owner] = func
end
return owner
end
local function CreateCallbackHandle(cbr, event, owner)
local handle =
{
Unregister = function()
cbr:UnregisterCallback(event, owner)
end,
}
return handle
end
function CallbackRegistryMixin:RegisterCallbackWithHandle(event, func, owner, ...)
owner = self:RegisterCallback(event, func, owner, unpack(arg))
return CreateCallbackHandle(self, event, owner)
end
function CallbackRegistryMixin:TriggerEvent(event, ...)
if type(event) ~= "string" then
error("CallbackRegistryMixin:TriggerEvent 'event' requires string type.")
elseif not self.isUndefinedEventAllowed and not (self.Event and self.Event[event]) then
error(string.format("CallbackRegistryMixin:TriggerEvent event '%s' doesn't exist.", event))
end
local closures = self:GetCallbacksByEvent(CallbackType.Closure, event)
if closures then
for _, closure in pairs(closures) do
closure(unpack(arg))
end
end
local funcs = self:GetCallbacksByEvent(CallbackType.Function, event)
if funcs then
for owner, func in pairs(funcs) do
-- Stock 3.3.5 passes the owner as the first arg so callers can
-- distinguish multiple registrations of the same function. `arg`
-- carries its own `.n`, so unpack(arg) forwards the payload with
-- leading/embedded nils intact. Do NOT instead merge into a
-- `{ owner, ... }` table and unpack that WITHOUT setting `.n`: this
-- build's unpack counts via getn, so a `.n`-less table collapses at
-- the first hole (verified in-game: reload's PLAYER_ENTERING_WORLD,
-- arg1=nil arg2=1, dropped the payload).
func(owner, unpack(arg))
end
end
end
function CallbackRegistryMixin:UnregisterCallback(event, owner)
if type(event) ~= "string" then
error("CallbackRegistryMixin:UnregisterCallback 'event' requires string type.")
elseif owner == nil then
error("CallbackRegistryMixin:UnregisterCallback 'owner' is required.")
end
for _, callbackTable in pairs(self:GetCallbackTables()) do
local callbacks = callbackTable[event]
if callbacks then
callbacks[owner] = nil
end
end
end
function CallbackRegistryMixin:UnregisterEvents(eventTable)
if eventTable then
for _, callbackTable in pairs(self:GetCallbackTables()) do
for event in pairs(eventTable) do
if callbackTable[event] then
callbackTable[event] = nil
end
end
end
else
for _, callbackTable in pairs(self:GetCallbackTables()) do
for k in pairs(callbackTable) do
callbackTable[k] = nil
end
end
end
end
function CallbackRegistryMixin:GenerateCallbackEvents(events)
if not self.Event then
self.Event = {}
end
for eventIndex, eventName in ipairs(events) do
if self.Event[eventName] then
error(string.format("CallbackRegistryMixin:GenerateCallbackEvents: event '%s' already exists.", eventName))
end
self.Event[eventName] = eventName
end
end
function CallbackRegistryMixin.DoesFrameHaveEvent(frame, event)
return frame.Event and frame.Event[event]
end
@@ -1,110 +0,0 @@
-- Backport of FrameXML/FormattingUtil.lua (feasible subset) to vanilla
-- 1.12 / Lua 5.0.
--
-- 5.0 notes: no `#`/`%`/string-method syntax — uses string.*, math.mod.
-- GetMoneyString gives the text-symbol form ("12g 34s 56c"). The coin-icon form
-- (GetCoinTextureString / C_CurrencyInfo.GetCoinTextureString) is provided by the
-- ClassicAPI DLL in C++ (src/currency/CoinText.cpp) — like retail, where it's a
-- C function — now that the inline-texture backport renders |T markup in 1.12.
--
-- The FrameXML format-string globals these functions consume
-- (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING, GOLD_AMOUNT_SYMBOL,
-- GOLD_AMOUNT_TEXTURE, …) are defined in the locale layer (locales/enUS.lua),
-- which loads first.
COPPER_PER_SILVER = 100;
SILVER_PER_GOLD = 100;
COPPER_PER_GOLD = COPPER_PER_SILVER * SILVER_PER_GOLD;
function FormatLargeNumber(amount)
amount = tostring(amount);
local newDisplay = "";
local strlen = string.len(amount);
-- Insert a separator before each group of three trailing digits.
for i = 4, strlen, 3 do
newDisplay = LARGE_NUMBER_SEPERATOR ..
string.sub(amount, -(i - 1), -(i - 3)) .. newDisplay;
end
-- Prepend everything before the first separator.
local lead = (math.mod(strlen, 3) == 0) and 3 or math.mod(strlen, 3);
newDisplay = string.sub(amount, 1, lead) .. newDisplay;
return newDisplay;
end
function FormatPercentage(percentage, roundToNearestInteger)
if roundToNearestInteger then
percentage = Round(percentage * 100);
else
percentage = percentage * 100;
end
return string.format(PERCENTAGE_STRING, percentage);
end
function FormatFraction(numerator, denominator)
return string.format(GENERIC_FRACTION_STRING, numerator, denominator);
end
function SplitTextIntoLines(text, delimiter)
local lines = {};
local startIndex = 1;
local foundIndex = string.find(text, delimiter);
while foundIndex do
table.insert(lines, string.sub(text, startIndex, foundIndex - 1));
startIndex = foundIndex + 2;
foundIndex = string.find(text, delimiter, startIndex);
end
if startIndex <= string.len(text) then
table.insert(lines, string.sub(text, startIndex));
end
return lines;
end
function SplitTextIntoHeaderAndNonHeader(text)
local foundIndex = string.find(text, "|n");
local len = string.len(text);
if not foundIndex then
return text; -- no newline: whole thing is a header
elseif len == 2 then
return nil; -- the string was just a newline
elseif foundIndex == 1 then
return string.sub(text, 3); -- leading newline: rest is a header
elseif foundIndex == len - 1 then
return string.sub(text, 1, foundIndex - 1); -- trailing newline
else
return string.sub(text, 1, foundIndex - 1), string.sub(text, foundIndex + 2);
end
end
-- (money, separateThousands [, checkGoldThreshold, showZeroAsGold])
-- Text-symbol form ("12g 34s 56c"); for the coin-icon form use
-- GetCoinTextureString below. The checkGoldThreshold arg is accepted for
-- signature compatibility but not applied (it relied on modern Constants tables).
function GetMoneyString(money, separateThousands, checkGoldThreshold, showZeroAsGold)
local gold = math.floor(money / COPPER_PER_GOLD);
local silver = math.floor(math.mod(money, COPPER_PER_GOLD) / COPPER_PER_SILVER);
local copper = math.mod(money, COPPER_PER_SILVER);
local goldString = (separateThousands and FormatLargeNumber(gold) or tostring(gold)) ..
GOLD_AMOUNT_SYMBOL;
local silverString = silver .. SILVER_AMOUNT_SYMBOL;
local copperString = copper .. COPPER_AMOUNT_SYMBOL;
if showZeroAsGold and money == 0 then
return goldString;
end
local moneyString = "";
local separator = "";
if gold > 0 then
moneyString = goldString;
separator = " ";
end
if silver > 0 then
moneyString = moneyString .. separator .. silverString;
separator = " ";
end
if copper > 0 or moneyString == "" then
moneyString = moneyString .. separator .. copperString;
end
return moneyString;
end
-70
View File
@@ -1,70 +0,0 @@
-- Backport of FrameXML/FrameUtil.lua (core subset) to vanilla 1.12.
--
-- Only the dependency-light, commonly-called helpers are ported. Omitted:
-- - the fade/flash functions (UIFrameFade*/UIFrameFlash*) are already
-- native in 1.12.
-- - the scale/layout/secure/EventRegistry helpers (rely on APIs absent
-- here).
FrameUtil = FrameUtil or {};
function FrameUtil.RegisterFrameForEvents(frame, events)
for i, event in ipairs(events) do
frame:RegisterEvent(event);
end
end
function FrameUtil.RegisterFrameForUnitEvents(frame, events, ...)
for i, event in ipairs(events) do
frame:RegisterUnitEvent(event, unpack(arg));
end
end
function FrameUtil.UnregisterFrameForEvents(frame, events)
for i, event in ipairs(events) do
frame:UnregisterEvent(event);
end
end
function FrameUtil.GetRootParent(frame)
local parent = frame:GetParent();
while parent do
local nextParent = parent:GetParent();
if not nextParent then
break;
end
parent = nextParent;
end
return parent;
end
-- Reparents while preserving the frame's render layering.
function FrameUtil.SetParentMaintainRenderLayering(frame, parent)
local origStrata = frame:GetFrameStrata();
local origFrameLevel = frame:GetFrameLevel();
frame:SetParent(parent);
frame:SetFrameStrata(origStrata);
frame:SetFrameLevel(origFrameLevel);
end
function DoesAncestryInclude(ancestry, frame)
if ancestry then
local currentFrame = frame;
while currentFrame do
if currentFrame == ancestry then
return true;
end
currentFrame = currentFrame:GetParent();
end
end
return false;
end
function DoesAncestryIncludeAny(ancestry, frames)
for _, frame in ipairs(frames) do
if DoesAncestryInclude(ancestry, frame) then
return true;
end
end
return false;
end
-415
View File
@@ -1,415 +0,0 @@
-- Backport of 3.3.5 FrameXML/ItemUtil.lua to vanilla 1.12 / Lua 5.0.
--
-- Implementation differences from the 3.3.5 source:
-- - Many C_Item.* calls the modern code expects are not exposed by
-- ClassicAPI.dll. We shim the missing ones at the top using the
-- stock 1.12 globals (GetItemInfo, GetInventoryItemLink,
-- GetContainerItemLink/Info). DLL-provided functions are left alone
-- via `or` guards so we never clobber a real implementation.
-- - Lua 5.0 has no select(); calls that previously did
-- `select(N, GetItemInfo(...))` use named locals to peel off the
-- return values instead.
-- - CallErrorHandler isn't in 1.12; we route through geterrorhandler().
-- - In 1.12 OnEvent scripts, event name + payload are exposed via the
-- `event`, `arg1`, `arg2`... globals rather than as function args.
-- - 1.12 has no concept of an item GUID and no public item-lock API,
-- so those return nil / no-op.
----------------------------------------------------------------------
Item = {}
ItemMixin = {}
local ItemEventListener
--[[static]] function Item:CreateFromItemLocation(itemLocation)
if type(itemLocation) ~= "table" or type(itemLocation.HasAnyLocation) ~= "function" or not itemLocation:HasAnyLocation() then
error("Usage: Item:CreateFromItemLocation(notEmptyItemLocation)", 2)
end
local item = CreateFromMixins(ItemMixin)
item:SetItemLocation(itemLocation)
return item
end
--[[static]] function Item:CreateFromBagAndSlot(bagID, slotIndex)
if type(bagID) ~= "number" or type(slotIndex) ~= "number" then
error("Usage: Item:CreateFromBagAndSlot(bagID, slotIndex)", 2)
end
local item = CreateFromMixins(ItemMixin)
item:SetItemLocation(ItemLocation:CreateFromBagAndSlot(bagID, slotIndex))
return item
end
--[[static]] function Item:CreateFromEquipmentSlot(equipmentSlotIndex)
if type(equipmentSlotIndex) ~= "number" then
error("Usage: Item:CreateFromEquipmentSlot(equipmentSlotIndex)", 2)
end
local item = CreateFromMixins(ItemMixin)
item:SetItemLocation(ItemLocation:CreateFromEquipmentSlot(equipmentSlotIndex))
return item
end
--[[static]] function Item:CreateFromItemLink(itemLink)
if type(itemLink) ~= "string" then
error("Usage: Item:CreateFromItemLink(itemLinkString)", 2)
end
local item = CreateFromMixins(ItemMixin)
item:SetItemLink(itemLink)
return item
end
--[[static]] function Item:CreateFromItemID(itemID)
if type(itemID) ~= "number" then
error("Usage: Item:CreateFromItemID(itemID)", 2)
end
local item = CreateFromMixins(ItemMixin)
item:SetItemID(itemID)
return item
end
--[[static]] function Item:CreateFromItemGUID(itemGUID)
if type(itemGUID) ~= "string" then
error("Usage: Item:CreateFromItemGUID(itemGUIDString)", 2);
end
local item = CreateFromMixins(ItemMixin);
item:SetItemGUID(itemGUID);
return item;
end
--[[static]] function Item:DoItemsMatch(item1, item2)
if not item1 or not item2 then
return false;
end
return item1:Matches(item2);
end
function ItemMixin:Matches(item)
if not item then
return false;
end
local itemID = item:GetItemID();
if (itemID ~= nil) and (self:GetItemID() == itemID) then
return true;
end
local itemLocation = item:GetItemLocation();
if (itemLocation ~= nil) and itemLocation:IsEqualTo(self:GetItemLocation()) then
return true;
end
return false;
end
function ItemMixin:SetItemLocation(itemLocation)
self:Clear()
self.itemLocation = itemLocation
end
function ItemMixin:SetItemLink(itemLink)
self:Clear()
self.itemLink = itemLink
end
function ItemMixin:SetItemID(itemID)
self:Clear()
self.itemID = itemID
end
function ItemMixin:SetItemGUID(itemGUID)
self:Clear();
self.itemGUID = itemGUID;
end
function ItemMixin:GetItemLocation()
if self.itemLocation then
return self.itemLocation;
end
if self.itemGUID then
return C_Item.GetItemLocation(self.itemGUID);
end
return nil;
end
function ItemMixin:HasItemLocation()
return self.itemLocation ~= nil
end
function ItemMixin:Clear()
self.itemLocation = nil
self.itemLink = nil
self.itemID = nil
self.itemGUID = nil
end
function ItemMixin:IsItemEmpty()
if self:GetStaticBackingItem() then
return not C_Item.DoesItemExistByID(self:GetStaticBackingItem())
end
return not self:IsItemInPlayersControl()
end
function ItemMixin:GetStaticBackingItem()
return self.itemLink or self.itemID
end
function ItemMixin:IsItemInPlayersControl()
local itemLocation = self:GetItemLocation()
return itemLocation and C_Item.DoesItemExist(itemLocation)
end
-- Item API
function ItemMixin:GetItemID()
if self:GetStaticBackingItem() then
return (C_Item.GetItemInfoInstant(self:GetStaticBackingItem()))
end
if not self:IsItemEmpty() then
return C_Item.GetItemID(self:GetItemLocation())
end
return nil
end
function ItemMixin:IsItemLocked()
return self:IsItemInPlayersControl() and C_Item.IsLocked(self:GetItemLocation())
end
function ItemMixin:LockItem()
if self:IsItemInPlayersControl() then
C_Item.LockItem(self:GetItemLocation())
end
end
function ItemMixin:UnlockItem()
if self:IsItemInPlayersControl() then
C_Item.UnlockItem(self:GetItemLocation())
end
end
function ItemMixin:GetItemIcon() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return C_Item.GetItemIconByID(self:GetStaticBackingItem())
end
if not self:IsItemEmpty() then
return C_Item.GetItemIcon(self:GetItemLocation())
end
end
function ItemMixin:GetItemName() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return C_Item.GetItemNameByID(self:GetStaticBackingItem())
end
if not self:IsItemEmpty() then
return C_Item.GetItemName(self:GetItemLocation())
end
return nil
end
function ItemMixin:GetItemLink() -- requires item data to be loaded
if self.itemLink then
return self.itemLink
end
if self.itemID then
return (select(2, C_Item.GetItemInfo(self.itemID)));
end
if not self:IsItemEmpty() then
return C_Item.GetItemLink(self:GetItemLocation())
end
return nil
end
function ItemMixin:GetItemQuality() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return C_Item.GetItemQualityByID(self:GetStaticBackingItem())
end
if not self:IsItemEmpty() then
return C_Item.GetItemQuality(self:GetItemLocation())
end
return nil
end
function ItemMixin:GetCurrentItemLevel() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return (C_Item.GetDetailedItemLevelInfo(self:GetStaticBackingItem()))
end
if not self:IsItemEmpty() then
return C_Item.GetCurrentItemLevel(self:GetItemLocation())
end
return nil
end
function ItemMixin:GetItemQualityColor() -- requires item data to be loaded
local itemQuality = self:GetItemQuality()
return ITEM_QUALITY_COLORS[itemQuality] -- may be nil if item data isn't loaded
end
function ItemMixin:GetItemQualityColorRGB() -- requires item data to be loaded
local colorTbl = self:GetItemQualityColor()
return colorTbl.color:GetRGB()
end
function ItemMixin:GetItemMaxStackSize() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return C_Item.GetItemMaxStackSizeByID(self:GetStaticBackingItem());
end
if not self:IsItemEmpty() then
return C_Item.GetItemMaxStackSize(self:GetItemLocation());
end
return nil;
end
function ItemMixin:IsStackable() -- requires item data to be loaded
local maxStackSize = self:GetItemMaxStackSize();
return maxStackSize and maxStackSize > 1;
end
function ItemMixin:GetInventoryType()
if self:GetStaticBackingItem() then
return C_Item.GetItemInventoryTypeByID(self:GetStaticBackingItem())
end
if not self:IsItemEmpty() then
return C_Item.GetItemInventoryType(self:GetItemLocation())
end
return nil
end
function ItemMixin:GetItemGUID()
if self:GetStaticBackingItem() then
return nil
end
if not self:IsItemEmpty() then
return C_Item.GetItemGUID(self:GetItemLocation())
end
return nil
end
function ItemMixin:GetInventoryTypeName()
if not self:IsItemEmpty() then
return select(4, C_Item.GetItemInfoInstant(self:GetItemID()));
end
end
function ItemMixin:IsItemDataCached()
if self:GetStaticBackingItem() then
return C_Item.IsItemDataCachedByID(self:GetStaticBackingItem())
end
if not self:IsItemEmpty() then
return C_Item.IsItemDataCached(self:GetItemLocation())
end
return true
end
function ItemMixin:IsDataEvictable()
return true
end
-- Default error handler routing: stock 1.12 has no CallErrorHandler.
local function ItemUtilErrorHandler(err)
local h = geterrorhandler and geterrorhandler()
if h then return h(err) end
end
function ItemMixin:ContinueOnItemLoad(callbackFunction)
if type(callbackFunction) ~= "function" or self:IsItemEmpty() then
error("Usage: NonEmptyItem:ContinueOnLoad(callbackFunction)", 2)
end
ItemEventListener:AddCallback(self:GetItemID(), callbackFunction)
end
function ItemMixin:ContinueWithCancelOnItemLoad(callbackFunction)
if type(callbackFunction) ~= "function" or self:IsItemEmpty() then
error("Usage: NonEmptyItem:ContinueWithCancelOnItemLoad(callbackFunction)", 2)
end
return ItemEventListener:AddCancelableCallback(self:GetItemID(), callbackFunction)
end
--[ Item Event Listener ]
ItemEventListener = CreateFrame("Frame")
ItemEventListener.callbacks = {}
-- 1.12 OnEvent receives no function args; event name and payload live in
-- the `event`, `arg1`, ... globals.
ItemEventListener:SetScript("OnEvent", function()
if event == "ITEM_DATA_LOAD_RESULT" then
local itemID, success = arg1, arg2
if success then
ItemEventListener:FireCallbacks(itemID)
else
ItemEventListener:ClearCallbacks(itemID)
end
end
end)
ItemEventListener:RegisterEvent("ITEM_DATA_LOAD_RESULT")
local CANCELED_SENTINEL = -1
function ItemEventListener:AddCallback(itemID, callbackFunction)
local callbacks = self:GetOrCreateCallbacks(itemID)
table.insert(callbacks, callbackFunction)
C_Item.RequestLoadItemDataByID(itemID)
end
function ItemEventListener:AddCancelableCallback(itemID, callbackFunction)
local callbacks = self:GetOrCreateCallbacks(itemID)
table.insert(callbacks, callbackFunction)
C_Item.RequestLoadItemDataByID(itemID)
local index = table.getn(callbacks)
return function()
if table.getn(callbacks) > 0 and callbacks[index] ~= CANCELED_SENTINEL then
callbacks[index] = CANCELED_SENTINEL
return true
end
return false
end
end
function ItemEventListener:FireCallbacks(itemID)
local callbacks = self:GetCallbacks(itemID)
if callbacks then
self:ClearCallbacks(itemID)
for i, callback in ipairs(callbacks) do
if callback ~= CANCELED_SENTINEL then
xpcall(callback, ItemUtilErrorHandler)
end
end
for i = table.getn(callbacks), 1, -1 do
callbacks[i] = nil
end
end
end
function ItemEventListener:ClearCallbacks(itemID)
self.callbacks[itemID] = nil
end
function ItemEventListener:GetCallbacks(itemID)
return self.callbacks[itemID]
end
function ItemEventListener:GetOrCreateCallbacks(itemID)
local callbacks = self.callbacks[itemID]
if not callbacks then
callbacks = {}
self.callbacks[itemID] = callbacks
end
return callbacks
end
-522
View File
@@ -1,522 +0,0 @@
-- Backport of FrameXML/Pools.lua to vanilla 1.12 / Lua 5.0.
--
-- Implementation differences from the modern source:
-- - 1.12 has no taint/secure system, so the entire Secure*/Proxy
-- machinery (SecureTypes, ProxyUtil, securecallfunction,
-- CreateForbiddenFrame) is dropped. Only the Unsecured pools and
-- pool collections exist; the canonical `CreateObjectPool` /
-- `CreateFramePool` / etc. globals are aliased to them at the
-- bottom of the file.
-- - FrameUtil.SpecializeFrameWithMixins isn't a thing in 1.12; the
-- "table specialization" branch is inlined as a plain Mixin().
-- - CreateMaskTexture and CreateActor aren't in 1.12 — the
-- corresponding pool creators are kept as stubs so chunk-load of
-- consumer addons doesn't fail; they'll error at Acquire() time
-- if anything actually invokes them.
-- - Lua 5.0 has no `...` as expression; the converter mixins use
-- `unpack(arg)` to forward varargs (spans arg[1..arg.n] via getn;
-- this build's unpack ignores explicit i/j — the count lives in `.n`).
-- - assertsafe / nop aren't globals in stock 1.12; defined as local
-- fallbacks (same pattern as FunctionUtil.lua).
local function nop() end
local function assertsafe(condition, msg)
if not condition then
local h = geterrorhandler and geterrorhandler()
if h then h(msg or "assertion failed!") end
end
end
local ObjectPoolBaseMixin = {};
--[[
Reserve() is not exposed on the pool to prevent the attack vector of addons having control
over the quantity of objects available to a preexisting pool.
]]--
local function Reserve(pool, capacity)
pool.capacity = capacity or math.huge;
if pool.capacity ~= math.huge then
for index = 1, pool.capacity do
pool:Acquire();
end
pool:ReleaseAll();
end
end
local function GetObjectIsInvalidMsg(object, poolOrCollection)
if poolOrCollection:DoesObjectBelongToPool(object) then
return string.format("Attempted to release inactive object '%s'", tostring(object));
else
return string.format("Attempted to release object '%s' that doesn't belong to this pool", tostring(object));
end
end
function ObjectPoolBaseMixin:Acquire()
if self:GetNumActive() == self.capacity then
return nil, false;
end
local object = self:PopInactiveObject();
local new = object == nil;
if new then
object = self:CallCreate();
--[[
While pools don't necessarily need to only contain tables, support for other types
has not been tested, and therefore isn't allowed until we can justify a use for them.
]]--
assert(type(object) == "table");
self:CallReset(object, new);
end
self:AddObject(object);
return object, new;
end
function ObjectPoolBaseMixin:Release(object, canFailToFindObject)
local active = self:IsActive(object);
--[[
If Release() is called on a pool directly from external code, then we expect
an assert if the object is not found. However, if it was called from a pool
collection, the object not being active is expected as the pool collection iterates
all the pools until it is found. A separate assert in pool collections accounts for
the case where the object was not found in any pool.
]]--
if not canFailToFindObject then
assertsafe(active, GetObjectIsInvalidMsg(object, self));
end
if active then
self:CallReset(object);
self:ReclaimObject(object);
end
return active;
end
function ObjectPoolBaseMixin:Dump()
for index, object in self:EnumerateActive() do
print(tostring(object));
end
end
local ObjectPoolMixin = CreateFromMixins(ObjectPoolBaseMixin);
function ObjectPoolMixin:Init(createFunc, resetFunc, capacity)
self.createFunc = createFunc;
self.resetFunc = resetFunc;
self.activeObjects = {};
self.inactiveObjects = {};
self.activeObjectCount = 0;
Reserve(self, capacity);
end
function ObjectPoolMixin:CallReset(object, new)
self.resetFunc(self, object, new);
end
function ObjectPoolMixin:CallCreate()
-- The pool argument 'self' is passed only for addons already reliant on it.
return self.createFunc(self);
end
function ObjectPoolMixin:PopInactiveObject()
return tremove(self.inactiveObjects);
end
function ObjectPoolMixin:AddObject(object)
local dummy = true;
self.activeObjects[object] = dummy;
self.activeObjectCount = self.activeObjectCount + 1;
end
function ObjectPoolMixin:ReclaimObject(object)
tinsert(self.inactiveObjects, object);
self.activeObjects[object] = nil;
self.activeObjectCount = self.activeObjectCount - 1;
end
function ObjectPoolMixin:ReleaseAll()
for object in pairs(self.activeObjects) do
self:Release(object);
end
end
function ObjectPoolMixin:EnumerateActive()
return pairs(self.activeObjects);
end
function ObjectPoolMixin:GetNextActive(current)
return next(self.activeObjects, current);
end
function ObjectPoolMixin:IsActive(object)
return self.activeObjects[object] ~= nil;
end
function ObjectPoolMixin:DoesObjectBelongToPool(object)
if self:IsActive(object) then
return true;
end
for index, candidate in pairs(self.inactiveObjects) do
if candidate == object then
return true;
end
end
return false;
end
function ObjectPoolMixin:GetNumActive()
return self.activeObjectCount;
end
local function GetPoolKey(template, specialization)
if specialization == nil then
return template.."nil";
end
return template..tostring(specialization);
end
local PoolCollectionBaseMixin = {};
function PoolCollectionBaseMixin:GetPool(template, specialization)
local poolKey = GetPoolKey(template, specialization);
return self:GetPoolByKey(poolKey);
end
function PoolCollectionBaseMixin:HasPool(poolKey)
return self:GetPoolByKey(poolKey) ~= nil;
end
function PoolCollectionBaseMixin:Acquire(template, specialization)
local pool = self:GetPool(template, specialization);
return pool:Acquire();
end
function PoolCollectionBaseMixin:ReleaseAllByTemplate(template, specialization)
local pool = self:GetPool(template, specialization);
if pool then
pool:ReleaseAll();
end
end
function PoolCollectionBaseMixin:EnumerateActiveByTemplate(template, specialization)
local pool = self:GetPool(template, specialization);
if pool then
return pool:EnumerateActive();
end
return nop;
end
function PoolCollectionBaseMixin:GetOrCreatePool(...)
assert(false);
end
function PoolCollectionBaseMixin:CreatePool(...)
assert(false);
end
function PoolCollectionBaseMixin:CreatePoolWithArgs(args)
local poolKey = self:CreatePoolKeyFromPoolArgs(args);
assert(not self:HasPool(poolKey));
local pool = self:CreatePoolInternal(args);
self:SetPool(poolKey, pool);
return pool;
end
function PoolCollectionBaseMixin:GetOrCreatePoolWithArgs(args)
local poolKey = self:CreatePoolKeyFromPoolArgs(args);
local pool = self:GetPoolByKey(poolKey);
local new = pool == nil;
if new then
pool = self:CreatePoolWithArgs(args);
end
return pool, new;
end
function PoolCollectionBaseMixin:Dump()
for object in self:EnumerateActive() do
print(tostring(object));
end
end
local PoolCollectionMixin = CreateFromMixins(PoolCollectionBaseMixin);
function PoolCollectionMixin:Init()
self.pools = {};
end
function PoolCollectionMixin:GetNumActive()
local total = 0;
for poolKey, pool in pairs(self.pools) do
total = total + pool:GetNumActive();
end
return total;
end
function PoolCollectionMixin:SetPool(poolKey, pool)
self.pools[poolKey] = pool;
end
function PoolCollectionMixin:GetPoolByKey(poolKey)
return self.pools[poolKey];
end
function PoolCollectionMixin:Release(object)
local canFailToFindObject = true;
for poolKey, pool in pairs(self.pools) do
if pool:Release(object, canFailToFindObject) then
return;
end
end
assertsafe(false, GetObjectIsInvalidMsg(object, self));
end
function PoolCollectionMixin:DoesObjectBelongToPool(object)
for poolKey, pool in pairs(self.pools) do
if pool:DoesObjectBelongToPool(object) then
return true;
end
end
return false;
end
function PoolCollectionMixin:ReleaseAll()
for poolKey, pool in pairs(self.pools) do
pool:ReleaseAll();
end
end
-- Warning: this function only returns the object, unlike a pool that also returns the key.
function PoolCollectionMixin:EnumerateActive()
local currentObject = nil;
local currentPoolKey, currentPool = next(self.pools, currentObject);
return function()
if currentPool then
currentObject = currentPool:GetNextActive(currentObject);
while not currentObject do
currentPoolKey, currentPool = next(self.pools, currentPoolKey)
if currentPool then
currentObject = currentPool:GetNextActive(nil);
else
break;
end
end
end
return currentObject;
end, nil;
end
function Pool_HideAndClearAnchors(pool, region)
region:Hide();
region:ClearAllPoints();
end
function ActorPool_HideAndClearModel(actorPool, actor)
actor:ClearModel();
actor:Hide();
end
--[[
In addition to a specialization being used to create separate pools for the same template, it can
be used to modify a frame after being created. On modern WoW this delegates to
FrameUtil.SpecializeFrameWithMixins (which also wires up conventional script handlers); on 1.12
we just Mixin() the table into the frame and let the consumer call SetScript() if needed.
]]--
local function ConvertSpecializationToPostCreate(specialization)
local specializationType = type(specialization);
if specializationType == "function" then
return specialization;
elseif specializationType == "table" then
local function Initializer(frame)
Mixin(frame, specialization);
end
return Initializer;
end
return nil;
end
function CreateUnsecuredRegionPoolInstance(template, createFunc, resetFunc, capacity)
local pool = CreateFromMixins(ObjectPoolMixin);
pool:Init(createFunc, resetFunc or Pool_HideAndClearAnchors, capacity);
pool.GetTemplate = function(self)
return template;
end
return pool;
end
--[[
Args are packed into a table for ease of implementation in PoolCollectionBaseMixin, which is
derived with different argument signatures. This also makes it easy to access named fields
like `parent` off the table without dealing with argument position.
]]--
local function FramePoolCollection_ArgsToTable(frameType, parent, template, resetFunc, forbidden, specialization, capacity)
return {
frameType = frameType,
parent = parent,
template = template,
resetFunc = resetFunc,
forbidden = forbidden, -- ignored on 1.12 (no CreateForbiddenFrame)
specialization = specialization,
capacity = capacity,
};
end
local FramePoolCollectionConverterMixin = {};
function FramePoolCollectionConverterMixin:CreatePoolKeyFromPoolArgs(args)
return GetPoolKey(args.template, args.specialization);
end
function FramePoolCollectionConverterMixin:GetOrCreatePool(...)
local args = FramePoolCollection_ArgsToTable(unpack(arg));
return self:GetOrCreatePoolWithArgs(args);
end
function FramePoolCollectionConverterMixin:CreatePool(...)
local args = FramePoolCollection_ArgsToTable(unpack(arg));
return self:CreatePoolWithArgs(args);
end
local FramePoolCollectionMixin = CreateFromMixins(PoolCollectionMixin, FramePoolCollectionConverterMixin);
do
local function CreateFramePoolInstance(frameType, parent, template, resetFunc, postCreate, capacity)
local function Create()
local name = nil;
local frame = CreateFrame(frameType, name, parent, template);
if postCreate then
postCreate(frame);
end
return frame;
end
return CreateUnsecuredRegionPoolInstance(template, Create, resetFunc, capacity);
end
function FramePoolCollectionMixin:CreatePoolInternal(args)
local postCreate = ConvertSpecializationToPostCreate(args.specialization);
return CreateFramePoolInstance(args.frameType, args.parent, args.template, args.resetFunc, postCreate, args.capacity);
end
end
local FontStringPoolCollectionMixin = CreateFromMixins(PoolCollectionMixin);
function FontStringPoolCollectionMixin:CreatePoolKeyFromPoolArgs(args)
return args.template;
end
local function FontStringPoolCollection_ArgsToTable(parent, layer, subLayer, template, resetFunc, capacity)
return {
parent = parent,
layer = layer,
subLayer = subLayer,
template = template,
resetFunc = resetFunc,
capacity = capacity,
};
end
function FontStringPoolCollectionMixin:GetOrCreatePool(...)
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
return self:GetOrCreatePoolWithArgs(args);
end
function FontStringPoolCollectionMixin:CreatePool(...)
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
return self:CreatePoolWithArgs(args);
end
function FontStringPoolCollectionMixin:CreatePoolInternal(args)
return CreateUnsecuredFontStringPool(args.parent, args.layer, args.subLayer, args.template, args.resetFunc, args.capacity);
end
function CreateUnsecuredObjectPool(createFunc, resetFunc, capacity)
local pool = CreateFromMixins(ObjectPoolMixin);
pool:Init(createFunc, resetFunc, capacity);
return pool;
end
function CreateUnsecuredFramePool(frameType, parent, template, resetFunc, capacity)
local function Create()
local name = nil;
return CreateFrame(frameType, name, parent, template);
end
return CreateUnsecuredRegionPoolInstance(template, Create, resetFunc, capacity);
end
function CreateUnsecuredTexturePool(parent, layer, subLayer, template, resetFunc, capacity)
local function Create()
local name = nil;
return parent:CreateTexture(name, layer, template, subLayer);
end
return CreateUnsecuredRegionPoolInstance(template, Create, resetFunc, capacity);
end
function CreateUnsecuredFontStringPool(parent, layer, subLayer, template, resetFunc, capacity)
local function Create()
local name = nil;
return parent:CreateFontString(name, layer, template, subLayer);
end
return CreateUnsecuredRegionPoolInstance(template, Create, resetFunc, capacity);
end
-- Stub: 1.12 has no CreateActor (model actors are Cataclysm+). Acquire()
-- will raise once it actually tries to call parent:CreateActor — kept so
-- that addon code merely referencing the symbol doesn't fail to load.
function CreateUnsecuredActorPool(parent, template, resetFunc, capacity)
local function Create()
local name = nil;
return parent:CreateActor(name, template);
end
return CreateUnsecuredRegionPoolInstance(template, Create, resetFunc or ActorPool_HideAndClearModel, capacity);
end
-- Stub: 1.12 has no CreateMaskTexture (mask textures are post-vanilla).
-- Same caveat as CreateUnsecuredActorPool.
function CreateUnsecuredMaskTexturePool(parent, layer, subLayer, template, resetFunc, capacity)
local function Create()
local name = nil;
return parent:CreateMaskTexture(name, layer, template, subLayer);
end
return CreateUnsecuredRegionPoolInstance(template, Create, resetFunc, capacity);
end
function CreateUnsecuredFramePoolCollection()
local poolCollection = CreateFromMixins(FramePoolCollectionMixin);
poolCollection:Init();
return poolCollection;
end
function CreateUnsecuredFontStringPoolCollection()
local poolCollection = CreateFromMixins(FontStringPoolCollectionMixin);
poolCollection:Init();
return poolCollection;
end
-- Canonical API aliases. On modern WoW these point at the Secure variants;
-- on 1.12 there's no taint system, so they all go straight to the Unsecured
-- implementations.
CreateObjectPool = CreateUnsecuredObjectPool;
CreateFramePool = CreateUnsecuredFramePool;
CreateTexturePool = CreateUnsecuredTexturePool;
CreateFontStringPool = CreateUnsecuredFontStringPool;
CreateActorPool = CreateUnsecuredActorPool;
CreateFramePoolCollection = CreateUnsecuredFramePoolCollection;
CreateFontStringPoolCollection = CreateUnsecuredFontStringPoolCollection;
CreateMaskTexturePool = CreateUnsecuredMaskTexturePool;
-725
View File
@@ -1,725 +0,0 @@
-- Backport of 3.3.5 FrameXML/TableUtil.lua to vanilla 1.12 / Lua 5.0.
--
-- Implementation differences from the 3.3.5 source:
-- - Lua 5.0 has no length operator (#). Sequence length comes from
-- table.getn(t), which honors an explicit .n field when present.
-- - Lua 5.0 has no select() and no `...` outside a vararg function
-- body; varargs are passed via the implicit `arg` table.
-- - 1.12 has no global wipe(); we ship a local Wipe() helper instead.
-- - Lua 5.0 has no math.huge; 1/0 yields the same +inf sentinel.
local tRemove, tInsert, tWipe = table.remove, table.insert, table.wipe
local tGetN = table.getn
TableUtil = TableUtil or {}
TableUtil.Constants =
{
AssociativePriorityTable = true,
ArraylikePriorityTable = false,
IsIndexTable = true,
}
function ipairs_reverse(table)
local function Enumerator(table, index)
index = index - 1
local value = table[index]
if value ~= nil then
return index, value
end
end
return Enumerator, table, tGetN(table) + 1
end
function CreateTableEnumerator(tbl, minIndex, maxIndex)
minIndex = minIndex and (minIndex - 1) or 0
maxIndex = maxIndex or math.huge
local function Enumerator(tbl, index)
index = index + 1
if index <= maxIndex then
local value = tbl[index]
if value ~= nil then
return index, value
end
end
end
return Enumerator, tbl, minIndex
end
function CreateTableReverseEnumerator(tbl, minIndex, maxIndex)
minIndex = minIndex or 1
maxIndex = (maxIndex or tGetN(tbl)) + 1
local function Enumerator(tbl, index)
index = index - 1
if index >= minIndex then
local value = tbl[index]
if value ~= nil then
return index, value
end
end
end
return Enumerator, tbl, maxIndex
end
function tDeleteItem(tbl, item)
local size = tGetN(tbl);
local index = size;
while index > 0 do
if item == tbl[index] then
tRemove(tbl, index);
end
index = index - 1;
end
return size - tGetN(tbl);
end
function tIndexOf(tbl, item)
for i, v in ipairs(tbl) do
if item == v then
return i
end
end
end
function tContains(tbl, item)
for k, v in pairs(tbl) do
if item == v then
return true;
end
end
return false;
end
function TableUtil.ContainsAllKeys(lhsTable, rhsTable)
for key, _ in pairs(lhsTable) do
if rhsTable[key] == nil then
return false
end
end
return true
end
function TableUtil.CompareValuesAsKeys(lhsTable, rhsTable, valueToKeyOp)
local lhsKeys = CopyTransformedValuesAsKeys(lhsTable, valueToKeyOp)
local rhsKeys = CopyTransformedValuesAsKeys(rhsTable, valueToKeyOp)
return TableUtil.ContainsAllKeys(lhsKeys, rhsKeys)
end
function tCompare(lhsTable, rhsTable, depth)
depth = depth or 1
for key, value in pairs(lhsTable) do
if type(value) == "table" then
local rhsValue = rhsTable[key]
if type(rhsValue) ~= "table" then
return false
end
if depth > 1 then
if not tCompare(value, rhsValue, depth - 1) then
return false
end
end
elseif value ~= rhsTable[key] then
return false
end
end
for key, value in pairs(rhsTable) do
if lhsTable[key] == nil then
return false
end
end
return true
end
function tInvert(tbl)
local inverted = {}
for k, v in pairs(tbl) do
inverted[v] = k
end
return inverted
end
function TableUtil.TrySet(tbl, key)
if not tbl[key] then
tbl[key] = true
return true
end
return false
end
function TableUtil.CopyUnique(tbl, isIndexTable)
local found = {}
local function FilterPredicate(value)
return TableUtil.TrySet(found, value)
end
return tFilter(tbl, FilterPredicate, isIndexTable)
end
function TableUtil.CopyUniqueByPredicate(tbl, isIndexTable, unaryPredicate)
local found = {}
local function FilterPredicate(value)
return TableUtil.TrySet(found, unaryPredicate(value))
end
return tFilter(tbl, FilterPredicate, isIndexTable)
end
function tFilter(tbl, pred, isIndexTable)
local out = {}
if isIndexTable then
local currentIndex = 1
for i, v in ipairs(tbl) do
if pred(v) then
out[currentIndex] = v
currentIndex = currentIndex + 1
end
end
else
for k, v in pairs(tbl) do
if pred(v) then
out[k] = v
end
end
end
return out
end
function tAppendAll(table, addedArray)
for i, element in ipairs(addedArray) do
tInsert(table, element)
end
end
function tInsertUnique(tbl, item)
if not tContains(tbl, item) then
tInsert(tbl, item)
return tGetN(tbl)
end
return nil
end
function tUnorderedRemove(tbl, index)
local n = tGetN(tbl)
if index ~= n then
tbl[index] = tbl[n]
end
tRemove(tbl)
end
function CopyTable(settings, shallow)
local copy = {}
for k, v in pairs(settings) do
if type(v) == "table" and not shallow then
copy[k] = CopyTable(v)
else
copy[k] = v
end
end
return copy
end
function CopyTableSafe(settings, shallow)
if not settings then
return nil;
end
return CopyTable(settings, shallow);
end
function MergeTable(destination, source)
for k, v in pairs(source) do
destination[k] = v
end
end
function SetTablePairsToTable(destination, source)
tWipe(destination)
MergeTable(destination, source)
end
function CountTable(tbl)
local count = 0;
for k, v in pairs(tbl) do
count = count + 1;
end
return count;
end
function Accumulate(tbl)
local count = 0
for k, v in pairs(tbl) do
count = count + v
end
return count
end
function AccumulateOp(tbl, op)
local count = 0
for k, v in pairs(tbl) do
count = count + op(v)
end
return count
end
function TableUtil.Execute(tbl, op)
for k, v in pairs(tbl) do
op(v)
end
end
function TableUtil.ExecuteOnKeys(tbl, op)
for k in pairs(tbl) do
op(k)
end
end
function TableUtil.ExecuteUntil(tbl, op)
for k, v in pairs(tbl) do
local operationResult = op(v)
if operationResult then
return operationResult
end
end
return nil
end
function TableUtil.Transform(tbl, op)
local result = {}
for k, v in pairs(tbl) do
tInsert(result, op(v))
return result
end
end
-- Returns the value in a table deemed smallest by evaluating each value returned by the op function parameter.
-- The return of the op function must return a number.
function TableUtil.FindMin(tbl, op)
local result = nil;
local min = math.huge;
for k, v in pairs(tbl) do
local value = op(v);
if value < min then
min = value;
result = v;
end
end
return result;
end
-- Returns the value in a table deemed largest by evaluating each value returned by the op function parameter.
-- The return of the op function must return a number.
function TableUtil.FindMax(tbl, op)
local result = nil;
local max = -math.huge;
for k, v in pairs(tbl) do
local value = op(v);
if value > max then
max = value;
result = v;
end
end
return result;
end
function ContainsIf(tbl, pred)
for k, v in pairs(tbl) do
if pred(v) then
return true
end
end
return false
end
function FindInTable(tbl, value)
for k, v in pairs(tbl) do
if v == value then
return k, v;
end
end
return nil;
end
function FindInTableIf(tbl, pred)
for k, v in pairs(tbl) do
if pred(v) then
return k, v
end
end
return nil
end
function FindValueInTableIf(tbl, pred)
local _, value = FindInTableIf(tbl, pred)
return value
end
local function FindSortedIndexImplementation(tbl, searchComparison, startIndex, rangeStart, rangeEnd)
local comparisonResult = searchComparison(tbl[startIndex])
if comparisonResult == 0 then
return startIndex
end
if comparisonResult > 0 then
if startIndex >= rangeEnd then
return startIndex + 1
end
rangeStart = startIndex + 1
return FindSortedIndexImplementation(tbl, searchComparison, startIndex + math.ceil((rangeEnd - startIndex) / 2), rangeStart, rangeEnd)
end
if startIndex <= rangeStart then
return startIndex
end
rangeEnd = startIndex - 1
return FindSortedIndexImplementation(tbl, searchComparison, math.floor(startIndex / 2), rangeStart, rangeEnd)
end
function FindSortedIndex(tbl, searchComparison)
local numTable = tGetN(tbl)
local startingIndex = math.ceil(numTable / 2)
if startingIndex == 0 then
return 1
end
return FindSortedIndexImplementation(tbl, searchComparison, startingIndex, 1, numTable)
end
function TableIsEmpty(tbl)
return next(tbl) == nil
end
function TableHasAnyEntries(tbl)
return next(tbl) ~= nil
end
function CopyValuesAsKeys(tbl)
local output = {}
for k, v in ipairs(tbl) do
output[v] = v
end
return output
end
function CopyTransformedValuesAsKeys(tbl, transformOp)
local output = {}
for _, v in ipairs(tbl) do
output[transformOp(v)] = v
end
return output
end
function SafePack(...)
local tbl = {}
for i = 1, arg.n do tbl[i] = arg[i] end
tbl.n = arg.n
return tbl
end
function SafeUnpack(tbl)
return unpack(tbl)
end
-- Returns the length of a table, accounting for the possibility of a table constructed using SafePack.
function SafeLength(tbl)
if not tbl then
return 0;
end
local operatorCount = tGetN(tbl);
local safePackCount = tbl.n;
if safePackCount and operatorCount ~= safePackCount then
return safePackCount;
end
return operatorCount;
end
function GetOrCreateTableEntry(table, key, defaultValue)
local currentValue = table[key]
local isNewValue = (currentValue == nil)
if isNewValue then
if defaultValue ~= nil then
currentValue = defaultValue
else
currentValue = {}
end
table[key] = currentValue
end
return currentValue, isNewValue
end
function GetOrCreateTableEntryByCallback(table, key, callback)
local currentValue = table[key]
local isNewValue = (currentValue == nil)
if isNewValue then
currentValue = callback(key)
table[key] = currentValue
end
return currentValue, isNewValue
end
function GetOrCreateTableEntryByMethod(table, key, method, owner)
local currentValue = table[key];
local isNewValue = (currentValue == nil);
if isNewValue then
currentValue = method(owner, key);
table[key] = currentValue;
end
return currentValue, isNewValue;
end
function GetRandomArrayEntry(array)
return array[math.random(1, tGetN(array))]
end
function GetRandomTableValue(tbl)
local value
local n = 0
for k, v in pairs(tbl) do
n = n + 1
local r = math.random()
if r <= (1 / n) then
value = v
end
end
return value
end
function GetKeysArray(tbl)
local keysArray = {}
for key in pairs(tbl) do
tInsert(keysArray, key)
end
return keysArray
end
function GetValuesArray(tbl)
local valuesArray = {}
for key, value in pairs(tbl) do
tInsert(valuesArray, value)
end
return valuesArray
end
function GetPairsArray(tbl)
local pairsArray = {}
for key, value in pairs(tbl) do
tInsert(pairsArray, { key = key, value = value, })
end
return pairsArray
end
function SwapTableEntries(lhsTable, rhsTable, key)
local lhsValue = lhsTable[key]
lhsTable[key] = rhsTable[key]
rhsTable[key] = lhsValue
end
function GetKeysArraySortedByValue(tbl)
local keysArray = GetKeysArray(tbl);
table.sort(keysArray, function(a, b)
return tbl[a] < tbl[b];
end);
return keysArray;
end
function TableUtil.GetTableValueListFromEnumeration(tableKey, ...)
local values = {}
for enumerationKey, tbl in arg[1] do
tInsert(values, tbl[tableKey])
end
return values
end
function TableUtil.GetHighestNumericalValueInTable(table)
local highestValue = nil;
for key, value in pairs(table) do
if type(value) == "number" and (not highestValue or value > highestValue) then
highestValue = value;
end
end
return highestValue;
end
--[[
This utility creates and returns a table of elements that are sorted by value "priority".
Arguments:
comparator - comparator(A, B) returns whether A has higher priority than B
isAssociative - whether the table should be associative (true) or array-like (false). Only values have priorities for associative tables, not keys.
Return usage:
t[k]/t:Get(k) - returns the value stored with key/index k
t[k] = v - (Associative only) stores the value v at key k, sorting accordingly
t:Insert(v) - (Array-like only) inserts the value v, sorting accordingly
t:Remove(k) - removes the element at key/index k
t:Iterate(cb) - calls function cb on each key/index value pair in sorted priority order
t:GetTop() - returns the highest priority element
t:Pop() - returns and removes the highest priority element
t:GetBottom() - returns the lowest priority element
t:Size() - returns the number of stored elements
t:Clear() - removes all elements
--]]
function TableUtil.CreatePriorityTable(comparator, isAssociative)
local sortedArray = {}
local ShiftPositionMap
local keyToPosMap
if isAssociative then
keyToPosMap = {}
ShiftPositionMap = function(position, shiftUp)
local mod = shiftUp and 1 or -1
for k, p in pairs(keyToPosMap) do
if p >= position then
keyToPosMap[k] = p + mod
end
end
end
end
local function SortedInsert(k, v)
if isAssociative then
local prevPos = keyToPosMap[k]
if prevPos then
tRemove(sortedArray, prevPos)
local shiftUp = false
ShiftPositionMap(prevPos, shiftUp)
end
end
local n = tGetN(sortedArray)
local top = (n > 0) and (n + 1) or 1
local bottom = 1
while top ~= bottom do
local mid = math.floor((top - bottom) / 2) + bottom
if not comparator(v, sortedArray[mid]) then
bottom = mid + 1
else
top = mid
end
end
local idx = bottom
tInsert(sortedArray, idx, v)
if isAssociative then
local shiftUp = true
ShiftPositionMap(idx, shiftUp)
keyToPosMap[k] = idx
end
end
local t = {}
function t:Get(k)
local key = isAssociative and keyToPosMap[k] or k
return sortedArray[key]
end
if not isAssociative then
function t:Insert(v)
SortedInsert(nil, v)
end
end
function t:Remove(k)
local key = isAssociative and keyToPosMap[k] or k
tRemove(sortedArray, key)
if isAssociative then
keyToPosMap[k] = nil
local shiftUp = false
ShiftPositionMap(key, shiftUp)
end
end
function t:GetTop()
return tGetN(sortedArray) > 0 and sortedArray[1]
end
function t:GetBottom()
local n = tGetN(sortedArray)
return n > 0 and sortedArray[n]
end
function t:Pop()
if t:Size() == 0 then
return nil
end
local top = t:GetTop()
local removalKey = isAssociative and tInvert(keyToPosMap)[1] or 1
t:Remove(removalKey)
return top
end
function t:Iterate(callback)
local posToKeyMap = isAssociative and tInvert(keyToPosMap) or nil
for pos, v in ipairs(sortedArray) do
local key = isAssociative and posToKeyMap[pos] or pos
local done = callback(key, v)
if done then
return
end
end
end
function t:Size()
return tGetN(sortedArray)
end
function t:Clear()
sortedArray = {}
if isAssociative then
keyToPosMap = {}
end
end
local mt =
{
__index = function(t, k)
return t:Get(k)
end,
}
if isAssociative then
mt.__newindex = function(t, k, v)
if v ~= nil then
SortedInsert(k, v)
else
t:Remove(k)
end
end
else
mt.__newindex = function(t, k, v)
error("Attempted to assign a value to an array-like priority queue index. Use Insert()/Remove() instead.")
end
end
setmetatable(t, mt)
return t
end
@@ -1,855 +0,0 @@
-- Trade-skill *list* links (`|Htrade:...|h`) — backport of the 2.x+ shareable
-- profession link. The link is built entirely in the DLL
-- (`GetTradeSkillListLink` / `GetCraftListLink`, see src/tradeskill/Link.cpp);
-- this file only intercepts clicks on received links and renders the linker's
-- recipe list in a scroll frame (the UI half).
--
-- link: |cffffd000|Htrade:<skillLineID>:<curRank>:<maxRank>:<linkerName>:<base64 bits>|h[Name]|h|r
--
-- "Which recipes are known" is a bitfield over the skill line's canonical
-- recipe list (deterministic per DBC). The viewer lists the recipes the linker
-- knows (with an "X of Y" summary of the whole skill line), regardless of what
-- the reader can craft themselves.
-- Frame geometry mirrors the real TradeSkillFrame. Turtle ships an HD
-- 768x512 two-column frame (TW-TradeSkill art); stock 1.12.1 only has the
-- vanilla 384x512 single-column art, so on that client we fall back to the
-- native Blizzard_TradeSkillUI geometry (list on top, detail below) rather
-- than stretch the wide art. LO holds every layout value that differs.
local TURTLE = (TURTLE_WOW_VERSION ~= nil);
local FRAME_W = TURTLE and 768 or 384;
local FRAME_H = 512;
local NUM_ROWS = TURTLE and 19 or 8;
local ROW_HEIGHT = 16;
local LO = TURTLE and {
hit = { 2, 82, 2, 73 },
quads = {
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-TopLeft", 512, 256, "TOPLEFT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-TopRight", 256, 256, "TOPRIGHT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-BotLeft2", 512, 256, "BOTTOMLEFT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-BotRight2", 256, 256, "BOTTOMRIGHT" },
},
close = { -77, -8 },
bar = { 596, 15, 76, -44 },
barBdr = { 604, 22 },
summary = { "TOPRIGHT", -110, -70 },
drop = { 40, -60 },
scroll = { 296, 330, 37, -98 },
-- detail scroll frame: { x, y, width, height }. Tall enough that the
-- content fits (scrollbar auto-hides) — Turtle's right column is roomy.
detail = { 371, -110, 340, 290 },
exit = { "CENTER", "BOTTOMRIGHT", -128, 92 },
} or {
hit = { 0, 34, 0, 75 },
quads = {
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopLeft", 256, 256, "TOPLEFT" },
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopRight", 128, 256, "TOPRIGHT" },
{ "Interface\\TradeSkillFrame\\UI-TradeSkill-BotLeft", 256, 256, "BOTTOMLEFT" },
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-BotRight", 128, 256, "BOTTOMRIGHT" },
},
close = { -29, -8 },
bar = { 268, 15, 73, -37 },
barBdr = { 278, 22 },
summary = { "TOPRIGHT", -40, -58 },
drop = { 12, -62 },
scroll = { 296, 130, 21, -96 },
-- detail scroll frame { x, y, width, height } — native TradeSkillFrame's
-- TradeSkillDetailScrollFrame values. Small enough that many-reagent
-- recipes scroll (clipping partial rows), like native.
detail = { 20, -234, 297, 176 },
exit = { "CENTER", "TOPLEFT", 305, -422 },
sep = { 15, -221, 331 },
};
-- Reagents render in a 2-column grid inside the detail scroll child (see
-- the detail pane below). The detail is a real ScrollFrame like the
-- native TradeSkillFrame, so the whole pane pixel-scrolls and clips
-- partial reagent rows — `REAGENT_ROW_HEIGHT` is just the vertical stride
-- of one reagent row.
local REAGENT_ROW_HEIGHT = 43;
-- Scroll-frame templates. Stock mimics the native TradeSkillFrame, whose
-- list/detail scrolls use the ClassTrainer templates (they add the
-- decorative UI-ClassTrainer-ScrollBar track). Turtle's HD frame art
-- doesn't want that groove, so it keeps the plain templates. Both detail
-- templates are real ScrollFrames (ClassTrainerDetail... inherits
-- UIPanelScrollFrameTemplate), so the pixel-scroll logic is identical.
local LIST_SCROLL_TEMPLATE = TURTLE and "FauxScrollFrameTemplate"
or "ClassTrainerListScrollFrameTemplate";
local DETAIL_SCROLL_TEMPLATE = TURTLE and "UIPanelScrollFrameTemplate"
or "ClassTrainerDetailScrollFrameTemplate";
local PROFESSION_ICONS = {
[171] = "Trade_Alchemy",
[164] = "Trade_BlackSmithing",
[185] = "INV_Misc_Food_15", -- Cooking
[333] = "Trade_Engraving", -- Enchanting
[202] = "Trade_Engineering",
[129] = "Spell_Holy_SealOfSacrifice", -- First Aid
[165] = "INV_Misc_ArmorKit_17", -- Leatherworking
[197] = "Trade_Tailoring",
[186] = "Trade_Mining",
[182] = "Trade_Herbalism",
[393] = "INV_Misc_Pelt_Wolf_01", -- Skinning
[356] = "Trade_Fishing",
};
if TURTLE then
PROFESSION_ICONS[142] = "Trade_Survival"
PROFESSION_ICONS[755] = "INV_Misc_Gem_01" -- Jewelcrafting
end
local function ProfessionIcon(skillLineID)
local icon = PROFESSION_ICONS[skillLineID];
return icon and ("Interface\\Icons\\" .. icon)
or "Interface\\Icons\\INV_Misc_QuestionMark";
end
local function DifficultyBand(skill, hi, lo)
if not hi or hi <= 0 then
return 3;
end
if not lo or lo == 0 then
lo = (hi ~= 25) and (hi - 25) or 0;
end
local yellow = (hi + lo) / 2;
if skill < lo then
return 0; -- orange (optimal)
elseif skill < yellow then
return 1; -- yellow (medium)
elseif skill < hi then
return 2; -- green (easy)
end
return 3; -- grey (trivial)
end
local BAND_COLOR = {
[0] = DIFFICULT_DIFFICULTY_COLOR, -- orange
[1] = FAIR_DIFFICULTY_COLOR, -- yellow
[2] = EASY_DIFFICULTY_COLOR, -- green
[3] = TRIVIAL_DIFFICULTY_COLOR, -- grey
};
local function CraftableCount(entry)
local reagents = entry.reagents;
if not reagents or table.getn(reagents) == 0 then
return nil;
end
local best;
for i = 1, table.getn(reagents) do
local r = reagents[i];
local need = r.count or 1;
local n = math.floor((C_Item.GetItemCount(r.itemID) or 0) / need);
if not best or n < best then
best = n;
end
end
return best;
end
local function ParseTradeLink(link)
-- Current format, with the linker's name: trade:id:cur:max:Name:bits
local _, _, id, cur, max, who, bits =
string.find(link, "^trade:(%d+):(%d+):(%d+):([^:]+):([%w%+/]*)$");
if not id then
-- Legacy format (no name): trade:id:cur:max:bits
who = nil;
_, _, id, cur, max, bits =
string.find(link, "^trade:(%d+):(%d+):(%d+):([%w%+/]*)$");
end
if not id then
return nil;
end
return tonumber(id), tonumber(cur), tonumber(max), who, bits or "";
end
------------------------------------------------------------------------------
-- Display frame (built lazily on first use)
------------------------------------------------------------------------------
local frame;
local function ResolveSubclasses(list, onLoaded)
local present, subs, anyItem = {}, {}, false;
for i = 1, table.getn(list) do
local r = list[i];
if r.createdItem and r.createdItem > 0 then
anyItem = true;
if not r.subclass then
-- GetItemInfoInstant's 3rd return is the localized subclass
-- name (or nil when uncached).
local _, _, subType = C_Item.GetItemInfoInstant(r.createdItem);
if subType and subType ~= "" then
r.subclass = subType;
elseif onLoaded then
-- Not cached — re-derive when the item loads.
Item:CreateFromItemID(r.createdItem):ContinueOnItemLoad(onLoaded);
end
end
if r.subclass and not present[r.subclass] then
present[r.subclass] = true;
table.insert(subs, r.subclass);
end
end
end
table.sort(subs);
return subs, anyItem;
end
local SUBCLASS_ALL = {};
local function SubClassButton_OnClick()
local v = this.value;
if v == SUBCLASS_ALL then
v = nil;
end
frame.subclassFilter = v;
UIDropDownMenu_SetText(v or ALL_SUBCLASSES, frame.dropdown);
frame:ApplyFilter();
end
local function SubClassDropDown_Initialize()
local info = {};
info.text = ALL_SUBCLASSES;
info.value = SUBCLASS_ALL;
info.checked = (frame.subclassFilter == nil);
info.func = SubClassButton_OnClick;
UIDropDownMenu_AddButton(info);
local subs = frame.subclasses or {};
for i = 1, table.getn(subs) do
local b = {};
b.text = subs[i];
b.value = subs[i];
b.checked = (frame.subclassFilter == subs[i]);
b.func = SubClassButton_OnClick;
UIDropDownMenu_AddButton(b);
end
end
local function RefreshSubclasses()
if not (frame and frame:IsShown() and frame.recipes) then
return;
end
local subs, anyItem = ResolveSubclasses(frame.recipes, RefreshSubclasses);
frame.subclasses = subs;
if anyItem then
UIDropDownMenu_Initialize(frame.dropdown, SubClassDropDown_Initialize);
UIDropDownMenu_SetWidth(120, frame.dropdown);
UIDropDownMenu_SetText(frame.subclassFilter or ALL_SUBCLASSES, frame.dropdown);
frame.dropdown:Show();
else
frame.dropdown:Hide();
end
if frame.subclassFilter then
frame:ApplyFilter();
end
end
local function CreateFrame_TradeSkillLink()
local f = CreateFrame("Frame", "ClassicAPITradeSkillLinkFrame", UIParent);
f:SetSize(FRAME_W, FRAME_H);
f:SetPoint("CENTER");
f:SetMovable(true);
f:EnableMouse(true);
f:SetHitRectInsets(LO.hit[1], LO.hit[2], LO.hit[3], LO.hit[4]);
f:RegisterForDrag("LeftButton");
f:SetScript("OnDragStart", function() this:StartMoving(); end);
f:SetScript("OnDragStop", function() this:StopMovingOrSizing(); end);
f:SetFrameStrata("DIALOG");
f:Hide();
tinsert(UISpecialFrames, f:GetName())
f:SetScript("OnShow", function() PlaySound("igCharacterInfoOpen"); end);
f:SetScript("OnHide", function() PlaySound("igCharacterInfoClose"); end);
local portrait = f:CreateTexture(nil, "BACKGROUND");
portrait:SetSize(60, 60);
portrait:SetPoint("TOPLEFT", 7, -6);
f.portrait = portrait;
for i = 1, table.getn(LO.quads) do
local q = LO.quads[i];
local tx = f:CreateTexture(nil, "BORDER");
tx:SetTexture(q[1]);
tx:SetSize(q[2], q[3]);
tx:SetPoint(q[4]);
end
-- Horizontal divider between the recipe list and the detail pane.
-- Stock only (Turtle's HD frame art already has its own).
if LO.sep then
-- Decoded the BLP: it's a thin divider rod whose graphic lives in the
-- top quarter of the 256x64 texture (a 2nd rod fills the next quarter,
-- bottom half empty). So sample the top quarter, stretched to width —
-- the same region the native TradeSkillFrame uses for this divider.
local bar = f:CreateTexture(nil, "ARTWORK");
bar:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-HorizontalBar");
bar:SetTexCoord(0, 1, 0, 0.25);
bar:SetSize(LO.sep[3], 16);
bar:SetPoint("TOPLEFT", LO.sep[1], LO.sep[2]);
end
local title = f:CreateFontString(nil, "OVERLAY", "GameFontNormal");
title:SetPoint("TOP", 0, -18);
f.title = title;
local close = CreateFrame("Button", nil, f, "UIPanelCloseButton");
close:SetPoint("TOPRIGHT", LO.close[1], LO.close[2]);
local exit = CreateFrame("Button", nil, f, "UIPanelButtonTemplate");
exit:SetSize(80, 22);
exit:SetPoint(LO.exit[1], f, LO.exit[2], LO.exit[3], LO.exit[4]);
exit:SetText(EXIT);
exit:SetScript("OnClick", function() f:Hide(); end);
local bar = CreateFrame("StatusBar", nil, f);
bar:SetSize(LO.bar[1], LO.bar[2]);
bar:SetPoint("TOPLEFT", LO.bar[3], LO.bar[4]);
bar:SetStatusBarTexture("Interface\\PaperDollInfoFrame\\UI-Character-Skills-Bar");
bar:SetStatusBarColor(0.0, 0.0, 1.0, 0.5);
bar:SetMinMaxValues(0, 1);
local barBg = bar:CreateTexture(nil, "BACKGROUND");
barBg:SetAllPoints(bar);
barBg:SetTexture(1, 1, 1, 0.2);
barBg:SetVertexColor(0.0, 0.0, 0.75, 0.5);
-- Border: tooltip edge around the bar (TradeSkillRankFrameBorder).
local border = CreateFrame("Frame", nil, bar);
border:SetSize(LO.barBdr[1], LO.barBdr[2]);
border:SetPoint("LEFT", -5, 1);
border:SetBackdrop({
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 16,
});
border:SetBackdropBorderColor(0.4, 0.4, 0.4);
local barName = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall");
barName:SetPoint("LEFT", 6, 1);
local barRank = bar:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
barRank:SetJustifyH("LEFT");
barRank:SetWidth(128);
barRank:SetPoint("LEFT", barName, "RIGHT", 13, 0);
f.bar = bar;
f.barName = barName;
f.barRank = barRank;
local summary = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
summary:SetPoint(LO.summary[1], LO.summary[2], LO.summary[3]);
f.summary = summary;
local dropdown = CreateFrame("Frame", "ClassicAPITradeSkillLinkSubClass", f,
"UIDropDownMenuTemplate");
dropdown:SetPoint("TOPLEFT", LO.drop[1], LO.drop[2]);
f.dropdown = dropdown;
f.subclassFilter = nil; -- nil = all subclasses
local scroll = CreateFrame("ScrollFrame", "ClassicAPITradeSkillLinkScroll",
f, LIST_SCROLL_TEMPLATE);
scroll:SetSize(LO.scroll[1], LO.scroll[2]);
scroll:SetPoint("TOPLEFT", LO.scroll[3], LO.scroll[4]);
scroll:SetScript("OnVerticalScroll", function()
FauxScrollFrame_OnVerticalScroll(ROW_HEIGHT, function() f:Refresh(); end)
end)
f.scroll = scroll;
f.rows = {};
for i = 1, NUM_ROWS do
local rowName = "ClassicAPITradeSkillLinkRow" .. i;
local row = CreateFrame("Button", rowName, f,
"ClassTrainerSkillButtonTemplate");
row:SetWidth(290); -- height (16) comes from the template
if i == 1 then
row:SetPoint("TOPLEFT", scroll, "TOPLEFT", 0, 0);
else
row:SetPoint("TOPLEFT", f.rows[i - 1], "BOTTOMLEFT", 0, 0);
end
row:SetNormalTexture("");
row:SetDisabledTexture("");
row:SetHighlightTexture("");
local txt = getglobal(rowName .. "Text");
if txt then
txt:ClearAllPoints();
txt:SetPoint("LEFT", row, "LEFT", 4, 0);
end
local sel = row:CreateTexture(nil, "BACKGROUND");
sel:SetTexture("Interface\\Buttons\\UI-Listbox-Highlight2");
sel:SetAllPoints(row);
sel:Hide();
row.select = sel;
-- Override the template's trainer scripts with ours.
row:SetScript("OnClick", function()
if this.entry then
f:SelectRecipe(this.entry);
end
end);
f.rows[i] = row;
end
-- Detail pane: a real ScrollFrame (like the native TradeSkillFrame's
-- TradeSkillDetailScrollFrame) whose scroll child holds the product
-- header + reagent grid, so the whole pane pixel-scrolls — clipping
-- partial reagent rows — with the standard UIPanelScrollBar. The bar
-- auto-hides when the content fits (scrollBarHideable).
local detailScroll = CreateFrame("ScrollFrame",
"ClassicAPITradeSkillLinkDetailScroll", f, DETAIL_SCROLL_TEMPLATE);
detailScroll:SetSize(LO.detail[3], LO.detail[4]);
detailScroll:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
-- Bar visibility + wheel are driven per-render by SetDetailBarShown (from
-- the computed overflow). `scrollBarHideable` still lets the template's
-- ScrollFrame_OnScrollRangeChanged manage the slider on range changes.
-- Start with the wheel off; the first RenderDetail/ClearDetail sets it.
detailScroll.scrollBarHideable = 1;
detailScroll:EnableMouseWheel(false);
f.detailScroll = detailScroll;
local detailChild = CreateFrame("Frame",
"ClassicAPITradeSkillLinkDetailChild", detailScroll);
detailChild:SetSize(LO.detail[3], LO.detail[4]);
detailScroll:SetScrollChild(detailChild);
f.detailChild = detailChild;
local prodIcon = CreateFrame("Button", nil, detailChild);
prodIcon:SetSize(37, 37); -- native TradeSkillSkillIcon (37x37 @ 8,-3)
prodIcon:SetPoint("TOPLEFT", 8, -3);
prodIcon:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
local hdrLeft = detailChild:CreateTexture(nil, "BACKGROUND");
hdrLeft:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderLeft");
hdrLeft:SetSize(256, 64); -- Blizzard's native fill width (+64 cap = 320 total)
hdrLeft:SetPoint("TOPLEFT", prodIcon, "TOPLEFT", -8, 6);
local hdrRight = detailChild:CreateTexture(nil, "BACKGROUND");
hdrRight:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderRight");
hdrRight:SetSize(64, 64);
hdrRight:SetPoint("TOPLEFT", hdrLeft, "TOPRIGHT", 0, 0);
local prodCount = prodIcon:CreateFontString(nil, "ARTWORK", "NumberFontNormal");
prodCount:SetPoint("BOTTOMRIGHT", -5, 2);
prodIcon.count = prodCount;
f.prodIcon = prodIcon;
local prodName = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormal");
prodName:SetPoint("LEFT", prodIcon, "RIGHT", 5, 0);
prodName:SetWidth(LO.detail[3] - 60);
prodName:SetJustifyH("LEFT");
prodName:SetJustifyV("MIDDLE");
f.prodName = prodName;
local prodDesc = detailChild:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall");
prodDesc:SetPoint("TOPLEFT", prodIcon, "BOTTOMLEFT", 0, -6);
prodDesc:SetWidth(LO.detail[3] - 20);
prodDesc:SetJustifyH("LEFT");
prodDesc:SetJustifyV("TOP");
f.prodDesc = prodDesc;
local reagentLabel = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall");
reagentLabel:SetPoint("TOPLEFT", prodDesc, "BOTTOMLEFT", 0, -8);
reagentLabel:SetText(SPELL_REAGENTS or "Reagents:");
f.reagentLabel = reagentLabel;
prodIcon:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
if this.itemID then
GameTooltip:SetHyperlink("item:"..this.itemID);
elseif this.spellID then
GameTooltip:SetSpellByID(this.spellID);
end
GameTooltip:Show();
end);
prodIcon:SetScript("OnLeave", GameTooltip_Hide);
-- Reagent buttons live in the scroll child (2 per row), built on
-- demand so any reagent count is handled and they scroll/clip with
-- the child.
f.reagents = {};
local function AcquireReagent(i)
if f.reagents[i] then
return f.reagents[i];
end
local rowN = math.floor((i - 1) / 2);
local col = (i - 1) - rowN * 2;
local btn = CreateFrame("Button", nil, detailChild);
btn:SetSize(39, 39);
btn:SetPoint("TOPLEFT", reagentLabel, "BOTTOMLEFT",
col * 134 - 5, -3 - rowN * REAGENT_ROW_HEIGHT);
-- Extend the hit area rightward over the name plate so the tooltip
-- fires anywhere on the element, not just the icon. Stop short of the
-- next column (pitch 134 − icon 39) so hovers don't bleed across.
btn:SetHitRectInsets(0, -(134 - 39), 0, 0);
btn:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
local plate = btn:CreateTexture(nil, "BACKGROUND");
plate:SetTexture("Interface\\QuestFrame\\UI-QuestItemNameFrame");
plate:SetSize(114, 64);
plate:SetPoint("LEFT", btn, "RIGHT", -10, 0);
local cnt = btn:CreateFontString(nil, "ARTWORK", "NumberFontNormal");
cnt:SetPoint("BOTTOMRIGHT", -4, 1);
btn.count = cnt;
local nm = btn:CreateFontString(nil, "ARTWORK", "GameFontHighlight");
nm:SetPoint("LEFT", plate, "LEFT", 15, 0);
nm:SetSize(80, 36);
nm:SetJustifyH("LEFT");
nm:SetJustifyV("MIDDLE");
btn.nameText = nm;
btn:SetScript("OnEnter", function()
if this.itemID then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
GameTooltip:SetHyperlink("item:"..this.itemID);
GameTooltip:Show();
end
end);
btn:SetScript("OnClick", function()
if this.itemLink and IsShiftKeyDown() and ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:Insert(this.itemLink);
end
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
f.reagents[i] = btn;
return btn;
end
-- Show the detail bar/borders only when the content overflows the pane.
-- Driven by our own computed overflow, NOT the engine: scrollBarHideable's
-- auto-hide only fires on a range CHANGE (so content that fits from the
-- start never hides), and GetVerticalScrollRange isn't reliably readable
-- the same tick as UpdateScrollChildRect.
function f:SetDetailBarShown(show)
self.detailScroll:EnableMouseWheel(show);
local base = "ClassicAPITradeSkillLinkDetailScroll";
local sb = getglobal(base .. "ScrollBar");
if sb then sb:SetShown(show) end
local top = getglobal(base .. "Top");
if top then top:SetShown(show) end
local bottom = getglobal(base .. "Bottom");
if bottom then bottom:SetShown(show) end
end
function f:RenderDetail(entry)
local function itemInfo(id)
local item = Item:CreateFromItemID(id);
if not item:IsItemDataCached() then
item:ContinueOnItemLoad(function()
if self:IsShown() and self.selectedSpellID == entry.spellID then
self:RenderDetail(entry);
end
end);
end
return item:GetItemName(), item:GetItemIcon(), item:GetItemLink();
end
local prodTex, prodText, prodLink;
local created = entry.createdItem or 0;
if created > 0 then
local name, tex, link = itemInfo(created);
prodText = name or entry.name;
prodTex = tex;
prodLink = link;
else
prodText = entry.name;
prodTex = C_Spell.GetSpellTexture(entry.spellID);
end
local descText = (created > 0) and ""
or (C_Spell.GetSpellDescription(entry.spellID) or "");
self.prodDesc:SetText(descText);
self.prodIcon:SetNormalTexture(prodTex or
"Interface\\Icons\\INV_Misc_QuestionMark");
self.prodName:SetText(prodText or "");
self.prodName:SetTextColor(NORMAL_FONT_COLOR:GetRGB());
self.prodIcon.itemLink = prodLink; -- item tooltip when created > 0
self.prodIcon.spellID = entry.spellID; -- else spell tooltip
local made = entry.numMade or 0;
self.prodIcon.count:SetText(made > 1 and tostring(made) or "");
self.prodIcon:Show();
-- Fill every reagent slot; the scroll child clips/scrolls the
-- overflow. Buttons past the count are hidden.
local reagents = entry.reagents or {};
local total = table.getn(reagents);
for i = 1, total do
local btn = AcquireReagent(i);
local reagent = reagents[i];
local name, tex, link = itemInfo(reagent.itemID);
btn:SetNormalTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark");
btn.itemLink = link;
btn.itemID = reagent.itemID
btn.nameText:SetText(name or ("item:" .. reagent.itemID));
local need = reagent.count or 1;
local have = C_Item.GetItemCount(reagent.itemID) or 0;
if have >= need then
local r, g, b = HIGHLIGHT_FONT_COLOR:GetRGB()
btn:GetNormalTexture():SetVertexColor(r, g, b);
btn.nameText:SetTextColor(r, g, b);
else
local r, g, b = GRAY_FONT_COLOR:GetRGB()
btn:GetNormalTexture():SetVertexColor(r, g, b);
btn.nameText:SetTextColor(r, g, b);
end
local haveText = (have >= 100) and "*" or tostring(have);
btn.count:SetText(haveText .. " /" .. need);
btn:Show();
end
for i = total + 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i].itemID = nil;
self.reagents[i]:Hide();
end
local function applyContentHeight()
local contentH = LO.detail[4];
if total > 0 then
local childTop = self.detailChild:GetTop();
local lastBtn = self.reagents[total];
local lastBottom = lastBtn and lastBtn:GetBottom();
if childTop and lastBottom then
-- +22: ~12px name-plate overhang below the icon + ~10px pad.
contentH = (childTop - lastBottom) + 22;
end
end
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
-- Recompute the scroll range from the child's new height, then
-- show the bar only if the content actually overflows the pane.
self.detailScroll:UpdateScrollChildRect();
self:SetDetailBarShown(contentH > LO.detail[4] + 0.5);
end
applyContentHeight();
local sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if sb then
sb:SetValue(0);
end
self.detailScroll:SetVerticalScroll(0);
RunNextFrame(function()
if self:IsShown() and self.selectedSpellID == entry.spellID then
applyContentHeight();
end
end)
end
function f:SelectRecipe(entry)
self.selectedSpellID = entry.spellID;
self.selectedEntry = entry;
self:RenderDetail(entry); -- also resets the detail scroll to top
self:Refresh();
end
function f:ClearDetail()
self.selectedSpellID = nil;
self.selectedEntry = nil;
self.prodIcon:Hide();
self.prodName:SetText("");
self.prodDesc:SetText("");
for i = 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i].itemID = nil;
self.reagents[i]:Hide();
end
self.detailChild:SetHeight(LO.detail[4]);
self.detailScroll:UpdateScrollChildRect();
self:SetDetailBarShown(false);
self.detailScroll:SetVerticalScroll(0);
end
function f:Refresh()
local recipes = self.displayed or {};
local total = table.getn(recipes);
FauxScrollFrame_Update(self.scroll, total, NUM_ROWS, ROW_HEIGHT);
local offset = FauxScrollFrame_GetOffset(self.scroll);
for i = 1, NUM_ROWS do
local row = self.rows[i];
local entry = recipes[offset + i];
if entry then
-- Append " [N]" when the reader can craft any (Blizzard's list).
local craftable = CraftableCount(entry);
local label = entry.name or "";
if craftable and craftable > 0 then
label = label .. " [" .. craftable .. "]";
end
row:SetText(label);
local r, g, b = BAND_COLOR[entry.band or 3]:GetRGB();
if self.selectedSpellID and entry.spellID == self.selectedSpellID then
row.select:SetVertexColor(r, g, b);
row.select:Show();
row:SetTextColor(1, 1, 1);
else
row.select:Hide();
row:SetTextColor(r, g, b);
end
row.spellID = entry.spellID;
row.entry = entry;
row:Show();
else
row.spellID = nil;
row.entry = nil;
row.select:Hide();
row:Hide();
end
end
end
function f:ApplyFilter()
local src = self.recipes or {};
if not self.subclassFilter then
self.displayed = src;
else
local out = {};
for i = 1, table.getn(src) do
if src[i].subclass == self.subclassFilter then
table.insert(out, src[i]);
end
end
self.displayed = out;
end
local sb = getglobal("ClassicAPITradeSkillLinkScrollScrollBar");
if sb then
sb:SetValue(0);
end
self:Refresh();
end
f:RegisterEvent("BAG_UPDATE_DELAYED");
f:SetScript("OnEvent", function()
if not f:IsShown() then
return;
end
f:Refresh();
if f.selectedEntry then
f:RenderDetail(f.selectedEntry);
end
end);
return f;
end
local function ShowTradeSkillLink(link, text)
local id, cur, max, who, bits = ParseTradeLink(link);
if not id then
return false;
end
local recipes = C_TradeSkillUI.GetTradeSkillListRecipes(id, bits);
if not recipes then
return false;
end
local prof = text and string.match(text, "|h%[(.+)%]|h") or "Trade Skills";
local total = table.getn(recipes);
local knownList = {};
for i = 1, total do
local r = recipes[i];
if r.isKnown then
r.band = DifficultyBand(cur or 0, r.trivialLevel, r.greenLevel);
r.name = C_Spell.GetSpellName(r.spellID) or ("spell:" .. r.spellID);
table.insert(knownList, r);
end
end
table.sort(knownList, function(a, b)
if a.band ~= b.band then
return a.band < b.band;
end
return a.name < b.name;
end);
local known = table.getn(knownList);
if not frame then
frame = CreateFrame_TradeSkillLink();
end
frame.title:SetText(who and who ~= "" and (who .. " - " .. prof) or prof);
SetPortraitToTexture(frame.portrait, ProfessionIcon(id));
frame.bar:SetMinMaxValues(0, (max and max > 0) and max or 1);
frame.bar:SetValue(cur or 0);
frame.barName:SetText(prof);
frame.barRank:SetFormattedText("%d/%d", cur or 0, max or 0);
frame.summary:SetFormattedText("%d of %d recipes known", known, total);
frame.recipes = knownList;
frame.subclassFilter = nil;
frame:ClearDetail();
frame:Show();
RefreshSubclasses();
frame:ApplyFilter();
local first = frame.displayed and frame.displayed[1];
if first then
frame:SelectRecipe(first);
end
return true;
end
------------------------------------------------------------------------------
-- Click interception: handle `trade:` before the engine's SetHyperlink (which
-- in 1.12 only knows item:/enchant: and would reject the link). Registered
-- on LinkUtil's SetItemRef dispatch (Util/LinkUtil.lua) instead of wrapping
-- SetItemRef a second time, so there is exactly one override to chain.
------------------------------------------------------------------------------
LinkUtil.RegisterLinkHandler("trade", function(link, text, linkData, contextData)
if IsShiftKeyDown() and ChatFrameEditBox and ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:Insert(text);
return LinkProcessorResponse.Handled;
end
if ShowTradeSkillLink(link, text) then
return LinkProcessorResponse.Handled;
end
return LinkProcessorResponse.Unhandled;
end);
local function CreateProfessionLinkButton(parentName, getLink)
local parent = _G[parentName];
local btnName = parentName .. "LinkButton";
if _G[btnName] or not parent then
return;
end
local title = _G[parentName .. "TitleText"];
local btn = CreateFrame("Button", btnName, parent);
btn:SetSize(16, 16);
local function reposition()
if not title then
return;
end
btn:ClearAllPoints();
if title:GetJustifyH() == "LEFT" then
btn:SetPoint("LEFT", title, "LEFT", title:GetStringWidth() + 4, 0);
else
btn:SetPoint("LEFT", title, "CENTER", title:GetStringWidth() / 2 + 4, 0);
end
end
reposition()
btn:SetScript("OnShow", function() RunNextFrame(reposition) end)
hooksecurefunc(title, "SetText", function() RunNextFrame(reposition) end)
local icon = "Interface\\GossipFrame\\PetitionGossipIcon";
btn:SetNormalTexture(icon);
btn:SetHighlightTexture(icon, "ADD");
btn:SetScript("OnClick", function()
local link = getLink();
if not link then
return;
end
if not ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:Show();
end
ChatFrameEditBox:Insert(link);
end);
btn:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_TOPLEFT");
GameTooltip:SetText(LINK_TRADESKILL_TOOLTIP, nil, nil, nil, nil, 1);
GameTooltip:Show();
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
end
EventUtil.ContinueOnAddOnLoaded("Blizzard_TradeSkillUI", function()
CreateProfessionLinkButton("TradeSkillFrame", C_TradeSkillUI.GetTradeSkillListLink);
end)
EventUtil.ContinueOnAddOnLoaded("Blizzard_CraftUI", function()
CreateProfessionLinkButton("CraftFrame", C_TradeSkillUI.GetCraftListLink)
end)
EventUtil.ContinueOnAddOnLoaded("AdvancedTradeSkillWindow", function()
CreateProfessionLinkButton("ATSWFrame", function()
return atsw_oldmode and C_TradeSkillUI.GetCraftListLink() or C_TradeSkillUI.GetTradeSkillListLink()
end)
end)
EventUtil.ContinueOnAddOnLoaded("AdvancedTradeSkillWindow2", function()
CreateProfessionLinkButton("ATSWFrame", function()
return ATSW_TradeSkill() and C_TradeSkillUI.GetTradeSkillListLink() or C_TradeSkillUI.GetCraftListLink()
end)
end)
-187
View File
@@ -1,187 +0,0 @@
ACTION_BUTTON_USE_KEY_DOWN = "Cast action keybinds on key down";
ACTION_TARGETING_OPTION = "Enable Action Targeting";
ADDON_MEM_KB_ABBR = "(%.0f KB) %s";
ADDON_MEM_MB_ABBR = "(%.2f MB) %s";
ADDON_OPTIONS = "AddOn Options";
BINDING_NAME_AOELOOT = "AoE Loot";
BINDING_NAME_FOCUSTARGET = "Focus Target";
BINDING_NAME_INTERACTMOUSEOVER = "Interact With Mouseover";
BINDING_NAME_INTERACTTARGET = "Interact With Target";
BINDING_NAME_TARGETFOCUS = "Target Focus";
COMBAT_LABEL = "Combat";
CONFIRM_DELETE_EQUIPMENT_SET = "Are you sure you want to delete the equipment set %s?";
CONFIRM_SAVE_EQUIPMENT_SET = "Would you like to save the equipment set \"%s\"?";
COPPER_AMOUNT_SYMBOL = "c";
COPPER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:2:0:4:1:2:3:0:1|t";
DEBUFF_SYMBOL_CURSE = "Cu";
DEBUFF_SYMBOL_DISEASE = "Di";
DEBUFF_SYMBOL_MAGIC = "Ma";
DEBUFF_SYMBOL_POISON = "Po";
DEBUG_FRAMESTACK = "Frame Stack";
ENABLE_INTERACT_TEXT = "Enable Interact Key";
EQUIPMENT_MANAGER = "Equipment Manager";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignore This Slot";
EQUIPMENT_MANAGER_IS_DISABLED = "The Equipment Manager is disabled.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(%d missing)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Place In Bags";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Include This Slot";
EQUIPMENT_SETS = "Equipment Sets: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "An equipment set with that name already exists.";
EQUIPMENT_SETS_TOO_MANY = "You cannot create any more new equipment sets.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Assign to Specialization:";
EQUIPMENT_SET_EDIT = "Change Name/Icon";
EQUIPMENT_SET_SETTINGS = "Settings";
EQUIPSET_EQUIP = "Equip";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Equipment swap failed. Inventory is full.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Some items from set %s cannot be swapped because you are in combat.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "One or more items from set %s were missing and could not be equipped.";
CLEAR_FOCUS = "Clear Focus";
INTERACT_ICONS_DEFAULT = "NPCs Only (Default)";
INTERACT_ICONS_OPTION = "Interact Key Icons";
INTERACT_ICONS_SHOW_ALL = "Show All";
INTERACT_ICONS_SHOW_NONE = "Show None";
OPTION_TOOLTIP_ACTION_BUTTON_USE_KEY_DOWN = "Action button keybinds will respond on key down, rather than on key up.";
OPTION_TOOLTIP_ACTION_TARGETING = "A targeting system that dynamically targets enemies based on where you're looking. Works alongside the standard targeting system.";
OPTION_TOOLTIP_ENABLE_INTERACT = "Allows you to interact with NPCs and objects using a keypress";
OPTION_TOOLTIP_INTERACT_ICONS = "Controls which icons appear above NPCs and objects when the Interact Key feature is enabled";
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "Stop auto attacking when you switch targets.";
SET_FOCUS = "Set Focus";
FOCUS_TOKEN_NOT_FOUND = "<no focus>";
FOCUSTARGET = "Focus Target";
GEARSETS_POPUP_TEXT = "Enter Set Name (Max 16 Characters):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "g";
GOLD_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:2:0:4:1:0:1:0:1|t";
GOLD_AMOUNT_TEXTURE_STRING = "%s|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:2:0:4:1:0:1:0:1|t";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "All Icons";
ICON_FILTER_ITEM = "Items";
ICON_FILTER_SPELL = "Spells";
ICON_SELECTION_TITLE_CURRENT = "Currently Selected";
ITEM_DELTA_DESCRIPTION = "If you replace this item, the following stat changes will occur:";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Health";
ITEM_MOD_AGILITY_SHORT = "Agility";
ITEM_MOD_STRENGTH_SHORT = "Strength";
ITEM_MOD_INTELLECT_SHORT = "Intellect";
ITEM_MOD_SPIRIT_SHORT = "Spirit";
ITEM_MOD_STAMINA_SHORT = "Stamina";
ITEM_MOD_ATTACK_POWER_SHORT = "Attack Power";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Ranged Attack Power";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Critical Strike (Melee)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Critical Strike (Ranged)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Hit (Melee)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Hit (Ranged)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Hit (Spell)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Critical Strike (Spell)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Spell Damage";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Spell Healing";
ITEM_MOD_MANA_REGENERATION_SHORT = "Mana Regeneration";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Defense";
ITEM_MOD_DODGE_RATING_SHORT = "Dodge";
ITEM_MOD_PARRY_RATING_SHORT = "Parry";
ITEM_MOD_BLOCK_RATING_SHORT = "Block";
ITEM_MOD_BLOCK_VALUE_SHORT = "Block Value";
ITEM_MOD_HOLY_SPELL_DAMAGE_DONE_SHORT = "Holy Spell Damage";
ITEM_MOD_FIRE_SPELL_DAMAGE_DONE_SHORT = "Fire Spell Damage";
ITEM_MOD_NATURE_SPELL_DAMAGE_DONE_SHORT = "Nature Spell Damage";
ITEM_MOD_FROST_SPELL_DAMAGE_DONE_SHORT = "Frost Spell Damage";
ITEM_MOD_SHADOW_SPELL_DAMAGE_DONE_SHORT = "Shadow Spell Damage";
ITEM_MOD_ARCANE_SPELL_DAMAGE_DONE_SHORT = "Arcane Spell Damage";
ITEM_MOD_SPELL_PENETRATION_SHORT = "Spell Penetration";
ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "Armor Penetration";
ITEM_MOD_HEALTH_REGEN_SHORT = "Health Regeneration";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Damage Per Second";
ITEMS_EQUIPPED = "%d |4item:items; equipped";
ITEMS_IN_INVENTORY = "%d |4item:items; in inventory";
ITEMS_NOT_IN_INVENTORY = "%d |4item:items; missing";
ITEMS_VARIABLE_QUANTITY = "%d |4Item:Items;";
ITEM_MISSING = "%s missing";
ITEM_SLOTS_IGNORED = "%d |4slot:slots; ignored";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Equipment Manager";
PAPERDOLL_NEWEQUIPMENTSET = "New Set";
PERCENTAGE_STRING = "%d%%";
PLAYER_LIST_DELIMITER = ", ";
SET_FRIENDNOTE_LABEL = "Set Notes for %s:";
SET_NOTE = "Set Note";
SILVER_AMOUNT_SYMBOL = "s";
SILVER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:2:0:4:1:1:2:0:1|t";
SLASH_CANCELAURA1 = "/cancelaura";
SLASH_CANCELAURA2 = "/cancelaura";
SLASH_CANCELFORM1 = "/cancelform";
SLASH_CANCELFORM2 = "/cancelform";
SLASH_CHANGEACTIONBAR1 = "/changeactionbar";
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_CLEARTARGET1 = "/cleartarget";
SLASH_CLEARTARGET2 = "/cleartarget";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/click";
SLASH_DISMOUNT1 = "/dismount";
SLASH_DISMOUNT2 = "/dismount";
SLASH_EQUIP1 = "/equip";
SLASH_EQUIP2 = "/equip";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_EQUIP_TO_SLOT1 = "/equipslot";
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focus";
SLASH_PET_AGGRESSIVE1 = "/petaggressive";
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
SLASH_PET_ATTACK1 = "/petattack";
SLASH_PET_ATTACK2 = "/petattack";
SLASH_PET_AUTOCASTOFF1 = "/petautocastoff";
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
SLASH_PET_AUTOCASTON1 = "/petautocaston";
SLASH_PET_AUTOCASTON2 = "/petautocaston";
SLASH_PET_AUTOCASTTOGGLE1 = "/petautocasttoggle";
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
SLASH_PET_DEFENSIVE1 = "/petdefensive";
SLASH_PET_DEFENSIVE2 = "/petdefensive";
SLASH_PET_FOLLOW1 = "/petfollow";
SLASH_PET_FOLLOW2 = "/petfollow";
SLASH_PET_PASSIVE1 = "/petpassive";
SLASH_PET_PASSIVE2 = "/petpassive";
SLASH_PET_STAY1 = "/petstay";
SLASH_PET_STAY2 = "/petstay";
SLASH_STARTATTACK1 = "/startattack";
SLASH_STARTATTACK2 = "/startattack";
SLASH_STOPATTACK1 = "/stopattack";
SLASH_STOPATTACK2 = "/stopattack";
SLASH_STOPCASTING1 = "/stopcasting";
SLASH_STOPCASTING2 = "/stopcasting";
SLASH_SWAPACTIONBAR1 = "/swapactionbar";
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
SLASH_TARGET_EXACT1 = "/targetexact";
SLASH_TARGET_EXACT2 = "/targetexact";
SLASH_TARGET_LAST_ENEMY1 = "/targetlastenemy";
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
SLASH_TARGET_LAST_TARGET1 = "/targetlasttarget";
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
SLASH_TARGET_NEAREST_ENEMY1 = "/targetenemy";
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_FRIEND1 = "/targetfriend";
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_PARTY1 = "/targetparty";
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
SLASH_TARGET_NEAREST_RAID1 = "/targetraid";
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/use";
SLASH_USE2 = "/use";
STOP_AUTO_ATTACK = "Stop Auto Attack";
TARGETFOCUS = "Target Focus";
LINK_TRADESKILL_TOOLTIP = "Click here to create a link to your profession.";
TEXT_TO_SPEECH = "Text To Speech";
TEXT_TO_SPEECH_ADJUST_RATE = "Adjust Rate of Speech";
TEXT_TO_SPEECH_ADJUST_VOLUME = "Speech Volume";
TEXT_TO_SPEECH_VOICE_OPTION = "Voice Option";
TOTAL_MEM_KB_ABBR = "AddOn Memory: %.0f KB";
TOTAL_MEM_MB_ABBR = "AddOn Memory: %.2f MB";
-182
View File
@@ -1,182 +0,0 @@
if GetLocale() ~= "ruRU" then return end
ACTION_BUTTON_USE_KEY_DOWN = "Способности при нажатии клавиш";
ACTION_TARGETING_OPTION = "Разрешить динамическое выделение";
ADDON_MEM_KB_ABBR = "(%.0f КБ) %s";
ADDON_MEM_MB_ABBR = "(%.2f МБ) %s";
BINDING_NAME_FOCUSTARGET = "Запомненная цель";
BINDING_NAME_INTERACTMOUSEOVER = "Взаимодействовать указателем мыши";
BINDING_NAME_INTERACTTARGET = "Взаимодействовать с целью";
BINDING_NAME_TARGETFOCUS = "Восстановить цель";
COMBAT_LABEL = "Бой";
CONFIRM_DELETE_EQUIPMENT_SET = "Вы действительно хотите удалить комплект экипировки \"%s\"?";
CONFIRM_SAVE_EQUIPMENT_SET = "Хотите сохранить комплект экипировки \"%s\"?";
DEBUFF_SYMBOL_CURSE = "Пр";
DEBUFF_SYMBOL_DISEASE = "Бз";
DEBUFF_SYMBOL_MAGIC = "Мг";
DEBUFF_SYMBOL_POISON = "Яд";
DEBUG_FRAMESTACK = "Соединение рамок";
ENABLE_INTERACT_TEXT = "Включить клавишу взаимодействия";
EQUIPMENT_MANAGER = "Комплекты экипировки";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Пропустить ячейку";
EQUIPMENT_MANAGER_IS_DISABLED = "Управление экипировкой отключено.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(предмет для ячейки \"%d\" отсутствует)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Поместить в сумку";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Учитывать ячейку";
EQUIPMENT_SETS = "Комплекты экипировки: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Это название уже используется для другого комплекта.";
EQUIPMENT_SETS_TOO_MANY = "Вы не можете создавать новые комплекты экипировки.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Выбрать специализацию:";
EQUIPMENT_SET_EDIT = "Изменить название/значок";
EQUIPMENT_SET_SETTINGS = "Параметры";
EQUIPSET_EQUIP = "Надеть";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Не удалось заменить комплект обмундирования. Инвентарь переполнен.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Некоторые предметы из набора \"%s\" не будут экипированы, поскольку вы участвуете в бою.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Недостает одного или более предметов из набора \"%s\".";
CLEAR_FOCUS = "Отменить запоминание цели";
INTERACT_ICONS_DEFAULT = "Только NPC (по умолчанию)";
INTERACT_ICONS_OPTION = "Значки клавиш взаимодействия";
INTERACT_ICONS_SHOW_ALL = "Показать все";
INTERACT_ICONS_SHOW_NONE = "Не показывать";
OPTION_TOOLTIP_ACTION_BUTTON_USE_KEY_DOWN = "Способности будут задействоваться, когда пользователь нажимает, а не отпускает клавишу.";
OPTION_TOOLTIP_ACTION_TARGETING = "Система захвата целей, динамически выбирающая цели в поле зрения. Работает параллельно со стандартной системой наведения.";
OPTION_TOOLTIP_ENABLE_INTERACT = "Позволяет взаимодействовать с NPC и объектами по нажатию клавиши.";
OPTION_TOOLTIP_INTERACT_ICONS = "Определяет, какие значки появляются над NPC и объектами, если включен параметр клавиши взаимодействия.";
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "Прекращение автоматической атаки при смене цели.";
SET_FOCUS = "Запомнить цель";
FOCUSTARGET = "Запомнить цель";
ITEMS_VARIABLE_QUANTITY = "%d |4предмет:предмета:предметов;";
ITEMS_EQUIPPED = "%d |4предмет экипирован:предмета экипировано:предметов экипировано;";
ITEMS_IN_INVENTORY = "%d |4предмет:предмета:предметов; в сумках";
ITEM_SLOTS_IGNORED = "%d |4ячейка пропущена:ячейки пропущены:ячейки пропущены;";
ITEM_MISSING = "Предмет для ячейки %s отсутствует";
ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предмета отсутствует:предметов отсутствуют;";
STOP_AUTO_ATTACK = "Прекращение автоатаки";
TARGETFOCUS = "Восстановить цель";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "м";
GEARSETS_POPUP_TEXT = "Введите название комплекта (не более 16 символов):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "з";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Все значки";
ICON_FILTER_ITEM = "Предметы";
ICON_FILTER_SPELL = "Заклинания";
ICON_SELECTION_TITLE_CURRENT = "Текущий выбор";
ITEM_DELTA_DESCRIPTION = "Если вы замените этот предмет, произойдут следующие изменения:";
ITEM_MOD_MANA_SHORT = "к мане";
ITEM_MOD_HEALTH_SHORT = "к здоровью";
ITEM_MOD_AGILITY_SHORT = "к ловкости";
ITEM_MOD_STRENGTH_SHORT = "к силе";
ITEM_MOD_INTELLECT_SHORT = "к интеллекту";
ITEM_MOD_SPIRIT_SHORT = "к духу";
ITEM_MOD_STAMINA_SHORT = "к выносливости";
ITEM_MOD_ATTACK_POWER_SHORT = "к силе атаки";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "к силе атаки дальнего боя";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "к показателю критического удара в ближнем бою";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "к показателю критического удара в дальнем бою";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "к меткости в ближнем бою";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "к меткости в дальнем бою";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "к меткости заклинаний";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "к критическому эффекту заклинаний";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "к дополнительному урону";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "к дополнительному исцелению";
ITEM_MOD_MANA_REGENERATION_SHORT = "к восполнению маны";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Защита";
ITEM_MOD_DODGE_RATING_SHORT = "Уклонение";
ITEM_MOD_PARRY_RATING_SHORT = "Парирование";
ITEM_MOD_BLOCK_RATING_SHORT = "Блок";
ITEM_MOD_BLOCK_VALUE_SHORT = "к показателю блокирования";
ITEM_MOD_HOLY_SPELL_DAMAGE_DONE_SHORT = "к урону от магии света";
ITEM_MOD_FIRE_SPELL_DAMAGE_DONE_SHORT = "к урону от магии огня";
ITEM_MOD_NATURE_SPELL_DAMAGE_DONE_SHORT = "к урону от сил природы";
ITEM_MOD_FROST_SPELL_DAMAGE_DONE_SHORT = "к урону от магии льда";
ITEM_MOD_SHADOW_SPELL_DAMAGE_DONE_SHORT = "к урону от темной магии";
ITEM_MOD_ARCANE_SPELL_DAMAGE_DONE_SHORT = "к урону от тайной магии";
ITEM_MOD_SPELL_PENETRATION_SHORT = "к проникающей способности заклинаний";
ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "к пробиванию брони";
ITEM_MOD_HEALTH_REGEN_SHORT = "к восполнению здоровья";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "к урону в секунду";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Комплекты экипировки";
PAPERDOLL_NEWEQUIPMENTSET = "Новый комплект";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Заметка о |3-5(%s):";
SET_NOTE = "Добавить заметку";
SILVER_AMOUNT_SYMBOL = "с";
SLASH_CANCELAURA1 = "/снятьауру";
SLASH_CANCELAURA2 = "/cancelaura";
SLASH_CANCELFORM1 = "/нормоблик";
SLASH_CANCELFORM2 = "/cancelform";
SLASH_CHANGEACTIONBAR1 = "/сменитьпанель";
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/сбросфокуса";
SLASH_CLEARTARGET1 = "/сбросцели";
SLASH_CLEARTARGET2 = "/cleartarget";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/щелчок";
SLASH_DISMOUNT1 = "/спешиться";
SLASH_DISMOUNT2 = "/dismount";
SLASH_EQUIP1 = "/экип";
SLASH_EQUIP2 = "/equip";
SLASH_EQUIP_SET1 = "/комплект";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_EQUIP_TO_SLOT1 = "/надеть";
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/фокус";
SLASH_PET_AGGRESSIVE1 = "/питомецагрессивный";
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
SLASH_PET_ATTACK1 = "/питомецатака";
SLASH_PET_ATTACK2 = "/petattack";
SLASH_PET_AUTOCASTOFF1 = "/питомецзаклинаниеоткл";
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
SLASH_PET_AUTOCASTON1 = "/питомецзаклинаниевкл";
SLASH_PET_AUTOCASTON2 = "/petautocaston";
SLASH_PET_AUTOCASTTOGGLE1 = "/питомецавтозаклинание";
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
SLASH_PET_DEFENSIVE1 = "/питомецзащита";
SLASH_PET_DEFENSIVE2 = "/petdefensive";
SLASH_PET_FOLLOW1 = "/питомецзамной";
SLASH_PET_FOLLOW2 = "/petfollow";
SLASH_PET_PASSIVE1 = "/питомецпассивный";
SLASH_PET_PASSIVE2 = "/petpassive";
SLASH_PET_STAY1 = "/питомецстоять";
SLASH_PET_STAY2 = "/petstay";
SLASH_STARTATTACK1 = "/startattack";
SLASH_STARTATTACK2 = "/начатьатаку";
SLASH_STOPATTACK1 = "/stopattack";
SLASH_STOPATTACK2 = "/стопатака";
SLASH_STOPCASTING1 = "/стопзаклинание";
SLASH_STOPCASTING2 = "/stopcasting";
SLASH_SWAPACTIONBAR1 = "/сменитьпанелькоманд";
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
SLASH_TARGET_EXACT1 = "/цельточно";
SLASH_TARGET_EXACT2 = "/targetexact";
SLASH_TARGET_LAST_ENEMY1 = "/цельпослвраг";
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
SLASH_TARGET_LAST_TARGET1 = "/цельпослцель";
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
SLASH_TARGET_NEAREST_ENEMY1 = "/цельвраг";
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_FRIEND1 = "/цельдруг";
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_PARTY1 = "/цельгруппа";
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
SLASH_TARGET_NEAREST_RAID1 = "/цельрейд";
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/use";
SLASH_USE2 = "/использовать";
LINK_TRADESKILL_TOOLTIP = "Щелкните здесь, чтобы создать ссылку на вашу профессию.";
TEXT_TO_SPEECH = "Текст в речь";
TEXT_TO_SPEECH_ADJUST_RATE = "Изменить скорость речи.";
TEXT_TO_SPEECH_ADJUST_VOLUME = "Громкость речи";
TEXT_TO_SPEECH_VOICE_OPTION = "Параметры голоса";
TOTAL_MEM_KB_ABBR = "Память: %.0f КБ";
TOTAL_MEM_MB_ABBR = "Модификации занимают в памяти: %.2f МБ";
-182
View File
@@ -1,182 +0,0 @@
if GetLocale() ~= "zhCN" then return end
ACTION_BUTTON_USE_KEY_DOWN = "按下快捷键时施法";
ACTION_TARGETING_OPTION = "开启动作瞄准";
ADDON_MEM_KB_ABBR = "(%.0f KB) %s";
ADDON_MEM_MB_ABBR = "(%.2f MB) %s";
BINDING_NAME_FOCUSTARGET = "焦点目标";
BINDING_NAME_INTERACTMOUSEOVER = "与鼠标悬停处互动";
BINDING_NAME_INTERACTTARGET = "与目标互动";
BINDING_NAME_TARGETFOCUS = "目标焦点";
COMBAT_LABEL = "战斗";
CONFIRM_DELETE_EQUIPMENT_SET = "你确认要删除装备方案%s吗?";
CONFIRM_SAVE_EQUIPMENT_SET = "你想要保存装备方案\"%s\"吗?";
DEBUFF_SYMBOL_CURSE = "诅";
DEBUFF_SYMBOL_DISEASE = "疾";
DEBUFF_SYMBOL_MAGIC = "魔";
DEBUFF_SYMBOL_POISON = "毒";
DEBUG_FRAMESTACK = "框架栈";
ENABLE_INTERACT_TEXT = "开启交互按键";
EQUIPMENT_MANAGER = "装备管理";
EQUIPMENT_MANAGER_IGNORE_SLOT = "忽略此栏位";
EQUIPMENT_MANAGER_IS_DISABLED = "装备管理已禁用。";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(缺少%d)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "放入背包";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "包括此栏位";
EQUIPMENT_SETS = "装备配置方案:|cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "一个重名的装备方案已经存在。";
EQUIPMENT_SETS_TOO_MANY = "你不能再创建新的装备方案了。";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "指定专精:";
EQUIPMENT_SET_EDIT = "修改名称/图标";
EQUIPMENT_SET_SETTINGS = "设置";
EQUIPSET_EQUIP = "装备";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "装备更换失败。背包已满。";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "装备方案%s中的某些物品不能在战斗中切换。";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "未找到套装方案%s中的一件或多件物品,无法装备。";
CLEAR_FOCUS = "清除焦点";
INTERACT_ICONS_DEFAULT = "仅限NPC(默认)";
INTERACT_ICONS_OPTION = "交互按键图标";
INTERACT_ICONS_SHOW_ALL = "显示全部";
INTERACT_ICONS_SHOW_NONE = "全不显示";
OPTION_TOOLTIP_ACTION_BUTTON_USE_KEY_DOWN = "在按下快捷键时施法,而不是在松开快捷键时施法。";
OPTION_TOOLTIP_ACTION_TARGETING = "此瞄准系统可以以你观看的方向动态地瞄准敌人。可以和标准的瞄准系统一起使用。";
OPTION_TOOLTIP_ENABLE_INTERACT = "使你可以通过按键与NPC和物体进行交互";
OPTION_TOOLTIP_INTERACT_ICONS = "控制交互按键启用后NPC和物体上方出现的图标";
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "当你切换目标时停止自动攻击。";
SET_FOCUS = "设置焦点";
FOCUSTARGET = "设定焦点目标";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "已装备%d件物品";
ITEMS_IN_INVENTORY = "背包中有%d件物品";
ITEM_SLOTS_IGNORED = "忽略%d个栏位";
ITEM_MISSING = "%s缺失";
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
STOP_AUTO_ATTACK = "停止自动攻击";
TARGETFOCUS = "选中焦点目标";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "铜";
GEARSETS_POPUP_TEXT = "输入方案名称(最多16个字符):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "金";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "所有图标";
ICON_FILTER_ITEM = "物品";
ICON_FILTER_SPELL = "法术";
ICON_SELECTION_TITLE_CURRENT = "当前已选择";
ITEM_DELTA_DESCRIPTION = "如果你替换该物品,将会产生以下的属性变更:";
ITEM_MOD_MANA_SHORT = "法力值";
ITEM_MOD_HEALTH_SHORT = "生命值";
ITEM_MOD_AGILITY_SHORT = "敏捷";
ITEM_MOD_STRENGTH_SHORT = "力量";
ITEM_MOD_INTELLECT_SHORT = "智力";
ITEM_MOD_SPIRIT_SHORT = "精神";
ITEM_MOD_STAMINA_SHORT = "耐力";
ITEM_MOD_ATTACK_POWER_SHORT = "攻击强度";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "远程攻击强度";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "爆击(近战)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "爆击(远程)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "命中(近战)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "命中(远程)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "命中(法术)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "爆击(法术)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "伤害加成";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "治疗加成";
ITEM_MOD_MANA_REGENERATION_SHORT = "法力回复";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "防御";
ITEM_MOD_DODGE_RATING_SHORT = "躲闪";
ITEM_MOD_PARRY_RATING_SHORT = "招架";
ITEM_MOD_BLOCK_RATING_SHORT = "格挡";
ITEM_MOD_BLOCK_VALUE_SHORT = "格挡值";
ITEM_MOD_HOLY_SPELL_DAMAGE_DONE_SHORT = "神圣伤害加成";
ITEM_MOD_FIRE_SPELL_DAMAGE_DONE_SHORT = "火焰伤害加成";
ITEM_MOD_NATURE_SPELL_DAMAGE_DONE_SHORT = "自然伤害加成";
ITEM_MOD_FROST_SPELL_DAMAGE_DONE_SHORT = "冰霜伤害加成";
ITEM_MOD_SHADOW_SPELL_DAMAGE_DONE_SHORT = "暗影伤害加成";
ITEM_MOD_ARCANE_SPELL_DAMAGE_DONE_SHORT = "奥术伤害加成";
ITEM_MOD_SPELL_PENETRATION_SHORT = "法术穿透";
ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "护甲穿透";
ITEM_MOD_HEALTH_REGEN_SHORT = "生命值恢复";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒伤害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "装备管理";
PAPERDOLL_NEWEQUIPMENTSET = "新的方案";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "为%s设置备注:";
SET_NOTE = "设置备注";
SILVER_AMOUNT_SYMBOL = "银";
SLASH_CANCELAURA1 = "/cancelaura";
SLASH_CANCELAURA2 = "/cancelaura";
SLASH_CANCELFORM1 = "/cancelform";
SLASH_CANCELFORM2 = "/cancelform";
SLASH_CHANGEACTIONBAR1 = "/changeactionbar";
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_CLEARTARGET1 = "/cleartarget";
SLASH_CLEARTARGET2 = "/cleartarget";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/click";
SLASH_DISMOUNT1 = "/dismount";
SLASH_DISMOUNT2 = "/dismount";
SLASH_EQUIP1 = "/equip";
SLASH_EQUIP2 = "/equip";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_EQUIP_TO_SLOT1 = "/equipslot";
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focus";
SLASH_PET_AGGRESSIVE1 = "/petaggressive";
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
SLASH_PET_ATTACK1 = "/petattack";
SLASH_PET_ATTACK2 = "/petattack";
SLASH_PET_AUTOCASTOFF1 = "/petautocastoff";
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
SLASH_PET_AUTOCASTON1 = "/petautocaston";
SLASH_PET_AUTOCASTON2 = "/petautocaston";
SLASH_PET_AUTOCASTTOGGLE1 = "/petautocasttoggle";
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
SLASH_PET_DEFENSIVE1 = "/petdefensive";
SLASH_PET_DEFENSIVE2 = "/petdefensive";
SLASH_PET_FOLLOW1 = "/petfollow";
SLASH_PET_FOLLOW2 = "/petfollow";
SLASH_PET_PASSIVE1 = "/petpassive";
SLASH_PET_PASSIVE2 = "/petpassive";
SLASH_PET_STAY1 = "/petstay";
SLASH_PET_STAY2 = "/petstay";
SLASH_STARTATTACK1 = "/startattack";
SLASH_STARTATTACK2 = "/startattack";
SLASH_STOPATTACK1 = "/stopattack";
SLASH_STOPATTACK2 = "/stopattack";
SLASH_STOPCASTING1 = "/stopcasting";
SLASH_STOPCASTING2 = "/stopcasting";
SLASH_SWAPACTIONBAR1 = "/swapactionbar";
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
SLASH_TARGET_EXACT1 = "/targetexact";
SLASH_TARGET_EXACT2 = "/targetexact";
SLASH_TARGET_LAST_ENEMY1 = "/targetlastenemy";
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
SLASH_TARGET_LAST_TARGET1 = "/targetlasttarget";
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
SLASH_TARGET_NEAREST_ENEMY1 = "/targetenemy";
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_FRIEND1 = "/targetfriend";
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_PARTY1 = "/targetparty";
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
SLASH_TARGET_NEAREST_RAID1 = "/targetraid";
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/use";
SLASH_USE2 = "/use";
LINK_TRADESKILL_TOOLTIP = "点击这里以创建一个链接到你的专业的快捷方式。";
TEXT_TO_SPEECH = "文本转语音";
TEXT_TO_SPEECH_ADJUST_RATE = "调节讲话速度";
TEXT_TO_SPEECH_ADJUST_VOLUME = "音量";
TEXT_TO_SPEECH_VOICE_OPTION = "语音设置";
TOTAL_MEM_KB_ABBR = "插件内存:%.0f KB";
TOTAL_MEM_MB_ABBR = "插件内存:%.2f MB";
-217
View File
@@ -1,217 +0,0 @@
-- AoE Loot Test
--
-- /aoeloot opens a picker window listing every item in every lootable
-- corpse within click range. Clicking a row hands the (guid, itemID)
-- pair to C_Loot.LootUnitItem and re-scans so the picker refreshes
-- with whatever's left.
--
-- Known visual: each scan opens / closes loot windows in rapid
-- succession, so the Blizzard LootFrame flickers during the scan. This
-- addon intentionally doesn't suppress that — the goal is to exercise
-- the C++ surface, not to ship polished UI.
local MAX_ROWS = 24
local RESCAN_DELAY = 0.3 -- seconds; lets LootUnitItem complete before re-scan
-- ---------- UI scaffolding ----------
-- Frame geometry. Height is recomputed dynamically per scan so the
-- window only takes up as much screen real estate as the result count
-- needs — see `ResizeForRows`.
local FRAME_WIDTH = 320
local HEADER_HEIGHT = 64 -- title + status + insets; minimum frame height
local ROW_HEIGHT = 22
local frame = CreateFrame("Frame", "AoELootTestFrame", UIParent)
frame:SetWidth(FRAME_WIDTH)
frame:SetHeight(HEADER_HEIGHT)
frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
frame:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tile = true, tileSize = 32, edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
})
frame:SetMovable(true)
frame:EnableMouse(true)
frame:RegisterForDrag("LeftButton")
-- Vanilla 1.12 fires script handlers with the frame set as the global
-- `this`, not as a `self` arg to the function. Passing `frame.StartMoving`
-- directly (the modern shortcut) breaks because the C method has no
-- `self` to operate on. Wrap in closures so `this:method()` resolves
-- correctly inside the handler.
frame:SetScript("OnDragStart", function() this:StartMoving() end)
frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
frame:Hide()
local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
title:SetPoint("TOP", frame, "TOP", 0, -16)
title:SetText("AoE Loot")
local status = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
status:SetPoint("TOPLEFT", frame, "TOPLEFT", 18, -36)
status:SetText("")
local closeBtn = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
closeBtn:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -6, -6)
-- ---------- Row pool ----------
local rows = {}
local function CreateRow(parent, idx)
local row = CreateFrame("Button", nil, parent)
row:SetWidth(290)
row:SetHeight(20)
row:SetPoint("TOPLEFT", parent, "TOPLEFT", 15,
-54 - (idx - 1) * ROW_HEIGHT)
local icon = row:CreateTexture(nil, "ARTWORK")
icon:SetWidth(18); icon:SetHeight(18)
icon:SetPoint("LEFT", row, "LEFT", 0, 0)
row.icon = icon
local text = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
text:SetPoint("LEFT", icon, "RIGHT", 6, 0)
text:SetPoint("RIGHT", row, "RIGHT", 0, 0)
text:SetJustifyH("LEFT")
row.text = text
local hl = row:CreateTexture(nil, "HIGHLIGHT")
hl:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight")
hl:SetBlendMode("ADD")
hl:SetAllPoints(row)
-- Tooltip on hover. The engine's native `SetHyperlink` does a
-- literal `SStrCmpI` of the input's first 5 chars against the
-- string `"item:"` at `.rdata` VA `0x00842D60` — anything that
-- starts with `|c...` (full link with color) or `|Hitem` (full
-- link without color) fails the prefix check with "Unknown link
-- type". Only the bare payload form survives.
--
-- We extract the payload from the engine's full link rather than
-- composing `item:ID:0:0:0` ourselves, so per-instance fields
-- (enchant / random-suffix / unique) survive round-trip and
-- "Stringy Wolf Meat of the Bear" tooltips correctly instead of
-- collapsing to the un-suffixed itemID.
row:SetScript("OnEnter", function()
if this.payload then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetHyperlink(this.payload)
GameTooltip:Show()
end
end)
row:SetScript("OnLeave", function() GameTooltip:Hide() end)
row:Hide()
return row
end
for i = 1, MAX_ROWS do
rows[i] = CreateRow(frame, i)
end
-- ---------- Render ----------
-- Tail of a GUID string — last 4 hex chars of the low dword. Lets the
-- user tell apart two rows of "Stringy Wolf Meat x1" coming from
-- different corpses, without occupying a whole column with the full
-- 16-digit GUID.
local function ShortGuidSuffix(guid)
return "[.." .. string.sub(guid, -4) .. "]"
end
-- Extract the `item:ID:enchant:suffix:unique` payload out of the
-- engine's full link string (`|cff...|Hitem:...|h[Name]|h|r`).
-- Vanilla `SetHyperlink` requires the input to start literally with
-- `"item:"` — so we can't pass the full link, but we also can't drop
-- the per-instance fields (random suffix is what distinguishes "of
-- the Bear" from "of the Eagle" on otherwise identical itemIDs).
-- Returns the payload, or nil if extraction fails.
local function PayloadFromLink(link)
if not link then return nil end
return string.match(link, "|H(item:[^|]+)|h")
end
local function ClearRows()
for i = 1, MAX_ROWS do
rows[i]:Hide()
rows[i]:SetScript("OnClick", nil)
rows[i].payload = nil
end
end
local function PopulateRows(results)
local idx = 0
for _, corpse in ipairs(results) do
for _, item in ipairs(corpse.items) do
idx = idx + 1
if idx > MAX_ROWS then break end
local row = rows[idx]
local name, _, _, _, _, _, _, _, icon = GetItemInfo(item.itemID)
row.icon:SetTexture(icon or "Interface\\Icons\\INV_Misc_QuestionMark")
local label = name or ("Item #" .. item.itemID)
if item.count and item.count > 1 then
label = label .. " x" .. item.count
end
label = label .. " " .. ShortGuidSuffix(corpse.guid)
row.text:SetText(label)
row.payload = PayloadFromLink(item.link) -- for OnEnter tooltip
local guid, itemID = corpse.guid, item.itemID
row:SetScript("OnClick", function()
C_Loot.LootUnitItem(guid, itemID)
C_Timer.After(RESCAN_DELAY, function()
if frame:IsShown() then C_Loot.ScanNearbyLoot() end
end)
end)
row:Show()
end
if idx > MAX_ROWS then break end
end
for i = idx + 1, MAX_ROWS do rows[i]:Hide() end
local shown
if idx == 0 then
status:SetText("Nothing lootable in range.")
shown = 0
elseif idx > MAX_ROWS then
status:SetText(MAX_ROWS .. "+ items (showing first " .. MAX_ROWS .. ")")
shown = MAX_ROWS
else
status:SetText(idx .. " item(s) available")
shown = idx
end
frame:SetHeight(HEADER_HEIGHT + shown * ROW_HEIGHT)
end
-- ---------- Scan trigger ----------
local function StartScan()
status:SetText("Scanning...")
ClearRows()
frame:SetHeight(HEADER_HEIGHT) -- collapse while we wait for results
if not C_Loot.ScanNearbyLoot() then
status:SetText("Scan busy or not in world.")
end
end
-- ---------- Event wiring ----------
local evt = CreateFrame("Frame")
evt:RegisterEvent("LOOT_SCAN_COMPLETED")
evt:SetScript("OnEvent", function()
-- Race guard: a delayed scan may complete after the user closed
-- the picker. Just drop the result rather than re-showing.
if not frame:IsShown() then return end
PopulateRows(C_Loot.GetLastScanResults())
end)
-- ---------- Slash command ----------
RegisterNewSlashCommand(function()
if frame:IsShown() then
StartScan() -- already open → just refresh
else
frame:Show()
StartScan()
end
end, "aoeloot", "aoe")
-7
View File
@@ -1,7 +0,0 @@
## Interface: 11200
## Title: AoE Loot Test
## Notes: Test bed for ClassicAPI's C_Loot.ScanNearbyLoot / LootUnitItem. /aoeloot
## Dependencies: !!!ClassicAPI
## Version: DEV
AoELootTest.lua
+373
View File
@@ -0,0 +1,373 @@
-- Broodwatch brood: spawning, hit feedback, damage and death.
local _, BW = ...
local CENTER = BW.CENTER
local ROOM_RADIUS = BW.ROOM_RADIUS
local SPAWN_MIN_DISTANCE = BW.SPAWN_MIN_DISTANCE
local MAX_ALIVE = BW.MAX_ALIVE
local DEATH_SECONDS = BW.DEATH_SECONDS
local ANIM_STAND = BW.ANIM_STAND
local ANIM_DEATH = BW.ANIM_DEATH
local KINDS = BW.KINDS
local ELITE = BW.ELITE
local TUNING = BW.TUNING
local VAMPIRIC_SHARE = BW.VAMPIRIC_SHARE
local pools = BW.pools
local enemies = BW.enemies
local FloorAt = BW.FloorAt
local Dist2 = BW.Dist2
local DropGem = BW.DropGem
local Mastered = BW.Mastered
local SPELLS = BW.SPELLS
------------------------------------------------------------------------------
-- The brood
-- Health grows faster as the run goes on (x3 at 5:00, x9 at 10:00), so late
-- upgrades keep pace instead of running away; bites grow x4 by 10:00.
local function KindStats(kind)
local m = BW.run.time / 300;
local grow = 1 + 2 * m + m * m;
return math.floor(kind.hp * grow + 0.5), kind.dps * grow;
end
local function NewEnemy(kind)
local e = { kind = kind, actor = BW.scene:AcquireActor() };
e.actor:SetModelByCreatureDisplayID(kind.display);
-- A whelp casts the next leap of Chain Lightning: its impact comes here.
e.actor:HookScript("OnSpellVisualImpact", function(_, _, _, castID) BW.OnImpact(nil, nil, nil, castID); end);
return e;
end
local function Spawn(kind)
local hx, hy = BW.hero:GetPosition();
local x, y;
for _ = 1, 10 do
local a = math.random() * 2 * math.pi;
local r = ROOM_RADIUS * math.sqrt(math.random());
x, y = CENTER[1] + r * math.cos(a), CENTER[2] + r * math.sin(a);
if Dist2(x, y, hx, hy) >= SPAWN_MIN_DISTANCE * SPAWN_MIN_DISTANCE then
break;
end
end
local pool = pools[kind.key];
local e = pool and table.remove(pool) or NewEnemy(kind);
e.hp, e.dps = KindStats(kind);
e.maxHp = e.hp;
e.alive = true;
e.dying = nil;
e.fading = nil;
e.dotLeft = nil;
e.slowUntil, e.slowFactor = nil, nil;
e.stunUntil = nil;
e.fearUntil, e.fearTick = nil, nil;
e.frozenUntil = nil;
e.weakUntil, e.weakShare = nil, nil;
e.taunter, e.tauntUntil = nil, nil;
if e.kit then
e.kit, e.kitUntil = nil, nil;
e.actor:SetSpellVisualKit();
end
e.angle = math.random() * 2 * math.pi;
local actor = e.actor;
actor:SetPosition(x, y, FloorAt(x, y, CENTER[3]) + 0.5);
actor:SetAnimation(ANIM_STAND);
actor:Show();
actor:SetMovementEnabled(true);
actor:SetMovementSpeed("run", kind.speed);
tinsert(enemies, e);
BW.run.alive = BW.run.alive + 1;
return e;
end
local function Kill(e)
e.alive = false;
e.dying = DEATH_SECONDS;
BW.run.alive = BW.run.alive - 1;
BW.run.kills = BW.run.kills + 1;
local x, y, z = e.actor:GetPosition();
-- The corpse keeps its movement until it is released: it stops where
-- it is, plays Death, then lies Dead, as a dead unit does.
e.actor:StopMoving();
e.actor:SetAnimation(ANIM_DEATH);
if e.kit then
e.kit, e.kitUntil = nil, nil;
e.actor:SetSpellVisualKit();
end
e.fearUntil = nil;
DropGem(x, y, z, e.kind.xp, e.kind.gemScale);
BW.DropItem(e, x, y, z);
-- A corrupted enemy passes its Corruption on (in the step, after the loop).
if e.dotLeft and e.dotSpread > 0 then
tinsert(BW.run.spreads, e);
end
e.dotLeft = nil;
end
------------------------------------------------------------------------------
-- Hit feedback: a short flash on the model, and a number that rises from the
-- hit point and fades.
local FLASH_SECONDS = 0.12
local NUMBER_SECONDS = 0.8
local NUMBER_RISE = 34 -- pixels over the number's life
local NUMBER_FONT = "Fonts\\ARIALN.TTF"
local BIG_HIT = 40 -- hits this large use the large size
local MAX_NUMBERS = 80
local numbers = {} -- live numbers, oldest first
local numberPool = {}
local function TakeNumber()
local n = table.remove(numberPool);
if n then
return n;
end
if table.getn(numbers) >= MAX_NUMBERS then
-- Too many at once: the oldest gives up its string.
return table.remove(numbers, 1);
end
n = { text = BW.feedback:CreateFontString(nil, "OVERLAY") };
n.text:SetFontObject(NumberFontNormal);
return n;
end
local function ShowNumber(e, amount, color, text)
local x, y, z = e.actor:GetNamePosition();
if not x then
x, y, z = e.actor:GetPosition();
z = z + 1.5;
end
local n = TakeNumber();
n.x, n.y, n.z = x, y, z;
n.age = 0;
n.dx = math.random(-12, 12);
local size = amount >= BIG_HIT and 22 or 16;
if n.size ~= size then
n.size = size;
n.text:SetFont(NUMBER_FONT, size, "OUTLINE");
end
n.text:SetTextColor(color[1], color[2], color[3]);
n.text:SetText(text or tostring(amount));
n.text:SetAlpha(1);
n.text:Show();
tinsert(numbers, n);
end
local function UpdateNumbers(dt)
local count = table.getn(numbers);
if count == 0 then
return;
end
local scale = BW.scene:GetEffectiveScale();
for i = count, 1, -1 do
local n = numbers[i];
n.age = n.age + dt;
local sx, sy = BW.scene:Project3DPointTo2D(n.x, n.y, n.z);
if n.age >= NUMBER_SECONDS or not sx then
n.text:Hide();
tinsert(numberPool, n);
table.remove(numbers, i);
else
local t = n.age / NUMBER_SECONDS;
n.text:ClearAllPoints();
n.text:SetPoint("BOTTOM", BW.scene, "BOTTOMLEFT", sx / scale + n.dx, sy / scale + NUMBER_RISE * t);
if t > 0.5 then
n.text:SetAlpha(2 - 2 * t);
end
end
end
end
local function ClearNumbers()
for i = table.getn(numbers), 1, -1 do
numbers[i].text:Hide();
tinsert(numberPool, numbers[i]);
numbers[i] = nil;
end
end
local function Flash(e)
e.flash = FLASH_SECONDS;
if not e.lit then
e.lit = true;
e.actor:SetHighlight("mouseover", true);
end
end
local function Unflash(e)
if e.lit then
e.lit = false;
e.actor:SetHighlight("mouseover", false);
end
end
local function Damage(e, amount, color)
if not e.alive then
return;
end
-- Masteries: Corruption and Frost Nova's Shatter make their marks take more.
if e.dotLeft and Mastered("corruption") then
amount = amount * SPELLS.corruption.mastery.amplify;
end
if e.frozenUntil and BW.run.time < e.frozenUntil and Mastered("frostnova") then
amount = amount * SPELLS.frostnova.mastery.amplify;
end
local dealt = math.floor(amount * BW.run.damageScale + 0.5);
e.hp = e.hp - dealt;
if BW.run.passives.vampiric then
BW.run.leech = BW.run.leech + dealt * VAMPIRIC_SHARE * BW.run.passives.vampiric;
end
Flash(e);
ShowNumber(e, dealt, color);
if e.hp <= 0 then
Kill(e);
end
end
-- Throws an enemy away from (x, y) at `knock` (out, up). A corpse flies too,
-- in its death pose.
local function Blast(e, x, y, knock)
local share = e.kind == ELITE and TUNING.ELITE_KNOCK_SHARE or 1;
e.actor:KnockBack(x, y, knock[1] * share, knock[2] * share);
end
------------------------------------------------------------------------------
-- Lasting effects: a hold (a root or a stun) keeps an enemy where it is, a
-- fear sends it away from you, a weakness softens its bites. A hold or a fear
-- can show a SpellVisualKit on the enemy while it lasts. Elites shrug off
-- part of a hold or a fear.
local function ShowKit(e, kit, untilTime)
if not kit then
return;
end
if e.kit ~= kit then
e.kit = kit;
e.actor:SetSpellVisualKit(kit);
end
e.kitUntil = math.max(e.kitUntil or 0, untilTime);
end
local function HoldShare(e)
return e.kind == ELITE and TUNING.ELITE_HOLD_SHARE or 1;
end
-- `frozen`: the hold is ice (Frost Nova's Shatter counts it).
local function Hold(e, seconds, kit, frozen)
e.stunUntil = math.max(e.stunUntil or 0, BW.run.time + seconds * HoldShare(e));
if frozen then
e.frozenUntil = e.stunUntil;
end
e.fearUntil = nil;
e.actor:StopMoving();
ShowKit(e, kit, e.stunUntil);
end
local function Fear(e, seconds, kit)
e.fearUntil = math.max(e.fearUntil or 0, BW.run.time + seconds * HoldShare(e));
ShowKit(e, kit, e.fearUntil);
end
local function Weaken(e, share, seconds)
e.weakShare = math.max(e.weakShare or 0, share);
e.weakUntil = math.max(e.weakUntil or 0, BW.run.time + seconds);
end
-- Ends the effects whose time has run out (the step, for each live enemy),
-- and burns the fleeing with Psychic Scream's Terror.
local function UpdateStates(e, dt)
local t = BW.run.time;
if e.fearUntil and Mastered("scream") then
e.fearTick = (e.fearTick or 1) - dt;
if e.fearTick <= 0 then
e.fearTick = e.fearTick + 1;
Damage(e, SPELLS.scream.mastery.damage, SPELLS.scream.color);
if not e.alive then
return;
end
end
end
if e.kitUntil and t >= e.kitUntil then
e.kit, e.kitUntil = nil, nil;
e.actor:SetSpellVisualKit();
end
if e.fearUntil and t >= e.fearUntil then
e.fearUntil, e.fearTick = nil, nil;
end
if e.weakUntil and t >= e.weakUntil then
e.weakUntil, e.weakShare = nil, nil;
end
end
-- The share of an enemy's bite that lands.
local function BiteShare(e)
return e.weakShare and 1 - e.weakShare or 1;
end
local function Release(i)
local e = enemies[i];
Unflash(e);
e.actor:SetMovementEnabled(false);
e.actor:Hide();
local pool = pools[e.kind.key];
if not pool then
pool = {};
pools[e.kind.key] = pool;
end
tinsert(pool, e);
table.remove(enemies, i);
end
local function ClearEnemies()
for i = table.getn(enemies), 1, -1 do
if enemies[i].alive then
BW.run.alive = BW.run.alive - 1;
end
Release(i);
end
end
-- How many should be alive at this second of the run.
local function TargetAlive(t)
return math.min(MAX_ALIVE, 8 + math.floor(t * 0.38));
end
local function PickKind()
local open = 0;
for _, kind in ipairs(KINDS) do
if BW.run.time >= kind.from then
open = open + 1;
end
end
-- Newer kinds are rarer: the first kind open gets the most weight.
local roll = math.random() * (open * (open + 1) / 2);
for i = 1, open do
local w = open - i + 1;
if roll < w then
return KINDS[i];
end
roll = roll - w;
end
return KINDS[1];
end
-- For the files that load after this one.
BW.Blast = Blast
BW.Hold = Hold
BW.Fear = Fear
BW.Weaken = Weaken
BW.UpdateStates = UpdateStates
BW.BiteShare = BiteShare
BW.Spawn = Spawn
BW.Kill = Kill
BW.ShowNumber = ShowNumber
BW.UpdateNumbers = UpdateNumbers
BW.ClearNumbers = ClearNumbers
BW.Flash = Flash
BW.Unflash = Unflash
BW.Damage = Damage
BW.Release = Release
BW.ClearEnemies = ClearEnemies
BW.TargetAlive = TargetAlive
BW.PickKind = PickKind
+614
View File
@@ -0,0 +1,614 @@
-- Broodwatch: hold Onyxia's Lair against an endless brood.
--
-- You run with W A S D (in screen directions). Your spells cast themselves.
-- Whelps pour into the lair and chase you; each kill drops a gem, and gems
-- level you up. At each level you pick one of three upgrades (click, or
-- press 1 2 3). An elite dragonspawn joins every 90 seconds. Survive ten
-- minutes to win.
--
-- /broodwatch starts a run. P pauses. Escape leaves.
--
-- Prototype: one ClassicAPI ModelScene, the lair as its WMO, every unit an
-- actor. The frame time and the Lua time per frame show in the corner.
local _, BW = ...
local Blast = BW.Blast
local TUNING = BW.TUNING
local LAIR_MAP = BW.LAIR_MAP
local CENTER = BW.CENTER
local FAR_CLIP = BW.FAR_CLIP
local CAMERA_YAW = BW.CAMERA_YAW
local CAMERA_PITCH = BW.CAMERA_PITCH
local CAMERA_ZOOM = BW.CAMERA_ZOOM
local RUN_SECONDS = BW.RUN_SECONDS
local ELITE_EVERY = BW.ELITE_EVERY
local RETARGET_SECONDS = BW.RETARGET_SECONDS
local SURROUND_RADIUS = BW.SURROUND_RADIUS
local HERO_HP = BW.HERO_HP
local MAGNET = BW.MAGNET
local GEM_SPEED = BW.GEM_SPEED
local GEM_TAKE = BW.GEM_TAKE
local ELITE = BW.ELITE
local SPELLS = BW.SPELLS
local PASSIVES = BW.PASSIVES
local SOULSTONE_SPELL = BW.SOULSTONE_SPELL
local SOULSTONE_BLAST = BW.SOULSTONE_BLAST
local AT_ONCE = BW.AT_ONCE
local enemies = BW.enemies
local gemPool = BW.gemPool
local gems = BW.gems
local Dist2 = BW.Dist2
local Clock = BW.Clock
local ClearGems = BW.ClearGems
local Spawn = BW.Spawn
local Kill = BW.Kill
local ShowNumber = BW.ShowNumber
local UpdateNumbers = BW.UpdateNumbers
local ClearNumbers = BW.ClearNumbers
local Unflash = BW.Unflash
local Damage = BW.Damage
local Release = BW.Release
local ClearEnemies = BW.ClearEnemies
local TargetAlive = BW.TargetAlive
local PickKind = BW.PickKind
local Stat = BW.Stat
local Nearest = BW.Nearest
local UpdateTotems = BW.UpdateTotems
local ClearTotems = BW.ClearTotems
local Unslow = BW.Unslow
local UpdateAreas = BW.UpdateAreas
local UpdateLandings = BW.UpdateLandings
local ClearLandings = BW.ClearLandings
local ClearAreas = BW.ClearAreas
local Fire = BW.Fire
local CastNext = BW.CastNext
local Corrupt = BW.Corrupt
local UpdateBuild = BW.UpdateBuild
local SetPaused = BW.SetPaused
local MaxHp = BW.MaxHp
local UpdateHealth = BW.UpdateHealth
local XpNeeded = BW.XpNeeded
local GainXp = BW.GainXp
local Taunted = BW.Taunted
local UpdateItems = BW.UpdateItems
local ClearItems = BW.ClearItems
local UpdateStates = BW.UpdateStates
local BiteShare = BW.BiteShare
local UpdateWards = BW.UpdateWards
local ClearWards = BW.ClearWards
local UpdatePet = BW.UpdatePet
local ClearPet = BW.ClearPet
local EndShield = BW.EndShield
local Mastered = BW.Mastered
------------------------------------------------------------------------------
-- Movement: W A S D in screen directions. The hero faces the way it runs.
local function Held(key)
return BW.held[key] and 1 or 0;
end
local function Steer()
if not BW.run or BW.paused or BW.run.over then
return;
end
local forward = Held("W") - Held("S");
local left = Held("A") - Held("D");
if forward == 0 and left == 0 then
if BW.moving then
BW.hero:StopMoving();
BW.moving = false;
end
return;
end
local yaw = BW.camera:GetYaw() + math.atan2(left, forward);
if not BW.moving or math.abs(yaw - BW.run.heroYaw) > 0.001 then
BW.run.heroYaw = yaw;
BW.hero:SetYaw(yaw);
end
if not BW.moving then
BW.hero:StartMovingForward();
BW.moving = true;
end
end
------------------------------------------------------------------------------
-- The run
local function EndRun(won)
BW.run.over = true;
SetPaused(true);
BW.cards:Hide();
ClearNumbers();
BW.run.hurtGlow = 0;
BW.hurtTexture:SetAlpha(0);
if won then
BW.bannerText:SetText("The lair holds!");
else
BW.bannerText:SetText("You fell at " .. Clock(BW.run.time));
end
BW.hintText:SetText(string.format("Level %d, %d kills. R: try again. Escape: leave.", BW.run.level, BW.run.kills));
end
BW.heroNumber = {} -- ShowNumber's stand-in record for the hero
local HEAL_COLOR = { 0.3, 1, 0.3 }
local function StartRun()
if BW.run then
ClearEnemies();
ClearGems();
ClearNumbers();
ClearTotems();
ClearAreas();
ClearLandings();
ClearItems();
ClearWards();
ClearPet();
end
BW.hero:SetSpellVisualKit();
BW.hurtTexture:SetAlpha(0);
BW.run = {
time = 0, kills = 0, alive = 0, level = 1, xp = 0, pendingLevels = 0,
hp = HERO_HP, damageScale = 1, heroYaw = 0,
spells = { frostbolt = { level = 1, cooldown = 0, pending = 0 } },
passives = {},
castQueue = {}, castGap = 0, inFlight = {},
spawnGap = 0, nextElite = ELITE_EVERY, retargetCarry = 0, retargetCursor = 1,
shownSecond = -1, perfFrames = 0, perfLua = 0, perfClock = 0, hurtGlow = 0,
spreads = {}, pendingHeal = 0, leech = 0, landings = {},
};
SetPaused(false);
BW.held = {};
BW.moving = false;
BW.hero:SetMovementEnabled(false);
BW.hero:SetPosition(CENTER[1], CENTER[2], CENTER[3] + 0.5);
BW.hero:SetYaw(CAMERA_YAW);
BW.hero:Show();
BW.hero:SetMovementEnabled(true);
BW.run.heroRun = BW.hero:GetMovementSpeed("run");
BW.cards:Hide();
BW.bannerText:SetText("");
BW.hintText:SetText("");
BW.xpBar:SetMinMaxValues(0, XpNeeded(1), AT_ONCE);
BW.xpBar:SetValue(0, AT_ONCE);
UpdateHealth(AT_ONCE);
UpdateBuild();
BW.camera:SetZoomDistance(CAMERA_ZOOM);
BW.camera:SnapAllInterpolatedValues();
end
local function IsCorrupted(e)
return e.dotLeft ~= nil;
end
-- Sends a slice of the brood after the hero, so each one gets a new MoveTo
-- every RETARGET_SECONDS, at its own point of a ring around the hero.
local function Chase(dt, hx, hy, hz)
local n = table.getn(enemies);
if n == 0 then
return;
end
BW.run.retargetCarry = BW.run.retargetCarry + n * dt / RETARGET_SECONDS;
while BW.run.retargetCarry >= 1 do
BW.run.retargetCarry = BW.run.retargetCarry - 1;
if BW.run.retargetCursor > n then
BW.run.retargetCursor = 1;
end
local e = enemies[BW.run.retargetCursor];
if e.alive and not (e.stunUntil and BW.run.time < e.stunUntil) and not e.actor:IsFalling() then
if e.fearUntil then
local x, y = e.actor:GetPosition();
local dx, dy = x - hx, y - hy;
local d = math.sqrt(dx * dx + dy * dy);
if d < 0.1 then
dx, dy, d = math.cos(e.angle), math.sin(e.angle), 1;
end
local step = TUNING.FEAR_STEP / d;
e.actor:MoveTo(x + dx * step, y + dy * step, hz);
elseif Taunted(e) then
local cx, cy, cz = e.taunter.actor:GetPosition();
e.actor:MoveTo(cx + SURROUND_RADIUS * math.cos(e.angle), cy + SURROUND_RADIUS * math.sin(e.angle), cz);
else
e.actor:MoveTo(hx + SURROUND_RADIUS * math.cos(e.angle), hy + SURROUND_RADIUS * math.sin(e.angle), hz);
end
end
BW.run.retargetCursor = BW.run.retargetCursor + 1;
end
end
local function Step(dt)
BW.run.time = BW.run.time + dt;
local hx, hy, hz = BW.hero:GetPosition();
-- Spawns.
BW.run.spawnGap = BW.run.spawnGap - dt;
if BW.run.spawnGap <= 0 then
BW.run.spawnGap = 0.1;
local want = TargetAlive(BW.run.time) - BW.run.alive;
for _ = 1, math.min(want, 5) do
Spawn(PickKind());
end
end
if BW.run.time >= BW.run.nextElite then
BW.run.nextElite = BW.run.nextElite + ELITE_EVERY;
for _ = 1, 1 + math.floor(BW.run.time / 180) do
Spawn(ELITE);
end
end
Chase(dt, hx, hy, hz);
-- Contact damage, and the corpses. Thorns burns the biters once a second.
local hurt = 0;
local thorns = false;
if BW.run.thornsUntil then
if BW.run.time >= BW.run.thornsUntil then
BW.run.thornsUntil = nil;
else
BW.run.thornsTick = BW.run.thornsTick - dt;
if BW.run.thornsTick <= 0 then
BW.run.thornsTick = BW.run.thornsTick + 1;
thorns = true;
end
end
end
for i = table.getn(enemies), 1, -1 do
local e = enemies[i];
if e.lit then
e.flash = e.flash - dt;
if e.flash <= 0 then
Unflash(e);
end
end
if e.alive and e.dotLeft then
e.dotTick = e.dotTick - dt;
if e.dotTick <= 0 then
e.dotTick = e.dotTick + 1;
e.dotLeft = e.dotLeft - 1;
-- The last tick can kill too, and then it still spreads (Kill
-- reads dotLeft), so it ends after the damage.
Damage(e, e.dotDamage, SPELLS.corruption.color);
if e.dotLeft and e.dotLeft <= 0 then
e.dotLeft = nil;
end
end
end
if e.alive and e.slowUntil and BW.run.time >= e.slowUntil then
Unslow(e);
end
if e.alive then
UpdateStates(e, dt);
end
if e.alive then
local x, y = e.actor:GetPosition();
local reach = e.kind.reach;
if not Taunted(e) and not e.fearUntil and Dist2(x, y, hx, hy) <= reach * reach then
hurt = hurt + e.dps * BiteShare(e) * (1 - PASSIVES.devotion.per * (BW.run.passives.devotion or 0) / 100);
if thorns then
Damage(e, BW.run.thornsDamage, SPELLS.thorns.color);
if Mastered("thorns") then
BW.run.pendingHeal = BW.run.pendingHeal + SPELLS.thorns.mastery.heal;
end
end
end
else
-- The corpse lies, then fades out as the world's do, and goes
-- once the fade has hidden it.
e.dying = e.dying - dt;
if e.dying <= 0 then
if not e.fading then
e.fading = true;
e.actor:FadeOut();
elseif not e.actor:IsShown() then
Release(i);
end
end
end
end
-- Corruption passes from each enemy that died with it to its neighbours.
for i = table.getn(BW.run.spreads), 1, -1 do
local from = BW.run.spreads[i];
BW.run.spreads[i] = nil;
local fx, fy = from.actor:GetPosition();
for _ = 1, from.dotSpread do
local e = Nearest(fx, fy, SPELLS.corruption.hop, IsCorrupted);
if not e then
break;
end
Corrupt(e, from.dotDamage, from.dotSeconds, from.dotSpread);
end
end
UpdateTotems(dt);
UpdateAreas(dt);
UpdateLandings(dt);
UpdateItems(dt, hx, hy, hz);
UpdateWards(dt);
UpdatePet(dt, hx, hy, hz);
if BW.run.shoutUntil and BW.run.time >= BW.run.shoutUntil then
BW.run.shoutUntil = nil;
BW.ApplyHeroSpeed();
end
-- Power Word: Shield soaks the bites first.
local taken = hurt * dt;
if BW.run.shield then
local soak = math.min(BW.run.shield, taken);
BW.run.shield = BW.run.shield - soak;
taken = taken - soak;
if BW.run.shield <= 0 then
EndShield(true);
elseif BW.run.time >= BW.run.shieldUntil then
EndShield();
elseif soak > 0 then
UpdateHealth();
end
end
-- The screen's edge glows red while the brood bites, and fades after.
local glow = BW.run.hurtGlow;
if taken > 0 then
glow = math.min(0.8, glow + 4 * dt);
elseif glow > 0 then
glow = math.max(0, glow - 1.5 * dt);
end
if glow ~= BW.run.hurtGlow then
BW.run.hurtGlow = glow;
BW.hurtTexture:SetAlpha(glow);
end
if BW.run.leech > 0 then
-- Vampiric Embrace heals at most its rate; the rest of a burst is
-- kept for at most a second.
local rate = PASSIVES.vampiric.per * (BW.run.passives.vampiric or 0);
BW.run.leech = math.min(BW.run.leech, rate);
local heal = math.min(BW.run.leech, rate * dt);
BW.run.leech = BW.run.leech - heal;
if heal > 0 and BW.run.hp < MaxHp() then
BW.run.hp = math.min(MaxHp(), BW.run.hp + heal);
UpdateHealth();
end
end
if BW.run.pendingHeal > 0 then
local heal = math.floor(BW.run.pendingHeal + 0.5);
BW.run.pendingHeal = 0;
if heal > 0 and BW.run.hp < MaxHp() then
BW.run.hp = math.min(MaxHp(), BW.run.hp + heal);
UpdateHealth();
BW.heroNumber.actor = BW.hero;
ShowNumber(BW.heroNumber, heal, HEAL_COLOR, "+" .. heal);
end
end
local regen = PASSIVES.renewal.per * (BW.run.passives.renewal or 0);
if taken > 0 or (regen > 0 and BW.run.hp < MaxHp()) then
BW.run.hp = math.min(MaxHp(), BW.run.hp + regen * dt - taken);
UpdateHealth();
if BW.run.hp <= 0 and BW.run.passives.soulstone and not BW.run.soulstoneUsed then
-- Rise again at half health, and throw back the brood close by.
BW.run.soulstoneUsed = true;
BW.run.hp = MaxHp() * 0.5;
UpdateHealth();
BW.hero:PlaySpellVisual(SOULSTONE_SPELL, BW.hero, 0);
local r2 = SOULSTONE_BLAST * SOULSTONE_BLAST;
for _, e in ipairs(enemies) do
if e.alive then
local x, y = e.actor:GetPosition();
if Dist2(x, y, hx, hy) <= r2 then
if e.kind ~= ELITE then
Kill(e);
end
Blast(e, hx, hy, TUNING.SOULSTONE_KNOCK);
end
end
end
UpdateBuild();
end
if BW.run.hp <= 0 then
EndRun(false);
return;
end
end
-- Gems.
local magnet = MAGNET * (1 + 0.4 * (BW.run.passives.reach or 0));
local m2 = magnet * magnet;
local take2 = GEM_TAKE * GEM_TAKE;
local gained = 0;
for i = table.getn(gems), 1, -1 do
local gem = gems[i];
local d2 = Dist2(gem.x, gem.y, hx, hy);
if not gem.pulled and d2 <= m2 then
gem.pulled = true;
end
if gem.pulled then
if d2 <= take2 then
gained = gained + gem.xp;
gem.actor:Hide();
tinsert(gemPool, gem);
table.remove(gems, i);
else
-- It flies at you, faster than you run, and rises to your chest.
local d = math.sqrt(d2);
local step = math.min(1, GEM_SPEED * dt / d);
gem.x = gem.x + (hx - gem.x) * step;
gem.y = gem.y + (hy - gem.y) * step;
gem.z = gem.z + (hz + 1 - gem.z) * step;
gem.actor:SetPosition(gem.x, gem.y, gem.z);
end
end
end
if gained > 0 then
GainXp(gained * (1 + PASSIVES.wisdom.per * (BW.run.passives.wisdom or 0) / 100));
end
-- Spells.
local haste = 1 - 0.08 * (BW.run.passives.haste or 0);
for key, state in pairs(BW.run.spells) do
state.cooldown = state.cooldown - dt;
if state.cooldown <= 0 and state.pending == 0 and Fire(key, state) then
state.cooldown = Stat(SPELLS[key], "cooldown", state.level) * haste;
state.cdFull = state.cooldown;
if state.slot then
state.slot.cd:SetCooldown(GetTime(), state.cooldown);
end
end
end
BW.run.castGap = BW.run.castGap - dt;
if BW.run.castGap <= 0 then
CastNext();
end
if BW.run.time >= RUN_SECONDS then
EndRun(true);
end
end
local function UpdateHud(dt, luaMs)
local second = math.floor(BW.run.time);
if second ~= BW.run.shownSecond then
BW.run.shownSecond = second;
BW.infoText:SetText(string.format("Level %d %s Kills %d", BW.run.level, Clock(BW.run.time), BW.run.kills));
end
BW.run.perfFrames = BW.run.perfFrames + 1;
BW.run.perfLua = BW.run.perfLua + luaMs;
BW.run.perfClock = BW.run.perfClock + dt;
if BW.run.perfClock >= 1 then
BW.perfText:SetText(string.format("%.0f fps Lua %.2f ms/frame %d alive %d gems",
BW.run.perfFrames / BW.run.perfClock, BW.run.perfLua / BW.run.perfFrames, BW.run.alive, table.getn(gems)));
BW.run.perfFrames, BW.run.perfLua, BW.run.perfClock = 0, 0, 0;
end
end
local function OnUpdate()
if not BW.run or BW.paused or BW.run.over then
return;
end
local dt = arg1;
local t0 = debugprofilestop();
Steer();
Step(dt);
UpdateNumbers(dt);
local luaMs = debugprofilestop() - t0;
if not BW.run.over then
UpdateHud(dt, luaMs);
end
end
------------------------------------------------------------------------------
-- Keys
local MOVE_KEYS = { W = true, A = true, S = true, D = true }
local function OnKeyDown()
local key = arg1;
if key == "ESCAPE" then
BW.frame:Hide();
return;
end
if not BW.run then
return;
end
if BW.run.over then
if key == "R" then
StartRun();
end
return;
end
if BW.cards:IsShown() then
if key == "1" or key == "2" or key == "3" then
Broodwatch_PickCard(tonumber(key));
end
return;
end
if key == "P" then
SetPaused(not BW.paused);
BW.bannerText:SetText(BW.paused and "Paused" or "");
BW.hintText:SetText(BW.paused and "P: resume." or "");
return;
end
if MOVE_KEYS[key] then
BW.held[key] = true;
Steer();
end
end
local function OnKeyUp()
BW.held[arg1] = nil;
Steer();
end
------------------------------------------------------------------------------
-- Setup
local function Build()
debugprofilestart();
BW.scene:TransitionToModelSceneID(4, CAMERA_TRANSITION_TYPE_IMMEDIATE, CAMERA_MODIFICATION_TYPE_DISCARD, true);
BW.scene:SetWorldModelByMapID(LAIR_MAP);
BW.scene:SetCameraFarClip(FAR_CLIP);
local _, cameraIDs = C_ModelInfo.GetModelSceneInfoByID(4);
BW.camera = BW.scene:AddCamera(CameraRegistry:CreateCameraByType("FollowCamera"));
BW.camera:ApplyFromModelSceneCameraInfo(C_ModelInfo.GetModelSceneCameraInfoByID(cameraIDs[1]),
CAMERA_TRANSITION_TYPE_IMMEDIATE, CAMERA_MODIFICATION_TYPE_DISCARD);
BW.camera:SetTargetSpline(nil);
BW.camera:SetOrientationSpline(nil);
BW.camera:SetMinZoomDistance(12);
BW.camera:SetMaxZoomDistance(60);
BW.hero = BW.scene:AcquireActor();
BW.hero:SetModelByUnit("player");
BW.hero:HookScript("OnSpellVisualImpact", BW.OnImpact);
BW.scene:SetSoundListenerActor(BW.hero);
BW.camera:SetFollowActor(BW.hero);
BW.camera:SetYaw(CAMERA_YAW);
BW.camera:SetPitch(CAMERA_PITCH);
BW.camera:SetZoomDistance(CAMERA_ZOOM);
BW.camera:SnapAllInterpolatedValues();
BW.scene:SetActiveCamera(BW.camera);
end
function Broodwatch_OnLoad(self)
BW.frame = self;
BW.scene = getglobal(self:GetName() .. "Scene");
BW.feedback = getglobal(self:GetName() .. "Feedback");
BW.hurtTexture = getglobal(self:GetName() .. "FeedbackHurt");
BW.hurtTexture:SetAlpha(0);
-- Siblings at one frame level draw in no set order, and the scene's
-- paint covered the feedback (item icons, numbers). Chained parents
-- order them: scene, feedback over it, the HUD over both. SetParent
-- moves a frame's children with it; SetFrameLevel would not.
local hud = self:GetName() .. "Hud";
BW.feedback:SetParent(BW.scene);
getglobal(hud):SetParent(BW.feedback);
BW.infoText = getglobal(hud .. "Info");
BW.perfText = getglobal(hud .. "Perf");
BW.bannerText = getglobal(hud .. "Banner");
BW.hintText = getglobal(hud .. "Hint");
BW.xpBar = getglobal(hud .. "XP");
BW.healthBar = getglobal(hud .. "Health");
BW.healthText = getglobal(hud .. "HealthText");
BW.cards = getglobal(hud .. "Cards");
BW.build = getglobal(hud .. "Build");
self:SetScript("OnUpdate", OnUpdate);
self:SetScript("OnKeyDown", OnKeyDown);
self:SetScript("OnKeyUp", OnKeyUp);
self:SetScript("OnShow", function()
BW.frame:EnableKeyboard(true);
end);
self:SetScript("OnHide", function()
BW.frame:EnableKeyboard(false);
if BW.run and not BW.run.over then
EndRun(false);
end
end);
end
SLASH_BROODWATCH1 = "/broodwatch";
SlashCmdList["BROODWATCH"] = function()
BW.frame:Show();
if not BW.hero then
Build();
end
StartRun();
end
+12
View File
@@ -0,0 +1,12 @@
## Interface: 11200
## Title: Broodwatch
## Notes: Hold Onyxia's Lair against an endless brood. /broodwatch starts a run.
## Dependencies: !!!ClassicAPI
Data.lua
Core.lua
Brood.lua
Spells.lua
Hud.lua
Items.lua
Broodwatch.lua
Broodwatch.xml
+227
View File
@@ -0,0 +1,227 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.blizzard.com/wow/ui/
..\FrameXML\UI.xsd">
<!-- A level-up card: icon, name, what kind of pick it is, what it changes,
five rank pips and the key that picks it. Broodwatch.lua fills it. -->
<Button name="BroodwatchCardTemplate" virtual="true">
<Size x="210" y="280"/>
<Backdrop bgFile="Interface\Tooltips\UI-Tooltip-Background" edgeFile="Interface\Tooltips\UI-Tooltip-Border" tile="true">
<EdgeSize>
<AbsValue val="16"/>
</EdgeSize>
<TileSize>
<AbsValue val="16"/>
</TileSize>
<BackgroundInsets>
<AbsInset left="4" right="4" top="4" bottom="4"/>
</BackgroundInsets>
</Backdrop>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentIcon">
<Size x="64" y="64"/>
<Anchors>
<Anchor point="TOP" x="0" y="-20"/>
</Anchors>
</Texture>
<Texture name="$parentPip3">
<Size x="18" y="6"/>
<Anchors>
<Anchor point="BOTTOM" x="0" y="40"/>
</Anchors>
<Color r="1" g="1" b="1" a="1"/>
</Texture>
<Texture name="$parentPip2">
<Size x="18" y="6"/>
<Anchors>
<Anchor point="RIGHT" relativeTo="$parentPip3" relativePoint="LEFT" x="-4" y="0"/>
</Anchors>
<Color r="1" g="1" b="1" a="1"/>
</Texture>
<Texture name="$parentPip1">
<Size x="18" y="6"/>
<Anchors>
<Anchor point="RIGHT" relativeTo="$parentPip2" relativePoint="LEFT" x="-4" y="0"/>
</Anchors>
<Color r="1" g="1" b="1" a="1"/>
</Texture>
<Texture name="$parentPip4">
<Size x="18" y="6"/>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentPip3" relativePoint="RIGHT" x="4" y="0"/>
</Anchors>
<Color r="1" g="1" b="1" a="1"/>
</Texture>
<Texture name="$parentPip5">
<Size x="18" y="6"/>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentPip4" relativePoint="RIGHT" x="4" y="0"/>
</Anchors>
<Color r="1" g="1" b="1" a="1"/>
</Texture>
</Layer>
<Layer level="OVERLAY">
<FontString name="$parentName" inherits="GameFontNormalLarge">
<Anchors>
<Anchor point="TOP" relativeTo="$parentIcon" relativePoint="BOTTOM" x="0" y="-10"/>
</Anchors>
</FontString>
<FontString name="$parentTag" inherits="GameFontHighlightSmall">
<Anchors>
<Anchor point="TOP" relativeTo="$parentName" relativePoint="BOTTOM" x="0" y="-4"/>
</Anchors>
</FontString>
<FontString name="$parentDesc" inherits="GameFontHighlight" justifyH="CENTER" justifyV="TOP">
<Size x="184" y="96"/>
<Anchors>
<Anchor point="TOP" relativeTo="$parentTag" relativePoint="BOTTOM" x="0" y="-14"/>
</Anchors>
</FontString>
<FontString name="$parentKey" inherits="GameFontDisableSmall">
<Anchors>
<Anchor point="BOTTOM" x="0" y="16"/>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnClick>
Broodwatch_PickCard(this:GetID());
</OnClick>
<OnEnter>
Broodwatch_CardHover(this, true);
</OnEnter>
<OnLeave>
Broodwatch_CardHover(this, false);
</OnLeave>
</Scripts>
</Button>
<Frame name="BroodwatchFrame" parent="UIParent" toplevel="true" hidden="true" frameStrata="DIALOG">
<Anchors>
<Anchor point="TOPLEFT" x="40" y="-40"/>
<Anchor point="BOTTOMRIGHT" x="-40" y="40"/>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture setAllPoints="true">
<Color r="0" g="0" b="0" a="1"/>
</Texture>
</Layer>
</Layers>
<Frames>
<ModelScene name="$parentScene" inherits="ModelSceneMixinTemplate" setAllPoints="true"/>
<Frame name="$parentFeedback" setAllPoints="true">
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentHurt" file="Interface\FullScreenTextures\LowHealth" alphaMode="ADD" setAllPoints="true"/>
</Layer>
</Layers>
</Frame>
<Frame name="$parentHud" setAllPoints="true">
<Layers>
<Layer level="OVERLAY">
<FontString name="$parentInfo" inherits="GameFontHighlightLarge" justifyH="LEFT">
<Anchors>
<Anchor point="TOPLEFT" x="14" y="-30"/>
</Anchors>
</FontString>
<FontString name="$parentPerf" inherits="GameFontHighlightSmall" justifyH="RIGHT">
<Anchors>
<Anchor point="BOTTOMRIGHT" x="-10" y="10"/>
</Anchors>
</FontString>
<FontString name="$parentBanner" inherits="GameFontNormalHuge">
<Anchors>
<Anchor point="CENTER" x="0" y="140"/>
</Anchors>
</FontString>
<FontString name="$parentHint" inherits="GameFontHighlight">
<Anchors>
<Anchor point="TOP" relativeTo="$parentBanner" relativePoint="BOTTOM" x="0" y="-10"/>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<StatusBar name="$parentXP">
<Size x="10" y="12"/>
<Anchors>
<Anchor point="TOPLEFT" x="8" y="-8"/>
<Anchor point="TOPRIGHT" x="-8" y="-8"/>
</Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.58" g="0.0" b="0.55"/>
</StatusBar>
<StatusBar name="$parentHealth">
<Size x="320" y="16"/>
<Anchors>
<Anchor point="BOTTOM" x="0" y="24"/>
</Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.1" g="0.85" b="0.1"/>
<Layers>
<Layer level="OVERLAY">
<FontString name="$parentText" inherits="GameFontHighlightSmall">
<Anchors>
<Anchor point="CENTER"/>
</Anchors>
</FontString>
</Layer>
</Layers>
</StatusBar>
<!-- What you have: one slot per spell and passive, filled by Lua. -->
<Frame name="$parentBuild">
<Size x="10" y="34"/>
<Anchors>
<Anchor point="BOTTOMLEFT" x="12" y="12"/>
</Anchors>
</Frame>
<!-- The level-up screen: the scene dims behind three cards. -->
<Frame name="$parentCards" hidden="true" setAllPoints="true">
<Layers>
<Layer level="BACKGROUND">
<Texture setAllPoints="true">
<Color r="0" g="0" b="0" a="0.55"/>
</Texture>
</Layer>
<Layer level="OVERLAY">
<FontString name="$parentTitle" inherits="GameFontNormalHuge">
<Anchors>
<Anchor point="BOTTOM" relativePoint="CENTER" x="0" y="160"/>
</Anchors>
</FontString>
<FontString name="$parentHint" inherits="GameFontHighlight" text="Choose an upgrade: click a card, or press 1, 2 or 3.">
<Anchors>
<Anchor point="TOP" relativeTo="$parentTitle" relativePoint="BOTTOM" x="0" y="-6"/>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Button name="$parentCard2" inherits="BroodwatchCardTemplate" id="2">
<Anchors>
<Anchor point="TOP" relativeTo="$parentHint" relativePoint="BOTTOM" x="0" y="-24"/>
</Anchors>
</Button>
<Button name="$parentCard1" inherits="BroodwatchCardTemplate" id="1">
<Anchors>
<Anchor point="RIGHT" relativeTo="$parentCard2" relativePoint="LEFT" x="-18" y="0"/>
</Anchors>
</Button>
<Button name="$parentCard3" inherits="BroodwatchCardTemplate" id="3">
<Anchors>
<Anchor point="LEFT" relativeTo="$parentCard2" relativePoint="RIGHT" x="18" y="0"/>
</Anchors>
</Button>
</Frames>
</Frame>
</Frames>
</Frame>
</Frames>
<Scripts>
<OnLoad>
Broodwatch_OnLoad(this);
</OnLoad>
</Scripts>
</Frame>
</Ui>
+105
View File
@@ -0,0 +1,105 @@
-- Broodwatch core: the shared state, small helpers and the gems.
--
-- The files share the addon table BW (`local _, BW = ...`). A file keeps
-- its own helpers local. What another file calls, and state that changes,
-- lives on BW; constants are copied into a local at the top of each file
-- that reads them. The TOC loads Data first and Broodwatch.lua last.
local _, BW = ...
local GEM_PATH = BW.GEM_PATH
local MAX_GEMS = BW.MAX_GEMS
local MAX_LEVEL = BW.MAX_LEVEL
-- Whether the hero has the spell at rank V (its mastery).
local function Mastered(key)
local state = BW.run and BW.run.spells[key];
return state ~= nil and state.level >= MAX_LEVEL;
end
------------------------------------------------------------------------------
-- State
-- BW.OnImpact (the impact of any cast in flight) is defined with the spells,
-- BW.DropItem (a kill's chance of an item) with the items.
BW.held = {}
BW.moving = false
local pools = {} -- kind key -> list of free enemy records
local enemies = {} -- live and dying enemy records
local gemPool = {}
local gems = {}
-- BW.run is the current run, or nil.
BW.paused = false
local offers = {} -- the three level-up choices
------------------------------------------------------------------------------
-- Helpers
local function FloorAt(x, y, z)
local _, _, hz = BW.scene:TraceSegment(x, y, z + 10, x, y, z - 50);
return hz or z;
end
local function Dist2(ax, ay, bx, by)
local dx, dy = ax - bx, ay - by;
return dx * dx + dy * dy;
end
local function Clock(seconds)
local s = math.floor(seconds);
return string.format("%d:%02d", math.floor(s / 60), math.mod(s, 60));
end
local function Shuffle(list)
for i = table.getn(list), 2, -1 do
local j = math.random(i);
list[i], list[j] = list[j], list[i];
end
end
------------------------------------------------------------------------------
-- Gems
local function DropGem(x, y, z, xp, scale)
if table.getn(gems) >= MAX_GEMS then
-- Too many on the floor: the experience goes straight to the oldest gem.
gems[1].xp = gems[1].xp + xp;
return;
end
local gem = table.remove(gemPool);
if not gem then
gem = { actor = BW.scene:AcquireActor() };
gem.actor:SetModelByPath(GEM_PATH);
end
gem.xp = xp;
gem.x, gem.y, gem.z = x, y, z;
gem.pulled = false;
gem.actor:SetScale(scale or 0.3);
gem.actor:SetPosition(x, y, z);
gem.actor:Show();
tinsert(gems, gem);
end
local function ClearGems()
for i = table.getn(gems), 1, -1 do
gems[i].actor:Hide();
tinsert(gemPool, gems[i]);
gems[i] = nil;
end
end
-- For the files that load after this one.
BW.Mastered = Mastered
BW.pools = pools
BW.enemies = enemies
BW.gemPool = gemPool
BW.gems = gems
BW.offers = offers
BW.FloorAt = FloorAt
BW.Dist2 = Dist2
BW.Clock = Clock
BW.Shuffle = Shuffle
BW.DropGem = DropGem
BW.ClearGems = ClearGems
+346
View File
@@ -0,0 +1,346 @@
-- Broodwatch tuning and data: the game's constants, spells, passives and items.
local _, BW = ...
------------------------------------------------------------------------------
-- Tuning
local LAIR_MAP = 249
local CENTER = { 0.7, -211.97, -86.08 } -- Onyxia's spawn point, the room's floor
local ROOM_RADIUS = 22 -- yards around CENTER where the brood spawns
local SPAWN_MIN_DISTANCE = 13 -- yards from the hero
local FAR_CLIP = 500
local CAMERA_YAW = 3.09
local CAMERA_PITCH = 1.0
local CAMERA_ZOOM = 50 -- each run starts zoomed out; the wheel zooms in
local RUN_SECONDS = 600
local ELITE_EVERY = 90
local MAX_ALIVE = 200
local RETARGET_SECONDS = 0.3 -- each chaser gets a new MoveTo this often
local SURROUND_RADIUS = 1.0 -- chasers aim at a ring around the hero, not its centre
local CAST_SPACING = 0.3 -- seconds between two of the hero's casts
local DEATH_SECONDS = 4.5 -- the death animation (1.5 s), then the corpse lies; then it fades out
local ANIM_STAND = 0
local ANIM_DEATH = 1
local HERO_HP = 100
local MAGNET = 5 -- gems this close (yards) fly to you
local GEM_SPEED = 20 -- yards a second, once pulled
local GEM_TAKE = 0.8 -- a pulled gem this close is taken
local GEM_PATH = "World\\Generic\\ActiveDoodads\\Rocks\\GemRock01.m2"
local MAX_GEMS = 150
-- The brood. `from` is the second of the run when the kind starts to spawn.
local KINDS = {
{ key = "black", display = 497, hp = 20, dps = 5, xp = 1, reach = 1.4, speed = 3.0, from = 0 },
{ key = "red", display = 956, hp = 30, dps = 6, xp = 2, reach = 1.4, speed = 4.4, from = 120 },
{ key = "green", display = 694, hp = 70, dps = 9, xp = 3, reach = 1.4, speed = 2.4, from = 240 },
}
-- `speed` is the run speed in yards a second (the hero runs at 7): black
-- whelps plod, red ones dart, green ones are slow and tough, and the elite
-- is faster than any whelp but still slower than you.
local ELITE = { key = "elite", display = 2534, hp = 220, dps = 9, xp = 20, reach = 2.2, speed = 4.8, gemScale = 0.6 }
-- Spells, by level 1 to 5. A missile spell hits `count` enemies in `range`;
-- a nova spell hits every enemy within `radius` of the hero. At rank V a
-- spell masters: `mastery` is what it does then, and its numbers.
local SPELLS = {
frostbolt = {
spell = 116, blurb = "Bolts the nearest enemy.", color = { 0.55, 0.8, 1 }, kind = "missile", pick = "nearest", range = 24,
damage = { 14, 18, 18, 24, 26 }, count = { 1, 1, 2, 2, 3 }, cooldown = { 1.2, 1.05, 1.05, 0.9, 0.8 },
mastery = { text = "Frostbite: each bolt freezes its target for 1 second.", freeze = 1 },
},
shadowbolt = {
spell = 686, blurb = "A heavy bolt at the toughest enemy in range.", color = { 0.8, 0.45, 1 }, kind = "missile", pick = "strongest", range = 26,
damage = { 45, 60, 75, 75, 100 }, count = { 1, 1, 1, 2, 2 }, cooldown = { 2.6, 2.4, 2.2, 2.2, 2.0 },
mastery = { text = "Shadowburn: a bolt that kills bursts, hitting enemies within 4 yards for half its damage.", radius = 4, share = 0.5 },
},
lightning = {
spell = 403, blurb = "Strikes enemies at random.", color = { 1, 0.95, 0.4 }, kind = "missile", pick = "random", range = 22,
damage = { 22, 28, 34, 40, 48 }, count = { 1, 2, 2, 3, 4 }, cooldown = { 1.8, 1.7, 1.6, 1.5, 1.3 },
mastery = { text = "Overload: each bolt has a 1 in 3 chance to strike another enemy.", chance = 1 / 3 },
},
nova = {
spell = 1449, blurb = "Bursts arcane power at every enemy around you.", color = { 0.85, 0.55, 1 }, kind = "nova",
damage = { 10, 14, 18, 22, 28 }, radius = { 6, 6.5, 7, 7.5, 8 }, cooldown = { 5, 4.6, 4.2, 3.8, 3.4 },
mastery = { text = "Echo: each explosion bursts a second time." },
},
chain = {
spell = 421, blurb = "Leaps from enemy to enemy.",
color = { 0.6, 0.75, 1 }, kind = "chain", pick = "nearest", range = 22, hop = 8,
damage = { 20, 24, 30, 36, 44 }, jumps = { 3, 4, 4, 5, 6 }, cooldown = { 2.4, 2.2, 2.0, 1.8, 1.6 },
mastery = { text = "Each leap strikes 20% harder than the one before.", grow = 1.2 },
},
corruption = {
spell = 172, blurb = "Rots enemies every second, and spreads when they die.",
color = { 0.55, 0.85, 0.3 }, kind = "dot", pick = "nearest", range = 22, hop = 6,
damage = { 6, 8, 10, 12, 15 }, count = { 2, 3, 3, 4, 5 }, spread = { 1, 1, 2, 2, 3 },
duration = { 6, 6, 7, 7, 8 }, cooldown = { 3, 3, 2.8, 2.6, 2.4 },
mastery = { text = "Corrupted enemies take 25% more damage from everything.", amplify = 1.25 },
},
holynova = {
spell = 15237, blurb = "Burns enemies around you, and heals you for each one.",
color = { 1, 0.95, 0.6 }, kind = "nova",
damage = { 12, 16, 20, 26, 32 }, radius = { 6, 6.5, 7, 7.5, 8 }, heal = { 1, 1, 1.5, 1.5, 2 },
cooldown = { 4, 3.7, 3.4, 3.1, 2.8 },
mastery = { text = "Enemies it burns are dazed: half speed for 2 seconds.", slow = 0.5, seconds = 2 },
},
-- A fire totem model (display 4589, FireElementalTotem.mdx) that pulses
-- as the real Magma Totem does (its pulse spell 8187, whose visual puts an
-- Immolate impact on each enemy it burns).
totem = {
spell = 8190, pulse = 8187, display = 4589,
blurb = "Plants a totem that burns every enemy near it every 2 seconds.",
color = { 1, 0.5, 0.15 }, kind = "totem", pulseEvery = 2,
damage = { 14, 18, 23, 28, 35 }, radius = { 6, 6.5, 7, 7.5, 8 }, totems = { 1, 1, 2, 2, 3 },
duration = { 12, 12, 14, 14, 16 }, cooldown = { 14, 13, 12, 11, 10 },
mastery = { text = "When a totem's time runs out, it erupts for triple damage.", burst = 3 },
},
-- Ground areas: the spell's own area model (SetModelBySpellArea) stands
-- where it lands and burns every enemy inside it each second. An area
-- with `slow` also slows them to that share of their speed.
blizzard = {
spell = 10, blurb = "Ice storms down on the thickest pack and chills it.",
color = { 0.55, 0.8, 1 }, kind = "area", place = "pack", range = 22,
damage = { 8, 10, 13, 16, 20 }, radius = { 5, 5.5, 6, 6.5, 7 }, slow = { 0.7, 0.65, 0.6, 0.55, 0.5 },
duration = { 4, 4, 5, 5, 6 },
cooldown = { 6, 5.5, 5, 4.5, 4 },
mastery = { text = "Deep Freeze: when the storm ends, it freezes everything inside for 2 seconds.", freeze = 2 },
},
rainoffire = {
spell = 5740, blurb = "Fire rains on the thickest pack.",
color = { 1, 0.45, 0.15 }, kind = "area", place = "pack", range = 22,
damage = { 14, 18, 23, 28, 35 }, radius = { 4, 4.4, 4.8, 5.2, 6 }, duration = { 3, 3, 3, 4, 4 },
cooldown = { 7, 6.5, 6, 5.5, 5 },
mastery = { text = "A second storm falls on another enemy." },
},
consecration = {
spell = 26573, blurb = "Hallows the ground at your feet.",
color = { 1, 0.85, 0.4 }, kind = "area", place = "self",
damage = { 6, 8, 10, 13, 16 }, radius = { 5, 5.5, 6, 6.5, 7 }, duration = { 6, 6, 7, 7, 8 },
cooldown = { 7, 6.5, 6, 5.5, 5 },
mastery = { text = "The hallowed ground moves with you." },
},
thunder = {
spell = 6343, blurb = "Shakes the ground close around you.", color = { 1, 0.6, 0.2 }, kind = "nova",
damage = { 16, 20, 26, 32, 40 }, radius = { 4, 4.4, 4.8, 5.2, 6 }, cooldown = { 3, 2.8, 2.6, 2.4, 2.2 },
mastery = { text = "Aftershock: the clap throws the brood back.", knock = { 8, 5 } },
},
-- Tanky spells keep the brood off you. `kit` is a SpellVisualKit shown
-- while the effect lasts: the shield's bubble, Frost Nova's ice on each
-- frozen enemy, Psychic Scream's terror over each fleeing one.
shield = {
spell = 17, kit = 847, blurb = "A shield soaks up the brood's bites until it breaks.",
color = { 1, 1, 0.75 }, kind = "shield",
absorb = { 30, 40, 50, 60, 75 }, duration = { 10, 10, 12, 12, 15 }, cooldown = { 15, 14, 13, 12, 11 },
mastery = { text = "When the shield breaks, it bursts and hurls the brood around you away.", radius = 6, damage = 30, knock = { 12, 8 } },
},
thorns = {
spell = 467, blurb = "Every enemy biting you takes damage each second.",
color = { 0.6, 0.9, 0.3 }, kind = "thorns",
damage = { 6, 8, 10, 13, 16 }, duration = { 8, 9, 10, 11, 12 }, cooldown = { 14, 13, 12, 11, 10 },
mastery = { text = "You heal 1 health for every biter it burns.", heal = 1 },
},
frostnova = {
spell = 122, kit = 285, blurb = "Freezes every enemy around you in place.",
color = { 0.6, 0.85, 1 }, kind = "nova",
damage = { 6, 8, 10, 12, 15 }, radius = { 6, 6.5, 7, 7.5, 8 }, root = { 2, 2.5, 3, 3.5, 4 },
cooldown = { 10, 9.5, 9, 8.5, 8 },
mastery = { text = "Shatter: frozen enemies take double damage.", amplify = 2 },
},
scream = {
spell = 8122, kit = 4031, blurb = "Every enemy around you flees in terror.",
color = { 0.75, 0.5, 1 }, kind = "nova",
radius = { 6, 6.5, 7, 7.5, 8 }, fear = { 2, 2.5, 3, 3.5, 4 }, cooldown = { 14, 13, 12, 11, 10 },
mastery = { text = "Terror: fleeing enemies take 10 damage a second.", damage = 10 },
},
demoshout = {
spell = 1160, blurb = "Enemies around you bite softer for a while.",
color = { 1, 0.55, 0.35 }, kind = "nova",
radius = { 8, 8.5, 9, 9.5, 10 }, weaken = { 0.3, 0.35, 0.4, 0.45, 0.5 }, duration = { 6, 7, 8, 9, 10 },
cooldown = { 10, 9.5, 9, 8.5, 8 },
mastery = { text = "Battle cry: you run 20% faster while it lasts.", speed = 1.2 },
},
stoneclaw = {
spell = 5730, display = 4588, blurb = "Plants a totem that the brood around it must attack.",
color = { 0.8, 0.65, 0.4 }, kind = "ward",
radius = { 6, 7, 8, 9, 10 }, duration = { 5, 6, 7, 8, 9 }, cooldown = { 16, 15, 14, 13, 12 },
mastery = { text = "When the totem crumbles, it stuns the brood around it for 2 seconds.", stun = 2 },
},
-- Fun ones. `knock` is the knockback (out, up), as the items' are.
blastwave = {
spell = 11113, blurb = "A ring of fire hurls the brood away from you.",
color = { 1, 0.5, 0.2 }, kind = "nova", knock = { 10, 7 },
damage = { 18, 24, 30, 38, 48 }, radius = { 6, 6.5, 7, 7.5, 8 }, cooldown = { 7, 6.6, 6.2, 5.8, 5.4 },
mastery = { text = "The brood it hits is dazed: half speed for 3 seconds.", slow = 0.5, seconds = 3 },
},
-- The imp (display 4449) stays for the run and casts the real Firebolt.
imp = {
spell = 688, missile = 3110, display = 4449, blurb = "An imp follows you and hurls Firebolts.",
color = { 1, 0.55, 0.2 }, kind = "pet", range = 22,
damage = { 12, 15, 19, 24, 30 }, cooldown = { 1.6, 1.5, 1.4, 1.3, 1.2 },
mastery = { text = "The imp hurls two Firebolts at once.", bolts = 2 },
},
}
local SPELL_ORDER = {
"frostbolt", "shadowbolt", "lightning", "chain", "corruption", "nova", "holynova", "thunder", "totem", "blizzard",
"rainoffire", "consecration", "shield", "thorns", "frostnova", "scream", "demoshout", "stoneclaw", "blastwave", "imp",
}
local MAX_SPELLS = 6
local EARLY_SPELLS = 3 -- until you have this many spells, each level offers a new one
-- `icon` is the spell whose icon the passive shows; `total` is what all its
-- ranks give together.
local PASSIVES = {
might = { name = "Might", icon = 19740, text = "+15% damage", total = "+%d%% damage", per = 15 },
haste = { name = "Haste", icon = 12043, text = "-8% spell cooldowns", total = "-%d%% cooldowns", per = 8 },
reach = { name = "Reach", icon = 2481, text = "+40% gem pickup radius", total = "+%d%% pickup radius", per = 40 },
vigor = { name = "Vigor", icon = 1243, text = "+25 maximum health, and heal 25", total = "+%d maximum health", per = 25 },
renewal = { name = "Renewal", icon = 139, text = "+1.2 health per second", total = "%.1f health per second", per = 1.2 },
sprint = { icon = 2983, text = "+8% run speed", total = "+%d%% run speed", per = 8 },
devotion = { icon = 465, text = "-5% damage taken", total = "-%d%% damage taken", per = 5 },
amplify = { icon = 1008, text = "+10% spell radius", total = "+%d%% spell radius", per = 10 },
multishot = {
icon = 2643, text = "+1 target for bolts and Corruption", total = "+%d targets", per = 1,
max = 3,
},
wisdom = { icon = 19742, text = "+15% experience", total = "+%d%% experience", per = 15 },
soulstone = {
name = "Soulstone", icon = 20707, text = "Once per run, rise again with half your health",
total = "%d revive", per = 1, max = 1,
},
vampiric = {
icon = 15286,
text = "Heals you for 0.2% of the damage you deal, at most 2 health a second",
total = "up to %d health a second", per = 2,
},
}
-- The names are the client's own, in its language: each spell's from its
-- spell, and each passive without a name of its own from its icon's spell.
for _, def in pairs(SPELLS) do
def.name = C_Spell.GetSpellName(def.spell)
end
for _, p in pairs(PASSIVES) do
p.name = p.name or C_Spell.GetSpellName(p.icon)
end
local HEAL_ICON = 5185 -- Healing Touch
local UNKNOWN_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"
local PASSIVE_ORDER = {
"might", "haste", "reach", "vigor", "renewal", "sprint", "vampiric", "devotion", "amplify", "multishot",
"wisdom", "soulstone",
}
local VAMPIRIC_SHARE = 0.002 -- of the damage dealt, per rank
local HOLY_NOVA_HEAL_TARGETS = 8 -- Holy Nova heals for at most this many enemies
local SOULSTONE_SPELL = 20707 -- Soulstone Resurrection, the revive's visual
local SOULSTONE_BLAST = 10 -- yards: rising again throws back the enemies this close
local MAX_LEVEL = 5
local ROMAN = { "I", "II", "III", "IV", "V" }
-- The bars ease to a new value in the engine (StatusBar interpolation); a
-- reset (a new run, a level's wrap) shows at once.
local EASE = Enum.StatusBarInterpolation.ExponentialEaseOut
local AT_ONCE = Enum.StatusBarInterpolation.Immediate
-- The items' tuning.
local TUNING = {
SWIFTNESS_FACTOR = 1.5,
SWIFTNESS_SECONDS = 10,
ITEM_CHANCE = 1 / 150, -- a whelp's chance to drop an item
ITEM_REACH = 1.5, -- yards: walking this close uses a drop
ITEM_SECONDS = 30, -- a drop lies this long
ITEM_ICON = 28, -- pixels
ITEM_COLOR = { 1, 0.82, 0 },
POTION_HEAL = 0.3, -- of the maximum health
GRENADE_RADIUS = 5,
GRENADE_DAMAGE = 45, -- grows with the run as the brood's health does
GRENADE_STUN = 2, -- seconds
SAPPER_RADIUS = 10,
SAPPER_SELF = 0.15, -- of the maximum health; it never kills you
SAPPER_ELITE = 0.25, -- of an elite's maximum health
CHICKEN_DISPLAY = 6909, -- the robot chicken (ChickenSkinRobot)
CHICKEN_SECONDS = 20,
CHICKEN_SPEED = 9,
CHICKEN_DAMAGE = 12, -- a peck, grows with the run
CHICKEN_PECK = 0.8, -- seconds between pecks
CHICKEN_REACH = 1.8,
CHICKEN_LEASH = 20, -- yards from the hero: it hunts no farther
CHICKEN_HUNT_MIN = 6, -- yards from the hero: it prefers prey past this
CHICKEN_TAUNT = 3, -- seconds a peck holds the whelps around it
CHICKEN_TAUNT_RADIUS = 3, -- yards around the chicken that a peck taunts
CHICKEN_ROAM = 6, -- yards around the hero that it roams with no prey
-- Knockbacks (actor:KnockBack): yards a second out and up.
GRENADE_KNOCK = { 10, 7 },
SOULSTONE_KNOCK = { 16, 10 },
ELITE_KNOCK_SHARE = 0.4, -- an elite takes this share of a knockback
ELITE_HOLD_SHARE = 0.5, -- and this share of a root's or a fear's time
FEAR_STEP = 6, -- yards a fleeing enemy runs before its next MoveTo
IMP_SPEED = 8.5,
IMP_FOLLOW = 3, -- yards: farther from you than this, the imp follows
}
-- `itemID` is the real item (its name and icon), `spell` the visual its use plays.
local ITEMS = {
{ key = "potion", itemID = 929, spell = 17534, weight = 30 },
{ key = "grenade", itemID = 15993, spell = 19769, weight = 25 },
{ key = "swift", itemID = 2459, spell = 2379, weight = 15 },
{ key = "hearth", itemID = 6948, spell = 8690, weight = 10 },
{ key = "sapper", itemID = 10646, spell = 13241, weight = 10 },
{ key = "chicken", itemID = 10725, spell = 23133, weight = 10 },
}
-- Each item's name and icon are the client's (the Item mixin), once its data
-- has come from the server.
for _, item in ipairs(ITEMS) do
local data = Item:CreateFromItemID(item.itemID);
data:ContinueOnItemLoad(function()
item.name = data:GetItemName();
item.icon = data:GetItemIcon();
end);
end
-- For the files that load after this one.
BW.LAIR_MAP = LAIR_MAP
BW.CENTER = CENTER
BW.ROOM_RADIUS = ROOM_RADIUS
BW.SPAWN_MIN_DISTANCE = SPAWN_MIN_DISTANCE
BW.FAR_CLIP = FAR_CLIP
BW.CAMERA_YAW = CAMERA_YAW
BW.CAMERA_PITCH = CAMERA_PITCH
BW.CAMERA_ZOOM = CAMERA_ZOOM
BW.RUN_SECONDS = RUN_SECONDS
BW.ELITE_EVERY = ELITE_EVERY
BW.MAX_ALIVE = MAX_ALIVE
BW.RETARGET_SECONDS = RETARGET_SECONDS
BW.SURROUND_RADIUS = SURROUND_RADIUS
BW.CAST_SPACING = CAST_SPACING
BW.DEATH_SECONDS = DEATH_SECONDS
BW.ANIM_STAND = ANIM_STAND
BW.ANIM_DEATH = ANIM_DEATH
BW.HERO_HP = HERO_HP
BW.MAGNET = MAGNET
BW.GEM_SPEED = GEM_SPEED
BW.GEM_TAKE = GEM_TAKE
BW.GEM_PATH = GEM_PATH
BW.MAX_GEMS = MAX_GEMS
BW.KINDS = KINDS
BW.ELITE = ELITE
BW.SPELLS = SPELLS
BW.SPELL_ORDER = SPELL_ORDER
BW.MAX_SPELLS = MAX_SPELLS
BW.EARLY_SPELLS = EARLY_SPELLS
BW.PASSIVES = PASSIVES
BW.HEAL_ICON = HEAL_ICON
BW.UNKNOWN_ICON = UNKNOWN_ICON
BW.PASSIVE_ORDER = PASSIVE_ORDER
BW.VAMPIRIC_SHARE = VAMPIRIC_SHARE
BW.HOLY_NOVA_HEAL_TARGETS = HOLY_NOVA_HEAL_TARGETS
BW.SOULSTONE_SPELL = SOULSTONE_SPELL
BW.SOULSTONE_BLAST = SOULSTONE_BLAST
BW.MAX_LEVEL = MAX_LEVEL
BW.ROMAN = ROMAN
BW.EASE = EASE
BW.AT_ONCE = AT_ONCE
BW.TUNING = TUNING
BW.ITEMS = ITEMS
+458
View File
@@ -0,0 +1,458 @@
-- Broodwatch HUD: the level-up cards, the build bar, health and experience.
local _, BW = ...
local HERO_HP = BW.HERO_HP
local SPELLS = BW.SPELLS
local SPELL_ORDER = BW.SPELL_ORDER
local MAX_SPELLS = BW.MAX_SPELLS
local EARLY_SPELLS = BW.EARLY_SPELLS
local PASSIVES = BW.PASSIVES
local HEAL_ICON = BW.HEAL_ICON
local UNKNOWN_ICON = BW.UNKNOWN_ICON
local PASSIVE_ORDER = BW.PASSIVE_ORDER
local MAX_LEVEL = BW.MAX_LEVEL
local ROMAN = BW.ROMAN
local EASE = BW.EASE
local AT_ONCE = BW.AT_ONCE
local offers = BW.offers
local Shuffle = BW.Shuffle
local Stat = BW.Stat
local TUNING = BW.TUNING
------------------------------------------------------------------------------
-- Level-up offers
local function BuildOffers()
local all = {};
local owned = 0;
for _ in pairs(BW.run.spells) do
owned = owned + 1;
end
for _, key in ipairs(SPELL_ORDER) do
local state = BW.run.spells[key];
if state and state.level < MAX_LEVEL then
tinsert(all, { kind = "spell", key = key, level = state.level + 1 });
elseif not state and owned < MAX_SPELLS then
tinsert(all, { kind = "spell", key = key, level = 1 });
end
end
for _, key in ipairs(PASSIVE_ORDER) do
local level = (BW.run.passives[key] or 0) + 1;
if level <= (PASSIVES[key].max or MAX_LEVEL) then
tinsert(all, { kind = "passive", key = key, level = level });
end
end
Shuffle(all);
-- An early build must not stall: the first level offers only new spells,
-- and until you have EARLY_SPELLS spells each level offers at least one.
local fresh = {};
for _, o in ipairs(all) do
if o.kind == "spell" and o.level == 1 then
tinsert(fresh, o);
end
end
if owned == 1 and table.getn(fresh) >= 3 then
all = fresh;
elseif owned < EARLY_SPELLS and table.getn(fresh) > 0 then
local hasFresh = false;
for i = 1, 3 do
local o = all[i];
if o and o.kind == "spell" and o.level == 1 then
hasFresh = true;
end
end
if not hasFresh then
-- The new spell takes the third card's place.
for i, o in ipairs(all) do
if o == fresh[1] then
table.remove(all, i);
break;
end
end
tinsert(all, 3, fresh[1]);
end
end
for i = 1, 3 do
offers[i] = all[i] or { kind = "heal" };
end
end
-- Card colours by kind: a new spell gold, a spell's next rank blue, a
-- passive green, the heal when nothing is left red.
local CARD_COLORS = {
new = { 1, 0.82, 0 },
spell = { 0.35, 0.65, 1 },
passive = { 0.3, 0.9, 0.4 },
heal = { 1, 0.45, 0.45 },
}
local STAT_LABELS = {
damage = "Damage", count = "Targets", jumps = "Jumps", totems = "Totems", spread = "Spreads to",
heal = "Heal per hit", radius = "Radius", slow = "Enemy speed", duration = "Lasts", cooldown = "Every",
absorb = "Absorbs", root = "Freezes for", fear = "Fears for", weaken = "Their bites",
}
local STAT_UNITS = { radius = " yd", duration = " s", cooldown = " s", root = " s", fear = " s" }
local STAT_ORDER = {
"damage", "count", "jumps", "totems", "spread", "heal", "absorb", "radius", "slow", "root", "fear", "weaken",
"duration", "cooldown",
}
local function Num(v)
if v == math.floor(v) then
return string.format("%d", v);
end
local text = string.format("%.2f", v);
if string.sub(text, -1) == "0" then
text = string.sub(text, 1, -2);
end
return text;
end
-- A stat as the cards show it: the slow as a percentage of speed, the rest
-- with their unit.
local function StatText(field, v)
if field == "slow" then
return math.floor(v * 100 + 0.5) .. "%";
elseif field == "weaken" then
return "-" .. math.floor(v * 100 + 0.5) .. "%";
elseif field == "absorb" then
return Num(v) .. "% of health";
end
return Num(v) .. (STAT_UNITS[field] or "");
end
local function Icon(spellID)
return C_Spell.GetSpellTexture(spellID) or UNKNOWN_ICON;
end
-- The card's lines: a new spell's blurb and stats, a rank's changes (old in
-- grey, new in green), a passive's rank and its total.
local function OfferLines(o)
local lines = {};
if o.kind == "spell" then
local def = SPELLS[o.key];
if o.level == 1 then
tinsert(lines, def.blurb);
tinsert(lines, "");
end
for _, field in ipairs(STAT_ORDER) do
local b = Stat(def, field, o.level);
if b then
if o.level == 1 then
tinsert(lines, STAT_LABELS[field] .. " " .. StatText(field, b));
else
local a = Stat(def, field, o.level - 1);
if a ~= b then
tinsert(lines, string.format("%s |cff888888%s|r > |cff40ff40%s|r",
STAT_LABELS[field], StatText(field, a), StatText(field, b)));
end
end
end
end
-- The mastery: a teaser on a new spell's card, the prize at rank V.
if def.mastery then
if o.level == MAX_LEVEL then
tinsert(lines, "");
tinsert(lines, "|cffffd100Mastery:|r " .. def.mastery.text);
elseif o.level == 1 then
tinsert(lines, "");
tinsert(lines, "|cff888888At rank V: " .. def.mastery.text .. "|r");
end
end
elseif o.kind == "passive" then
local p = PASSIVES[o.key];
tinsert(lines, p.text);
tinsert(lines, "");
tinsert(lines, "|cff888888Total:|r " .. string.format(p.total, p.per * o.level));
else
tinsert(lines, "Heal 50 health.");
end
return table.concat(lines, "\n");
end
-- A card grows to its text: the parts above the description (the icon at
-- 20 from the top, 64 tall, the name 10 below it, the tag 4 below that, the
-- description 14 below that) and below it (12 above the pips, which sit 40
-- from the bottom, 6 tall), as Broodwatch.xml lays them out.
local CARD_ABOVE = 20 + 64 + 10 + 4 + 14
local CARD_BELOW = 12 + 6 + 40
local CARD_MIN_HEIGHT = 280
local CARD_DESC_ROOM = 1000 -- the description's height while it is measured
-- Fills card i with offer o; returns the height the card needs.
local function FillCard(i, o)
local card = getglobal(BW.cards:GetName() .. "Card" .. i);
local name = card:GetName();
local icon, title, tag, colorKey;
if o.kind == "spell" then
local def = SPELLS[o.key];
icon, title = Icon(def.spell), def.name;
if o.level == 1 then
tag, colorKey = "New spell", "new";
else
tag, colorKey = "Rank " .. ROMAN[o.level], "spell";
end
elseif o.kind == "passive" then
local p = PASSIVES[o.key];
icon, title = Icon(p.icon), p.name;
tag, colorKey = "Passive, rank " .. ROMAN[o.level], "passive";
else
icon, title, tag, colorKey = Icon(HEAL_ICON), "Second Wind", "Everything is at rank V", "heal";
end
local color = CARD_COLORS[colorKey];
card.color = color;
card:SetBackdropColor(0.04, 0.04, 0.06, 0.95);
card:SetBackdropBorderColor(color[1], color[2], color[3]);
getglobal(name .. "Icon"):SetTexture(icon);
local nameText = getglobal(name .. "Name");
nameText:SetText(title);
nameText:SetTextColor(color[1], color[2], color[3]);
local tagText = getglobal(name .. "Tag");
tagText:SetText(tag);
local desc = getglobal(name .. "Desc");
desc:SetHeight(CARD_DESC_ROOM);
desc:SetText(OfferLines(o));
local descHeight = desc:GetStringHeight();
desc:SetHeight(descHeight);
getglobal(name .. "Key"):SetText("Press " .. i);
-- The ranks: those you have in the card's colour, the new one white.
local maxRank = o.kind == "passive" and PASSIVES[o.key].max or MAX_LEVEL;
for r = 1, MAX_LEVEL do
local pip = getglobal(name .. "Pip" .. r);
if o.kind == "heal" or r > maxRank then
pip:Hide();
else
pip:Show();
if r < o.level then
pip:SetVertexColor(color[1], color[2], color[3]);
elseif r == o.level then
pip:SetVertexColor(1, 1, 1);
else
pip:SetVertexColor(0.2, 0.2, 0.2);
end
end
end
return CARD_ABOVE + nameText:GetStringHeight() + tagText:GetStringHeight() + descHeight + CARD_BELOW;
end
function Broodwatch_CardHover(card, over)
if over then
card:SetBackdropBorderColor(1, 1, 1);
elseif card.color then
card:SetBackdropBorderColor(card.color[1], card.color[2], card.color[3]);
end
end
-- The build bar: an icon for each spell and passive you have, with its rank.
local BUILD_SLOT = 32
local buildSlots = {}
local function BuildSlot(i)
local slot = buildSlots[i];
if not slot then
slot = CreateFrame("Frame", nil, BW.build);
slot:SetWidth(BUILD_SLOT);
slot:SetHeight(BUILD_SLOT);
slot.icon = slot:CreateTexture(nil, "ARTWORK");
slot.icon:SetAllPoints(slot);
-- The spell's recharge sweeps over its icon. No flash at the end:
-- the fast spells would flash all the time.
slot.cd = CreateFrame("Cooldown", nil, slot);
slot.cd:SetAllPoints(slot);
slot.cd:SetDrawEdge(true);
slot.cd:SetDrawBling(false);
slot.cd:SetHideCountdownNumbers(true);
slot.cd:SetPaused(BW.paused);
-- The rank sits above the sweep.
slot.top = CreateFrame("Frame", nil, slot);
slot.top:SetAllPoints(slot);
slot.top:SetFrameLevel(slot.cd:GetFrameLevel() + 1);
slot.rank = slot.top:CreateFontString(nil, "OVERLAY");
slot.rank:SetFontObject(NumberFontNormal);
slot.rank:SetPoint("BOTTOMRIGHT", slot, "BOTTOMRIGHT", -1, 1);
buildSlots[i] = slot;
end
return slot;
end
local buildCount = 0
local PASSIVE_SLOT = 24
local function AddBuildSlot(icon, level, x, y, size)
buildCount = buildCount + 1;
local slot = BuildSlot(buildCount);
slot:SetWidth(size);
slot:SetHeight(size);
slot:ClearAllPoints();
slot:SetPoint("BOTTOMLEFT", BW.build, "BOTTOMLEFT", x, y);
slot.icon:SetTexture(icon);
slot.icon:SetDesaturated(false);
slot.rank:SetText(ROMAN[level]);
slot:Show();
return slot;
end
-- Shows a spell's recharge on its slot: the part of `full` seconds left in
-- `left`, measured on the game's clock (the sweep pauses with the game).
local function ShowRecharge(slot, left, full)
if left > 0 and full and full > 0 then
slot.cd:SetCooldown(GetTime() - (full - left), full);
else
slot.cd:Clear();
end
end
local function UpdateBuild()
buildCount = 0;
local x = 0;
for _, key in ipairs(SPELL_ORDER) do
local state = BW.run.spells[key];
if state then
-- A new spell can move the others along the bar: each slot shows
-- its own spell's recharge again.
state.slot = AddBuildSlot(Icon(SPELLS[key].spell), state.level, x, 0, BUILD_SLOT);
ShowRecharge(state.slot, state.cooldown, state.cdFull);
x = x + BUILD_SLOT + 4;
end
end
x = 0;
for _, key in ipairs(PASSIVE_ORDER) do
local level = BW.run.passives[key];
if level then
local slot = AddBuildSlot(Icon(PASSIVES[key].icon), level, x, BUILD_SLOT + 6, PASSIVE_SLOT);
slot.cd:Clear();
slot.icon:SetDesaturated(key == "soulstone" and BW.run.soulstoneUsed and true or false);
x = x + PASSIVE_SLOT + 3;
end
end
for i = buildCount + 1, table.getn(buildSlots) do
buildSlots[i]:Hide();
end
end
local function SetPaused(on)
BW.paused = on;
BW.scene:SetPaused(on);
for _, slot in ipairs(buildSlots) do
slot.cd:SetPaused(on);
end
if on and BW.moving then
BW.hero:StopMoving();
BW.moving = false;
end
end
local function ShowCards()
BuildOffers();
-- The three cards share the tallest one's height.
local height = CARD_MIN_HEIGHT;
for i = 1, 3 do
height = math.max(height, FillCard(i, offers[i]));
end
for i = 1, 3 do
getglobal(BW.cards:GetName() .. "Card" .. i):SetHeight(height);
end
getglobal(BW.cards:GetName() .. "Title"):SetText("Level " .. (BW.run.level - BW.run.pendingLevels + 1));
BW.cards:Show();
SetPaused(true);
end
local function ApplyHeroSpeed()
local factor = 1 + PASSIVES.sprint.per * (BW.run.passives.sprint or 0) / 100;
if BW.run.swiftUntil then
factor = factor * TUNING.SWIFTNESS_FACTOR;
end
if BW.run.shoutUntil then
factor = factor * SPELLS.demoshout.mastery.speed;
end
BW.hero:SetMovementSpeed("run", BW.run.heroRun * factor);
end
local function MaxHp()
return HERO_HP + 25 * (BW.run.passives.vigor or 0);
end
function Broodwatch_PickCard(i)
if not BW.run or not BW.cards:IsShown() then
return;
end
local o = offers[i];
if o.kind == "spell" then
local state = BW.run.spells[o.key];
if state then
state.level = o.level;
else
BW.run.spells[o.key] = { level = 1, cooldown = 0, pending = 0 };
end
elseif o.kind == "passive" then
BW.run.passives[o.key] = o.level;
if o.key == "might" then
BW.run.damageScale = 1 + 0.15 * o.level;
elseif o.key == "vigor" then
BW.run.hp = math.min(MaxHp(), BW.run.hp + 25);
elseif o.key == "sprint" then
ApplyHeroSpeed();
end
else
BW.run.hp = math.min(MaxHp(), BW.run.hp + 50);
end
BW.cards:Hide();
UpdateBuild();
BW.run.pendingLevels = BW.run.pendingLevels - 1;
if BW.run.pendingLevels > 0 then
ShowCards();
else
SetPaused(false);
end
end
local function UpdateHealth(mode)
local max = MaxHp();
BW.healthBar:SetMinMaxValues(0, max, mode or EASE);
BW.healthBar:SetValue(math.max(BW.run.hp, 0), mode or EASE);
local text = string.format("%d / %d", math.max(math.ceil(BW.run.hp), 0), max);
if BW.run.shield then
text = text .. string.format(" (+%d)", math.ceil(BW.run.shield));
end
BW.healthText:SetText(text);
end
local LEVEL_HEAL = 0.12 -- each level heals this share of the maximum health
local function XpNeeded(level)
return 5 + 3 * level + math.floor(level * level / 8);
end
local function GainXp(xp)
BW.run.xp = BW.run.xp + xp;
local wrapped = false;
while BW.run.xp >= XpNeeded(BW.run.level) do
BW.run.xp = BW.run.xp - XpNeeded(BW.run.level);
BW.run.level = BW.run.level + 1;
BW.run.pendingLevels = BW.run.pendingLevels + 1;
wrapped = true;
end
-- A level starts the bar again from empty at once; a gain within a
-- level eases.
if wrapped then
local levels = BW.run.pendingLevels;
BW.run.hp = math.min(MaxHp(), BW.run.hp + MaxHp() * LEVEL_HEAL * levels);
UpdateHealth();
end
local mode = wrapped and AT_ONCE or EASE;
BW.xpBar:SetMinMaxValues(0, XpNeeded(BW.run.level), mode);
BW.xpBar:SetValue(BW.run.xp, mode);
if BW.run.pendingLevels > 0 and not BW.cards:IsShown() then
ShowCards();
end
end
-- For the files that load after this one.
BW.Icon = Icon
BW.UpdateBuild = UpdateBuild
BW.SetPaused = SetPaused
BW.ApplyHeroSpeed = ApplyHeroSpeed
BW.MaxHp = MaxHp
BW.UpdateHealth = UpdateHealth
BW.XpNeeded = XpNeeded
BW.GainXp = GainXp
+325
View File
@@ -0,0 +1,325 @@
-- Broodwatch items: drops and what each one does.
local _, BW = ...
local Blast = BW.Blast
local Hold = BW.Hold
local ELITE = BW.ELITE
local enemies = BW.enemies
local gems = BW.gems
local FloorAt = BW.FloorAt
local Dist2 = BW.Dist2
local Kill = BW.Kill
local ShowNumber = BW.ShowNumber
local Damage = BW.Damage
local PackCenter = BW.PackCenter
local markerPool = BW.markerPool
local ReleaseMarker = BW.ReleaseMarker
local UNKNOWN_ICON = BW.UNKNOWN_ICON
local TUNING = BW.TUNING
local ApplyHeroSpeed = BW.ApplyHeroSpeed
local MaxHp = BW.MaxHp
local UpdateHealth = BW.UpdateHealth
local ITEMS = BW.ITEMS
------------------------------------------------------------------------------
-- Items: a whelp drops one now and then, an elite always. A drop lies on the
-- floor (a reticle under it, its icon bobbing over it) and is used when you
-- walk over it. The icons are the items' use spells' (C_Spell.GetSpellTexture).
local ANIM_ATTACK_UNARMED = 16
local drops = {}
local dropPool = {}
local chickens = {}
local chickenPool = {}
local function RunGrowth()
local m = BW.run.time / 300;
return 1 + 2 * m + m * m;
end
local function PickItem()
local total = 0;
for _, item in ipairs(ITEMS) do
total = total + item.weight;
end
local roll = math.random() * total;
for _, item in ipairs(ITEMS) do
if roll < item.weight then
return item;
end
roll = roll - item.weight;
end
return ITEMS[1];
end
BW.DropItem = function(e, x, y, z)
if e.kind ~= ELITE and math.random() >= TUNING.ITEM_CHANCE then
return;
end
local drop = table.remove(dropPool);
if not drop then
drop = { actor = BW.scene:AcquireActor(), icon = BW.feedback:CreateTexture(nil, "ARTWORK") };
drop.icon:SetWidth(TUNING.ITEM_ICON);
drop.icon:SetHeight(TUNING.ITEM_ICON);
end
drop.item = PickItem();
drop.x, drop.y, drop.z = x, y, z;
drop.life = TUNING.ITEM_SECONDS;
drop.actor:SetPosition(x, y, z);
drop.actor:SetGroundReticle(0.9);
drop.actor:Show();
drop.icon:SetTexture(drop.item.icon or UNKNOWN_ICON);
drop.icon:Show();
tinsert(drops, drop);
end
local function ReleaseDrop(i)
local drop = drops[i];
drop.actor:ClearGroundReticle();
drop.actor:Hide();
drop.icon:Hide();
tinsert(dropPool, drop);
table.remove(drops, i);
end
-- A grenade lands on the thickest pack: the reticle marks it while it flies.
local function ThrowGrenade(item)
local x, y, z, target = PackCenter(24, TUNING.GRENADE_RADIUS);
if not target then
return;
end
local marker = table.remove(markerPool) or { actor = BW.scene:AcquireActor() };
marker.actor:SetPosition(x, y, FloorAt(x, y, z));
marker.actor:SetGroundReticle(TUNING.GRENADE_RADIUS);
marker.actor:Show();
local damage = TUNING.GRENADE_DAMAGE * RunGrowth();
local function Explode(entry)
ReleaseMarker(marker);
local ex, ey = entry.target.actor:GetPosition();
local r2 = TUNING.GRENADE_RADIUS * TUNING.GRENADE_RADIUS;
for _, o in ipairs(enemies) do
if o.alive then
local ox, oy = o.actor:GetPosition();
if Dist2(ox, oy, ex, ey) <= r2 then
Hold(o, TUNING.GRENADE_STUN);
Damage(o, damage, TUNING.ITEM_COLOR);
Blast(o, ex, ey, TUNING.GRENADE_KNOCK);
end
end
end
end
local castID = BW.hero:PlaySpellVisual(item.spell, target.actor, 0);
local entry = { target = target, onImpact = Explode };
if castID then
BW.run.inFlight[castID] = entry;
else
Explode(entry);
end
end
-- The Sapper Charge goes off on you: it clears the brood close by, and hurts.
local function Sapper(item, hx, hy)
BW.hero:PlaySpellVisual(item.spell, BW.hero, 0);
local r2 = TUNING.SAPPER_RADIUS * TUNING.SAPPER_RADIUS;
for _, e in ipairs(enemies) do
if e.alive then
local x, y = e.actor:GetPosition();
if Dist2(x, y, hx, hy) <= r2 then
if e.kind == ELITE then
Damage(e, e.maxHp * TUNING.SAPPER_ELITE / BW.run.damageScale, TUNING.ITEM_COLOR);
else
Kill(e);
end
end
end
end
BW.run.hp = math.max(1, BW.run.hp - MaxHp() * TUNING.SAPPER_SELF);
UpdateHealth();
end
local function SummonChicken(item, hx, hy, hz)
BW.hero:PlaySpellVisual(item.spell, BW.hero, 0);
local chicken = table.remove(chickenPool);
if not chicken then
chicken = { actor = BW.scene:AcquireActor() };
chicken.actor:SetModelByCreatureDisplayID(TUNING.CHICKEN_DISPLAY);
end
chicken.life = TUNING.CHICKEN_SECONDS;
chicken.peck = 0;
chicken.move = 0;
chicken.prey = nil;
chicken.gone = nil;
chicken.actor:SetPosition(hx + 1.5, hy, hz + 0.5);
chicken.actor:Show();
chicken.actor:SetMovementEnabled(true);
chicken.actor:SetMovementSpeed("run", TUNING.CHICKEN_SPEED);
tinsert(chickens, chicken);
end
-- The chicken's prey: the whelp nearest to it among those at the edge of
-- the pack (past CHICKEN_HUNT_MIN from the hero, within the leash), else the
-- nearest within the leash. It keeps its prey until the prey dies or strays.
local function ChickenPrey(cx, cy, hx, hy)
local leash2 = TUNING.CHICKEN_LEASH * TUNING.CHICKEN_LEASH;
local min2 = TUNING.CHICKEN_HUNT_MIN * TUNING.CHICKEN_HUNT_MIN;
local edge, edgeD2, any, anyD2;
for _, e in ipairs(enemies) do
if e.alive then
local x, y = e.actor:GetPosition();
local fromHero = Dist2(x, y, hx, hy);
if fromHero <= leash2 then
local d2 = Dist2(x, y, cx, cy);
if fromHero >= min2 and (not edge or d2 < edgeD2) then
edge, edgeD2 = e, d2;
end
if not any or d2 < anyD2 then
any, anyD2 = e, d2;
end
end
end
end
return edge or any;
end
local function UpdateChickens(dt, hx, hy, hz)
local leash2 = TUNING.CHICKEN_LEASH * TUNING.CHICKEN_LEASH;
local reach2 = TUNING.CHICKEN_REACH * TUNING.CHICKEN_REACH;
local taunt2 = TUNING.CHICKEN_TAUNT_RADIUS * TUNING.CHICKEN_TAUNT_RADIUS;
for i = table.getn(chickens), 1, -1 do
local chicken = chickens[i];
chicken.life = chicken.life - dt;
if chicken.life <= 0 then
chicken.gone = true;
chicken.prey = nil;
chicken.actor:SetMovementEnabled(false);
chicken.actor:Hide();
tinsert(chickenPool, chicken);
table.remove(chickens, i);
else
local cx, cy, cz = chicken.actor:GetPosition();
chicken.peck = chicken.peck - dt;
chicken.move = chicken.move - dt;
local prey = chicken.prey;
if prey then
local px, py = prey.actor:GetPosition();
if not prey.alive or Dist2(px, py, hx, hy) > leash2 then
prey = nil;
end
end
if not prey then
prey = ChickenPrey(cx, cy, hx, hy);
chicken.prey = prey;
end
if prey then
local tx, ty, tz = prey.actor:GetPosition();
if Dist2(tx, ty, cx, cy) <= reach2 then
if chicken.peck <= 0 then
chicken.peck = TUNING.CHICKEN_PECK;
chicken.actor:SetYaw(math.atan2(ty - cy, tx - cx));
chicken.actor:SetAnimation(ANIM_ATTACK_UNARMED);
Damage(prey, TUNING.CHICKEN_DAMAGE * RunGrowth(), TUNING.ITEM_COLOR);
-- The peck taunts the whelps around the chicken: they
-- chase it, and leave the hero be (Chase, Step).
for _, e in ipairs(enemies) do
if e.alive then
local x, y = e.actor:GetPosition();
if Dist2(x, y, cx, cy) <= taunt2 then
e.taunter = chicken;
e.tauntUntil = BW.run.time + TUNING.CHICKEN_TAUNT;
end
end
end
end
elseif chicken.move <= 0 then
chicken.move = 0.25;
chicken.actor:MoveTo(tx, ty, tz);
end
elseif chicken.move <= 0 then
-- Nothing to hunt: it struts about the hero.
chicken.move = 1.5 + math.random();
local angle = math.random() * 2 * math.pi;
local r = TUNING.CHICKEN_ROAM * (0.4 + 0.6 * math.random());
chicken.actor:MoveTo(hx + r * math.cos(angle), hy + r * math.sin(angle), hz);
end
end
end
end
local function UseItem(item, hx, hy, hz)
BW.heroNumber.actor = BW.hero;
ShowNumber(BW.heroNumber, 0, TUNING.ITEM_COLOR, item.name);
if item.key == "potion" then
BW.hero:PlaySpellVisual(item.spell, BW.hero, 0);
local heal = math.floor(MaxHp() * TUNING.POTION_HEAL + 0.5);
BW.run.hp = math.min(MaxHp(), BW.run.hp + heal);
UpdateHealth();
elseif item.key == "grenade" then
ThrowGrenade(item);
elseif item.key == "swift" then
BW.hero:PlaySpellVisual(item.spell, BW.hero, 0);
BW.run.swiftUntil = BW.run.time + TUNING.SWIFTNESS_SECONDS;
ApplyHeroSpeed();
elseif item.key == "hearth" then
BW.hero:PlaySpellVisual(item.spell, BW.hero, 0);
for _, gem in ipairs(gems) do
gem.pulled = true;
end
elseif item.key == "sapper" then
Sapper(item, hx, hy);
elseif item.key == "chicken" then
SummonChicken(item, hx, hy, hz);
end
end
-- Whether a Battle Chicken holds this whelp: taunted, and the chicken out.
local function Taunted(e)
return e.taunter and not e.taunter.gone and BW.run.time < e.tauntUntil;
end
local function UpdateItems(dt, hx, hy, hz)
if BW.run.swiftUntil and BW.run.time >= BW.run.swiftUntil then
BW.run.swiftUntil = nil;
ApplyHeroSpeed();
end
local reach2 = TUNING.ITEM_REACH * TUNING.ITEM_REACH;
local scale = BW.scene:GetEffectiveScale();
local bob = math.sin(BW.run.time * 4) * 4;
for i = table.getn(drops), 1, -1 do
local drop = drops[i];
drop.life = drop.life - dt;
if Dist2(drop.x, drop.y, hx, hy) <= reach2 then
local item = drop.item;
ReleaseDrop(i);
UseItem(item, hx, hy, hz);
elseif drop.life <= 0 then
ReleaseDrop(i);
else
local sx, sy = BW.scene:Project3DPointTo2D(drop.x, drop.y, drop.z + 1.2);
if sx then
drop.icon:ClearAllPoints();
drop.icon:SetPoint("BOTTOM", BW.scene, "BOTTOMLEFT", sx / scale, sy / scale + bob);
end
end
end
UpdateChickens(dt, hx, hy, hz);
end
local function ClearItems()
for i = table.getn(drops), 1, -1 do
ReleaseDrop(i);
end
for i = table.getn(chickens), 1, -1 do
chickens[i].gone = true;
chickens[i].prey = nil;
chickens[i].actor:SetMovementEnabled(false);
chickens[i].actor:Hide();
tinsert(chickenPool, chickens[i]);
chickens[i] = nil;
end
end
-- For the files that load after this one.
BW.Taunted = Taunted
BW.UpdateItems = UpdateItems
BW.ClearItems = ClearItems
+814
View File
@@ -0,0 +1,814 @@
-- Broodwatch spells: targets, totems, ground areas, casts and leaps.
local _, BW = ...
local CAST_SPACING = BW.CAST_SPACING
local SPELLS = BW.SPELLS
local PASSIVES = BW.PASSIVES
local HOLY_NOVA_HEAL_TARGETS = BW.HOLY_NOVA_HEAL_TARGETS
local enemies = BW.enemies
local FloorAt = BW.FloorAt
local Dist2 = BW.Dist2
local Flash = BW.Flash
local Damage = BW.Damage
local Blast = BW.Blast
local Hold = BW.Hold
local Fear = BW.Fear
local Weaken = BW.Weaken
local TUNING = BW.TUNING
local Mastered = BW.Mastered
------------------------------------------------------------------------------
-- The hero's spells
local function Stat(def, field, level)
local v = def[field];
v = v and v[level];
if v and BW.run then
if field == "radius" and BW.run.passives.amplify then
v = v * (1 + PASSIVES.amplify.per * BW.run.passives.amplify / 100);
elseif field == "count" and BW.run.passives.multishot then
v = v + BW.run.passives.multishot;
end
end
return v;
end
-- Missile targets: up to `count` live enemies in range, by the spell's rule.
local picked = {}
local function PickTargets(def, count, hx, hy)
for i = table.getn(picked), 1, -1 do
picked[i] = nil;
end
local range2 = def.range * def.range;
for _ = 1, count do
local best, bestScore;
for _, e in ipairs(enemies) do
if e.alive and not e.marked and not (def.kind == "dot" and e.dotLeft) then
local x, y = e.actor:GetPosition();
local d2 = Dist2(x, y, hx, hy);
if d2 <= range2 then
local score;
if def.pick == "nearest" then
score = -d2;
elseif def.pick == "strongest" then
score = e.hp;
else
score = math.random();
end
if not bestScore or score > bestScore then
best, bestScore = e, score;
end
end
end
end
if not best then
break;
end
best.marked = true;
tinsert(picked, best);
end
for _, e in ipairs(picked) do
e.marked = nil;
end
return picked;
end
-- The live enemy nearest to (x, y) within `range` that `skip` allows.
local function Nearest(x, y, range, skip)
local best, bestD2 = nil, range * range;
for _, e in ipairs(enemies) do
if e.alive and not skip(e) then
local ex, ey = e.actor:GetPosition();
local d2 = Dist2(ex, ey, x, y);
if d2 <= bestD2 then
best, bestD2 = e, d2;
end
end
end
return best;
end
------------------------------------------------------------------------------
-- Magma Totems: each stands where it was planted and pulses until its time
-- runs out. A pulse burns every enemy in its radius and shows the impact on
-- up to MAGMA_IMPACTS of them.
local totems = {}
local totemPool = {}
local MAGMA_IMPACTS = 6
local function PlantTotems(def, level)
local hx, hy, hz = BW.hero:GetPosition();
local n = Stat(def, "totems", level);
for k = 1, n do
local totem = table.remove(totemPool);
if not totem then
totem = { actor = BW.scene:AcquireActor() };
totem.actor:SetModelByCreatureDisplayID(def.display);
totem.actor:HookScript("OnSpellVisualImpact", function(_, _, _, castID) BW.OnImpact(nil, nil, nil, castID); end);
end
local a = (k - 1) * 2 * math.pi / n + math.random() * 0.5;
local x, y = hx + 2 * math.cos(a), hy + 2 * math.sin(a);
totem.x, totem.y = x, y;
totem.life = Stat(def, "duration", level);
totem.damage = Stat(def, "damage", level);
totem.radius = Stat(def, "radius", level);
totem.pulseGap = 0.5;
totem.actor:SetPosition(x, y, FloorAt(x, y, hz));
totem.actor:Show();
tinsert(totems, totem);
end
end
local function UpdateTotems(dt)
local def = SPELLS.totem;
for i = table.getn(totems), 1, -1 do
local totem = totems[i];
totem.life = totem.life - dt;
if totem.life <= 0 then
if Mastered("totem") then
-- The eruption: every enemy in reach, at the mastery's multiple.
local r2 = totem.radius * totem.radius;
local shown = 0;
for _, e in ipairs(enemies) do
if e.alive then
local x, y = e.actor:GetPosition();
if Dist2(x, y, totem.x, totem.y) <= r2 then
if shown < MAGMA_IMPACTS then
shown = shown + 1;
totem.actor:PlaySpellVisual(def.pulse, e.actor, 0);
end
Damage(e, totem.damage * def.mastery.burst, def.color);
end
end
end
end
totem.actor:Hide();
tinsert(totemPool, totem);
table.remove(totems, i);
else
totem.pulseGap = totem.pulseGap - dt;
if totem.pulseGap <= 0 then
totem.pulseGap = totem.pulseGap + def.pulseEvery;
local r2 = totem.radius * totem.radius;
local shown = 0;
for _, e in ipairs(enemies) do
if e.alive then
local x, y = e.actor:GetPosition();
if Dist2(x, y, totem.x, totem.y) <= r2 then
if shown < MAGMA_IMPACTS then
shown = shown + 1;
totem.actor:PlaySpellVisual(def.pulse, e.actor, 0);
end
Damage(e, totem.damage, def.color);
end
end
end
end
end
end
end
local function ClearTotems()
for i = table.getn(totems), 1, -1 do
totems[i].actor:Hide();
tinsert(totemPool, totems[i]);
totems[i] = nil;
end
end
------------------------------------------------------------------------------
-- Ground areas: each stands where it landed for its duration and burns every
-- enemy inside it once a second.
local areas = {}
local areaPool = {}
-- Slows an enemy to `factor` of its run speed until a little after the next
-- tick; the step lets it go when that time passes.
local SLOW_SECONDS = 1.2
local function Slow(e, factor, seconds)
e.slowUntil = math.max(e.slowUntil or 0, BW.run.time + (seconds or SLOW_SECONDS));
if e.slowFactor ~= factor then
e.slowFactor = factor;
e.actor:SetMovementSpeed("run", e.kind.speed * factor);
end
end
local function Unslow(e)
e.slowUntil = nil;
e.slowFactor = nil;
e.actor:SetMovementSpeed("run", e.kind.speed);
end
-- The point of the thickest pack in range: of up to 24 enemies in range,
-- the one with the most others within `radius` of it.
local function PackCenter(range, radius)
local hx, hy = BW.hero:GetPosition();
local range2, r2 = range * range, radius * radius;
local best, bestCount = nil, -1;
local tried = 0;
for _, e in ipairs(enemies) do
if e.alive then
local x, y = e.actor:GetPosition();
if Dist2(x, y, hx, hy) <= range2 then
local count = 0;
for _, o in ipairs(enemies) do
if o.alive then
local ox, oy = o.actor:GetPosition();
if Dist2(ox, oy, x, y) <= r2 then
count = count + 1;
end
end
end
if count > bestCount then
best, bestCount = e, count;
end
tried = tried + 1;
if tried >= 24 then
break;
end
end
end
end
if not best then
return nil;
end
local x, y, z = best.actor:GetPosition();
return x, y, z, best;
end
local function PlantArea(key, level, x, y, z)
local def = SPELLS[key];
local area = table.remove(areaPool);
if not area then
area = { actor = BW.scene:AcquireActor() };
end
area.key = key;
area.x, area.y = x, y;
area.radius = Stat(def, "radius", level);
area.damage = Stat(def, "damage", level);
area.life = Stat(def, "duration", level);
area.slow = Stat(def, "slow", level);
area.tick = 1;
area.actor:SetModelBySpellArea(def.spell, area.radius);
area.actor:SetPosition(x, y, FloorAt(x, y, z));
area.actor:Show();
tinsert(areas, area);
end
local function UpdateAreas(dt)
local follow = Mastered("consecration");
for i = table.getn(areas), 1, -1 do
local area = areas[i];
if follow and area.key == "consecration" then
local hx, hy, hz = BW.hero:GetPosition();
area.x, area.y = hx, hy;
area.actor:SetPosition(hx, hy, hz);
end
area.life = area.life - dt;
area.tick = area.tick - dt;
if area.tick <= 0 then
area.tick = area.tick + 1;
local r2 = area.radius * area.radius;
local color = SPELLS[area.key].color;
for _, e in ipairs(enemies) do
if e.alive then
local x, y = e.actor:GetPosition();
if Dist2(x, y, area.x, area.y) <= r2 then
if area.slow then
Slow(e, area.slow);
end
Damage(e, area.damage, color);
end
end
end
end
if area.life <= 0 then
if area.key == "blizzard" and Mastered("blizzard") then
local def = SPELLS.blizzard;
local r2 = area.radius * area.radius;
for _, e in ipairs(enemies) do
if e.alive then
local x, y = e.actor:GetPosition();
if Dist2(x, y, area.x, area.y) <= r2 then
Hold(e, def.mastery.freeze, SPELLS.frostnova.kit, true);
end
end
end
end
area.actor:Hide();
tinsert(areaPool, area);
table.remove(areas, i);
end
end
end
-- A storm aimed at a pack marks the ground with the reticle first
-- (actor:SetGroundReticle on an actor with no model), then lands.
local LANDING_SECONDS = 0.6
local markerPool = {}
local function QueueLanding(key, level, x, y, z)
local marker = table.remove(markerPool);
if not marker then
marker = { actor = BW.scene:AcquireActor() };
end
local floorZ = FloorAt(x, y, z);
marker.actor:SetPosition(x, y, floorZ);
marker.actor:SetGroundReticle(Stat(SPELLS[key], "radius", level));
marker.actor:Show();
tinsert(BW.run.landings, { marker = marker, key = key, level = level, x = x, y = y, z = floorZ, left = LANDING_SECONDS });
end
local function ReleaseMarker(marker)
marker.actor:ClearGroundReticle();
marker.actor:Hide();
tinsert(markerPool, marker);
end
local function UpdateLandings(dt)
for i = table.getn(BW.run.landings), 1, -1 do
local landing = BW.run.landings[i];
landing.left = landing.left - dt;
if landing.left <= 0 then
ReleaseMarker(landing.marker);
PlantArea(landing.key, landing.level, landing.x, landing.y, landing.z);
table.remove(BW.run.landings, i);
end
end
end
local function ClearLandings()
for i = table.getn(BW.run.landings), 1, -1 do
ReleaseMarker(BW.run.landings[i].marker);
BW.run.landings[i] = nil;
end
end
local function ClearAreas()
for i = table.getn(areas), 1, -1 do
areas[i].actor:Hide();
tinsert(areaPool, areas[i]);
areas[i] = nil;
end
end
------------------------------------------------------------------------------
-- Stoneclaw Totems: each pulls the brood around it onto itself (the Battle
-- Chicken's taunt, BW.Taunted) until it crumbles.
local wards = {}
local wardPool = {}
local WARD_PULSE = 0.5 -- seconds between two taunts
local function PlantWard(def, level)
local hx, hy, hz = BW.hero:GetPosition();
local ward = table.remove(wardPool);
if not ward then
ward = { actor = BW.scene:AcquireActor() };
ward.actor:SetModelByCreatureDisplayID(def.display);
end
local a = math.random() * 2 * math.pi;
local x, y = hx + 2.5 * math.cos(a), hy + 2.5 * math.sin(a);
ward.x, ward.y = x, y;
ward.life = Stat(def, "duration", level);
ward.radius = Stat(def, "radius", level);
ward.pulse = 0;
ward.gone = nil;
ward.actor:SetPosition(x, y, FloorAt(x, y, hz));
ward.actor:Show();
tinsert(wards, ward);
end
-- `crumbled`: its time ran out (the mastery's stun), not a run's end.
local function DropWard(i, crumbled)
local ward = wards[i];
if crumbled and Mastered("stoneclaw") then
local r2 = ward.radius * ward.radius;
for _, e in ipairs(enemies) do
if e.alive then
local x, y = e.actor:GetPosition();
if Dist2(x, y, ward.x, ward.y) <= r2 then
Hold(e, SPELLS.stoneclaw.mastery.stun);
end
end
end
end
ward.gone = true;
ward.actor:Hide();
tinsert(wardPool, ward);
table.remove(wards, i);
end
local function UpdateWards(dt)
for i = table.getn(wards), 1, -1 do
local ward = wards[i];
ward.life = ward.life - dt;
if ward.life <= 0 then
DropWard(i, true);
else
ward.pulse = ward.pulse - dt;
if ward.pulse <= 0 then
ward.pulse = ward.pulse + WARD_PULSE;
local r2 = ward.radius * ward.radius;
for _, e in ipairs(enemies) do
if e.alive then
local x, y = e.actor:GetPosition();
if Dist2(x, y, ward.x, ward.y) <= r2 then
e.taunter = ward;
e.tauntUntil = BW.run.time + WARD_PULSE * 2;
end
end
end
end
end
end
end
local function ClearWards()
for i = table.getn(wards), 1, -1 do
DropWard(i);
end
end
------------------------------------------------------------------------------
-- The imp: summoned the first time Summon Imp casts, it stays for the run,
-- follows you and casts Firebolt (the spell's `missile`) at the nearest enemy.
local pet
local function SummonPet(def)
if pet and pet.out then
return pet;
end
if not pet then
pet = { actor = BW.scene:AcquireActor() };
pet.actor:SetModelByCreatureDisplayID(def.display);
pet.actor:HookScript("OnSpellVisualImpact", function(_, _, _, castID) BW.OnImpact(nil, nil, nil, castID); end);
end
local hx, hy, hz = BW.hero:GetPosition();
BW.hero:PlaySpellVisual(def.spell, BW.hero, 0);
pet.actor:SetPosition(hx + 1.5, hy + 1.5, hz + 0.5);
pet.actor:Show();
pet.actor:SetMovementEnabled(true);
pet.actor:SetMovementSpeed("run", TUNING.IMP_SPEED);
pet.out = true;
pet.move = 0;
return pet;
end
local function UpdatePet(dt, hx, hy, hz)
if not (pet and pet.out) then
return;
end
pet.move = pet.move - dt;
if pet.move > 0 then
return;
end
pet.move = 0.4;
local px, py = pet.actor:GetPosition();
if Dist2(px, py, hx, hy) > TUNING.IMP_FOLLOW * TUNING.IMP_FOLLOW then
-- To your side and behind you, as a pet follows.
local a = BW.run.heroYaw + 2.4;
pet.actor:MoveTo(hx + 2 * math.cos(a), hy + 2 * math.sin(a), hz);
end
end
local function ClearPet()
if pet and pet.out then
pet.out = nil;
pet.actor:SetMovementEnabled(false);
pet.actor:Hide();
end
end
local petHit = {}
local function NotPetHit(e)
return petHit[e];
end
local function PetCast(key, def, level)
local imp = SummonPet(def);
local px, py = imp.actor:GetPosition();
local bolts = Mastered(key) and def.mastery.bolts or 1;
local cast = false;
for _ = 1, bolts do
local target = Nearest(px, py, def.range, NotPetHit);
if not target then
break;
end
petHit[target] = true;
local castID = imp.actor:PlaySpellVisual(def.missile, target.actor, 0);
if castID then
BW.run.inFlight[castID] = { key = key, target = target, damage = Stat(def, "damage", level) };
end
cast = true;
end
for e in pairs(petHit) do
petHit[e] = nil;
end
return cast;
end
------------------------------------------------------------------------------
-- Power Word: Shield: the bites soak into it first (the step), and its bubble
-- shows until it breaks or its time ends.
-- `broken`: the bites used it up (the mastery's burst), not its time.
local function EndShield(broken)
if broken and Mastered("shield") then
local m = SPELLS.shield.mastery;
local hx, hy = BW.hero:GetPosition();
local r2 = m.radius * m.radius;
for _, e in ipairs(enemies) do
if e.alive then
local x, y = e.actor:GetPosition();
if Dist2(x, y, hx, hy) <= r2 then
Damage(e, m.damage, SPELLS.shield.color);
Blast(e, hx, hy, m.knock);
end
end
end
end
if BW.run.shield then
BW.run.shield = nil;
BW.run.shieldUntil = nil;
BW.hero:SetSpellVisualKit();
BW.UpdateHealth();
end
end
-- The hero casts one visual at a time; casts wait in a queue.
local function QueueCast(entry)
tinsert(BW.run.castQueue, entry);
end
local function Fire(key, state)
local def = SPELLS[key];
local level = state.level;
local hx, hy = BW.hero:GetPosition();
if def.kind == "missile" or def.kind == "dot" or def.kind == "chain" then
local targets = PickTargets(def, Stat(def, "count", level) or 1, hx, hy);
if table.getn(targets) == 0 then
return false;
end
for _, e in ipairs(targets) do
QueueCast({
key = key, state = state, target = e, damage = Stat(def, "damage", level),
jumps = Stat(def, "jumps", level), level = level,
});
state.pending = state.pending + 1;
end
elseif def.kind == "pet" then
return PetCast(key, def, level);
elseif def.kind == "totem" or def.kind == "ward" or def.kind == "shield" or def.kind == "thorns" then
QueueCast({ key = key, state = state, level = level });
state.pending = state.pending + 1;
elseif def.kind == "area" then
local x, y, z;
if def.place == "pack" then
x, y, z = PackCenter(def.range, Stat(def, "radius", level));
if not x then
return false;
end
else
x, y, z = BW.hero:GetPosition();
end
QueueCast({ key = key, state = state, level = level, x = x, y = y, z = z });
state.pending = state.pending + 1;
else
QueueCast({
key = key, state = state, level = level, damage = Stat(def, "damage", level),
radius = Stat(def, "radius", level), heal = Stat(def, "heal", level),
});
state.pending = state.pending + 1;
end
return true;
end
local function CastNext()
local entry = table.remove(BW.run.castQueue, 1);
if not entry then
return;
end
entry.state.pending = entry.state.pending - 1;
local def = SPELLS[entry.key];
if entry.target then
if not entry.target.alive then
return CastNext();
end
local castID = BW.hero:PlaySpellVisual(def.spell, entry.target.actor, 0);
if castID then
BW.run.inFlight[castID] = entry;
end
elseif def.kind == "totem" then
BW.hero:PlaySpellVisual(def.spell, BW.hero, 0);
PlantTotems(def, entry.level);
elseif def.kind == "ward" then
BW.hero:PlaySpellVisual(def.spell, BW.hero, 0);
PlantWard(def, entry.level);
elseif def.kind == "shield" then
BW.hero:PlaySpellVisual(def.spell, BW.hero, 0);
BW.run.shield = BW.MaxHp() * Stat(def, "absorb", entry.level) / 100;
BW.run.shieldUntil = BW.run.time + Stat(def, "duration", entry.level);
BW.hero:SetSpellVisualKit(def.kit);
BW.UpdateHealth();
elseif def.kind == "thorns" then
BW.hero:PlaySpellVisual(def.spell, BW.hero, 0);
BW.run.thornsUntil = BW.run.time + Stat(def, "duration", entry.level);
BW.run.thornsDamage = Stat(def, "damage", entry.level);
BW.run.thornsTick = 1;
elseif def.kind == "area" then
BW.hero:PlaySpellVisual(def.spell, BW.hero, 0);
if def.place == "pack" then
QueueLanding(entry.key, entry.level, entry.x, entry.y, entry.z);
if entry.key == "rainoffire" and Mastered("rainoffire") then
local hx, hy = BW.hero:GetPosition();
local other = PickTargets(def, 1, hx, hy)[1];
if other then
local x, y, z = other.actor:GetPosition();
QueueLanding(entry.key, entry.level, x, y, z);
end
end
else
PlantArea(entry.key, entry.level, entry.x, entry.y, entry.z);
end
else
BW.hero:PlaySpellVisual(def.spell, BW.hero, 0);
local hx, hy = BW.hero:GetPosition();
local r2 = entry.radius * entry.radius;
local root = Stat(def, "root", entry.level);
local fear = Stat(def, "fear", entry.level);
local weaken = Stat(def, "weaken", entry.level);
local mastery = Mastered(entry.key) and def.mastery;
local hits = 0;
for _, e in ipairs(enemies) do
if e.alive then
local x, y = e.actor:GetPosition();
if Dist2(x, y, hx, hy) <= r2 then
if entry.damage then
Damage(e, entry.damage, def.color);
end
if e.alive then
if root then
Hold(e, root, def.kit);
end
if fear then
Fear(e, fear, def.kit);
end
if weaken then
Weaken(e, weaken, Stat(def, "duration", entry.level));
end
end
if def.knock then
Blast(e, hx, hy, def.knock);
end
if mastery then
if mastery.slow and e.alive then
Slow(e, mastery.slow, mastery.seconds);
end
if mastery.knock then
Blast(e, hx, hy, mastery.knock);
end
end
hits = hits + 1;
end
end
end
if entry.heal and hits > 0 then
BW.run.pendingHeal = BW.run.pendingHeal + entry.heal * math.min(hits, HOLY_NOVA_HEAL_TARGETS);
end
-- Arcane Explosion's Echo bursts again (once); the shout's battle cry
-- speeds you while it lasts.
if mastery and entry.key == "nova" and not entry.echo then
QueueCast({
key = entry.key, state = entry.state, level = entry.level, damage = entry.damage,
radius = entry.radius, echo = true,
});
entry.state.pending = entry.state.pending + 1;
end
if mastery and entry.key == "demoshout" then
BW.run.shoutUntil = BW.run.time + Stat(def, "duration", entry.level);
BW.ApplyHeroSpeed();
end
end
BW.run.castGap = CAST_SPACING;
end
-- Puts Corruption on an enemy: `damage` each second for `seconds`, and it
-- spreads to `spread` neighbours when the enemy dies.
local function Corrupt(e, damage, seconds, spread)
e.dotLeft = seconds;
e.dotTick = 1;
e.dotDamage = damage;
e.dotSeconds = seconds;
e.dotSpread = spread;
Flash(e);
end
-- Chain Lightning leaps on from the enemy it hit: that enemy casts the next
-- leap at its nearest neighbour not yet hit.
local chainHit
local function NotChainHit(e)
return chainHit[e];
end
local function Leap(entry)
if not entry.jumps or entry.jumps <= 0 then
return;
end
entry.hit = entry.hit or {};
entry.hit[entry.target] = true;
local from = entry.target;
local fx, fy = from.actor:GetPosition();
chainHit = entry.hit;
local nextTarget = Nearest(fx, fy, SPELLS[entry.key].hop, NotChainHit);
chainHit = nil;
if not nextTarget then
return;
end
local castID = from.actor:PlaySpellVisual(SPELLS[entry.key].spell, nextTarget.actor, 0);
if castID then
BW.run.inFlight[castID] = {
key = entry.key, target = nextTarget, jumps = entry.jumps - 1, hit = entry.hit,
damage = Mastered(entry.key) and entry.damage * SPELLS[entry.key].mastery.grow or entry.damage,
};
end
end
BW.OnImpact = function(_, _, _, castID)
if not BW.run then
return;
end
local entry = BW.run.inFlight[castID];
if not entry then
return;
end
BW.run.inFlight[castID] = nil;
if entry.onImpact then
entry.onImpact(entry);
return;
end
local def = SPELLS[entry.key];
if def.kind == "dot" then
if entry.target.alive then
Corrupt(entry.target, entry.damage, Stat(def, "duration", entry.level), Stat(def, "spread", entry.level));
end
return;
end
if def.kind == "chain" then
Leap(entry);
end
local target = entry.target;
local wasAlive = target.alive;
Damage(target, entry.damage, def.color);
if not Mastered(entry.key) or not def.mastery then
return;
end
local m = def.mastery;
if entry.key == "frostbolt" and target.alive then
Hold(target, m.freeze, SPELLS.frostnova.kit, true);
elseif entry.key == "shadowbolt" and wasAlive and not target.alive then
local tx, ty = target.actor:GetPosition();
local r2 = m.radius * m.radius;
for _, e in ipairs(enemies) do
if e.alive then
local x, y = e.actor:GetPosition();
if Dist2(x, y, tx, ty) <= r2 then
Damage(e, entry.damage * m.share, def.color);
end
end
end
elseif entry.key == "lightning" and not entry.overload and math.random() < m.chance then
local hx, hy = BW.hero:GetPosition();
local other = PickTargets(def, 1, hx, hy)[1];
local state = BW.run.spells[entry.key];
if other and state then
QueueCast({ key = entry.key, state = state, target = other, damage = entry.damage, level = entry.level, overload = true });
state.pending = state.pending + 1;
end
end
end
-- For the files that load after this one.
BW.UpdateWards = UpdateWards
BW.ClearWards = ClearWards
BW.UpdatePet = UpdatePet
BW.ClearPet = ClearPet
BW.EndShield = EndShield
BW.Stat = Stat
BW.Nearest = Nearest
BW.UpdateTotems = UpdateTotems
BW.ClearTotems = ClearTotems
BW.Unslow = Unslow
BW.PackCenter = PackCenter
BW.UpdateAreas = UpdateAreas
BW.markerPool = markerPool
BW.ReleaseMarker = ReleaseMarker
BW.UpdateLandings = UpdateLandings
BW.ClearLandings = ClearLandings
BW.ClearAreas = ClearAreas
BW.Fire = Fire
BW.CastNext = CastNext
BW.Corrupt = Corrupt
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
## Interface: 11200
## Title: UI Debug Tools
## Notes: Tools for developing addons (backport of Blizzard_DebugTools 3.3.5 to 1.12.1 / Lua 5.0)
## Author: Blizzard Entertainment, backported
## Special Thanks: Iriel, Kirov, Esamynn
## Version: 1.0
## SavedVariables: DebugToolsSavedVars
Dump.lua
DebugTools.lua
TableInspector.lua
DebugTools.xml
-598
View File
@@ -1,598 +0,0 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.blizzard.com/wow/ui/ ..\FrameXML\UI.xsd">
<!-- ================================================================ -->
<!-- EventTrace -->
<!-- ================================================================ -->
<Button name="EventTraceEventTemplate" virtual="true">
<Size x="280" y="16"/>
<Layers>
<Layer level="ARTWORK">
<FontString name="$parentTime" inherits="NumberFontNormalSmall" justifyH="RIGHT">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="TOPRIGHT" relativePoint="TOPLEFT">
<Offset x="62" y="0"/>
</Anchor>
<Anchor point="BOTTOM"/>
</Anchors>
</FontString>
<FontString name="$parentEvent" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Anchors>
<Anchor point="TOPRIGHT"/>
<Anchor point="TOPLEFT">
<Offset x="70" y="0"/>
</Anchor>
<Anchor point="BOTTOM"/>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>
this:GetHighlightTexture():SetAlpha(.15);
</OnLoad>
<OnEnter>EventTraceFrameEvent_OnEnter(this);</OnEnter>
<OnLeave>EventTraceFrameEvent_OnLeave(this);</OnLeave>
<OnClick>EventTraceFrameEvent_OnClick(this);</OnClick>
</Scripts>
<HighlightTexture file="Interface\Buttons\UI-Common-MouseHilight" setAllPoints="true" alphaMode="ADD">
<Color r=".8" g=".8" b="1" a="1"/>
</HighlightTexture>
</Button>
<Frame name="EventTraceFrame" parent="UIParent" movable="true" hidden="true" frameStrata="MEDIUM" toplevel="true">
<Size x="306" y="484"/>
<Anchors>
<Anchor point="LEFT">
<Offset x="64" y="0"/>
</Anchor>
</Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\Tooltips\UI-Tooltip-Border" tile="true">
<BackgroundInsets>
<AbsInset left="5" right="5" top="5" bottom="5"/>
</BackgroundInsets>
<TileSize>
<AbsValue val="16"/>
</TileSize>
<EdgeSize>
<AbsValue val="16"/>
</EdgeSize>
</Backdrop>
<Layers>
<!-- ARTWORK, not BACKGROUND: the Backdrop draws its own
background into BACKGROUND after the textures declared here,
which would bury this bar. The title text is in OVERLAY, so
it still reads over the top. -->
<Layer level="ARTWORK">
<Texture name="$parentTitleBG" file="Interface\QuestFrame\UI-QuestLogTitleHighlight">
<Size x="280" y="20"/>
<Anchors>
<Anchor point="TOP">
<Offset x="0" y="-6"/>
</Anchor>
</Anchors>
<Color r=".6" g=".6" b=".6" a=".4"/>
</Texture>
</Layer>
<Layer level="OVERLAY">
<FontString name="$parentTitle" inherits="GameFontNormal" text="Events">
<Anchors>
<Anchor point="TOP">
<Offset x="0" y="-8"/>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Button name="$parentCloseButton" inherits="UIPanelCloseButton">
<Anchors>
<Anchor point="TOPRIGHT">
<Offset x="-2" y="-2"/>
</Anchor>
</Anchors>
</Button>
<Frame name="$parentTitleButton">
<Size x="280" y="20"/>
<Anchors>
<Anchor point="TOP">
<Offset x="0" y="-6"/>
</Anchor>
</Anchors>
<Scripts>
<OnLoad>
this:RegisterForDrag("LeftButton");
</OnLoad>
<OnDragStart>
EventTraceFrame.moving = true;
EventTraceFrame:StartMoving();
</OnDragStart>
<OnDragStop>
EventTraceFrame.moving = nil;
EventTraceFrame:StopMovingOrSizing();
</OnDragStop>
</Scripts>
</Frame>
<!-- Pushed down 26px (was y=-28) to make room for the
toolbar row of Pause + Args/Time toggles + Search. -->
<Slider name="$parentScroll" orientation="VERTICAL">
<Size x="16" y="0"/>
<Anchors>
<Anchor point="TOPRIGHT">
<Offset x="-7" y="-54"/>
</Anchor>
<Anchor point="BOTTOMRIGHT">
<Offset x="-7" y="10"/>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentBG" setAllPoints="true">
<Color r=".8" g=".8" b="1" a="0.1"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnLoad>
this:SetFrameLevel(this:GetFrameLevel() + 1);
this:SetMinMaxValues(0, 1);
this:SetValue(0);
this:SetValueStep(1);
</OnLoad>
<OnValueChanged>
EventTraceFrame_Update();
</OnValueChanged>
</Scripts>
<ThumbTexture name="$parentThumb" file="Interface\Buttons\UI-ScrollBar-Knob">
<Size x="16" y="16"/>
<TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/>
</ThumbTexture>
</Slider>
<!-- ===== Toolbar (Pause + Args/Time toggles + Search) ===== -->
<Button name="$parentPauseButton" inherits="UIPanelButtonTemplate" text="Pause">
<Size x="60" y="20"/>
<Anchors>
<Anchor point="TOPLEFT">
<Offset x="12" y="-30"/>
</Anchor>
</Anchors>
<Scripts>
<OnClick>EventTraceFrame_TogglePause();</OnClick>
</Scripts>
</Button>
<!-- `UICheckButtonTemplate`'s `$parentText` FontString is
auto-positioned at the right of the box. Vanilla 1.12
doesn't read a `text=` XML attribute (that came in
TBC); set it from OnLoad via the global lookup
instead. The earlier approach of separate sibling
FontStrings in the parent's `<Layers>` block fell
over because anchors that reference later-defined
siblings ($parentArgsCheck etc.) resolve to (0,0)
when the anchor is computed before the target exists. -->
<!-- Initial checked state is set here from defaults so the
first paint matches `_EventTraceFrame.showArguments`
/ `showTimestamp`. `_ApplySavedVars` later overrides
from `DebugToolsSavedVars` once ADDON_LOADED fires;
if the user toggled the box to a non-default value
in a prior session, the second SetChecked call wins. -->
<CheckButton name="$parentArgsCheck" inherits="UICheckButtonTemplate">
<Size x="20" y="20"/>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentPauseButton" relativePoint="RIGHT">
<Offset x="4" y="0"/>
</Anchor>
</Anchors>
<Scripts>
<OnLoad>
getglobal(this:GetName() .. "Text"):SetText("Args");
this:SetChecked(nil); -- showArguments default = false
</OnLoad>
<OnClick>EventTraceFrame_ToggleArgs(this:GetChecked());</OnClick>
</Scripts>
</CheckButton>
<CheckButton name="$parentTimeCheck" inherits="UICheckButtonTemplate">
<Size x="20" y="20"/>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentArgsCheck" relativePoint="RIGHT">
<Offset x="40" y="0"/>
</Anchor>
</Anchors>
<Scripts>
<OnLoad>
getglobal(this:GetName() .. "Text"):SetText("Time");
this:SetChecked(1); -- showTimestamp default = true
</OnLoad>
<OnClick>EventTraceFrame_ToggleTimestamp(this:GetChecked());</OnClick>
</Scripts>
</CheckButton>
<EditBox name="$parentSearch" inherits="InputBoxTemplate" autoFocus="false">
<Size x="100" y="20"/>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentTimeCheck" relativePoint="RIGHT">
<Offset x="48" y="0"/>
</Anchor>
</Anchors>
<Scripts>
<OnTextChanged>EventTraceFrame_OnSearchChanged(this);</OnTextChanged>
<!-- Escape unfocuses but preserves text — clearing is
the X button's job. Modern WoW behaves the same. -->
<OnEscapePressed>this:ClearFocus();</OnEscapePressed>
</Scripts>
<Frames>
<!-- Embedded clear button: anchored inside the editbox
at the right edge, visible only while there's text.
Declared as a child so sibling z-order puts it
above the editbox's own backdrop textures. -->
<Button name="$parentClear" hidden="true">
<Size x="14" y="14"/>
<Anchors>
<Anchor point="RIGHT">
<Offset x="-4" y="0"/>
</Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<FontString name="$parentLabel" inherits="GameFontNormalSmall" text="X">
<Anchors>
<Anchor point="CENTER"/>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnClick>
local eb = getglobal("EventTraceFrameSearch");
eb:SetText("");
eb:ClearFocus();
</OnClick>
</Scripts>
<HighlightTexture file="Interface\Buttons\UI-Common-MouseHilight" setAllPoints="true" alphaMode="ADD">
<Color r="1" g="1" b="1" a=".5"/>
</HighlightTexture>
</Button>
</Frames>
</EditBox>
</Frames>
<Scripts>
<OnLoad>EventTraceFrame_OnLoad(this);</OnLoad>
<OnEvent>EventTraceFrame_OnEvent(this, event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12, arg13, arg14, arg15, arg16, arg17, arg18, arg19);</OnEvent>
<OnUpdate>EventTraceFrame_OnUpdate(this, arg1);</OnUpdate>
</Scripts>
</Frame>
<!-- ================================================================ -->
<!-- ScriptErrorsFrame -->
<!-- ================================================================ -->
<Frame name="ScriptErrorsFrame" parent="UIParent" movable="true" hidden="true" toplevel="true" frameStrata="DIALOG" enableMouse="true">
<Size x="384" y="260"/>
<Anchors>
<Anchor point="CENTER"/>
</Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<BackgroundInsets>
<AbsInset left="11" right="12" top="12" bottom="11"/>
</BackgroundInsets>
<TileSize>
<AbsValue val="32"/>
</TileSize>
<EdgeSize>
<AbsValue val="32"/>
</EdgeSize>
</Backdrop>
<Layers>
<!-- ARTWORK, not BACKGROUND: the Backdrop above draws its
background and border into BACKGROUND, and it is applied
after the textures declared here, so a header in that layer
ends up behind the window border. Every Blizzard frame that
uses UI-DialogBox-Header puts it in ARTWORK for this reason. -->
<Layer level="ARTWORK">
<Texture name="$parentTitleBG" file="Interface\DialogFrame\UI-DialogBox-Header">
<Size x="256" y="64"/>
<Anchors>
<Anchor point="TOP">
<Offset x="0" y="12"/>
</Anchor>
</Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<FontString name="$parentTitle" inherits="GameFontNormal" text="Lua Error">
<Anchors>
<Anchor point="TOP">
<Offset x="0" y="-2"/>
</Anchor>
</Anchors>
</FontString>
<FontString name="$parentIndexLabel" inherits="GameFontNormal" justifyH="CENTER">
<Size x="70" y="16"/>
<Anchors>
<Anchor point="BOTTOM">
<Offset x="0" y="20"/>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Frame name="$parentTitleButton">
<Size x="256" y="20"/>
<Anchors>
<Anchor point="TOP">
<Offset x="0" y="0"/>
</Anchor>
</Anchors>
<Scripts>
<OnLoad>
this:RegisterForDrag("LeftButton");
</OnLoad>
<OnDragStart>ScriptErrorsFrame_TitleButton_OnDragStart();</OnDragStart>
<OnDragStop>ScriptErrorsFrame_TitleButton_OnDragStop();</OnDragStop>
</Scripts>
</Frame>
<!-- Width leaves room for the scroll bar inside the border.
UIPanelScrollFrameTemplate hangs its bar off this frame's
right edge (+6, 16 wide, button art a little wider), so the
bar ends at about right + 23. The frame's interior margin is
368, where the Close button ends, which puts the widest this
can be at 328. Blizzard's own numbers do not carry over here:
its ScriptErrorsFrame inherits UIPanelDialogTemplate, a thin
GearManager border, not this 32px dialog backdrop. -->
<ScrollFrame name="$parentScrollFrame" inherits="UIPanelScrollFrameTemplate">
<Size x="328" y="170"/>
<Anchors>
<Anchor point="TOPLEFT">
<Offset x="16" y="-32"/>
</Anchor>
</Anchors>
<ScrollChild>
<EditBox name="$parentText" multiLine="true" letters="4000" autoFocus="false">
<Size x="328" y="170"/>
<Scripts>
<OnEscapePressed>this:ClearFocus();</OnEscapePressed>
<OnEditFocusGained>this:HighlightText(0);</OnEditFocusGained>
</Scripts>
<FontString inherits="GameFontHighlightSmall"/>
</EditBox>
</ScrollChild>
</ScrollFrame>
<Button name="$parentPrevious" inherits="UIPanelButtonTemplate" text="Previous" id="2">
<Size x="96" y="24"/>
<Anchors>
<Anchor point="BOTTOMLEFT">
<Offset x="16" y="16"/>
</Anchor>
</Anchors>
<Scripts>
<OnClick>ScriptErrorsFrameButton_OnClick(this);</OnClick>
</Scripts>
</Button>
<Button name="$parentNext" inherits="UIPanelButtonTemplate" text="Next" id="1">
<Size x="96" y="24"/>
<Anchors>
<Anchor point="BOTTOMLEFT">
<Offset x="116" y="16"/>
</Anchor>
</Anchors>
<Scripts>
<OnClick>ScriptErrorsFrameButton_OnClick(this);</OnClick>
</Scripts>
</Button>
<Button name="$parentClose" inherits="UIPanelButtonTemplate" text="Close">
<Size x="96" y="24"/>
<Anchors>
<Anchor point="BOTTOMRIGHT">
<Offset x="-16" y="16"/>
</Anchor>
</Anchors>
<Scripts>
<OnClick>ScriptErrorsFrame:Hide();</OnClick>
</Scripts>
</Button>
</Frames>
<Scripts>
<OnLoad>ScriptErrorsFrame_OnLoad(this);</OnLoad>
<OnShow>ScriptErrorsFrame_OnShow(this);</OnShow>
</Scripts>
</Frame>
<!-- ================================================================ -->
<!-- TableInspector -->
<!-- ================================================================ -->
<!-- Per-row button shared by every visible entry in the inspector.
Two FontStrings: key column on the left, value column on the
right. Highlight texture marks the hover row; click drills if
the value is a table. -->
<Button name="TableInspectorRowTemplate" virtual="true">
<Size x="440" y="16"/>
<Layers>
<Layer level="ARTWORK">
<FontString name="$parentKey" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size x="160" y="16"/>
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMLEFT"/>
</Anchors>
</FontString>
<FontString name="$parentValue" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentKey" relativePoint="TOPRIGHT">
<Offset x="6" y="0"/>
</Anchor>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>
this:GetHighlightTexture():SetAlpha(.15);
</OnLoad>
<OnClick>TableInspectorRow_OnClick(this);</OnClick>
</Scripts>
<HighlightTexture file="Interface\Buttons\UI-Common-MouseHilight" setAllPoints="true" alphaMode="ADD">
<Color r=".8" g=".8" b="1" a="1"/>
</HighlightTexture>
</Button>
<Frame name="TableInspectorFrame" parent="UIParent" movable="true" hidden="true" frameStrata="MEDIUM" toplevel="true">
<Size x="480" y="464"/>
<Anchors>
<Anchor point="CENTER"/>
</Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\Tooltips\UI-Tooltip-Border" tile="true">
<BackgroundInsets>
<AbsInset left="5" right="5" top="5" bottom="5"/>
</BackgroundInsets>
<TileSize>
<AbsValue val="16"/>
</TileSize>
<EdgeSize>
<AbsValue val="16"/>
</EdgeSize>
</Backdrop>
<Layers>
<!-- ARTWORK, not BACKGROUND: see the same note on
EventTraceFrame above. -->
<Layer level="ARTWORK">
<Texture name="$parentTitleBG" file="Interface\QuestFrame\UI-QuestLogTitleHighlight">
<Size x="454" y="20"/>
<Anchors>
<Anchor point="TOP">
<Offset x="0" y="-6"/>
</Anchor>
</Anchors>
<Color r=".6" g=".6" b=".6" a=".4"/>
</Texture>
</Layer>
<Layer level="OVERLAY">
<FontString name="$parentTitle" inherits="GameFontNormal" text="Table Inspector">
<Anchors>
<Anchor point="TOP">
<Offset x="0" y="-8"/>
</Anchor>
</Anchors>
</FontString>
<FontString name="$parentBreadcrumb" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size x="300" y="16"/>
<Anchors>
<Anchor point="TOPLEFT">
<Offset x="156" y="-34"/>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Button name="$parentCloseButton" inherits="UIPanelCloseButton">
<Anchors>
<Anchor point="TOPRIGHT">
<Offset x="-2" y="-2"/>
</Anchor>
</Anchors>
<Scripts>
<OnClick>TableInspector_Close();</OnClick>
</Scripts>
</Button>
<Frame name="$parentTitleButton">
<Size x="280" y="20"/>
<Anchors>
<Anchor point="TOP">
<Offset x="0" y="-6"/>
</Anchor>
</Anchors>
<Scripts>
<OnLoad>
this:RegisterForDrag("LeftButton");
</OnLoad>
<OnDragStart>
TableInspectorFrame:StartMoving();
</OnDragStart>
<OnDragStop>
TableInspectorFrame:StopMovingOrSizing();
</OnDragStop>
</Scripts>
</Frame>
<Button name="$parentBackButton" inherits="UIPanelButtonTemplate" text="&lt; Back">
<Size x="60" y="20"/>
<Anchors>
<Anchor point="TOPLEFT">
<Offset x="12" y="-32"/>
</Anchor>
</Anchors>
<Scripts>
<OnClick>TableInspector_Back();</OnClick>
</Scripts>
</Button>
<Button name="$parentRefreshButton" inherits="UIPanelButtonTemplate" text="Refresh">
<Size x="70" y="20"/>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentBackButton" relativePoint="RIGHT">
<Offset x="4" y="0"/>
</Anchor>
</Anchors>
<Scripts>
<OnClick>TableInspector_Refresh();</OnClick>
</Scripts>
</Button>
<Slider name="$parentScroll" orientation="VERTICAL">
<Size x="16" y="0"/>
<Anchors>
<Anchor point="TOPRIGHT">
<Offset x="-7" y="-56"/>
</Anchor>
<Anchor point="BOTTOMRIGHT">
<Offset x="-7" y="10"/>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentBG" setAllPoints="true">
<Color r=".8" g=".8" b="1" a="0.1"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnLoad>
this:SetFrameLevel(this:GetFrameLevel() + 1);
this:SetMinMaxValues(0, 1);
this:SetValue(0);
this:SetValueStep(1);
</OnLoad>
<OnValueChanged>
TableInspectorFrame_Update();
</OnValueChanged>
</Scripts>
<ThumbTexture name="$parentThumb" file="Interface\Buttons\UI-ScrollBar-Knob">
<Size x="16" y="16"/>
<TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/>
</ThumbTexture>
</Slider>
</Frames>
<Scripts>
<OnLoad>TableInspectorFrame_OnLoad(this);</OnLoad>
</Scripts>
</Frame>
<!-- ================================================================ -->
<!-- Tooltips -->
<!-- ================================================================ -->
<GameTooltip name="FrameStackTooltip" frameStrata="TOOLTIP" hidden="true" parent="UIParent" inherits="GameTooltipTemplate">
<Scripts>
<OnLoad>DebugTooltip_OnLoad(this);</OnLoad>
<OnShow>FrameStackTooltip_OnShow(this);</OnShow>
<OnEnter>FrameStackTooltip_OnEnter(this);</OnEnter>
<OnUpdate>FrameStackTooltip_OnUpdate(this, arg1);</OnUpdate>
</Scripts>
</GameTooltip>
<GameTooltip name="EventTraceTooltip" frameStrata="TOOLTIP" hidden="true" parent="EventTraceFrame" inherits="GameTooltipTemplate">
<Scripts>
<OnLoad>DebugTooltip_OnLoad(this);</OnLoad>
</Scripts>
</GameTooltip>
</Ui>
-335
View File
@@ -1,335 +0,0 @@
------------------------------------------------------------------------------
-- TableInspector.lua
--
-- Vanilla 1.12 backport of the modern Blizzard_TableInspector UX,
-- scoped to the "minimal" feature set: one navigable window with a
-- breadcrumb showing the current path, a scrollable list of (key,
-- value) rows for the current table, and click-to-drill / Back-to-pop
-- navigation through sub-tables.
--
-- Trade-offs vs the modern source:
-- * No DataProvider / ScrollBox infrastructure — we use the same
-- fixed-row + slider pattern EventTraceFrame uses (vanilla
-- FauxScrollFrame works too but we already validated the slider
-- approach in this addon).
-- * No anchor / attribute providers, no live polling — open once
-- against the current snapshot.
-- * No multi-tab or pinning — one window, navigate via Back.
--
-- API:
-- TableInspector_Show(tbl, label) -- open with `tbl` as root, `label` shown in breadcrumb
-- TableInspector_Push(tbl, label) -- drill in (called by row click)
-- TableInspector_Back() -- pop one level
-- TableInspector_Refresh() -- re-read the current level's live table (scroll kept)
-- TableInspector_Close() -- hide the window
-- /tinspect <luaexpr> -- evaluate `<luaexpr>` as Lua and open if result is a table
------------------------------------------------------------------------------
TABLE_INSPECTOR_ROW_HEIGHT = 16;
TABLE_INSPECTOR_NUM_ROWS = 22;
local _TableInspectorFrame;
-- Navigation stack — `{ {tbl=..., label=...}, ... }`. The last entry
-- is the active view. Push when drilling in, pop on Back.
local _navStack = {};
------------------------------------------------------------------------------
-- Key + value formatting
------------------------------------------------------------------------------
-- Returns three lists: numeric keys (sorted ascending), string keys
-- (sorted), and other-type keys (booleans, tables-as-keys, etc.).
-- The other-type bucket stays unsorted — there's no canonical order
-- across types and these are rare in practice.
local function _SortedKeys(tbl)
local nums, strs, others = {}, {}, {};
for k in pairs(tbl) do
local t = type(k);
if (t == "number") then
table.insert(nums, k);
elseif (t == "string") then
table.insert(strs, k);
else
table.insert(others, k);
end
end
table.sort(nums);
table.sort(strs);
return nums, strs, others;
end
-- Returns (displayText, valueType, isDrillable). `isDrillable` is true
-- only for tables (the only type that can be navigated into).
local function _FormatValue(v)
local t = type(v);
if (t == "string") then
-- Truncate very long strings to keep rows scannable; user can
-- always tooltip-hover for the full content if needed.
if (string.len(v) > 80) then
return string.format('"%s..."', string.sub(v, 1, 77)), "string", false;
end
return string.format('"%s"', v), "string", false;
elseif (t == "number") then
return tostring(v), "number", false;
elseif (t == "boolean") then
return v and "true" or "false", "boolean", false;
elseif (t == "table") then
return tostring(v), "table", true;
elseif (t == "function") then
return tostring(v), "function", false;
elseif (t == "userdata") then
return tostring(v), "userdata", false;
end
return tostring(v), t, false;
end
local _TYPE_COLORS = {
["string"] = { 1.00, 0.75, 0.40 },
["number"] = { 0.60, 0.85, 1.00 },
["boolean"] = { 0.70, 0.70, 1.00 },
["table"] = { 0.60, 1.00, 0.60 },
["function"] = { 1.00, 0.60, 0.60 },
["userdata"] = { 1.00, 0.60, 0.60 },
};
local function _ValueColor(t)
local c = _TYPE_COLORS[t];
if (c) then return c[1], c[2], c[3]; end
return 1, 1, 1;
end
local function _FormatKey(k)
local t = type(k);
if (t == "string") then return k; end
if (t == "number") then return string.format("[%d]", k); end
return string.format("[%s]", tostring(k));
end
------------------------------------------------------------------------------
-- View state — manages the flattened (key, value) list for the active
-- table. Rebuilt on push / pop, not on every Update tick.
------------------------------------------------------------------------------
-- Cached array of (key, value) tuples for the active table, in the
-- sort order returned by _SortedKeys. Re-derived on every navigation
-- change so the rows can be rendered by simple `entries[scroll + i]`
-- indexing instead of walking `pairs()` mid-frame.
local _entries = {};
local function _RebuildEntries()
_entries = {};
local top = _navStack[table.getn(_navStack)];
if (not top or type(top.tbl) ~= "table") then return; end
local nums, strs, others = _SortedKeys(top.tbl);
for i = 1, table.getn(nums) do
local k = nums[i];
table.insert(_entries, { key = k, value = top.tbl[k] });
end
for i = 1, table.getn(strs) do
local k = strs[i];
table.insert(_entries, { key = k, value = top.tbl[k] });
end
for i = 1, table.getn(others) do
local k = others[i];
table.insert(_entries, { key = k, value = top.tbl[k] });
end
end
local function _Breadcrumb()
local parts = {};
for i = 1, table.getn(_navStack) do
table.insert(parts, _navStack[i].label or "?");
end
return table.concat(parts, " > ");
end
------------------------------------------------------------------------------
-- Public API
------------------------------------------------------------------------------
function TableInspector_Show(tbl, label)
if (type(tbl) ~= "table") then
DEFAULT_CHAT_FRAME:AddMessage(
"|cffff8888TableInspector: argument is not a table|r");
return;
end
_navStack = { { tbl = tbl, label = label or tostring(tbl) } };
_RebuildEntries();
if (_TableInspectorFrame) then
_TableInspectorFrame:Show();
TableInspectorFrame_Update();
end
end
function TableInspector_Push(tbl, label)
if (type(tbl) ~= "table") then return; end
table.insert(_navStack, { tbl = tbl, label = label or tostring(tbl) });
_RebuildEntries();
-- Reset scroll to top so the user starts at the beginning of the
-- newly-drilled table.
local scroll = getglobal("TableInspectorFrameScroll");
if (scroll) then scroll:SetValue(0); end
TableInspectorFrame_Update();
end
function TableInspector_Back()
if (table.getn(_navStack) <= 1) then
TableInspector_Close();
return;
end
table.remove(_navStack);
_RebuildEntries();
local scroll = getglobal("TableInspectorFrameScroll");
if (scroll) then scroll:SetValue(0); end
TableInspectorFrame_Update();
end
function TableInspector_Refresh()
local top = _navStack[table.getn(_navStack)];
if (not top or type(top.tbl) ~= "table") then return; end
_RebuildEntries();
TableInspectorFrame_Update();
end
function TableInspector_Close()
if (_TableInspectorFrame) then _TableInspectorFrame:Hide(); end
_navStack = {};
_entries = {};
end
------------------------------------------------------------------------------
-- Frame scripts (called from TableInspector XML in DebugTools.xml)
------------------------------------------------------------------------------
function TableInspectorFrame_OnLoad(self)
self.rows = {};
_TableInspectorFrame = self;
self:EnableMouse(true);
self:EnableMouseWheel(true);
self:SetScript("OnMouseWheel", function()
TableInspectorFrame_OnMouseWheel(this, arg1);
end);
for i = 1, TABLE_INSPECTOR_NUM_ROWS do
local row = CreateFrame("Button", "TableInspectorFrameRow" .. i, self,
"TableInspectorRowTemplate");
row:SetPoint("TOPLEFT", 12, -(56 + (i - 1) * TABLE_INSPECTOR_ROW_HEIGHT));
row:SetPoint("RIGHT", -28, 0);
row.rowIndex = i;
table.insert(self.rows, row);
end
end
function TableInspectorFrame_Update()
if (not _TableInspectorFrame or not _TableInspectorFrame.rows) then return; end
local scroll = getglobal("TableInspectorFrameScroll");
if (not scroll) then return; end
local total = table.getn(_entries);
local visible = TABLE_INSPECTOR_NUM_ROWS;
local maxOffset = math.max(0, total - visible);
scroll:SetMinMaxValues(0, maxOffset);
local offset = math.floor(scroll:GetValue() + 0.5);
if (offset > maxOffset) then offset = maxOffset; end
if (offset < 0) then offset = 0; end
-- Update breadcrumb each tick — cheap (string concat over a tiny
-- nav stack) and avoids a separate "breadcrumb changed" signal.
local crumb = getglobal("TableInspectorFrameBreadcrumb");
if (crumb) then
crumb:SetText(_Breadcrumb() .. string.format(" (%d entries)", total));
end
for i = 1, visible do
local row = _TableInspectorFrame.rows[i];
local entry = _entries[offset + i];
if (entry) then
local keyFS = getglobal(row:GetName() .. "Key");
local valFS = getglobal(row:GetName() .. "Value");
keyFS:SetText(_FormatKey(entry.key));
local disp, vt, drillable = _FormatValue(entry.value);
valFS:SetText(disp);
valFS:SetTextColor(_ValueColor(vt));
row.entryKey = entry.key;
row.entryValue = entry.value;
row.drillable = drillable;
row:Show();
else
row.entryKey = nil;
row.entryValue = nil;
row.drillable = false;
row:Hide();
end
end
end
function TableInspectorFrame_OnMouseWheel(self, delta)
local scroll = getglobal("TableInspectorFrameScroll");
if (not scroll) then return; end
local mn, mx = scroll:GetMinMaxValues();
local v = scroll:GetValue() - delta * 3;
if (v < mn) then v = mn; end
if (v > mx) then v = mx; end
scroll:SetValue(v);
end
function TableInspectorRow_OnClick(self)
if (not self.drillable or type(self.entryValue) ~= "table") then return; end
TableInspector_Push(self.entryValue, _FormatKey(self.entryKey));
end
------------------------------------------------------------------------------
-- Slash command — /tinspect <luaexpr>
--
-- Evaluates `<luaexpr>` in a fresh chunk with `return ` prefixed, so
-- bare expressions (`_G`, `GameTooltip`, `MyAddOn.foo`) work without
-- the user having to write `return ...` themselves.
------------------------------------------------------------------------------
local function _ChatErr(msg)
DEFAULT_CHAT_FRAME:AddMessage("|cffff8888TableInspector:|r " .. msg);
end
function TableInspector_HandleSlashCmd(msg)
if (not msg) then msg = ""; end
msg = string.gsub(msg, "^%s*(.-)%s*$", "%1"); -- trim
if (msg == "" or msg == "help") then
DEFAULT_CHAT_FRAME:AddMessage(
"|cff88ff88/tinspect|r <expr> -- open inspector on the result of <expr>");
DEFAULT_CHAT_FRAME:AddMessage(" example: /tinspect _G");
DEFAULT_CHAT_FRAME:AddMessage(" example: /tinspect ChatTypeInfo");
return;
end
if (msg == "close") then
TableInspector_Close();
return;
end
local chunk, parseErr = loadstring("return " .. msg);
if (not chunk) then
_ChatErr("parse error: " .. tostring(parseErr));
return;
end
-- Run in the global environment so the user's expression sees
-- the usual addon globals. setfenv on the loadstring chunk
-- defaults to that already in vanilla, but be explicit.
setfenv(chunk, getfenv(0));
local ok, result = pcall(chunk);
if (not ok) then
_ChatErr("eval error: " .. tostring(result));
return;
end
if (type(result) ~= "table") then
_ChatErr(string.format(
"result is %s (%s), expected table",
type(result), tostring(result)));
return;
end
TableInspector_Show(result, msg);
end
SLASH_TINSPECT1 = "/tinspect";
SLASH_TINSPECT2 = "/tins";
SlashCmdList["TINSPECT"] = TableInspector_HandleSlashCmd;
+24 -23
View File
@@ -76,26 +76,22 @@ endif()
add_library(${PROJECT_NAME} SHARED
${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES})
# Embed `AddOns/!!!ClassicAPI/` and `src/bindings/Bindings.xml` as RCDATA
# resources. The file-read hook in `src/addons/Embedded.cpp` serves the
# addon files when the engine asks for paths under
# `Interface\AddOns\!!!ClassicAPI\`, so the addon loads even when the user
# doesn't have it on disk. `src/bindings/Inject.cpp` splices the bindings
# XML (our TARGETING-group additions) into the engine's
# `Interface\FrameXML\Bindings.xml` at load time.
# Embed the addons under `src/embed/AddOns/`, `src/bindings/Bindings.xml` and
# the textures under `src/embed/Textures/` as RCDATA resources. The file-read
# hook in `src/addons/Embedded.cpp` serves the addon files when the engine
# asks for paths under `Interface\AddOns\<addon>\`, so the addons load even
# when the user doesn't have them on disk. `src/bindings/Inject.cpp` splices
# the bindings XML (our TARGETING-group additions) into the engine's
# `Interface\FrameXML\Bindings.xml` at load time. `src/texture/Builtin.cpp`
# serves each texture at its game path when no file has it.
include(cmake/embed_resources.cmake)
set(EMBEDDED_CLASSICAPI_SRC "${CMAKE_CURRENT_SOURCE_DIR}/AddOns/!!!ClassicAPI")
file(GLOB_RECURSE EMBEDDED_CLASSICAPI_FILES CONFIGURE_DEPENDS RELATIVE
"${EMBEDDED_CLASSICAPI_SRC}" "${EMBEDDED_CLASSICAPI_SRC}/*")
# Never embed the developer marker: it must reflect on-disk presence only
# (see DiskHasDevMarker in src/addons/Embedded.cpp).
list(FILTER EMBEDDED_CLASSICAPI_FILES EXCLUDE REGEX "(^|/)\\.classicapi-dev$")
embed_resources(
TARGET ${PROJECT_NAME}
ADDON_DIR "${EMBEDDED_CLASSICAPI_SRC}"
ADDON_FILES ${EMBEDDED_CLASSICAPI_FILES}
ADDONS_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/src/embed/AddOns"
ADDONS "!!!ClassicAPI" "ClassicAPI_DebugTools" "ClassicAPI_EventTrace"
ADDON_VERSION "${CLASSICAPI_TOC_VERSION}"
BINDINGS_XML "${CMAKE_CURRENT_SOURCE_DIR}/src/bindings/Bindings.xml"
TEXTURES_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/src/embed/Textures"
OUT_DIR "${CMAKE_BINARY_DIR}/generated")
target_compile_definitions(
@@ -104,8 +100,7 @@ target_compile_definitions(
# Stamp the PE VERSIONINFO resource from the same version components, so the
# DLL's file/product version (Explorer -> Properties -> Details) matches the
# CLASSIC_API_VERSION Lua global. The .rsrc section the resource lands in is
# untouched by the .rdata/.pdata section merges below.
# CLASSIC_API_VERSION Lua global.
set(CLASSICAPI_VER_STRING
"${CLASSICAPI_VER_MAJOR}.${CLASSICAPI_VER_MINOR}.${CLASSICAPI_VER_PATCH}.0")
set(CLASSICAPI_VERSION_RC "${CMAKE_BINARY_DIR}/generated/version.rc")
@@ -139,11 +134,13 @@ target_include_directories(
set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "ClassicAPI")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /MT")
# /Zi retains full debug info (source file + line numbers, locals, types) in a
# separate PDB WITHOUT disabling optimization — the DLL stays a fully optimized
# /O2 release build; the symbols just live in ClassicAPI.pdb on the side. Feeds
# the linker's /DEBUG below.
set(CMAKE_CXX_FLAGS_RELEASE "/MT /O2 /Ob2 /DNDEBUG /Zi")
# /O1 optimizes for size: the code is mostly short glue around engine calls,
# and /O1 made the DLL 280 KB smaller than /O2 /Ob2 (link-time code generation,
# /GL + /LTCG, made it larger again). /Zi retains full debug info (source file +
# line numbers, locals, types) in a separate PDB WITHOUT disabling
# optimization; the symbols just live in ClassicAPI.pdb on the side. Feeds the
# linker's /DEBUG below.
set(CMAKE_CXX_FLAGS_RELEASE "/MT /O1 /DNDEBUG /Zi")
set(CMAKE_SHARED_LINKER_FLAGS
# Two debug artifacts for triaging crashes from a SHIPPED DLL. Both are
# published as release artifacts, match this exact build, and have no effect
@@ -160,5 +157,9 @@ set(CMAKE_SHARED_LINKER_FLAGS
# identical-COMDAT fold); they are listed explicitly below so they stay ON
# and the shipped DLL does not grow. (/OPT:ICF folds identical functions, so
# a few map symbols share an address — harmless.)
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP /DEBUG"
# The sections keep the standard layout: .text is read + execute only and
# constants stay in a read-only .rdata. Nothing writes into this DLL's own
# code; engine patches make their target writable themselves
# (Common::PatchBytes).
"/OPT:REF /OPT:ICF /NODEFAULTLIB:MSVCRT /MAP /DEBUG"
)
+148 -95
View File
@@ -1,7 +1,7 @@
# ClassicAPI
ClassicAPI is a DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW). It
backports a large part of the modern WoW API — 550+ functions and 50+ events,
backports a large part of the modern WoW API — 950+ functions and 80+ events,
plus much of Lua 5.1 — into the 1.12 client, so addons written for later
versions (3.3.5+ or Classic Era) run with little or no change.
@@ -15,33 +15,21 @@ The headline features. Each one takes effect as soon as the DLL loads, so your
existing addons benefit without any code changes. The complete per-function
reference is in **[docs/API.md](docs/API.md)**.
### Lua 5.1 compatibility
The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
| Feature | Effect |
|---------|--------|
| [Syntax](docs/API.md#lua-51-syntax) | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). |
| [Upvalue limit](docs/API.md#upvalue-limit) | A function can use up to 60 upvalues, the outer locals it refers to, matching Lua 5.1. Lua 5.0 stops at 32 and rejects the whole file, so a large handler that reads many file-level locals can be valid 5.1 and still fail to load here. Nothing changes for a function within 32. |
| [`for` loop variables](docs/API.md#for-loop-variables-per-pass) | Each pass of a `for` loop has its own loop variables, as in Lua 5.1, so a function made in the loop keeps that pass's values. On Lua 5.0 every such function sees the loop's last value, which is `nil` for a `for ... in` loop. |
| [String methods](docs/API.md#string-methods-supper-sformat) | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Works on literals and variables, in-world and on the login screen, and inside coroutines. |
| [Script-handler arguments](docs/API.md#setmodernscriptargsenable--getmodernscriptargs) | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. |
| [XML script bodies](docs/API.md#xml-script-bodies-see-self-and-their-arguments) | An XML script body written for later clients, such as `<OnLoad>self:Init()</OnLoad>`, sees `self` and its script's arguments by name (`elapsed`, `button`, ...). Bodies that read `this` / `arg1` compile as before. |
### Modern client behaviors
| Feature | Effect |
|---------|--------|
| Lua 5.1 code | Addon code written for Lua 5.1 runs on the 1.12 Lua 5.0 VM. It covers the [5.1 syntax](docs/API.md#lua-51-syntax) (`#`, `%`, `...` expressions, `0x` hex, `[=[ ]=]` long brackets), [method calls on strings](docs/API.md#string-methods-supper-sformat) (`msg:match("^!(%w+)")`), [up to 60 upvalues](docs/API.md#upvalue-limit) for each function, and [new `for` loop variables](docs/API.md#for-loop-variables-per-pass) on each pass. Each addon file also gets its `(name, table)` through `...`. |
| [Modern script handlers](docs/API.md#setmodernscriptargsenable--getmodernscriptargs) | Frame scripts get their values as arguments, for example `OnClick(self, button, down)` and `OnEvent(self, event, ...)`. [XML script bodies](docs/API.md#xml-script-bodies-see-self-and-their-arguments) such as `<OnLoad>self:Init()</OnLoad>` see `self` and their arguments by name. The `this` / `arg1` globals stay set. `SetModernScriptArgs(false)` restores the vanilla behavior. |
| Inline textures | Draws inline texture markup (`\|T…\|t`) and atlas markup (`\|A…\|a`) as icons in FontStrings, chat, and tooltips. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| [Soft targeting](docs/API.md#soft-interact-softinteract--anyinteract) | The Interact Key, and soft targets for enemies and friendly units. After you bind **Interact With Target** to a key, the key interacts with the nearest object in front of you. The object can be an NPC with a service, a corpse to loot or skin, a mailbox, a chest, or an herb or mining node. The [`softenemy` and `softfriend`](docs/API.md#soft-enemy-and-soft-friend-softenemy--softfriend--anyenemy--anyfriend) unit tokens point at the nearest enemy and the nearest friendly unit, and your target does not change. With no target, an attack or a harmful spell goes to `softenemy`, and a helpful spell goes to `softfriend`. Addons and macros use the picked units through the `softinteract`, `softenemy` and `softfriend` tokens. `anyenemy` and `anyfriend` use your target first. `PLAYER_SOFT_INTERACT_CHANGED`, `PLAYER_SOFT_ENEMY_CHANGED` and `PLAYER_SOFT_FRIEND_CHANGED` fire when the units change. The `SoftTargetEnemy` and `SoftTargetFriend` CVars turn on soft enemy and soft friend. The `SoftTargetIcon*` CVars show an icon over each unit. |
| [Soft targeting](docs/API.md#soft-interact-softinteract--anyinteract) | The Interact Key and soft targets. Bind **Interact With Target** to a key to interact with the nearest NPC, corpse, mailbox, chest or gathering node in front of you. The [`softenemy` and `softfriend`](docs/API.md#soft-enemy-and-soft-friend-softenemy--softfriend--anyenemy--anyfriend) tokens point at the nearest enemy and friendly unit without a change of target. With no target, harmful spells go to `softenemy` and helpful spells go to `softfriend`. `PLAYER_SOFT_*_CHANGED` events fire when the units change. |
| [Animations](docs/API.md#animation) | Animation groups on frames, textures and font strings, made in Lua or XML: `CreateAnimationGroup`, looping, smoothing, scripts and templates, with `Alpha`, `Translation`, `Scale`, `Rotation` and `Path` applied. |
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| [XML attributes](docs/API.md#parentkey-in-xml) | More XML attributes work: `parentKey="Name"` makes `parent.Name` point to a frame, texture or font string, `<OnLoad function="Name"/>` uses a global function as a script handler, `<OnLoad method="Name"/>` uses the frame's own function, `mixin="A, B"` copies mixins onto the object, `<KeyValues>` sets fields on the object, a button's `<NormalFont style="..."/>` sets its font, and `inherits="A, B"` applies more than one template. |
| [Model scenes](docs/API.md#modelscene) | The `ModelScene` frame type shows many models in one 3D scene, with its own camera, light and fog. Each model is an actor that you place, turn, scale and animate. An actor shows a creature display, a model file or a copy of a unit. It can also seat a rider, show spell visual kits and pause on one frame. A scene can show a WMO, for example a dungeon, around the actors, with its props and their sounds. An actor can move on the WMO as a character moves in the world: it runs, jumps, falls, swims and walks to a point. You hear the sounds of a scene from its camera, or from an actor. A follow camera follows an actor as the camera of the world follows your character, and stops in front of walls. An actor can glow, show a selection circle and show a name over its head, and a scene can show the click-to-move marker. |
| Tooltip line cap | Removes `GameTooltip`'s 30-line limit for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). A tooltip adds a new line pair when it needs one, so long tooltips and comparison blocks (for example, pfUI's eqcompare) keep all their lines. |
| [XML attributes](docs/API.md#parentkey-in-xml) | More XML attributes work: `parentKey="Name"` makes `parent.Name` point to a frame, texture or font string, `<OnLoad function="Name"/>` uses a global function as a script handler, `<OnLoad method="Name"/>` uses the frame's own function, `mixin="A, B"` copies mixins onto the object, `<KeyValues>` sets fields on the object, a button's `<NormalFont style="..."/>` sets its font, and `inherits="A, B"` applies more than one template. `parentArray="Name"` adds the object to a list on its parent. An `<Anchor>` takes `x` and `y` offsets and `relativeKey="$parent.Key"`. `<OnEnter inherit="prepend">` runs the template's handler and then its own, and `inherit="append"` runs them in the other order. A frame with `intrinsic="true"` makes a new frame type. Frames take `scale`, `registerForDrag`, `clipChildren`, `useParentLevel`, `dontSavePosition`, `propagateKeyboardInput`, `propagateMouseInput`, `enableMouseClicks`, `enableMouseMotion` and `motionScriptsWhileDisabled`, and buttons take `registerForClicks`. Textures take `alpha`, `atlas`, `useAtlasSize`, `mask`, `desaturated`, `rotation`, `horizTile`, `vertTile`, `hWrapMode` and `vWrapMode`. Font strings take `alpha`, `wordwrap`, `indented`, `degrees` and `radians`. Status bars take `reverseFill`, `rotatesTexture` and `fillStyle`, and edit boxes take `countInvisibleLetters`. |
| [Macro conditions](docs/API.md#more-commands-with-conditions) | `[conditions]` and `@unit` work on 38 slash commands, from `/cast` and `/use` to `/target`, `/follow`, `/cancelaura` and the `/pet` family. With `/cast [@mouseover,harm][] Fireball`, the spell goes to the mouseover unit. With no mouseover unit, it goes to your target. `@cursor` places a ground-target spell or item under the mouse and `@player` places it at your feet. A character name works where a unit token does (`[target=Feral]`). Addons read the same rules through `SecureCmdOptionParse`. |
| [`#showtooltip`](docs/API.md#showtooltip-and-show) | A macro button shows what the macro does. The icon, tooltip, cooldown, range, usable state and auto-repeat glow all follow the spell or item that the directive resolves to, conditions included. They update as the conditions change. The result goes into the engine field that action-bar addons already read, so they need no code of their own. An addon with its own macro parser can publish its result through `C_Macro.SetMacroDisplay`. It then does not need to replace the action globals. |
| [`#showtooltip`](docs/API.md#showtooltip-and-show) | A macro button shows the spell or item that its directive resolves to, conditions included: icon, tooltip, cooldown, range, usable state and auto-repeat glow. Action-bar addons get this with no code of their own. Addons with their own macro parser can publish through `C_Macro.SetMacroDisplay`. |
| [Event-driven nameplates](docs/API.md#nameplate) | The modern `C_NamePlate` API, driven by real events. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. |
| [Focus target](docs/API.md#focus) | A sticky focus unit. `FocusUnit("target")` pins it and `ClearFocus()` drops it, with `PLAYER_FOCUS_CHANGED` on every change. The `focus` / `focustarget` tokens resolve with every `UnitX` function and fire unit events (`UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "focus"`), and the predefined `FOCUSTARGET` / `TARGETFOCUS` keybinds are ready to set. |
| [Combat log events](docs/API.md#combat_log_event_unfiltered--combat_log_event-events) | `COMBAT_LOG_EVENT_UNFILTERED` fires for each combat log event: a hit, a miss, a heal, a cast, an aura that starts or ends, a dispel, a death. Its values identify units by GUID and `COMBATLOG_OBJECT_*` flags. The values do not depend on the client language. As a result, damage meters and combat addons do not need to parse chat text. `CombatLogGetCurrentEventInfo` returns the same values. `COMBAT_LOG_EVENT` fires only for the events that match the `CombatLogAddFilter` filters. Addons can read recent events again as kept entries. The combat lines in the chat frames do not change. |
| [Combat log events](docs/API.md#combat_log_event_unfiltered--combat_log_event-events) | `COMBAT_LOG_EVENT_UNFILTERED` and `CombatLogGetCurrentEventInfo` give each combat event with GUIDs and `COMBATLOG_OBJECT_*` flags. The values do not depend on the client language, so damage meters do not need to parse chat text. `COMBAT_LOG_EVENT` fires for the events that pass the `CombatLogAddFilter` filters. |
| [Retail-like `/reload`](docs/API.md#reload-picks-up-new-addons-and-new-files) | `/reload` picks up addon changes made while the game runs. A new folder under `Interface\AddOns\` registers and loads as a normal addon, new files added to an existing addon's TOC load, a newly installed addon's first SavedVariables save survives `/reload`, `##` metadata edits (`## SavedVariables:`, `## Dependencies:`, `## Title:`, …) take effect, and a deleted addon folder drops from the addon list. |
| [Multi-flavor & conditional TOC](docs/API.md#conditional-and-multi-flavor-toc-loading) | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. |
| [Launch switches](docs/API.md#launch) | Options you add to the shortcut that starts the game. `-config <name>` reads and writes `WTF\<name>` in place of `WTF\Config.wtf`, so one install can hold several settings profiles. `-gluescript` and `-gluescriptFile` run Lua at the login and character-select screens, every time those screens appear. `-gamescript` and `-gamescriptFile` run it one time, after you enter the world. Each script runs after the interface has loaded, so it can call interface functions and use frames. |
@@ -58,93 +46,105 @@ heading to expand it. For signatures and return values, see the per-function
reference in **[docs/API.md](docs/API.md)**.
<details>
<summary><b>In-game Lua calls</b> — 550+ functions across ~60 namespaces</summary>
<summary><b>In-game Lua calls</b> — 950+ functions across ~75 namespaces</summary>
In the Frame rows, a name that starts with `:` is a method of the widget that the row names, for example `:SetShown` is `frame:SetShown`.
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [Action](docs/API.md#action) | `GetActionInfo`, `IsItemAction`, `IsStackableAction`, `HasBonusActionBar`, `C_ActionBar.EnableActionRangeCheck`, `C_ActionBar.GetSpell`, `C_ActionBar.GetItemActionOnEquipSpellID`, `C_ActionBar.IsStackableAction`, `C_ActionBar.IsMacroActionWithShowTooltip`, `C_ActionBar.FindSpellActionButtons`, `C_ActionBar.HasSpellActionButtons`, `C_ActionBar.IsOnBarOrSpecialBar` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnInterfaceVersion`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded`, `C_AddOns.LoadAddOn` |
| [APIDocumentation](docs/API.md#apidocumentation) | `/classicapi` in-game browser, `C_APIDocumentation.GetSystem`, `C_APIDocumentation.GetSystems` |
| [Animation](docs/API.md#animation) | `region:CreateAnimationGroup`, `region:GetAnimationGroups`, `region:StopAnimating`, `SetMaxAnimFramerate`, `GetMaxAnimFramerate`, `AnimationGroup` and `Animation` / `Alpha` / `Translation` / `Scale` / `Rotation` / `Path` / `ControlPoint` methods |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem`, `CanCancelAuction` |
| [AutoComplete](docs/API.md#autocomplete) | `C_AutoComplete.GetAutoCompleteResults`, `C_AutoComplete.IsRecognizedName`, `C_AutoComplete.GetAutoCompletePresenceID`, `C_AutoComplete.GetAutoCompleteRealms` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings`, `GetBindingAction` (`checkOverride`), `GetBindingByKey`, `IsModifiedClick`, `GetModifiedClick`, `SetModifiedClick`, `GetNumModifiedClickActions`, `GetModifiedClickAction` |
| [Chat](docs/API.md#chat) | `GetCurrentChatEvent`, `GetCurrentChatGUID` |
| [Chat](docs/API.md#chat) | `GetCurrentChatEvent`, `GetCurrentChatGUID`, `IsChatAFK`, `IsChatDND`, `SendSystemMessage` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [ClassColor](docs/API.md#classcolor) | `C_ClassColor.GetClassColor` |
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
| [Combat](docs/API.md#combat) | `InCombatLockdown`, `StartAttack`, `StopAttack` |
| [CombatLog](docs/API.md#combatlog) | `CombatLogGetCurrentEventInfo`, `COMBATLOG_OBJECT_*` (constants) |
| [Console](docs/API.md#console) | `CalculateStringEditDistance`, `ConsoleEcho`, `ConsoleExec`, `ConsoleGetAllCommands`, `ConsoleGetColorFromType`, `ConsoleGetFontHeight`, `ConsoleIsActive`, `ConsolePrintAllMatchingCommands`, `SetConsoleKey` |
| [Console](docs/API.md#console) | `CalculateStringEditDistance`, `ConsoleAddMessage`, `ConsoleEcho`, `ConsoleExec`, `ConsoleGetAllCommands`, `ConsoleGetColorFromType`, `ConsoleGetFontHeight`, `ConsoleIsActive`, `ConsolePrintAllMatchingCommands`, `SetConsoleKey` |
| [CVar](docs/API.md#cvar) | `C_CVar.AreCVarsLoaded`, `C_CVar.DoesCVarExist`, `C_CVar.GetCVarBitfield`, `C_CVar.GetCVarBool`, `C_CVar.GetCVarInfo`, `C_CVar.RemoveTempCVar`, `C_CVar.SetCVar`, `C_CVar.SetCVarBitfield`, `C_CVar.SetTempCVar`, `GetCVar`, `GetCVarDefault` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.AutoStoreItem`, `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemEquipmentSetInfo`, `C_Container.GetContainerItemQuestInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerFreeSlots`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetBackpackAutosortDisabled`, `C_Container.GetBankAutosortDisabled`, `C_Container.GetItemCooldown`, `C_Container.GetSortBagsRightToLeft`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SetBackpackAutosortDisabled`, `C_Container.SetBankAutosortDisabled`, `C_Container.SetSortBagsRightToLeft`, `C_Container.SortBags`, `C_Container.SortBankBags`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Cursor](docs/API.md#cursor) | `CursorHasMacro`, `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.AutoStoreItem`, `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemEquipmentSetInfo`, `C_Container.GetContainerItemQuestInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerFreeSlots`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetBackpackAutosortDisabled`, `C_Container.GetBankAutosortDisabled`, `C_Container.GetItemCooldown`, `C_Container.GetSortBagsRightToLeft`, `C_Container.HasContainerItem`, `C_Container.IsContainerFiltered`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SetBackpackAutosortDisabled`, `C_Container.SetBagPortraitTexture`, `C_Container.SetBankAutosortDisabled`, `C_Container.SetItemSearch`, `C_Container.SetSortBagsRightToLeft`, `C_Container.SortBags`, `C_Container.SortBankBags`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown`, `SetBagPortraitTexture` |
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
| [Currency](docs/API.md#currency) | `GetCoinTextureString`, `C_CurrencyInfo.GetCoinTextureString` |
| [Currency](docs/API.md#currency) | `GetCoinText`, `C_CurrencyInfo.GetCoinText`, `GetCoinTextureString`, `C_CurrencyInfo.GetCoinTextureString`, `Constants.MoneyFormattingConstants` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [DeathInfo](docs/API.md#deathinfo) | `C_DeathInfo.GetSelfResurrectOptions`, `C_DeathInfo.UseSelfResurrectOption` |
| [DurationUtil](docs/API.md#durationutil) | `C_DurationUtil.CreateDuration`, `C_DurationUtil.CreateDurationTextBinding` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.PickupEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByID`, `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.IsFactionActive`, `C_Reputation.IsFactionActiveByID`, `C_Reputation.IsFactionInactive`, `C_Reputation.SetFactionActive`, `C_Reputation.SetFactionActiveByID`, `C_Reputation.SetFactionInactive`, `C_Reputation.SetFactionInactiveByID`, `C_Reputation.SetSelectedFaction`, `C_Reputation.SetSelectedFactionByID`, `C_Reputation.SetWatchedFactionByID`, `C_Reputation.ToggleFactionAtWar`, `C_Reputation.ToggleFactionAtWarByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetAccountExpansionLevel`, `GetClassicExpansionLevel`, `GetExpansionLevel`, `GetMaxPlayerLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByID`, `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.IsFactionActive`, `C_Reputation.IsFactionActiveByID`, `C_Reputation.IsFactionInactive`, `C_Reputation.SetFactionActive`, `C_Reputation.SetFactionActiveByID`, `C_Reputation.SetFactionInactive`, `C_Reputation.SetFactionInactiveByID`, `C_Reputation.SetSelectedFaction`, `C_Reputation.SetSelectedFactionByID`, `C_Reputation.SetWatchedFactionByID`, `C_Reputation.ToggleFactionAtWar`, `C_Reputation.ToggleFactionAtWarByID`, `CollapseAllFactionHeaders`, `ExpandAllFactionHeaders`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame: Region](docs/API.md#regionsetpointpoint-one-argument-form) | `:SetPoint("point")` (one-arg form), `:SetSize` / `:GetSize`, `:GetRect`, `:IsMouseOver`, `:IsDragging`, `:IsProtected` / `:CanChangeProtectedState` |
| [Frame: Frame](docs/API.md#createframeframetype--name-parent-template-id) | `CreateFrame` template lists and `id`, `GetMouseFoci`, `:SetShown`, `:SetClampRectInsets` / `:GetClampRectInsets`, `:SetResizeBounds`, `:GetBoundsRect`, `:GetEffectiveAlpha`, `:HookScript`, `:IsEventRegistered`, `:RegisterUnitEvent`, `:SetPropagateKeyboardInput` / `:GetPropagateKeyboardInput`, `:SetDontSavePosition` / `:GetDontSavePosition`, `:SetAttribute`, `:SetAttributeNoHandler`, `:ClearAttribute`, `:GetAttribute`, `:CanChangeAttribute`, `:AllowAttributeChanges` |
| [Frame: Region](docs/API.md#regionsetpointpoint-one-argument-form) | `:SetPoint("point")` (one-arg form), `:SetSize` / `:GetSize`, `ignoreRect` on `:GetSize` / `:GetWidth` / `:GetHeight`, `:GetUnscaledWidth` / `:GetUnscaledHeight` / `:GetUnscaledSize`, `:GetRect`, `:GetScaledRect`, `:IsRectValid`, `:GetPointByName`, `:ClearPoint`, `:GetScale` / `:GetEffectiveScale` (on textures and font strings), `:GetDebugName`, `:IsMouseOver`, `:IsDragging`, `:IsProtected` / `:CanChangeProtectedState` |
| [Frame: Frame](docs/API.md#createframeframetype--name-parent-template-id) | `CreateFrame` template lists and `id`, `GetMouseFoci`, `:SetShown`, `:SetClampRectInsets` / `:GetClampRectInsets`, `:SetResizeBounds`, `:SetClipsChildren` / `:DoesClipChildren`, `:GetBoundsRect`, `:GetEffectiveAlpha`, `:SetAlphaGradient` / `:ClearAlphaGradient` / `:HasAlphaGradient`, `:DesaturateHierarchy`, `:HookScript`, `:IsEventRegistered`, `:RegisterUnitEvent`, `:SetPropagateKeyboardInput` / `:GetPropagateKeyboardInput`, `:SetDontSavePosition` / `:GetDontSavePosition`, `:SetAttribute`, `:SetAttributeNoHandler`, `:ClearAttribute`, `:GetAttribute`, `:CanChangeAttribute`, `:AllowAttributeChanges` |
| [Frame: Button](docs/API.md#buttonsetnormalfontobjectfont--buttongetnormalfontobject) | `:SetEnabled`, `:SetFormattedText`, `:SetNormalFontObject` / `:GetNormalFontObject`, `:SetMotionScriptsWhileDisabled` / `:GetMotionScriptsWhileDisabled`, `:RegisterForClicks("AnyUp"/"AnyDown")`, `:RegisterForMouse`, `GetClickFrame` |
| [Frame: FontString](docs/API.md#fontstringgetstringheight) | `:GetStringHeight`, `:GetUnboundedStringWidth`, `:GetWrappedWidth`, `:GetNumLines`, `:GetLineHeight`, `:GetFontHeight` / `:SetFontHeight` (also on fonts), `:GetFieldSize`, `:IsTruncated`, `:SetMaxLines` / `:GetMaxLines`, `:SetWordWrap` / `:CanWordWrap`, `:SetIndentedWordWrap` / `:GetIndentedWordWrap` (also on fonts, edit boxes, message frames, and SimpleHTML), `:SetFormattedText`, `:SetRotation` / `:GetRotation` |
| [Frame: Texture](docs/API.md#texturesetrotationangle--cx-cy) | `:SetRotation` / `:GetRotation`, `:SetVertexOffset` / `:GetVertexOffset`, `:SetColorTexture`, `:SetAtlas` / `:GetAtlas`, `:ResetTexCoord`, `:SetSpriteSheetCell`, `:SetDesaturation` / `:GetDesaturation`, `IsDesaturateSupported`, `:SetMask`, `frame:CreateMaskTexture`, `:AddMaskTexture` / `:RemoveMaskTexture`, `:GetNumMaskTextures`, `:GetMaskTexture` |
| [Frame: Texture](docs/API.md#texturesetrotationangle--cx-cy) | `:SetRotation` / `:GetRotation`, `:SetVertexOffset` / `:GetVertexOffset`, `:SetColorTexture`, `:SetAtlas` / `:GetAtlas`, `:ResetTexCoord`, `:SetSpriteSheetCell`, `:SetTexture` wrap modes, `:SetHorizTile` / `:GetHorizTile`, `:SetVertTile` / `:GetVertTile`, `:SetDesaturation` / `:GetDesaturation`, `IsDesaturateSupported`, `:SetMask`, `frame:CreateMaskTexture`, `:AddMaskTexture` / `:RemoveMaskTexture`, `:GetNumMaskTextures`, `:GetMaskTexture` |
| [Frame: EditBox](docs/API.md#editboxsetcursorpositionposition) | `:SetCursorPosition` / `:GetCursorPosition`, `:GetUTF8CursorPosition`, `:ClearHighlightText`, `:SetHighlightColor` / `:GetHighlightColor`, `:HasFocus`, `GetCurrentKeyBoardFocus`, `:HasText`, `:ClearHistory`, `:Enable` / `:Disable` / `:IsEnabled`, `:SetCountInvisibleLetters` / `:IsCountInvisibleLetters`, `:IsInIMECompositionMode` |
| [Frame: StatusBar](docs/API.md#statusbarsetreversefillisreversefill--statusbargetreversefill) | `:SetReverseFill` / `:GetReverseFill`, `:SetRotatesTexture` / `:GetRotatesTexture`, `:SetFillStyle` / `:GetFillStyle`, `:SetColorFill`, `:SetStatusBarDesaturated` / `:IsStatusBarDesaturated`, `:SetStatusBarDesaturation` / `:GetStatusBarDesaturation` |
| [Frame: StatusBar](docs/API.md#statusbarsetreversefillisreversefill--statusbargetreversefill) | `:SetReverseFill` / `:GetReverseFill`, `:SetRotatesTexture` / `:GetRotatesTexture`, `:SetFillStyle` / `:GetFillStyle`, `:SetColorFill`, `:SetStatusBarDesaturated` / `:IsStatusBarDesaturated`, `:SetStatusBarDesaturation` / `:GetStatusBarDesaturation`, `:SetTimerDuration` / `:GetTimerDuration`, `:SetValue` / `:SetMinMaxValues` with `interpolation`, `:GetInterpolatedValue`, `:IsInterpolating`, `:SetToTargetValue` |
| [Frame: Slider](docs/API.md#sliderenable--sliderdisable--sliderisenabled) | `:Enable` / `:Disable` / `:IsEnabled` |
| [Frame: Cooldown](docs/API.md#createframecooldown--name-parent-template) | `CreateFrame("Cooldown")`, `:SetCooldown`, `:SetReverse` / `:GetReverse`, `:SetDrawEdge` / `:GetDrawEdge` |
| [Frame: ScrollingMessageFrame](docs/API.md#smfsethyperlinksenabledenabled--smfgethyperlinksenabled) | `:SetHyperlinksEnabled` / `:GetHyperlinksEnabled` |
| [Frame: Cooldown](docs/API.md#createframecooldown--name-parent-template) | `CreateFrame("Cooldown")`, `:SetCooldown`, `:SetCooldownDuration`, `:SetCooldownFromExpirationTime`, `:Clear`, `:GetCooldownTimes`, `:GetCooldownDuration`, `:GetCooldownDisplayDuration`, `:Pause` / `:Resume`, `:SetPaused` / `:IsPaused`, `:SetReverse` / `:GetReverse`, `:SetDrawEdge` / `:GetDrawEdge`, `:SetDrawSwipe` / `:GetDrawSwipe`, `:SetDrawBling` / `:GetDrawBling`, `:SetSwipeColor`, `:SetEdgeColor`, `:SetSwipeTexture`, `:SetEdgeTexture`, `:SetBlingTexture`, `:SetRotation` / `:GetRotation`, `:SetHideCountdownNumbers` / `:GetHideCountdownNumbers`, `:GetCountdownFontString`, `:SetCountdownAbbrevThreshold` / `:GetCountdownAbbrevThreshold`, `:SetCountdownMillisecondsThreshold` / `:GetCountdownMillisecondsThreshold`, `:SetMinimumCountdownDuration` / `:GetMinimumCountdownDuration`, `OnCooldownDone`, `CooldownFrame_Set`, `CooldownFrame_Clear`, `CooldownFrame_SetDisplayAsPercentage` |
| [Frame: ModelScene](docs/API.md#modelscene) | `CreateFrame("ModelScene")`, `<ModelScene>` (`drawLayer`, `fogNear`, `fogFar`, `<FogColor>`, `<ViewInsets>`), `:CreateActor`, `:GetNumActors`, `:GetActorAtIndex`, `:SetCameraPosition` / `:GetCameraPosition`, `:SetCameraOrientationByYawPitchRoll`, `:SetCameraOrientationByAxisVectors`, `:GetCameraForward`, `:GetCameraRight`, `:GetCameraUp`, `:SetCameraFieldOfView` / `:GetCameraFieldOfView`, `:SetCameraNearClip` / `:GetCameraNearClip`, `:SetCameraFarClip` / `:GetCameraFarClip`, `:SetViewInsets` / `:GetViewInsets`, `:SetViewTranslation` / `:GetViewTranslation`, `:Project3DPointTo2D`, `"FollowCamera"` (`:SetFollowActor` / `:GetFollowActor`), `:SetLightType` / `:GetLightType`, `:SetLightVisible` / `:IsLightVisible`, `:SetLightDirection` / `:GetLightDirection`, `:SetLightPosition` / `:GetLightPosition`, `:SetLightAmbientColor` / `:GetLightAmbientColor`, `:SetLightDiffuseColor` / `:GetLightDiffuseColor`, `:SetFogColor` / `:GetFogColor`, `:SetFogNear` / `:GetFogNear`, `:SetFogFar` / `:GetFogFar`, `:ClearFog`, `:SetDrawLayer` / `:GetDrawLayer`, `:SetPaused`, `:GetSceneTime`, `:After`, `:NewTimer`, `:NewTicker`, `:SetAllowOverlappedModels` / `:GetAllowOverlappedModels`, `:SetGroundTerrain` / `:GetGroundTerrain`, `:SetWorldModel`, `:SetWorldModelByMapID`, `:ClearWorldModel`, `:SetPlayWorldModelSounds` / `:GetPlayWorldModelSounds`, `:TraceSegment`, `:GetLiquidAtPoint`, `:SweepCollider`, `:SetSoundListenerActor` / `:GetSoundListenerActor`, `:GetActorAtCursor`, `:GetCursorRay`, `:GetActorsInRadius`, `:SetMoveMarker` / `:ClearMoveMarker` / `:GetMoveMarker`, `OnActorEnter` / `OnActorLeave` |
| [Frame: ModelSceneActor](docs/API.md#actorsetmodelbycreaturedisplayiddisplayid--useactiveplayercustomizations) | `:SetModelByCreatureDisplayID`, `:SetModelByPath`, `:SetModelByUnit`, `:SetModelByUnitCreatureDisplayID`, `:GetModelUnitGUID`, `:Dress`, `:Undress`, `:UndressSlot`, `:TryOn`, `:DressPlayerSlot`, `:IsSlotAllowed` / `:IsSlotVisible`, `:GetItemTransmogInfo`, `:GetItemTransmogInfoList`, `:SetItemTransmogInfo`, `:SetSheathed` / `:GetSheathed`, `:SetSheathedCategory`, `:UseUnitSheatheCategory`, `:SetAutoDress` / `:GetAutoDress`, `:IsGeoReady`, `:ClearModel`, `:IsLoaded`, `:GetModelPath`, `:SetPosition` / `:GetPosition`, `:SetYaw` / `:GetYaw`, `:SetPitch` / `:GetPitch`, `:SetRoll` / `:GetRoll`, `:SetScale` / `:GetScale`, `:SetUseCenterForOrigin` / `:IsUsingCenterForOrigin`, `:GetActiveBoundingBox`, `:GetMaxBoundingBox`, `:Show` / `:Hide` / `:SetShown` / `:IsShown` / `:IsVisible`, `:SetAlpha` / `:GetAlpha`, `:SetAnimation` / `:GetAnimation`, `:GetAnimationVariation`, `:SetAnimationBlendOperation` / `:GetAnimationBlendOperation`, `:SetPaused` / `:GetPaused`, `:SetSpellVisualKit` / `:GetSpellVisualKit`, `:ApplySpellVisualKit`, `:PlayAnimationKit` / `:StopAnimationKit`, `:AttachToMount`, `:DetachFromMount`, `:CalculateMountScale`, `:GetAttachmentPosition`, `:AttachToActor`, `:DetachFromActor`, `:PlaySpellVisual`, `:StopSpellVisual`, `:PlaySound`, `:SetPlayAnimationSounds` / `:GetPlayAnimationSounds`, `:SetShadowShown` / `:IsShadowShown`, `:SetHighlight` / `:IsHighlighted`, `:SetSelectionCircle` / `:ClearSelectionCircle` / `:GetSelectionCircle`, `:SetNameText` / `:ClearNameText` / `:GetNameText`, `:GetNamePosition`, `:SetMovementEnabled` / `:IsMovementEnabled`, `:StartMovingForward`, `:StartMovingBackward`, `:StopMoving`, `:StartStrafingLeft`, `:StartStrafingRight`, `:StopStrafing`, `:StartTurningLeft`, `:StartTurningRight`, `:StopTurning`, `:Jump`, `:StartPitchingUp`, `:StartPitchingDown`, `:StopPitching`, `:IsFalling`, `:IsSwimming`, `:MoveTo`, `:SetWalking` / `:IsWalking`, `:GetCameraPivotHeight`, `OnLoad`, `OnUpdate`, `OnModelLoading`, `OnModelLoaded`, `OnModelCleared`, `OnAnimFinished`, `OnSpellVisualImpact`, `OnMoveFinished`, `<Actor>` templates |
| [Frame: ScrollingMessageFrame](docs/API.md#smfsethyperlinksenabledenabled--smfgethyperlinksenabled) | `:SetHyperlinksEnabled` / `:GetHyperlinksEnabled`, `:GetMessageInfo` (with the extra `:AddMessage` values), `:BackFillMessage` (adds a message as the oldest), `:RemoveMessagesByPredicate`, `:SetScrollOffset` / `:GetScrollOffset`, `:GetMaxScrollRange`, `:ScrollByAmount`, `:SetInsertMode` / `:GetInsertMode`, `:AddOnDisplayRefreshedCallback` |
| [Frame: scripts](docs/API.md#onenable-and-ondisable-scripts) | `OnEnable`, `OnDisable`, `OnMinMaxChanged`, `OnAttributeChanged`, `OnCharComposition`, `OnTextChanged` (`userInput`), `PreClick`, `PostClick`, `SetModernScriptArgs` / `GetModernScriptArgs` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
| [FriendList](docs/API.md#friendlist) | `AddOrRemoveFriend`, `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `GameTooltip:AddTexture`, `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetMinimumWidth`, `GameTooltip:GetOwner`, `GameTooltip:GetPadding`, `GameTooltip:GetSpell`, `GameTooltip:GetUnit`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:IsUnit`, `GameTooltip:SetAnchorType`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetFrameStack`, `GameTooltip:SetHyperlink` (full links, `spell:`, `quest:`), `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem`, `OnTooltipSetSpell`, `OnTooltipSetUnit`, `OnTooltipSetGameObject`, `OnTooltipSetFrameStack` (scripts) |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetPoiForUiMapID`, `C_GossipInfo.GetPoiInfo`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex`, `GetNumGossipActiveQuests`, `GetNumGossipAvailableQuests` |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetPoiForUiMapID`, `C_GossipInfo.GetPoiInfo`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex`, `GetNumGossipActiveQuests`, `GetNumGossipAvailableQuests`, `GetNumGossipOptions` |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Input](docs/API.md#input) | `GetCursorDelta`, `GetMouseButtonClicked`, `GetMouseButtonName`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo`, `IsActiveBattlefieldArena` |
| [Item: C_Item](docs/API.md#item) | `C_Item.DoesItemExist` / `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemClassInfo`, `C_Item.GetItemCount`, `C_Item.GetItemData` / `C_Item.GetItemDataByID`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon` / `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType` / `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize` / `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName` / `C_Item.GetItemNameByID`, `C_Item.GetItemQuality` / `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice` / `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID` / `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemTempEnchantInfo`, `C_Item.GetItemUniqueness` / `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached` / `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.IsUsableItem`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockItem`, `C_Item.UnlockAllItems`, `C_Item.PickupItem`, `C_Item.RequestLoadItemData` / `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName` |
| [Item: info](docs/API.md#getitemclassinfoclassid) | `GetItemClassInfo`, `GetItemSubClassInfo`, `GetItemIcon`, `IsConsumableItem`, `IsUsableItem` |
| [Item: inventory](docs/API.md#getaverageitemlevel) | `GetAverageItemLevel`, `GetInventoryItemID`, `GetInventoryItemDurability`, `GetInventoryItemRepairCost`, `GetInventoryItemsForSlot`, `OffhandHasWeapon` |
| [Item: C_Item](docs/API.md#item) | `C_Item.DoesItemExist` / `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemClassInfo`, `C_Item.GetItemCount`, `C_Item.GetItemData` / `C_Item.GetItemDataByID`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon` / `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType` / `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize` / `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName` / `C_Item.GetItemNameByID`, `C_Item.GetItemQuality` / `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice` / `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID` / `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemTempEnchantInfo`, `C_Item.GetItemUniqueness` / `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached` / `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.IsUsableItem`, `C_Item.ItemHasRange`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockItem`, `C_Item.UnlockAllItems`, `C_Item.PickupItem`, `C_Item.RequestLoadItemData` / `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName` |
| [Item: info](docs/API.md#getitemclassinfoclassid) | `GetItemClassInfo`, `GetItemSubClassInfo`, `GetItemIcon`, `IsConsumableItem`, `IsCurrentItem`, `IsDressableItem`, `IsEquippedItemType`, `IsHarmfulItem`, `IsHelpfulItem`, `IsUsableItem` |
| [Item: inventory](docs/API.md#getaverageitemlevel) | `GetAverageItemLevel`, `GetInventoryItemID`, `GetInventoryItemDurability`, `GetInventoryItemRepairCost`, `GetInventoryItemsForSlot`, `IsDualWielding`, `SetInventoryPortraitTexture` |
| [Item: item IDs](docs/API.md#getitemid--companions-to-the-engines-getitemlink-family) | `GetAuctionItemID`, `GetAuctionSellItemID`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeTargetItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID` |
| [Localization](docs/API.md#localization) | `AbbreviateLargeNumbers`, `AbbreviateNumbers`, `BreakUpLargeNumbers`, `C_StringUtil.GetDefaultAbbreviationBreakpoints` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount`, `C_LossOfControl.GetSchoolLockout` |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.gsub` (table replacement), `string.join`, `string.match`, `string.reverse`, `string.split`, `string.trim`, `strjoin`, `strmatch`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args), `xpcall` (argument forwarding) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `C_Macro.SetMacroDisplay`, `DeleteMacro` / `EditMacro` / `GetMacroInfo` / `PickupMacro` by name, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItem`, `GetMacroItemIcons`, `GetMacroSpell`, `StopMacro` |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `debuglocals`, `debugstack` (coroutine argument), `difftime`, `GetCallstackHeight`, `GetErrorCallstackHeight`, `gmatch`, `math.cosh`, `math.fmod`, `math.huge`, `math.modf`, `math.sinh`, `math.tanh`, `Mixin`, `newproxy`, `scrub`, `select`, `SetErrorCallstackHeight`, `strbyte` (range args), `strcmputf8i`, `strconcat`, `string.byte` (range args), `string.concat`, `string.format` (two-digit `%n$`), `string.gmatch`, `string.gsub` (table replacement, `%0`), `string.join`, `string.match`, `string.reverse`, `string.split`, `string.trim`, `strjoin`, `strlenutf8`, `strmatch`, `strreplace`, `strrev`, `strsplit`, `strsplittable`, `strtrim`, `table.count`, `table.maxn`, `table.removemulti`, `table.wipe`, `unpack` (range args), `wipe`, `xpcall` (argument forwarding) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `C_Macro.SetMacroDisplay`, `DeleteMacro` / `EditMacro` / `GetMacroInfo` / `PickupMacro` by name, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroBody`, `GetMacroIcons`, `GetMacroItem`, `GetMacroItemIcons`, `GetMacroSpell`, `GetRunningMacro`, `GetRunningMacroButton`, `RunMacroText`, `SetMacroItem`, `SetMacroSpell`, `StopMacro` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [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` |
| [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`) |
| [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`, `DressUpModel:GetItemTransmogInfo` / `GetItemTransmogInfoList` / `SetItemTransmogInfo`, `DressUpModel:IsSlotAllowed` / `IsSlotVisible`, `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` |
| [ModelInfo](docs/API.md#c_modelinfogetmodelsceneinfobyidmodelsceneid) | `C_ModelInfo.GetModelSceneInfoByID`, `C_ModelInfo.GetModelSceneCameraInfoByID`, `C_ModelInfo.GetModelSceneActorInfoByID`, `C_ModelInfo.GetModelSceneActorDisplayInfoByID` |
| [Mount](docs/API.md#mount) | `C_MountJournal.GetMountFromItem`, `C_MountJournal.GetMountFromSpell`, `C_MountJournal.GetMountInfoExtraByID` |
| [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` |
| [PartyInfo](docs/API.md#partyinfo) | `InviteUnit`, `PromoteToLeader`, `UninviteUnit` |
| [Pet](docs/API.md#pet) | `IsPetAttackAction`, `IsAtStableMaster` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
| [Profiling](docs/API.md#profiling) | `GetAddOnCPUUsage`, `GetAddOnMemoryUsage`, `GetEventCPUUsage`, `GetFrameCPUUsage`, `GetFunctionCPUUsage`, `GetScriptCPUUsage`, `ResetCPUUsage`, `UpdateAddOnCPUUsage`, `UpdateAddOnMemoryUsage` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetInfo`, `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestLogTitle`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetInfo`, `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestLogTitle`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogCompletionText`, `GetQuestLogLeaderBoardID` (Turtle WoW only: `C_QuestLog.GetAllCompletedQuestIDs`, `C_QuestLog.IsQuestFlaggedCompleted`, `GetQuestsCompleted`, `QueryQuestsCompleted`) |
| [Sound](docs/API.md#sound) | `C_Sound.GetRecentSoundFiles`, `C_Sound.GetSoundScaledVolume`, `C_Sound.IsPlaying`, `C_Sound.PlayItemSound`, `C_Sound.PlaySound`, `C_Sound.PlaySoundWithOptions`, `C_Sound.PlayVocalErrorSound`, `MuteSoundFile`, `PlaySound`, `PlaySoundFile`, `StopSound`, `UnmuteSoundFile` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCastCount`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectInfo`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellLossOfControlCooldown`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCastCount`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectInfo`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellLossOfControlCooldown`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CancelUnitBuff`, `CastSpellByID`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetCritChance`, `GetRangedCritChance`, `GetSpellBaseCooldown`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsAttackSpell`, `IsAutoRepeatSpell`, `IsConsumableSpell`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange`, `UnitCastingDuration`, `UnitChannelDuration` |
| [SpellActivationOverlay](docs/API.md#spellactivationoverlay) | `C_SpellActivationOverlay.IsSpellOverlayed`, `IsSpellOverlayed` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.ContainsAnyDisenchantSpell`, `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetNumSpellBookSkillLines`, `C_SpellBook.GetPlayerSpellsByAura`, `C_SpellBook.GetSkillLineIndexByID`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemCastCount`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellBookItemLossOfControlCooldownDuration`, `C_SpellBook.GetSpellBookItemLossOfControlCooldownInfo`, `C_SpellBook.GetSpellBookItemSkillLineIndex`, `C_SpellBook.GetSpellBookSkillLineInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsClassTalentSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `GetSheathState`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftForm`, `GetShapeshiftFormID`, `GetSheathState`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsLoggingOut`, `IsMounted`, `IsOutdoors`, `IsPlayerInWorld`, `IsStealthed`, `IsSwimming` |
| [SwingTimer](docs/API.md#swingtimer) | `C_SwingTimer.EnableRangeCheck`, `C_SwingTimer.IsTargetWithinSwingRange` |
| [System](docs/API.md#system) | `CopyToClipboard`, `FlashClientIcon`, `GetPhysicalScreenSize` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetDirectionFinished`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer`, `InteractUnit`, `C_PlayerInteractionManager.InteractUnit`, `IsTargetLoose` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
| [Texture](docs/API.md#texture) | `C_Texture.GetAtlasElementID`, `C_Texture.GetAtlasElements`, `C_Texture.GetAtlasExists`, `C_Texture.GetAtlasID`, `C_Texture.GetAtlasInfo`, `C_Texture.RegisterAtlas` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTimeCached` |
| [Texture](docs/API.md#texture) | `C_Texture.GetAtlasElementID`, `C_Texture.GetAtlasElements`, `C_Texture.GetAtlasExists`, `C_Texture.GetAtlasID`, `C_Texture.GetAtlasInfo`, `C_Texture.RegisterAtlas`, `SetPortraitTextureFromCreatureDisplayID` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTickTime`, `GetTimeCached` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes`, `GetTradeSkillRecipeLink`, `IsTradeSkillLinked` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `GetTradeSkillListLink`, `GetCraftListLink`, `GetTradeSkillDescription`, `GetTradeSkillRecipeLink`, `IsTradeSkillLinked`, `IsCraftLinked` |
| [Trainer](docs/API.md#trainer) | `GetTrainerServiceItemLink` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `IsUnitToken`, `SetUnitCursorTexture`, `UnitClassBase`, `UnitCreatedBySpell`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSelectionColor`, `UnitSelectionType`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID`, `UnitTokenFromName` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySlot`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetAuraSlots`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitAuraBySlot`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
| [Unit](docs/API.md#unit) | `CanBeRaidTarget`, `ClosestUnitPosition`, `GetUnitSpeed`, `IsUnitToken`, `SetUnitCursorTexture`, `UnitClassBase`, `UnitCreatedBySpell`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitFacing`, `UnitGUID`, `UnitHasIncomingResurrection`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSelectionColor`, `UnitSelectionType`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID`, `UnitTokenFromName` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByAuraInstanceID`, `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySlot`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetAuraSlots`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitAuraBySlot`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
@@ -166,6 +166,7 @@ the glue state because GlueXML had no way to reach them otherwise.
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool`, `C_CVar.SetTempCVar`, `C_CVar.RemoveTempCVar` (storage is process-global — writes from glue are visible in-world and vice versa) |
| [Glue](docs/API.md#glue) | `C_Glue.IsFirstLoadThisSession`, `C_Glue.IsOnGlueScreen` |
| [Input](docs/API.md#getcursordelta) | `GetCursorDelta` |
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
| State | `IsLoggedIn` |
@@ -216,16 +217,25 @@ for what each command does, and
Each setting is saved in `WTF\Config.wtf`. Read and write them with `GetCVar`
/ `SetCVar` or `/console`. Click a group name for the full rules. To change
the most common settings and the player cache in the game, open the game
menu and click AddOn Options, then ClassicAPI.
these settings, the player cache and the ClassicAPI key bindings in the game,
open the game menu and click AddOn Options, then ClassicAPI.
| Group | CVar | Default | What it does |
|-------|------|---------|--------------|
| [Bindings](docs/API.md#actionbuttonusekeydown) | `ActionButtonUseKeyDown` | `0` | `1` uses an action-bar key binding when you press the key. `0` uses it when you release the key. |
| [Combat](docs/API.md#stopautoattackontargetchange) | `stopAutoAttackOnTargetChange` | `0` | `1` stops melee auto-attack, Auto Shot and wand shots when you change target. |
| [Cast settings](docs/API.md#cast-settings-autostand-autodismount-autounshift) | `autoStand` | `0` | `1` makes you stand up when you cast while you sit. |
| | `autoDismount` | `0` | `1` makes you dismount when you cast while you are mounted. |
| | `autoUnshift` | `0` | `1` makes you leave your form when a spell cannot be cast in it. |
| [Combat log](docs/API.md#kept-entries) | `combatLogRetentionTime` | `300` | The number of seconds that the combat log keeps an entry. |
| [Stand, dismount and form](docs/API.md#stand-dismount-and-form-settings-autostand-autodismount-autounshift) | `autoStand` | `0` | `1` makes you stand up when you cast, loot or open the flight map while you sit. |
| | `autoDismount` | `0` | `1` makes you dismount when you cast, use an object, loot, attack or open the flight map while you are mounted. |
| | `autoUnshift` | `0` | `1` makes you leave your form when you cast a spell that cannot be cast in it, use an item, or open the flight map. It also lets you talk to NPCs and use objects in druid forms and Ghost Wolf. |
| [Bag sorting](docs/API.md#c_containergetsortbagsrighttoleft--c_containersetsortbagsrighttoleftenable) | `sortBagsRightToLeft` | `0` | `1` makes a sort fill your bags from the last slot of the last bag. |
| | `backpackAutosortDisabled` | `0` | `1` keeps the items in your backpack in place when you sort your bags. |
| | `bankAutosortDisabled` | `0` | `1` keeps the items in the main bank slots in place when you sort your bank. |
| [Expanded cursors](docs/API.md#expanded-cursors-expandedcursors) | `expandedCursors` | `0` | `1` makes the cursor show what you can do with an NPC or an object: an anvil for repairs, a hearthstone for an inn, a horseshoe for a stable, `!` or `?` for quests, and a fish hook for your fishing bobber. |
| [Equipment sets](docs/API.md#equipment-sets-on-item-tooltips) | `dontShowEquipmentSetsOnItems` | `0` | `1` removes the Equipment Sets line from item tooltips. |
| [Name completion](docs/API.md#name-completion-settings) | `autoCompleteResortNamesOnRecency` | `1` | `1` puts the players that you whispered first in `C_AutoComplete` results, the most recent first. `0` uses alphabetical order. |
| | `autoCompleteWhenEditingFromCenter` | `1` | `1` also matches the text after the cursor when you type inside a name. |
| | `autoCompleteUseContext` | `1` | `0` makes `C_AutoComplete` ignore `includeFlags` and `excludeFlags`. |
| [Profiling](docs/API.md#scriptprofile) | `scriptProfile` | `0` | `1` turns on CPU profiling for the `*CPUUsage` functions. The change starts after `/reload`. |
| [Soft interact](docs/API.md#soft-interact-softinteract--anyinteract) | `SoftTargetInteract` | `3` | Turns `softinteract` on: `2` keyboard and mouse, `3` always, `0` / `1` off. |
| | `SoftTargetInteractArc` | `0` | Where the object can be: `0` straight ahead of the camera, `1` within 30° of it, `2` any direction. |
@@ -242,6 +252,11 @@ menu and click AddOn Options, then ClassicAPI.
| | `SoftTargetIconEnemy` / `SoftTargetIconFriend` | `0` | `1` shows an icon over `softenemy` / `softfriend`. |
| | `SoftTargetLowPriorityIcons` | `0` | `1` also shows the normal pointer as an icon. |
| | `SoftTargetNameplateSize` | `19` | Size of all soft-target icons. |
| [Localization](docs/API.md#breakuplargenumbers-cvar) | `breakUpLargeNumbers` | `1` | `1` puts a separator between each group of three digits in `BreakUpLargeNumbers`. `0` writes no separator. |
| [Cooldown](docs/API.md#createframecooldown--name-parent-template) | `countdownForCooldowns` | `0` | `1` shows the time left as numbers on cooldown frames. |
| `ScriptAnimationUtil` | `ShakeStrengthUI` | `1` | Scales how far `ScriptAnimationUtil.ShakeFrame` moves a frame. `0` turns the shake off. |
| [NamePlate](docs/API.md#nameplate) | `nameplateNotSelectedAlpha` | `0.5` | Alpha of a non-target default nameplate, `0` to `1`. |
| | `nameplateSelectedAlpha` | `1` | Alpha of the target's default nameplate, `0` to `1`. |
| [Text to speech](docs/API.md#tts-cvars) | `ttsVoice` | `0` | The voice that `C_VoiceChat.SpeakText` uses. |
| | `ttsSpeed` | `0` | Speech rate, `-10` to `10`. |
| | `ttsVolume` | `100` | Speech volume, `0` to `100`. |
@@ -272,6 +287,8 @@ when launching with `-console`), not as Lua functions. See the
| Event | Payload |
|-------|---------|
| `ACTION_RANGE_CHECK_UPDATE` | `slot, isInRange, checksRange` (slots with `C_ActionBar.EnableActionRangeCheck` on) |
| `ACTION_USABLE_CHANGED` | `changes` (a `{ slot, usable, noMana }` table for each slot that changed) |
| `BAG_NEW_ITEMS_UPDATED` | *(none)* |
| `BAG_UPDATE_DELAYED` | *(none)* |
| `CHAT_MSG_*` | adds `lineID, guid` as `arg11, arg12` after the existing ten ([details](docs/API.md#chat_msg_-arguments-lineid-arg11-and-guid-arg12)) |
@@ -286,6 +303,8 @@ when launching with `-console`), not as Lua functions. See the
| `GLOBAL_MOUSE_DOWN` | `button` |
| `GLOBAL_MOUSE_UP` | `button` |
| `HEARTHSTONE_BOUND` | *(none)* |
| `INCOMING_RESURRECT_CHANGED` | `unitTarget` |
| `INVENTORY_SEARCH_UPDATE` | *(none)* |
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
| `LEARNED_SPELL_IN_SKILL_LINE` | `spellID, skillLineIndex, isGuildPerkSpell` |
| `LOOT_HISTORY_ROLL_CHANGED` | `itemIndex, playerIndex` |
@@ -301,6 +320,7 @@ when launching with `-console`), not as Lua functions. See the
| `PLAYER_AVG_ITEM_LEVEL_READY` | *(none)* |
| `PLAYER_EQUIPMENT_CHANGED` | `equipmentSlot, hasCurrent` |
| `PLAYER_FOCUS_CHANGED` | *(none)* |
| `PLAYER_MOUNT_DISPLAY_CHANGED` | *(none)* |
| `PLAYER_SOFT_ENEMY_CHANGED` | *(none)* |
| `PLAYER_SOFT_FRIEND_CHANGED` | *(none)* |
| `PLAYER_SOFT_INTERACT_CHANGED` | `oldTarget, newTarget` |
@@ -315,9 +335,21 @@ when launching with `-console`), not as Lua functions. See the
| `PLAYER_TOTEM_UPDATE` | `totemSlot` |
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_QUERY_COMPLETE` | *(none)* (Turtle WoW only) |
| `QUEST_REMOVED` | `questID` |
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
| `SELF_RES_SPELL_CHANGED` | *(none)* |
| `SOUNDKIT_FINISHED` | `soundHandle` (only for sounds played with `runFinishCallback`) |
| `SPELL_ACTIVATION_OVERLAY_SHOW` | `spellID, texture, locationType, scale, r, g, b` ([details](docs/API.md#spell_activation_overlay_show--spell_activation_overlay_hide-events)) |
| `SPELL_ACTIVATION_OVERLAY_HIDE` | `spellID` (`nil` hides every alert) |
| `SPELL_ACTIVATION_OVERLAY_GLOW_SHOW` | `spellID` ([details](docs/API.md#spell_activation_overlay_glow_show--spell_activation_overlay_glow_hide-events)) |
| `SPELL_ACTIVATION_OVERLAY_GLOW_HIDE` | `spellID` |
| `UI_SCALE_CHANGED` | *(none)* |
| `UNIT_AREA_CHANGED` | `unitTarget` |
| `UNIT_AURA_DELTA` | `unitTarget, updateInfo` (the instance IDs of the auras added, updated, and removed) |
| `UNIT_CONNECTION` | `unitTarget, isConnected` |
| `UNIT_DISTANCE_CHECK_UPDATE` | `unitTarget, isInDistance` |
| `UNIT_IN_RANGE_UPDATE` | `unitTarget, isInRange` |
| `UNIT_SPELLCAST_SENT` | `"player", target, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_START` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_STOP` | `unit, castGUID, spellID, spellName, rank` |
@@ -333,6 +365,12 @@ when launching with `-console`), not as Lua functions. See the
| `UNIT_SPELLCAST_RETICLE_CLEAR` | `"player", "", spellID, spellName, rank` |
| `UNIT_SPELLCAST_INTERRUPTIBLE` | `unit` |
| `UNIT_SPELLCAST_NOT_INTERRUPTIBLE` | `unit` |
| `UNIT_MAXPOWER` | `unitTarget, powerType` |
| `UNIT_POWER_FREQUENT` | `unitTarget, powerType` |
| `UNIT_POWER_UPDATE` | `unitTarget, powerType` |
| `UNIT_SPELL_HASTE` | `unitTarget` |
| `UNIT_TARGET` | `unitTarget` |
| `UNIT_TARGETABLE_CHANGED` | `unitTarget` |
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
| `USER_WAYPOINT_UPDATED` | *(none)* |
@@ -363,6 +401,11 @@ when launching with `-console`), not as Lua functions. See the
| Spellbook bank | `Enum.SpellBookSpellBank.{Player,Pet}` (0–1) |
| Spellbook item type | `Enum.SpellBookItemType.{None,Spell,FutureSpell,PetAction,Flyout}` (0–4; 1.12 only yields `Spell`/`PetAction`) |
| Player swing type | `Enum.PlayerSwingType.{MainHand,OffHand,Ranged}` (0–2) |
| Screen location type | `Enum.ScreenLocationType.{Center,Left,Right,Top,Bottom,TopLeft,TopRight,LeftOutside,RightOutside,LeftRight,TopBottom,LeftRightOutside}` (0–11) |
| Self-resurrect option type | `Enum.SelfResurrectOptionType.{Spell,Item}` (0–1) |
| Autocomplete entry flag | `Enum.AutoCompleteEntryFlag.{InGroup,InGuild,Friend,Bnet,InteractedWith,Online,InAOI,AccountCharacter,RecentPlayer}` (bits 1–256) |
| Model blend operation | `Enum.ModelBlendOperation.{None,Anim}` (0–1) |
| Autocomplete priority | `Enum.AutoCompletePriority.{Other,Interacted,InGroup,Guild,Friend,AccountCharacter,AccountCharacterSameRealm}` (0–6) |
</details>
@@ -418,24 +461,31 @@ Download the prebuilt `ClassicAPI.dll` from the
3. Add `ClassicAPI.dll` to `dlls.txt`.
4. Launch the game with `VanillaFixes.exe`.
The bundled `!!!ClassicAPI` addon ships *inside* the DLL — no separate
addon download or install step needed.
The `!!!ClassicAPI`, `ClassicAPI_DebugTools` and `ClassicAPI_EventTrace`
addons are *inside* the DLL. You do not download or install them. The
`src/embed/AddOns/` folder in this repository is source code for the DLL.
Do not copy it into your game.
## Bundled addon: !!!ClassicAPI
The Lua-side companion library lives in
[`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/). It's a 1.12.1 /
[`src/embed/AddOns/!!!ClassicAPI/`](src/embed/AddOns/!!!ClassicAPI/). It's a 1.12.1 /
Lua 5.0 backport of the modern Blizzard helpers that aren't engine
functions but that consumer code still expects to find as globals —
`CallbackRegistryMixin`, `EventRegistry`, `ColorMixin` + `CreateColor`,
`Item` / `ItemLocation`, `MathUtil`(`Lerp` / `Clamp` / `CreateCounter`),
`TableUtil` (`tCompare`, `MergeTable`, `SafePack`, etc.), and `EventUtil`
(`ContinueOnAddOnLoaded` and more). It also holds the conditional slash
`TableUtil` (`tCompare`, `MergeTable`, `SafePack`, etc.), `EventUtil`
(`ContinueOnAddOnLoaded` and more), `FormattingUtil` (`GetMoneyString`,
`FormatLargeNumber` and more), `FrameUtil`, `DataProvider`, `CircularBuffer`,
`EasingUtil`, `ScriptAnimationUtil`, `Interpolator`, `EnumUtil`, `StringUtil`, `AnchorUtil`,
`GridLayoutUtil`, and the layout frame templates (`VerticalLayoutFrame`,
`HorizontalLayoutFrame`, `ResizeLayoutFrame`, `GridLayoutFrame`,
`StaticGridLayoutFrame`). It also holds the conditional slash
commands and `SecureCmdOptionParse`, the parser that reads `[conditions]`
and `@unit` for them.
**You don't have to install the addon manually** — the DLL embeds
the contents of [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) and
the contents of [`src/embed/AddOns/!!!ClassicAPI/`](src/embed/AddOns/!!!ClassicAPI/) and
registers them with the engine as a synthetic addon on startup, so
the library is always available when the DLL is loaded. It fires
`ADDON_LOADED`, supports `SavedVariables`, and is resolvable by name —
@@ -452,44 +502,47 @@ can't be switched off. Being hidden means it's omitted from the
index-based `GetNumAddOns()` / `GetAddOnInfo(i)` enumeration, but every
by-name lookup and dependency reference still resolves normally.
If you drop the folder into your `Interface/AddOns/` directory, **that
copy's files win** — the DLL's embedded version only serves files when
the engine's normal scan doesn't already have a newer entry under that
name. Useful for editing the Lua locally without rebuilding the DLL.
The hidden + force-enabled treatment applies either way (it's keyed to
the addon name, not to which copy serves the files), so the local copy
is also absent from the AddOns list. The dispatch is transparent:
addons consuming `Mixin`, `ColorMixin`, `TableUtil`, etc. behave
identically in both cases.
For development, you can put a copy of the addon folder in
`Interface/AddOns/` and add an empty `.classicapi-dev` file to it. The
game then reads the Lua files from that copy, so you can edit them
without a DLL rebuild. Without this file, the game uses the copy with the
newer `## Version:`. The copy is hidden and always enabled, the same as
the embedded addon.
## Bundled addon: DebugTools
## Lua errors
A 1.12.1 / Lua 5.0 backport of Blizzard's `Blizzard_DebugTools` addon
lives in [`AddOns/DebugTools/`](AddOns/DebugTools/).
It's an independent addon — it doesn't use anything ClassicAPI adds, and
ClassicAPI works fine without it. It's bundled here because it's the
natural companion for testing and debugging anything written against the
1.12 Lua surface (with or without the ClassicAPI extensions).
ClassicAPI replaces the game's Lua error box with a Lua error window. The
window opens when an error occurs. It shows the message, the time, the count,
the stack and the local variables of each error. You can select and copy the
text. The arrow buttons show the previous and the next error. The
**Reload UI** button reloads the interface.
Slash commands provided:
If you use `!BugGrabber` (with BugSack), BugGrabber gets the errors and the
window does not open. If an addon sets its own error handler, the window also
does not open.
| Command | Purpose |
|---------|---------|
| `/dump <expr>` | Pretty-print any Lua value, including tables and multi-return tuples. The right tool for inspecting return values from `GetSpellInfo`, `C_Item.GetItemInfoInstant`, etc. |
| `/etrace` | Event tracer window. `/etrace start`, `/etrace stop`, `/etrace add EVENT`, etc. |
| `/framestack` (or `/fstack`) | Tooltip showing the frame hierarchy under the mouse cursor. |
| `/luaerrors` (or `/scripterrors`) | Lua error display window. |
| `/luaerrors` (or `/scripterrors`) | Opens or closes the Lua error window. |
| `/luaerrors clear` | Removes all errors from the window. |
Lua globals provided (backports of Blizzard helpers):
## Debug tools
| Global | Purpose |
|--------|---------|
| `print(...)` | Backport of Blizzard's `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
| `setprinthandler(func)` / `getprinthandler()` | Install / query a custom print handler. Useful for redirecting print output in tests. |
| `tostringall(...)` | Apply `tostring()` to every vararg, preserving the count. Lua 5.0-compatible (uses `arg.n` since `select` doesn't exist in 5.0). |
ClassicAPI contains backports of two Blizzard addons: `Blizzard_DebugTools`,
with the name `ClassicAPI_DebugTools`, and `Blizzard_EventTrace`, with the
name `ClassicAPI_EventTrace`. The DLL contains these addons, so you do not
install them. They are not in the AddOns list, and you cannot disable them.
To install: copy [`AddOns/DebugTools/`](AddOns/DebugTools/) into your
`Interface/AddOns/` directory like any other addon.
The slash commands are always available. Each addon loads the first time
that you use one of its commands:
| Command | Purpose |
|---------|---------|
| `/dump <expr>` | Shows a Lua value in the chat frame. It shows tables and all return values. Use it to examine the return values of `GetSpellInfo`, `C_Item.GetItemInfoInstant`, and other functions. |
| `/etrace` (or `/eventtrace`) | Opens or closes the event trace window. The window logs each event with its time and its arguments. Move the mouse over an event to see its arguments, and the contents of each table argument. You can search the log, hide events with the filter, and pause the log. `/etrace mark <text>` adds a line of text to the log. |
| `/framestack [showHidden [showRegions]]` (or `/fstack`) | Shows a tooltip with a list of the frames under the cursor. A `-->` mark shows one frame, with its anchors and the file and line that made it. Left Alt and Right Alt move the mark. Ctrl opens the marked frame in the table inspector. Shift shows its texture files. The tooltip also shows the addon that made the marked frame. When `scriptProfile` is `1`, it shows the CPU time of the frame scripts. |
| `/tinspect [expr]` (or `/tins`) | Opens a table inspector on the result of `expr`. With no table, it opens on the frame under the cursor. The window shows the anchors and the fields of the table, grouped by type. You can edit numbers, strings and booleans, filter the lines, and open a child table or frame. |
| `/profiler` (or `/prof`) | Opens the profiler. The profiler shows the Lua memory, the allocations and the CPU time of each addon, frame, event and global function. `/profiler widget` shows a small window with the Lua memory, the frame rate and the addons that use the most. `/profiler reset` sets the counters to zero. CPU times need `scriptProfile` `1`. `/profiler profile on` sets it and reloads the UI. |
## Building
+43 -37
View File
@@ -5,23 +5,25 @@ Candidates for new DLL functions, prioritized. All are things the
addon polyfills badly, hackily, or not at all, and where engine access is the
right answer.
## ~~1. `C_QuestLog.IsQuestFlaggedCompleted(questID)`~~ — NOT FEASIBLE in 1.12
## ~~1. `C_QuestLog.IsQuestFlaggedCompleted(questID)`~~ — DONE (Turtle-lineage servers)
The original TODO note ("engine maintains a completed-quests bitfield in
client memory") was based on 3.3.5 / WotLK semantics. Vanilla 1.12 has no
equivalent: neither `QueryQuestsCompleted` nor `GetQuestsCompleted`
exists, the protocol has no `CMSG_QUERY_QUESTS_COMPLETED`, and the
client doesn't track historical completions. The polyfill at
`Util/C_QuestLog.lua:5-8` works on Frostmourne (3.3.5a) where those
functions exist; on the 1.12 Octo client they're absent.
Stock 1.12 has no completed-quests query and keeps no quest history, so
this exists only where the server offers the list another way. Turtle WoW
and its forks answer the player command `.queststatuses` with `TWQUEST`
guild addon messages from the player, split at 2000 characters, the last
message always shorter (probed: 2003, 2004, 0) - an exact end marker.
Servers that want to expose this in 1.12 (notably Turtle WoW) do it
via the `CHAT_MSG_ADDON` channel — the addon sends `.queststatus` over
guild/whisper chat and the server replies with addon-channel messages
the addon parses. Fundamentally async, server-specific, and requires
SavedVariables-style persistence across sessions to amortize the
round-trip — all of which belong on the Lua side, not in this DLL.
There's nothing the DLL can usefully add here.
[src/quest/Completed.cpp](src/quest/Completed.cpp) does it all in the DLL,
Turtle clients only: it sends the request itself (on
SMSG_LOGIN_VERIFY_WORLD, after each turn-in, and from
`QueryQuestsCompleted`), reads every answer at the packet funnel (sender
must be the player), and keeps the live list behind `GetQuestsCompleted`,
`C_QuestLog.IsQuestFlaggedCompleted` and
`C_QuestLog.GetAllCompletedQuestIDs`; `QUEST_QUERY_COMPLETE` fires per
answer. No disk cache: the first answer lands one round trip after
PLAYER_ENTERING_WORLD, because the server sends the player object (which
fires PLAYER_LOGIN) before it reads any request. Replaces the closed Lua
collector of PR #86.
## ~~2. `C_Spell.GetSpellDescription(spellID)`~~ — DONE
@@ -147,7 +149,7 @@ on a frame timer" goal landed as the request/event pair below:
cache (or synchronously if it was already loaded).
Together those replace the polyfill's 180-second polling frame
(`ItemEventListener` in `Util/ItemUtil.lua:254-372`) — addons can now
(`ItemEventListener`, now in `Util/AsyncCallbackSystem.lua`) — addons can now
fire `RequestLoadItemDataByID(id)` and listen for
`ITEM_DATA_LOAD_RESULT` instead.
@@ -455,8 +457,7 @@ the other spell registrations in [src/spell/Info.cpp](src/spell/Info.cpp).
## ~~26. `C_Item.IsEquippableItem(item)`~~ — DONE
Cache-record read of `m_inventoryType` at `OFF_ITEMSTATS_INVENTORY_TYPE`.
Non-zero = equippable. Lives in `item/Equipment.cpp` alongside
`OffhandHasWeapon` (which uses the same field) and `IsEquippedItem`.
Non-zero = equippable. Lives in `item/Equipment.cpp` alongside `IsEquippedItem`.
Synchronous; returns `false` on cache miss without firing a load.
Namespaced under `C_Item` to match Classic Era 1.15.x.
@@ -1465,20 +1466,12 @@ it on a struct pointer to scan layouts, or from Lua via
`_classicapi_HexDump(label, addr, len)` to inspect engine
globals without a per-investigation probe module.
## ~~56. `OffhandHasWeapon()`~~ — DONE
## ~~56. `OffhandHasWeapon()`~~ — DROPPED
Shipped as `src/item/Equipment.cpp`. Resolves the off-hand
equipment slot (slot 17) via `Item::Location::ResolveEquipmentSlot`,
reads the cached `ItemStats_C` record's `m_inventoryType` at
`+0x2C`, and returns true iff it's `INVTYPE_WEAPON` (13) or
`INVTYPE_WEAPONOFFHAND` (22). Shields, holdables (tomes/orbs),
empty slots, and uncached items all return false.
No async load is fired — modern API behavior matches; if the
off-hand item isn't cached the function silently returns false
until something else warms the cache. Typically only matters on
first login before the player's own gear is in the cache, which
in practice is loaded eagerly anyway.
Vanilla already ships it (`0x004C9570`, same body as 3.3.5/4.3.4:
off-hand slot item class 2 → `1`, else `nil`). Our override returned
booleans and tested inventory type instead; removed so the engine's
own global stands.
## 57. `IsFlying()` — TESTED, DROPPED
@@ -1952,8 +1945,8 @@ current item/spell/unit. Approach:
4. Each match reveals the offset for that state field.
Once located, `GetItem()`, `GetSpell()`, `GetUnit()` are trivial
deref+push. Implementation roughly the same as `OffhandHasWeapon`
or `IsEquippedItem` patterns.
deref+push. Implementation roughly the same as the `IsEquippedItem`
pattern.
### Risks
@@ -3818,7 +3811,7 @@ of descriptors feeds the in-game browser, a page generator, and a drift check.
registration on the first pass of each Lua state, derives systems, and
exports `C_APIDocumentation.GetSystems()` / `GetSystem(name)` lazily in
Blizzard's exact table shape, plus `_classicapi_UndocumentedAPI()`.
- `AddOns/!!!ClassicAPI/APIDocumentation/` — the 8-file port of
- `src/embed/AddOns/!!!ClassicAPI/APIDocumentation/` — the 8-file port of
`Blizzard_APIDocumentation`, driven by `/classicapi` (short `/capi`).
Named for what it documents: the browser holds only ClassicAPI's own
surface, so `/api` would have over-promised.
@@ -4088,9 +4081,22 @@ Each entry still needs the usual check of how the engine does it before building
`GetRotatesTexture` (`CSimpleStatusBar.cpp:135`, the vertical texcoord path
at `:285`). Retail adds `SetFillStyle` (STANDARD / CENTER / REVERSE),
`SetColorFill`, `SetStatusBarDesaturated` / `Desaturation`.
- **ScrollingMessageFrame** `SetInsertMode` / `GetInsertMode`,
`GetMessageInfo`, `RemoveMessagesByAccessID`, `RemoveMessagesByExtraData`,
`SetScrollOffset` — in C in 4.3.4 (retail moved the type to Lua).
- **DONE** (`src/scrollingmessageframe/`, retail's shapes).
**ScrollingMessageFrame** `GetMessageInfo`, `SetScrollOffset` (1.12's
`SetScrollFromBottom`, 0x00793070, under its later name, clamped as
retail), `GetScrollOffset`, `GetMaxScrollRange`, `ScrollByAmount`,
`AddOnDisplayRefreshedCallback`, `SetInsertMode` / `GetInsertMode` (TOP
through a redirect of the layout's one line-anchor call, 0x00788975),
`BackFillMessage` (writes 4.3.4's addToTop slot of the ring, the free one
before the oldest; a full frame drops it, as retail's CircularBuffer
`PushBack` does — 4.3.4 overwrites the oldest there).
- **DONE** (`src/scrollingmessageframe/MessageInfo.cpp`).
**ScrollingMessageFrame** `RemoveMessagesByPredicate` (retail's shape; it
covers 4.3.4's `RemoveMessagesByAccessID` / `RemoveMessagesByExtraData`,
whose int fields our retail-shaped records do not have). The compaction
is 4.3.4's `RemoveLines` loop over 1.12's ring, then the engine's own
layout (0x00788750) at the kept scroll offset and re-arm (0x00788B80),
or Clear (0x007882B0) when nothing is kept.
- **DONE** (`src/editbox/Focus.cpp`, `src/animation/Framerate.cpp`).
**Globals** (`SimpleFrameScript_Scripts.cpp`): `GetCurrentKeyBoardFocus()`
(the focused edit box's wrapper, or nil); `SetMaxAnimFramerate(fps)` /
+144 -46
View File
@@ -1,37 +1,83 @@
# embed_resources.cmake — embed the addon and the bindings XML as Win32
# RCDATA resources.
# embed_resources.cmake — embed the addons, the bindings XML and the
# built-in textures as Win32 RCDATA resources.
#
# Runs at configure time. It writes two files into OUT_DIR:
#
# embedded.rc one `<id> RCDATA "<path>"` line per file; rc.exe
# reads each file straight from disk.
# embedded.rc one `<id> RCDATA "<path>"` line per pack; rc.exe
# reads each gzipped pack from disk.
# embedded_resources.h the IDs the DLL looks up at runtime through
# src/embed/Resource.h:
#
# namespace Embed::Ids { constexpr int kBindingsXml = ...; }
# namespace Addons::Embedded::ClassicAPIFiles {
# struct File { const char *path; int resourceId; };
# namespace Addons::Embedded::Manifest {
# constexpr const char *kAddons[] = { ... }; // in ADDONS order
# constexpr size_t kAddonCount = ...;
# struct File { const char *path; int resourceId; int index; };
# constexpr File kFiles[] = { ... };
# constexpr size_t kFileCount = ...;
# }
# namespace Texture::Builtin::Manifest {
# struct File { const char *path; int resourceId; int index; };
# constexpr File kFiles[] = { ... };
# constexpr size_t kFileCount = ...;
# }
#
# Addon paths are relative to the addon folder, in backslash form
# (`Util\Print.lua`) — the suffix the engine builds after
# `Interface\AddOns\!!!ClassicAPI\`. The DLL compares them ignoring case.
# Each resource is a pack: one gzip stream of several files, which
# pack_files.cmake joins at build time into `OUT_DIR/embed/<name>.pack.gz`.
# Resource 1 is the bindings XML, a pack of one; then each addon is one
# pack, in ADDONS order; then the textures, one pack. A pack compresses each
# file against the files before it, so the addon text packs to about 22% of
# its size, against 27% for one gzip stream per file. The DLL inflates a
# whole pack with the engine's zlib on first use (src/embed/Resource.cpp),
# so the load-on-demand addon costs nothing until it loads.
#
# The `.toc` is the one file that is not embedded verbatim: its
# Addon file paths are relative to ADDONS_ROOT, in backslash form
# (`!!!ClassicAPI\Util\Print.lua`) — the suffix the engine builds after
# `Interface\AddOns\`. Texture paths are relative to TEXTURES_ROOT, where
# each file sits at its game path (`Interface\Cursor\Quest.blp`), for
# src/texture/Builtin.cpp. The DLL compares both ignoring case. A file's
# index is its place in its pack, which is in path order.
#
# Each addon's `.toc` is the one file that is not embedded verbatim: its
# `## Version:` line is stamped with ADDON_VERSION into a copy under
# OUT_DIR, so a release build is a pure function of `-DCLASSICAPI_TAG` and
# the source toc stays `DEV`.
# the source tocs stay `DEV`. The developer marker `.classicapi-dev` is
# never embedded: it must reflect on-disk presence only (see
# DiskHasDevMarker in src/addons/Embedded.cpp).
#
# Rebuild triggers: adding or removing a file reconfigures through the
# caller's CONFIGURE_DEPENDS glob; editing the toc reconfigures through
# CMAKE_CONFIGURE_DEPENDS; editing any other file recompiles the .rc
# through its OBJECT_DEPENDS.
# CONFIGURE_DEPENDS glob; editing a toc reconfigures through
# CMAKE_CONFIGURE_DEPENDS; editing any other file re-packs its addon through
# the pack's custom command, which recompiles the .rc through its
# OBJECT_DEPENDS.
#
# Usage:
# embed_resources(TARGET <t> ADDON_DIR <dir> ADDON_FILES <rel paths...>
# ADDON_VERSION <v> BINDINGS_XML <file> OUT_DIR <dir>)
# embed_resources(TARGET <t> ADDONS_ROOT <dir> ADDONS <folder names...>
# ADDON_VERSION <v> BINDINGS_XML <file>
# TEXTURES_ROOT <dir> OUT_DIR <dir>)
set(_EMBED_PACK_SCRIPT "${CMAKE_CURRENT_LIST_DIR}/pack_files.cmake")
# Add the build-time command that packs `ARGN` (absolute paths, in pack
# order) into `<out_dir>/embed/<name>.pack.gz`, and set `gz` in the caller
# to that path. The paths reach the script through a generated list file:
# an addon's paths are too long for one command line.
function(_embed_pack out_dir name)
set(pack "${out_dir}/embed/${name}.pack")
set(list "set(FILES\n")
foreach(path ${ARGN})
string(APPEND list " [==[${path}]==]\n")
endforeach()
string(APPEND list ")\n")
_embed_write_if_changed("${pack}.cmake" "${list}")
add_custom_command(
OUTPUT "${pack}.gz"
COMMAND "${CMAKE_COMMAND}" "-DLIST=${pack}.cmake" "-DOUT=${pack}.gz"
-P "${_EMBED_PACK_SCRIPT}"
DEPENDS ${ARGN} "${pack}.cmake" "${_EMBED_PACK_SCRIPT}"
VERBATIM)
set(gz "${pack}.gz" PARENT_SCOPE)
endfunction()
# Write `content` to `path` only when it differs, so an unchanged
# reconfigure does not touch the file and trigger a rebuild.
@@ -43,58 +89,93 @@ function(_embed_write_if_changed path content)
endfunction()
function(embed_resources)
cmake_parse_arguments(E "" "TARGET;ADDON_DIR;ADDON_VERSION;BINDINGS_XML;OUT_DIR"
"ADDON_FILES" ${ARGN})
cmake_parse_arguments(E ""
"TARGET;ADDONS_ROOT;ADDON_VERSION;BINDINGS_XML;TEXTURES_ROOT;OUT_DIR"
"ADDONS" ${ARGN})
set(rc "// Generated by cmake/embed_resources.cmake - do not edit.\n\n")
set(addon_names "")
set(manifest "")
set(inputs "")
set(id 1)
string(APPEND rc "${id} RCDATA \"${E_BINDINGS_XML}\"\n")
list(APPEND inputs "${E_BINDINGS_XML}")
set(bindings_id ${id})
math(EXPR id "${id} + 1")
set(bindings_id 1)
_embed_pack("${E_OUT_DIR}" "Bindings.xml" "${E_BINDINGS_XML}")
string(APPEND rc "${bindings_id} RCDATA \"${gz}\"\n")
list(APPEND inputs "${gz}")
set(files ${E_ADDON_FILES})
list(SORT files) # deterministic IDs
foreach(rel ${files})
set(abs "${E_ADDON_DIR}/${rel}")
if(rel MATCHES "\\.toc$")
file(READ "${abs}" toc)
string(REGEX REPLACE "## Version:[^\n\r]*"
"## Version: ${E_ADDON_VERSION}" toc "${toc}")
set(stamped "${E_OUT_DIR}/embed/${rel}")
_embed_write_if_changed("${stamped}" "${toc}")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${abs}")
set(abs "${stamped}")
endif()
string(APPEND rc "${id} RCDATA \"${abs}\"\n")
list(APPEND inputs "${abs}")
string(REPLACE "/" "\\\\" rel_bs "${rel}")
string(APPEND manifest " { \"${rel_bs}\", ${id} },\n")
set(id 2)
foreach(addon ${E_ADDONS})
string(APPEND addon_names " \"${addon}\",\n")
file(GLOB_RECURSE files CONFIGURE_DEPENDS RELATIVE
"${E_ADDONS_ROOT}" "${E_ADDONS_ROOT}/${addon}/*")
list(FILTER files EXCLUDE REGEX "(^|/)\\.classicapi-dev$")
list(SORT files) # deterministic indexes
set(paths "")
set(index 0)
foreach(rel ${files})
set(abs "${E_ADDONS_ROOT}/${rel}")
if(rel MATCHES "\\.toc$")
file(READ "${abs}" toc)
string(REGEX REPLACE "## Version:[^\n\r]*"
"## Version: ${E_ADDON_VERSION}" toc "${toc}")
set(stamped "${E_OUT_DIR}/embed/${rel}")
_embed_write_if_changed("${stamped}" "${toc}")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${abs}")
set(abs "${stamped}")
endif()
list(APPEND paths "${abs}")
string(REPLACE "/" "\\\\" rel_bs "${rel}")
string(APPEND manifest " { \"${rel_bs}\", ${id}, ${index} },\n")
math(EXPR index "${index} + 1")
endforeach()
_embed_pack("${E_OUT_DIR}" "${addon}" ${paths})
string(APPEND rc "${id} RCDATA \"${gz}\"\n")
list(APPEND inputs "${gz}")
math(EXPR id "${id} + 1")
endforeach()
file(GLOB_RECURSE textures CONFIGURE_DEPENDS RELATIVE
"${E_TEXTURES_ROOT}" "${E_TEXTURES_ROOT}/*")
list(SORT textures) # deterministic indexes
set(texture_paths "")
set(texture_manifest "")
set(index 0)
foreach(rel ${textures})
list(APPEND texture_paths "${E_TEXTURES_ROOT}/${rel}")
string(REPLACE "/" "\\\\" rel_bs "${rel}")
string(APPEND texture_manifest " { \"${rel_bs}\", ${id}, ${index} },\n")
math(EXPR index "${index} + 1")
endforeach()
_embed_pack("${E_OUT_DIR}" "Textures" ${texture_paths})
string(APPEND rc "${id} RCDATA \"${gz}\"\n")
list(APPEND inputs "${gz}")
_embed_write_if_changed("${E_OUT_DIR}/embedded.rc" "${rc}")
_embed_write_if_changed("${E_OUT_DIR}/embedded_resources.h"
"// Generated by cmake/embed_resources.cmake — do not edit.
// Resource IDs for the RCDATA entries in embedded.rc. Load them through
// src/embed/Resource.h.
// Resource IDs for the RCDATA packs in embedded.rc, and each embedded file's
// place in them. Load them through src/embed/Resource.h.
#pragma once
#include <cstddef>
namespace Embed::Ids {
constexpr int kBindingsXml = ${bindings_id};
constexpr int kBindingsXml = ${bindings_id}; // a pack of the one file
} // namespace Embed::Ids
namespace Addons::Embedded::ClassicAPIFiles {
namespace Addons::Embedded::Manifest {
// Registration order: the first one registers, and so loads, first.
constexpr const char *kAddons[] = {
${addon_names}};
constexpr size_t kAddonCount = sizeof(kAddons) / sizeof(kAddons[0]);
struct File {
const char *path;
int resourceId;
int resourceId; // its addon's pack
int index; // its place in the pack
};
constexpr File kFiles[] = {
@@ -102,9 +183,26 @@ ${manifest}};
constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]);
} // namespace Addons::Embedded::ClassicAPIFiles
} // namespace Addons::Embedded::Manifest
namespace Texture::Builtin::Manifest {
struct File {
const char *path; // the game path
int resourceId; // the textures' pack
int index; // its place in the pack
};
constexpr File kFiles[] = {
${texture_manifest}};
constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]);
} // namespace Texture::Builtin::Manifest
")
add_custom_target(${E_TARGET}_embed DEPENDS ${inputs})
add_dependencies(${E_TARGET} ${E_TARGET}_embed)
target_sources(${E_TARGET} PRIVATE "${E_OUT_DIR}/embedded.rc")
set_source_files_properties("${E_OUT_DIR}/embedded.rc" PROPERTIES
OBJECT_DEPENDS "${inputs}")
+50
View File
@@ -0,0 +1,50 @@
# pack_files.cmake — join files into one gzipped pack at build time, for
# embed_resources.cmake.
#
# cmake -DLIST=<list.cmake> -DOUT=<pack.gz> -P pack_files.cmake
#
# LIST sets FILES to the paths to join, in order. The pack is one gzip
# stream, so each file compresses against the files before it: the addon
# text packs about 16% smaller than when each file is gzipped alone. Inside
# the stream is an index, each file's size as a decimal line and then an
# empty line, followed by the files' bytes in FILES order. The DLL splits
# it in src/embed/Resource.cpp.
include("${LIST}")
set(index "")
foreach(file IN LISTS FILES)
file(SIZE "${file}" size)
string(APPEND index "${size}\n")
endforeach()
# Not file(WRITE): on Windows it writes each \n as \r\n.
file(CONFIGURE OUTPUT "${OUT}.index" CONTENT "${index}\n" @ONLY NEWLINE_STYLE UNIX)
# Join `ARGN` byte for byte into `out`.
function(cat out)
execute_process(COMMAND "${CMAKE_COMMAND}" -E cat ${ARGN}
OUTPUT_FILE "${out}" RESULT_VARIABLE rc)
if(NOT rc EQUAL 0)
message(FATAL_ERROR "pack_files.cmake: cannot join into ${out}: ${rc}")
endif()
endfunction()
# `cmake -E cat` takes its files on the command line, which Windows limits
# to 32767 characters: join in batches of 100, then join the batches.
set(parts "${OUT}.index")
list(LENGTH FILES count)
set(first 0)
while(first LESS count)
list(SUBLIST FILES ${first} 100 batch)
set(part "${OUT}.part${first}")
cat("${part}" ${batch})
list(APPEND parts "${part}")
math(EXPR first "${first} + 100")
endwhile()
cat("${OUT}.raw" ${parts})
# The raw format holds the one file's bytes with no archive entry around
# them, so OUT is a plain gzip stream.
file(ARCHIVE_CREATE OUTPUT "${OUT}" PATHS "${OUT}.raw" FORMAT raw
COMPRESSION GZip COMPRESSION_LEVEL 9)
file(REMOVE ${parts} "${OUT}.raw")
+8269 -1282
View File
File diff suppressed because it is too large Load Diff
+40 -26
View File
@@ -27,24 +27,22 @@ The DLL hooks the engine's own text pipeline — no companion addon:
pen is lazy and omits the last glyph's own advance, so a coin sat on its
digits; the emitter now adds the true terminal advance from the engine's own
glyph-advance helpers (`550cf5e`).
- **Rendering** — the paint-pass co-hook `FUN_005c8fe0` walks the icon records
and queues each to `Text::InlineTexturePool`, which places the region on the
next frame tick (never mid-render).
- **Chat has no one-frame lag** — a ScrollingMessageFrame re-`SetText`s every
visible line on each new message, and the engine builds each line lazily at
paint, so a line's icons would land one frame after its glyphs (glyphs draw
synchronously; the icon regions are separate objects whose placement is only
known once the line builds mid-paint, after the frame's texture pass already
drew). A co-hook on the SMF display refresh (`FUN_00788750`, which runs
pre-render from the frame's own methods) forces each icon-bearing line to
resolve+build and applies its icon placement synchronously — before the frame
draws — so icons move in lockstep with their text. It reuses the engine's own
`Realize(0)` → `RebuildString` → ensure-built path and the same placement math
the paint flush uses (`ComputePlacementsForNode`), so the later flush dedups to
a no-op. Additive and best-effort: any line whose rect isn't resolvable in time
falls back to the paint + tick pipeline (correct, one-frame lagged). A pure
scroll or drag never lagged — the icons are anchored to the line and the engine
moves them for free; only content changes on a reused line needed this.
- **Rendering** — 4.3.4's `CSimpleEmbeddedTexture` model. Each icon is a
pooled `CSimpleTexture` with no frame, anchored to its fontstring: it is in
no draw layer, so the fontstring draws it. The fontstring's batch call
(`FUN_FONTSTRING_EMIT` `0x00772E50`, 4.3.4's `CSimpleFontString::Draw` →
`QueueTextures`) adds the icons right after its text, and only when the text
went in, so an icon shows exactly when its line does — hidden, faded out, off
screen, or scrolled off the top of a chat frame — in the same frame.
- **No one-frame lag** — 4.3.4 sets the embedded textures up in `UpdateString`,
right after it builds the string. The `RebuildString` co-hook does the same:
a rebuild drops the fontstring's icons, lays the new node out now (the
ensure-built call the paint makes) and applies the placements at once, so a
string's icons draw with its text wherever it was rebuilt (a chat line laid
out by its frame, a tooltip line, a rect change). The paint-pass co-hook
`FUN_005c8fe0` still walks the icon records and queues them to
`Text::InlineTexturePool` for the next frame tick: the backstop for a string
whose placement can't be computed at rebuild time.
- **Placement coordinates** — text pen space IS render-target pixels. The
engine stores node positions NORMALIZED (fs-local anchor ÷
`[0x832A44]`/`[0x832A48]` per axis, `FUN_0041ade0`), and the origin finalize
@@ -86,9 +84,11 @@ The DLL hooks the engine's own text pipeline — no companion addon:
(`49934b0`, `b9d0705`).
- **Chat fade** — icons fade in lockstep with their chat line (`3ca3b7e`).
- **Edit boxes** — render the icons in their text, as 4.3.4 does; the caret,
the mouse and the arrow keys treat an icon as one character, and Ctrl+C
drops icons (4.3.4's 0x1500 strip mask). Typed markup stays literal by the
engine's own input escape (below).
the mouse and the arrow keys treat an icon as one character, Ctrl+C
drops icons (4.3.4's 0x1500 strip mask), and a multi-line box's caret,
selection and click rows follow the lines a tall icon moves (below;
4.3.4's own rows do not). Typed markup stays literal by the engine's own
input escape (below).
- **Payload** — the full
`|Tpath:height:width:offsetX:offsetY:texW:texH:left:right:top:bottom:r:g:b|t`:
size, pen offset, sprite-sheet crop, and vertex tint. BLP and uncompressed
@@ -105,10 +105,8 @@ Every fs-level measure path now counts icons: width (`FUN_00772890`), height
refute that: the GameTooltip auto-size measures each WRAPPED SEGMENT of a
wrap-enabled line through it, so icon-bearing wrapped tooltip lines
undersized their tooltip until it was hooked too). The Lua control surface is
two toggles: `_classicapi_InlineTexEnable` (the whole-feature kill switch; the
SEH latch trips it on a flush fault) and `_classicapi_InlineTexEarly` (the
SMF-refresh early apply above — on by default, toggle off to A/B the chat lag
against the baseline paint+tick pipeline, which keeps running either way). The
one toggle: `_classicapi_InlineTexEnable` (the whole-feature kill switch; the
SEH latch trips it on a fault). The
bring-up tune / stat / probe functions were removed (`5e5677b`, `6800a9a`).
## REMOVED (bring-up record) — the raw-GxU-quad rendering primitive
@@ -377,7 +375,7 @@ funnel for exactly 4 callers:
- `Script_GetStringWidth` (0x0079E510)
- the GameTooltip auto-size (0x00530640)
- the editbox caret positioner (0x0077DE70)
- the editbox selection-piece placer (0x0077DE70, a whole last line's width)
- the layout effective-width vmethod (`FUN_00772930` = fs+0x24 vtbl +0x1C,
which feeds auto-width fontstring layout)
@@ -523,6 +521,22 @@ the same input escape, so ClassicAPI's edit boxes need no special case:
icon with the following character into one step; ours is its own step.
- **Caret x** — `FUN_0077DA80` measures the line through
`FUN_00772AE0`, whose co-hook adds the icons.
- **Rows (multi-line)** — `PlaceCaret` (`0x77da80`), `PlaceSelectionPiece`
(`0x77de70`) and `GetIndexAtPoint` (`0x77d0d0`) step rows of one height
(font height + spacing, pixel-aligned), as 4.3.4's `UpdateVisibleCursor`,
`UpdateHighlightArea` and `ConvertCoordinateToIndex` do — while the render
moves a tall-icon line and every line after it down by the overflow
(`InitializeText`; the emitter co-hook) and the box grows to match (the
string-height co-hook). 4.3.4 has that fault too; `EditBox::IconRows`
(`src/editbox/IconRows.cpp`) gives each placer the render's move, from
`Text::InlineTexture::LineIconShift` over the box's own line table: the
click's y is moved onto the uniform row of the icon-aware row under it
before the engine maps it; a selection piece is re-anchored after the
engine placed it, with the engine's own x; the caret likewise, then
`OnCursorChanged` fired once with the moved y through
`FUN_FRAME_RUN_SCRIPT_VARIADIC` exactly as `PlaceCaret` fires it (its own
fire muted for the call by emptying the slot), so
`ScrollingEditBoxMixin:ScrollCursorIntoView` scrolls to the real caret.
- **Click → cursor, single-line window** — `GetIndexAtPoint` (`0x77d0d0`),
`RebuildLabel` and `MeasureWindow` count characters with
`FUN_FONTSTRING_FIT_TEXT` (`0x772bc0` → `0x44d860` → `0x5c6c50`), whose
+3 -3
View File
@@ -178,9 +178,9 @@ behaviour the comment describes (`push 0; mov edx, 1; mov ecx, L`) is
| `0x006F4450` | `lua_next` | fastcall | calls `luaH_next` at `0x006FA2A0` | ★ |
| `0x006F44E0` | `lua_concat` | fastcall | calls `luaV_concat` at `0x006F84D0` | ★ |
| `0x006F4560` | `lua_newuserdata` | fastcall | tag 7; calls `luaS_newudata` at `0x006F9E30` | ★ |
| `0x006F45B0` | `lua_getupvalue` | fastcall | reads upvalue from C closure | ◦ |
| `0x006F4660` | `lua_setupvalue` | fastcall | | ◦ |
| `0x006F46E0` | *internal upvalue helper*| | shared by get/set above | ◦ |
| `0x006F45B0` | `lua_pushupvalues` | fastcall | pushes the running C closure's upvalues, returns how many | ★ |
| `0x006F4660` | `lua_getupvalue` | fastcall | `(L, funcindex, n)`: pushes upvalue n, returns its name or null | ★ |
| `0x006F46E0` | `aux_upvalue` | | finds upvalue n of a function, returns its name | ★ |
## Auxiliary library (`luaL_*`)
+44 -1
View File
@@ -48,8 +48,35 @@ bool RedirectCall(uintptr_t site, uintptr_t expected, const void *target) {
return PatchBytes(reinterpret_cast<uint8_t *>(site) + 1, &rel, sizeof rel);
}
bool RedirectJcc(uintptr_t site, uintptr_t expected, const void *target) {
const auto *code = reinterpret_cast<const uint8_t *>(site);
if (code[0] != 0x0F || (code[1] & 0xF0) != 0x80)
return false;
const auto dest = [site](int32_t rel) { return site + 6 + static_cast<uintptr_t>(rel); };
int32_t rel = 0;
std::memcpy(&rel, code + 2, sizeof rel);
const uintptr_t to = reinterpret_cast<uintptr_t>(target);
if (dest(rel) == to)
return true;
if (dest(rel) != expected)
return false;
rel = static_cast<int32_t>(to - (site + 6));
return PatchBytes(reinterpret_cast<uint8_t *>(site) + 2, &rel, sizeof rel);
}
bool RedirectSlot(uintptr_t slot, uintptr_t expected, const void *target) {
uintptr_t current = 0;
std::memcpy(&current, reinterpret_cast<const void *>(slot), sizeof current);
const uintptr_t to = reinterpret_cast<uintptr_t>(target);
if (current == to)
return true;
if (current != expected)
return false;
return PatchBytes(reinterpret_cast<void *>(slot), &to, sizeof to);
}
bool ReplaceWithCall(uintptr_t site, const uint8_t *original, size_t len, const void *target) {
uint8_t code[16];
uint8_t code[64];
if (len < 5 || len > sizeof code)
return false;
code[0] = 0xE8;
@@ -86,4 +113,20 @@ void EnsureParentDirs(const char *relPath) {
}
}
char FoldPathChar(char c) {
if (c == '/')
return '\\';
if (c >= 'A' && c <= 'Z')
return static_cast<char>(c + ('a' - 'A'));
return c;
}
bool SamePath(const char *a, const char *b) {
for (; FoldPathChar(*a) == FoldPathChar(*b); ++a, ++b) {
if (*a == '\0')
return true;
}
return false;
}
} // namespace Common
+17 -2
View File
@@ -24,8 +24,16 @@ bool PatchBytes(void *dst, const void *src, size_t len);
// Idempotent, so it can run on every registration pass.
bool RedirectCall(uintptr_t site, uintptr_t expected, const void *target);
// Writes `call target` plus NOP padding over the `len`-byte (>= 5)
// instruction at `site` when it still holds `original`; true when the site
// RedirectCall for a `0F 8x rel32` conditional jump: points the taken branch
// at `site` to `target` when it still reaches `expected`. Same contract.
bool RedirectJcc(uintptr_t site, uintptr_t expected, const void *target);
// RedirectCall for a function-pointer slot (a vtable entry): points the slot
// at `slot` to `target` when it still holds `expected`. Same contract.
bool RedirectSlot(uintptr_t slot, uintptr_t expected, const void *target);
// Writes `call target` plus NOP padding over the `len` bytes (5 to 64) of
// code at `site` when they still hold `original`; true when the site
// now calls `target`. A site holding anything else (another DLL's patch, a
// different build) is left alone. Idempotent, like RedirectCall.
bool ReplaceWithCall(uintptr_t site, const uint8_t *original, size_t len, const void *target);
@@ -41,4 +49,11 @@ void BoundedCopy(char *dst, const char *src, size_t n);
// directory. Directories that already exist are a no-op.
void EnsureParentDirs(const char *relPath);
// `c` as game paths compare it: '/' as '\', and A-Z as a-z.
char FoldPathChar(char c);
// Whether `a` and `b` are the same game path: equal after FoldPathChar, so
// any case and either slash.
bool SamePath(const char *a, const char *b);
} // namespace Common
+19 -13
View File
@@ -80,10 +80,13 @@ static void __stdcall LoadGlueScriptFunctions_h() {
}
// Every Lua-side `frame:RegisterEvent(...)` is a chance to claim a slot
// for any custom event still waiting. By this point the engine's table
// is fully populated and SuperWoWhook / other DLLs have done their
// post-rebuild writes, so the table state is settled and our backwards
// walk finds genuine NULL slots near the tail.
// for any custom event still waiting. By the first FrameXML registration
// the engine's table is fully populated and SuperWoWhook / other DLLs have
// done their post-rebuild writes, so the walk from the low end finds
// genuinely free slots. A registration that arrives EARLIER (another DLL's
// frame, between LoadScriptFunctions and the table build — issue #134)
// still sees glue's 26-entry table; `RetryClaims` refuses that table and
// re-validates its cached slots against the live one on every call.
static void __fastcall FrameRegisterEvent_h(void *frame, void *edx,
const char *eventName) {
Event::Custom::RetryClaims();
@@ -234,16 +237,19 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
CloseHandle(worker);
}
} else if (reason == DLL_PROCESS_DETACH) {
// Clean /quit path — only tear down if we actually initialized.
// Flush before MH_Uninitialize — the cache's file I/O uses Win32
// directly (no MinHook involvement), so either order works in
// practice, but flushing first lets us bail early if the hook
// teardown ever grows side effects. Hard process termination (task
// manager kill) bypasses this path; that's an inherent OS
// limitation, and the 5-minute backstop flush in Remember() covers
// the worst case there.
// Process exit — only tear down if we actually initialized. A
// clean /quit already saved the name cache from its atexit handler,
// which runs before this; this save covers an exit that skips the
// atexit handlers (Storm's fatal exit 0x0064CC80 calls ExitProcess
// itself) and a save that failed there. Save before
// MH_Uninitialize — the cache's file I/O uses Win32 directly (no
// MinHook involvement), so either order works in practice, but
// saving first lets us bail early if the hook teardown ever grows
// side effects. Hard process termination (task manager kill)
// bypasses this path; that's an inherent OS limitation, and the
// 5-minute backstop flush covers the worst case there.
if (g_mhInitialized) {
Player::NameCache::Flush();
Player::NameCache::Shutdown();
MH_Uninitialize();
}
}
+69 -18
View File
@@ -15,6 +15,7 @@
#include "MinHook.h"
#include "Offsets.h"
#include "api/Documentation.h"
#include "compat/WowTurbo.h"
namespace Game {
@@ -29,15 +30,23 @@ float PixelToInternal(float px) {
const float div = Read<float>(Offsets::VAR_COORD_ASPECT_COMPENSATION);
if (div == 0.0f)
return 0.0f;
return px * mul / (div * static_cast<float>(Offsets::UI_COORD_SCALE_UNIT));
return px * mul / (div * Read<float>(Offsets::FLOAT_SCRIPT_COORD_SCALE));
}
double InternalToPixel(float internal) {
const float mul = Read<float>(Offsets::VAR_COORD_DDC_WIDTH_SCALE);
const float div = Read<float>(Offsets::VAR_COORD_ASPECT_COMPENSATION);
if (mul == 0.0f)
using GetScaledScreenAspect_t = float(__cdecl *)();
// Declared double so the quotient is read from ST0, as the engine's
// pushes store it with `fstp qword`, not narrowed to float.
using DivideByScreenScaleX_t = double(__stdcall *)(float);
if (Read<float>(Offsets::VAR_COORD_DDC_WIDTH_SCALE) == 0.0f)
return 0.0;
return static_cast<double>(internal) * div * Offsets::UI_COORD_SCALE_UNIT / mul;
// `aspect * 1024` is exact, so the float product rounds once, as the
// engine's `fstp dword` does.
const float aspect =
reinterpret_cast<GetScaledScreenAspect_t>(Offsets::FUN_COORD_GET_SCALED_SCREEN_ASPECT)();
const float scaled = aspect * Read<float>(Offsets::FLOAT_SCRIPT_COORD_SCALE) * internal;
return reinterpret_cast<DivideByScreenScaleX_t>(Offsets::FUN_COORD_DIVIDE_BY_SCREEN_SCALE_X)(
scaled);
}
bool StringToFlag(const char *text) {
@@ -72,12 +81,14 @@ namespace Lua {
F(GetTable, lua_gettable, LUA_GET_TABLE) \
F(RawGet, lua_rawget, LUA_RAW_GET) \
F(RawGetI, lua_rawgeti, LUA_RAWGETI) \
F(RawSetI, lua_rawseti, LUA_RAW_SET_I) \
F(SetTable, lua_settable, LUA_SET_TABLE) \
F(RawSet, lua_rawset, LUA_RAW_SET) \
F(GetMetatable,lua_getmetatable,LUA_GET_METATABLE)\
F(SetMetatable,lua_setmetatable,LUA_SET_METATABLE)\
F(Insert, lua_insert, LUA_INSERT) \
F(Remove, lua_remove, LUA_REMOVE) \
F(Replace, lua_replace, LUA_REPLACE) \
F(GetTop, lua_gettop, LUA_GET_TOP) \
F(SetTop, lua_settop, LUA_SET_TOP) \
F(Call, lua_call, LUA_CALL) \
@@ -89,13 +100,24 @@ namespace Lua {
F(ToPointer, lua_topointer, LUA_TO_POINTER) \
F(ToThread, lua_tothread, LUA_TO_THREAD) \
F(IsCFunction, lua_iscfunction, LUA_IS_C_FUNCTION)\
F(ToCFunction, lua_tocfunction, LUA_TO_C_FUNCTION)\
F(GetStack, lua_getstack, LUA_GET_STACK) \
F(GetInfo, lua_getinfo, LUA_GET_INFO) \
F(XMove, lua_xmove, LUA_X_MOVE) \
F(NewThread, lua_newthread, LUA_NEW_THREAD) \
F(Resume, lua_resume, LUA_RESUME) \
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
F(CheckLString,luaL_checklstring,LUAL_CHECK_LSTRING)\
F(CheckNumber, luaL_checknumber,LUAL_CHECK_NUMBER)\
F(OptNumber, luaL_optnumber, LUAL_OPT_NUMBER) \
F(SetN, luaL_setn, LUAL_SETN) \
F(CheckStack, lua_checkstack, LUA_CHECK_STACK)
F(Ref, luaL_ref, LUA_REF_REF) \
F(Unref, luaL_unref, LUA_REF_UNREF) \
F(CheckStack, lua_checkstack, LUA_CHECK_STACK) \
F(Concat, lua_concat, LUA_CONCAT) \
F(NewUserdata, lua_newuserdata, LUA_NEW_USERDATA) \
F(ToUserdata, lua_touserdata, FUN_FRAMESCRIPT_GET_OBJECT)
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
@@ -148,11 +170,14 @@ bool ToFlag(void *L, int index, bool defaultValue) {
defaultValue ? 1 : 0) != 0;
}
const char *ObjectDisplayName(void *object) {
const char *ObjectName(void *object) {
using GetName_t = const char *(__thiscall *)(void *);
const void *vtable = Game::Read<const void *>(object, 0);
const char *name = Game::Read<GetName_t>(
vtable, Offsets::VTBL_SCRIPTOBJECT_GET_NAME)(object);
return Game::Read<GetName_t>(vtable, Offsets::VTBL_SCRIPTOBJECT_GET_NAME)(object);
}
const char *ObjectDisplayName(void *object) {
const char *name = ObjectName(object);
return name != nullptr ? name
: reinterpret_cast<const char *>(Offsets::VAR_STR_UNNAMED);
}
@@ -235,6 +260,12 @@ void *ResolveEditBox(void *L, int idx, bool raiseError) {
void *ResolveModel(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_MODEL_LUA_TYPE_ID, raiseError);
}
void *ResolvePlayerModel(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_PLAYERMODEL_LUA_TYPE_ID, raiseError);
}
void *ResolveDressUpModel(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_DRESSUPMODEL_LUA_TYPE_ID, raiseError);
}
// Looks up `_G[name]`. If absent, creates a fresh table and binds it.
// Leaves the resulting table on top of the stack.
@@ -344,11 +375,11 @@ void RegisterGlueFunction(const char *name, CFunction func, const Doc::Function
Api::Documentation::RecordGlobal(name, doc);
}
void RegisterFrameMethods(void *context, const FrameMethodEntry *table, int count,
const Doc::Method *docs, int docCount) {
void RegisterFrameMethods(void *context, ArrayRef<FrameMethodEntry> methods,
Doc::MethodList docs) {
auto fn = reinterpret_cast<RegisterFrameMethods_t>(Offsets::FUN_REGISTER_FRAME_METHODS);
fn(table, count, context);
Api::Documentation::RecordFrameMethods(context, table, count, docs, docCount);
fn(methods.data, methods.count, context);
Api::Documentation::RecordFrameMethods(context, methods, docs);
}
// Registers `func` at `_G[tableName][methodName]`. If the namespace
@@ -395,12 +426,12 @@ void RegisterGlobalAlias(const char *alias, const char *tableName, const char *m
Api::Documentation::RecordGlobal(alias, doc);
}
void RegisterIntegerEnum(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count,
const char *docSystem) {
// `_G[parent][sub] = { entries }`, creating `_G[parent]` if needed.
static bool SetIntegerTable(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count) {
void *L = State();
if (L == nullptr)
return;
return false;
EnsureGlobalTable(L, parent); // [parentTbl]
PushString(L, sub); // [parentTbl, subName]
NewTable(L); // [parentTbl, subName, subTbl]
@@ -411,7 +442,19 @@ void RegisterIntegerEnum(const char *parent, const char *sub,
}
SetTable(L, -3); // parentTbl[sub] = subTbl
SetTop(L, -2); // pop parentTbl
Api::Documentation::RecordEnum(parent, sub, entries, count, docSystem);
return true;
}
void RegisterIntegerEnum(const char *parent, const char *sub, ArrayRef<EnumIntegerEntry> entries,
const char *docSystem) {
if (SetIntegerTable(parent, sub, entries.data, entries.count))
Api::Documentation::RecordEnum(parent, sub, entries, docSystem);
}
void RegisterConstants(const char *sub, ArrayRef<EnumIntegerEntry> entries,
const char *docSystem) {
if (SetIntegerTable("Constants", sub, entries.data, entries.count))
Api::Documentation::RecordConstants(sub, entries, docSystem);
}
void SetFieldNumber(void *L, const char *key, double value) {
@@ -432,6 +475,12 @@ void SetFieldBool(void *L, const char *key, bool value) {
SetTable(L, -3);
}
void SetFieldNil(void *L, const char *key) {
PushString(L, key);
PushNil(L);
SetTable(L, -3);
}
void SetGlobalNumber(void *L, const char *name, double value) {
PushString(L, name);
PushNumber(L, value);
@@ -556,6 +605,8 @@ HookAutoRegister::HookAutoRegister(uintptr_t target, void *hook, void **original
bool RunHookRegistrations() {
for (auto *node = g_hookHead; node != nullptr; node = node->next) {
auto *targetPtr = reinterpret_cast<LPVOID>(node->target);
// A hook must wrap the engine's body, not wow_turbo's reimplementation.
Compat::WowTurbo::Reclaim(node->target);
if (MH_CreateHook(targetPtr, node->hook,
reinterpret_cast<LPVOID *>(node->original)) != MH_OK)
return false;
+106 -21
View File
@@ -58,6 +58,18 @@ inline T &Ref(uintptr_t address) {
return *reinterpret_cast<T *>(address);
}
// An engine constant at a fixed VA, read each time it is used, so the value is
// the one the engine's own code reads: a runtime patch shows, and so does a
// global the engine's static initializers fill after this DLL loads. Converts
// to T, so it stands in a float expression as a float would.
//
// const Game::Live<float> kSkin{Offsets::VAR_MOVER_SKIN};
template <typename T>
struct Live {
uintptr_t address;
operator T() const { return Read<T>(address); }
};
// Calls `fn(object)` for each object of one of a frame's intrusive lists,
// OFF_FRAME_REGION_LIST or OFF_FRAME_CHILD_LIST: node+4 next, node+8 object,
// a NULL or low-bit-1 link ends it - the walk the engine's own frame code
@@ -89,8 +101,10 @@ void *ResolveUnitToken(const char *token);
// UI pixels <-> internal layout (anchor) units. Script_SetPoint converts its
// pixel offsets with `px * [VAR_COORD_DDC_WIDTH_SCALE] / ([VAR_COORD_ASPECT_COMPENSATION]
// * UI_COORD_SCALE_UNIT)`; Script_GetLeft and Script_GetStringWidth push the
// inverse. A region's LOCAL pixels must be multiplied by its effective scale
// * [FLOAT_SCRIPT_COORD_SCALE])`; every script coordinate push (GetLeft,
// GetStringWidth, GetFont's height, GetCursorPosition, OnCursorChanged) is the
// inverse, `DivideByScreenScaleX(GetScaledScreenAspect() * 1024 * value)`,
// which InternalToPixel calls in that order. A region's LOCAL pixels must be multiplied by its effective scale
// (OFF_REGION_EFFECTIVE_SCALE) first. Both return 0 while the scale globals
// are unset, so an early caller gets a zero offset rather than a wild one.
float PixelToInternal(float px);
@@ -147,6 +161,18 @@ int StrCmpI(const char *a, const char *b, int n = 0x7FFFFFFF);
// literals + file-scope arrays): no heap, no dynamic init.
// - Summaries are user-facing text: one sentence, plain words, present
// tense, no patch versions.
// A view over a file-scope `const T[]` that takes its count from the array's
// type, so no call site passes one; `{}` means none.
template <typename T>
struct ArrayRef {
const T *data = nullptr;
int count = 0;
constexpr ArrayRef() = default;
template <size_t N>
constexpr ArrayRef(const T (&a)[N]) : data(a), count(static_cast<int>(N)) {}
};
namespace Doc {
struct Field {
@@ -172,14 +198,7 @@ constexpr Field Vararg(const char *type, const char *doc = nullptr) {
return {"...", type, true, nullptr, doc};
}
// A view over a file-scope `const Field[]`; `{}` means none.
struct FieldList {
const Field *data = nullptr;
int count = 0;
constexpr FieldList() = default;
template <size_t N>
constexpr FieldList(const Field (&a)[N]) : data(a), count(static_cast<int>(N)) {}
};
using FieldList = ArrayRef<Field>;
// A registered function. Positional init: `{summary, args, rets}` for a
// table function, `{summary, args, rets, "XGlobals"}` for a global, and
@@ -200,6 +219,7 @@ struct Method {
const char *name;
const Function *doc;
};
using MethodList = ArrayRef<Method>;
// A table shape a function returns or accepts → `Tables{Type="Structure"}`
// in `system`. Self-registering: nothing binds a structure to Lua, so it
@@ -240,7 +260,9 @@ constexpr int UpvalueIndex(int i) { return GLOBALS_INDEX - i; }
// `lua_call` / `lua_pcall` nresults sentinel meaning "all".
constexpr int MULTRET = -1;
// Type tag values returned by `Type()` (lua_type).
// Type tag values returned by `Type()` (lua_type). TYPE_NONE is an absent
// argument (past the top of the stack).
constexpr int TYPE_NONE = -1;
constexpr int TYPE_NIL = 0;
constexpr int TYPE_BOOLEAN = 1;
constexpr int TYPE_LIGHTUSERDATA = 2;
@@ -270,6 +292,8 @@ using lua_rawget_t = void(__fastcall *)(void *L, int idx);
// Pushes t[n] for the table at idx, raw (no metamethods). With idx =
// REGISTRY_INDEX it pushes a value stored under an integer ref.
using lua_rawgeti_t = void(__fastcall *)(void *L, int idx, int n);
// Pops the top value into t[n] for the table at idx, raw.
using lua_rawseti_t = void(__fastcall *)(void *L, int idx, int n);
using lua_settable_t = void(__fastcall *)(void *L, int idx);
using lua_rawset_t = void(__fastcall *)(void *L, int idx);
// Pushes the metatable of the value at idx and returns 1, or pushes
@@ -280,6 +304,8 @@ using lua_getmetatable_t = int(__fastcall *)(void *L, int idx);
using lua_setmetatable_t = int(__fastcall *)(void *L, int idx);
using lua_insert_t = void(__fastcall *)(void *L, int idx);
using lua_remove_t = void(__fastcall *)(void *L, int idx);
// Pops the top value into slot idx.
using lua_replace_t = void(__fastcall *)(void *L, int idx);
using lua_gettop_t = int(__fastcall *)(void *L);
using lua_settop_t = void(__fastcall *)(void *L, int idx);
using lua_call_t = void(__fastcall *)(void *L, int nargs, int nresults);
@@ -298,6 +324,11 @@ using lua_error_t = void(__cdecl *)(void *L, const char *fmt, ...);
using lua_topointer_t = const void *(__fastcall *)(void *L, int idx);
using lua_tothread_t = void *(__fastcall *)(void *L, int idx);
using lua_iscfunction_t = int(__fastcall *)(void *L, int idx);
using lua_tocfunction_t = CFunction(__fastcall *)(void *L, int idx);
// Fill a lua_Debug (Offsets::SIZEOF_LUA_DEBUG bytes) for a call level /
// with the fields `what` names. Level 0 is the running C function.
using lua_getstack_t = int(__fastcall *)(void *L, int level, void *ar);
using lua_getinfo_t = int(__fastcall *)(void *L, const char *what, void *ar);
using lua_xmove_t = void(__fastcall *)(void *from, void *to, int n);
// `lua_newthread` returns the new `lua_State *` in eax, but Ghidra
// infers `void` because the inner call's value just flows through.
@@ -308,8 +339,26 @@ using lua_newthread_t = void *(__fastcall *)(void *L);
using lua_resume_t = int(__fastcall *)(void *L, int nargs);
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
// The auxlib argument readers: each raises the engine's own "bad argument"
// error. CheckLString's `len` may be null.
using luaL_checklstring_t = const char *(__fastcall *)(void *L, int narg, unsigned int *len);
using luaL_checknumber_t = double(__fastcall *)(void *L, int narg);
using luaL_optnumber_t = double(__fastcall *)(void *L, int narg, double def);
using luaL_setn_t = void(__fastcall *)(void *L, int t, int n);
// Pops the top value into the table at `t` under a fresh integer key and
// returns the key (LUA_REFNIL, -1, for a nil). With REGISTRY_INDEX it anchors
// a value across calls; read it back with RawGetI(L, REGISTRY_INDEX, ref).
using luaL_ref_t = int(__fastcall *)(void *L, int t);
// Releases a key returned by luaL_ref; ignores LUA_REFNIL / LUA_NOREF.
using luaL_unref_t = void(__fastcall *)(void *L, int t, int ref);
using lua_checkstack_t = int(__fastcall *)(void *L, int size);
// Pops the top `n` values and pushes their `..` concatenation; n == 0 pushes
// "" and n == 1 leaves the value untouched.
using lua_concat_t = void(__fastcall *)(void *L, int n);
// Pushes a new full userdata of `size` bytes and returns its block.
using lua_newuserdata_t = void *(__fastcall *)(void *L, unsigned int size);
// The block of the full userdata at idx, the pointer of a light one, else null.
using lua_touserdata_t = void *(__fastcall *)(void *L, int idx);
extern const lua_isnumber_t IsNumber;
extern const lua_isstring_t IsString;
@@ -340,12 +389,14 @@ extern const lua_newtable_t NewTable;
extern const lua_gettable_t GetTable;
extern const lua_rawget_t RawGet;
extern const lua_rawgeti_t RawGetI;
extern const lua_rawseti_t RawSetI;
extern const lua_settable_t SetTable;
extern const lua_rawset_t RawSet;
extern const lua_getmetatable_t GetMetatable;
extern const lua_setmetatable_t SetMetatable;
extern const lua_insert_t Insert;
extern const lua_remove_t Remove;
extern const lua_replace_t Replace;
extern const lua_gettop_t GetTop;
extern const lua_settop_t SetTop;
extern const lua_call_t Call;
@@ -357,13 +408,24 @@ extern const lua_error_t Error;
extern const lua_topointer_t ToPointer;
extern const lua_tothread_t ToThread;
extern const lua_iscfunction_t IsCFunction;
extern const lua_tocfunction_t ToCFunction;
extern const lua_getstack_t GetStack;
extern const lua_getinfo_t GetInfo;
extern const lua_xmove_t XMove;
extern const lua_newthread_t NewThread;
extern const lua_resume_t Resume;
extern const lua_yield_t Yield;
extern const luaL_argerror_t ArgError;
extern const luaL_checklstring_t CheckLString;
extern const luaL_checknumber_t CheckNumber;
extern const luaL_optnumber_t OptNumber;
extern const luaL_setn_t SetN;
extern const luaL_ref_t Ref;
extern const luaL_unref_t Unref;
extern const lua_checkstack_t CheckStack;
extern const lua_concat_t Concat;
extern const lua_newuserdata_t NewUserdata;
extern const lua_touserdata_t ToUserdata;
// Returns the global `lua_State *` (read on demand from the engine's global).
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
@@ -386,8 +448,12 @@ int ObjectLuaRef(void *object);
// Pushes the object's Lua table (registry[ObjectLuaRef(object)]).
void PushObject(void *L, void *object);
// The object's global name (VTBL_SCRIPTOBJECT_GET_NAME), or nullptr when it
// has none.
const char *ObjectName(void *object);
// How the engine names an object in its frame-script usage errors:
// `GetName() ?: "<unnamed>"` (VTBL_SCRIPTOBJECT_GET_NAME).
// `GetName() ?: "<unnamed>"`.
const char *ObjectDisplayName(void *object);
// Same resolve, then the type gate every engine frame method applies to its
@@ -435,6 +501,8 @@ void *ResolveTexture(void *L, int idx = 1, bool raiseError = true);
void *ResolveFontString(void *L, int idx = 1, bool raiseError = true);
void *ResolveEditBox(void *L, int idx = 1, bool raiseError = true);
void *ResolveModel(void *L, int idx = 1, bool raiseError = true);
void *ResolvePlayerModel(void *L, int idx = 1, bool raiseError = true);
void *ResolveDressUpModel(void *L, int idx = 1, bool raiseError = true);
// Every name these registrars bind is ALSO bound under `_G.ClassicAPI` —
// `ClassicAPI.GetSpellInfo`, `ClassicAPI.C_Item.IsBound` — as an escape
@@ -479,10 +547,11 @@ struct FrameMethodEntry {
// Registers a batch of methods on a per-frame-type registry (e.g.
// GameTooltipMethodRegistry for `tooltip:Foo()` calls). `context` is the
// registry address — see Offsets::VAR_*_METHOD_REGISTRY. `docs` pairs
// descriptors to the entries by NAME (any order, any subset).
void RegisterFrameMethods(void *context, const FrameMethodEntry *table, int count,
const Doc::Method *docs = nullptr, int docCount = 0);
// registry address — see Offsets::VAR_*_METHOD_REGISTRY. `methods` and
// `docs` are file-scope arrays; `docs` pairs descriptors to the entries by
// NAME (any order, any subset).
void RegisterFrameMethods(void *context, ArrayRef<FrameMethodEntry> methods,
Doc::MethodList docs = {});
// Registers `func` at `_G[tableName][methodName]`, creating the namespace
// table if it doesn't already exist. This is how modern WoW C_*-style APIs
@@ -500,9 +569,9 @@ void RegisterTableFunction(const char *tableName, const char *methodName,
void RegisterGlobalAlias(const char *alias, const char *tableName, const char *methodName,
const Doc::Function *doc = nullptr);
// Key/value pair for `RegisterIntegerEnum`. `key` becomes a field name
// (PascalCase, matching Blizzard's `Enum.*` naming) and `value` is the
// integer the enum field maps to.
// Key/value pair for `RegisterIntegerEnum` and `RegisterConstants`. `key`
// becomes a field name (PascalCase for Blizzard's `Enum.*`, UPPER_CASE for
// its `Constants.*`) and `value` is the integer the field maps to.
struct EnumIntegerEntry {
const char *key;
int value;
@@ -513,10 +582,16 @@ struct EnumIntegerEntry {
// `Enum.AddOnSecurityStatus = { Secure=0, Insecure=1, ... }` shapes.
// `docSystem` is the documentation System the enumeration belongs to
// ("SpellBook", "Item", …); the entries themselves are the documentation.
void RegisterIntegerEnum(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count,
// `entries` is a file-scope array.
void RegisterIntegerEnum(const char *parent, const char *sub, ArrayRef<EnumIntegerEntry> entries,
const char *docSystem = nullptr);
// Registers `Constants[sub] = { entries }`, Blizzard's engine constant
// tables (`Constants.MoneyFormattingConstants`). Documented as a "Constants"
// table of `docSystem`.
void RegisterConstants(const char *sub, ArrayRef<EnumIntegerEntry> entries,
const char *docSystem);
// Set `t[key] = value` on the table currently at stack[-1] (the most
// common shape used when populating a struct-style table mid-build).
// Pushes the key + value, calls `SetTable(L, -3)`, which pops both and
@@ -525,6 +600,10 @@ void RegisterIntegerEnum(const char *parent, const char *sub,
void SetFieldNumber(void *L, const char *key, double value);
void SetFieldString(void *L, const char *key, const char *value);
void SetFieldBool(void *L, const char *key, bool value);
// `t[key] = nil` — clears a field a reused (caller-supplied) table may still
// hold from an earlier fill. Not for fresh tables: Lua 5.0 inserts a node for
// a missing key even when the value is nil.
void SetFieldNil(void *L, const char *key);
// Sets `_G[name] = value` via `lua_rawset` on the globals
// pseudo-index. `RawSet` over `SetTable` because the globals table has
@@ -723,6 +802,12 @@ bool RunHookRegistrations();
// issue #33, Frame::Attributes). A pure-C++ cache keyed by spellID/itemID is
// NOT fragile and must not register.
//
// It runs AFTER the frames are gone: CGGameUI::Shutdown destroys every frame
// (and each Model frame's scene with every model in it) before
// CGGameUI::Initialize resets the Lua state. So a cleanup here only FORGETS
// pointers; it never releases an engine object tied to a frame. Release those
// when the frame dies (Model::Teardown for Model frames).
//
// Same lifetime + ordering rules as ModuleAutoRegister: static-init chaining,
// undefined cross-TU order — fine, because each callback only clears its OWN
// state. This is the "clear stale state" category only; a save-before-teardown
+2 -3
View File
@@ -32,9 +32,8 @@ namespace {
// that are not base-library functions at all. So do not derive this from
// the engine's `luaopen_base` table (`0x00811E28`, 36 entries — see
// BlizzardScriptAPI.md §3); that is a different set with a different
// purpose. Names with no 1.12 counterpart (`wipe` is `table.wipe` here; the
// secure family does not exist) stay for parity of the error an addon
// sees. Sorted alphabetically.
// purpose. Names with no 1.12 counterpart (the secure family does not
// exist) stay for parity of the error an addon sees. Sorted alphabetically.
const char *const kUnhookableNames[] = {
"getfenv", "getmetatable", "hooksecurefunc", "ipairs", "issecurevalue",
"issecurevariable", "next", "pairs", "pcall", "pcallwithenv", "rawget",
+4025 -223
View File
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
// 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/>.
// `IsItemAction(slot)` / `HasBonusActionBar()` — the action-bar predicates
// from the newer clients' ActionBarFrame_Scripts, read off the same engine
// state the 1.12 readers use.
//
// IsItemAction 1 when the 1-based slot holds an item, else nil; a
// non-number slot is a usage error. The item kind is
// the 0x80000000 tag in VAR_ACTION_TABLE.
// HasBonusActionBar 1 while a bonus bar is active, else nil: the offset
// GetBonusBarOffset returns is nonzero.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Action::Bar {
namespace {
int PushFlag(void *L, bool on) {
if (on)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
int __fastcall Script_IsItemAction(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: IsItemAction(slot)");
return 0;
}
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
return PushFlag(L, false);
const auto *table = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_ACTION_TABLE));
return PushFlag(L, (table[slot0] & Offsets::ACTION_TYPE_MASK) ==
Offsets::ACTION_TYPE_ITEM_BY_ID);
}
int __fastcall Script_HasBonusActionBar(void *L) {
return PushFlag(L, Game::Read<uint32_t>(Offsets::VAR_BONUS_BAR_OFFSET) != 0);
}
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kSlotArgs[] = {
Game::Doc::Req("slot", "number", "The action slot, 1 to 120."),
};
const Game::Doc::Field kIsItemRets[] = {
Game::Doc::Opt("isItem", "number", nullptr, "1 if the slot holds an item, else nil."),
};
const Game::Doc::Function kIsItemAction{
"Returns 1 if an action slot holds an item.",
kSlotArgs, kIsItemRets, "ActionBar"};
const Game::Doc::Field kHasBonusRets[] = {
Game::Doc::Opt("hasBonusBar", "number", nullptr,
"1 if a bonus action bar is active, else nil."),
};
const Game::Doc::Function kHasBonusActionBar{
"Returns 1 if a bonus action bar is active, for example in a stance or form.",
{}, kHasBonusRets, "ActionBar"};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsItemAction", &Script_IsItemAction, &kIsItemAction);
Game::Lua::RegisterGlobalFunction("HasBonusActionBar", &Script_HasBonusActionBar,
&kHasBonusActionBar);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Action::Bar
+178
View File
@@ -0,0 +1,178 @@
// 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/>.
// Where a spell sits on the bars.
//
// FindSpellActionButtons(spell [, set]) the 1-based slots holding the
// spell as a spell action, or nothing when none does.
// HasSpellActionButtons(spell [, set]) whether any slot does.
// IsOnBarOrSpecialBar(spell [, set]) the same, or the spell is on the
// shapeshift bar or the pet bar.
//
// A slot matches on the spell ID it stores, as 4.3.4's PutActionInSlot
// compares (IsSpell && GetSpell == spellId): a macro or an item that casts
// the spell is not the spell, and a rank matches only itself. The special
// bars are the engine's own lists — the shapeshift bar's spell array that
// GetShapeshiftFormInfo reads, and the pet bar's spell entries
// (GetPetActionInfo's types 1-5) while a pet bar is up.
//
// `set` is Enum.ActionBarSet, a mask whose Mkb bit is the one set of slots
// this client has; Gamepad alone finds no slot.
#include "Game.h"
#include "Offsets.h"
#include "spell/Arg.h"
#include <cstdint>
namespace Action::Find {
namespace {
constexpr int ACTION_BAR_SET_MKB = 1;
constexpr int ACTION_BAR_SET_GAMEPAD = 2;
constexpr int ACTION_BAR_SET_ALL = ACTION_BAR_SET_MKB | ACTION_BAR_SET_GAMEPAD;
// The spell at stack[1] and whether the set at stack[2] (default All)
// covers this client's slots. 0 when the spell does not resolve.
int Args(void *L, bool *searchSlots) {
const int set = Game::Lua::IsNumber(L, 2) ? static_cast<int>(Game::Lua::ToNumber(L, 2))
: ACTION_BAR_SET_ALL;
*searchSlots = (set & ACTION_BAR_SET_MKB) != 0;
return Spell::Arg::ResolveSpellID(L, 1);
}
bool SlotHolds(int slot0, int spellID) {
const uint32_t entry = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_ACTION_TABLE))[slot0];
return (entry & Offsets::ACTION_TYPE_MASK) == Offsets::ACTION_TYPE_SPELL &&
entry == static_cast<uint32_t>(spellID);
}
bool AnySlotHolds(int spellID) {
for (int slot0 = 0; slot0 < Offsets::ACTION_TABLE_MAX_SLOTS; ++slot0) {
if (SlotHolds(slot0, spellID))
return true;
}
return false;
}
bool OnShapeshiftBar(int spellID) {
const int count = Game::Read<int>(Offsets::VAR_SHAPESHIFT_BAR_SPELL_COUNT);
const auto *spells = Game::Read<const uint32_t *>(Offsets::VAR_SHAPESHIFT_BAR_SPELLS);
if (spells == nullptr)
return false;
for (int i = 0; i < count; ++i) {
if (spells[i] == static_cast<uint32_t>(spellID))
return true;
}
return false;
}
bool OnPetBar(int spellID) {
if (Game::Read<uint64_t>(Offsets::VAR_PET_GUID) == 0)
return false;
const auto *slots = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PET_ACTION_SLOTS));
for (int i = 0; i < Offsets::PET_ACTION_SLOT_COUNT; ++i) {
const uint32_t type = (slots[i] >> 24) & 0x3F;
if (type >= Offsets::PET_ACTION_TYPE_SPELL_FIRST &&
type <= Offsets::PET_ACTION_TYPE_SPELL_LAST &&
(slots[i] & 0xFFFF) == static_cast<uint32_t>(spellID))
return true;
}
return false;
}
int __fastcall Script_FindSpellActionButtons(void *L) {
bool searchSlots;
const int spellID = Args(L, &searchSlots);
if (spellID <= 0 || !searchSlots || !AnySlotHolds(spellID))
return 0;
Game::Lua::NewTable(L);
int index = 0;
for (int slot0 = 0; slot0 < Offsets::ACTION_TABLE_MAX_SLOTS; ++slot0) {
if (!SlotHolds(slot0, spellID))
continue;
Game::Lua::PushNumber(L, static_cast<double>(++index));
Game::Lua::PushNumber(L, static_cast<double>(slot0 + 1));
Game::Lua::RawSet(L, -3);
}
return 1;
}
int __fastcall Script_HasSpellActionButtons(void *L) {
bool searchSlots;
const int spellID = Args(L, &searchSlots);
Game::Lua::PushBool(L, spellID > 0 && searchSlots && AnySlotHolds(spellID));
return 1;
}
int __fastcall Script_IsOnBarOrSpecialBar(void *L) {
bool searchSlots;
const int spellID = Args(L, &searchSlots);
Game::Lua::PushBool(L, spellID > 0 && ((searchSlots && AnySlotHolds(spellID)) ||
OnShapeshiftBar(spellID) || OnPetBar(spellID)));
return 1;
}
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("spellID", "SpellIdentifier", "A spell ID, spell link, or spell name."),
Game::Doc::Opt("setToSearch", "ActionBarSet", "All"),
};
const Game::Doc::Field kFindRets[] = {
Game::Doc::Req("slots", "table", "The 1-based action slots; nothing when no slot "
"holds the spell."),
};
const Game::Doc::Function kFindSpellActionButtons{
"The action slots that hold the spell as a spell action.",
kArgs, kFindRets};
const Game::Doc::Field kHasRets[] = {
Game::Doc::Req("hasSpellActionButtons", "bool"),
};
const Game::Doc::Function kHasSpellActionButtons{
"Whether any action slot holds the spell as a spell action.",
kArgs, kHasRets};
const Game::Doc::Field kOnBarRets[] = {
Game::Doc::Req("isOnBarOrSpecialBar", "bool"),
};
const Game::Doc::Function kIsOnBarOrSpecialBar{
"Whether the spell is in an action slot, on the shapeshift bar, or on the pet bar.",
kArgs, kOnBarRets};
const Game::Lua::EnumIntegerEntry kActionBarSet[] = {
{"None", 0},
{"Mkb", ACTION_BAR_SET_MKB},
{"Gamepad", ACTION_BAR_SET_GAMEPAD},
{"All", ACTION_BAR_SET_ALL},
};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_ActionBar", "FindSpellActionButtons",
&Script_FindSpellActionButtons, &kFindSpellActionButtons);
Game::Lua::RegisterTableFunction("C_ActionBar", "HasSpellActionButtons",
&Script_HasSpellActionButtons, &kHasSpellActionButtons);
Game::Lua::RegisterTableFunction("C_ActionBar", "IsOnBarOrSpecialBar",
&Script_IsOnBarOrSpecialBar, &kIsOnBarOrSpecialBar);
Game::Lua::RegisterIntegerEnum("Enum", "ActionBarSet", kActionBarSet, "ActionBar");
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Action::Find
+61
View File
@@ -38,11 +38,24 @@
// through to an empty return. There is no bag-item tag: items are always
// by-itemID (0x80), and an earlier reading of the 0x40 "miss" as a bag item
// was wrong.
//
// `C_ActionBar.GetSpell(slot)` — the spell the slot stands for, 0 for none.
// It is the engine's own resolver (FUN_ACTION_SLOT_TO_SPELL), which every
// action reader (cooldown, usable, range, current) goes through: a spell
// action is its spell, a macro is its cached primary spell, and an item is
// its first on-use spell — retail's ActionButton.lua calls GetSpell for
// exactly that item case. The spellbook flag it also hands back is not part
// of the answer.
//
// `C_ActionBar.IsMacroActionWithShowTooltip(slot)` — the slot holds a macro
// whose body carries `#showtooltip`, by the parse `Macro::ShowTooltip` hands
// the tooltip over on. `#show` is not it.
#include "Game.h"
#include "Offsets.h"
#include "action/Slot.h"
#include "equipmentset/Data.h"
#include "macro/ShowTooltip.h"
#include <cstdint>
@@ -126,10 +139,58 @@ int __fastcall Script_GetActionInfo(void *L) {
return 0;
}
int __fastcall Script_GetSpell(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_ActionBar.GetSpell(actionID)");
return 0;
}
// The resolver bounds-checks the slot itself and answers 0 outside it.
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
uint32_t petFlag = 0;
const uint32_t spellID = reinterpret_cast<SlotToSpell_t>(
static_cast<uintptr_t>(Offsets::FUN_ACTION_SLOT_TO_SPELL))(
static_cast<uint32_t>(slot0), &petFlag);
Game::Lua::PushNumber(L, static_cast<double>(spellID));
return 1;
}
int __fastcall Script_IsMacroActionWithShowTooltip(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_ActionBar.IsMacroActionWithShowTooltip(actionID)");
return 0;
}
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
const uint32_t macroID = Action::Slot::MacroIDForSlot(slot0);
Game::Lua::PushBool(L, macroID != 0 && Macro::ShowTooltip::HasShowTooltipDirective(macroID));
return 1;
}
const Game::Doc::Field kGetSpellArgs[] = {
Game::Doc::Req("actionID", "luaIndex"),
};
const Game::Doc::Field kGetSpellRets[] = {
Game::Doc::Req("spellID", "number"),
};
const Game::Doc::Function kGetSpellDoc{
"The spell an action stands for: a spell's own ID, a macro's spell, or an item's "
"on-use spell; 0 when there is none.",
kGetSpellArgs, kGetSpellRets};
const Game::Doc::Field kIsMacroActionWithShowTooltipRets[] = {
Game::Doc::Req("isMacroActionWithShowTooltip", "bool"),
};
const Game::Doc::Function kIsMacroActionWithShowTooltipDoc{
"Whether the action is a macro with an explicit #showtooltip line.",
kGetSpellArgs, kIsMacroActionWithShowTooltipRets};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetActionInfo", &Script_GetActionInfo);
Game::Lua::RegisterTableFunction("C_ActionBar", "GetSpell", &Script_GetSpell, &kGetSpellDoc);
Game::Lua::RegisterTableFunction("C_ActionBar", "IsMacroActionWithShowTooltip",
&Script_IsMacroActionWithShowTooltip,
&kIsMacroActionWithShowTooltipDoc);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+114
View File
@@ -0,0 +1,114 @@
// 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/>.
// The range answer of an action button whose macro is aimed at a unit.
//
// 4.3.4 keeps that unit on the macro (MACRONODE::m_target: the `[@unit]` of
// the clause its display resolved from, or the target SetMacroSpell /
// SetMacroItem named), and CGActionBar::HasValidTarget range-checks the slot's
// spell against it in place of the current target. 1.12's slot range function
// FUN_ACTION_SLOT_RANGE already takes the unit as a GUID for its range check,
// and always passes 0, the selection. `Macro::ShowTooltip` keeps the unit (its
// `Info::unit`), and this answers for those slots with that GUID, in the
// engine's order: the range check, then the implicit-target test, where the
// unit standing in for the selection must exist. Every other slot goes to the
// engine function unchanged.
//
// The function's one caller, Script_IsActionInRange, is redirected rather than
// the function hooked.
#include "action/Range.h"
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "action/Slot.h"
#include "macro/ShowTooltip.h"
#include "object/Resolve.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Action::Range {
namespace {
using SlotRange_t = int(__fastcall *)(int slot0, int *inRange);
using SlotToSpell_t = int(__fastcall *)(int slot0, uint32_t *outPetFlag);
using CheckRange_t = uint8_t(__fastcall *)(const void *caster, const uint8_t *record,
uint32_t guidLo, uint32_t guidHi, uint32_t report);
// The first implicit targets the engine answers for: case 0 of its table.
bool TakesUnitTarget(int32_t implicitTarget) {
switch (implicitTarget) {
case 6: case 21: case 23: case 25: case 26: case 35: case 45: case 57: case 61:
return true;
default:
return false;
}
}
uint64_t MacroUnit(int slot0) {
const uint32_t macroID = Action::Slot::MacroIDForSlot(slot0);
Macro::ShowTooltip::Info info{};
// Passive: this runs inside a Lua call, from a button's OnUpdate.
if (macroID == 0 || !Macro::ShowTooltip::LookupPassive(macroID, &info))
return 0;
return info.unit;
}
int __fastcall SlotRange_h(int slot0, int *inRange) {
const auto original = reinterpret_cast<SlotRange_t>(Offsets::FUN_ACTION_SLOT_RANGE);
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
return original(slot0, inRange);
const uint64_t unit = MacroUnit(slot0);
if (unit == 0)
return original(slot0, inRange);
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return 0;
uint32_t pet = 0;
const int spellID =
reinterpret_cast<SlotToSpell_t>(Offsets::FUN_ACTION_SLOT_TO_SPELL)(slot0, &pet);
const uint8_t *record = ::Spell::Lookup::RecordForID(spellID);
if (record == nullptr || pet != 0)
return 0;
if (!reinterpret_cast<CheckRange_t>(Offsets::FUN_CHECK_SPELL_TARGET_RANGE)(
player, record, static_cast<uint32_t>(unit), static_cast<uint32_t>(unit >> 32), 0)) {
*inRange = 0;
return 1;
}
const int32_t implicitTarget =
Game::Read<int32_t>(record, Offsets::OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_A);
if (!TakesUnitTarget(implicitTarget) || Object::ByGuid(Offsets::TYPEMASK_UNIT, unit) == nullptr)
return 0;
*inRange = 1;
return 1;
}
void Register() {
Common::RedirectCall(Offsets::CALL_IS_ACTION_IN_RANGE_SLOT_RANGE,
Offsets::FUN_ACTION_SLOT_RANGE,
reinterpret_cast<const void *>(&SlotRange_h));
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
int Answer(int slot0, int *inRange) { return SlotRange_h(slot0, inRange); }
} // namespace Action::Range
+24
View File
@@ -0,0 +1,24 @@
// 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/>.
#pragma once
namespace Action::Range {
// IsActionInRange's answer for a slot (0-based, 0..119): 0 when it has none
// (IsActionInRange's nil), else 1 with `*inRange` 0 or 1. A macro aimed at a
// unit is checked against that unit.
int Answer(int slot0, int *inRange);
} // namespace Action::Range
+144
View File
@@ -0,0 +1,144 @@
// 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/>.
// `C_ActionBar.EnableActionRangeCheck(slot, enable)` and
// `ACTION_RANGE_CHECK_UPDATE(slot, isInRange, checksRange)`.
//
// Nothing in the client reacts to a distance (see Unit::InRangeEvent), so the
// enabled slots are checked every world tick, while a frame is registered for
// the event, and a slot fires when its answer changes. The answer is
// IsActionInRange's (Action::Range::Answer), macro unit included, so a slot
// whose action changes fires when the new action's answer differs.
//
// Enabling is counted per slot, as retail's ActionBarButtonRangeCheckFrame
// expects: it enables once for each button on a slot and disables once as
// each button leaves. Retail's buttons never call IsActionInRange; they take
// their first state from this event. So each enable makes the slot's next
// check fire, changed or not. A new Lua state (`/reload`) starts with no slot
// enabled.
#include "Game.h"
#include "Offsets.h"
#include "action/Range.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace Action::RangeCheck {
namespace {
enum class State : uint8_t { NoCheck, OutOfRange, InRange };
struct Slot {
int refs = 0;
bool pending = false; // fire on the next check, changed or not
State last = State::NoCheck;
};
Slot g_slots[Offsets::ACTION_TABLE_MAX_SLOTS];
const Game::Doc::Field kPayload[] = {
Game::Doc::Req("slot", "luaIndex"),
Game::Doc::Req("isInRange", "bool",
"Whether the action's target is in range. Should not be used if checksRange "
"is false."),
Game::Doc::Req("checksRange", "bool",
"False when no range check was made, for example when there is no target."),
};
const Game::Doc::Event kDoc{
"ActionBar",
"Fires when an action that has EnableActionRangeCheck on goes in or out of range of its "
"target.",
kPayload};
const Event::Custom::AutoReserve kEvent{"ACTION_RANGE_CHECK_UPDATE", &kDoc};
State Evaluate(int slot0) {
int inRange = 0;
if (Action::Range::Answer(slot0, &inRange) == 0)
return State::NoCheck;
return inRange != 0 ? State::InRange : State::OutOfRange;
}
void OnWorldTick() {
const int event = kEvent.Slot();
if (!Event::Custom::HasListeners(event))
return;
void *L = Game::Lua::State();
if (L == nullptr)
return;
const int top = Game::Lua::GetTop(L);
for (int slot0 = 0; slot0 < Offsets::ACTION_TABLE_MAX_SLOTS; ++slot0) {
Slot &slot = g_slots[slot0];
if (slot.refs == 0)
continue;
const State state = Evaluate(slot0);
if (!slot.pending && state == slot.last)
continue;
slot.pending = false;
slot.last = state;
Game::Lua::PushNumber(L, static_cast<double>(slot0 + 1));
Game::Lua::PushBool(L, state == State::InRange);
Game::Lua::PushBool(L, state != State::NoCheck);
Event::Custom::FireValues(event, L, top + 1, 3);
Game::Lua::SetTop(L, top);
}
}
const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
// --- C_ActionBar.EnableActionRangeCheck(slot, enable) -----------------------
int __fastcall Script_EnableActionRangeCheck(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_ActionBar.EnableActionRangeCheck(slot, enable)");
return 0;
}
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
return 0;
Slot &slot = g_slots[slot0];
if (Game::Lua::ToBoolean(L, 2) != 0) {
++slot.refs;
slot.pending = true;
} else if (slot.refs > 0) {
--slot.refs;
}
return 0;
}
const Game::Doc::Field kEnableArgs[] = {
Game::Doc::Req("slot", "luaIndex"),
Game::Doc::Req("enable", "bool",
"True if changes in range for the action should dispatch "
"ACTION_RANGE_CHECK_UPDATE. False if the action no longer needs the event."),
};
const Game::Doc::Function kEnableDoc{
"Used with ACTION_RANGE_CHECK_UPDATE to be informed when an action goes in or out of range "
"of its target.",
kEnableArgs};
void RegisterLuaFunctions() {
for (Slot &slot : g_slots)
slot = Slot{};
Game::Lua::RegisterTableFunction("C_ActionBar", "EnableActionRangeCheck",
&Script_EnableActionRangeCheck, &kEnableDoc);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Action::RangeCheck
+148
View File
@@ -0,0 +1,148 @@
// 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/>.
// The item behind an action slot, 4.3.4's CGActionBar::GetItem: an item-by-ID
// action's item, or the item a macro's `#showtooltip` / `#show` resolved to
// (1.12 keeps no item per macro, so `Macro::ShowTooltip` holds it). Both
// readers below peek the item cache and never query it, as 4.3.4 does.
//
// IsStackableAction the item's max stack is more than 1 —
// 4.3.4's IsStackableAucItem. The global
// answers 1/nil, the C_ActionBar form a bool.
// GetItemActionOnEquipSpellID the item's first on-equip spell (trigger 1),
// nil when it has none.
#include "Game.h"
#include "Offsets.h"
#include "item/Record.h"
#include "macro/ShowTooltip.h"
#include <cstdint>
namespace Action::SlotItem {
namespace {
uint32_t ItemForSlot(int slot0) {
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
return 0;
const uint32_t entry = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_ACTION_TABLE))[slot0];
if ((entry & Offsets::ACTION_TYPE_MASK) == Offsets::ACTION_TYPE_ITEM_BY_ID)
return entry & Offsets::ACTION_PAYLOAD_MASK_ITEM_BY_ID;
Macro::ShowTooltip::Info info;
if (Macro::ShowTooltip::ForSlot(slot0, &info) &&
info.target == Macro::ShowTooltip::Target::Item)
return static_cast<uint32_t>(info.itemID);
return 0;
}
// Reads the 1-based slot at stack[1]; false (after the usage error) when it
// is not a number.
bool SlotArg(void *L, const char *usage, int *slot0) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, usage);
return false;
}
*slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
return true;
}
bool IsStackable(int slot0) {
const uint8_t *record = ::Item::PeekRecord(ItemForSlot(slot0));
return record != nullptr && Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_STACKABLE) > 1;
}
int __fastcall Script_IsStackableAction(void *L) {
int slot0;
if (!SlotArg(L, "Usage: IsStackableAction(slot)", &slot0))
return 0;
if (IsStackable(slot0))
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
int __fastcall Script_C_IsStackableAction(void *L) {
int slot0;
if (!SlotArg(L, "Usage: C_ActionBar.IsStackableAction(actionID)", &slot0))
return 0;
Game::Lua::PushBool(L, IsStackable(slot0));
return 1;
}
int __fastcall Script_GetItemActionOnEquipSpellID(void *L) {
int slot0;
if (!SlotArg(L, "Usage: C_ActionBar.GetItemActionOnEquipSpellID(actionID)", &slot0))
return 0;
const uint8_t *record = ::Item::PeekRecord(ItemForSlot(slot0));
if (record == nullptr)
return 0;
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i) {
const uint32_t spellID = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_ID + i * 4);
const uint32_t trigger = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_TRIGGER + i * 4);
if (spellID != 0 && trigger == Offsets::ITEM_SPELLTRIGGER_ON_EQUIP) {
Game::Lua::PushNumber(L, static_cast<double>(spellID));
return 1;
}
}
return 0;
}
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kSlotArgs[] = {
Game::Doc::Req("slot", "number", "The action slot, 1 to 120."),
};
const Game::Doc::Field kIsStackableRets[] = {
Game::Doc::Opt("isStackable", "number", nullptr,
"1 if the slot's item stacks, else nil."),
};
const Game::Doc::Function kIsStackableAction{
"Returns 1 if an action slot holds an item that stacks.",
kSlotArgs, kIsStackableRets, "ActionBar"};
const Game::Doc::Field kActionIDArgs[] = {
Game::Doc::Req("actionID", "luaIndex"),
};
const Game::Doc::Field kCIsStackableRets[] = {
Game::Doc::Req("isStackableAction", "bool"),
};
const Game::Doc::Function kCIsStackableAction{
"Whether the action's item stacks.",
kActionIDArgs, kCIsStackableRets};
const Game::Doc::Field kOnEquipRets[] = {
Game::Doc::Opt("onEquipSpellID", "number", nullptr,
"Nil when the action has no item or its item has no on-equip spell."),
};
const Game::Doc::Function kGetItemActionOnEquipSpellID{
"The spell the action's item grants while equipped.",
kActionIDArgs, kOnEquipRets};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsStackableAction", &Script_IsStackableAction,
&kIsStackableAction);
Game::Lua::RegisterTableFunction("C_ActionBar", "IsStackableAction",
&Script_C_IsStackableAction, &kCIsStackableAction);
Game::Lua::RegisterTableFunction("C_ActionBar", "GetItemActionOnEquipSpellID",
&Script_GetItemActionOnEquipSpellID,
&kGetItemActionOnEquipSpellID);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Action::SlotItem
+125
View File
@@ -0,0 +1,125 @@
// 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/>.
// `ACTION_USABLE_CHANGED(changes)` — the slots whose usable / noMana pair
// changed, as `{ slot, usable, noMana }` entries.
//
// The engine's bulk refresh FUN_ACTION_UPDATE_USABLE already finds them: it
// computes each slot with FUN_ACTION_SLOT_USABLE, compares the pair against
// the caches IsUsableAction reads, stores a changed pair, and signals the
// argument-less ACTIONBAR_UPDATE_USABLE once when any changed. Two of its
// calls are redirected. The per-slot call records the slots whose pair differs
// from the caches, the same compare the engine makes next. The signal sends
// ACTIONBAR_UPDATE_USABLE unchanged, then this event with the recorded slots.
//
// The per-slot refresh in FUN_ACTION_SLOT_CHANGED_NOTIFY stores its pair
// without this event; it signals ACTIONBAR_SLOT_CHANGED for the slot instead.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include <cstdint>
#include <cstring>
namespace Action::UsableChanged {
namespace {
using SlotUsable_t = uint32_t(__fastcall *)(uint32_t slot0, uint32_t *outNoMana);
using FireEvent_t = void(__fastcall *)(int eventID);
struct Change {
uint32_t slot0;
bool usable;
bool noMana;
};
// This refresh's changes. The loop runs slots 0..119 in order, so slot 0
// starts a new list.
Change g_changes[Offsets::ACTION_TABLE_MAX_SLOTS];
int g_count = 0;
const Game::Doc::Field kStateFields[] = {
Game::Doc::Req("slot", "luaIndex"),
Game::Doc::Req("usable", "bool"),
Game::Doc::Req("noMana", "bool"),
};
const Game::Doc::Structure kState{"ActionUsableState", "ActionBar", kStateFields};
const Game::Doc::Field kPayload[] = {
Game::Doc::Req("changes", "table", "An ActionUsableState for each slot that changed."),
};
const Game::Doc::Event kDoc{
"ActionBar", "Fires when the usable or noMana state of one or more action slots changes.",
kPayload};
const Event::Custom::AutoReserve kEvent{"ACTION_USABLE_CHANGED", &kDoc};
uint32_t __fastcall SlotUsable_h(uint32_t slot0, uint32_t *outNoMana) {
const uint32_t usable =
reinterpret_cast<SlotUsable_t>(Offsets::FUN_ACTION_SLOT_USABLE)(slot0, outNoMana);
if (slot0 == 0)
g_count = 0;
const auto *usableCache =
reinterpret_cast<const uint32_t *>(static_cast<uintptr_t>(Offsets::VAR_ACTION_USABLE));
const auto *noManaCache =
reinterpret_cast<const uint32_t *>(static_cast<uintptr_t>(Offsets::VAR_ACTION_NO_MANA));
if (slot0 < static_cast<uint32_t>(Offsets::ACTION_TABLE_MAX_SLOTS) &&
g_count < Offsets::ACTION_TABLE_MAX_SLOTS &&
(usable != usableCache[slot0] || *outNoMana != noManaCache[slot0]))
g_changes[g_count++] = {slot0, usable != 0, *outNoMana != 0};
return usable;
}
void __fastcall FireUsable_h(int eventID) {
// Copied first: the handlers below can run another refresh.
Change changes[Offsets::ACTION_TABLE_MAX_SLOTS];
const int count = g_count;
std::memcpy(changes, g_changes, sizeof(Change) * count);
g_count = 0;
reinterpret_cast<FireEvent_t>(Offsets::FUN_FIRE_EVENT_NO_ARGS)(eventID);
const int event = kEvent.Slot();
void *L = Game::Lua::State();
if (count == 0 || !Event::Custom::HasListeners(event) || L == nullptr)
return;
const int top = Game::Lua::GetTop(L);
Game::Lua::NewTable(L);
for (int i = 0; i < count; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "slot", static_cast<double>(changes[i].slot0 + 1));
Game::Lua::SetFieldBool(L, "usable", changes[i].usable);
Game::Lua::SetFieldBool(L, "noMana", changes[i].noMana);
Game::Lua::RawSet(L, -3);
}
Game::Lua::SetN(L, -1, count);
Event::Custom::FireValues(event, L, top + 1, 1);
Game::Lua::SetTop(L, top);
}
void Register() {
Common::RedirectCall(Offsets::CALL_UPDATE_USABLE_SLOT_USABLE, Offsets::FUN_ACTION_SLOT_USABLE,
reinterpret_cast<const void *>(&SlotUsable_h));
Common::RedirectCall(Offsets::CALL_UPDATE_USABLE_FIRE, Offsets::FUN_FIRE_EVENT_NO_ARGS,
reinterpret_cast<const void *>(&FireUsable_h));
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
} // namespace Action::UsableChanged
+195 -160
View File
@@ -11,22 +11,33 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Embedded `!!!ClassicAPI` addon fallback. When the user doesn't have
// the addon installed on disk, this module makes the engine think it
// does — without writing anything to the filesystem.
// Embedded addons. When the user doesn't have an addon installed on disk,
// this module makes the engine think they do — without writing anything
// to the filesystem. The addons are `Manifest::kAddons` (the `ADDONS`
// list in CMakeLists.txt): `!!!ClassicAPI`, which loads at login, and
// `ClassicAPI_DebugTools` and `ClassicAPI_EventTrace`, which are
// `## LoadOnDemand` behind the `/dump` / `/fstack` / `/etrace` stubs in
// `!!!ClassicAPI` — retail's chat-frame + Blizzard_DebugTools /
// Blizzard_EventTrace split. (The `Blizzard_` name is not
// available to us: the engine moves loose `Interface\AddOns\Blizzard*`
// folders aside at startup, `MoveAsideLooseInterface`, and refuses a
// `Blizzard_*` TOC without a signature.)
//
// How:
//
// 1. The CMake build embeds every file under `AddOns/!!!ClassicAPI/`
// as an RCDATA resource, plus a generated `{path, resourceId}`
// manifest (`embedded_resources.h`).
// 1. The CMake build embeds each listed addon as one RCDATA resource,
// a gzipped pack of its files, plus a generated
// `{path, resourceId, index}` manifest (`embedded_resources.h`).
//
// 2. We hook the engine's file-read function at `FUN_FILE_READ`.
// When the engine asks for a path under
// `Interface\AddOns\!!!ClassicAPI\`, we allocate a Storm buffer
// `Interface\AddOns\<embedded addon>\`, we allocate a Storm buffer
// via the same allocator the engine itself uses, copy the
// embedded content in, and hand it back. The caller's normal
// `SMemFree` reclaims it on completion — no special lifetime.
// A TOC then goes through `AddOns::TocRewrite`, as an installed
// addon's does, so its file lines can carry `[AllowLoadGameType ...]`
// / `[ExcludeLoadGameType ...]` conditions.
//
// 3. We hook the exists-check `FUN_FILE_EXISTS` so it answers the same
// way for the same paths. Without it every embedded file reads fine
@@ -35,13 +46,16 @@
// embedded `<Script file="sub\x.lua">`.
//
// 4. We pre-hook the engine's `FUN_ADDON_SCAN_FULL` and call the TOC
// parser with `"!!!ClassicAPI"` before the scan runs. The registry
// parser with each embedded name before the scan runs. The registry
// is empty there (the login init resets it just before), so our
// entry is the first record and the load pass — which walks the
// records in registration order — loads it first. The scan's own
// `## LoadWith:` pass then sees it like any disk addon. The parser
// is dedup-safe: when the user also has the addon on disk, the
// scan's call for that folder hits the hash table and is a no-op.
// entries are the first records and the load pass — which walks the
// records in registration order — loads `!!!ClassicAPI` first. The
// scan's own `## LoadWith:` pass then sees them like any disk addon.
// The parser is dedup-safe: when the user also has an addon on disk,
// the scan's call for that folder hits the hash table and is a no-op.
//
// Every embedded addon is hidden from the addon list and force-enabled,
// the way Blizzard's own addons are always available and never listed.
//
// Same pattern WeirdUtils.dll uses (we reverse-engineered it from
// `0x10015A74` while figuring this out). The addon load pass then
@@ -51,8 +65,10 @@
#include "embedded_resources.h"
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "addons/Embedded.h"
#include "addons/EngineIO.h"
#include "addons/FlavorBindings.h"
#include "addons/FlavorToc.h"
@@ -61,6 +77,7 @@
#include "addons/TocRewrite.h"
#include "bindings/Inject.h"
#include "embed/Resource.h"
#include "storm/Memory.h"
#include <cstdint>
#include <cstdio>
@@ -70,59 +87,66 @@ namespace Addons::Embedded {
namespace {
constexpr const char *kAddonName = "!!!ClassicAPI";
constexpr const char *kAddonTocFile = "!!!ClassicAPI.toc";
using Manifest::kAddonCount;
using Manifest::kAddons;
// Developer marker. When this file exists in the on-disk addon folder,
// the disk copy wins unconditionally (see `DiskHasDevMarker` /
// `DecideSource`). It is gitignored, excluded from the embed, and the
// release ships only the DLL — so it can only exist where a developer
// deliberately created it, never in a normal user's install.
// Developer marker. When this file exists in an on-disk addon folder,
// the disk copy of that addon wins unconditionally (see
// `DiskHasDevMarker` / `DecideSource`). It is gitignored, excluded from
// the embed, and the release ships only the DLL — so it can only exist
// where a developer deliberately created it, never in a normal user's
// install.
constexpr const char *kDevMarkerFile = ".classicapi-dev";
// `Interface\AddOns\!!!ClassicAPI\` — the prefix the engine
// constructs for any of our addon's files. Comparing case-
// `Interface\AddOns\` — the prefix the engine constructs for any addon
// file; the manifest paths are relative to it. Comparing case-
// insensitively because the TOC parser uses `Interface\AddOns\%s\…`
// where %s comes from the on-disk directory name, and Windows file
// paths are case-insensitive.
constexpr const char *kAddonPathPrefix = "Interface\\AddOns\\!!!ClassicAPI\\";
constexpr size_t kAddonPathPrefixLen = 30; // length of the literal above
constexpr const char *kAddOnsPrefix = "Interface\\AddOns\\";
char NormalizeChar(char c) {
if (c == '/') return '\\';
if (c >= 'A' && c <= 'Z') return static_cast<char>(c + 32);
return c;
// `path` with the case-insensitive `prefix` stripped, NULL when it does
// not start with it.
const char *StripPrefixCI(const char *path, const char *prefix) {
while (*prefix) {
if (*path == '\0') return nullptr;
if (Common::FoldPathChar(*path) != Common::FoldPathChar(*prefix)) return nullptr;
++path; ++prefix;
}
return path;
}
bool PathEqualsCI(const char *a, const char *b) {
while (*a && *b) {
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
++a; ++b;
int IndexOfAddon(const char *name) {
for (size_t i = 0; i < kAddonCount; ++i) {
if (Common::SamePath(name, kAddons[i]))
return static_cast<int>(i);
}
return *a == *b;
return -1;
}
// Strip the `Interface\AddOns\!!!ClassicAPI\` prefix and return the
// suffix (e.g. `Util\Print.lua`) on a match, NULL otherwise.
const char *StripAddonPrefix(const char *path) {
if (path == nullptr) return nullptr;
const char *p = path;
const char *q = kAddonPathPrefix;
while (*q) {
if (*p == '\0') return nullptr;
if (NormalizeChar(*p) != NormalizeChar(*q)) return nullptr;
++p; ++q;
// The embedded addon `path` lies in (`Interface\AddOns\<addon>\…`), or -1.
// `*relPath` gets the manifest-relative part (`<addon>\Util\Print.lua`).
int MatchAddonPath(const char *path, const char **relPath) {
if (path == nullptr) return -1;
const char *rel = StripPrefixCI(path, kAddOnsPrefix);
if (rel == nullptr) return -1;
for (size_t i = 0; i < kAddonCount; ++i) {
const char *rest = StripPrefixCI(rel, kAddons[i]);
if (rest != nullptr && Common::FoldPathChar(*rest) == '\\') {
*relPath = rel;
return static_cast<int>(i);
}
}
return p;
return -1;
}
// The embedded bytes for `suffix`; `data` is NULL when it is not embedded.
Embed::Bytes LookupEmbedded(const char *suffix) {
for (size_t i = 0; i < ClassicAPIFiles::kFileCount; ++i) {
if (PathEqualsCI(suffix, ClassicAPIFiles::kFiles[i].path))
return Embed::Resource(ClassicAPIFiles::kFiles[i].resourceId);
}
return {nullptr, 0};
// The embedded bytes for manifest path `relPath`; `data` is NULL when it
// is not embedded.
Embed::Bytes LookupEmbedded(const char *relPath) {
const Manifest::File *file = Embed::FindFile(Manifest::kFiles, relPath);
if (file == nullptr)
return {nullptr, 0};
return Embed::File(file->resourceId, file->index);
}
// Engine file I/O + Storm allocator — see addons/EngineIO.h for the shapes and
@@ -130,8 +154,6 @@ Embed::Bytes LookupEmbedded(const char *suffix) {
// by the caller's standard SMemFree in turn.
using AddOns::EngineIO::FileExistsFn;
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemAllocFn;
using AddOns::EngineIO::SMemFreeFn;
// The trampolines to the original FUN_FILE_READ / FUN_FILE_EXISTS (installed
// by the co-hooks below).
@@ -187,67 +209,63 @@ int CompareVersions(const char *a, const char *b) {
return 0;
}
// Pre-extracted embedded TOC version, populated lazily on first
// FileRead_h call under our prefix. Empty until populated.
char g_embeddedVersion[64] = "";
// Which source wins the embed-vs-disk comparison, decided once on
// first relevant file read.
// Which source wins the embed-vs-disk comparison, decided per addon once
// on its first file read.
enum class Source { Undecided, Disk, Embedded };
Source g_source = Source::Undecided;
Source g_source[kAddonCount] = {};
void EnsureEmbeddedVersionExtracted() {
if (g_embeddedVersion[0] != '\0') return;
const Embed::Bytes toc = LookupEmbedded(kAddonTocFile);
if (toc.data != nullptr)
ExtractTocVersion(reinterpret_cast<const char *>(toc.data), toc.size,
g_embeddedVersion, sizeof(g_embeddedVersion));
}
// True iff the on-disk addon folder contains the `.classicapi-dev`
// True iff addon `addon`'s on-disk folder contains the `.classicapi-dev`
// marker. Asked of the ORIGINAL `FUN_FILE_EXISTS` (bypassing our embed
// hook), so it reflects a real file on disk / in an MPQ — never an
// embedded copy. That makes it a clean "is this a developer working
// tree" signal: the marker is gitignored + excluded from the embed and
// the release ships only the DLL, so a normal user's install can't
// carry it.
bool DiskHasDevMarker() {
bool DiskHasDevMarker(int addon) {
char fullPath[256];
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
kAddonPathPrefix, kDevMarkerFile);
std::snprintf(fullPath, sizeof(fullPath), "%s%s\\%s", kAddOnsPrefix,
kAddons[addon], kDevMarkerFile);
return FileExists_o(fullPath, 1) != 0;
}
// Decide whether disk or embedded should serve all reads for this
// addon. Called on the first FileRead_h call with a matching prefix.
// The `.classicapi-dev` marker forces disk unconditionally (explicit
// developer override). Otherwise reads the on-disk TOC via the original
// `FUN_FILE_READ` (bypassing our hook), parses its version, compares to
// the embedded version, and caches the newer. If disk has no TOC at
// all, embedded wins by default.
void DecideSource() {
if (g_source != Source::Undecided) return;
EnsureEmbeddedVersionExtracted();
// Decide whether disk or embedded should serve all reads for addon
// `addon`. Called on the first FileRead_h / FileExists_h call under its
// folder. The `.classicapi-dev` marker forces disk unconditionally
// (explicit developer override). Otherwise reads the on-disk TOC via the
// original `FUN_FILE_READ` (bypassing our hook), parses its version,
// compares to the embedded version, and caches the newer. If disk has no
// TOC at all, embedded wins by default.
void DecideSource(int addon) {
if (g_source[addon] != Source::Undecided) return;
// Explicit developer override: disk wins regardless of version. This
// is how a dev keeps disk precedence when running local Lua against a
// *released* DLL (where the embedded version would otherwise be newer
// and win). Dev intent lives in this marker, decoupled from the toc
// version — so users never get silently shadowed by a stray copy.
if (DiskHasDevMarker()) {
g_source = Source::Disk;
if (DiskHasDevMarker(addon)) {
g_source[addon] = Source::Disk;
return;
}
char embeddedVersion[64] = "";
char tocRelPath[256];
std::snprintf(tocRelPath, sizeof(tocRelPath), "%s\\%s.toc",
kAddons[addon], kAddons[addon]);
const Embed::Bytes toc = LookupEmbedded(tocRelPath);
if (toc.data != nullptr)
ExtractTocVersion(reinterpret_cast<const char *>(toc.data), toc.size,
embeddedVersion, sizeof(embeddedVersion));
char fullPath[256];
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
kAddonPathPrefix, kAddonTocFile);
std::snprintf(fullPath, sizeof(fullPath), "%s%s", kAddOnsPrefix,
tocRelPath);
void *diskBuf = nullptr;
size_t diskSize = 0;
const int ok = FileRead_o(0, fullPath, &diskBuf, &diskSize, 1, 1, 0);
if (ok == 0 || diskBuf == nullptr) {
g_source = Source::Embedded;
g_source[addon] = Source::Embedded;
return;
}
@@ -258,18 +276,58 @@ void DecideSource() {
// Free the disk buffer we just borrowed — we only needed it for
// the version-line scrape. The actual content for the engine's
// TOC read comes through the regular hook path below.
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
SMemFree(diskBuf, __FILE__, __LINE__, 0);
Storm::Free(diskBuf, __FILE__, __LINE__);
// Missing or unparseable disk version → assume it's older than
// anything we ship, embedded wins.
if (diskVersion[0] == '\0') {
g_source = Source::Embedded;
g_source[addon] = Source::Embedded;
return;
}
const int cmp = CompareVersions(g_embeddedVersion, diskVersion);
g_source = (cmp > 0) ? Source::Embedded : Source::Disk;
const int cmp = CompareVersions(embeddedVersion, diskVersion);
g_source[addon] = (cmp > 0) ? Source::Embedded : Source::Disk;
}
// Serves one file of embedded addon `addon` from whichever source won
// DecideSource.
int ReadAddonFile(int addon, const char *relPath, int unused, const char *path,
void **outBuf, size_t *outSize, size_t extraBytes, int flag1,
int flag2) {
DecideSource(addon);
if (g_source[addon] == Source::Disk) {
// Disk version is at least as new — serve disk. If disk
// doesn't have this specific file (e.g. embedded has files
// disk doesn't), fall through and try embedded.
const int diskResult = FileRead_o(unused, path, outBuf, outSize,
extraBytes, flag1, flag2);
if (diskResult != 0) return diskResult;
}
// Source::Embedded path, OR Source::Disk-but-file-missing fallback.
// Look up the embedded version and synthesize a Storm buffer the
// caller can free normally.
const Embed::Bytes entry = LookupEmbedded(relPath);
if (entry.data == nullptr) {
// Not in our embedded set either — let the engine try disk
// one more time (returns 0 if that also fails). Covers
// pathological cases like an empty disk install + embedded
// missing some file.
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
flag1, flag2);
}
const size_t totalSize = entry.size + extraBytes;
void *buf = Storm::Alloc(totalSize, __FILE__, __LINE__);
if (buf == nullptr) return 0;
std::memcpy(buf, entry.data, entry.size);
if (extraBytes > 0) {
std::memset(static_cast<uint8_t *>(buf) + entry.size, 0, extraBytes);
}
if (outBuf != nullptr) *outBuf = buf;
if (outSize != nullptr) *outSize = entry.size;
return 1;
}
int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
@@ -283,9 +341,10 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
return 1;
}
const char *suffix = StripAddonPrefix(path);
if (suffix == nullptr) {
// Not our embedded addon. If this is a base-TOC read for a multi-flavor
const char *relPath = nullptr;
const int addon = MatchAddonPath(path, &relPath);
if (addon < 0) {
// Not an embedded addon. If this is a base-TOC read for a multi-flavor
// addon that ships only `<Name>_Turtle.toc` / `_ClassicAPI.toc`, serve
// that in place of the missing `<Name>.toc` so it registers and loads
// (see addons/FlavorToc.h).
@@ -314,42 +373,13 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
return result;
}
DecideSource();
if (g_source == Source::Disk) {
// Disk version is at least as new — serve disk. If disk
// doesn't have this specific file (e.g. embedded has files
// disk doesn't), fall through and try embedded.
const int diskResult = FileRead_o(unused, path, outBuf, outSize,
extraBytes, flag1, flag2);
if (diskResult != 0) return diskResult;
}
// Source::Embedded path, OR Source::Disk-but-file-missing fallback.
// Look up the embedded version and synthesize a Storm buffer the
// caller can free normally.
const Embed::Bytes entry = LookupEmbedded(suffix);
if (entry.data == nullptr) {
// Not in our embedded set either — let the engine try disk
// one more time (returns 0 if that also fails). Covers
// pathological cases like an empty disk install + embedded
// missing some file.
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
flag1, flag2);
}
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(
Offsets::FUN_STORM_SMEM_ALLOC);
const size_t totalSize = entry.size + extraBytes;
void *buf = SMemAlloc(totalSize, __FILE__, __LINE__, 0);
if (buf == nullptr) return 0;
std::memcpy(buf, entry.data, entry.size);
if (extraBytes > 0) {
std::memset(static_cast<uint8_t *>(buf) + entry.size, 0, extraBytes);
}
if (outBuf != nullptr) *outBuf = buf;
if (outSize != nullptr) *outSize = entry.size;
return 1;
// An embedded addon's TOC takes the same per-line directives as an
// installed addon's, whichever source serves it.
const int result = ReadAddonFile(addon, relPath, unused, path, outBuf, outSize,
extraBytes, flag1, flag2);
if (result != 0)
AddOns::TocRewrite::Transform(path, outBuf, outSize);
return result;
}
// The exists-check counterpart of FileRead_h: the same source decision and
@@ -357,23 +387,24 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
// would serve it. Other paths go to FlavorBindings (flavor-only addon
// Bindings.xml), then to the original.
int __stdcall FileExists_h(const char *path, int mode) {
const char *suffix = StripAddonPrefix(path);
if (suffix == nullptr) {
const char *relPath = nullptr;
const int addon = MatchAddonPath(path, &relPath);
if (addon < 0) {
if (FileExists_o(path, mode) != 0)
return 1;
return AddOns::FlavorBindings::TryExists(path, mode, FileExists_o) ? 1 : 0;
}
DecideSource();
if (g_source == Source::Disk && FileExists_o(path, mode) != 0)
DecideSource(addon);
if (g_source[addon] == Source::Disk && FileExists_o(path, mode) != 0)
return 1;
if (LookupEmbedded(suffix).data != nullptr)
if (LookupEmbedded(relPath).data != nullptr)
return 1;
return FileExists_o(path, mode);
}
// `FUN_ADDON_SCAN_FULL` — `void __cdecl()`. We pre-hook it: register
// `"!!!ClassicAPI"` into the empty registry, then let the engine's scan
// `FUN_ADDON_SCAN_FULL` — `void __cdecl()`. We pre-hook it: register the
// embedded addons into the empty registry, then let the engine's scan
// run. Dedup-safe — see file header.
using AddonScan_t = void(__cdecl *)();
AddonScan_t AddonScan_o = nullptr;
@@ -383,55 +414,55 @@ using TocParser_t = void(__fastcall *)(const char *name);
// `entry + OFF_ADDON_ENTRY_FILTER_OUT` is the "filter-out" byte. The
// flat display-array builder `FUN_0051da70` walks the linked list and
// copies an entry into `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when
// this byte is 0, so setting it hides `!!!ClassicAPI` from the
// this byte is 0, so setting it hides an embedded addon from the
// character-select AddOns list — there's no checkbox, so it can't be
// toggled off. The builder only ever *writes* this byte on the
// secure/SMSG path (`entry+0x28 != 0`); our entry is non-secure, so a
// manual `1` here is stable across rebuilds. Hiding does NOT stop
// loading (the load pass `FUN_0051f600` never reads `+0x29`) and does
// NOT break dependency resolution or by-name queries (those use the
// addon hash table, which keeps the entry regardless).
// addon hash table, which keeps the entry regardless — `LoadAddOn` of a
// hidden load-on-demand entry still works).
//
// DefaultState (`+0x2b`) needs no write: the entry constructor
// `FUN_00520550` stores 1 there, our toc has no `## DefaultState:`, and
// `FUN_00520550` stores 1 there, our tocs have no `## DefaultState:`, and
// the resolver co-hook below forces enabled anyway.
// Post-registration finalize for the embedded entry: hide it from the
// addon list. Combined with the `FUN_ADDON_ENABLE_RESOLVE` co-hook,
// `!!!ClassicAPI` always loads and never appears as a toggleable entry.
void FinalizeEmbeddedEntry() {
Addons::ForEachEntry([](uintptr_t entry) -> bool {
// Post-registration finalize for the embedded entries: hide them from the
// addon list. Combined with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, an
// embedded addon is always loadable and never appears as a toggleable
// entry.
void FinalizeEmbeddedEntries() {
Addons::ForEachEntry([](uintptr_t entry) {
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
if (name == nullptr || std::strcmp(name, kAddonName) != 0)
return true; // keep walking
*reinterpret_cast<uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) = 1;
return false;
if (name != nullptr && IndexOfAddon(name) >= 0)
*reinterpret_cast<uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) = 1;
});
}
void __cdecl AddonScan_h() {
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
TocParser(kAddonName);
FinalizeEmbeddedEntry();
for (size_t i = 0; i < kAddonCount; ++i)
TocParser(kAddons[i]);
FinalizeEmbeddedEntries();
AddonScan_o();
}
// `FUN_ADDON_ENABLE_RESOLVE` — per-character enable resolver. We
// co-hook it to force-enable `!!!ClassicAPI` regardless of DefaultState
// or any stale WTF disable-override, so hiding it from the addon list
// can never leave it stuck off. Returns 2 ("enabled for all"); the
// load-pass gate only tests `!= 0`. Every other addon falls through to
// the original untouched. See the Offsets.h note for the ABI.
// co-hook it to force-enable the embedded addons regardless of
// DefaultState or any stale WTF disable-override, so hiding them from
// the addon list can never leave one stuck off. Returns 2 ("enabled for
// all"); the load-pass gate only tests `!= 0`. Every other addon falls
// through to the original untouched. See the Offsets.h note for the ABI.
using AddonEnableResolve_t =
uint32_t(__fastcall *)(const char *name, const char *account, char resolveAll);
AddonEnableResolve_t AddonEnableResolve_o = nullptr;
uint32_t __fastcall AddonEnableResolve_h(const char *name, const char *account,
char resolveAll) {
if (name != nullptr && PathEqualsCI(name, kAddonName))
if (name != nullptr && IndexOfAddon(name) >= 0)
return 2;
return AddonEnableResolve_o(name, account, resolveAll);
}
@@ -458,4 +489,8 @@ const Game::HookAutoRegister _hookEnableResolve{
} // namespace
bool IsEmbedded(const char *name) {
return name != nullptr && IndexOfAddon(name) >= 0;
}
} // namespace Addons::Embedded
+23
View File
@@ -0,0 +1,23 @@
// 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/>.
#pragma once
namespace Addons::Embedded {
// True when `name` is one of the addons embedded in the DLL (compared
// ignoring case). Their registry entries are owned by `Addons::Embedded`:
// registered first, hidden from the addon list, force-enabled.
bool IsEmbedded(const char *name);
} // namespace Addons::Embedded
+1 -5
View File
@@ -32,10 +32,6 @@ using FileReadFn = int(__stdcall *)(int unused, const char *path, void **outBuf,
// FUN_FILE_EXISTS (0x00648A30) — __stdcall, RET 8.
using FileExistsFn = int(__stdcall *)(const char *path, int mode);
// FUN_STORM_SMEM_ALLOC / FUN_STORM_SMEM_FREE — __stdcall, RET 0x10. Buffers
// FUN_FILE_READ hands out are freed with SMemFreeFn; a buffer we allocate with
// SMemAllocFn is freed cleanly by the engine's own SMemFree in turn.
using SMemAllocFn = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
using SMemFreeFn = void(__stdcall *)(void *buf, const char *file, int line, int flags);
// Buffers FUN_FILE_READ hands out are freed with Storm::Free (storm/Memory.h).
} // namespace AddOns::EngineIO
+1 -5
View File
@@ -408,11 +408,7 @@ static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnInterfaceVersion",
&Script_GetAddOnInterfaceVersion,
&kGetAddOnInterfaceVersion);
Game::Lua::RegisterIntegerEnum(
"Enum", "AddOnSecurityStatus",
kAddOnSecurityStatusEntries,
sizeof(kAddOnSecurityStatusEntries) /
sizeof(kAddOnSecurityStatusEntries[0]));
Game::Lua::RegisterIntegerEnum("Enum", "AddOnSecurityStatus", kAddOnSecurityStatusEntries);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+2 -3
View File
@@ -34,6 +34,7 @@
#include "addons/EngineIO.h"
#include "addons/Toc.h"
#include "luasyntax/AddonNamespace.h"
#include "storm/Memory.h"
#include <cstddef>
#include <cstdint>
@@ -51,7 +52,6 @@ using IsLoadedFn_t = uint8_t(__fastcall *)(const char *name);
// FUN_FILE_READ address routes through Embedded's file-read hook, which forwards
// every non-`!!!ClassicAPI` path to the original untouched — what we want here.
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemFreeFn;
// True iff the addon's `.toc` has a line `## AllowAddOnTableAccess: <nonzero>`.
// The value is read as a TOC boolean flag: parse the leading integer, true iff
@@ -87,8 +87,7 @@ bool TocAllowsTableAccess(const char *name) {
allowed = anyDigit && value != 0;
}
auto smemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
smemFree(buf, __FILE__, __LINE__, 0);
Storm::Free(buf, __FILE__, __LINE__);
return allowed;
}
+9 -24
View File
@@ -89,8 +89,8 @@ void PatchLoadWindow() {
reinterpret_cast<const void *>(&LoadWindowCloseThunk));
}
int __fastcall Script_UpdateAddOnMemoryUsage(void * /*L*/) {
Gc::Tag::UpdateUsage();
int __fastcall Script_UpdateAddOnMemoryUsage(void *L) {
Gc::Tag::UpdateUsage(L);
return 0;
}
@@ -107,30 +107,15 @@ int __fastcall Script_GetAddOnMemoryUsage(void *L) {
}
// `_classicapi_LuaHeapAudit()` → objectBytes, nblocks, taggedObjects.
// Walks every collectable object the engine can reach — the main list,
// the userdata list and the string table, the three lists `luaC_sweep`
// sweeps — summing `Gc::Tag::SizeOf`. `objectBytes` should sit just under
// `nblocks` (the engine's own byte count): the gap is what is not a GC
// object (string-table buckets, the main thread and its stack, buffers).
// A gap that grows with the heap means a size formula is wrong.
uint64_t SumList(const uint8_t *object) {
uint64_t total = 0;
while (object != nullptr) {
total += Gc::Tag::SizeOf(object);
object = Game::Read<const uint8_t *>(object, Offsets::OFF_LUA_GCHEADER_NEXT);
}
return total;
}
// Sums `Gc::Tag::SizeOf` over every collectable object the engine can
// reach. `objectBytes` should sit just under `nblocks` (the engine's own
// byte count): the gap is what is not a GC object (string-table buckets,
// the main thread and its stack, buffers). A gap that grows with the heap
// means a size formula is wrong.
int __fastcall Script_LuaHeapAudit(void *L) {
uint64_t bytes = 0;
Gc::Tag::ForEachObject(L, [&bytes](const void *object) { bytes += Gc::Tag::SizeOf(object); });
const uint8_t *g = Game::Read<const uint8_t *>(L, Offsets::OFF_LUA_STATE_G);
uint64_t bytes = SumList(Game::Read<const uint8_t *>(g, Offsets::OFF_LUA_G_ROOTGC)) +
SumList(Game::Read<const uint8_t *>(g, Offsets::OFF_LUA_G_ROOTUDATA));
const auto buckets =
Game::Read<const uint8_t *const *>(g, Offsets::OFF_LUA_G_STRT_HASH);
const int size = Game::Read<int>(g, Offsets::OFF_LUA_G_STRT_SIZE);
for (int i = 0; i < size; ++i)
bytes += SumList(buckets[i]);
Game::Lua::PushNumber(L, static_cast<double>(bytes));
Game::Lua::PushNumber(L, static_cast<double>(Game::Read<uint32_t>(g, Offsets::OFF_LUA_G_NBLOCKS)));
Game::Lua::PushNumber(L, static_cast<double>(Gc::Tag::TaggedCount()));
+10 -13
View File
@@ -61,7 +61,7 @@
// bytes cleared), and the load pass hasn't — nothing holds entry state.
//
// Excluded from the evict path (everything else is fair game):
// - `!!!ClassicAPI` — embedded; `Addons::Embedded` owns its entry
// - the embedded addons — `Addons::Embedded` owns their entries
// (registered first + filter byte);
// - `## Secure:` / SMSG-managed entries — the parser cannot restore
// packet-delivered state (see OFF_ADDON_ENTRY_SECURE).
@@ -83,8 +83,10 @@
#include "Game.h"
#include "Offsets.h"
#include "addons/Embedded.h"
#include "addons/EngineIO.h"
#include "addons/Registry.h"
#include "storm/Memory.h"
#include <algorithm>
#include <cstdint>
@@ -164,10 +166,6 @@ void RefreshLooseFileIndex() {
// ── Step 1.5: `##` metadata refresh (evict + re-register on change) ──
// The embedded addon's name — its entry is owned by `Addons::Embedded`
// (registered first + filter byte) and never refreshed here.
constexpr const char *kEmbeddedAddon = "!!!ClassicAPI";
// name → FNV-1a hash of the addon's `##` lines, as last parsed.
// Per-process on purpose: the registry persists across /reload, and a
// stale hash after a full re-login scan only causes one harmless
@@ -221,11 +219,12 @@ uint32_t HashTocMetadata(const char *buf, size_t size) {
return h;
}
// True when the entry never takes the evict path: the embedded addon
// (owned by `Addons::Embedded`) and SMSG-managed secure entries (the
// parser cannot restore packet-delivered state).
// True when the entry never takes the evict path: the embedded addons
// (owned by `Addons::Embedded` — registered first + filter byte) and
// SMSG-managed secure entries (the parser cannot restore packet-delivered
// state).
bool IsRefreshExempt(uintptr_t entry, const char *name) {
return name == nullptr || std::strcmp(name, kEmbeddedAddon) == 0 ||
return name == nullptr || Addons::Embedded::IsEmbedded(name) ||
*reinterpret_cast<const uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_SECURE) != 0;
}
@@ -237,9 +236,7 @@ bool TryHashToc(const char *name, uint32_t *outHash) {
if (!ReadToc(name, &buf, &size))
return false;
*outHash = HashTocMetadata(static_cast<const char *>(buf), size);
reinterpret_cast<AddOns::EngineIO::SMemFreeFn>(
static_cast<uintptr_t>(Offsets::FUN_STORM_SMEM_FREE))(
buf, __FILE__, __LINE__, 0);
Storm::Free(buf, __FILE__, __LINE__);
return true;
}
@@ -373,7 +370,7 @@ void FixupReverseLoadWith(const std::vector<uintptr_t> &added) {
// `FUN_0051DA70` phase 2, mirrored (never call that function itself —
// its phase 1 consumes an SMSG_ADDON_INFO packet): rebuild the
// `GetNumAddOns`/`GetAddOnInfo(i)` name-pointer array from the linked
// list, skipping filtered entries (keeps `!!!ClassicAPI` hidden), then
// list, skipping filtered entries (keeps the embedded addons hidden), then
// sort with the engine's own comparator to restore alphabetical order.
void RebuildDisplayArray() {
auto desc = reinterpret_cast<Desc *>(
+2 -3
View File
@@ -48,6 +48,7 @@
#include "addons/EngineIO.h"
#include "addons/Toc.h"
#include "script/LoadFile.h"
#include "storm/Memory.h"
#include <cstddef>
#include <cstdint>
@@ -66,7 +67,6 @@ constexpr size_t NPOS = static_cast<size_t>(-1);
// FlavorToc applies), the exists-check, and the Storm free. See addons/EngineIO.h.
using AddOns::EngineIO::FileExistsFn;
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemFreeFn;
using LoadTocFilesFn = uint32_t(__fastcall *)(char *tocPath, int *a2, int *a3);
using NameFn = const char *(*)(); // no-arg readers (return in EAX)
@@ -160,8 +160,7 @@ SvFlags ReadSvFlags(const char *tocPath) {
f.perChar =
AddOns::Toc::FindValue(b, size, "SavedVariablesPerCharacter", &v, &n) && n > 0;
reinterpret_cast<SMemFreeFn>(static_cast<uintptr_t>(Offsets::FUN_STORM_SMEM_FREE))(
buf, __FILE__, __LINE__, 0);
Storm::Free(buf, __FILE__, __LINE__);
return f;
}
+19 -20
View File
@@ -14,8 +14,11 @@
#include "addons/EngineIO.h"
#include "addons/TocRewrite.h"
#include "addons/Toc.h"
#include "localization/Locale.h"
#include "Offsets.h"
#include "storm/Memory.h"
#include "turtle/Detect.h"
#include <cstddef>
#include <cstdint>
@@ -26,24 +29,12 @@ namespace AddOns::TocRewrite {
namespace {
using AddOns::EngineIO::SMemAllocFn;
using AddOns::EngineIO::SMemFreeFn;
using AddOns::Toc::EqCI;
using AddOns::Toc::Lower;
bool IsSpace(char c) { return c == ' ' || c == '\t'; }
// The client locale code ("enUS", "frFR", …) — the exact string
// GetLocale() returns (Offsets.h VAR_LOCALE_NAME_TABLE derivation).
const char *ClientLocale() {
uint32_t idx = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
if (idx > 7) idx = 0; // slot 8 overreads the string pool — clamp
const char *const *table = reinterpret_cast<const char *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_NAME_TABLE));
const char *s = table[idx];
return (s != nullptr) ? s : "enUS";
}
using Localization::Locale::Client;
// True iff the comma-separated `list` (spaces allowed) contains `token` (CI).
bool ListContains(const char *list, size_t listLen, const char *token) {
@@ -65,7 +56,7 @@ bool ListContains(const char *list, size_t listLen, const char *token) {
const char *VariableValue(const char *head, size_t headLen) {
if (EqCI(head, headLen, "Family")) return "Classic";
if (EqCI(head, headLen, "Game")) return "Vanilla";
if (EqCI(head, headLen, "TextLocale")) return ClientLocale();
if (EqCI(head, headLen, "TextLocale")) return Client();
return nullptr;
}
@@ -139,15 +130,25 @@ bool TryRewriteInterfaceLine(const char *line, size_t len, std::string &out) {
return true;
}
// True iff the game-type list names a game type this client is: always
// `vanilla`, and `turtle` (a ClassicAPI extension, like the `_Turtle.toc`
// flavor) on a Turtle client.
bool ListContainsGameType(const char *list, size_t listLen) {
return ListContains(list, listLen, "vanilla") ||
(Turtle::Detected() && ListContains(list, listLen, "turtle"));
}
// Evaluate a `[keyword args]` condition — the return is whether the line should
// load. An unrecognized keyword returns false so the caller drops the line (a
// condition we cannot confirm must not load — fail safe).
bool EvalCondition(const char *head, size_t headLen, const char *args,
size_t argsLen) {
if (EqCI(head, headLen, "AllowLoadGameType"))
return ListContains(args, argsLen, "vanilla");
return ListContainsGameType(args, argsLen);
if (EqCI(head, headLen, "ExcludeLoadGameType"))
return !ListContainsGameType(args, argsLen);
if (EqCI(head, headLen, "AllowLoadTextLocale"))
return ListContains(args, argsLen, ClientLocale());
return ListContains(args, argsLen, Client());
if (EqCI(head, headLen, "AllowLoad"))
return ListContains(args, argsLen, "game"); // addon files: in-game only
return false;
@@ -286,15 +287,13 @@ void Transform(const char *path, void **outBuf, size_t *outSize) {
std::memcmp(rebuilt.data(), content, size) == 0)
return; // brackets/commas were only in unaffected lines — nothing changed
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(Offsets::FUN_STORM_SMEM_ALLOC);
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
const size_t newLen = rebuilt.size();
void *buf = SMemAlloc(newLen + 1, __FILE__, __LINE__, 0); // +1 NUL (extraBytes=1)
void *buf = Storm::Alloc(newLen + 1, __FILE__, __LINE__); // +1 NUL (extraBytes=1)
if (buf == nullptr) return; // OOM — keep the original
std::memcpy(buf, rebuilt.data(), newLen);
static_cast<char *>(buf)[newLen] = '\0';
SMemFree(*outBuf, __FILE__, __LINE__, 0);
Storm::Free(*outBuf, __FILE__, __LINE__);
*outBuf = buf;
if (outSize != nullptr) *outSize = newLen;
}
+9 -1
View File
@@ -42,7 +42,15 @@ namespace AddOns::TocRewrite {
// below); metadata-line conditions, added in retail 12.0.7, are out of
// scope. What the tokens resolve to on this 1.12 client:
//
// [AllowLoadGameType ...] keep the line iff the list contains `vanilla`
// [AllowLoadGameType ...] keep the line iff the list names a game type
// of this client
// [ExcludeLoadGameType ...] keep the line iff the list names none
// This client is the `vanilla` game type, and
// also `turtle` (a ClassicAPI extension, like
// the `_Turtle.toc` flavor) when
// Turtle::Detected(). So
// `[ExcludeLoadGameType turtle]` loads a line
// everywhere but Turtle.
// [AllowLoadTextLocale ...] keep the line iff the list contains the
// client locale (GetLocale() code, e.g. enUS)
// [AllowLoad ...] addon files only load in-game, so `game`
+216 -16
View File
@@ -15,6 +15,17 @@
// subclasses (wowdecomp15595 CSimpleAnim.cpp, CSimple*Anim.cpp and their
// _Scripts.cpp), on Animation::Object, and the group's CreateAnimation.
// Path and its control points are in Path.cpp.
//
// Later clients' additions, on the same objects:
// - the target setters (SetTarget, SetTargetName, SetTargetKey,
// SetChildKey, SetTargetParent, GetTarget);
// - Alpha's fromAlpha / toAlpha. Blizzard's own code starts a fade from
// the region's current alpha with SetFromAlpha(region:GetAlpha()), and
// its XML fades in with toAlpha="1" alone, so the alpha is absolute (the
// target's alpha becomes from + (to - from) x progress) and an unset
// fromAlpha is 0. SetChange goes back to 4.3.4's relative change;
// - Scale's SetScaleFrom / SetScaleTo. 4.3.4's scale is the case from = 1,
// and SetScale sets the end, so its old results do not change.
#include "animation/Classes.h"
#include "animation/Region.h"
@@ -25,7 +36,9 @@
#include "baselib/Ascii.h"
#include <cmath>
#include <cstdio>
#include <cstring>
#include <string>
namespace Animation {
@@ -79,9 +92,15 @@ Anim::~Anim() {
// ---- Playback --------------------------------------------------------------------
bool Anim::Play() {
if (targetName[0] != '\0')
void Anim::ResolveTarget() {
if (!targetKey.empty())
target = Region::FindRegionByKey(this, targetKey.c_str());
else if (targetName[0] != '\0')
target = Region::FindRegionByName(targetName);
}
bool Anim::Play() {
ResolveTarget();
if (playState != 1) {
playState = 1;
RunScript(A_ONPLAY, 0);
@@ -204,14 +223,21 @@ void Anim::Apply(float p) {
return;
switch (kind) {
case KIND_ALPHA:
Region::AddAnimAlpha(region, target, static_cast<int16_t>(change * p));
if (alphaFromTo)
Region::SetAnimAlpha(region, target, static_cast<uint8_t>((fromAlpha + (toAlpha - fromAlpha) * p) * 255.0f));
else
Region::AddAnimAlpha(region, target, static_cast<int16_t>(change * p));
break;
case KIND_TRANSLATION:
Region::AddAnimTranslation(region, target, offsetX * p, offsetY * p);
break;
case KIND_SCALE:
Region::AddAnimScale(region, target, originPoint, originX, originY, scaleChangeX * p, scaleChangeY * p);
case KIND_SCALE: {
// AddAnimScale takes the share of the way to the origin, 1 - scale.
const float x = scaleFromX + (scaleToX - scaleFromX) * p;
const float y = scaleFromY + (scaleToY - scaleFromY) * p;
Region::AddAnimScale(region, target, originPoint, originX, originY, 1.0f - x, 1.0f - y);
break;
}
case KIND_ROTATION:
Region::AddAnimRotation(region, target, originPoint, originX, originY, radians * p);
break;
@@ -473,6 +499,101 @@ int __fastcall Script_GetRegionParent(void *L) {
return 1;
}
// ---- Target (later clients) ----
//
// An animation with no target animates its group's region. Each setter
// replaces the others' target and returns whether it names a region now;
// a name or key is looked up again on Play, as 4.3.4 looks up `target`.
int PushTargetFound(void *L, const Anim *a) {
Game::Lua::PushBool(L, a->target != nullptr);
return 1;
}
void *RegionArg(void *L, int idx) {
if (Game::Lua::Type(L, idx) != Game::Lua::TYPE_TABLE)
return nullptr;
void *object = Game::Lua::ResolveObject(L, idx);
if (object == nullptr || IsOurs(object) || !Game::Lua::IsA(object, Offsets::VAR_REGION_LUA_TYPE_ID))
return nullptr;
return object;
}
const char *StringArg(void *L, Anim *a, const char *usage) {
if (Game::Lua::IsString(L, 2))
return Game::Lua::ToString(L, 2);
Game::Lua::Error(L, usage, a->DisplayName());
return nullptr;
}
int __fastcall Script_SetTarget(void *L) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
void *region = RegionArg(L, 2);
if (region == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
a->targetName[0] = '\0';
a->targetKey.clear();
a->target = region;
return PushTargetFound(L, a);
}
int __fastcall Script_SetTargetName(void *L) {
Anim *a = Self(L);
const char *name = a != nullptr ? StringArg(L, a, "Usage: %s:SetTargetName(name)") : nullptr;
if (name == nullptr)
return 0;
std::snprintf(a->targetName, sizeof(a->targetName), "%s", CreateName(name, a).c_str());
a->targetKey.clear();
a->target = nullptr;
a->ResolveTarget();
return PushTargetFound(L, a);
}
int __fastcall Script_SetTargetKey(void *L) {
Anim *a = Self(L);
const char *key = a != nullptr ? StringArg(L, a, "Usage: %s:SetTargetKey(key)") : nullptr;
if (key == nullptr)
return 0;
a->targetName[0] = '\0';
a->targetKey = key;
a->target = nullptr;
a->ResolveTarget();
return PushTargetFound(L, a);
}
int __fastcall Script_SetChildKey(void *L) {
Anim *a = Self(L);
const char *key = a != nullptr ? StringArg(L, a, "Usage: %s:SetChildKey(childKey)") : nullptr;
if (key == nullptr)
return 0;
a->targetName[0] = '\0';
a->targetKey = std::string("$parent.$parent.") + key;
a->target = nullptr;
a->ResolveTarget();
return PushTargetFound(L, a);
}
int __fastcall Script_SetTargetParent(void *L) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
a->targetName[0] = '\0';
a->targetKey.clear();
a->target = a->GetRegionParent();
return PushTargetFound(L, a);
}
int __fastcall Script_GetTarget(void *L) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
void *region = a->target != nullptr ? a->target : a->GetRegionParent();
if (region != nullptr)
Game::Lua::PushObject(L, region);
else
Game::Lua::PushNil(L);
return 1;
}
// ---- Alpha ----
int __fastcall Script_SetChange(void *L) {
@@ -483,6 +604,7 @@ int __fastcall Script_SetChange(void *L) {
const float change = static_cast<float>(Game::Lua::ToNumber(L, 2));
const float value = change < -1.0f ? -1.0f : (change >= 1.0f ? 1.0f : change);
a->change = static_cast<int16_t>(value * 255.0f);
a->alphaFromTo = false;
return 0;
}
int __fastcall Script_GetChange(void *L) {
@@ -493,6 +615,39 @@ int __fastcall Script_GetChange(void *L) {
return 1;
}
// Later clients' absolute alpha. Setting either end turns it on; the other
// end keeps its value, 0 until set.
void SetAlphaEnd(void *L, float Anim::*end, const char *usage) {
Anim *a = SelfOf(L, Anim::s_alphaClass);
if (a == nullptr || !NumberArg(L, 2, a, usage))
return;
const float value = static_cast<float>(Game::Lua::ToNumber(L, 2));
a->*end = value < 0.0f ? 0.0f : (value > 1.0f ? 1.0f : value);
a->alphaFromTo = true;
}
int __fastcall Script_SetFromAlpha(void *L) {
SetAlphaEnd(L, &Anim::fromAlpha, "Usage: %s:SetFromAlpha(alpha)");
return 0;
}
int __fastcall Script_SetToAlpha(void *L) {
SetAlphaEnd(L, &Anim::toAlpha, "Usage: %s:SetToAlpha(alpha)");
return 0;
}
int __fastcall Script_GetFromAlpha(void *L) {
Anim *a = SelfOf(L, Anim::s_alphaClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, a->fromAlpha);
return 1;
}
int __fastcall Script_GetToAlpha(void *L) {
Anim *a = SelfOf(L, Anim::s_alphaClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, a->toAlpha);
return 1;
}
// ---- Translation ----
int __fastcall Script_SetOffset(void *L) {
@@ -572,19 +727,57 @@ int __fastcall Script_SetScale(void *L) {
Game::Lua::Error(L, "Usage: %s:SetScale(x, y)", a->DisplayName());
return 0;
}
// CSimpleScaleAnim::SetScale.
// CSimpleScaleAnim::SetScale: the scale it ends on.
const float x = static_cast<float>(Game::Lua::ToNumber(L, 2));
const float y = static_cast<float>(Game::Lua::ToNumber(L, 3));
a->scaleChangeX = 1.0f - (x >= 0.001f ? x : 0.001f);
a->scaleChangeY = 1.0f - (y >= 0.001f ? y : 0.001f);
a->scaleToX = x >= 0.001f ? x : 0.001f;
a->scaleToY = y >= 0.001f ? y : 0.001f;
return 0;
}
int __fastcall Script_GetScale(void *L) {
Anim *a = SelfOf(L, Anim::s_scaleClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, 1.0f - a->scaleChangeX);
Game::Lua::PushNumber(L, 1.0f - a->scaleChangeY);
Game::Lua::PushNumber(L, a->scaleToX);
Game::Lua::PushNumber(L, a->scaleToY);
return 2;
}
// Later clients' SetScaleFrom / SetScaleTo: the scale the animation starts
// and ends on, as given.
void SetScaleEnd(void *L, float Anim::*x, float Anim::*y, const char *usage) {
Anim *a = SelfOf(L, Anim::s_scaleClass);
if (a == nullptr)
return;
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, usage, a->DisplayName());
return;
}
a->*x = static_cast<float>(Game::Lua::ToNumber(L, 2));
a->*y = static_cast<float>(Game::Lua::ToNumber(L, 3));
}
int __fastcall Script_SetScaleFrom(void *L) {
SetScaleEnd(L, &Anim::scaleFromX, &Anim::scaleFromY, "Usage: %s:SetScaleFrom(x, y)");
return 0;
}
int __fastcall Script_SetScaleTo(void *L) {
SetScaleEnd(L, &Anim::scaleToX, &Anim::scaleToY, "Usage: %s:SetScaleTo(x, y)");
return 0;
}
int __fastcall Script_GetScaleFrom(void *L) {
Anim *a = SelfOf(L, Anim::s_scaleClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, a->scaleFromX);
Game::Lua::PushNumber(L, a->scaleFromY);
return 2;
}
int __fastcall Script_GetScaleTo(void *L) {
Anim *a = SelfOf(L, Anim::s_scaleClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, a->scaleToX);
Game::Lua::PushNumber(L, a->scaleToY);
return 2;
}
@@ -647,24 +840,31 @@ const Game::Lua::FrameMethodEntry kAnimMethods[] = {
{"GetSmoothing", &Script_GetSmoothing},
{"SetParent", &Script_SetParent},
{"GetRegionParent", &Script_GetRegionParent},
{"SetTarget", &Script_SetTarget},
{"SetTargetName", &Script_SetTargetName},
{"SetTargetKey", &Script_SetTargetKey},
{"SetChildKey", &Script_SetChildKey},
{"SetTargetParent", &Script_SetTargetParent},
{"GetTarget", &Script_GetTarget},
{"HasScript", &Script_HasScript},
{"GetScript", &Script_GetScript},
{"SetScript", &Script_SetScript},
{"HookScript", &Script_HookScript},
};
const Game::Lua::FrameMethodEntry kAlphaMethods[] = {
{"SetChange", &Script_SetChange},
{"GetChange", &Script_GetChange},
{"SetChange", &Script_SetChange}, {"GetChange", &Script_GetChange},
{"SetFromAlpha", &Script_SetFromAlpha}, {"GetFromAlpha", &Script_GetFromAlpha},
{"SetToAlpha", &Script_SetToAlpha}, {"GetToAlpha", &Script_GetToAlpha},
};
const Game::Lua::FrameMethodEntry kTranslationMethods[] = {
{"SetOffset", &Script_SetOffset},
{"GetOffset", &Script_GetOffset},
};
const Game::Lua::FrameMethodEntry kScaleMethods[] = {
{"SetOrigin", &Script_SetOrigin},
{"GetOrigin", &Script_GetOrigin},
{"SetScale", &Script_SetScale},
{"GetScale", &Script_GetScale},
{"SetOrigin", &Script_SetOrigin}, {"GetOrigin", &Script_GetOrigin},
{"SetScale", &Script_SetScale}, {"GetScale", &Script_GetScale},
{"SetScaleFrom", &Script_SetScaleFrom}, {"GetScaleFrom", &Script_GetScaleFrom},
{"SetScaleTo", &Script_SetScaleTo}, {"GetScaleTo", &Script_GetScaleTo},
};
const Game::Lua::FrameMethodEntry kRotationMethods[] = {
{"SetOrigin", &Script_SetOrigin}, {"GetOrigin", &Script_GetOrigin}, {"SetDegrees", &Script_SetDegrees},
+21 -3
View File
@@ -16,6 +16,7 @@
#include "animation/Object.h"
#include <cstdint>
#include <string>
#include <vector>
// 4.3.4's CSimpleAnimGroup and CSimpleAnim (and its Alpha, Translation,
@@ -62,6 +63,10 @@ class Group : public Object {
bool HasAlphaAnim() const;
bool HasPositionAnim() const;
// Later clients' setToFinalAlpha: when the group finishes, its alpha
// animations' targets keep the alpha they end on (KeepAnimAlpha).
bool setToFinalAlpha = false;
void NotifyAnimAdded(Anim *anim);
void NotifyOrderChanged(Anim *anim, int oldOrder);
void NotifyAnimRemoved(Anim *anim);
@@ -141,17 +146,30 @@ class Anim : public Object {
uint8_t flags = 0;
int playState = 0; // 0 stopped, 1 playing, 2 paused
int loopState = STATE_NONE;
void *target = nullptr; // m_target, resolved from targetName on Play
void *target = nullptr; // m_target, resolved from targetName or targetKey on Play
char targetName[128] = {}; // m_targetName
// Later clients' targetKey: a key path from the animation itself, where
// `$parent` steps to the group, then the group's region, then that
// region's parent. childKey "Icon" is "$parent.$parent.Icon".
std::string targetKey;
bool hasSmoother = false;
float smoothIn = 0.0f, smoothOut = 0.0f;
// Alpha: m_change, -255..255.
// Resolves targetName or targetKey into target, when either is set.
void ResolveTarget();
// Alpha: m_change, -255..255, or (alphaFromTo) the absolute fromAlpha /
// toAlpha, 0..1, that later clients replaced it with.
int16_t change = 0;
bool alphaFromTo = false;
float fromAlpha = 0.0f, toAlpha = 0.0f;
// Translation: the offset; Scale: the scale and origin; Rotation: the
// angle and origin. Offsets in layout units, as 4.3.4 keeps them.
float offsetX = 0.0f, offsetY = 0.0f;
float scaleChangeX = 0.0f, scaleChangeY = 0.0f; // m_scaleChange: 1 - scale
// Scale: from and to, 1 by default. 4.3.4's m_scaleChange (1 - scale)
// is the case from = 1; later clients added the from scale.
float scaleFromX = 1.0f, scaleFromY = 1.0f;
float scaleToX = 1.0f, scaleToY = 1.0f;
float radians = 0.0f;
int originPoint = 4; // FRAMEPOINT_CENTER
float originX = 0.0f, originY = 0.0f;
+24 -1
View File
@@ -12,7 +12,10 @@
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// 4.3.4's CSimpleAnimGroup (wowdecomp15595 CSimpleAnimGroup.cpp and
// CSimpleAnimGroup_Scripts.cpp), on Animation::Object.
// CSimpleAnimGroup_Scripts.cpp), on Animation::Object, with later clients'
// SetToFinalAlpha / IsSetToFinalAlpha: the final alpha is kept when the
// group finishes (Finish included), and Stop still restores the alpha. No
// source on disk says what a later client's Stop does with the flag.
#include "animation/Classes.h"
#include "animation/Region.h"
@@ -343,6 +346,12 @@ bool Group::OnUpdate(float elapsedSec) {
RunScript(G_ONUPDATE, 1);
}
if (done) {
// setToFinalAlpha: the alpha each alpha animation's target finished on
// becomes what the end restores. Stop restores the alpha as before.
if (setToFinalAlpha)
for (Anim *anim : animations)
if (anim->kind == KIND_ALPHA)
Region::KeepAnimAlpha(RegionObject(), anim->target);
Region::NotifyEnd(RegionObject(), this);
const bool requested = (flags & GROUP_PENDING_FINISH) != 0;
loopState = STATE_NONE;
@@ -632,6 +641,18 @@ int __fastcall Script_GetIgnoreFramerateThrottle(void *L) {
Game::Lua::PushNil(L);
return 1;
}
int __fastcall Script_SetToFinalAlpha(void *L) {
if (Group *g = Self(L))
g->setToFinalAlpha = Game::Lua::ToFlag(L, 2);
return 0;
}
int __fastcall Script_IsSetToFinalAlpha(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushBool(L, g->setToFinalAlpha);
return 1;
}
const Game::Lua::FrameMethodEntry kMethods[] = {
{"GetObjectType", &Script_GetObjectType},
@@ -662,6 +683,8 @@ const Game::Lua::FrameMethodEntry kMethods[] = {
{"HookScript", &Script_HookScript},
{"SetIgnoreFramerateThrottle", &Script_SetIgnoreFramerateThrottle},
{"GetIgnoreFramerateThrottle", &Script_GetIgnoreFramerateThrottle},
{"SetToFinalAlpha", &Script_SetToFinalAlpha},
{"IsSetToFinalAlpha", &Script_IsSetToFinalAlpha},
};
} // namespace
+2 -4
View File
@@ -30,8 +30,6 @@ using Lock_t = void(__fastcall *)(void *object, void *edx, const char *name);
using Unlock_t = void(__fastcall *)(void *object, void *edx, const char *name);
using BaseResolve_t = uint32_t *(__fastcall *)(void *object, void *edx, const char *name);
using Release_t = void(__stdcall *)(uint32_t *slot);
using LuaRef_t = int(__fastcall *)(void *L, int t);
using LuaUnref_t = void(__fastcall *)(void *L, int t, int ref);
using GetName_t = const char *(__fastcall *)(const void *object);
// 4.3.4's CScriptObject type name, which every class's IsA(name) accepts.
@@ -190,7 +188,7 @@ void RegisterClass(Class &cls) {
Game::Lua::PushCClosure(L, cls.methods[i].func, 0);
Game::Lua::RawSet(L, -3);
}
cls.methodsRef = reinterpret_cast<LuaRef_t>(Offsets::LUA_REF_REF)(L, Game::Lua::REGISTRY_INDEX);
cls.methodsRef = Game::Lua::Ref(L, Game::Lua::REGISTRY_INDEX);
}
// ---- The base methods -----------------------------------------------------------
@@ -306,7 +304,7 @@ int __fastcall Script_SetScript(void *L) {
}
reinterpret_cast<Release_t>(Offsets::FUN_SCRIPT_RELEASE)(slot);
Game::Lua::SetTop(L, 3);
const int ref = reinterpret_cast<LuaRef_t>(Offsets::LUA_REF_REF)(L, Game::Lua::REGISTRY_INDEX);
const int ref = Game::Lua::Ref(L, Game::Lua::REGISTRY_INDEX);
slot[0] = ref > 0 ? static_cast<uint32_t>(ref) : 0;
slot[1] = Game::Read<uint32_t>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT);
return 0;
+66 -6
View File
@@ -61,13 +61,16 @@
#include "Game.h"
#include "Offsets.h"
#include "frame/Layout.h"
#include "frame/Alpha.h"
#include "frame/Destruction.h"
#include "texture/Transform.h"
#include "xml/Anchors.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <string>
#include <unordered_map>
#include <vector>
@@ -113,7 +116,6 @@ using SetColor_t = void(__fastcall *)(void *region, void *edx, const uint32_t *b
using StoreCorners_t = void(__fastcall *)(void *region, void *edx, float *rect);
using Crop_t = void(__fastcall *)(void *region, void *edx, float *rect);
using SetStringPos_t = void(__fastcall *)(void *block, const float *position);
using NdcToDdc_t = void(__fastcall *)(float *outX, float *outY, float x, float y);
using SizeChanged_t = void(__fastcall *)(void *layout, void *edx, const float *oldRect);
using InvalidateNode_t = void(__fastcall *)(void *node);
@@ -162,8 +164,20 @@ uint32_t VertexColor(void *region) {
return bgra;
}
// An unchanged alpha writes nothing. The setter already does nothing when the
// one entry it would store is the region's colour, but the getter reads a
// gradient back as white, and the setter shrinks a gradient's four entries to
// that one (benilla-re ui/texture, "Setting a vertex colour"). So restoring
// the stored alpha (PreOnAnimUpdate, AnimDeactivated) on a gradient under an
// ancestor's alpha group flattened it to white. 3.3.5's and 4.3.4's
// SetVertexColor drop the gradient there too; here only a region that an
// animation itself fades loses it. The ancestor's fade still reaches the
// gradient: a frame's alpha change refolds every region's entries (0x77FAC0).
void SetVertexAlpha(void *region, uint8_t alpha) {
const uint32_t bgra = (VertexColor(region) & 0x00FFFFFF) | (static_cast<uint32_t>(alpha) << 24);
const uint32_t color = VertexColor(region);
if ((color >> 24) == alpha)
return;
const uint32_t bgra = (color & 0x00FFFFFF) | (static_cast<uint32_t>(alpha) << 24);
reinterpret_cast<SetColor_t>(Offsets::FUN_FONTSTRING_SET_COLOR)(region, nullptr, &bgra);
}
@@ -202,7 +216,7 @@ bool GetStringPos(void *block, float out[3]) {
if (node == nullptr)
return false;
const float *position = Game::Ptr<const float>(node, Offsets::OFF_TEXT_NODE_POSITION);
reinterpret_cast<NdcToDdc_t>(Offsets::FUN_COORD_NDC_TO_DDC)(&out[0], &out[1], position[0], position[1]);
Frame::Layout::NdcToDdc(&out[0], &out[1], position[0], position[1]);
out[2] = position[2];
return true;
}
@@ -546,9 +560,8 @@ void RegisterLuaFunctions() {
RegisterClass(Anim::s_rotationClass);
RegisterClass(Anim::s_pathClass);
RegisterClass(ControlPoint::s_class);
Game::Lua::RegisterFrameMethods(reinterpret_cast<void *>(Offsets::VAR_REGION_METHOD_REGISTRY), kRegionMethods,
static_cast<int>(sizeof(kRegionMethods) / sizeof(kRegionMethods[0])),
kRegionDocs, static_cast<int>(sizeof(kRegionDocs) / sizeof(kRegionDocs[0])));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_REGION_METHOD_REGISTRY), kRegionMethods, kRegionDocs);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
@@ -671,6 +684,28 @@ void AddAnimAlpha(void *region, void *target, int16_t alpha) {
SetVertexAlpha(region, static_cast<uint8_t>(value > 255 ? 255 : (value < 0 ? 0 : value)));
}
void SetAnimAlpha(void *region, void *target, uint8_t alpha) {
if (IsFrame(region)) {
if (target != nullptr && target != region) {
ForEachChild(region, [target, alpha](void *child) { SetAnimAlpha(child, target, alpha); });
return;
}
if (alpha != OwnAlpha(region))
SetOwnAlpha(region, alpha);
return;
}
if (target != nullptr && target != region)
return;
SetVertexAlpha(region, alpha);
}
void KeepAnimAlpha(void *region, void *target) {
void *shown = target != nullptr ? target : region;
AnimData *data = DataOf(shown);
if (data != nullptr && data->hasAlpha)
data->alpha = IsFrame(shown) ? OwnAlpha(shown) : static_cast<uint8_t>(VertexColor(shown) >> 24);
}
namespace {
// The corners (TL, BL, TR, BR) as 2D points.
@@ -830,4 +865,29 @@ void *FindRegionByName(const char *name) {
return object;
}
// The leading `$parent` steps that stay among our objects (an animation to
// its group, a group to its region) are taken here; the rest of the path,
// from that region, is the one Xml::Anchors resolves for relativeKey.
void *FindRegionByKey(Object *from, const char *path) {
if (from == nullptr || path == nullptr)
return nullptr;
void *current = from;
const char *p = path;
while (current != nullptr && IsOurs(current)) {
const char *dot = std::strchr(p, '.');
const std::string part = dot != nullptr ? std::string(p, dot) : std::string(p);
if (Game::StrCmpI(part.c_str(), "$parent") != 0)
return nullptr; // a key on a group or animation names no region
current = static_cast<Object *>(current)->parent;
p = dot != nullptr ? dot + 1 : p + part.size();
}
if (current == nullptr)
return nullptr;
if (*p != '\0')
current = ::Xml::Anchors::ResolveKeyPath(current, p, false);
if (current == nullptr || IsOurs(current) || !Game::Lua::IsA(current, Offsets::VAR_REGION_LUA_TYPE_ID))
return nullptr;
return current;
}
} // namespace Animation::Region
+11
View File
@@ -21,6 +21,7 @@
// frames, textures and font strings, which have no room for them.
namespace Animation {
class Group;
class Object;
}
namespace Animation::Region {
@@ -35,6 +36,12 @@ void NotifyEnd(void *region, Group *group);
void PreOnAnimUpdate(void *region);
// CScriptRegion::AddAnimAlpha(target, alpha) on the group's region.
void AddAnimAlpha(void *region, void *target, int16_t alpha);
// Later clients' absolute alpha (Alpha fromAlpha / toAlpha): the same target
// rule as AddAnimAlpha, but the alpha becomes `alpha` instead of growing.
void SetAnimAlpha(void *region, void *target, uint8_t alpha);
// AnimationGroup setToFinalAlpha: the alpha that `target` (the group's
// `region` when null) shows now becomes the one the group's end restores.
void KeepAnimAlpha(void *region, void *target);
// CScriptRegion::AddAnimTranslation(target, translation), in layout units:
// moves the drawn textures and font strings, never a rect.
void AddAnimTranslation(void *region, void *target, float x, float y);
@@ -58,5 +65,9 @@ bool StringAnimated(void *fontString);
// CScriptObject::GetScriptObjectByName for a region (Animation:Play's
// target lookup): the region named `name`, or nullptr.
void *FindRegionByName(const char *name);
// The region a later-client targetKey path names, read from `from`: each
// dot-separated part is `$parent` (the object's parent) or a key on the
// current object's table. Nullptr unless the path ends on a region.
void *FindRegionByKey(Object *from, const char *path);
} // namespace Animation::Region
+57 -4
View File
@@ -16,6 +16,11 @@
// LoadXML_AnimOrigin; the attributes BinaryXML.cpp parses), on 1.12 nodes.
// A script body compiles through the engine's own SetScriptObjectScript, so
// it is named "<object>:<script>" and handled like a frame's.
//
// Later clients' attributes go to the same fields their methods set:
// setToFinalAlpha and parentArray on a group; targetKey and childKey on any animation;
// fromAlpha / toAlpha on Alpha; fromScaleX / fromScaleY / toScaleX /
// toScaleY on Scale. Each applies only when present and non-empty.
#include "animation/Xml.h"
@@ -38,7 +43,6 @@ namespace {
using SetScriptText_t = bool(__fastcall *)(void *object, void *edx, const char *name, const char *text);
using StringToPoint_t = int(__fastcall *)(const char *name, int *point);
using LuaRef_t = int(__fastcall *)(void *L, int t);
const void *FindChild(const void *node, const char *tag) {
for (const void *c = ::Xml::FirstChild(node); c != nullptr; c = ::Xml::NextSibling(c))
@@ -55,6 +59,8 @@ bool FloatAttr(const void *node, const char *name, float &out) {
return true;
}
} // namespace
// object[parentKey] = child, as 4.3.4's LoadXML does for both.
void BindParentKey(void *object, Object *child, const char *key) {
if (key == nullptr || *key == '\0')
@@ -71,6 +77,22 @@ void BindParentKey(void *object, Object *child, const char *key) {
Game::Lua::SetTop(L, top);
}
// Later clients' parentArray on a group: appended to object[key], as a
// frame or region is.
void BindParentArray(void *object, Object *child, const char *key) {
if (key == nullptr || *key == '\0')
return;
void *L = Game::Lua::State();
if (L == nullptr)
return;
const int top = Game::Lua::GetTop(L);
if (::Xml::PushWrapper(L, object)) {
Push(L, child);
::Xml::AppendToArray(L, top + 1, key);
}
Game::Lua::SetTop(L, top);
}
// <Scripts>: each element's body, its `function` global, or its `method`
// field on the object.
void LoadScripts(Object *object, const void *node) {
@@ -103,7 +125,7 @@ void LoadScripts(Object *object, const void *node) {
continue;
}
reinterpret_cast<void(__stdcall *)(uint32_t *)>(Offsets::FUN_SCRIPT_RELEASE)(slot);
slot[0] = static_cast<uint32_t>(reinterpret_cast<LuaRef_t>(Offsets::LUA_REF_REF)(L, Game::Lua::REGISTRY_INDEX));
slot[0] = static_cast<uint32_t>(Game::Lua::Ref(L, Game::Lua::REGISTRY_INDEX));
slot[1] = 0;
continue;
}
@@ -125,6 +147,8 @@ void LoadKeyValues(Object *object, const void *node, void *status) {
Game::Lua::SetTop(L, top);
}
namespace {
// LoadXML_AnimOrigin: <Origin point="..."><Offset x= y=/></Origin>.
void LoadOrigin(Anim *anim, const void *node) {
const void *origin = FindChild(node, "Origin");
@@ -206,6 +230,7 @@ void LoadGroupNode(Group *group, const void *node, void *status) {
LoadGroupNode(group, base, status);
}
BindParentKey(group->RegionObject(), group, ::Xml::Attr(node, "parentKey"));
BindParentArray(group->RegionObject(), group, ::Xml::Attr(node, "parentArray"));
if (const char *looping = ::Xml::Attr(node, "looping"); looping != nullptr && *looping != '\0') {
LoopType type;
if (StringToLoopType(looping, type))
@@ -213,6 +238,8 @@ void LoadGroupNode(Group *group, const void *node, void *status) {
else
::Xml::Warn(status, "AnimationGroup %s: Invalid looping value: %s", group->DisplayName(), looping);
}
if (const char *keep = ::Xml::Attr(node, "setToFinalAlpha"); keep != nullptr && *keep != '\0')
group->setToFinalAlpha = Game::StringToFlag(keep);
float x = 0.0f, y = 0.0f;
FloatAttr(node, "initialOffsetX", x);
FloatAttr(node, "initialOffsetY", y);
@@ -263,8 +290,13 @@ void LoadAnim(Anim *anim, const void *node, void *status) {
if (FloatAttr(node, "scaleY", sy) && sy < 0.001f)
::Xml::Warn(status, "%s: Invalid scaleY value: %s. Value must be at least 0.001.", anim->DisplayName(),
::Xml::Attr(node, "scaleY"));
anim->scaleChangeX = 1.0f - (sx >= 0.001f ? sx : 0.001f);
anim->scaleChangeY = 1.0f - (sy >= 0.001f ? sy : 0.001f);
anim->scaleToX = sx >= 0.001f ? sx : 0.001f;
anim->scaleToY = sy >= 0.001f ? sy : 0.001f;
// Later clients' from and to scale, each axis when given.
FloatAttr(node, "fromScaleX", anim->scaleFromX);
FloatAttr(node, "fromScaleY", anim->scaleFromY);
FloatAttr(node, "toScaleX", anim->scaleToX);
FloatAttr(node, "toScaleY", anim->scaleToY);
break;
}
default:
@@ -286,6 +318,16 @@ void LoadAnim(Anim *anim, const void *node, void *status) {
if (name.size() + 1 > sizeof(anim->targetName))
::Xml::Warn(status, "target key too long: %s", name.c_str());
std::snprintf(anim->targetName, sizeof(anim->targetName), "%s", name.c_str());
anim->targetKey.clear();
}
// Later clients' key paths, looked up on Play like `target`.
if (const char *key = ::Xml::Attr(node, "targetKey"); key != nullptr && *key != '\0') {
anim->targetName[0] = '\0';
anim->targetKey = key;
}
if (const char *key = ::Xml::Attr(node, "childKey"); key != nullptr && *key != '\0') {
anim->targetName[0] = '\0';
anim->targetKey = std::string("$parent.$parent.") + key;
}
if (FloatAttr(node, "startDelay", x))
anim->startDelay = x < 0.0f ? 0.0f : x;
@@ -320,6 +362,17 @@ void LoadAnim(Anim *anim, const void *node, void *status) {
x = 0.0f;
}
anim->change = static_cast<int16_t>((x < -1.0f ? -1.0f : (x >= 1.0f ? 1.0f : x)) * 255.0f);
if (::Xml::Attr(node, "change") != nullptr)
anim->alphaFromTo = false;
// Later clients' absolute alpha: either end turns it on.
if (FloatAttr(node, "fromAlpha", x)) {
anim->fromAlpha = x < 0.0f ? 0.0f : (x > 1.0f ? 1.0f : x);
anim->alphaFromTo = true;
}
if (FloatAttr(node, "toAlpha", x)) {
anim->toAlpha = x < 0.0f ? 0.0f : (x > 1.0f ? 1.0f : x);
anim->alphaFromTo = true;
}
}
if (anim->kind == KIND_SCALE || anim->kind == KIND_ROTATION)
LoadOrigin(anim, node);
+10
View File
@@ -21,6 +21,7 @@
// (RegisterXMLTemplate), whatever its tag, so `inherits` resolves through
// Xml::Template as 4.3.4's XMLHashNodeRef does.
namespace Animation {
class Object;
class Group;
class Anim;
class ControlPoint;
@@ -39,4 +40,13 @@ void LoadControlPoint(ControlPoint *point, const void *node, void *status);
// The template node `name` resolves to, or nullptr.
const void *Template(const char *name);
// What every script object's element shares (Model::Scene's actors too):
// object[key] = child for parentKey, an append to object[key] for
// parentArray, the <Scripts> (a body, a `function` global or a `method`
// field) and the <KeyValues>.
void BindParentKey(void *object, Object *child, const char *key);
void BindParentArray(void *object, Object *child, const char *key);
void LoadScripts(Object *object, const void *node);
void LoadKeyValues(Object *object, const void *node, void *status);
} // namespace Animation::Xml
+63 -21
View File
@@ -36,8 +36,8 @@
// listed by the coverage function
// - a frame method -> Blizzard's widget system for its registry
// ("SimpleTextureAPI", Type "ScriptObject")
// - an enum / structure /
// event -> the System named in its declaration
// - an enum / constants /
// structure / event -> the System named in its declaration
// A system's `Environment` is "All" when its functions were recorded in both
// Lua states, else "Game" or "Glue"; a function whose own environment is
// narrower than its system's gets a Documentation line saying so.
@@ -46,7 +46,6 @@
#include "Game.h"
#include "Offsets.h"
#include "debug/Log.h"
#include "event/Custom.h"
#include <algorithm>
@@ -80,13 +79,20 @@ enum Kind : uint8_t {
KIND_TABLE, // RegisterTableFunction
KIND_FRAME_METHOD, // RegisterFrameMethods, one per entry
KIND_ENUM, // RegisterIntegerEnum
KIND_CONSTANTS, // RegisterConstants
KIND_ORPHAN_METHOD_DOC // a Doc::Method naming no entry in its table
};
// The value-table kinds, which share the enum* fields of Entry.
bool IsValueTable(Kind kind) {
return kind == KIND_ENUM || kind == KIND_CONSTANTS;
}
struct Entry {
Kind kind;
unsigned env; // Env bits the registration was recorded in
const char *table; // KIND_TABLE: the namespace; KIND_ENUM: the parent ("Enum")
const char *table; // KIND_TABLE: the namespace; KIND_ENUM: the parent ("Enum");
// KIND_CONSTANTS: "Constants"
const char *name; // registered name / method name / enum sub-name
const void *registry; // frame methods only
const Function *doc; // nullptr = undocumented
@@ -98,7 +104,6 @@ struct Entry {
std::vector<Entry> g_entries;
Env g_pass = ENV_NONE;
unsigned g_sealed = 0;
int g_recordedThisPass = 0;
constexpr const char *kDebugPrefix = "_classicapi_";
@@ -131,7 +136,6 @@ void Record(Kind kind, const char *table, const char *name, const void *registry
const Function *doc) {
if (g_pass == ENV_NONE || name == nullptr)
return;
++g_recordedThisPass;
if (Entry *e = Find(kind, table, name, registry)) {
e->env |= g_pass;
if (e->doc == nullptr)
@@ -198,6 +202,7 @@ const char *SystemOf(const Entry &e) {
case KIND_FRAME_METHOD:
return WidgetSystemFor(e.registry);
case KIND_ENUM:
case KIND_CONSTANTS:
return e.enumSystem;
default:
return nullptr;
@@ -243,6 +248,7 @@ std::vector<SystemInfo> CollectSystems() {
AddSystem(systems, SystemOf(e), nullptr, "ScriptObject", e.env);
break;
case KIND_ENUM:
case KIND_CONSTANTS:
AddSystem(systems, e.enumSystem, nullptr, nullptr, 0);
break;
default:
@@ -397,6 +403,25 @@ void PushEnumeration(void *L, const Entry &e) {
Lua::SetTable(L, -3);
}
// Pushes { Name, Type = "Constants",
// Values = { { Name, Type = "number", Value } } }.
void PushConstants(void *L, const Entry &e) {
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", e.name);
Lua::SetFieldString(L, "Type", "Constants");
Lua::PushString(L, "Values");
Lua::NewTable(L);
for (int i = 0; i < e.enumCount; ++i) {
Lua::PushNumber(L, static_cast<double>(i + 1));
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", e.enumEntries[i].key);
Lua::SetFieldString(L, "Type", "number");
Lua::SetFieldNumber(L, "Value", e.enumEntries[i].value);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
}
// Pushes { Name, Type = "Structure" [, Documentation], Fields = { … } }.
void PushStructure(void *L, const Structure &s) {
Lua::NewTable(L);
@@ -482,10 +507,13 @@ void PushSystem(void *L, const SystemInfo &sys) {
Lua::SetTable(L, -3);
}
for (const Entry &e : g_entries) {
if (e.kind != KIND_ENUM || !SameTextNoCase(e.enumSystem, sys.name))
if (!IsValueTable(e.kind) || !SameTextNoCase(e.enumSystem, sys.name))
continue;
Lua::PushNumber(L, static_cast<double>(++n));
PushEnumeration(L, e);
if (e.kind == KIND_ENUM)
PushEnumeration(L, e);
else
PushConstants(L, e);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
@@ -530,6 +558,7 @@ std::vector<std::string> Undocumented() {
break;
}
case KIND_ENUM:
case KIND_CONSTANTS:
if (e.enumSystem == nullptr)
out.push_back(std::string(e.table) + "." + e.name);
break;
@@ -619,19 +648,13 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
void BeginPass(Env env) {
g_pass = (g_sealed & env) != 0 ? ENV_NONE : env;
g_recordedThisPass = 0;
}
void EndPass() {
if (g_pass == ENV_NONE)
return;
g_sealed |= g_pass;
const Env done = g_pass;
g_pass = ENV_NONE;
const std::vector<std::string> missing = Undocumented();
Debug::Log::Printf("[doc] %s pass: %d registrations recorded, %d undocumented",
done == ENV_GLUE ? "glue" : "game", g_recordedThisPass,
static_cast<int>(missing.size()));
}
void RecordGlobal(const char *name, const Function *doc) {
@@ -642,10 +665,15 @@ void RecordTable(const char *table, const char *name, const Function *doc) {
Record(KIND_TABLE, table, name, nullptr, doc);
}
void RecordFrameMethods(const void *registry, const Game::Lua::FrameMethodEntry *table,
int count, const Method *docs, int docCount) {
void RecordFrameMethods(const void *registry,
Game::ArrayRef<Game::Lua::FrameMethodEntry> methods,
Game::Doc::MethodList docList) {
if (g_pass == ENV_NONE)
return;
const Game::Lua::FrameMethodEntry *table = methods.data;
const int count = methods.count;
const Method *docs = docList.data;
const int docCount = docList.count;
for (int i = 0; i < count; ++i) {
const Function *doc = nullptr;
for (int d = 0; d < docCount; ++d) {
@@ -671,19 +699,21 @@ void NameWidgetSystem(const void *registry, const char *system) {
g_ownWidgetSystems.push_back({reinterpret_cast<uintptr_t>(registry), system});
}
void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntegerEntry *entries,
int count, const char *system) {
namespace {
void RecordValueTable(Kind kind, const char *parent, const char *sub,
const Game::Lua::EnumIntegerEntry *entries, int count,
const char *system) {
if (g_pass == ENV_NONE || sub == nullptr)
return;
++g_recordedThisPass;
if (Entry *e = Find(KIND_ENUM, parent, sub, nullptr)) {
if (Entry *e = Find(kind, parent, sub, nullptr)) {
e->env |= g_pass;
if (e->enumSystem == nullptr)
e->enumSystem = system;
return;
}
Entry e{};
e.kind = KIND_ENUM;
e.kind = kind;
e.env = g_pass;
e.table = parent;
e.name = sub;
@@ -693,6 +723,18 @@ void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntege
g_entries.push_back(e);
}
} // namespace
void RecordEnum(const char *parent, const char *sub,
Game::ArrayRef<Game::Lua::EnumIntegerEntry> entries, const char *system) {
RecordValueTable(KIND_ENUM, parent, sub, entries.data, entries.count, system);
}
void RecordConstants(const char *sub, Game::ArrayRef<Game::Lua::EnumIntegerEntry> entries,
const char *system) {
RecordValueTable(KIND_CONSTANTS, "Constants", sub, entries.data, entries.count, system);
}
} // namespace Api::Documentation
// The Structure constructor lives here so the list head is private to this
+7 -4
View File
@@ -32,12 +32,15 @@ void EndPass();
void RecordGlobal(const char *name, const Game::Doc::Function *doc);
void RecordTable(const char *table, const char *name, const Game::Doc::Function *doc);
void RecordFrameMethods(const void *registry, const Game::Lua::FrameMethodEntry *table,
int count, const Game::Doc::Method *docs, int docCount);
void RecordFrameMethods(const void *registry,
Game::ArrayRef<Game::Lua::FrameMethodEntry> methods,
Game::Doc::MethodList docs);
// Names the widget system of a method hash this DLL owns (a frame class it
// adds), alongside the engine registries the exporter already knows.
void NameWidgetSystem(const void *registry, const char *system);
void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntegerEntry *entries,
int count, const char *system);
void RecordEnum(const char *parent, const char *sub,
Game::ArrayRef<Game::Lua::EnumIntegerEntry> entries, const char *system);
void RecordConstants(const char *sub, Game::ArrayRef<Game::Lua::EnumIntegerEntry> entries,
const char *system);
} // namespace Api::Documentation
+69
View File
@@ -0,0 +1,69 @@
// 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/>.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
// CanCancelAuction(index) — 4.3.4's Script_CanCancelAuction (0x00975180):
// nothing without an auctioneer, nil for an owner-list index with no row or a
// sold row, else 1. The gates are 1.12's own Script_CancelAuction (0x004D0260):
// auctioneer GUID set, ftol(index) - 1 below the owner count (unsigned, so 0 and
// negatives miss), non-null record. 1.12's owner records carry no sold flag, so
// that 4.3.4 gate has nothing to read. The cancel-fee money check stays in
// CancelAuction, as in 4.3.4.
namespace AuctionHouse::Cancel {
namespace {
int __fastcall Script_CanCancelAuction(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: CanCancelAuction(index)");
return 0;
}
if (Game::Read<uint32_t>(Offsets::VAR_AUCTION_AUCTIONEER_GUID_LO) == 0 &&
Game::Read<uint32_t>(Offsets::VAR_AUCTION_AUCTIONEER_GUID_HI) == 0)
return 0;
const uint32_t index = static_cast<uint32_t>(static_cast<int64_t>(Game::Lua::ToNumber(L, 1))) - 1;
const auto *entries = reinterpret_cast<void *const *>(Offsets::VAR_AUCTION_OWNER_ENTRIES);
if (index < Game::Read<uint32_t>(Offsets::VAR_AUCTION_OWNER_COUNT) && entries[index] != nullptr)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("index", "number", "Row in the \"owner\" auction list, from 1."),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Opt("canCancel", "number", nullptr,
"1 when the row is one of the player's auctions, else nil. Nothing when the auction house is closed."),
};
const Game::Doc::Function kCanCancelAuction{
"Returns whether CancelAuction can cancel a row of the owner auction list.", kArgs, kRets,
"AuctionHouseGlobals"};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("CanCancelAuction", &Script_CanCancelAuction, &kCanCancelAuction);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace AuctionHouse::Cancel
+96
View File
@@ -40,6 +40,8 @@
#include "Game.h"
#include "Offsets.h"
#include "aura/Instance.h"
#include "spell/Lookup.h"
#include "ui/ColorData.h"
#include "unit/Identity.h"
@@ -474,6 +476,46 @@ int PushAuraBySlot(void *L, Data::Emit emit) {
return 1;
}
// C_UnitAuras.GetAuraDataByAuraInstanceID(unit, auraInstanceID [, buf])
// -> AuraData | nil
// With `buf`, the fields go into that table (cleared of any this aura lacks)
// and it is returned — the allocation-free form for UNIT_AURA_DELTA handlers
// that look up each changed ID. Only auras of a unit in view have IDs.
int __fastcall Script_GetAuraDataByAuraInstanceID(void *L) {
const char *unitToken = ArgUnit(L, 1);
if (unitToken == nullptr || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::PushNil(L);
return 1;
}
const auto id = static_cast<uint32_t>(Game::Lua::ToNumber(L, 2));
const bool fill = Game::Lua::Type(L, 3) == Game::Lua::TYPE_TABLE;
const uint8_t *unit = ResolveUnit(unitToken);
const int slot = Aura::Instance::SlotForID(unit, id);
if (slot < 0) {
Game::Lua::PushNil(L);
return 1;
}
if (fill) {
Game::Lua::PushValue(L, 3);
Data::Push(L, unit, slot, Data::Emit::Fill);
} else {
Data::Push(L, unit, slot);
}
return 1;
}
const Game::Doc::Field kByInstanceIDArgs[] = {
Game::Doc::Req("unit", "UnitToken"),
Game::Doc::Req("auraInstanceID", "number"),
Game::Doc::Opt("buf", "table", nullptr,
"A table to write the aura into and return instead of a new one."),
};
const Game::Doc::Field kByInstanceIDRets[] = {
Game::Doc::Opt("aura", "AuraData", nullptr, "Nil when no aura on the unit has that ID."),
};
const Game::Doc::Function kGetAuraDataByAuraInstanceID{
"The aura with the given instance ID on a unit.", kByInstanceIDArgs, kByInstanceIDRets};
// C_UnitAuras.GetAuraDataBySlot(unit, slot) -> AuraData | nil
int __fastcall Script_GetAuraDataBySlot(void *L) {
return PushAuraBySlot(L, Data::Emit::Table);
@@ -611,6 +653,55 @@ int __fastcall Script_GetAuraDispelTypeColor(void *L) {
return 1;
}
// CancelUnitBuff(unit, index [, filter]) — 4.3.4's Script_CancelUnitBuff.
// Only the player's own auras: its other branch exits a vehicle seat, which
// 1.12 has no counterpart for. The index counts in `FindNthSlot` order, the
// same order 4.3.4's GetSortedAuraSlot walks, so it matches the
// C_UnitAuras by-index getters for the same filter (HELPFUL by default).
// The cancel gate is the engine's: the slot's cancelable bit, or the spell's
// `SPELL_ATTR_EX_CHANNELED_1`, before the CMSG_CANCEL_AURA sender.
int __fastcall Script_CancelUnitBuff(void *L) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: CancelUnitBuff(\"unit\", index[, \"filter\"])");
return 0;
}
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr ||
Unit::Identity::GuidForToken(Game::Lua::ToString(L, 1)) !=
Unit::Identity::PlayerGuid())
return 0;
const ParsedFilter pf = ParseFilters(ArgOptString(L, 3));
const int slot =
Data::FindNthSlot(player, ArgInt(L, 2), RangeFilter(pf), pf.ToMatch());
if (slot < 0)
return 0;
const int spellID = static_cast<int>(Data::ReadSpellID(player, slot));
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return 0;
const bool cancelable =
(Data::SlotNibble(player, slot) & Offsets::UNIT_AURA_FLAG_CANCELABLE) != 0 ||
(Game::Read<uint32_t>(record, Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX) &
Offsets::SPELL_ATTR_EX_CHANNELED_1) != 0;
if (!cancelable)
return 0;
using CancelAuraSend_t = void(__fastcall *)(int spellID);
reinterpret_cast<CancelAuraSend_t>(
static_cast<uintptr_t>(Offsets::FUN_CANCEL_AURA_SEND))(spellID);
return 0;
}
const Game::Doc::Field kCancelUnitBuffArgs[] = {
Game::Doc::Req("unit", "string", "Must be the player."),
Game::Doc::Req("index", "number",
"Which aura of the ones `filter` selects, from 1."),
Game::Doc::Opt("filter", "string", "\"HELPFUL\"",
"Aura filter tokens, the same as `C_UnitAuras.GetAuraDataByIndex`."),
};
const Game::Doc::Function kCancelUnitBuff{
"Cancels one of your own auras by index.", kCancelUnitBuffArgs, {},
"SpellGlobals"};
} // namespace
static void RegisterLuaFunctions() {
@@ -630,6 +721,9 @@ static void RegisterLuaFunctions() {
&Script_GetAuraSlots);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDataBySlot",
&Script_GetAuraDataBySlot);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDataByAuraInstanceID",
&Script_GetAuraDataByAuraInstanceID,
&kGetAuraDataByAuraInstanceID);
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitAuraBySlot",
&Script_UnitAuraBySlot);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetUnitAuraBySpellID",
@@ -642,6 +736,8 @@ static void RegisterLuaFunctions() {
&Script_GetUnitAuras);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDispelTypeColor",
&Script_GetAuraDispelTypeColor);
Game::Lua::RegisterGlobalFunction("CancelUnitBuff", &Script_CancelUnitBuff,
&kCancelUnitBuff);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+126 -47
View File
@@ -15,10 +15,12 @@
#include "Game.h"
#include "Offsets.h"
#include "aura/Instance.h"
#include "aura/Source.h"
#include "dbc/Lookup.h"
#include "group/MemberStats.h"
#include "guid/Guid.h"
#include "net/PacketDispatch.h"
#include "object/Resolve.h"
#include "spell/CrowdControl.h"
#include "spell/IsSelfBuff.h"
@@ -120,38 +122,55 @@ bool PlayerBuffUntilCancelled(const uint8_t *record) {
return Game::Read<int>(record, Offsets::OFF_PLAYER_BUFF_UNTIL_CANCELLED) != 0;
}
// Reads the absolute expiration timestamp (in seconds, GetTime-epoch)
// for a player-buff record, or 0 if it has expired / has no timing. The
// engine stores ms-since-tickcount-epoch which matches what Lua's
// `GetTime()` returns (engineMs * 0.001), so converting
// `expirationMs * 0.001` lands directly on the same timeline addons
// compare against on the Lua side. No epoch reconciliation needed.
//
// **Read as `uint32_t`**: GetTickCount overflows `INT_MAX` at ~24.86
// days of system uptime. A signed read flips negative past that
// point, which broke a `<= 0` early-bail that was supposed to catch
// only the "no timing" (0) sentinel. The engine's own arithmetic
// works on the unsigned tick because both sides of `expiration -
// currentMs` wrap together.
double PlayerBuffExpirationSeconds(const uint8_t *record) {
if (record == nullptr || PlayerBuffUntilCancelled(record))
return 0.0;
const int slotCode = Game::Read<int>(record, Offsets::OFF_PLAYER_BUFF_SLOT_CODE);
if (slotCode < 0 || slotCode >= Offsets::UNIT_AURA_TOTAL)
return 0.0;
auto *expirationTable = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_BUFF_EXPIRATION_TABLE));
const uint32_t expirationMs = expirationTable[slotCode];
if (expirationMs == 0)
return 0.0;
// An elapsed expiry reads as "no timer", as the engine's own
// GetPlayerBuffTimeLeft (0x004e4930 → FUN_004e4450) returns 0 once
// `(int)(now - expiration[slot]) >= 0`. `Time::Clock::Reached` takes the
// same signed diff, so it also absorbs the ~24.86-day GetTickCount wrap.
if (Time::Clock::Reached(expirationMs))
return 0.0;
return static_cast<double>(expirationMs) * 0.001;
// The applied duration the server last sent for each of the player's aura
// slots. SMSG_UPDATE_AURA_DURATION (`u8 slot, i32 ms`) is the only wire
// timing for the player's own auras, and the engine's `SetAuraDuration`
// (0x004E4390) keeps just `now + ms` from it — the expiration table. The ms
// is the holder's REMAINING time (tortoise-wow `UpdateAuraDuration` sends
// `GetAuraDuration()`), which on application and refresh is the true total
// (diminishing returns and the caster's modifiers already applied
// server-side), so it is kept here. Subscribers run before the engine
// handler, so an entry changes in the same packet as its slot's expiry and,
// like it, outlives the aura (the until-cancelled flag and the elapsed test
// gate both).
uint32_t g_playerDurationMs[Offsets::PLAYER_BUFF_TABLE_COUNT];
// Slots whose ms came from an aura the character already held — only what is
// left of it, not its total. Login sends those durations twice over: a fresh
// login from `_LoadAuras` while the character loads, before the player's
// create block (no player object yet); a reconnect from
// Map::ExistingPlayerLogin after it (slots full, no earlier duration).
// `PlayerAuraTiming` stands the spell's base in for the total.
bool g_playerDurationRemainingOnly[Offsets::PLAYER_BUFF_TABLE_COUNT];
void OnPacket(uint32_t opcode, Net::CDataStore *packet) {
if (opcode != Offsets::SMSG_UPDATE_AURA_DURATION)
return;
const uint8_t slot = Net::Read<uint8_t>(packet);
const int32_t ms = Net::Read<int32_t>(packet);
if (packet->m_read > packet->m_size || slot >= Offsets::PLAYER_BUFF_TABLE_COUNT)
return;
const uint8_t *player = Unit::Identity::PlayerObject();
const uint32_t previous = g_playerDurationMs[slot];
// A duration packet for an aura already in the slot is a refresh — no
// descriptor field changes, so nothing else reports it. An application's
// packet comes before the descriptor write that fills its slot (verified
// live: the slot reads 0 at that moment), so an occupied slot held the
// aura already. The login burst finds no earlier duration: the table is
// cleared with the glue screen.
const uint32_t spellId = ReadSpellID(player, slot);
g_playerDurationMs[slot] = (ms > 0) ? static_cast<uint32_t>(ms) : 0;
g_playerDurationRemainingOnly[slot] = player == nullptr || (spellId != 0 && previous == 0);
Aura::Instance::NoteUpdated(Unit::Identity::PlayerGuid(), slot);
if (spellId != 0 && previous != 0 && ms > 0)
Aura::Source::SignalRefreshed(Unit::Identity::PlayerGuid(), spellId, slot);
}
const Net::PacketDispatch::AutoSubscribe _durationSub{&OnPacket};
// The glue screen ends the table: the next login's duration burst must find
// no earlier duration, or every aura would read as refreshed.
void ResetPlayerDurations() { std::memset(g_playerDurationMs, 0, sizeof g_playerDurationMs); }
const Game::GlueModuleAutoRegister _glueReset{&ResetPlayerDurations};
// The spell's base duration in seconds, as the server computes it before
// combo points and caster modifiers: the SpellDuration row's base, with
@@ -187,7 +206,7 @@ double SpellBaseDurationSeconds(uint32_t spellID) {
}
// True if the engine-tick expiration `expMs` has already reached/passed now.
// Same signed-wrap-safe compare `PlayerBuffExpirationSeconds` uses (absorbs
// Same signed-wrap-safe compare `PlayerAuraTiming` uses (absorbs
// the ~24.86-day GetTickCount wrap). An already-elapsed cached expiration is
// not meaningful — the caster is kept regardless, but the time is reported as
// unknown rather than as a negative remaining.
@@ -877,18 +896,28 @@ static Display ResolveDisplay(uint32_t spellID, uint64_t casterGuid, bool isHelp
return d;
}
static void ClearField(void *L, const char *key) {
Game::Lua::PushString(L, key);
Game::Lua::PushNil(L);
Game::Lua::SetTable(L, -3);
}
// Writes the full modern AuraData table on top of the Lua stack from
// already-resolved values. Shared by the descriptor path (`Push`) and the
// cache-fallback path (`PushFromCache`). Net stack effect: +1 (the table).
// cache-fallback path (`PushFromCache`). Net stack effect: +1 (the table), or
// 0 with `fill`, which writes into the table already on top and clears the
// optional fields this aura doesn't have. `instanceID` 0 = none
// (`Aura::Instance` names only live descriptor auras).
static void BuildTable(void *L, uint64_t unitGuid, uint32_t spellID, int applications,
bool isHelpful, double duration, double expirationTime,
uint64_t casterGuid) {
uint64_t casterGuid, uint32_t instanceID, bool fill) {
const Display disp = ResolveDisplay(spellID, casterGuid, isHelpful, unitGuid);
const char *name = disp.name;
const char *icon = disp.icon;
const char *dispel = disp.dispelName;
Game::Lua::NewTable(L);
if (!fill)
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", name);
Game::Lua::SetFieldString(L, "icon", icon);
@@ -915,10 +944,22 @@ static void BuildTable(void *L, uint64_t unitGuid, uint32_t spellID, int applica
Guid::FormatAsString(casterGuid, guidBuf, sizeof guidBuf);
if (sourceUnit != nullptr)
Game::Lua::SetFieldString(L, "sourceUnit", sourceUnit);
else if (fill)
ClearField(L, "sourceUnit");
if (sourceGUID != nullptr)
Game::Lua::SetFieldString(L, "sourceGUID", sourceGUID);
else if (fill)
ClearField(L, "sourceGUID");
} else if (fill) {
ClearField(L, "sourceUnit");
ClearField(L, "sourceGUID");
}
if (instanceID != 0)
Game::Lua::SetFieldNumber(L, "auraInstanceID", static_cast<double>(instanceID));
else if (fill)
ClearField(L, "auraInstanceID");
Game::Lua::SetFieldNumber(L, "charges", 0);
Game::Lua::SetFieldNumber(L, "maxCharges", 0);
Game::Lua::SetFieldNumber(L, "timeMod", 1);
@@ -935,9 +976,9 @@ static void BuildTable(void *L, uint64_t unitGuid, uint32_t spellID, int applica
Game::Lua::SetFieldBool(L, "nameplateShowPersonal", false);
Game::Lua::SetFieldBool(L, "shouldConsolidate", false);
// `auraInstanceID` and `points` are deliberately omitted — modern returns
// nil for those when they don't apply, and Lua reading a missing key
// yields nil, so the table doesn't need an explicit entry.
// `points` is deliberately omitted — modern returns nil when it doesn't
// apply, and Lua reading a missing key yields nil, so the table doesn't
// need an explicit entry.
}
// Emits the Classic-Era `UnitAura` 15-value tuple onto the Lua stack from the
@@ -977,6 +1018,37 @@ static void PushPositional(void *L, uint64_t unitGuid, uint32_t spellID, int app
Game::Lua::PushNumber(L, 1); // 15 timeMod
}
bool PlayerAuraTiming(int slot, uint32_t *outExpirationMs, uint32_t *outDurationMs) {
// FindPlayerBuffRecord matches slotCode == slot, and an empty record's
// slotCode is -1, so an unbound slot must not reach it.
if (slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
return false;
const uint8_t *record = FindPlayerBuffRecord(slot);
if (record == nullptr || PlayerBuffUntilCancelled(record))
return false;
// The engine's expiry, in the GetTime() epoch (×0.001) and read as
// `uint32_t` — the tick is unsigned and wraps (see `Time::Clock`).
auto *expirationTable = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_BUFF_EXPIRATION_TABLE));
const uint32_t expirationMs = expirationTable[slot];
// An elapsed expiry reads as "no timer", as the engine's own
// GetPlayerBuffTimeLeft (0x004e4930 → FUN_004e4450) returns 0 once
// `(int)(now - expiration[slot]) >= 0` — the same signed diff `Reached` takes.
if (expirationMs == 0 || Time::Clock::Reached(expirationMs))
return false;
*outExpirationMs = expirationMs;
*outDurationMs = g_playerDurationMs[slot];
// A remaining-only ms gives way to the spell's base, unless a caster
// modifier stretched the aura past it.
if (g_playerDurationRemainingOnly[slot] && *outDurationMs != 0) {
const uint32_t spellID = ReadSpellID(Unit::Identity::PlayerObject(), slot);
const auto baseMs = static_cast<uint32_t>(SpellBaseDurationSeconds(spellID) * 1000.0);
if (baseMs > *outDurationMs)
*outDurationMs = baseMs;
}
return true;
}
// Enriches a bare `(guid, spellID)` into a full AuraData table — the shared
// core behind every push path (descriptor, cache fallback, group-array). No
// dependence on a live descriptor: the descriptor path passes what it read
@@ -987,8 +1059,9 @@ static void PushPositional(void *L, uint64_t unitGuid, uint32_t spellID, int app
// (`SpellBaseDurationSeconds`); talent/glyph
// duration modifiers are baked into the caster-side expiration, so
// `expirationTime - GetTime()` is the true remaining time even when `duration`
// omits the boost. `expirationTime` for the local player comes from the engine
// buff table (gated on `isPlayer`); for everyone else it, the caster, and the
// omits the boost. For the local player both come from `PlayerAuraTiming`
// (gated on `isPlayer`) — the server's applied duration, which no cache
// estimate overrides; for everyone else the expiration, the caster, and the
// applied (caster-modified) duration come from the `Aura::Source` SMSG_SPELL_GO
// cache when it observed the cast — a miss leaves the modern-truthful defaults
// (expiration 0, no sourceUnit/GUID). `slot` is the absolute descriptor slot
@@ -1009,18 +1082,24 @@ static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
bool isHelpful, int applications,
bool isPlayer, int slot,
const Attribution *known = nullptr,
Emit emit = Emit::Table) {
Emit emit = Emit::Table, uint32_t instanceID = 0) {
double duration = 0.0;
double expirationTime = 0.0;
uint64_t casterGuid = 0;
bool untilCancelled = false;
bool exactDuration = false;
if (spellID != 0)
duration = SpellBaseDurationSeconds(spellID);
if (isPlayer && spellID != 0) {
const uint8_t *record = FindPlayerBuffRecord(slot);
if (record != nullptr) {
untilCancelled = PlayerBuffUntilCancelled(record);
expirationTime = PlayerBuffExpirationSeconds(record);
untilCancelled = record != nullptr && PlayerBuffUntilCancelled(record);
uint32_t expMs = 0, durMs = 0;
if (PlayerAuraTiming(slot, &expMs, &durMs)) {
expirationTime = static_cast<double>(expMs) * 0.001;
if (durMs != 0) {
duration = static_cast<double>(durMs) * 0.001;
exactDuration = true;
}
}
}
if (spellID != 0 && guid != 0) {
@@ -1040,7 +1119,7 @@ static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
if (expirationTime == 0.0 && a.expirationMs != 0 &&
!ExpirationElapsed(a.expirationMs))
expirationTime = static_cast<double>(a.expirationMs) * 0.001;
if (a.durationMs != 0)
if (a.durationMs != 0 && !exactDuration)
duration = static_cast<double>(a.durationMs) * 0.001;
casterGuid = a.caster;
}
@@ -1055,7 +1134,7 @@ static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
expirationTime, casterGuid);
else
BuildTable(L, guid, spellID, applications, isHelpful, duration, expirationTime,
casterGuid);
casterGuid, instanceID, emit == Emit::Fill);
}
// Attribution held directly by an `Aura::Source` cache entry (from `Enumerate`),
@@ -1069,7 +1148,7 @@ void Push(void *L, const uint8_t *unit, int slot, Emit emit) {
const bool isHelpful = !IsSlotHarmful(unit, slot); // nibble, not slot range
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
unit != nullptr && unit == LocalPlayer(), slot,
/*known*/ nullptr, emit);
/*known*/ nullptr, emit, Aura::Instance::IDForSlot(unit, slot));
}
namespace {
+21 -4
View File
@@ -59,8 +59,11 @@ enum class Filter { Helpful, Harmful };
// pushes the Classic-Era `UnitAura` 15-value tuple (net stack +15) with NO table
// allocation, for the `C_UnitAuras.UnitAura/UnitBuff/UnitDebuff` accessors that
// avoid the per-call GC churn. Selection + timing resolution are identical for
// both — only the terminal leaf differs.
enum class Emit { Table, Positional };
// both — only the terminal leaf differs. `Fill` writes the `AuraData` fields
// into the table already on top of the stack instead of a new one (net stack
// 0), for getters that take a caller's table to reuse; fields the aura lacks
// are cleared to nil.
enum class Emit { Table, Positional, Fill };
// Caster restriction from the `PLAYER` / `!PLAYER` aura filter tokens.
// `Any` = no restriction; `PlayerOnly` = only auras the local player cast,
@@ -104,6 +107,10 @@ struct Match {
bool raid = false;
};
// The slot's descriptor flag nibble (0 for an empty slot or a null unit).
// Bit `UNIT_AURA_FLAG_CANCELABLE` is the cancelable bit on every server.
uint8_t SlotNibble(const uint8_t *unit, int slot);
// Reads the spell ID at the given absolute slot (0..47). Returns 0
// if the slot is empty or the unit pointer is null.
uint32_t ReadSpellID(const uint8_t *unit, int slot);
@@ -173,6 +180,14 @@ int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
// whose cast we didn't observe.
bool IsPlayerCast(const uint8_t *unit, int slot);
// The server's own timing for the local player's aura in absolute `slot`:
// the engine's expiration (what `GetPlayerBuffTimeLeft` counts down) and the
// applied duration from the same SMSG_UPDATE_AURA_DURATION, both exact —
// diminishing returns and the caster's modifiers included. False when the
// slot has no running timer (empty, until-cancelled, or elapsed).
// `outDurationMs` is 0 when no duration packet was seen for the slot.
bool PlayerAuraTiming(int slot, uint32_t *outExpirationMs, uint32_t *outDurationMs);
// Applies a `CasterMode` to a per-aura "was cast by the player or a unit
// they own" answer. `Any` → always true; `PlayerOnly` → the answer;
// `NotPlayer` → its negation.
@@ -240,14 +255,16 @@ const char *DispelName(uint32_t dispelTypeID);
// dispel type a spell the player or pet knows removes from
// an enemy)
//
// And `auraInstanceID` from `Aura::Instance` (the table forms only).
//
// And these with vanilla-truthful defaults (matches modern's
// "field present but inapplicable" semantics):
// charges=0, maxCharges=0, auraInstanceID=nil, points=nil,
// charges=0, maxCharges=0, points=nil,
// isBossAura=false, isNameplateOnly=false, nameplateShowAll=false,
// nameplateShowPersonal=false, shouldConsolidate=false
//
// Net stack effect: +1 for `Emit::Table` (the table), +15 for `Emit::Positional`
// (the Classic-Era `UnitAura` tuple — see `Emit`).
// (the Classic-Era `UnitAura` tuple), 0 for `Emit::Fill` — see `Emit`.
void Push(void *L, const uint8_t *unit, int slot, Emit emit = Emit::Table);
// Fallback for the index path when the descriptor has dropped an aura's slot
+151
View File
@@ -0,0 +1,151 @@
// 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.
// UNIT_AURA_DELTA(unitTarget, updateInfo) — retail's UNIT_AURA payload under
// its own name, so the engine's UNIT_AURA stays exactly as it is.
//
// Once per frame, from the world tick, each unit whose auras changed fires
// once per unit token it has (`Unit::Identity::TokensForGUID`), the way the
// engine fires UNIT_AURA. The change set is `Aura::Instance`'s.
//
// The payload is built to cost no garbage. It carries instance IDs only —
// never AuraData; a handler reads the auras it cares about with
// `C_UnitAuras.GetAuraDataByAuraInstanceID(unit, id, buf)` — and the
// `updateInfo` table and its three ID lists are the same four tables on every
// fire, refilled each time. So they are valid only during the handler; a
// handler that keeps one must copy it.
//
// With nothing registered for the event, the tick reads one pointer and stops:
// `Aura::Instance` records no change set, and nothing here runs.
#include "Game.h"
#include "aura/Instance.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Aura::Delta {
namespace {
const Game::Doc::Field kInfoFields[] = {
Game::Doc::Req("isFullUpdate", "bool",
"True when the unit's auras must be read again in full; the lists are then nil."),
Game::Doc::Opt("addedAuraInstanceIDs", "table", nullptr,
"Instance IDs of the auras added to the unit."),
Game::Doc::Opt("updatedAuraInstanceIDs", "table", nullptr,
"Instance IDs of the unit's auras that changed in place."),
Game::Doc::Opt("removedAuraInstanceIDs", "table", nullptr,
"Instance IDs of the auras removed from the unit."),
};
const Game::Doc::Structure kInfo{
"UnitAuraDeltaInfo", "UnitAuras", kInfoFields,
"What changed in a unit's auras. Valid only while the event handler runs."};
const Game::Doc::Field kPayload[] = {
Game::Doc::Req("unitTarget", "UnitToken"),
Game::Doc::Req("updateInfo", "UnitAuraDeltaInfo"),
};
const Game::Doc::Event kDoc{
"UnitAuras",
"Fires when a unit's auras change, with the instance IDs of the auras that "
"were added, changed, or removed.",
kPayload};
const Event::Custom::AutoReserve _evt{"UNIT_AURA_DELTA", &kDoc};
// The reused payload tables, anchored in the registry of the Lua state that
// made them. LUA_NOREF until first use in each state.
constexpr int kNoRef = -2;
int g_infoRef = kNoRef;
int g_addedRef = kNoRef;
int g_updatedRef = kNoRef;
int g_removedRef = kNoRef;
// Pushes the table anchored at `ref`, making it on first use.
void PushTable(void *L, int &ref) {
if (ref == kNoRef) {
Game::Lua::NewTable(L);
ref = Game::Lua::Ref(L, Game::Lua::REGISTRY_INDEX);
}
Game::Lua::RawGetI(L, Game::Lua::REGISTRY_INDEX, ref);
}
// Sets `key` on the table at the top to the list at `ref` refilled with `ids`,
// or to nil when there are none.
void SetList(void *L, const char *key, int &ref, const uint32_t *ids, int count) {
Game::Lua::PushString(L, key);
if (count == 0) {
Game::Lua::PushNil(L);
Game::Lua::SetTable(L, -3);
return;
}
PushTable(L, ref);
for (int i = 0; i < count; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(ids[i]));
Game::Lua::RawSet(L, -3);
}
// Clear what lies past the new end: a longer earlier list, or anything a
// handler appended.
for (int i = count + 1;; ++i) {
Game::Lua::RawGetI(L, -1, i);
const bool end = Game::Lua::Type(L, -1) == Game::Lua::TYPE_NIL;
Game::Lua::SetTop(L, -2);
if (end)
break;
Game::Lua::PushNumber(L, static_cast<double>(i));
Game::Lua::PushNil(L);
Game::Lua::RawSet(L, -3);
}
Game::Lua::SetN(L, -1, count);
Game::Lua::SetTable(L, -3);
}
void Fire(const Aura::Instance::Delta &d) {
char tokens[16][32];
const int n = Unit::Identity::TokensForGUID(d.guid, tokens, 16);
void *L = Game::Lua::State();
if (n == 0 || L == nullptr)
return;
const int top = Game::Lua::GetTop(L);
for (int i = 0; i < n; ++i) {
// Refilled for each token: a handler may have changed the tables.
Game::Lua::PushString(L, tokens[i]);
PushTable(L, g_infoRef);
Game::Lua::SetFieldBool(L, "isFullUpdate", d.fullUpdate);
SetList(L, "addedAuraInstanceIDs", g_addedRef, d.added, d.addedCount);
SetList(L, "updatedAuraInstanceIDs", g_updatedRef, d.updated, d.updatedCount);
SetList(L, "removedAuraInstanceIDs", g_removedRef, d.removed, d.removedCount);
Event::Custom::FireValues(_evt.Slot(), L, top + 1, 2);
Game::Lua::SetTop(L, top);
}
}
void OnWorldTick() {
const bool listening = Event::Custom::HasListeners(_evt.Slot());
Aura::Instance::SetRecording(listening);
if (listening)
Aura::Instance::Drain(&Fire);
}
const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
// Each registration pass is a new Lua state; the old state's refs are gone.
void ResetRefs() {
g_infoRef = g_addedRef = g_updatedRef = g_removedRef = kNoRef;
}
const Game::ModuleAutoRegister _autoreg{&ResetRefs};
} // namespace
} // namespace Aura::Delta
+317
View File
@@ -0,0 +1,317 @@
// 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.
// Aura instance IDs. See `Instance.h` for the contract.
#include "Instance.h"
#include "Game.h"
#include "Offsets.h"
#include "aura/Data.h"
#include "aura/Source.h"
#include "object/Resolve.h"
#include "tick/WorldTick.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <unordered_map>
#include <vector>
namespace Aura::Instance {
namespace {
constexpr int kSlots = Offsets::UNIT_AURA_TOTAL;
constexpr int kFlagBytes = kSlots / 2; // UNIT_FIELD_AURAFLAGS: a nibble per slot
// What was last seen of one unit's aura fields, the IDs given to its visible
// auras, and (while recording) its change set since the last Drain.
struct Tracked {
const uint8_t *object = nullptr;
uint32_t spell[kSlots] = {};
uint8_t flags[kFlagBytes] = {};
uint8_t stacks[kSlots] = {};
uint32_t id[kSlots] = {}; // 0 = no visible aura in the slot
bool full = false;
std::vector<uint32_t> added, updated, removed;
};
std::unordered_map<uint64_t, Tracked> g_units;
uint32_t g_nextID = 1;
bool g_recording = false;
const uint8_t *Descriptor(const uint8_t *unit) {
return Game::Read<const uint8_t *>(unit, Offsets::OFF_CGUNIT_OBJECT_FIELDS);
}
uint8_t Nibble(const uint8_t *flags, int slot) {
return (flags[slot / 2] >> ((slot & 1) * 4)) & 0xF;
}
bool Contains(const std::vector<uint32_t> &v, uint32_t id) {
return std::find(v.begin(), v.end(), id) != v.end();
}
bool Erase(std::vector<uint32_t> &v, uint32_t id) {
const auto it = std::find(v.begin(), v.end(), id);
if (it == v.end())
return false;
v.erase(it);
return true;
}
void ClearPending(Tracked &t) {
t.full = false;
t.added.clear();
t.updated.clear();
t.removed.clear();
}
// The change-set writes, coalesced within one Drain interval: an aura added and
// removed before the consumer heard of it is never reported, and an added or
// removed aura is not also reported as updated. A pending full update already
// covers everything.
void NoteAdded(Tracked &t, uint32_t id) {
if (g_recording && !t.full)
t.added.push_back(id);
}
void NoteChanged(Tracked &t, uint32_t id) {
if (g_recording && !t.full && !Contains(t.added, id) && !Contains(t.updated, id))
t.updated.push_back(id);
}
void NoteRemoved(Tracked &t, uint32_t id) {
if (!g_recording || t.full || Erase(t.added, id))
return;
Erase(t.updated, id);
t.removed.push_back(id);
}
// Records `unit`'s aura fields as they are now, with a fresh ID for every
// visible aura. `full` marks the change set as a full update: the consumer has
// never been told about these IDs.
void Seed(Tracked &t, const uint8_t *unit, bool full) {
const uint8_t *desc = Descriptor(unit);
t.object = unit;
ClearPending(t);
t.full = full && g_recording;
if (desc == nullptr) {
std::memset(t.spell, 0, sizeof t.spell);
std::memset(t.flags, 0, sizeof t.flags);
std::memset(t.stacks, 0, sizeof t.stacks);
std::memset(t.id, 0, sizeof t.id);
return;
}
std::memcpy(t.spell, desc + Offsets::OFF_UNIT_FIELD_AURA, sizeof t.spell);
std::memcpy(t.flags, desc + Offsets::OFF_UNIT_FIELD_AURAFLAGS, sizeof t.flags);
std::memcpy(t.stacks, desc + Offsets::OFF_UNIT_FIELD_AURAAPPLICATIONS, sizeof t.stacks);
for (int slot = 0; slot < kSlots; ++slot)
t.id[slot] = Data::IsSlotPopulated(unit, slot) ? g_nextID++ : 0;
}
// Brings one slot's record in line with the live descriptor. A new spell, or a
// visibility change, ends the old instance and starts a new one; the same
// visible aura with new flags or stacks is the same instance, updated.
void SyncSlot(Tracked &t, const uint8_t *unit, const uint8_t *desc, int slot) {
const uint32_t spell = Game::Read<uint32_t>(desc, Offsets::OFF_UNIT_FIELD_AURA + slot * 4);
const uint8_t *flags = desc + Offsets::OFF_UNIT_FIELD_AURAFLAGS;
const uint8_t nibble = Nibble(flags, slot);
const uint8_t stacks = desc[Offsets::OFF_UNIT_FIELD_AURAAPPLICATIONS + slot];
if (spell == t.spell[slot] && nibble == Nibble(t.flags, slot)) {
if (stacks != t.stacks[slot]) {
t.stacks[slot] = stacks;
if (t.id[slot] != 0)
NoteChanged(t, t.id[slot]);
}
return;
}
const bool visible = Data::IsSlotPopulated(unit, slot);
if (visible && spell == t.spell[slot] && t.id[slot] != 0) {
NoteChanged(t, t.id[slot]);
} else {
if (t.id[slot] != 0)
NoteRemoved(t, t.id[slot]);
t.id[slot] = visible ? g_nextID++ : 0;
if (t.id[slot] != 0)
NoteAdded(t, t.id[slot]);
}
t.spell[slot] = spell;
// Only this slot's nibble: the byte's other half is the neighbour's, which
// its own sync records.
const uint8_t mask = static_cast<uint8_t>(0xF << ((slot & 1) * 4));
t.flags[slot / 2] = static_cast<uint8_t>((t.flags[slot / 2] & ~mask) | (flags[slot / 2] & mask));
t.stacks[slot] = stacks;
}
void SyncAll(Tracked &t, const uint8_t *unit) {
const uint8_t *desc = Descriptor(unit);
if (desc == nullptr)
return;
// The common case is no change at all: three compares, no per-slot work.
if (std::memcmp(t.spell, desc + Offsets::OFF_UNIT_FIELD_AURA, sizeof t.spell) == 0 &&
std::memcmp(t.flags, desc + Offsets::OFF_UNIT_FIELD_AURAFLAGS, sizeof t.flags) == 0 &&
std::memcmp(t.stacks, desc + Offsets::OFF_UNIT_FIELD_AURAAPPLICATIONS,
sizeof t.stacks) == 0)
return;
for (int slot = 0; slot < kSlots; ++slot)
SyncSlot(t, unit, desc, slot);
}
// The record for `unit`, created (seeded from the live fields) on first sight
// and re-seeded when its GUID now names a different object — the old one left
// view and a new one was built, so nothing recorded of it still holds. A
// rebuilt unit is always a full update (the consumer holds its old IDs). For a
// first sight, `full` says whether it is news to the consumer: true from the
// change handlers (an aura changed on a unit the consumer has no IDs for),
// false from the getters (the caller is reading the auras right now).
Tracked *Track(const uint8_t *unit, bool full, bool *seeded) {
*seeded = false;
const uint64_t guid = Unit::Identity::GuidForObject(unit);
if (guid == 0 || Descriptor(unit) == nullptr)
return nullptr;
auto [it, inserted] = g_units.try_emplace(guid);
Tracked &t = it->second;
if (inserted || t.object != unit) {
Seed(t, unit, inserted ? full : true);
*seeded = true;
}
return &t;
}
// The engine's aura handlers name the slot that changed; syncing that slot is
// what keeps a spell removed and applied again in one slot two instances (the
// two arrive in separate update blocks, so each reaches its own sync).
void OnAuraChange(const Aura::Source::Change &c) {
if (c.unit == nullptr || c.slot < 0 || c.slot >= kSlots)
return;
const auto *unit = static_cast<const uint8_t *>(c.unit);
bool seeded;
Tracked *t = Track(unit, /*full*/ true, &seeded);
if (t != nullptr && !seeded)
SyncSlot(*t, unit, Descriptor(unit), c.slot);
}
const Aura::Source::AutoObserve _obs{&OnAuraChange};
// Units that left view are forgotten; one coming back is seeded afresh with new
// IDs. While recording, `Drain` does this every frame; otherwise this sweep
// keeps the table to the units in view.
constexpr uint32_t kSweepIntervalMs = 1000;
uint32_t g_lastSweepMs = 0;
void OnWorldTick() {
const uint32_t now = Time::Clock::NowMs();
if (Time::Clock::Elapsed(g_lastSweepMs, now) < kSweepIntervalMs)
return;
g_lastSweepMs = now;
for (auto it = g_units.begin(); it != g_units.end();) {
if (Object::ByGuid(Offsets::TYPEMASK_UNIT, it->first) == nullptr)
it = g_units.erase(it);
else
++it;
}
}
const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
// A delta copied out of its record, so `Drain`'s callback can run Lua that
// grows `g_units` without invalidating it.
struct OwnedDelta {
uint64_t guid;
bool full;
std::vector<uint32_t> added, updated, removed;
};
} // namespace
uint32_t IDForSlot(const uint8_t *unit, int slot) {
if (unit == nullptr || slot < 0 || slot >= kSlots)
return 0;
bool seeded;
Tracked *t = Track(unit, /*full*/ false, &seeded);
if (t == nullptr)
return 0;
if (!seeded)
SyncSlot(*t, unit, Descriptor(unit), slot);
return t->id[slot];
}
int SlotForID(const uint8_t *unit, uint32_t id) {
if (unit == nullptr || id == 0)
return -1;
bool seeded;
Tracked *t = Track(unit, /*full*/ false, &seeded);
if (t == nullptr || seeded) // a fresh seed hands out new IDs only
return -1;
for (int slot = 0; slot < kSlots; ++slot) {
if (t->id[slot] != id)
continue;
SyncSlot(*t, unit, Descriptor(unit), slot);
return t->id[slot] == id ? slot : -1;
}
return -1;
}
void NoteUpdated(uint64_t guid, int slot) {
if (slot < 0 || slot >= kSlots)
return;
const auto it = g_units.find(guid);
if (it != g_units.end() && it->second.id[slot] != 0)
NoteChanged(it->second, it->second.id[slot]);
}
void SetRecording(bool on) {
if (on == g_recording)
return;
g_recording = on;
if (!on)
for (auto &[guid, t] : g_units)
ClearPending(t);
}
void Drain(void (*fn)(const Delta &delta)) {
std::vector<OwnedDelta> out;
for (auto it = g_units.begin(); it != g_units.end();) {
Tracked &t = it->second;
const auto *unit = static_cast<const uint8_t *>(
Object::ByGuid(Offsets::TYPEMASK_UNIT, it->first));
if (unit == nullptr) {
it = g_units.erase(it);
continue;
}
if (unit != t.object)
Seed(t, unit, /*full*/ true);
else
SyncAll(t, unit);
if (t.full || !t.added.empty() || !t.updated.empty() || !t.removed.empty()) {
out.push_back({it->first, t.full, std::move(t.added), std::move(t.updated),
std::move(t.removed)});
ClearPending(t);
}
++it;
}
for (const OwnedDelta &d : out) {
const Delta view{d.guid,
d.full,
d.added.data(),
static_cast<int>(d.added.size()),
d.updated.data(),
static_cast<int>(d.updated.size()),
d.removed.data(),
static_cast<int>(d.removed.size())};
fn(view);
}
}
} // namespace Aura::Instance
+75
View File
@@ -0,0 +1,75 @@
// 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.
#pragma once
#include <cstdint>
// Aura instance IDs — retail's `auraInstanceID`. 1.12 has no aura identity:
// a unit's descriptor holds 48 spell IDs and nothing that tells one application
// of a spell from the next. This module gives each aura a number when it lands
// in a descriptor slot and keeps it until the aura leaves, so the same aura
// reads the same ID from every getter, and an aura removed and applied again
// gets a new one. IDs are never reused.
//
// Only the visible auras of a unit in view get an ID: the slots
// `Aura::Data::IsSlotPopulated` accepts (the UnitBuff gate). An aura only the
// `Aura::Source` cache or the out-of-range group array knows has none.
//
// Every change goes through one step: compare what was recorded for a slot
// with the live descriptor. The engine's aura handlers (`Aura::Source`'s
// Added / Removed / Stacks changes) run it for the slot they name, which is
// what tells a removal and re-application of one spell apart; the getters run
// it before answering; and `Drain` runs it over every tracked unit, which
// catches what no handler reports (a bulk clear, an object rebuilt).
//
// While recording is on, each unit also collects the change set since the last
// `Drain` — what `UNIT_AURA_DELTA` reports.
namespace Aura::Instance {
// The instance ID of the aura in absolute descriptor `slot` on `unit`, or 0
// when the slot holds no visible aura.
uint32_t IDForSlot(const uint8_t *unit, int slot);
// The absolute descriptor slot holding instance `id` on `unit`, or -1 when no
// aura on the unit has that ID.
int SlotForID(const uint8_t *unit, uint32_t id);
// Records the aura in `slot` on `guid` as updated in place — a timing change
// with no descriptor change behind it (a refresh, a duration packet). No-op
// for a slot with no ID or a unit nobody has asked about.
void NoteUpdated(uint64_t guid, int slot);
// One unit's changes since the last `Drain`. With `fullUpdate` set the lists
// are empty and the consumer re-reads every aura (the unit was first seen, or
// its object was rebuilt).
struct Delta {
uint64_t guid;
bool fullUpdate;
const uint32_t *added;
int addedCount;
const uint32_t *updated;
int updatedCount;
const uint32_t *removed;
int removedCount;
};
// Turns change collection on or off. Off, no change set is kept, and turning
// it off discards what was collected. IDs are assigned either way.
void SetRecording(bool on);
// Reconciles every tracked unit with its descriptor, then hands each unit with
// changes to `fn` and clears them. The deltas are copied out first, so `fn`
// may run Lua that calls the getters; changes made meanwhile wait for the next
// `Drain`.
void Drain(void (*fn)(const Delta &delta));
} // namespace Aura::Instance
+93 -20
View File
@@ -18,6 +18,7 @@
#include "ComboDuration.h"
#include "Data.h"
#include "Game.h"
#include "Instance.h"
#include "Offsets.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
@@ -361,6 +362,11 @@ Entry *Claim(uint32_t now) {
// Defined in the duration-modifier section below; used by StoreFromCast's
// refresh path too.
void SignalAuraChanged(uint64_t guid);
void SignalEntryChanged(const Entry &e);
void SignalEntryRefreshed(const Entry &e);
// Defined with the aura handlers below; used by the refresh flush.
Change MakeChange(ChangeType type, void *unit, int slot, uint32_t spellId);
void NotifyObservers(const Change &change);
// Defined with the duration-modifier machinery below. A trigger can CREATE the
// aura it then edits, in which case the edit is armed at trigger time and
@@ -405,9 +411,10 @@ void StoreFromCast(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
// An in-place recast refresh changes no descriptor field, so the engine
// fires no UNIT_AURA — event-driven consumers (aura-bar addons counting
// down a once-read duration) would never re-read the new timing. Signal it
// ourselves (deferred to the world tick, coalesced). A NEW application
// doesn't need this: its descriptor write fires the engine's own event.
SignalAuraChanged(targetGuid);
// ourselves (deferred to the world tick, coalesced), and report the
// refresh. A NEW application doesn't need this: its descriptor write
// fires the engine's own event.
SignalEntryRefreshed(*e);
ConsumePendingMods(*e, now);
}
@@ -639,7 +646,65 @@ void SignalAuraChanged(uint64_t guid) {
g_pendingSignals[g_pendingSignalCount++] = guid;
}
// An entry's timing changed in place: the unit's UNIT_AURA re-broadcast, and
// the instance in the entry's slot reported as updated to UNIT_AURA_DELTA.
void SignalEntryChanged(const Entry &e) {
Aura::Instance::NoteUpdated(e.targetGuid, e.slot);
SignalAuraChanged(e.targetGuid);
}
// The refreshes of this frame (SignalRefreshed), resolved by the flush.
struct PendingRefresh {
uint64_t guid;
uint32_t spellId;
int slot;
};
constexpr int kPendingRefreshMax = 32;
PendingRefresh g_pendingRefreshes[kPendingRefreshMax];
int g_pendingRefreshCount = 0;
// A server refresh the entry mirrors — a recast, a mechanic's reset to a full
// duration: the timing signal, and a Refreshed change for the observers. Not
// for the tick-speed compression's periodic nudge (this client's estimate
// moving, no server event) nor a reduction.
void SignalEntryRefreshed(const Entry &e) {
SignalEntryChanged(e);
SignalRefreshed(e.targetGuid, e.spellId, e.slot);
}
// The slot of `unit`'s descriptor holding `spellId`, or -1.
int DescriptorSlotOf(const uint8_t *unit, uint32_t spellId) {
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
if (Aura::Data::ReadSpellID(unit, slot) == spellId)
return slot;
}
return -1;
}
// Reports the frame's refreshes whose aura the descriptor still holds, in the
// slot that was named when it still holds the spell, else wherever it is now.
void FlushRefreshes() {
constexpr int kUnitOrPlayerMask =
(1 << Offsets::OBJECT_TYPE_UNIT) | (1 << Offsets::OBJECT_TYPE_PLAYER);
const int count = g_pendingRefreshCount;
g_pendingRefreshCount = 0; // an observer's own action may queue again
for (int i = 0; i < count; ++i) {
const PendingRefresh &p = g_pendingRefreshes[i];
void *unit = Object::ByGuid(kUnitOrPlayerMask, p.guid, nullptr, 0);
if (unit == nullptr)
continue;
const auto *u = static_cast<const uint8_t *>(unit);
int slot = p.slot;
if (slot < 0 || Aura::Data::ReadSpellID(u, slot) != p.spellId)
slot = DescriptorSlotOf(u, p.spellId);
if (slot < 0)
continue;
NotifyObservers(MakeChange(ChangeType::Refreshed, unit, slot, p.spellId));
}
}
void FlushAuraSignals() {
FlushRefreshes();
// The engine broadcast plus ClassicAPI's tokens (see Unit::TokenEvents).
for (int i = 0; i < g_pendingSignalCount; ++i)
Unit::TokenEvents::Broadcast(&g_pendingSignals[i], Offsets::UNIT_EVENT_UNIT_AURA);
@@ -651,20 +716,20 @@ void ApplyModOp(Entry &e, int32_t op, int32_t valueMs, uint32_t now) {
case MOD_REFRESH:
if (e.durationMs > 0) {
e.expirationMs = now + e.durationMs; // RefreshHolder → reset to max
SignalAuraChanged(e.targetGuid);
SignalEntryRefreshed(e);
}
break;
case MOD_SET:
e.durationMs = static_cast<uint32_t>(valueMs);
e.expirationMs = now + static_cast<uint32_t>(valueMs);
SignalAuraChanged(e.targetGuid);
SignalEntryRefreshed(e);
break;
case MOD_REDUCE:
if (e.expirationMs != 0) {
if (Time::Clock::Remaining(now, e.expirationMs) >
static_cast<uint32_t>(valueMs)) {
e.expirationMs -= static_cast<uint32_t>(valueMs);
SignalAuraChanged(e.targetGuid);
SignalEntryChanged(e);
} else {
e.used = false; // shaved to/past now → server removes it
}
@@ -958,16 +1023,9 @@ CompressState *FindOrClaimCompressState(uint64_t targetGuid,
// would compress it, this gate excludes it exactly as the server's does.
// (Drain Soul passes via its effect-2 periodic damage: aura=[86,3,0].)
bool HasPeriodicDamageOrLeech(const uint8_t *rec) {
if (rec == nullptr)
return false;
constexpr int32_t kPeriodicDamage = 3, kPeriodicLeech = 53;
for (int i = 0; i < 3; ++i) {
const int32_t aura = *reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME + i * 4);
if (aura == kPeriodicDamage || aura == kPeriodicLeech)
return true;
}
return false;
return Spell::Lookup::HasAuraEffect(rec, kPeriodicDamage) ||
Spell::Lookup::HasAuraEffect(rec, kPeriodicLeech);
}
// Compression pairs by CASTER, mirroring the server (each warlock's Dark
@@ -1011,7 +1069,7 @@ void CompressTarget(const TickCompression &c, uint64_t targetGuid,
// pollers pick it up on their own. First write nudges immediately.
if (s->lastSignalMs == 0 || now - s->lastSignalMs >= 1000) {
s->lastSignalMs = now;
SignalAuraChanged(e.targetGuid);
SignalEntryChanged(e);
}
}
}
@@ -1227,7 +1285,7 @@ int RefreshJudgementsImpl(uint64_t unitGuid, uint64_t attackerGuid, bool adopt)
e.casterGuid = attackerGuid; // adoption claim
e.expirationMs = now + e.durationMs; // RefreshHolder → own duration
e.stampMs = now;
SignalAuraChanged(e.targetGuid);
SignalEntryRefreshed(e);
++refreshed;
}
return refreshed;
@@ -1320,7 +1378,7 @@ void RefreshTriggeredExisting(uint64_t victim, const TriggeredApplication &r,
e.durationMs = newMs;
e.expirationMs = now + newMs;
e.stampMs = now;
SignalAuraChanged(victim);
SignalEntryRefreshed(e);
break; // one aura instance per (target, spell, caster) — server touches one holder
}
}
@@ -1962,7 +2020,7 @@ uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
// duration, not a five-point one.
e.expirationMs = now + e.durationMs;
e.stampMs = now;
SignalAuraChanged(e.targetGuid);
SignalEntryRefreshed(e);
return e.spellId;
}
return 0;
@@ -1983,7 +2041,7 @@ void RestampPlayerChannel(uint32_t spellId, uint32_t remainingMs) {
continue;
e.expirationMs = now + remainingMs;
e.stampMs = now;
SignalAuraChanged(e.targetGuid);
SignalEntryChanged(e);
}
}
@@ -2098,4 +2156,19 @@ AutoObserve::AutoObserve(ChangeCallback cb) : callback(cb), next(g_observers) {
g_observers = this;
}
void SignalRefreshed(uint64_t unitGuid, uint32_t spellId, int slot) {
if (unitGuid == 0 || spellId == 0)
return;
for (int i = 0; i < g_pendingRefreshCount; ++i) {
PendingRefresh &p = g_pendingRefreshes[i];
if (p.guid == unitGuid && p.spellId == spellId) {
if (p.slot < 0)
p.slot = slot;
return;
}
}
if (g_pendingRefreshCount < kPendingRefreshMax)
g_pendingRefreshes[g_pendingRefreshCount++] = {unitGuid, spellId, slot};
}
} // namespace Aura::Source
+11 -2
View File
@@ -239,8 +239,9 @@ void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray);
// An aura arriving on a unit, leaving it, or changing its stack count, as the
// engine's own handlers see it (`OnAuraAdded`, `OnAuraRemoved`,
// `OnAuraStacksChanged`), with the caster this cache binds to it.
enum class ChangeType { Added, Removed, Stacks };
// `OnAuraStacksChanged`), with the caster this cache binds to it — or its
// duration set again while it stays (Refreshed, see `SignalRefreshed`).
enum class ChangeType { Added, Removed, Stacks, Refreshed };
struct Change {
ChangeType type;
@@ -264,4 +265,12 @@ struct AutoObserve {
const AutoObserve *next;
};
// A duration set again for an aura already on `unitGuid`: the server's
// SMSG_UPDATE_AURA_DURATION for an occupied slot of the player, a recast this
// cache refreshed, a server refresh mechanic it mirrors. Observers get it as a
// Refreshed change at the world tick, once per aura per frame, and only if the
// descriptor then still holds the spell — a slot that changed hands in the
// same tick was an application, not a refresh. `slot` may be SLOT_UNBOUND.
void SignalRefreshed(uint64_t unitGuid, uint32_t spellId, int slot);
} // namespace Aura::Source
+468
View File
@@ -0,0 +1,468 @@
// 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/>.
// C_AutoComplete: the player names an edit box can complete.
//
// 4.3.4's CGAutoCompleteName keeps its own list of names, each with flag bits
// that the party, raid, guild, friend list and chat code add and remove as
// those lists change. 1.12 has no such list, but it keeps every list the bits
// come from, so a query reads them where the engine keeps them:
// - party: the four VAR_PARTY_GUIDS, the name from the name cache, online
// from the member stats block (Group::MemberStats) - InGroup;
// - raid: the VAR_RAID_MEMBER_PTRS slots and their online bit - InGroup;
// - guild roster: each GuildMemberInfo's GUID, name and online word, read
// only while the player is in a guild, the test Script_IsInGuild
// (0x00516DE0) makes, because the engine keeps the old roster after a
// /gquit - InGuild;
// - friends list: the social singleton's slots (FriendList.h) - Friend.
// The one list the engine does not keep is ours: the players this character
// whispered or was whispered by (Chat::Dispatch reports each line), in the
// order they were last heard from - InteractedWith. Logging out clears it,
// as 4.3.4's ShutdownGame clears its list.
//
// A player on several lists is one entry with every list's bit. A list that
// holds the player decides Online; a player known only from a whisper is
// Online, as 4.3.4 sets Online together with InteractedWith.
//
// Matching is 4.3.4's NodeMatchesParams (the same in 3.3.5's binary,
// 0x0057ACA0), with two differences:
// - a cursorPosition outside 1..length means the end of the text. Retail's
// tell-target extraction (ChatFrameEditBox.lua) passes 0 and expects a
// whole-text match;
// - the edit-from-center search also tries the last place. The engine's
// loop stops one place early, so it never finds the text after the
// cursor at the end of a name.
// Order is 3.3.5's SortNameNode (0x0057B130): with
// autoCompleteResortNamesOnRecency on, the most recent whisper first, then by
// name. Retail's client cannot be read, so each result's priority is the
// highest Enum.AutoCompletePriority its bits reach, in the enum's own order:
// Friend, Guild, InGroup, Interacted, else Other.
#include "autocomplete/Names.h"
#include "Game.h"
#include "Offsets.h"
#include "cvar/Factory.h"
#include "friendlist/FriendList.h"
#include "group/MemberStats.h"
#include "guid/Guid.h"
#include "text/Utf8.h"
#include "unit/Identity.h"
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
namespace AutoComplete::Names {
namespace {
// Enum.AutoCompleteEntryFlag. 1.12 has nothing to fill Bnet, InAOI,
// AccountCharacter or RecentPlayer from.
enum EntryFlag : uint32_t {
FLAG_IN_GROUP = 0x1,
FLAG_IN_GUILD = 0x2,
FLAG_FRIEND = 0x4,
FLAG_BNET = 0x8,
FLAG_INTERACTED_WITH = 0x10,
FLAG_ONLINE = 0x20,
FLAG_IN_AOI = 0x40,
FLAG_ACCOUNT_CHARACTER = 0x80,
FLAG_RECENT_PLAYER = 0x100,
};
// Enum.AutoCompletePriority.
enum Priority {
PRIORITY_OTHER = 0,
PRIORITY_INTERACTED = 1,
PRIORITY_IN_GROUP = 2,
PRIORITY_GUILD = 3,
PRIORITY_FRIEND = 4,
PRIORITY_ACCOUNT_CHARACTER = 5,
PRIORITY_ACCOUNT_CHARACTER_SAME_REALM = 6,
};
constexpr int CHAT_MSG_WHISPER = 6;
constexpr int CHAT_MSG_WHISPER_INFORM = 7;
constexpr int LANGUAGE_ADDON = -1; // the dispatch's addon-message arm
CVar::Factory::Handle g_resortOnRecency = nullptr;
CVar::Factory::Handle g_editFromCenter = nullptr;
CVar::Factory::Handle g_useContext = nullptr;
bool CVarOn(CVar::Factory::Handle cvar) { return CVar::Factory::GetInt(cvar, 1) != 0; }
// A whisper partner. `order` grows with every whisper, so a larger one was
// heard from more recently.
struct Interaction {
uint64_t guid;
std::string name;
uint32_t order;
};
std::vector<Interaction> g_interactions;
uint32_t g_lastOrder = 0;
// One player as one list knows them, or, after Merge, as all lists do.
struct Entry {
uint64_t guid;
const char *name; // the list's own storage, valid until Lua runs
uint32_t flags; // EntryFlag bits, Online excluded
bool listed; // a group, guild or friends list holds the player
bool online; // what those lists say
uint32_t order; // the Interaction's order, 0 for none
};
bool InGuild() {
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return false;
const auto *info = Game::Read<const uint8_t *>(player, Offsets::OFF_CGPLAYER_INFO);
return info != nullptr && Game::Read<uint32_t>(info, Offsets::OFF_PLAYER_INFO_GUILD_KEY) != 0;
}
// Every player whose name `match` accepts, one entry per list that has them.
template <typename Match>
std::vector<Entry> Collect(Match match) {
std::vector<Entry> found;
const uint64_t self = Unit::Identity::PlayerGuid();
const auto add = [&](uint64_t guid, const char *name, uint32_t flag, bool listed, bool online,
uint32_t order) {
if (guid == 0 || guid == self || name == nullptr || *name == '\0' || !match(name))
return;
found.push_back({guid, name, flag, listed, online, order});
};
const auto *party =
reinterpret_cast<const uint64_t *>(static_cast<uintptr_t>(Offsets::VAR_PARTY_GUIDS));
for (int i = 0; i < Offsets::PARTY_MAX_SLOTS; ++i) {
const uint64_t guid = party[i];
if (guid != 0)
add(guid, Unit::Identity::NameForGuid(guid), FLAG_IN_GROUP, true,
Group::MemberStats::Lookup(guid).online, 0);
}
int raidCount = Game::Read<int>(Offsets::VAR_RAID_MEMBER_COUNT);
if (raidCount > Offsets::RAID_MAX_SLOTS)
raidCount = Offsets::RAID_MAX_SLOTS;
const auto *slots = reinterpret_cast<const uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_RAID_MEMBER_PTRS));
for (int i = 0; i < raidCount; ++i) {
if (slots[i] == nullptr)
continue;
const uint64_t guid = Game::Read<uint64_t>(slots[i], 0);
const uint32_t flags = Game::Read<uint32_t>(slots[i], Offsets::OFF_RAID_SLOT_FLAGS);
add(guid, Unit::Identity::NameForGuid(guid), FLAG_IN_GROUP, true,
(flags & Offsets::RAID_SLOT_FLAG_ONLINE) != 0, 0);
}
if (InGuild()) {
auto **roster = Game::Read<const uint8_t **>(Offsets::VAR_GUILD_ROSTER_PTR);
const int total = Game::Read<int>(Offsets::VAR_GUILD_ROSTER_TOTAL_COUNT);
for (int i = 0; roster != nullptr && i < total; ++i) {
const uint8_t *member = roster[i];
if (member == nullptr)
continue;
add(Game::Read<uint64_t>(member, Offsets::OFF_GUILD_MEMBER_GUID),
reinterpret_cast<const char *>(member + Offsets::OFF_GUILD_MEMBER_NAME),
FLAG_IN_GUILD, true,
Game::Read<uint32_t>(member, Offsets::OFF_GUILD_MEMBER_ONLINE) != 0, 0);
}
}
const int friends = FriendList::Count();
for (int i = 0; i < friends; ++i) {
const uint8_t *entry = FriendList::EntryByIndex(i);
if (entry == nullptr)
break;
add(Game::Read<uint64_t>(entry, FriendList::OFF_FRIEND_GUID),
Game::Read<const char *>(entry, FriendList::OFF_FRIEND_NAME), FLAG_FRIEND, true,
Game::Read<uint8_t>(entry, FriendList::OFF_FRIEND_CONNECTED) != 0, 0);
}
for (const Interaction &i : g_interactions)
add(i.guid, i.name.c_str(), FLAG_INTERACTED_WITH, false, false, i.order);
return found;
}
// One entry per player, with every list's bits and Online decided; then the
// include / exclude test, which autoCompleteUseContext turns off.
std::vector<Entry> Merge(std::vector<Entry> found, uint32_t include, uint32_t exclude) {
std::sort(found.begin(), found.end(),
[](const Entry &a, const Entry &b) { return a.guid < b.guid; });
std::vector<Entry> merged;
for (const Entry &e : found) {
if (!merged.empty() && merged.back().guid == e.guid) {
Entry &m = merged.back();
m.flags |= e.flags;
m.listed = m.listed || e.listed;
m.online = m.online || e.online;
m.order = std::max(m.order, e.order);
} else {
merged.push_back(e);
}
}
const bool useContext = CVarOn(g_useContext);
std::vector<Entry> kept;
for (Entry &e : merged) {
if (e.listed ? e.online : (e.flags & FLAG_INTERACTED_WITH) != 0)
e.flags |= FLAG_ONLINE;
if (!useContext || ((e.flags & include) != 0 && (e.flags & exclude) == 0))
kept.push_back(e);
}
return kept;
}
int PriorityOf(uint32_t flags) {
if (flags & FLAG_FRIEND)
return PRIORITY_FRIEND;
if (flags & FLAG_IN_GUILD)
return PRIORITY_GUILD;
if (flags & FLAG_IN_GROUP)
return PRIORITY_IN_GROUP;
if (flags & FLAG_INTERACTED_WITH)
return PRIORITY_INTERACTED;
return PRIORITY_OTHER;
}
// Past `chars` code points of `s`, continuation bytes included, stopping at
// the terminator. (Storm's SStrAdvanceCharsUTF8 stops one byte into a
// multi-byte character.)
const char *Advance(const char *s, size_t chars) {
for (; *s != '\0' && chars != 0; --chars) {
++s;
while ((static_cast<uint8_t>(*s) & 0xC0) == 0x80)
++s;
}
return s;
}
// What the edit box holds: the text and the cursor in it, in code points.
struct Typed {
const char *text;
size_t length;
size_t cursor;
bool allowFullMatch;
bool editFromCenter;
};
bool Completes(const Typed &t, const char *name) {
if (!t.allowFullMatch && Text::Utf8::CompareI(t.text, name) == 0)
return false;
if (!t.editFromCenter || t.cursor == t.length)
return Text::Utf8::CompareI(t.text, name, t.length) == 0;
// The text before the cursor starts the name, and the text after it
// follows somewhere later in the name.
if (Text::Utf8::CompareI(t.text, name, t.cursor) != 0)
return false;
const size_t nameLength = Text::Utf8::Length(name);
if (nameLength < t.length)
return false;
const char *tail = Advance(t.text, t.cursor);
const size_t tailLength = t.length - t.cursor;
const char *at = Advance(name, t.cursor);
for (size_t places = nameLength - t.length + 1; places != 0; --places) {
if (Text::Utf8::CompareI(at, tail, tailLength) == 0)
return true;
at = Advance(at, 1);
}
return false;
}
// A flag mask from Lua: bit.bor results above 0x7FFFFFFF arrive negative.
uint32_t FlagArg(void *L, int index) {
return static_cast<uint32_t>(static_cast<int64_t>(Game::Lua::ToNumber(L, index)));
}
// C_AutoComplete.GetAutoCompleteResults(name, numResults, cursorPosition,
// allowFullMatch, includeFlags, excludeFlags) -> results
// A numResults of 0 or less returns every match, as the engine's count test
// (`++count == numReturns`) does.
int __fastcall Script_GetAutoCompleteResults(void *L) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3) ||
!Game::Lua::IsNumber(L, 5) || !Game::Lua::IsNumber(L, 6)) {
Game::Lua::Error(L, "Usage: C_AutoComplete.GetAutoCompleteResults(name, numResults, "
"cursorPosition, allowFullMatch, includeFlags, excludeFlags)");
return 0;
}
Typed typed;
typed.text = Game::Lua::ToString(L, 1);
typed.length = Text::Utf8::Length(typed.text);
const double cursor = Game::Lua::ToNumber(L, 3);
typed.cursor = cursor >= 1 && cursor <= static_cast<double>(typed.length)
? static_cast<size_t>(cursor)
: typed.length;
typed.allowFullMatch = Game::Lua::ToBoolean(L, 4) != 0;
typed.editFromCenter = CVarOn(g_editFromCenter);
const int numResults = static_cast<int>(Game::Lua::ToNumber(L, 2));
std::vector<Entry> matches =
Merge(Collect([&](const char *name) { return Completes(typed, name); }), FlagArg(L, 5),
FlagArg(L, 6));
const bool recency = CVarOn(g_resortOnRecency);
std::sort(matches.begin(), matches.end(), [recency](const Entry &a, const Entry &b) {
if (recency && a.order != b.order)
return a.order > b.order;
return Text::Utf8::CompareI(a.name, b.name) < 0;
});
if (numResults > 0 && matches.size() > static_cast<size_t>(numResults))
matches.resize(numResults);
Game::Lua::NewTable(L);
for (size_t i = 0; i < matches.size(); ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", matches[i].name);
Game::Lua::SetFieldNumber(L, "priority", PriorityOf(matches[i].flags));
Game::Lua::SetFieldNumber(L, "bnetID", 0);
Game::Lua::RawSet(L, -3);
}
return 1;
}
// C_AutoComplete.IsRecognizedName(name, includeFlags, excludeFlags) -> bool
int __fastcall Script_IsRecognizedName(void *L) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L,
"Usage: C_AutoComplete.IsRecognizedName(name, includeFlags, excludeFlags)");
return 0;
}
const char *text = Game::Lua::ToString(L, 1);
const std::vector<Entry> matches =
Merge(Collect([text](const char *name) { return Text::Utf8::CompareI(text, name) == 0; }),
FlagArg(L, 2), FlagArg(L, 3));
Game::Lua::PushBool(L, !matches.empty());
return 1;
}
void ClearOnLogout() {
g_interactions.clear();
g_lastOrder = 0;
}
const Game::GlueModuleAutoRegister _clearOnLogout{&ClearOnLogout};
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kResultFields[] = {
Game::Doc::Req("name", "string"),
Game::Doc::Req("priority", "AutoCompletePriority",
"The closest tie to the player: friend, guild, group, whisper, or none."),
Game::Doc::Req("bnetID", "number", "Always 0."),
};
const Game::Doc::Structure kResult{"AutoCompleteResult", "AutoComplete", kResultFields};
const Game::Doc::Field kResultsArgs[] = {
Game::Doc::Req("name", "string", "The text typed so far."),
Game::Doc::Req("numResults", "number", "The most results to return; 0 returns all."),
Game::Doc::Req("cursorPosition", "number", "The cursor's place in the text, in characters."),
Game::Doc::Req("allowFullMatch", "bool", "Whether a name equal to the text can be a result."),
Game::Doc::Req("includeFlags", "number",
"Enum.AutoCompleteEntryFlag bits; a result has at least one."),
Game::Doc::Req("excludeFlags", "number",
"Enum.AutoCompleteEntryFlag bits; a result has none."),
};
const Game::Doc::Field kResultsRets[] = {
Game::Doc::Req("results", "table", "Array of AutoCompleteResult."),
};
const Game::Doc::Function kGetAutoCompleteResults{
"The known players whose names complete the typed text.", kResultsArgs, kResultsRets};
const Game::Doc::Field kRecognizedArgs[] = {
Game::Doc::Req("name", "string"),
Game::Doc::Req("includeFlags", "number",
"Enum.AutoCompleteEntryFlag bits; the player has at least one."),
Game::Doc::Req("excludeFlags", "number",
"Enum.AutoCompleteEntryFlag bits; the player has none."),
};
const Game::Doc::Field kRecognizedRets[] = {
Game::Doc::Req("isRecognizedName", "bool"),
};
const Game::Doc::Function kIsRecognizedName{"Whether a known player has exactly this name.",
kRecognizedArgs, kRecognizedRets};
const Game::Lua::EnumIntegerEntry kEntryFlagEntries[] = {
{"InGroup", FLAG_IN_GROUP},
{"InGuild", FLAG_IN_GUILD},
{"Friend", FLAG_FRIEND},
{"Bnet", FLAG_BNET},
{"InteractedWith", FLAG_INTERACTED_WITH},
{"Online", FLAG_ONLINE},
{"InAOI", FLAG_IN_AOI},
{"AccountCharacter", FLAG_ACCOUNT_CHARACTER},
{"RecentPlayer", FLAG_RECENT_PLAYER},
};
const Game::Lua::EnumIntegerEntry kPriorityEntries[] = {
{"Other", PRIORITY_OTHER},
{"Interacted", PRIORITY_INTERACTED},
{"InGroup", PRIORITY_IN_GROUP},
{"Guild", PRIORITY_GUILD},
{"Friend", PRIORITY_FRIEND},
{"AccountCharacter", PRIORITY_ACCOUNT_CHARACTER},
{"AccountCharacterSameRealm", PRIORITY_ACCOUNT_CHARACTER_SAME_REALM},
};
} // namespace
void OnChat(int chatType, int languageID, const char *name, uint64_t guid) {
if (chatType != CHAT_MSG_WHISPER && chatType != CHAT_MSG_WHISPER_INFORM)
return;
if (languageID == LANGUAGE_ADDON || guid == 0 || !Guid::IsPlayer(guid) || name == nullptr ||
*name == '\0')
return;
++g_lastOrder;
for (Interaction &i : g_interactions) {
if (i.guid == guid) {
i.name = name;
i.order = g_lastOrder;
return;
}
}
g_interactions.push_back({guid, name, g_lastOrder});
}
static void RegisterLuaFunctions() {
// Retail's help lines and category (Ketho's CVars.lua dump).
g_resortOnRecency = CVar::Factory::Register(
"autoCompleteResortNamesOnRecency",
"Shows people you recently spoke with higher up on the AutoComplete list.",
Offsets::CVAR_CATEGORY_GAME, "1");
g_editFromCenter = CVar::Factory::Register(
"autoCompleteWhenEditingFromCenter",
"If you edit a name by inserting characters into the center, a smarter auto-complete "
"will occur.",
Offsets::CVAR_CATEGORY_GAME, "1");
g_useContext = CVar::Factory::Register(
"autoCompleteUseContext",
"The system will, for example, only show people in your guild when you are typing "
"/gpromote. Names will also never be removed.",
Offsets::CVAR_CATEGORY_GAME, "1");
Game::Lua::RegisterTableFunction("C_AutoComplete", "GetAutoCompleteResults",
&Script_GetAutoCompleteResults, &kGetAutoCompleteResults);
Game::Lua::RegisterTableFunction("C_AutoComplete", "IsRecognizedName", &Script_IsRecognizedName,
&kIsRecognizedName);
Game::Lua::RegisterIntegerEnum(
"Enum", "AutoCompleteEntryFlag", kEntryFlagEntries, "AutoComplete");
Game::Lua::RegisterIntegerEnum(
"Enum", "AutoCompletePriority", kPriorityEntries, "AutoComplete");
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace AutoComplete::Names
+27
View File
@@ -0,0 +1,27 @@
// 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/>.
#pragma once
#include <cstdint>
namespace AutoComplete::Names {
// Chat::Dispatch calls this for every dispatched chat line, before the engine
// fires its CHAT_MSG_* event. A whisper to or from a player (`guid` is the
// other player: Chat::Dispatch's sender) makes that player an InteractedWith
// name, most recent first — 4.3.4's ChatFrame does the same in its message
// handler. Every other line is ignored.
void OnChat(int chatType, int languageID, const char *name, uint64_t guid);
} // namespace AutoComplete::Names
+3 -2
View File
@@ -19,8 +19,9 @@
// the `BAG_UPDATE_DELAYED` event. Subscribe here to run C++ work exactly once
// per frame in which any bag actually changed, instead of polling every frame.
//
// Callbacks fire at the tail of the world tick, after every BAG_UPDATE for the
// frame has already fired (so bag contents are settled). No firing on frames
// Callbacks fire from the world tick's subscription, after the frame's UI draw
// and after every BAG_UPDATE for the frame has already fired (so bag contents
// are settled). No firing on frames
// where nothing changed. Order across subscribers is unspecified.
//
// Module pattern, matching `Tick::WorldTick::AutoSubscribe`: declare a
+221
View File
@@ -0,0 +1,221 @@
// 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/>.
// `debuglocals([level [, skipFunctionsAndUserdata]])` — the locals and then
// the upvalues of a call level as text, one `name=value` line each, for an
// error report. 4.3.4's Script_debuglocals and the FrameScript_PackVariable
// it formats with, in retail's spacing (probed: `n=7`, `s="hi"`,
// `t=<table>{`, ` k=1`, `}`; 4.3.4 writes `n = 7` and `t = <table> {`):
// - Only while an error handler runs; otherwise it returns nothing. 4.3.4
// tests a flag its error function sets around the handler call. 1.12's
// error function sets none, but while a handler runs that function's
// level is on the stack (ErrorCallstack::ErrorFunctionLevel).
// - `level` counts as lua_getstack and debugstack do: 1 is the caller.
// Less than 1 is 1.
// - A table prints as `name=<table>{`, or with the frame's name for a
// frame (t[0], as FrameScript_GetObject reads it), then its fields one
// space in at the top level only, then `}`. A function prints where it
// is defined. The text stops at 4095 bytes, each line at 1023.
// Retail's second argument, which its Blizzard_ScriptErrors passes as true,
// leaves out the function and userdata values, at the top level and in
// tables.
//
// A number key in int range prints truncated with %d, as 4.3.4's
// IsIntegerNumber tests only the range: 2.5 prints as 2. Two departures from
// 4.3.4, both where it would fail inside the handler: a number key outside
// that range is formatted from a copy, not converted in place (that breaks
// lua_next), and a key that is not a number or string prints as its type in
// angle brackets (4.3.4 formats a null string).
//
// Retail also lists `(*temporary)` lines: 5.1's lua_getlocal names each stack
// slot past a level's locals so, a C level's arguments included (an
// `error("x")` report shows `(*temporary)="x"`). 1.12's lua_getlocal
// (0x6FBB20) is 5.0's, which names declared locals only. Those slots are
// the compiler's registers, which 5.0 and 5.1 allocate differently, so they
// are left out rather than imitated.
//
// Registered on both Lua states, like the base-library helpers.
#include "Game.h"
#include "Offsets.h"
#include "baselib/ErrorCallstack.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
namespace BaseLib::DebugLocals {
namespace {
namespace Lua = Game::Lua;
using lua_getlocal_t = const char *(__fastcall *)(void *L, const void *ar, int n);
using lua_getupvalue_t = const char *(__fastcall *)(void *L, int funcindex, int n);
const auto GetLocal = reinterpret_cast<lua_getlocal_t>(Offsets::LUA_GET_LOCAL);
const auto GetUpvalue = reinterpret_cast<lua_getupvalue_t>(Offsets::LUA_GET_UPVALUE);
constexpr size_t kTextMax = 4095; // 4.3.4's 4096-byte buffer, less its NUL
constexpr size_t kLineSize = 1024;
struct Packer {
void *L;
bool skipFunctionsAndUserdata;
std::string text;
void Line(const char *fmt, const char *indent, const char *name, const char *value = "",
int number = 0) {
char line[kLineSize];
std::snprintf(line, sizeof line, fmt, indent, name, value, number);
const size_t room = kTextMax - text.size();
text.append(line, std::min(room, std::strlen(line)));
}
// The value on the stack top, as 4.3.4's FrameScript_PackVariable.
void Pack(const char *name, int depth) {
const char *indent = depth > 0 ? " " : "";
switch (Lua::Type(L, -1)) {
case Lua::TYPE_NIL:
Line("%s%s=nil\n", indent, name);
break;
case Lua::TYPE_BOOLEAN:
Line("%s%s=%s\n", indent, name, Lua::ToBoolean(L, -1) ? "true" : "false");
break;
case Lua::TYPE_LIGHTUSERDATA:
case Lua::TYPE_USERDATA:
if (!skipFunctionsAndUserdata)
Line("%s%s=<userdata>\n", indent, name);
break;
case Lua::TYPE_NUMBER:
Line("%s%s=%s\n", indent, name, Lua::ToString(L, -1)); // a copy: safe to convert
break;
case Lua::TYPE_STRING:
Line("%s%s=\"%s\"\n", indent, name, Lua::ToString(L, -1));
break;
case Lua::TYPE_TABLE:
PackTable(name, depth, indent);
break;
case Lua::TYPE_FUNCTION:
if (!skipFunctionsAndUserdata)
PackFunction(name, indent);
break;
default:
Line("%s%s=<%s>\n", indent, name, Lua::TypeName(L, Lua::Type(L, -1)));
break;
}
}
void PackTable(const char *name, int depth, const char *indent) {
void *object = Lua::ResolveObject(L, -1);
Line("%s%s=%s{\n", indent, name, object ? Lua::ObjectDisplayName(object) : "<table>");
if (depth == 0) {
Lua::PushNil(L);
while (Lua::Next(L, -2) != 0) {
char key[32];
const char *keyName = key;
const int keyType = Lua::Type(L, -2);
const double number = keyType == Lua::TYPE_NUMBER ? Lua::ToNumber(L, -2) : 0;
if (keyType == Lua::TYPE_NUMBER && std::isfinite(number) && number >= -2147483648.0 &&
number <= 2147483647.0) {
std::snprintf(key, sizeof key, "%d", static_cast<int>(number));
} else if (keyType == Lua::TYPE_STRING) {
keyName = Lua::ToString(L, -2);
} else if (keyType == Lua::TYPE_NUMBER) {
Lua::PushValue(L, -2);
std::snprintf(key, sizeof key, "%s", Lua::ToString(L, -1));
Lua::SetTop(L, -2);
} else {
std::snprintf(key, sizeof key, "<%s>", Lua::TypeName(L, keyType));
}
Pack(keyName, 1);
Lua::SetTop(L, -2); // the value; the key stays for lua_next
}
}
Line("%s}\n", indent, "");
}
void PackFunction(const char *name, const char *indent) {
alignas(4) uint8_t ar[Offsets::SIZEOF_LUA_DEBUG];
Lua::PushValue(L, -1);
Lua::GetInfo(L, ">nS", ar); // pops the copy
const auto field = [&ar](uintptr_t offset) { return Game::Read<const char *>(ar, offset); };
const char *source = field(Offsets::OFF_LUA_DEBUG_SOURCE);
const int line = Game::Read<int>(ar, Offsets::OFF_LUA_DEBUG_LINEDEFINED);
if (const char *fn = field(Offsets::OFF_LUA_DEBUG_NAME)) {
char named[kLineSize];
std::snprintf(named, sizeof named, "%s() defined %s", fn, source ? source : "?");
Line("%s%s=%s:%d\n", indent, name, named, line);
} else {
Line("%s%s=<function> defined %s:%d\n", indent, name, source ? source : "?", line);
}
}
};
int __fastcall Script_debuglocals(void *L) {
if (ErrorCallstack::ErrorFunctionLevel(L) == 0)
return 0;
int level = static_cast<int>(Lua::ToNumber(L, 1));
if (level <= 0)
level = 1;
Packer packer{L, Lua::ToBoolean(L, 2) != 0, {}};
alignas(4) uint8_t ar[Offsets::SIZEOF_LUA_DEBUG];
if (Lua::GetStack(L, level, ar)) {
const char *name;
for (int i = 1; (name = GetLocal(L, ar, i)) != nullptr; i++) {
packer.Pack(name, 0);
Lua::SetTop(L, -2);
}
Lua::GetInfo(L, "f", ar); // the level's function
for (int i = 1; (name = GetUpvalue(L, -1, i)) != nullptr; i++) {
packer.Pack(name, 0);
Lua::SetTop(L, -2);
}
Lua::SetTop(L, -2);
}
Lua::PushLString(L, packer.text.data(), static_cast<unsigned int>(packer.text.size()));
return 1;
}
const Game::Doc::Field kDebuglocalsArgs[] = {
Game::Doc::Opt("level", "number", "1", "The call level, as debugstack counts it."),
Game::Doc::Opt("skipFunctionsAndUserdata", "boolean", "false",
"Leaves out function and userdata values."),
};
const Game::Doc::Field kDebuglocalsRets[] = {
Game::Doc::Opt("locals", "string", nullptr,
"One `name=value` line for each local and upvalue. Nothing outside an "
"error handler."),
};
const Game::Doc::Function kDebuglocals{
"The local variables and upvalues of a call level as text, for an error report.",
kDebuglocalsArgs, kDebuglocalsRets, "LuaGlobals"};
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("debuglocals", &Script_debuglocals, &kDebuglocals);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("debuglocals", &Script_debuglocals, &kDebuglocals);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::DebugLocals
+102
View File
@@ -0,0 +1,102 @@
// 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/>.
// `debugstack([thread,] [start [, count1 [, count2]]])` — the stack of a
// coroutine, as 4.3.4's Script_debugstack takes it: with a thread first,
// the levels are that thread's and `start` defaults to 0 (the thread's
// innermost level) instead of 1.
//
// 1.12's debugstack (FUN_SCRIPT_DEBUGSTACK) has no thread argument, and a
// thread falls through its argument checks so that it returns the thread
// itself: `msg .. debugstack(co)` raises "attempt to concatenate a thread
// value" in the coroutine error paths of WeakAuras, Questie's ThreadLib,
// MRT and Zygor.
//
// The engine's function still does the work, so its text and its head and
// tail windows stay exactly as they are. It reads levels through six calls
// (five lua_getstack, one lua_getinfo), and those calls are redirected to
// read from the state Script_debugstack names for the call: the thread, or
// the calling state when there is none. Script_debugstack replaces the
// global and is the engine function's only way in, so the state is set for
// every call; an error part way cannot leave a thread behind for the next.
//
// Registered on both Lua states, like the base-library helpers.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <initializer_list>
namespace BaseLib::DebugStack {
namespace {
namespace Lua = Game::Lua;
// The state whose levels the engine's debugstack reads, or null for its own.
void *g_levelsOf = nullptr;
int __fastcall GetStack(void *L, int level, void *ar) {
return Lua::GetStack(g_levelsOf ? g_levelsOf : L, level, ar);
}
int __fastcall GetInfo(void *L, const char *what, void *ar) {
return Lua::GetInfo(g_levelsOf ? g_levelsOf : L, what, ar);
}
int __fastcall Script_debugstack(void *L) {
const auto engineDebugStack =
reinterpret_cast<Lua::CFunction>(Offsets::FUN_SCRIPT_DEBUGSTACK);
g_levelsOf = nullptr;
if (Lua::Type(L, 1) == Lua::TYPE_THREAD) {
g_levelsOf = Lua::ToThread(L, 1);
Lua::Remove(L, 1);
if (!Lua::IsNumber(L, 1)) { // start defaults to 0 for a thread
Lua::PushNumber(L, 0);
Lua::Insert(L, 1);
}
}
const int results = engineDebugStack(L);
g_levelsOf = nullptr;
return results;
}
void Apply() {
const auto getStack = reinterpret_cast<const void *>(&GetStack);
for (const uintptr_t site :
{Offsets::CALL_DEBUGSTACK_GETSTACK_FIRST, Offsets::CALL_DEBUGSTACK_GETSTACK_TAIL_PROBE,
Offsets::CALL_DEBUGSTACK_GETSTACK_TAIL_START, Offsets::CALL_DEBUGSTACK_GETSTACK_TAIL_SKIP,
Offsets::CALL_DEBUGSTACK_GETSTACK_NEXT})
Common::RedirectCall(site, Offsets::LUA_GET_STACK, getStack);
Common::RedirectCall(Offsets::CALL_DEBUGSTACK_GETINFO, Offsets::LUA_GET_INFO,
reinterpret_cast<const void *>(&GetInfo));
}
void RegisterInGame() {
Apply();
Lua::RegisterGlobalFunction("debugstack", &Script_debugstack);
}
void RegisterGlue() {
Apply();
Lua::RegisterGlueFunction("debugstack", &Script_debugstack);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::DebugStack
+58
View File
@@ -0,0 +1,58 @@
// 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/>.
// `difftime(t2 [, t1])` — the seconds from `t1` (0 by default) to `t2`, the
// companion of the engine's `date` and `time` globals. 4.3.4's
// Script_difftime: C `difftime` over the two arguments as time_t.
//
// Registered on BOTH states, as the engine registers `date` and `time`.
#include "Game.h"
#include <ctime>
namespace BaseLib::DiffTime {
namespace {
int __fastcall Script_difftime(void *L) {
const auto t2 = static_cast<time_t>(Game::Lua::CheckNumber(L, 1));
const auto t1 = static_cast<time_t>(Game::Lua::OptNumber(L, 2, 0));
Game::Lua::PushNumber(L, std::difftime(t2, t1));
return 1;
}
const Game::Doc::Field kDifftimeArgs[] = {
Game::Doc::Req("time2", "number", "A time in seconds, as `time()` returns."),
Game::Doc::Opt("time1", "number", "0"),
};
const Game::Doc::Field kDifftimeRets[] = {
Game::Doc::Req("seconds", "number", "time2 minus time1."),
};
const Game::Doc::Function kDifftime{"The number of seconds from time1 to time2.",
kDifftimeArgs, kDifftimeRets, "LuaGlobals"};
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("difftime", &Script_difftime, &kDifftime);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("difftime", &Script_difftime, &kDifftime);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::DiffTime
+145
View File
@@ -0,0 +1,145 @@
// 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/>.
// `GetCallstackHeight()`, `GetErrorCallstackHeight()` and
// `SetErrorCallstackHeight([height])` — retail's call stack heights. Its
// error handler turns them into the debugstack level of the code that
// failed, however many functions sit between that code and the handler:
// debugstack(GetCallstackHeight() - (GetErrorCallstackHeight() - 1))
// A height counts call levels as lua_getstack does, and so as debugstack
// does: C functions and lost tail calls count, and level 1 is the caller of
// the C function that asks.
//
// 1.12 records no error height, and needs none. Every error report runs the
// engine's error function (FUN_FRAMESCRIPT_CALL_ERROR_HANDLER) as a C
// closure: each script pcall passes it as the errfunc, so Lua calls it at
// the point of the error before the stack unwinds, and a chunk that fails
// to compile is reported by a pcall of it (FUN_LUA_RUN_STRING). It calls the
// seterrorhandler function with lua_call. So while any handler runs, that
// closure's level is on the stack directly above the code that failed, and
// GetErrorCallstackHeight answers the height below the innermost one.
// Nothing is recorded, so a handler that fails part way leaves nothing
// behind, and a nested error finds its own level.
//
// SetErrorCallstackHeight is for a handler called directly, with no error
// raised (retail's CallErrorHandler and assertsafe): with no engine level on
// the stack, GetErrorCallstackHeight answers the value set. An engine level
// wins over it, so a value a failed handler left set does not move the
// stack of a later error. Retail's order between the two is not known; no
// retail code sets the value around a raised error.
#include "Game.h"
#include "Offsets.h"
#include "baselib/ErrorCallstack.h"
#include <cstdint>
#include <optional>
namespace BaseLib::ErrorCallstack {
int ErrorFunctionLevel(void *L) {
const auto errorFunction = reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_FRAMESCRIPT_CALL_ERROR_HANDLER);
alignas(4) uint8_t ar[Offsets::SIZEOF_LUA_DEBUG];
for (int level = 1; Game::Lua::GetStack(L, level, ar); ++level) {
Game::Lua::GetInfo(L, "f", ar); // the level's function; nil for a lost tail call
const bool found = Game::Lua::ToCFunction(L, -1) == errorFunction;
Game::Lua::SetTop(L, -2);
if (found)
return level;
}
return 0;
}
namespace {
std::optional<int> g_setHeight;
// The number of call levels below the running C function.
int Height(void *L) {
alignas(4) uint8_t ar[Offsets::SIZEOF_LUA_DEBUG];
int height = 0;
while (Game::Lua::GetStack(L, height + 1, ar))
++height;
return height;
}
int __fastcall Script_GetCallstackHeight(void *L) {
Game::Lua::PushNumber(L, Height(L));
return 1;
}
int __fastcall Script_GetErrorCallstackHeight(void *L) {
const int level = ErrorFunctionLevel(L);
if (level > 0)
Game::Lua::PushNumber(L, Height(L) - level);
else if (g_setHeight)
Game::Lua::PushNumber(L, *g_setHeight);
else
Game::Lua::PushNil(L);
return 1;
}
int __fastcall Script_SetErrorCallstackHeight(void *L) {
if (Game::Lua::Type(L, 1) <= Game::Lua::TYPE_NIL)
g_setHeight.reset();
else if (Game::Lua::IsNumber(L, 1))
g_setHeight = static_cast<int>(Game::Lua::ToNumber(L, 1));
else
Game::Lua::Error(L, "Usage: SetErrorCallstackHeight([height])");
return 0;
}
const Game::Doc::Field kHeightRets[] = {
Game::Doc::Req("height", "number", "The calling function's level and every level below it."),
};
const Game::Doc::Field kErrorHeightRets[] = {
Game::Doc::Opt("height", "number", nullptr, "The stack height of the code that caused the error."),
};
const Game::Doc::Field kSetArgs[] = {
Game::Doc::Opt("height", "number", nullptr, "The height to return, or nil to clear it."),
};
const Game::Doc::Function kGetCallstackHeight{
"Returns the number of call levels on the stack, counting the calling function as level 1.",
{}, kHeightRets, "FrameScriptGlobals"};
const Game::Doc::Function kGetErrorCallstackHeight{
"Returns the call stack height of the code that caused the error being handled, or nil when no error is being handled.",
{}, kErrorHeightRets, "FrameScriptGlobals"};
const Game::Doc::Function kSetErrorCallstackHeight{
"Sets the height that GetErrorCallstackHeight returns for an error handler called with no error raised.",
kSetArgs, {}, "FrameScriptGlobals"};
// A new Lua state starts with no height set.
void RegisterInGame() {
g_setHeight.reset();
Game::Lua::RegisterGlobalFunction("GetCallstackHeight", &Script_GetCallstackHeight, &kGetCallstackHeight);
Game::Lua::RegisterGlobalFunction("GetErrorCallstackHeight", &Script_GetErrorCallstackHeight,
&kGetErrorCallstackHeight);
Game::Lua::RegisterGlobalFunction("SetErrorCallstackHeight", &Script_SetErrorCallstackHeight,
&kSetErrorCallstackHeight);
}
void RegisterGlue() {
g_setHeight.reset();
Game::Lua::RegisterGlueFunction("GetCallstackHeight", &Script_GetCallstackHeight, &kGetCallstackHeight);
Game::Lua::RegisterGlueFunction("GetErrorCallstackHeight", &Script_GetErrorCallstackHeight,
&kGetErrorCallstackHeight);
Game::Lua::RegisterGlueFunction("SetErrorCallstackHeight", &Script_SetErrorCallstackHeight,
&kSetErrorCallstackHeight);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::ErrorCallstack
+23
View File
@@ -0,0 +1,23 @@
// 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/>.
#pragma once
namespace BaseLib::ErrorCallstack {
// The innermost call level whose function is the engine's error function
// (FUN_FRAMESCRIPT_CALL_ERROR_HANDLER), or 0 when no error is being
// reported. Nonzero exactly while an error handler runs.
int ErrorFunctionLevel(void *L);
} // namespace BaseLib::ErrorCallstack
+22 -1
View File
@@ -16,13 +16,16 @@
// - `math.fmod(x, y)` — floating-point remainder of `x / y`.
// - `math.modf(x)` — split into integral + fractional parts.
// - `math.huge` — positive infinity constant.
// - `math.cosh(x)`, `math.sinh(x)`, `math.tanh(x)` — hyperbolic functions.
//
// `math.fmod` is a pure rename: Lua 5.0 ships the exact same C function under
// the name `math.mod`; 5.1 renamed the Lua-facing entry to `math.fmod`. We
// register it as a direct alias of the engine's `math.mod` C function
// (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too, so code
// written for either name works. `modf` and `huge` are genuinely new in 5.1
// (not in 1.12's mathlib table), so we implement/define them here.
// (not in 1.12's mathlib table), so we implement/define them here, as are the
// hyperbolic functions (5.1's math_cosh/sinh/tanh: luaL_checknumber, the C
// function, one number).
#include "Game.h"
#include "Offsets.h"
@@ -53,9 +56,27 @@ int __fastcall Script_math_modf(void *L) {
return 2;
}
int __fastcall Script_math_cosh(void *L) {
Game::Lua::PushNumber(L, std::cosh(Game::Lua::CheckNumber(L, 1)));
return 1;
}
int __fastcall Script_math_sinh(void *L) {
Game::Lua::PushNumber(L, std::sinh(Game::Lua::CheckNumber(L, 1)));
return 1;
}
int __fastcall Script_math_tanh(void *L) {
Game::Lua::PushNumber(L, std::tanh(Game::Lua::CheckNumber(L, 1)));
return 1;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("math", "fmod", Script_math_fmod);
Game::Lua::RegisterTableFunction("math", "modf", &Script_math_modf);
Game::Lua::RegisterTableFunction("math", "cosh", &Script_math_cosh);
Game::Lua::RegisterTableFunction("math", "sinh", &Script_math_sinh);
Game::Lua::RegisterTableFunction("math", "tanh", &Script_math_tanh);
// `math.huge` — a numeric constant, not a function. Fetch the (now
// guaranteed-present) `math` table and set the field directly.
+78
View File
@@ -0,0 +1,78 @@
// 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/>.
// `newproxy([false | true | proxy])` — Lua 5.1's empty userdata (luaB_newproxy,
// lbaselib.c). `false` or nothing: no metatable. `true`: a new metatable of
// its own. Another proxy: share that proxy's metatable. Only metatables made
// by `newproxy(true)` count; any other argument raises "boolean or proxy
// expected". 5.0 has userdata and their `__gc`, only not this function.
//
// As in 5.1, the closure's upvalue is a weak table that marks the valid
// metatables (weak[m] = true), and is its own metatable with __mode "kv".
//
// Registered on BOTH states (language-level helper, both are 5.0).
#include "Game.h"
namespace BaseLib::NewProxy {
namespace {
namespace Lua = Game::Lua;
int __fastcall Script_newproxy(void *L) {
Lua::SetTop(L, 1);
Lua::NewUserdata(L, 0); // the proxy, at 2
if (Lua::ToBoolean(L, 1) == 0)
return 1; // no metatable
if (Lua::Type(L, 1) == Lua::TYPE_BOOLEAN) {
Lua::NewTable(L); // a new metatable m ...
Lua::PushValue(L, -1);
Lua::PushBoolean(L, 1);
Lua::RawSet(L, Lua::UpvalueIndex(1)); // ... marked valid: weak[m] = true
} else {
bool valid = false;
if (Lua::GetMetatable(L, 1)) {
Lua::RawGet(L, Lua::UpvalueIndex(1));
valid = Lua::ToBoolean(L, -1) != 0;
Lua::SetTop(L, -2);
}
if (!valid)
Lua::ArgError(L, 1, "boolean or proxy expected");
Lua::GetMetatable(L, 1);
}
Lua::SetMetatable(L, 2);
return 1;
}
void Register() {
void *L = Lua::State();
if (L == nullptr)
return;
Lua::PushString(L, "newproxy");
Lua::NewTable(L); // the weak table w
Lua::PushString(L, "__mode");
Lua::PushString(L, "kv");
Lua::RawSet(L, -3);
Lua::PushValue(L, -1);
Lua::SetMetatable(L, -2); // w is its own metatable
Lua::PushCClosure(L, &Script_newproxy, 1);
Lua::SetTable(L, Lua::GLOBALS_INDEX);
}
const Game::ModuleAutoRegister _autoreg{&Register};
const Game::GlueModuleAutoRegister _autoregGlue{&Register};
} // namespace
} // namespace BaseLib::NewProxy
+63
View File
@@ -0,0 +1,63 @@
// 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/>.
// `scrub(...)` — returns its arguments, each one that is not a boolean,
// number or string replaced by nil. 4.3.4's Script_scrub.
//
// Registered on both Lua states, like the base-library helpers.
#include "Game.h"
namespace BaseLib::Scrub {
namespace {
namespace Lua = Game::Lua;
int __fastcall Script_scrub(void *L) {
const int top = Lua::GetTop(L);
Lua::CheckStack(L, 1);
for (int i = 1; i <= top; i++) {
const int type = Lua::Type(L, i);
if (type != Lua::TYPE_BOOLEAN && type != Lua::TYPE_NUMBER && type != Lua::TYPE_STRING) {
Lua::PushNil(L);
Lua::Replace(L, i);
}
}
return top;
}
const Game::Doc::Field kScrubArgs[] = {
Game::Doc::Vararg("any"),
};
const Game::Doc::Field kScrubRets[] = {
Game::Doc::Vararg("any", "Each argument, or nil when it is not a boolean, number or string."),
};
const Game::Doc::Function kScrub{
"Returns its arguments, with each one that is not a boolean, number or string replaced by nil.",
kScrubArgs, kScrubRets, "LuaGlobals"};
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("scrub", &Script_scrub, &kScrub);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("scrub", &Script_scrub, &kScrub);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::Scrub
+162
View File
@@ -0,0 +1,162 @@
// 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/>.
// `securecall(func or name, ...)`, `securecallfunction(func, ...)`,
// `securecallmethod(object, method, ...)` and
// `secureexecuterange(tbl, func, ...)` — the later clients' protected calls,
// less taint (1.12 has none). Retail, probed:
// - securecall returns the function's results; a string names a global
// function. An error goes to the error handler and the call returns
// nothing.
// - securecallfunction is securecall without the name lookup: a string is
// a value that cannot be called, so it errors and returns nothing.
// - secureexecuterange calls func(key, value, ...) for each entry of tbl in
// `next` order; an error in one goes to the error handler and the walk
// goes on. It returns nothing.
// securecallmethod is from retail's API documentation, not a probe: it calls
// object[method](object, ...) with a raw lookup (no metatable), and an error
// in the lookup or the call goes to the error handler with no results. Here
// a lookup that finds no function, or an object that is not a table, errors
// the way calling that value does.
// The error handler is the one every engine script run passes
// (VAR_FRAMESCRIPT_ERROR_HANDLER_REF), as HookScript's wrapper uses.
//
// Registered on both Lua states, like the base-library helpers.
#include "Game.h"
#include "Offsets.h"
namespace BaseLib::SecureCall {
namespace {
// Pushes the engine's script error handler; false (nothing pushed) before
// the engine has set one.
bool PushErrorHandler(void *L) {
const int ref = Game::Read<int>(static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
if (ref <= 0)
return false;
Game::Lua::RawGetI(L, Game::Lua::REGISTRY_INDEX, ref);
return true;
}
// Calls the function at stack index 1 with the values above it, under the
// error handler. Returns the result count, 0 after an error.
int CallProtected(void *L) {
const int nargs = Game::Lua::GetTop(L) - 1;
int errIdx = 0;
if (PushErrorHandler(L)) {
Game::Lua::Insert(L, 1); // [handler, func, args...]
errIdx = 1;
}
if (Game::Lua::PCall(L, nargs, Game::Lua::MULTRET, errIdx) != 0)
return 0;
return Game::Lua::GetTop(L) - errIdx;
}
int __fastcall Script_securecall(void *L) {
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING) {
Game::Lua::PushValue(L, 1);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
Game::Lua::Remove(L, 1);
Game::Lua::Insert(L, 1);
}
return CallProtected(L);
}
int __fastcall Script_securecallfunction(void *L) { return CallProtected(L); }
int __fastcall Script_securecallmethod(void *L) {
if (Game::Lua::GetTop(L) < 2)
Game::Lua::SetTop(L, 2);
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE) {
Game::Lua::PushValue(L, 2);
Game::Lua::RawGet(L, 1);
} else {
Game::Lua::PushNil(L);
}
Game::Lua::Remove(L, 2); // [object, args..., func]
Game::Lua::Insert(L, 1); // [func, object, args...]
return CallProtected(L);
}
int __fastcall Script_secureexecuterange(void *L) {
const int n = Game::Lua::GetTop(L); // tbl, func, extra args 3..n
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE)
return 0;
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, 1) != 0) { // [.., key, value]
int errIdx = 0;
if (PushErrorHandler(L))
errIdx = n + 3;
Game::Lua::PushValue(L, 2);
Game::Lua::PushValue(L, n + 1);
Game::Lua::PushValue(L, n + 2);
for (int i = 3; i <= n; ++i)
Game::Lua::PushValue(L, i);
Game::Lua::PCall(L, n, 0, errIdx); // key, value and the n - 2 extras
Game::Lua::SetTop(L, n + 1); // the key, for the next step
}
return 0;
}
const Game::Doc::Field kCallArgs[] = {
Game::Doc::Req("func", "function", "The function to call, or for securecall the name of a global function."),
Game::Doc::Opt("...", "any", nullptr, "The arguments for the function."),
};
const Game::Doc::Field kCallRets[] = {
Game::Doc::Opt("...", "any", nullptr, "The function's results, or nothing after an error."),
};
const Game::Doc::Field kMethodArgs[] = {
Game::Doc::Req("object", "table", "The table that holds the method. The lookup ignores its metatable."),
Game::Doc::Req("method", "string", "The name of the method."),
Game::Doc::Opt("...", "any", nullptr, "The arguments after object."),
};
const Game::Doc::Field kRangeArgs[] = {
Game::Doc::Req("tbl", "table", "The table whose entries are visited."),
Game::Doc::Req("func", "function", "Called as func(key, value, ...) for each entry."),
Game::Doc::Opt("...", "any", nullptr, "Extra arguments for each call."),
};
const Game::Doc::Function kSecureCall{
"Calls a function and returns its results. An error goes to the error handler and the call returns nothing.",
kCallArgs, kCallRets, "LuaGlobals"};
const Game::Doc::Function kSecureCallFunction{
"Calls a function and returns its results. An error goes to the error handler and the call returns nothing.",
kCallArgs, kCallRets, "LuaGlobals"};
const Game::Doc::Function kSecureCallMethod{
"Calls object[method](object, ...) and returns its results. An error goes to the error handler and the call returns nothing.",
kMethodArgs, kCallRets, "LuaGlobals"};
const Game::Doc::Function kSecureExecuteRange{
"Calls a function for each entry of a table. An error goes to the error handler and the other entries still run.",
kRangeArgs, {}, "LuaGlobals"};
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("securecall", &Script_securecall, &kSecureCall);
Game::Lua::RegisterGlobalFunction("securecallfunction", &Script_securecallfunction, &kSecureCallFunction);
Game::Lua::RegisterGlobalFunction("securecallmethod", &Script_securecallmethod, &kSecureCallMethod);
Game::Lua::RegisterGlobalFunction("secureexecuterange", &Script_secureexecuterange, &kSecureExecuteRange);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("securecall", &Script_securecall, &kSecureCall);
Game::Lua::RegisterGlueFunction("securecallfunction", &Script_securecallfunction, &kSecureCallFunction);
Game::Lua::RegisterGlueFunction("securecallmethod", &Script_securecallmethod, &kSecureCallMethod);
Game::Lua::RegisterGlueFunction("secureexecuterange", &Script_secureexecuterange, &kSecureExecuteRange);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::SecureCall
+86
View File
@@ -0,0 +1,86 @@
// 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/>.
// `string.format` takes two-digit argument numbers (`%10$s`).
//
// 1.12's str_format (0x7FD3D0) is Lua 4.0's: a spec may start with `n$`, but
// only one digit, `isdigit(p[0]) && p[1] == '$'`. In "%10$s" the "10" is then
// read as a width and the '$' as the conversion, which raises "invalid
// option in `format'". 4.3.4's str_format adds a second branch for two
// digits, `isdigit(p[1]) && p[2] == '$'`, and its combat log strings need it
// (ACTION_SPELL_PERIODIC_LEECH_RESULT is "(%10$s %8$s Gained)").
//
// The thunk below stands in for the engine's one-digit branch and adds the
// two-digit one. Everything else stays the engine's: its locale isdigit on
// the first digit (already tested before the site), `arg++` after the site,
// the flag/width/precision scan from EBX, and every conversion. Every caller
// goes through it: `string.format`, `format`, and the C callers of
// FUN_LUA_STR_FORMAT (Frame::FormattedText, Text::NumericFormatter).
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace BaseLib::StringFormat {
namespace {
static_assert(Offsets::LUA_STR_FORMAT_FRAME_ARG == 8, "ArgNumberThunk writes [ebp - 8]");
// ESI = the spec after '%', whose first byte is a digit. Sets the argument
// number in [ebp - 8] and moves EBX past the `n$`; leaves both alone when
// the spec has no `n$`.
__declspec(naked) void ArgNumberThunk() {
__asm {
cmp byte ptr [esi + 1], '$'
jne twoDigits
movsx eax, byte ptr [esi]
sub eax, '0'
mov dword ptr [ebp - 8], eax
lea ebx, [esi + 2]
ret
twoDigits:
movzx eax, byte ptr [esi + 1]
sub eax, '0'
cmp eax, 9
ja done
cmp byte ptr [esi + 2], '$'
jne done
movsx ecx, byte ptr [esi]
sub ecx, '0'
lea ecx, [ecx + ecx * 4]
lea eax, [eax + ecx * 2]
mov dword ptr [ebp - 8], eax
lea ebx, [esi + 3]
done:
ret
}
}
// Runs on every registration pass; a site already patched no longer matches
// and is skipped.
void Apply() {
static const uint8_t kOneDigit[18] = {0x80, 0x7E, 0x01, 0x24, 0x75, 0x0C, 0x0F, 0xBE, 0x06,
0x83, 0xE8, 0x30, 0x89, 0x45, 0xF8, 0x8D, 0x5E, 0x02};
Common::ReplaceWithCall(Offsets::SITE_LUA_STR_FORMAT_ARG_NUMBER, kOneDigit, sizeof kOneDigit,
reinterpret_cast<const void *>(&ArgNumberThunk));
}
const Game::ModuleAutoRegister _autoreg{&Apply};
const Game::GlueModuleAutoRegister _glueAutoreg{&Apply};
} // namespace
} // namespace BaseLib::StringFormat
+163
View File
@@ -0,0 +1,163 @@
// 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/>.
// `string.gsub` takes 5.1's string and table replacements.
//
// 1.12's str_gsub (0x7FD0E0) is Lua 5.0's, and differs from 5.1 in its
// replacement argument:
// - it takes a string or a function, not a table;
// - `%0` in a replacement string, and `%1` when the pattern has no
// captures, raise "invalid capture index" (5.1: the whole match);
// - a function that returns nil or false deletes the match (5.1 keeps it),
// and one that returns a table deletes it too (5.1 raises an error).
//
// The first two only ever raised errors, so 5.1's behavior cannot break a
// 1.12 addon. The third does: SuperAPI's CombatText_AddMessage returns nil
// to delete the player's own " [0x...]" GUID from heal text. A function
// replacement therefore stays the engine's own add_s.
//
// str_gsub itself stays: its match loop, anchor, count and buffer are the
// same in both. Two parts are replaced:
// - the argument gate (SITE_LUA_STR_GSUB_REPL_CHECK) becomes CheckReplacement;
// - the one add_s call (CALL_LUA_STR_GSUB_ADD_S) goes to AddValue: 5.1's
// add_s for a string or number, 5.1's table lookup for a table, and the
// engine's add_s for a function. They use the engine's capture helpers.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace BaseLib::StringGsub {
namespace {
namespace Lua = Game::Lua;
// Lua 5.0's MatchState, as str_gsub builds it on its stack (str_format's
// spec scan reads the same layout: level at +0x0C, capture[0].len at +0x14).
struct MatchState {
const char *srcInit;
const char *srcEnd;
void *L;
int level;
struct {
const char *init;
int len;
} capture[32];
};
using add_s_t = void(__fastcall *)(MatchState *ms, void *b, const char *s, const char *e);
using push_onecapture_t = void(__fastcall *)(MatchState *ms, int i);
using luaL_addlstring_t = void(__fastcall *)(void *b, const char *s, unsigned int len);
using luaL_addvalue_t = void(__fastcall *)(void *b);
const auto EngineAddS = reinterpret_cast<add_s_t>(Offsets::FUN_LUA_STR_ADD_S);
const auto EnginePushOneCapture =
reinterpret_cast<push_onecapture_t>(Offsets::FUN_LUA_STR_PUSH_ONECAPTURE);
const auto AddLString = reinterpret_cast<luaL_addlstring_t>(Offsets::LUAL_ADD_LSTRING);
const auto AddBufferValue = reinterpret_cast<luaL_addvalue_t>(Offsets::LUAL_ADD_VALUE);
// 5.1's push_onecapture: capture `i`, or the whole match for `i == 0` when
// the pattern has no captures.
void PushOneCapture(MatchState *ms, int i, const char *s, const char *e) {
if (i < ms->level)
EnginePushOneCapture(ms, i);
else if (i == 0)
Lua::PushLString(ms->L, s, static_cast<unsigned int>(e - s));
else
Lua::Error(ms->L, "invalid capture index");
}
// 5.1's add_s: a replacement string, where `%0` is the whole match and `%1`
// to `%9` are captures.
void AddString(MatchState *ms, void *b, const char *s, const char *e) {
void *L = ms->L;
const char *news = Lua::ToString(L, 3);
const unsigned int l = Lua::StrLen(L, 3);
for (unsigned int i = 0; i < l; i++) {
if (news[i] != '%') {
AddLString(b, news + i, 1);
continue;
}
i++; // skip the escape
const char c = news[i];
if (c < '0' || c > '9') {
AddLString(b, news + i, 1);
} else if (c == '0') {
AddLString(b, s, static_cast<unsigned int>(e - s));
} else {
PushOneCapture(ms, c - '1', s, e);
AddBufferValue(b);
}
}
}
// 5.1's add_value for a string or table, the engine's add_s for a function.
// Called as add_s: __fastcall, ret 8.
void __fastcall AddValue(MatchState *ms, void *b, const char *s, const char *e) {
void *L = ms->L;
switch (Lua::Type(L, 3)) {
case Lua::TYPE_FUNCTION:
// A result that is not a string or number deletes the match.
EngineAddS(ms, b, s, e);
return;
case Lua::TYPE_TABLE:
break;
default: // number or string
AddString(ms, b, s, e);
return;
}
PushOneCapture(ms, 0, s, e);
Lua::GetTable(L, 3);
if (!Lua::ToBoolean(L, -1)) {
Lua::SetTop(L, -2);
Lua::PushLString(L, s, static_cast<unsigned int>(e - s)); // keep the match
} else if (!Lua::IsString(L, -1)) {
Lua::Error(L, "invalid replacement value (a %s)", Lua::TypeName(L, Lua::Type(L, -1)));
}
AddBufferValue(b);
}
// 5.1's gate on the replacement argument. Called in place of the engine's,
// with ECX = L.
void __fastcall CheckReplacement(void *L) {
const int t = Lua::Type(L, 3);
if (t != Lua::TYPE_NUMBER && t != Lua::TYPE_STRING && t != Lua::TYPE_FUNCTION &&
t != Lua::TYPE_TABLE)
Lua::ArgError(L, 3, "string/function/table expected");
}
// Runs on every registration pass; a site already patched no longer matches
// and is skipped.
void Apply() {
static const uint8_t kGate[60] = {
0xE8, 0x0D, 0x5F, 0xEF, 0xFF, 0x83, 0xF8, 0x03, 0x7C, 0x21, 0xBA, 0x03,
0x00, 0x00, 0x00, 0x8B, 0xCF, 0xE8, 0x9C, 0x63, 0xEF, 0xFF, 0x85, 0xC0,
0x75, 0x22, 0xBA, 0x03, 0x00, 0x00, 0x00, 0x8B, 0xCF, 0xE8, 0x7C, 0x62,
0xEF, 0xFF, 0x83, 0xF8, 0x06, 0x74, 0x11, 0x68, 0x84, 0x23, 0x88, 0x00,
0xBA, 0x03, 0x00, 0x00, 0x00, 0x8B, 0xCF, 0xE8, 0x76, 0x76, 0xEF, 0xFF,
};
Common::ReplaceWithCall(Offsets::SITE_LUA_STR_GSUB_REPL_CHECK, kGate, sizeof kGate,
reinterpret_cast<const void *>(&CheckReplacement));
Common::RedirectCall(Offsets::CALL_LUA_STR_GSUB_ADD_S, Offsets::FUN_LUA_STR_ADD_S,
reinterpret_cast<const void *>(&AddValue));
}
const Game::ModuleAutoRegister _autoreg{&Apply};
const Game::GlueModuleAutoRegister _glueAutoreg{&Apply};
} // namespace
} // namespace BaseLib::StringGsub
+202 -154
View File
@@ -15,13 +15,22 @@
//
// - `string.match(s, pattern [, init])` — first-match extraction (5.1),
// also the global `strmatch`, as `string.find` is `strfind`.
// - `string.gmatch(s, pattern)` — match iterator (5.1).
// - `string.gsub` TABLE replacement — 5.1 form (see the shim below).
// - `string.gmatch(s, pattern)` — match iterator (5.1), also `gmatch`.
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
// - `strjoin(delimiter, ...)` — WoW global; join varargs.
// - `strtrim(str [, chars])` — WoW global; trim a char set.
// - `strreplace(str, find, replace)` — plain-text replace (see below).
// - `string.reverse(s)` / `strrev(s)` — 5.1 string function, missing in 5.0.
// - `string.byte(s, i, j)` / `strbyte` — 5.1's range form (5.0 reads only i).
// - `strlenutf8(s)` — WoW global; characters, not bytes.
// - `strcmputf8i(a, b)` — WoW global; case-insensitive UTF-8 order.
// - `strsplittable(sep, str [, pieces])`— WoW global; `strsplit` into a table.
// - `strconcat(...)` / `string.concat` — WoW global; `..` over every argument.
//
// The two UTF-8 globals are 4.3.4's `Script_strlenutf8` and the newer
// clients' `strcmputf8i`, each a thin push over a Storm helper 1.12 does not
// have (`SStrLenUTF8` / `SStrCmpUTF8I`) — `Text::Utf8` mirrors those.
// `strconcat` is 4.3.4's: `lua_concat` over the whole stack.
//
// The two pattern `string.*` ones reuse machinery 5.0 already ships:
// - `match` is `find` returning captures / the whole match instead of the
@@ -38,6 +47,7 @@
#include "Game.h"
#include "Offsets.h"
#include "text/Utf8.h"
#include <cstddef>
#include <cstring>
@@ -92,33 +102,17 @@ int __fastcall Script_string_match(void *L) {
const auto Script_string_gmatch =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GFIND);
// `strsplit(sep, str [, pieces])` — the WoW global (also aliased as
// `string.split`), split `str` on ANY character in `sep` and return the
// pieces as multiple values. `pieces > 0` caps the result count, with the
// unsplit remainder as the final piece; `0` / omitted = unlimited. Ported
// from 3.3.5's `strsplit` (FUN_00816a60) with one deliberate change: we do
// NOT `lua_settop(L, 0)` up front. Popping the args would leave the source
// string unreferenced, so a GC step triggered by a `pushlstring` could free
// the very buffer we're still scanning. Keeping the args on the stack keeps
// the source GC-rooted; Lua returns the top N values (our pieces) regardless
// of the args sitting below them. Each push is guarded by `lua_checkstack`
// (this pushes an unbounded number of results), erroring like 3.3.5 on
// genuine stack exhaustion.
int __fastcall Script_strsplit(void *L) {
const char *sep = Game::Lua::ToString(L, 1);
const char *str = Game::Lua::ToString(L, 2);
if (sep == nullptr || str == nullptr) {
Game::Lua::Error(L, "Usage: strsplit(\"separators\", str [, pieces])");
return 0; // unreachable
}
const int pieces =
Game::Lua::IsNumber(L, 3) ? static_cast<int>(Game::Lua::ToNumber(L, 3)) : 0;
// The scan behind `strsplit` and `strsplittable`: splits `str` on ANY
// character in `sep` and hands each piece to `emit(start, length)` in order,
// the unsplit remainder last. `pieces > 0` caps the count, with the
// remainder (delimiters and all) as the final piece; `0` is unlimited.
// Only scans for separators while unlimited or the cap hasn't been reached;
// `pieces == 1` (or <= 0 but non-zero) yields the whole string as a single
// piece — matches 3.3.5's `strsplit` (FUN_00816a60).
template <typename Emit>
void SplitPieces(const char *sep, const char *str, int pieces, Emit emit) {
const char *segStart = str;
int count = 0;
// Only scan for separators while unlimited (pieces == 0) or the cap
// hasn't been reached (pieces > 1). pieces == 1 (or <= 0 but non-zero)
// yields the whole string as a single piece — matches 3.3.5.
if (pieces == 0 || pieces > 1) {
for (const char *p = str; *p != '\0'; ++p) {
bool isSep = false;
@@ -131,23 +125,108 @@ int __fastcall Script_strsplit(void *L) {
if (!isSep)
continue;
++count;
if (Game::Lua::CheckStack(L, count) == 0) {
Game::Lua::Error(L, "strsplit(): Stack overflow");
return 0; // unreachable
}
Game::Lua::PushLString(L, segStart,
static_cast<unsigned int>(p - segStart));
emit(segStart, static_cast<unsigned int>(p - segStart));
segStart = p + 1;
if (count == pieces - 1)
break; // cap hit; remainder becomes the final piece
}
}
if (Game::Lua::CheckStack(L, count + 1) == 0) {
Game::Lua::Error(L, "strsplit(): Stack overflow");
emit(segStart, static_cast<unsigned int>(std::strlen(segStart)));
}
// `strsplit(sep, str [, pieces])` — the WoW global (also aliased as
// `string.split`): the pieces as multiple values. Ported from 3.3.5 with one
// deliberate change: we do NOT `lua_settop(L, 0)` up front. Popping the args
// would leave the source string unreferenced, so a GC step triggered by a
// `pushlstring` could free the very buffer we're still scanning. Keeping the
// args on the stack keeps the source GC-rooted; Lua returns the top N values
// (our pieces) regardless of the args sitting below them. Each push is
// guarded by `lua_checkstack` (this pushes an unbounded number of results),
// erroring like 3.3.5 on genuine stack exhaustion.
int __fastcall Script_strsplit(void *L) {
const char *sep = Game::Lua::ToString(L, 1);
const char *str = Game::Lua::ToString(L, 2);
if (sep == nullptr || str == nullptr) {
Game::Lua::Error(L, "Usage: strsplit(\"separators\", str [, pieces])");
return 0; // unreachable
}
Game::Lua::PushString(L, segStart); // final piece (remainder to end)
return count + 1;
const int pieces =
Game::Lua::IsNumber(L, 3) ? static_cast<int>(Game::Lua::ToNumber(L, 3)) : 0;
int count = 0;
SplitPieces(sep, str, pieces, [L, &count](const char *start, unsigned int len) {
++count;
if (Game::Lua::CheckStack(L, count) == 0) {
Game::Lua::Error(L, "strsplit(): Stack overflow");
return; // unreachable
}
Game::Lua::PushLString(L, start, len);
});
return count;
}
// `strsplittable(sep, str [, pieces])` — the same split as one array
// table. Every piece goes to the next integer key of a fresh table, so the
// Lua stack never grows and no piece count is too many — the point of the
// table form. The args stay on the stack (GC-rooting the source, as above);
// the table sits above them at index 4.
int __fastcall Script_strsplittable(void *L) {
const char *sep = Game::Lua::ToString(L, 1);
const char *str = Game::Lua::ToString(L, 2);
if (sep == nullptr || str == nullptr) {
Game::Lua::Error(L, "Usage: strsplittable(\"separators\", str [, pieces])");
return 0; // unreachable
}
const int pieces =
Game::Lua::IsNumber(L, 3) ? static_cast<int>(Game::Lua::ToNumber(L, 3)) : 0;
Game::Lua::SetTop(L, 3);
Game::Lua::NewTable(L); // 4
int count = 0;
SplitPieces(sep, str, pieces, [L, &count](const char *start, unsigned int len) {
Game::Lua::PushNumber(L, static_cast<double>(++count));
Game::Lua::PushLString(L, start, len);
Game::Lua::RawSet(L, 4);
});
return 1;
}
// `strlenutf8(s)` — 4.3.4's `Script_strlenutf8`: `SStrLenUTF8` on the
// string, the count of characters (code points) rather than bytes. Same
// usage text and `lua_isstring` gate as the original.
int __fastcall Script_strlenutf8(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: strlenutf8(string)");
return 0; // unreachable
}
Game::Lua::PushNumber(
L, static_cast<double>(Text::Utf8::Length(Game::Lua::ToString(L, 1))));
return 1;
}
// `strcmputf8i(string1, string2)` — the newer clients' global (its usage
// text is in their binaries) over `SStrCmpUTF8I`: a case-insensitive sort
// order for UTF-8 text. The number is the first folded code-point
// difference, as Storm returns it; FrameXML only ever tests the sign (`< 0`
// in sort comparators, `== 0` for a name match).
int __fastcall Script_strcmputf8i(void *L) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: strcmputf8i(string1, string2)");
return 0; // unreachable
}
const int result =
Text::Utf8::CompareI(Game::Lua::ToString(L, 1), Game::Lua::ToString(L, 2));
Game::Lua::PushNumber(L, static_cast<double>(result));
return 1;
}
// `strconcat(...)` / `string.concat(...)` — 4.3.4's `Script_strconcat`:
// `lua_concat` over everything on the stack, i.e. the `..` operator applied
// to every argument (numbers convert, anything else raises the concatenate
// error), `""` for no arguments, and a single argument handed back as it is.
int __fastcall Script_strconcat(void *L) {
Game::Lua::Concat(L, Game::Lua::GetTop(L));
return 1;
}
// `strjoin(delimiter, ...)` — the inverse of `strsplit`: concatenate the
@@ -262,152 +341,121 @@ int __fastcall Script_string_reverse(void *L) {
return 1;
}
// --- string.gsub table-replacement upgrade (Lua 5.1) ------------------------
//
// 5.1 allows a TABLE as gsub's replacement: each match looks up the first
// capture (the whole match when the pattern declares no captures) in the
// table; a nil/false value keeps the original match; string/number values
// substitute. This engine's gsub (FUN_LUA_STR_GSUB) is stock 5.0 —
// string/function only ("string or function expected" otherwise), and its
// add_value appends NOTHING when a replacement function returns a
// non-string (match → empty; verified by decode, see Offsets.h). So a
// plain table→closure adapter would EAT unmatched tokens where 5.1 keeps
// them, and the closure can't reconstruct the whole match from capture
// args alone.
//
// The shim therefore rewrites the pattern with one OUTER capture —
// `pat` → `(pat)`, keeping a leading `^` and an unescaped trailing `$`
// outside (both are anchors only at their positional ends; inside the
// parens they'd turn literal) — so the adapter closure always receives
// the whole match as arg 1, the pattern's own first capture as arg 2, and
// can hand arg 1 back to reproduce 5.1's keep-match for missing keys.
// Patterns containing a `%1`..`%9` back-reference can't be wrapped (the
// outer capture would renumber what `%N` refers to); those fall back to
// an unwrapped adapter whose missing-key behavior is the engine's own
// (match → empty) — the one documented divergence, and the combination
// (table replacement + pattern back-references) is essentially absent
// from real code. String/function replacements tail-call the engine gsub
// untouched.
// `string.byte(s [, i [, j]])` / `strbyte` — the codes of bytes i..j, as
// 4.3.4's str_byte (j defaults to i). 1.12's (0x7FC210) is 5.0's: it reads
// only `i` and pushes one code. With `j` absent the two give the same
// result for every `i`. Positions truncate toward zero as the engine's
// str_byte and str_sub do. The engine's lua_checkstack (0x6F2F30) refuses
// more than 2048 stack slots, so the longest range is about 2000 bytes.
// True when the pattern contains a `%1`..`%9` back-reference. Escape-aware:
// the char after any `%` is consumed, so `%%1` (literal percent, then '1')
// doesn't count.
bool PatternHasBackref(const char *p, unsigned len) {
for (unsigned i = 0; i + 1 < len; ++i) {
if (p[i] != '%')
continue;
const char c = p[i + 1];
if (c >= '1' && c <= '9')
return true;
++i; // skip the escaped char
}
return false;
// 5.1's posrelat: a negative position counts from the end; before the start is 0.
long PosRelat(long pos, long len) {
if (pos < 0)
pos += len + 1;
return pos >= 0 ? pos : 0;
}
// Adapter for the wrapped-pattern path: arg 1 = whole match, arg 2 = the
// pattern's own first capture (when it declared any). Upvalue 1 = the
// replacement table. Missing/false value → return the whole match (5.1's
// keep-match).
int __fastcall GsubTableLookupWrapped(void *L) {
const int nargs = Game::Lua::GetTop(L);
Game::Lua::PushValue(L, nargs >= 2 ? 2 : 1); // the 5.1 lookup key
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1)); // tbl[key]
const int t = Game::Lua::Type(L, -1);
if (t == Game::Lua::TYPE_NIL ||
(t == Game::Lua::TYPE_BOOLEAN && Game::Lua::ToBoolean(L, -1) == 0))
Game::Lua::PushValue(L, 1); // keep the original match
return 1; // top value is the result
int __fastcall Script_string_byte(void *L) {
unsigned int l = 0;
const char *s = Game::Lua::CheckLString(L, 1, &l);
const long len = static_cast<long>(l);
long posi = PosRelat(static_cast<long>(Game::Lua::OptNumber(L, 2, 1)), len);
long pose =
PosRelat(static_cast<long>(Game::Lua::OptNumber(L, 3, static_cast<double>(posi))), len);
if (posi <= 0)
posi = 1;
if (pose > len)
pose = len;
if (posi > pose)
return 0;
const int n = static_cast<int>(pose - posi + 1);
if (!Game::Lua::CheckStack(L, n))
Game::Lua::Error(L, "stack overflow (%s)", "string slice too long");
for (int i = 0; i < n; i++)
Game::Lua::PushNumber(L, static_cast<unsigned char>(s[posi + i - 1]));
return n;
}
// Adapter for back-reference patterns (unwrapped): arg 1 is already the
// 5.1 key. A missing key returns nil → the engine appends nothing.
int __fastcall GsubTableLookupPlain(void *L) {
Game::Lua::PushValue(L, 1);
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1));
return 1;
}
// --- Documentation ----------------------------------------------------------
int __fastcall Script_string_gsub(void *L) {
const auto engineGsub =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GSUB);
if (Game::Lua::Type(L, 3) != Game::Lua::TYPE_TABLE)
return engineGsub(L); // string/function/error paths untouched
const Game::Doc::Field kStrlenutf8Args[] = {
Game::Doc::Req("string", "string", "UTF-8 text."),
};
const Game::Doc::Field kStrlenutf8Rets[] = {
Game::Doc::Req("length", "number", "The number of characters; strlen counts bytes."),
};
const Game::Doc::Function kStrlenutf8{
"The number of characters in a UTF-8 string, not the number of bytes.",
kStrlenutf8Args, kStrlenutf8Rets, "LuaGlobals"};
const char *pat = Game::Lua::ToString(L, 2);
if (pat == nullptr)
return engineGsub(L); // let the engine raise its own arg error
const unsigned patLen = Game::Lua::StrLen(L, 2);
const Game::Doc::Field kStrcmputf8iArgs[] = {
Game::Doc::Req("string1", "string"),
Game::Doc::Req("string2", "string"),
};
const Game::Doc::Field kStrcmputf8iRets[] = {
Game::Doc::Req("result", "number",
"Negative when string1 sorts first, 0 when the two match ignoring "
"case, positive when string2 sorts first."),
};
const Game::Doc::Function kStrcmputf8i{
"Compares two UTF-8 strings ignoring case, for sorting and matching.",
kStrcmputf8iArgs, kStrcmputf8iRets, "LuaGlobals"};
Game::Lua::SetTop(L, 4); // normalize to (s, pat, tbl, n) — gsub reads ≤4
const Game::Doc::Field kStrsplittableArgs[] = {
Game::Doc::Req("delimiter", "string", "Each character is a delimiter."),
Game::Doc::Req("str", "string"),
Game::Doc::Opt("pieces", "number", "0",
"Maximum number of pieces; the last one holds the rest of the string. "
"0 is unlimited."),
};
const Game::Doc::Field kStrsplittableRets[] = {
Game::Doc::Req("chunks", "table", "Array of the pieces, in order."),
};
const Game::Doc::Function kStrsplittable{
"Splits a string on delimiter characters and returns the pieces as an array.",
kStrsplittableArgs, kStrsplittableRets, "LuaGlobals"};
if (!PatternHasBackref(pat, patLen)) {
// Build `(body)` with the anchors outside the capture.
unsigned b = 0, e = patLen;
std::string wrapped;
wrapped.reserve(patLen + 3);
if (e > b && pat[0] == '^') {
wrapped += '^';
b = 1;
}
bool anchoredBack = false;
if (e > b && pat[e - 1] == '$') {
// `$` is an anchor only when not %-escaped: an even number of
// `%` immediately before it means the `$` itself is unescaped.
unsigned pc = 0;
for (int idx = static_cast<int>(e) - 2;
idx >= static_cast<int>(b) && pat[idx] == '%'; --idx)
++pc;
anchoredBack = (pc % 2) == 0;
if (anchoredBack)
--e;
}
wrapped += '(';
wrapped.append(pat + b, e - b);
wrapped += ')';
if (anchoredBack)
wrapped += '$';
Game::Lua::PushLString(L, wrapped.data(),
static_cast<unsigned int>(wrapped.size())); // 5
Game::Lua::PushValue(L, 3); // 6: tbl
Game::Lua::PushCClosure(L, &GsubTableLookupWrapped, 1); // 6: closure
Game::Lua::Insert(L, 2); // closure→2: s, closure, pat, tbl, n, wrapped
Game::Lua::Insert(L, 2); // wrapped→2: s, wrapped, closure, pat, tbl, n
Game::Lua::Remove(L, 4); // drop the original pattern
Game::Lua::Remove(L, 4); // drop the table → (s, wrapped, closure, n)
} else {
Game::Lua::PushValue(L, 3); // 5: tbl
Game::Lua::PushCClosure(L, &GsubTableLookupPlain, 1); // 5: closure
Game::Lua::Insert(L, 3); // → s, pat, closure, tbl, n
Game::Lua::Remove(L, 4); // → s, pat, closure, n
}
return engineGsub(L);
}
const Game::Doc::Field kStrconcatArgs[] = {
Game::Doc::Vararg("string", "Strings or numbers."),
};
const Game::Doc::Field kStrconcatRets[] = {
Game::Doc::Req("result", "string"),
};
const Game::Doc::Function kStrconcat{
"Joins every argument into one string with nothing between them.",
kStrconcatArgs, kStrconcatRets, "LuaGlobals"};
void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
Game::Lua::RegisterTableFunction("string", "gsub", &Script_string_gsub);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterTableFunction("string", "byte", &Script_string_byte);
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
Game::Lua::RegisterTableFunction("string", "trim", &Script_strtrim);
Game::Lua::RegisterTableFunction("string", "join", &Script_strjoin);
Game::Lua::RegisterTableFunction("string", "concat", &Script_strconcat, &kStrconcat);
// 4.3.4's compat.lua binds each short global and its string-table twin
// to ONE function by assignment (`gsub = str.gsub`, `strmatch =
// to ONE function by assignment (`gmatch = str.gmatch`, `strmatch =
// str.match`, `strrev = str.reverse`, `str.trim = strtrim`, `str.split =
// strsplit`, `str.join = strjoin`), so `strtrim == string.trim`. A second
// Register* call would mint a second closure over the same C function, so
// the globals alias the table entries by value instead. The string-table
// half is what lets `(" x "):trim()` work and libraries capture it at
// file scope (AceConfigCmd-3.0: `strtrim = string.trim`); the bare `gsub`
// gets the table-replacement upgrade by being the same function.
Game::Lua::RegisterGlobalAlias("gsub", "string", "gsub");
// file scope (AceConfigCmd-3.0: `strtrim = string.trim`). 1.12's own
// compat.lua set `strbyte` from the old string.byte, so it is re-aliased.
Game::Lua::RegisterGlobalAlias("gmatch", "string", "gmatch");
Game::Lua::RegisterGlobalAlias("strmatch", "string", "match");
Game::Lua::RegisterGlobalAlias("strsplit", "string", "split");
Game::Lua::RegisterGlobalAlias("strjoin", "string", "join");
Game::Lua::RegisterGlobalAlias("strtrim", "string", "trim");
Game::Lua::RegisterGlobalAlias("strrev", "string", "reverse");
Game::Lua::RegisterGlobalAlias("strbyte", "string", "byte");
Game::Lua::RegisterGlobalAlias("strconcat", "string", "concat", &kStrconcat);
Game::Lua::RegisterGlobalFunction("strreplace", &Script_strreplace);
Game::Lua::RegisterGlobalFunction("strlenutf8", &Script_strlenutf8, &kStrlenutf8);
Game::Lua::RegisterGlobalFunction("strcmputf8i", &Script_strcmputf8i, &kStrcmputf8i);
Game::Lua::RegisterGlobalFunction("strsplittable", &Script_strsplittable,
&kStrsplittable);
}
// Both states are Lua 5.0 and equally missing these; RegisterTableFunction
+4 -20
View File
@@ -21,7 +21,8 @@
// ITEM x on release: the item named x you carry (equipment, then bags)
// used through FUN_ITEM_USE with the same target.
// MACRO x on release: macro index x when x is all digits, else the macro
// named x, run through FUN_MACRO_RUN_BODY.
// named x, run through FUN_MACRO_RUN_BODY with the button
// "LeftButton", as 4.3.4 runs it.
// CLICK f:b press and release: the frame named f gets a mouse down and a
// mouse up for button b.
//
@@ -46,13 +47,13 @@
#include "item/Arg.h"
#include "item/Location.h"
#include "item/Use.h"
#include "macro/Execute.h"
#include "spell/Placement.h"
#include "time/Clock.h"
#include "unit/Focus.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstdlib>
#include <cstring>
namespace Bindings::Command {
@@ -90,23 +91,6 @@ void UseItem(const char *name) {
Item::Use::UseItem(found.item, ModifiedTarget());
}
void RunMacro(const char *name) {
using SlotToEntry_t = const uint8_t *(__fastcall *)(unsigned slot);
using NameToSlot_t = uint32_t(__fastcall *)(const char *name);
using RunBody_t = void(__fastcall *)(const uint8_t *entry);
const char *digit = name;
while (*digit >= '0' && *digit <= '9')
++digit;
const uint32_t slot =
(*name != '\0' && *digit == '\0')
? static_cast<uint32_t>(std::atoi(name) - 1)
: reinterpret_cast<NameToSlot_t>(Offsets::FUN_MACRO_NAME_TO_SLOT)(name);
const uint8_t *entry =
reinterpret_cast<SlotToEntry_t>(Offsets::FUN_MACRO_SLOT_TO_ENTRY)(slot);
if (entry != nullptr)
reinterpret_cast<RunBody_t>(Offsets::FUN_MACRO_RUN_BODY)(entry);
}
// StringToMouseButton: the click mask for a button name, 0 for any other
// (the click vmethod then names it "UNKNOWN").
uint32_t MaskForButton(const char *button) {
@@ -231,7 +215,7 @@ int RunKey(const char *command, int isDown) {
}
if (HasPrefix(command, kMacro)) {
if (!isDown)
RunMacro(command + sizeof kMacro - 1);
Macro::Execute::Saved(command + sizeof kMacro - 1, "LeftButton");
return 1;
}
return Click(command + sizeof kClick - 1, isDown);
+6 -22
View File
@@ -47,7 +47,9 @@
// section's header follows.
#include "Inject.h"
#include "Common.h"
#include "Offsets.h"
#include "storm/Memory.h"
#include "embed/Resource.h"
#include "embedded_resources.h"
@@ -133,7 +135,7 @@ bool ExtractHeaderAttr(const char *tagStart, const char *tagEnd,
EmbeddedConfig ResolveEmbeddedConfig() {
EmbeddedConfig out = {};
const Embed::Bytes xml = Embed::Resource(Embed::Ids::kBindingsXml);
const Embed::Bytes xml = Embed::File(Embed::Ids::kBindingsXml, 0);
if (xml.data == nullptr) return out;
const char *content = reinterpret_cast<const char *>(xml.data);
const char *end = content + xml.size;
@@ -212,28 +214,12 @@ const char *FindSplicePosition(const char *content, size_t size,
return FindOutsideComment(content, end, "</Bindings>");
}
char NormalizeChar(char c) {
if (c == '/') return '\\';
if (c >= 'A' && c <= 'Z') return static_cast<char>(c + 32);
return c;
}
bool PathEqualsCI(const char *a, const char *b) {
while (*a != '\0' && *b != '\0') {
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
++a; ++b;
}
return *a == *b;
}
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn original) {
if (path == nullptr || !PathEqualsCI(path, kFrameXMLBindingsPath))
if (path == nullptr || !Common::SamePath(path, kFrameXMLBindingsPath))
return false;
// Pull the real file through the original reader with the caller's
@@ -270,10 +256,8 @@ bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
const size_t splicePos = static_cast<size_t>(splice - content);
const size_t newSize = origSize + config.innerSize;
auto SMemAlloc = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEM_ALLOC);
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
void *newBuf = SMemAlloc(newSize + extraBytes, __FILE__, __LINE__, 0);
void *newBuf = Storm::Alloc(newSize + extraBytes, __FILE__, __LINE__);
if (newBuf == nullptr) {
// Allocation failed — fall back to the original buffer so the
// engine still loads its own bindings.
@@ -290,7 +274,7 @@ bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
if (extraBytes > 0)
std::memset(dest + newSize, 0, extraBytes);
SMemFree(origBuf, __FILE__, __LINE__, 0);
Storm::Free(origBuf, __FILE__, __LINE__);
if (outBuf != nullptr) *outBuf = newBuf;
if (outSize != nullptr) *outSize = newSize;
+2 -2
View File
@@ -46,6 +46,7 @@
#include "baselib/Ascii.h"
#include "frame/ClickEvents.h"
#include "input/Modifier.h"
#include "storm/Memory.h"
#include "xml/FrameLoad.h"
#include <cstdint>
@@ -219,8 +220,7 @@ void LoadModifiedClicks(const char *path, void *status) {
return;
void *tree = reinterpret_cast<TreeLoad_t>(Offsets::FUN_XML_TREE_LOAD)(
static_cast<const char *>(data), static_cast<uint32_t>(size));
reinterpret_cast<AddOns::EngineIO::SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE)(data, __FILE__,
__LINE__, 0);
Storm::Free(data, __FILE__, __LINE__);
if (tree == nullptr)
return; // the engine loader reports the parse error
const void *root = Game::Read<const void *>(tree, 0);

Some files were not shown because too many files have changed in this diff Show More