Compare commits

...
109 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
152 changed files with 28734 additions and 2082 deletions
+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
+10 -3
View File
@@ -22,6 +22,7 @@ reference is in **[docs/API.md](docs/API.md)**.
| 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. 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. |
| [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`. |
@@ -88,6 +89,8 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| [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`, `: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) | `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` |
@@ -96,7 +99,7 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| [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`, `GetNumGossipOptions` |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `GetMouseButtonName`, `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.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` |
@@ -112,7 +115,9 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| [Map](docs/API.md#map) | `C_Map.CanSetUserWaypointOnMap`, `C_Map.ClearUserWaypoint`, `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetFallbackWorldMapID`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapArtLayers`, `C_Map.GetMapArtLayerTextures`, `C_Map.GetMapChildrenInfo`, `C_Map.GetMapInfo`, `C_Map.GetMapInfoAtPosition`, `C_Map.GetMapOverlays`, `C_Map.GetMapPosFromWorldPos`, `C_Map.GetMapRectOnMap`, `C_Map.GetMapWorldSize`, `C_Map.GetPlayerMapPosition`, `C_Map.GetUserWaypoint`, `C_Map.GetUserWaypointFromHyperlink`, `C_Map.GetUserWaypointHyperlink`, `C_Map.GetUserWaypointPositionForMap`, `C_Map.GetWorldPosFromMapPos`, `C_Map.HasUserWaypoint`, `C_Map.MapHasArt`, `C_Map.SetUserWaypoint` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredAreaIDsAtPosition`, `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems`, `GetBuybackItemLink` |
| [Model](docs/API.md#model) | `DressUpModel:TryOn` (links, recipes, names, hand, enchant glow, result), `DressUpModel:UndressSlot`, `DressUpModel:IsGeoReady`, `DressUpModel:SetSheathed` / `GetSheathed`, `DressUpModel:SetAutoDress` / `GetAutoDress`, `Model:SetGlow`, `PlayerModel:RefreshCamera`, `PlayerModel:SetCamDistanceScale`, `PlayerModel:SetCreature` (`displayID`), `PlayerModel:SetDisplayInfo`, `PlayerModel:SetItem`, `PlayerModel:SetPortraitZoom`, `PlayerModel:SetRotation` (`animate`), `PlayerModel:SetUnit` (result), `PlayerModel:CanSetUnit`, `PlayerModel:GetDisplayInfo`, `PlayerModel:SetAnimation`, `PlayerModel:FreezeAnimation`, `PlayerModel:HasAnimation`, `PlayerModel:SetDoBlend` / `GetDoBlend`, `PlayerModel:SetKeepModelOnHide` / `GetKeepModelOnHide`, `PlayerModel:ApplySpellVisualKit` |
| [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` |
@@ -161,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` |
@@ -221,7 +227,7 @@ open the game menu and click AddOn Options, then ClassicAPI.
| [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, talk to an NPC, use an object or an item, or open the flight map. |
| | `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. |
@@ -398,6 +404,7 @@ when launching with `-console`), not as Lua functions. See the
| 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>
+2963 -904
View File
File diff suppressed because it is too large Load Diff
+7 -4
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 walk from the
// low end finds genuinely free slots.
// 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();
+13 -5
View File
@@ -30,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) {
+16 -2
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);
+1704 -27
View File
File diff suppressed because it is too large Load Diff
+3 -7
View File
@@ -77,6 +77,7 @@
#include "addons/TocRewrite.h"
#include "bindings/Inject.h"
#include "embed/Resource.h"
#include "storm/Memory.h"
#include <cstdint>
#include <cstdio>
@@ -153,8 +154,6 @@ Embed::Bytes LookupEmbedded(const char *relPath) {
// 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).
@@ -277,8 +276,7 @@ void DecideSource(int addon) {
// 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.
@@ -320,10 +318,8 @@ int ReadAddonFile(int addon, const char *relPath, int unused, const char *path,
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);
void *buf = Storm::Alloc(totalSize, __FILE__, __LINE__);
if (buf == nullptr) return 0;
std::memcpy(buf, entry.data, entry.size);
if (extraBytes > 0) {
+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
+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;
}
+2 -3
View File
@@ -86,6 +86,7 @@
#include "addons/Embedded.h"
#include "addons/EngineIO.h"
#include "addons/Registry.h"
#include "storm/Memory.h"
#include <algorithm>
#include <cstdint>
@@ -235,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;
}
+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;
}
+3 -6
View File
@@ -17,6 +17,7 @@
#include "localization/Locale.h"
#include "Offsets.h"
#include "storm/Memory.h"
#include "turtle/Detect.h"
#include <cstddef>
@@ -28,8 +29,6 @@ namespace AddOns::TocRewrite {
namespace {
using AddOns::EngineIO::SMemAllocFn;
using AddOns::EngineIO::SMemFreeFn;
using AddOns::Toc::EqCI;
using AddOns::Toc::Lower;
@@ -288,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;
}
+2 -2
View File
@@ -61,6 +61,7 @@
#include "Game.h"
#include "Offsets.h"
#include "frame/Layout.h"
#include "frame/Alpha.h"
#include "frame/Destruction.h"
#include "texture/Transform.h"
@@ -115,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);
@@ -216,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;
}
+4
View File
@@ -59,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')
@@ -145,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");
+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
+2 -9
View File
@@ -1023,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
+3 -6
View File
@@ -49,6 +49,7 @@
#include "Inject.h"
#include "Common.h"
#include "Offsets.h"
#include "storm/Memory.h"
#include "embed/Resource.h"
#include "embedded_resources.h"
@@ -213,8 +214,6 @@ const char *FindSplicePosition(const char *content, size_t size,
return FindOutsideComment(content, end, "</Bindings>");
}
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
@@ -257,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.
@@ -277,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);
+20 -6
View File
@@ -72,18 +72,25 @@ struct State {
uint32_t endMs = 0;
bool active = false; // at least one reset has happened this session
bool dirty = false; // needs a PLAYER_SWING fire on the next WorldTick
bool held = false; // the latest reset was recorded but not reported yet
};
State g_swing[3];
// `notify` false records the server's new timer without firing PLAYER_SWING:
// the reset is real, but no swing is coming yet (see OnSpellGo).
// the reset is real, but no swing is coming yet (see OnSpellGo). It stays
// `held` until the attack start reports it or a later reported reset replaces it.
void MarkReset(SwingType type, uint32_t durationMs, bool notify = true) {
if (durationMs == 0)
return; // no weapon for this hand right now -- nothing to report
State &s = g_swing[type];
s.endMs = NowMs() + durationMs;
s.active = true;
s.dirty = s.dirty || notify;
if (notify) {
s.dirty = true;
s.held = false;
} else {
s.held = true;
}
}
using HasAttackTarget_t = int(__fastcall *)(const void *unit);
@@ -410,11 +417,17 @@ void OnPacket(uint32_t opcode, CDataStore *packet) {
auto *playerObj = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (InMeleeRange(playerObj, victimObj)) {
MarkReset(OFF_HAND, AttackTimeMs(OFF_HAND));
// A cast reset made before the attack still holds the
// main-hand swing back; report it now that one is coming.
// A cast reset made before the attack is still held back;
// report it now that a swing is coming. Only a held reset:
// a target switch mid-attack also lands here, and there the
// main-hand swing was never reset -- re-reporting its
// remaining time would read as a fresh, short swing.
State &main = g_swing[MAIN_HAND];
if (main.active && Time::Clock::Remaining(NowMs(), main.endMs) > 0)
main.dirty = true;
if (main.held) {
main.held = false;
if (Time::Clock::Remaining(NowMs(), main.endMs) > 0)
main.dirty = true;
}
}
}
return;
@@ -477,6 +490,7 @@ void OnWorldTick() {
if (!s.dirty)
continue;
s.dirty = false;
s.held = false;
if (slot < 0)
continue;
const uint32_t remaining = Time::Clock::Remaining(now, s.endMs);
-11
View File
@@ -618,17 +618,6 @@ uint32_t SpellSchoolMask(const uint8_t *spell) {
: 0;
}
bool HasAuraEffect(const uint8_t *spell, int auraName) {
if (spell == nullptr)
return false;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
if (Game::Read<int>(spell, Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME + i * 4) ==
auraName)
return true;
}
return false;
}
Entry Begin(uint64_t source, uint64_t dest, int event, const uint8_t *spell) {
Entry e;
e.event = event;
-4
View File
@@ -167,10 +167,6 @@ uint32_t SchoolMask(uint32_t school);
// The spell's school as a mask (4.3.4's SpellRec m_schoolMask), 0 for nullptr.
uint32_t SpellSchoolMask(const uint8_t *spell);
// Whether one of the spell's three effects applies the aura `auraName`
// (EffectApplyAuraName).
bool HasAuraEffect(const uint8_t *spell, int auraName);
// GetCombatLogEntry: an entry for `event` from `source` to `dest` (either may
// be 0), with the spell prefix when `spell` is a record.
Entry Begin(uint64_t source, uint64_t dest, int event, const uint8_t *spell);
+2 -1
View File
@@ -55,6 +55,7 @@
#include "aura/Data.h"
#include "net/PacketDispatch.h"
#include "object/Resolve.h"
#include "spell/Lookup.h"
#include "time/Clock.h"
#include <cstdint>
@@ -501,7 +502,7 @@ const uint8_t *ShieldSpell(uint64_t owner, uint32_t school) {
const uint8_t *spell = SpellRecord(static_cast<int>(spellId));
if (spell == nullptr ||
Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_SCHOOL) != school ||
!HasAuraEffect(spell, AURA_DAMAGE_SHIELD))
!Spell::Lookup::HasAuraEffect(spell, AURA_DAMAGE_SHIELD))
continue;
if (found != nullptr && found != spell)
return nullptr;
+6 -4
View File
@@ -131,10 +131,7 @@ int __fastcall Script_RequestLoadCreatureByID(void *L) {
Game::Lua::PushBool(L, false);
return 1;
}
auto *cache = reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE);
const bool accepted =
Cache::QueryLoad::RequestLoad(cache, static_cast<uint32_t>(creatureID));
Game::Lua::PushBool(L, accepted);
Game::Lua::PushBool(L, RequestLoad(static_cast<uint32_t>(creatureID)));
return 1;
}
@@ -381,4 +378,9 @@ const uint8_t *CacheRecord(uint32_t creatureID) {
return creatureID != 0 ? PeekCreature(creatureID) : nullptr;
}
bool RequestLoad(uint32_t creatureID) {
return Cache::QueryLoad::RequestLoad(reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE),
creatureID);
}
} // namespace Creature::Info
+6
View File
@@ -24,6 +24,12 @@ namespace Creature::Info {
// engine's SetDisplay takes, and `PlayerModel:SetCreature` passes it there.
const uint8_t *CacheRecord(uint32_t creatureID);
// Starts loading `creatureID` into the creature cache; fires
// `CREATURE_DATA_LOAD_RESULT(creatureID, success)` when it lands (at once
// when already cached). False only when the load could not be tracked.
// Backs `C_CreatureInfo.RequestLoadCreatureByID`.
bool RequestLoad(uint32_t creatureID);
// The faction group a player race belongs to, resolved the way
// `Script_UnitFactionGroup` does it for a player whose object isn't in the
// world (its name-cache branch, which reads the race): ChrRaces -> FactionTemplate
+9 -15
View File
@@ -45,6 +45,7 @@
#include "Game.h"
#include "Offsets.h"
#include "frame/Layout.h"
#include "text/InlineTexture.h"
#include <cstdint>
@@ -59,21 +60,17 @@ using IndexAtPoint_t = void(__fastcall *)(void *editBox, void *edx, float x, flo
using FontHeight_t = float(__fastcall *)(void *fs, void *edx, int mode);
using MeasureRun_t = float(__fastcall *)(void *fs, void *edx, const uint8_t *text, int len);
using PixelAlignY_t = float(__stdcall *)(float length);
using GetRect_t = int(__fastcall *)(void *anchor, void *edx, float *out);
using ClearAllPoints_t = void(__fastcall *)(void *anchor);
using SetPoint_t = void(__thiscall *)(void *anchor, int point, void *relAnchor, int relPoint, float x, float y,
int flag);
using Resize_t = void(__fastcall *)(void *anchor, void *edx, int force);
using Extent_t = float(__fastcall *)(void *anchor, void *edx);
using ScreenAspect_t = float(__cdecl *)();
using DivideByScreenScaleX_t = float(__stdcall *)(float value);
using FireVariadic_t = void(__cdecl *)(void *frame, uint32_t *slotPtr, const char *fmt, ...);
PlaceCaret_t g_origPlaceCaret = nullptr;
PlaceSelectionPiece_t g_origPlaceSelectionPiece = nullptr;
IndexAtPoint_t g_origIndexAtPoint = nullptr;
void *AnchorOf(void *region) { return static_cast<uint8_t *>(region) + Offsets::OFF_REGION_ANCHOR; }
using Frame::Layout::Of;
// A multi-line box's rows: its label, the buffer the line table indexes (the
// '*' copy of a password box) and the table — each line's first byte, the
@@ -179,9 +176,9 @@ bool AnchorX(const Rows &rows, int line, uint32_t pos, float &x, int &relPoint)
// Anchors `region` as the placer did, at `y`.
void Anchor(const Rows &rows, void *region, int relPoint, float x, float y, int flag) {
void *anchor = AnchorOf(region);
void *anchor = Of(region);
reinterpret_cast<ClearAllPoints_t>(Offsets::FUN_LAYOUT_CLEAR_ALL_POINTS)(anchor);
reinterpret_cast<SetPoint_t>(Offsets::FUN_REGION_SET_POINT)(anchor, Offsets::FRAMEPOINT_TOPLEFT, AnchorOf(rows.fs),
reinterpret_cast<SetPoint_t>(Offsets::FUN_REGION_SET_POINT)(anchor, Offsets::FRAMEPOINT_TOPLEFT, Of(rows.fs),
relPoint, x, y, flag);
}
@@ -192,7 +189,7 @@ void __fastcall IndexAtPoint_h(void *editBox, void *edx, float x, float y, int *
if (!MultiLineRows(editBox, rows))
return g_origIndexAtPoint(editBox, edx, x, y, index);
float rect[4] = {0.0f, 0.0f, 0.0f, 0.0f}; // bottom, left, top, right
if (reinterpret_cast<GetRect_t>(Offsets::FUN_REGION_GET_RECT)(AnchorOf(rows.fs), nullptr, rect) == 0)
if (!Frame::Layout::GetRect(rows.fs, rect))
return g_origIndexAtPoint(editBox, edx, x, y, index);
// The engine walks rows down from the label's top while the click is
// below the row (strictly), stopping at the last line. Walk the rows as
@@ -240,7 +237,7 @@ void __fastcall PlaceSelectionPiece_h(void *editBox, void *edx, void *piece, int
return;
const float y = -(RowHeight(rows) / rows.scale) * static_cast<float>(line) - shift;
Anchor(rows, piece, relPoint, x, y, 0);
reinterpret_cast<Resize_t>(Offsets::FUN_LAYOUT_RESIZE)(AnchorOf(piece), nullptr, 1);
Frame::Layout::Resize(piece, true);
}
// --- the caret ----------------------------------------------------------------
@@ -276,15 +273,12 @@ void __fastcall PlaceCaret_h(void *editBox) {
return;
// OnCursorChanged(x, y, width, height) as the engine fires it: each
// value by the anchor→script factor, pushed as a double.
void *anchor = AnchorOf(caret);
void *anchor = Of(caret);
void **vtable = *static_cast<void ***>(anchor);
const float width = reinterpret_cast<Extent_t>(vtable[7])(anchor, nullptr); // +0x1C GetWidth
const float height = reinterpret_cast<Extent_t>(vtable[8])(anchor, nullptr); // +0x20 GetHeight
const auto aspect = reinterpret_cast<ScreenAspect_t>(Offsets::FUN_COORD_GET_SCALED_SCREEN_ASPECT);
const auto divide = reinterpret_cast<DivideByScreenScaleX_t>(Offsets::FUN_COORD_DIVIDE_BY_SCREEN_SCALE_X);
const float factor = Game::Read<float>(Offsets::FLOAT_SCRIPT_COORD_SCALE);
const auto push = [&](float value) { return static_cast<double>(divide(aspect() * factor * value)); };
const double args[4] = {push(x), push(y), push(width), push(height)};
const double args[4] = {Game::InternalToPixel(x), Game::InternalToPixel(y), Game::InternalToPixel(width),
Game::InternalToPixel(height)};
void *L = Game::Lua::State();
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
reinterpret_cast<FireVariadic_t>(Offsets::FUN_FRAME_RUN_SCRIPT_VARIADIC)(
@@ -144,6 +144,17 @@ FrameXML\ColorSwatch.lua
FrameXML\ColorSwatch.xml
FrameXML\ScriptErrorsFrame.lua
FrameXML\ScriptErrorsFrame.xml
FrameXML\ModelScene\CameraRegistry.lua
FrameXML\ModelScene\CameraBaseMixin.lua
FrameXML\ModelScene\OrbitCameraMixin.lua
FrameXML\ModelScene\FollowCameraMixin.lua
FrameXML\ModelScene\ModelSceneMixin.lua
FrameXML\ModelScene\ModelSceneMixin.xml
FrameXML\ModelScene\ModelSceneActorMixin.lua
FrameXML\ModelScene\ModelSceneActorMixin.xml
FrameXML\ModelScene\ModelSceneControlFrame.lua
FrameXML\ModelScene\ModelSceneControlFrame.xml
FrameXML\DressUpFrame.xml
FrameXML\DressUpFrame.lua
Menu\MenuConstants.lua
+19
View File
@@ -24,3 +24,22 @@ end
function C_AutoComplete.GetAutoCompleteRealms()
return {}
end
-- C_ModelInfo's getters come from the DLL. Retail's active-scene bookkeeping
-- "does nothing in public clients" (its own documentation).
function C_ModelInfo.AddActiveModelScene(modelSceneFrame, modelSceneID)
end
function C_ModelInfo.AddActiveModelSceneActor(modelSceneFrameActor, modelSceneActorID)
end
function C_ModelInfo.ClearActiveModelScene(modelSceneFrame)
end
function C_ModelInfo.ClearActiveModelSceneActor(modelSceneFrameActor)
end
-- This client has no alternate player forms.
function C_PlayerInfo.GetAlternateFormInfo()
return false, false
end
@@ -10,10 +10,203 @@
-- - An uncached item skips the IsDressableItem test, which reads only the
-- cache. 1.12.1 opens no tooltip before a chat link's Control-click, so
-- the test would drop most chat links; TryOn loads the item instead.
-- - An item the model cannot wear goes on to DressUpMountLink, retail's
-- next step in DressUpLink: 1.12.1's FrameXML (ItemRef, bags, loot, the
-- auction house) calls DressUpItemLink alone.
-- - It returns true when it showed the item or its mount, and false for a
-- link of another type, as retail's does.
function DressUpItemLink(link)
if ( not link or (C_Item.IsItemDataCachedByID(link) and not IsDressableItem(link)) ) then
return;
-- A link DressUpItemLink takes: an item link, or anything that is not a
-- link (an item ID or name).
local function IsItemLink(link)
local linkType = type(link) == "string" and LinkUtil.ExtractLink(link);
return not linkType or linkType == "item";
end
-- 4.3.4's DressUpItemLink body: the item, when the model can wear it.
local function DressUpWearable(link)
if ( C_Item.IsItemDataCachedByID(link) and not IsDressableItem(link) ) then
return false;
end
DressUpItem(link);
return true;
end
function DressUpItemLink(link)
if ( not link or not IsItemLink(link) ) then
return false;
end
return DressUpWearable(link) or DressUpMountLink(link);
end
-- Retail's DressUpLink (Mainline/DressUpFrames.lua), without battle pets.
function DressUpLink(link, forcedFrame)
return link and ((IsItemLink(link) and DressUpWearable(link)) or DressUpMountLink(link, forcedFrame));
end
-- Retail's mount preview (Mainline/DressUpFrames.lua DressUpMountLink and
-- DressUpMount, Blizzard_SharedXMLGame/DressUpModelFrameMixin.lua's mode and
-- last link) on 1.12.1's dressing room. Mount mode shows DressUpFrameModelScene
-- in place of DressUpModel and hides the Reset button, as retail's SetMode
-- hides it outside player mode; DressUpItem goes back to player mode.
--
-- Differences from the retail source:
-- - DressUpFrame keeps its own background (retail switches to the warrior
-- one) and has no view insets to set (retail sets them to 0, the default).
-- - There are no mount links, so a link never sets the model itself, and
-- shouldSetModelFromHyperlink changes nothing.
-- - A mount whose creature is not cached yet returns false, as retail does
-- with no display, and opens when CREATURE_DATA_LOAD_RESULT reports the
-- creature: retail has every display on disk, this client asks the server.
DressUpFrame.ModelScene = DressUpFrameModelScene;
DressUpFrame.ModelScene.ControlFrame:SetModelScene(DressUpFrame.ModelScene);
function DressUpFrame:GetLastLink()
return self.lastLink;
end
function DressUpFrame:SetLastLink(link)
self.lastLink = link;
end
function DressUpFrame:GetMode()
return self.mode;
end
function DressUpFrame:SetMode(mode)
self.mode = mode;
local inPlayerMode = mode ~= "mount";
DressUpModel:SetShown(inPlayerMode);
DressUpFrameResetButton:SetShown(inPlayerMode);
self.ModelScene:SetShown(not inPlayerMode);
end
DressUpFrame.ModelScene:SetResetCallback(function()
local link = DressUpFrame:GetLastLink();
if link then
DressUpLink(link, DressUpFrame);
end
end);
-- The stock DressUpItem sets the unit only when it opens the window, and the
-- model was hidden while a mount showed.
local originalDressUpItem = DressUpItem;
function DressUpItem(item)
if ( DressUpFrame:GetMode() == "mount" ) then
DressUpFrame:SetMode("player");
DressUpFrame:SetLastLink(nil);
if ( DressUpFrame:IsVisible() ) then
DressUpModel:SetUnit("player");
end
end
return originalDressUpItem(item);
end
function DressUpMountLink(link, forcedFrame)
if( link ) then
local mountID = 0;
local linkType, linkOptions = LinkUtil.ExtractLink(link);
if linkType == "item" then
local linkID = LinkUtil.SplitLinkOptions(linkOptions);
mountID = C_MountJournal.GetMountFromItem(tonumber(linkID));
elseif linkType == "spell" then
local linkID = LinkUtil.SplitLinkOptions(linkOptions);
mountID = C_MountJournal.GetMountFromSpell(tonumber(linkID));
end
if ( mountID ) then
return DressUpMount(mountID, forcedFrame, false, link);
end
end
return false
end
local pendingMount;
-- The creature GetMountInfoExtraByID loads for a mount: a mount ID is its
-- spell ID, and the mount aura's misc value is the creature.
local SPELL_AURA_MOUNTED = 78;
local function MountCreatureID(mountID)
local effects = C_Spell.GetSpellEffectInfo(mountID);
if ( effects ) then
for _, effect in ipairs(effects) do
if ( effect.auraName == SPELL_AURA_MOUNTED ) then
return effect.miscValue;
end
end
end
end
function DressUpMount(mountID, forcedFrame, shouldSetModelFromHyperlink, link)
if ( not mountID or mountID == 0 ) then
return false;
end
local creatureDisplayID, _, _, isSelfMount, _, modelSceneID, animID, spellVisualKitID, disablePlayerMountPreview = C_MountJournal.GetMountInfoExtraByID(mountID);
if ( not creatureDisplayID ) then
pendingMount = { mountID = mountID, creatureID = MountCreatureID(mountID), forcedFrame = forcedFrame, link = link };
return false;
end
pendingMount = nil;
local frame = forcedFrame or DressUpFrame;
--Show the frame
if ( not frame:IsShown() or frame:GetMode() ~= "mount" ) then
frame:SetMode("mount");
ShowUIPanel(frame);
end
frame:SetLastLink(link);
frame.ModelScene:ClearScene();
local forceEvenIfSame = true;
frame.ModelScene:TransitionToModelSceneID(modelSceneID, CAMERA_TRANSITION_TYPE_IMMEDIATE, CAMERA_MODIFICATION_TYPE_DISCARD, forceEvenIfSame);
local mountActor = frame.ModelScene:GetActorByTag("unwrapped");
if mountActor then
mountActor:SetModelByCreatureDisplayID(creatureDisplayID);
-- mount self idle animation
if (isSelfMount) then
mountActor:SetAnimationBlendOperation(Enum.ModelBlendOperation.None);
mountActor:SetAnimation(618); -- MountSelfIdle
else
mountActor:SetAnimationBlendOperation(Enum.ModelBlendOperation.Anim);
mountActor:SetAnimation(0);
end
frame.ModelScene:AttachPlayerToMount(mountActor, animID, isSelfMount, disablePlayerMountPreview);
end
return true;
end
local creatureLoadFrame = CreateFrame("Frame");
creatureLoadFrame:RegisterEvent("CREATURE_DATA_LOAD_RESULT");
creatureLoadFrame:SetScript("OnEvent", function(self, event, creatureID, success)
local pending = pendingMount;
if ( not pending or creatureID ~= pending.creatureID ) then
return;
end
pendingMount = nil;
if ( success ) then
DressUpMount(pending.mountID, pending.forcedFrame, false, pending.link);
end
end);
-- Control-click on a mount in the spellbook previews it. Retail has no such
-- click (its mounts are in the mount journal); this client keeps mounts in
-- the spellbook, where stock 1.12.1 casts on a Control-click. A mount spell
-- never casts here, even while its creature loads; any other spell does.
local originalSpellButtonOnClick = SpellButton_OnClick;
function SpellButton_OnClick(drag)
if ( not drag and IsControlKeyDown() and SpellBookFrame.bookType ~= BOOKTYPE_PET ) then
local id = SpellBook_GetSpellID(this:GetID());
local link = id <= MAX_SPELLS and GetSpellLink(id, SpellBookFrame.bookType);
if ( link and C_MountJournal.GetMountFromSpell(link) ) then
this:SetChecked("false");
DressUpMountLink(link);
return;
end
end
return originalSpellButtonOnClick(drag);
end
@@ -0,0 +1,27 @@
<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">
<!-- The dressing room's mount mode (DressUpFrame.lua): a scene shown in
place of DressUpModel, with retail's control frame. Like retail's, it
fills the backdrop above the buttons: the four DressUpBackground
textures, cut off at the top of the Reset / Close row (centred 422
below the frame top, 22 tall, so 48 above the backdrop's bottom).
DressUpModel's own rect is no guide: it is a scale 2 frame that
reaches past the backdrop and over the buttons. -->
<ModelScene name="DressUpFrameModelScene" parent="DressUpFrame" inherits="ModelSceneMixinTemplate" hidden="true">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="DressUpBackgroundTopLeft"/>
<Anchor point="BOTTOMRIGHT" relativeTo="DressUpBackgroundBotRight">
<Offset>
<AbsDimension x="0" y="48"/>
</Offset>
</Anchor>
</Anchors>
<Frames>
<Frame parentKey="ControlFrame" inherits="ModelSceneControlFrameTemplate">
<Anchors>
<Anchor point="TOP" x="0" y="-10"/>
</Anchors>
</Frame>
</Frames>
</ModelScene>
</Ui>
@@ -0,0 +1,103 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Shared/ModelSceneCameras/CameraBaseMixin.lua.
CameraBaseMixin = {};
function CameraBaseMixin:SetOwningScene(owningScene)
if self.owningScene ~= owningScene then
if self.owningScene then
self:OnRemoved();
end
self.owningScene = owningScene;
if self.owningScene then
self:OnAdded();
end
end
end
function CameraBaseMixin:GetOwningScene()
return self.owningScene;
end
CAMERA_TRANSITION_TYPE_IMMEDIATE = 1; -- Switch to the camera view instantly
CAMERA_TRANSITION_TYPE_INTERPOLATION = 2; -- Switch to the camera view by interpolating from the current view
CAMERA_MODIFICATION_TYPE_DISCARD = 1; -- Discard any modifications performed to the view by script
CAMERA_MODIFICATION_TYPE_MAINTAIN = 2; -- Retain any modifications performed to the view by script
-- Override this to handle transitions to a modelSceneCamera data set
function CameraBaseMixin:ApplyFromModelSceneCameraInfo(modelSceneCameraInfo, transitionType, modificationType)
end
-- Common accessors from the owning scene
function CameraBaseMixin:IsLeftMouseButtonDown()
return self:GetOwningScene():IsLeftMouseButtonDown();
end
function CameraBaseMixin:IsRightMouseButtonDown()
return self:GetOwningScene():IsRightMouseButtonDown();
end
function CameraBaseMixin:SetPosition(x, y, z)
self:GetOwningScene():SetCameraPosition(x, y, z);
end
function CameraBaseMixin:GetPosition()
return self:GetOwningScene():GetCameraPosition();
end
function CameraBaseMixin:GetForwardVector()
return self:GetOwningScene():GetCameraForward();
end
function CameraBaseMixin:GetRightVector()
return self:GetOwningScene():GetCameraRight();
end
function CameraBaseMixin:GetUpVector()
return self:GetOwningScene():GetCameraUp();
end
-- Override this with a uniquely identifying name for the camera type
function CameraBaseMixin:GetCameraType()
return "UnknownCamera";
end
-- Override this to perform any per-frame updates
function CameraBaseMixin:OnUpdate(elapsed)
end
-- Override this with code to synchronize the camera state with the model scene API, may be called by the ModelScene to synchronize deferred camera state in case it needs to be queried
function CameraBaseMixin:SynchronizeCamera()
end
-- Override this to be notified when the mouse is down on the model scene
function CameraBaseMixin:OnMouseDown(button)
end
-- Override this to be notified when the mouse is up on the model scene
function CameraBaseMixin:OnMouseUp(button)
end
-- Override this to be notified when the mouse wheel is changed on the model scene
function CameraBaseMixin:OnMouseWheel(delta)
end
-- Override this to be notified when the camera is added to a scene
function CameraBaseMixin:OnAdded()
end
-- Override this to be notified when the camera is remove from a scene
function CameraBaseMixin:OnRemoved()
end
-- Override this to be notified when the camera becomes activated
function CameraBaseMixin:OnActivated()
end
-- Override this to be notified when the camera becomes deactivated
function CameraBaseMixin:OnDeactivated()
end
@@ -0,0 +1,18 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Shared/ModelSceneCameras/CameraRegistry.lua.
CameraRegistry = { cameraTypeToFactoryFunction = {}, };
function CameraRegistry:AddCameraFactory(cameraTypeName, factoryFunction)
self.cameraTypeToFactoryFunction[cameraTypeName] = factoryFunction;
end
function CameraRegistry:AddCameraFactoryFromMixin(cameraTypeName, mixin)
self:AddCameraFactory(cameraTypeName, function() return CreateFromMixins(mixin); end);
end
function CameraRegistry:CreateCameraByType(cameraTypeName)
if self.cameraTypeToFactoryFunction[cameraTypeName] then
return self.cameraTypeToFactoryFunction[cameraTypeName]();
end
end
@@ -0,0 +1,133 @@
-- ClassicAPI extension: an orbit camera that follows an actor and stops in
-- front of the WMO between the actor and the eye.
--[[
FollowCameraMixin - An OrbitCamera whose target is an actor's pivot: its position, raised by the pivot height.
The pivot height is the one the world's follow camera eases to for a unit (actor:GetCameraPivotHeight), and
it eases the same way: the camera's first height is taken at once (BeginHeightTransition 0x00512790), and
each new one is armed (ArmHeight 0x005126B0) for |change| / cameraHeightSmoothSpeed seconds and eased with
the camera's cosine curve (0x0050F160). Until the actor's model has loaded, the camera waits, as the world's
does (0x0050E900).
The eye is placed the way the world's follow camera places it (the boom sweep of its solver 0x0050E570):
a trace of the camera's faces from the pivot to the eye (0x0050E816) shortens the distance, which then
backs off 1/9 yard (0x0050E871 to 0x0050E8A4). With no actor to follow, it is a plain OrbitCamera.
Create it beside the scene's own camera and give it that camera's settings:
local _, cameraIDs = C_ModelInfo.GetModelSceneInfoByID(modelSceneID);
local camera = scene:AddCamera(CameraRegistry:CreateCameraByType("FollowCamera"));
camera:ApplyFromModelSceneCameraInfo(C_ModelInfo.GetModelSceneCameraInfoByID(cameraIDs[1]), CAMERA_TRANSITION_TYPE_IMMEDIATE, CAMERA_MODIFICATION_TYPE_DISCARD);
camera:SetFollowActor(actor);
scene:SetActiveCamera(camera);
]]
FollowCameraMixin = CreateFromMixins(OrbitCameraMixin);
local CAMERA_NAME = "FollowCamera";
CameraRegistry:AddCameraFactoryFromMixin(CAMERA_NAME, FollowCameraMixin);
-- The solver skips the sweep when the distance is not past the near clip by
-- more than this (0x008026BC, 2^-20).
local SWEEP_MIN_DISTANCE = 9.5367431640625e-07;
-- A distance shortened by more than this (0x008029D4, 2^-22) backs off by
-- BOOM_BACKOFF (0x00808A00, 1/9 yard), to no less than 0.
local SHORTENED_EPSILON = 2.384185791015625e-07;
local BOOM_BACKOFF = 1 / 9;
-- A height within this of the armed or the live one arms nothing (0x00801360).
local ARM_EPSILON = 0.001;
-- The ease runs while the live height is this far or more from its target
-- (0x008029D4, 2^-22).
local EASE_EPSILON = 2.384185791015625e-07;
-- "public" functions
function FollowCameraMixin:GetCameraType() -- override
return CAMERA_NAME;
end
-- The target becomes the actor's pivot. Nil stops following.
function FollowCameraMixin:SetFollowActor(actor)
self.followActor = actor;
end
function FollowCameraMixin:GetFollowActor()
return self.followActor;
end
-- "private" functions
-- ArmHeight (0x005126B0) with no delay and a factor of 1, and its inner
-- BeginHeightTransition (0x00512790).
function FollowCameraMixin:ArmPivotHeight(height, now)
local live = self.pivotHeight or 0;
if self.pivotEasing and math.abs(self.pivotTarget - height) < ARM_EPSILON then
return;
end
if math.abs(live - height) < ARM_EPSILON then
return;
end
self.pivotTarget = height;
if not self.pivotSnapped then
self.pivotSnapped = true;
self.pivotHeight = height;
self.pivotEasing = false;
return;
end
self.pivotFrom = live;
self.pivotStart = now;
self.pivotDuration = math.abs(height - live) / tonumber(GetCVar("cameraHeightSmoothSpeed"));
self.pivotEasing = true;
end
-- The height channel of the camera's ease (0x0050F160).
function FollowCameraMixin:EasePivotHeight(now)
if math.abs(self.pivotTarget - self.pivotHeight) < EASE_EPSILON then
self.pivotEasing = false;
return;
end
if not self.pivotEasing or self.pivotStart > now then
return;
end
local t = (now - self.pivotStart) / self.pivotDuration;
if t >= 1 then
self.pivotHeight = self.pivotTarget;
else
self.pivotHeight = self.pivotFrom + (self.pivotTarget - self.pivotFrom) * (1 - math.cos(math.pi * t)) * 0.5;
end
end
function FollowCameraMixin:OnUpdate(elapsed) -- override
local actor = self.followActor;
if actor then
local height = actor:GetCameraPivotHeight(self:GetZoomDistance());
if not height then
return;
end
local now = GetTime();
self:ArmPivotHeight(height, now);
self:EasePivotHeight(now);
local x, y, z = actor:GetPosition();
self:SetTarget(x, y, z + self.pivotHeight);
end
OrbitCameraMixin.OnUpdate(self, elapsed);
end
function FollowCameraMixin:CalculatePositionByDistanceFromTarget(targetX, targetY, targetZ, zoomDistance, axisAngleX, axisAngleY, axisAngleZ) -- override
if not self.followActor then
return OrbitCameraMixin.CalculatePositionByDistanceFromTarget(self, targetX, targetY, targetZ, zoomDistance, axisAngleX, axisAngleY, axisAngleZ);
end
local scene = self:GetOwningScene();
local distance = zoomDistance;
if distance - scene:GetCameraNearClip() > SWEEP_MIN_DISTANCE then
local eyeX, eyeY, eyeZ = OrbitCameraMixin.CalculatePositionByDistanceFromTarget(self, targetX, targetY, targetZ, distance, axisAngleX, axisAngleY, axisAngleZ);
local _, _, _, _, _, _, fraction = scene:TraceSegment(targetX, targetY, targetZ, eyeX, eyeY, eyeZ, true);
if fraction then
distance = distance * fraction;
end
if math.abs(zoomDistance - distance) > SHORTENED_EPSILON then
distance = math.max(distance - BOOM_BACKOFF, 0);
end
end
return OrbitCameraMixin.CalculatePositionByDistanceFromTarget(self, targetX, targetY, targetZ, distance, axisAngleX, axisAngleY, axisAngleZ);
end
@@ -0,0 +1,144 @@
-- Verbatim backport of retail's Blizzard_SharedXML/ModelSceneActorMixin.lua.
ModelSceneActorMixin = {};
-- "public" methods
function ModelSceneActorMixin:ApplyFromModelSceneActorInfo(actorInfo)
self:SetUseCenterForOrigin(actorInfo.useCenterForOriginX, actorInfo.useCenterForOriginY, actorInfo.useCenterForOriginZ);
self:SetPosition(actorInfo.position:GetXYZ());
self:SetYaw(actorInfo.yaw);
self:SetPitch(actorInfo.pitch);
self:SetRoll(actorInfo.roll);
self.requestedScale = nil;
local actorDisplayInfo = actorInfo.modelActorDisplayID and C_ModelInfo.GetModelSceneActorDisplayInfoByID(actorInfo.modelActorDisplayID);
if actorDisplayInfo then
if actorDisplayInfo.animationKitID then
self:PlayAnimationKit(actorDisplayInfo.animationKitID);
else
self:SetAnimation(actorDisplayInfo.animation, actorDisplayInfo.animationVariation, actorDisplayInfo.animSpeed);
end
self:SetSpellVisualKit(actorDisplayInfo.spellVisualKitID);
self:SetAlpha(actorDisplayInfo.alpha);
self:SetRequestedScale(actorDisplayInfo.scale);
else
self:StopAnimationKit();
self:SetSpellVisualKit(nil);
self:SetAnimation(0, 0, 1.0);
self:SetAlpha(1.0);
self:SetRequestedScale(1.0);
end
self:SetNormalizedScaleAggressiveness(actorInfo.normalizeScaleAggressiveness or 0.0);
self:MarkScaleDirty();
self:UpdateScale();
C_ModelInfo.AddActiveModelSceneActor(self, actorInfo.modelActorID);
end
function ModelSceneActorMixin:OnModelCleared()
C_ModelInfo.ClearActiveModelSceneActor(self);
end
function ModelSceneActorMixin:SetOnSizeChangedCallback(onSizeChangedCallback)
self.onSizeChangedCallback = onSizeChangedCallback;
end
function ModelSceneActorMixin:GetOnSizeChangedCallback()
return self.onSizeChangedCallback;
end
function ModelSceneActorMixin:SetOnModelLoadedCallback(onModelLoadedCallback)
self.onModelLoadedCallback = onModelLoadedCallback;
end
function ModelSceneActorMixin:GetOnModelLoadedCallback()
return self.onModelLoadedCallback;
end
function ModelSceneActorMixin:SetNormalizedScaleAggressiveness(normalizedScaleAggressiveness)
if self.normalizedScaleAggressiveness ~= normalizedScaleAggressiveness then
self.normalizedScaleAggressiveness = normalizedScaleAggressiveness;
self:MarkScaleDirty();
end
end
function ModelSceneActorMixin:GetNormalizedScaleAggressiveness()
return self.normalizedScaleAggressiveness or 0;
end
function ModelSceneActorMixin:SetRequestedScale(requestedScale)
if self.requestedScale ~= requestedScale then
self.requestedScale = requestedScale;
self:MarkScaleDirty();
end
end
function ModelSceneActorMixin:GetRequestedScale()
return self.requestedScale or self:GetScale();
end
MODEL_SCENE_ACTOR_DIMENSIONS_FOR_NORMALIZATION = { width = .9984, depth = 1.121, height = 2.118 }; -- Roughly the dimensions of a human male model (but why male models?)
function ModelSceneActorMixin:CalculateNormalizedScale(normalizedScaleAggressiveness)
if self:IsLoaded() and normalizedScaleAggressiveness > 0.0 then
local bottomX, bottomY, bottomZ, topX, topY, topZ = self:GetActiveBoundingBox(); -- Could be nil for invisible models
if bottomX and bottomY and bottomZ and topX and topY and topZ then
local width = topX - bottomX;
local depth = topY - bottomY;
local height = topZ - bottomZ;
local widthScale = width / MODEL_SCENE_ACTOR_DIMENSIONS_FOR_NORMALIZATION.width;
local depthScale = depth / MODEL_SCENE_ACTOR_DIMENSIONS_FOR_NORMALIZATION.depth;
local heightScale = height / MODEL_SCENE_ACTOR_DIMENSIONS_FOR_NORMALIZATION.height;
local maxScale = math.max(widthScale, math.max(depthScale, heightScale));
if maxScale == 0 then
return 1.0;
end
return 1 / Lerp(1.0, maxScale, normalizedScaleAggressiveness);
end
end
return 1.0;
end
-- "private" methods
function ModelSceneActorMixin:OnReleased()
self:SetOnSizeChangedCallback(nil);
self:SetOnModelLoadedCallback(nil);
end
function ModelSceneActorMixin:OnUpdate()
self:UpdateScale();
end
function ModelSceneActorMixin:OnModelLoaded()
self:MarkScaleDirty();
if self.onModelLoadedCallback then
self.onModelLoadedCallback(self);
end
end
function ModelSceneActorMixin:UpdateScale()
if self.scaleDirty and self:IsLoaded() then
self.scaleDirty = nil;
local effectiveScale = self:GetRequestedScale() * self:CalculateNormalizedScale(self:GetNormalizedScaleAggressiveness());
self:SetScale(effectiveScale);
if self.onSizeChangedCallback then
self.onSizeChangedCallback(self);
end
end
end
function ModelSceneActorMixin:MarkScaleDirty()
self.scaleDirty = true;
end
@@ -0,0 +1,10 @@
<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/
UI.xsd">
<Actor name="ModelSceneActorTemplate" mixin="ModelSceneActorMixin" virtual="true">
<Scripts>
<OnModelCleared method="OnModelCleared" />
<OnModelLoaded method="OnModelLoaded" />
<OnUpdate method="OnUpdate" />
</Scripts>
</Actor>
</Ui>
@@ -0,0 +1,282 @@
-- Verbatim backport of retail's Blizzard_SharedXML/ModelSceneControlFrame.lua.
local DEFAULT_ZOOM_INCREMENT = 1;
local DEFAULT_ROTATE_INCREMENT = 0.05;
--------------------------------------------------
-- MODEL SCENE CONTROL FRAME MIXIN
ModelSceneControlFrameMixin = {};
function ModelSceneControlFrameMixin:OnLoad()
if self.enableZoom then
local increment = self:GetZoomIncrement();
self.zoomInButton:SetZoomAmount(increment);
self.zoomInButton:Init();
self.zoomOutButton:SetZoomAmount(-increment);
self.zoomOutButton:Init();
end
self.zoomInButton:SetShown(self.enableZoom);
self.zoomOutButton:SetShown(self.enableZoom);
if self.enableRotate then
local increment = self:GetRotateIncrement();
self.rotateLeftButton:SetRotation("left");
self.rotateLeftButton:SetRotationIncrement(increment);
self.rotateLeftButton:Init();
self.rotateRightButton:SetRotation("right");
self.rotateRightButton:SetRotationIncrement(increment);
self.rotateRightButton:Init();
end
self.rotateLeftButton:SetShown(self.enableRotate);
self.rotateRightButton:SetShown(self.enableRotate);
if self.enableReset then
self.resetButton:Init();
end
self.resetButton:SetShown(self.enableReset);
end
function ModelSceneControlFrameMixin:OnShow()
self:UpdateLayout();
end
function ModelSceneControlFrameMixin:OnEnter()
self:SetAlpha(1);
end
function ModelSceneControlFrameMixin:OnLeave()
self:SetAlpha(0.5);
end
function ModelSceneControlFrameMixin:SetZoomIncrement(increment)
self.zoomIncrement = increment or DEFAULT_ZOOM_INCREMENT;
end
function ModelSceneControlFrameMixin:GetZoomIncrement()
return self.zoomIncrement or DEFAULT_ZOOM_INCREMENT;
end
function ModelSceneControlFrameMixin:SetRotateIncrement(increment)
self.rotationIncrement = increment or DEFAULT_ROTATE_INCREMENT;
end
function ModelSceneControlFrameMixin:GetRotateIncrement()
return self.rotationIncrement or DEFAULT_ROTATE_INCREMENT;
end
function ModelSceneControlFrameMixin:SetModelScene(modelScene)
self.modelScene = modelScene;
self.zoomInButton:SetModelScene(modelScene);
self.zoomOutButton:SetModelScene(modelScene);
self.rotateLeftButton:SetModelScene(modelScene);
self.rotateRightButton:SetModelScene(modelScene);
self.resetButton:SetModelScene(modelScene);
end
function ModelSceneControlFrameMixin:UpdateLayout()
local lastButton;
local hPadding = self.buttonHorizontalPadding or 0;
local vPadding = 0;
local buttonSize = 32;
local totalHeight = buttonSize;
local totalWidth = 2; -- Initial non-zero total width to prevent flickering from negataive padding
local function LayoutButton(button)
button:ClearAllPoints();
if lastButton then
button:SetPoint("LEFT", lastButton, "RIGHT", hPadding, vPadding);
else
button:SetPoint("LEFT", 0, vPadding);
end
totalWidth = totalWidth + buttonSize + hPadding;
lastButton = button;
end
if self.enableZoom then
-- Make sure that zoom is supported on the model scene.
if self.modelScene then
local camera = self.modelScene:GetActiveCamera();
self.canZoom = camera ~= nil and camera:GetZoomAvailable();
self.zoomInButton:SetShown(self.canZoom);
self.zoomOutButton:SetShown(self.canZoom);
end
if self.canZoom then
LayoutButton(self.zoomInButton);
LayoutButton(self.zoomOutButton);
end
end
if self.enableRotate then
LayoutButton(self.rotateLeftButton);
LayoutButton(self.rotateRightButton);
end
if self.enableReset then
LayoutButton(self.resetButton);
end
self:SetSize(totalWidth, totalHeight);
end
--------------------------------------------------
-- MODEL SCENE CONTROL BUTTON MIXIN
ModelSceneControlButtonMixin = {};
function ModelSceneControlButtonMixin:Init(clickTypes, atlas, tooltip, tooltipText)
self:RegisterForClicks(clickTypes);
if atlas then
self.Icon:SetAtlas(atlas);
end
self.tooltip = tooltip;
self.tooltipText = tooltipText;
end
function ModelSceneControlButtonMixin:SetModelScene(modelScene)
self.modelScene = modelScene;
end
function ModelSceneControlButtonMixin:OnMouseDown()
self.Icon:AdjustPointsOffset(1, -1);
self:GetParent().buttonDown = self;
end
function ModelSceneControlButtonMixin:OnMouseUp()
self.Icon:AdjustPointsOffset(-1, 1);
self:GetParent().buttonDown = nil;
end
function ModelSceneControlButtonMixin:OnClick()
--override for your mouse click
end
function ModelSceneControlButtonMixin:OnEnter()
self:GetParent():SetAlpha(1);
if ( GetCVar("UberTooltips") == "1" ) then
local tooltip = GetAppropriateTooltip();
local topLevelParent = GetAppropriateTopLevelParent();
GameTooltip_SetDefaultAnchor(tooltip, topLevelParent);
GameTooltip_SetTitle(tooltip, self.tooltip);
if ( self.tooltipText ) then
GameTooltip_AddBodyLine(tooltip, self.tooltipText);
end
tooltip:Show();
end
end
function ModelSceneControlButtonMixin:OnLeave()
self:GetParent():SetAlpha(0.5);
local tooltip = GetAppropriateTooltip();
tooltip:Hide();
end
--------------------------------------------------
-- MODEL ZOOM BUTTON MIXIN
ModelSceneZoomButtonMixin = CreateFromMixins(ModelSceneControlButtonMixin);
function ModelSceneZoomButtonMixin:OnLoad()
self.zoomAmount = 0;
end
function ModelSceneZoomButtonMixin:Init()
if self.zoomAmount < 0 then
ModelSceneControlButtonMixin.Init(self, "AnyUp", "common-icon-zoomout", ZOOM_OUT, KEY_MOUSEWHEELDOWN);
else
ModelSceneControlButtonMixin.Init(self, "AnyUp", "common-icon-zoomin", ZOOM_IN, KEY_MOUSEWHEELUP);
end
end
function ModelSceneZoomButtonMixin:SetZoomAmount(amountToZoom)
self.zoomAmount = amountToZoom;
end
function ModelSceneZoomButtonMixin:OnClick()
if self.modelScene then
self.modelScene:OnMouseWheel(self.zoomAmount);
else
assertsafe(fale, "Model Scene not specified");
end
PlaySound(SOUNDKIT.IG_INVENTORY_ROTATE_CHARACTER);
end
--------------------------------------------------
-- MODEL SCENE ROTATE BUTTON MIXIN
ModelScenelRotateButtonMixin = CreateFromMixins(ModelSceneControlButtonMixin);
function ModelScenelRotateButtonMixin:OnLoad()
ModelSceneControlButtonMixin.OnLoad(self);
self.rotateDirection = "none";
end
function ModelScenelRotateButtonMixin:Init()
if (self.rotateDirection == "left") then
ModelSceneControlButtonMixin.Init(self, "AnyUp", "common-icon-rotateleft", ROTATE_LEFT, ROTATE_TOOLTIP);
elseif (self.rotateDirection == "right") then
ModelSceneControlButtonMixin.Init(self, "AnyUp", "common-icon-rotateright", ROTATE_RIGHT, ROTATE_TOOLTIP);
else
assertsafe(false, "Invalid rotation specified: "..tostring(self.rotateDirection));
end
end
function ModelScenelRotateButtonMixin:SetRotation(rotation)
self.rotateDirection = rotation;
end
function ModelScenelRotateButtonMixin:SetRotationIncrement(increment)
self.rotationIncrement = increment or 0.05;
end
function ModelScenelRotateButtonMixin:OnMouseDown()
ModelSceneControlButtonMixin.OnMouseDown(self);
if self.modelScene then
self.modelScene:AdjustCameraYaw(self.rotateDirection, self.rotationIncrement);
else
assertsafe(false, "Model Scene not specified");
end
PlaySound(SOUNDKIT.IG_INVENTORY_ROTATE_CHARACTER);
end
function ModelScenelRotateButtonMixin:OnMouseUp()
ModelSceneControlButtonMixin.OnMouseUp(self);
if self.modelScene then
self.modelScene:StopCameraYaw();
end
end
function ModelScenelRotateButtonMixin:OnSmartNavClick()
self:RotateStep();
end
function ModelScenelRotateButtonMixin:RotateStep()
local camera = self.modelScene and self.modelScene:GetActiveCamera();
if not camera then
return;
end
if self.rotateDirection == "left" then
camera:AdjustYaw(-1, -1, 0.25);
else
camera:AdjustYaw(1, 1, 0.25);
end
end
--------------------------------------------------
-- MODEL SCENE RESET BUTTON MIXIN
ModelSceneResetButtonMixin = CreateFromMixins(ModelSceneControlButtonMixin);
function ModelSceneResetButtonMixin:OnLoad()
ModelSceneControlButtonMixin.OnLoad(self);
end
function ModelSceneResetButtonMixin:Init()
local tooltipText = nil;
ModelSceneControlButtonMixin.Init(self, "AnyUp", "common-icon-undo", RESET_POSITION, tooltipText);
end
function ModelSceneResetButtonMixin:OnClick()
if self.modelScene then
self.modelScene:Reset();
else
assertsafe(false, "Model Scene not specified");
end
PlaySound(SOUNDKIT.IG_INVENTORY_ROTATE_CHARACTER);
end
@@ -0,0 +1,67 @@
<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/
UI.xsd">
<!-- Retail's Blizzard_SharedXML/ModelSceneControlFrame.xml. The one change:
HitRectInsets takes this client's <AbsInset> child. The atlases are
built in (src/texture/AtlasData.h, EMBEDDED ART). -->
<Button name="ModelSceneControlButtonTemplate" mixin="ModelSceneControlButtonMixin" virtual="true">
<Size x="32" y="32"/>
<HitRectInsets>
<AbsInset left="4" right="4" top="4" bottom="4"/>
</HitRectInsets>
<Layers>
<Layer level="OVERLAY">
<Texture parentKey="Icon" >
<Size x="16" y="16"/>
<Anchors>
<Anchor point="CENTER"/>
</Anchors>
</Texture>
<!--<Texture parentKey="Icon" setAllPoints="true" />-->
</Layer>
</Layers>
<Scripts>
<OnMouseDown method="OnMouseDown" />
<OnMouseUp method="OnMouseUp" />
<OnClick method="OnClick" />
<OnEnter method="OnEnter" />
<OnLeave method="OnLeave" />
</Scripts>
<NormalTexture parentKey="NormalTexture" atlas="common-button-square-gray-up"/>
<HighlightTexture parentKey="HighlightTexture" alphaMode="ADD" alpha="0.4">
<Anchors>
<Anchor point="TOPLEFT" relativeKey="$parent.Icon" relativePoint="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT" relativeKey="$parent.Icon" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</HighlightTexture>
<PushedTexture parentKey="PushedTexture" atlas="common-button-square-gray-down">
<Anchors>
<Anchor point="TOPLEFT" x="1" y="-1"/>
<Anchor point="BOTTOMRIGHT" x="1" y="-1"/>
</Anchors>
</PushedTexture>
</Button>
<Frame name="ModelSceneControlFrameTemplate" virtual="true" mixin="ModelSceneControlFrameMixin" alpha="0.5" hidden="true">
<Size x="32" y="32"/>
<KeyValues>
<KeyValue key="enableZoom" value="true" type="boolean" />
<KeyValue key="enableRotate" value="true" type="boolean" />
<KeyValue key="enableReset" value="true" type="boolean" />
<KeyValue key="rotationIncrement" value="0.05" type="number" />
<KeyValue key="zoomIncrement" value="1" type="number" />
<KeyValue key="buttonHorizontalPadding" value="-6" type="number" />
</KeyValues>
<Frames>
<Button parentKey="zoomInButton" hidden="true" mixin="ModelSceneZoomButtonMixin" inherits="ModelSceneControlButtonTemplate"/>
<Button parentKey="zoomOutButton" hidden="true" mixin="ModelSceneZoomButtonMixin" inherits="ModelSceneControlButtonTemplate"/>
<Button parentKey="rotateLeftButton" hidden="true" mixin="ModelScenelRotateButtonMixin" inherits="ModelSceneControlButtonTemplate"/>
<Button parentKey="rotateRightButton" hidden="true" mixin="ModelScenelRotateButtonMixin" inherits="ModelSceneControlButtonTemplate"/>
<Button parentKey="resetButton" hidden="true" mixin="ModelSceneResetButtonMixin" inherits="ModelSceneControlButtonTemplate"/>
</Frames>
<Scripts>
<OnLoad method="OnLoad" />
<OnShow method="OnShow" />
<OnEnter method="OnEnter" />
<OnLeave method="OnLeave" />
</Scripts>
</Frame>
</Ui>
@@ -0,0 +1,571 @@
-- Backport of retail's Blizzard_SharedXML/ModelSceneMixin.lua, verbatim except where
-- marked "ClassicAPI:".
ModelSceneMixin = {}
-- "public" functions
function ModelSceneMixin:OnLoad()
self.cameras = {};
self.actorTemplate = "ModelSceneActorTemplate";
self.tagToActor = {};
self.tagToCamera = {};
if self.allowOverlappedModels then
self:SetAllowOverlappedModels(true);
end
end
function ModelSceneMixin:ClearScene()
self.modelSceneID = nil;
self:ReleaseAllActors();
self:ReleaseAllCameras();
C_ModelInfo.ClearActiveModelScene(self);
end
function ModelSceneMixin:GetModelSceneID()
return self.modelSceneID;
end
-- Adjusts this scene to mirror a model scene from static data without transition
function ModelSceneMixin:SetFromModelSceneID(modelSceneID, forceEvenIfSame, noAutoCreateActors)
local modelSceneType, cameraIDs, actorIDs = C_ModelInfo.GetModelSceneInfoByID(modelSceneID);
if not modelSceneType then
return;
end
if self.modelSceneID ~= modelSceneID or forceEvenIfSame then
self.modelSceneID = modelSceneID;
self:ReleaseAllActors();
self:ReleaseAllCameras();
if not noAutoCreateActors then
for actorIndex, actorID in ipairs(actorIDs) do
self:CreateActorFromScene(actorID);
end
end
for cameraIndex, cameraID in ipairs(cameraIDs) do
self:CreateCameraFromScene(cameraID);
end
end
C_ModelInfo.AddActiveModelScene(self, self.modelSceneID);
EventRegistry:TriggerEvent("ModelScene.SetFromModelSceneID", self, self.modelSceneID);
end
function ModelSceneMixin:SetResetCallback(callback)
self.resetCallback = callback;
end
function ModelSceneMixin:Reset()
if self.modelSceneID then
self:TransitionToModelSceneID(self.modelSceneID, self.cameraTransitionType, self.cameraModificationType, self.forceEvenIfSame);
if self.resetCallback then
self.resetCallback(self);
end
end
end
-- Adjusts this scene to mirror a model scene from static data but with transition effects
-- Only actors/cameras with script tags will be transitioned
function ModelSceneMixin:TransitionToModelSceneID(modelSceneID, cameraTransitionType, cameraModificationType, forceEvenIfSame)
local modelSceneType, cameraIDs, actorIDs = C_ModelInfo.GetModelSceneInfoByID(modelSceneID);
if not modelSceneType or #cameraIDs == 0 or #actorIDs == 0 then
return;
end
if self.modelSceneID ~= modelSceneID or forceEvenIfSame then
self.modelSceneID = modelSceneID;
self.cameraTransitionType = cameraTransitionType;
self.cameraModificationType = cameraModificationType;
self.forceEvenIfSame = forceEvenIfSame;
local actorsToRelease = {};
for actor in self:EnumerateActiveActors() do
actorsToRelease[actor] = true;
end
local oldTagToActor = self.tagToActor;
self.tagToActor = {};
for actorIndex, actorID in ipairs(actorIDs) do
local actor = self:CreateOrTransitionActorFromScene(oldTagToActor, actorID);
if actor then
actorsToRelease[actor] = nil;
end
end
for actor in pairs(actorsToRelease) do
self.actorPool:Release(actor);
end
local oldTagToCamera = self.tagToCamera;
self.tagToCamera = {};
self.cameras = {};
local needsNewCamera = true;
for cameraIndex, cameraID in ipairs(cameraIDs) do
local camera = self:CreateOrTransitionCameraFromScene(oldTagToCamera, cameraTransitionType, cameraModificationType, cameraID);
if camera == self.activeCamera then
needsNewCamera = false;
end
end
if needsNewCamera then
self:SetActiveCamera(self.cameras[1]);
end
end
C_ModelInfo.AddActiveModelScene(self, self.modelSceneID);
EventRegistry:TriggerEvent("ModelScene.TransitionToModelSceneID", self, self.modelSceneID);
end
-- There may be inactive (pooled) actors maintained by this scene, these function only returns the active actors
function ModelSceneMixin:GetNumActiveActors()
return self.actorPool and self.actorPool:GetNumActive() or 0;
end
function ModelSceneMixin:EnumerateActiveActors()
if self.actorPool then
return self.actorPool:EnumerateActive();
end
return nop;
end
function ModelSceneMixin:GetActorByTag(tag)
return self.tagToActor[tag];
end
function ModelSceneMixin:AttachPlayerToMount(mountActor, animID, isSelfMount, disablePlayerMountPreview, spellVisualKitID, usePlayerNativeForm, optionalPlayerRiderTag)
local playerRiderTag = optionalPlayerRiderTag or "player-rider";
local playerActor = self:GetPlayerActor(playerRiderTag);
if (playerActor) then
if disablePlayerMountPreview or isSelfMount then
playerActor:ClearModel();
else
local sheatheWeapons = true;
local autoDress = true;
local hideWeapons = true;
if C_Glue.IsOnGlueScreen() then
local characterIndex = nil; -- defaults to selected character.
playerActor:SetPlayerModelFromGlues(characterIndex, sheatheWeapons, autoDress, hideWeapons, usePlayerNativeForm);
else
playerActor:SetModelByUnit("player", sheatheWeapons, autoDress, hideWeapons, usePlayerNativeForm);
end
local calcMountScale = mountActor:CalculateMountScale(playerActor);
local inverseScale = 1 / calcMountScale;
playerActor:SetRequestedScale( inverseScale );
mountActor:AttachToMount(playerActor, animID, spellVisualKitID);
end
end
end
function GetPlayerActorLabelTag(forceAlternateForm, overrideRaceFilename, overrideGender)
local playerRaceName = overrideRaceFilename;
local playerGender = overrideGender;
local playerRaceNameActorTag = nil;
local hasAlternateForm, inAlternateForm = false, false;
if not overrideRaceFilename or not overrideGender then
if C_Glue.IsOnGlueScreen() then
local characterGuid = GetCharacterGUID(GetCharacterSelection());
if characterGuid then
local basicCharacterInfo = GetBasicCharacterInfo(characterGuid);
playerRaceName = overrideRaceFilename or basicCharacterInfo.raceFilename;
playerGender = overrideGender or basicCharacterInfo.genderEnum;
end
else
hasAlternateForm, inAlternateForm = C_PlayerInfo.GetAlternateFormInfo();
local _, raceFilename = UnitRace("player");
playerRaceName = overrideRaceFilename or raceFilename;
playerGender = overrideGender or UnitSex("player");
end
end
if not playerRaceName or not playerGender then
return playerRaceName, playerRaceNameActorTag;
end
playerRaceName = playerRaceName:lower();
if hasAlternateForm and (inAlternateForm or forceAlternateForm) then
playerRaceName = playerRaceName.."-alt";
end
playerGender = (playerGender == 2) and "male" or "female";
playerRaceNameActorTag = playerRaceName.."-"..playerGender;
return playerRaceName, playerRaceNameActorTag;
end
function ModelSceneMixin:GetPlayerActor(overrideActorName, forceAlternateForm, overrideRaceFilename, overrideGender)
local actor;
if overrideActorName then
actor = self:GetActorByTag(overrideActorName);
else
local playerRaceName, playerRaceNameActorTag = GetPlayerActorLabelTag(forceAlternateForm, overrideRaceFilename, overrideGender);
actor = self:GetActorByTag(playerRaceNameActorTag);
if not actor then
actor = self:GetActorByTag(playerRaceName);
if not actor then
actor = self:GetActorByTag("player");
if not actor then
actor = self:GetActorByTag("player-rider");
end
end
end
end
return actor;
end
function ModelSceneMixin:ReleaseAllActors()
if self.actorPool then
self.actorPool:ReleaseAll();
if self.dropShadowPool then
self.dropShadowPool:ReleaseAll();
end
self.tagToActor = {};
end
end
local function OnReleased(actorPool, actor)
actor:OnReleased();
ActorPool_HideAndClearModel(actorPool, actor);
end
function ModelSceneMixin:AcquireActor()
if not self.actorPool then
self.actorPool = CreateActorPool(self, self.actorTemplate, OnReleased);
end
return self.actorPool:Acquire();
end
function ModelSceneMixin:ReleaseActor(actor)
if not actor or not self.actorPool then
return;
end
for tag, taggedActor in pairs(self.tagToActor) do
if actor == taggedActor then
self.tagToActor[tag] = nil;
break;
end
end
if actor.dropShadowTexture then
self.dropShadowPool:Release(actor.dropShadowTexture);
end
return self.actorPool:Release(actor);
end
function ModelSceneMixin:AcquireAndInitializeActor(actorInfo)
local actor = self:AcquireActor();
self:InitializeActor(actor, actorInfo);
return actor;
end
function ModelSceneMixin:ReleaseAllCameras()
self:SetActiveCamera(nil);
for i = #self.cameras, 1, -1 do
self.cameras[i]:SetOwningScene(nil);
self.cameras[i] = nil;
end
self.tagToCamera = {};
end
function ModelSceneMixin:GetCameraByTag(tag)
return self.tagToCamera[tag];
end
function ModelSceneMixin:AddCamera(camera)
table.insert(self.cameras, camera);
camera:SetOwningScene(self);
if not self:HasActiveCamera() then
self:SetActiveCamera(camera);
end
return camera;
end
function ModelSceneMixin:HasActiveCamera()
return self.activeCamera ~= nil;
end
function ModelSceneMixin:GetActiveCamera()
return self.activeCamera;
end
function ModelSceneMixin:SetActiveCamera(camera)
if camera ~= self.activeCamera then
if self.activeCamera then
self.activeCamera:OnDeactivated();
end
self.activeCamera = camera;
if self.activeCamera then
self.activeCamera:OnActivated();
end
end
end
function ModelSceneMixin:IsLeftMouseButtonDown()
return self.isLeftButtonDown;
end
function ModelSceneMixin:IsRightMouseButtonDown()
return self.isRightButtonDown;
end
function ModelSceneMixin:Transform3DPointTo2D(x, y, z)
self:SynchronizeActiveCamera();
return self:Project3DPointTo2D(x, y, z);
end
-- "private" functions
function ModelSceneMixin:OnUpdate(elapsed)
if self.activeCamera then
local yawDirection = self.yawDirection;
-- ClassicAPI: retail turns by the increment every frame, which spins
-- several times faster at this client's uncapped frame rates. The
-- increment counts per 1/60 s here: 0.05 is 3 rad/s, the speed of
-- 1.12's own rotate buttons (ROTATIONS_PER_SECOND, pi rad/s).
local increment = self.increment and self.increment * elapsed * 60;
if yawDirection == "left" then
self.activeCamera:AdjustYaw(-1, -1, increment);
elseif yawDirection == "right" then
self.activeCamera:AdjustYaw(1, 1, increment);
end
self.activeCamera:OnUpdate(elapsed);
end
end
function ModelSceneMixin:SynchronizeActiveCamera()
if self.activeCamera then
self.activeCamera:SynchronizeCamera();
end
end
function ModelSceneMixin:OnEnter(button)
if self.ControlFrame then
self.ControlFrame:Show();
end
end
function ModelSceneMixin:OnLeave(button)
if self.ControlFrame then
if not self.ControlFrame:IsMouseOver()then
self.ControlFrame:Hide();
end
end
end
function ModelSceneMixin:OnMouseDown(button)
if button == "LeftButton" then
self.isLeftButtonDown = true;
elseif button == "RightButton" then
self.isRightButtonDown = true;
end
if self.activeCamera then
self.activeCamera:OnMouseDown(button);
end
end
function ModelSceneMixin:OnMouseUp(button)
if button == "LeftButton" then
self.isLeftButtonDown = false;
elseif button == "RightButton" then
self.isRightButtonDown = false;
end
if self.activeCamera then
self.activeCamera:OnMouseUp(button);
end
end
function ModelSceneMixin:OnMouseWheel(delta)
if self.activeCamera then
self.activeCamera:OnMouseWheel(delta);
end
end
function ModelSceneMixin:AdjustCameraYaw(direction, increment)
if self.activeCamera then
self.yawDirection = direction;
self.increment = increment;
end
end
function ModelSceneMixin:StopCameraYaw()
self.yawDirection = nil;
self.increment = nil;
end
function ModelSceneMixin:InitializeActor(actor, actorInfo)
if actorInfo.scriptTag then
self.tagToActor[actorInfo.scriptTag] = actor;
end
actor:ApplyFromModelSceneActorInfo(actorInfo);
actor:Show();
end
function ModelSceneMixin:CreateActorFromScene(actorID)
local actorInfo = C_ModelInfo.GetModelSceneActorInfoByID(actorID);
return self:AcquireAndInitializeActor(actorInfo);
end
function ModelSceneMixin:CreateOrTransitionActorFromScene(oldTagToActor, actorID)
local actorInfo = C_ModelInfo.GetModelSceneActorInfoByID(actorID);
local existingActor = oldTagToActor[actorInfo.scriptTag];
if existingActor then
self:InitializeActor(existingActor, actorInfo);
return existingActor;
end
return self:AcquireAndInitializeActor(actorInfo);
end
function ModelSceneMixin:CreateCameraFromScene(modelSceneCameraID)
local modelSceneCameraInfo = C_ModelInfo.GetModelSceneCameraInfoByID(modelSceneCameraID);
if modelSceneCameraInfo then
local camera = CameraRegistry:CreateCameraByType(modelSceneCameraInfo.cameraType);
if camera then
if modelSceneCameraInfo.scriptTag then
self.tagToCamera[modelSceneCameraInfo.scriptTag] = camera;
end
self:AddCamera(camera);
camera:ApplyFromModelSceneCameraInfo(modelSceneCameraInfo, CAMERA_TRANSITION_TYPE_IMMEDIATE, CAMERA_MODIFICATION_TYPE_DISCARD);
return camera;
end
end
end
function ModelSceneMixin:CreateOrTransitionCameraFromScene(oldTagToCamera, cameraTransitionType, cameraModificationType, modelSceneCameraID)
local modelSceneCameraInfo = C_ModelInfo.GetModelSceneCameraInfoByID(modelSceneCameraID);
if modelSceneCameraInfo then
local existingCamera = oldTagToCamera[modelSceneCameraInfo.scriptTag];
if existingCamera and existingCamera:GetCameraType() == modelSceneCameraInfo.cameraType then
self.tagToCamera[modelSceneCameraInfo.scriptTag] = existingCamera;
self:AddCamera(existingCamera);
existingCamera:ApplyFromModelSceneCameraInfo(modelSceneCameraInfo, cameraTransitionType, cameraModificationType);
return existingCamera;
end
return self:CreateCameraFromScene(modelSceneCameraID);
end
end
-- actorSettings = {
-- "actorTag" = { startDelay = 0, duration = 0, speed = 1 } -- duration 0 means no automatic stoppage
-- }
function ModelSceneMixin:ShowAndAnimateActors(actorSettings, onFinishedCallback)
self:Show();
local totalTime = 0;
for actorTag, actorInfo in pairs(actorSettings) do
local actor = self:GetActorByTag(actorTag);
if actor then
local runningTime = actorInfo.startDelay + actorInfo.duration;
if actorInfo.startDelay > 0 then
actor:SetAnimation(0, 0, 0, 0);
C_Timer.After(actorInfo.startDelay,
function()
actor:SetAnimation(0, 0, actorInfo.speed, 0);
end
);
else
actor:SetAnimation(0, 0, actorInfo.speed, 0);
end
if actorInfo.duration > 0 then
C_Timer.After(runningTime,
function()
actor:SetAnimation(0, 0, 0, 0);
end
);
end
if runningTime > totalTime then
totalTime = runningTime;
end
end
end
if onFinishedCallback and totalTime > 0 then
C_Timer.After(totalTime, onFinishedCallback);
end
end
function ModelSceneMixin:GetOrAcquireDropShadow(actor)
if actor.dropShadowTexture then
return actor.dropShadowTexture;
end
if not self.dropShadowPool then
self.dropShadowPool = CreateTexturePool(self, "BORDER", 1, "ModelSceneDropShadowTemplate");
end
actor.dropShadowTexture = self.dropShadowPool:Acquire();
return actor.dropShadowTexture;
end
function ModelSceneMixin:AddOrUpdateDropShadow(actor, baseShadowScale)
baseShadowScale = baseShadowScale or 1.0;
local dropShadowTexture = self:GetOrAcquireDropShadow(actor);
local positionVector = CreateVector3D(actor:GetPosition());
positionVector:ScaleBy(actor:GetScale());
local x, y, depthScale = self:Transform3DPointTo2D(positionVector:GetXYZ());
if not x or not y or not depthScale then
dropShadowTexture:Hide();
return;
end
dropShadowTexture:ClearAllPoints();
depthScale = baseShadowScale * Lerp(.05, 1, ClampedPercentageBetween(depthScale, .8, 1))
-- Scales down the texture depending on it's depthScale.
dropShadowTexture:SetScale(depthScale);
-- Need to apply the effective scale to account for UI Scaling.
local inverseScale = self:GetEffectiveScale() * depthScale;
-- The position of the character can be found by the offset on the screen.
dropShadowTexture:SetPoint("CENTER", self, "BOTTOMLEFT", (x / inverseScale) + 2, (y / inverseScale) - 4);
dropShadowTexture:Show();
end
PanningModelSceneMixin = CreateFromMixins(ModelSceneMixin);
function PanningModelSceneMixin:TransitionToModelSceneID(modelSceneID, cameraTransitionType, cameraModificationType, forceEvenIfSame)
ModelSceneMixin.TransitionToModelSceneID(self, modelSceneID, cameraTransitionType, cameraModificationType, forceEvenIfSame);
local camera = self:GetActiveCamera();
if camera then
camera:SetRightMouseButtonXMode(ORBIT_CAMERA_MOUSE_PAN_HORIZONTAL, true);
camera:SetRightMouseButtonYMode(ORBIT_CAMERA_MOUSE_PAN_VERTICAL, true);
end
end
NoCameraControlModelSceneMixin = CreateFromMixins(ModelSceneMixin);
function NoCameraControlModelSceneMixin:OnMouseDown(button)
self.isLeftButtonDown = false;
self.isRightButtonDown = false;
end
function NoCameraControlModelSceneMixin:OnMouseUp(button)
self.isLeftButtonDown = false;
self.isRightButtonDown = false;
end
function NoCameraControlModelSceneMixin:OnMouseWheel(delta)
end
NoZoomModelSceneMixin = CreateFromMixins(ModelSceneMixin);
function NoZoomModelSceneMixin:OnMouseWheel(delta)
end
@@ -0,0 +1,28 @@
<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/
UI.xsd">
<ModelScene name="NonInteractableModelSceneMixinTemplate" mixin="ModelSceneMixin" virtual="true">
<Scripts>
<OnLoad method="OnLoad" />
<OnUpdate method="OnUpdate" />
</Scripts>
</ModelScene>
<ModelScene name="ModelSceneMixinTemplate" inherits="NonInteractableModelSceneMixinTemplate" virtual="true">
<Scripts>
<OnEnter method="OnEnter"/>
<OnLeave method="OnLeave"/>
<OnMouseUp method="OnMouseUp"/>
<OnMouseDown method="OnMouseDown"/>
<OnMouseWheel method="OnMouseWheel" />
</Scripts>
</ModelScene>
<ModelScene name="StoreModelSceneMixinTemplate" inherits="ModelSceneMixinTemplate" virtual="true">
<KeyValues>
<!-- CLASS-44103: Automatically allow overlapped models for shop scenes -->
<KeyValue key="allowOverlappedModels" value="true" type="boolean"/>
</KeyValues>
</ModelScene>
<ModelScene name="NoZoomModelSceneMixinTemplate" inherits="ModelSceneMixinTemplate" mixin="NoZoomModelSceneMixin" virtual="true"/>
<ModelScene name="PanningModelSceneMixinTemplate" inherits="ModelSceneMixinTemplate" mixin="PanningModelSceneMixin" virtual="true" />
<ModelScene name="NoCameraControlModelSceneMixinTemplate" inherits="ModelSceneMixinTemplate" mixin="NoCameraControlModelSceneMixin" virtual="true" />
<Texture name="ModelSceneDropShadowTemplate" atlas="scoreboard-charactermodels-shadow" useAtlasSize="true" virtual="true"/>
</Ui>
@@ -0,0 +1,640 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Shared/ModelSceneCameras/OrbitCameraMixin.lua.
--[[
OrbitCameraMixin - A simple camera that can orbit a target point at a fixed distance.
The orbit is represented using a distance from a target point in world space and yaw, pitch and roll.
The yaw, pitch and roll represent the camera's orientation, or inversely the direction of the camera from the target point.
Optionally, the target, orientation and zoom can have splines that are added to the respective axes. The splines are fed the zoom percentage to calculate the point on the spline.
This is ideal for changing view points based on zoom distance from the target. For example, target model's face on zoom in and center on the model on zoom out.
]]
OrbitCameraMixin = CreateFromMixins(CameraBaseMixin);
local CAMERA_NAME = "OrbitCamera";
CameraRegistry:AddCameraFactoryFromMixin(CAMERA_NAME, OrbitCameraMixin);
-- "public" functions
function OrbitCameraMixin:GetCameraType() -- override
return CAMERA_NAME;
end
local function TryCreateZoomSpline(x, y, z, existingSpline)
if x and y and z and (x ~= 0 or y ~= 0 or z ~= 0) then
local spline = existingSpline or CreateCatmullRomSpline(3);
spline:ClearPoints();
spline:AddPoint(0, 0, 0);
spline:AddPoint(x, y, z);
return spline;
end
return nil;
end
function OrbitCameraMixin:ApplyFromModelSceneCameraInfo(modelSceneCameraInfo, transitionType, modificationType) -- override
self.panningXOffset = 0;
self.panningYOffset = 0;
local transitionalCameraInfo = self:CalculateTransitionalValues(self.modelSceneCameraInfo, modelSceneCameraInfo, modificationType);
self.modelSceneCameraInfo = modelSceneCameraInfo;
self:SetTarget(transitionalCameraInfo.target:GetXYZ());
self:SetTargetSpline(TryCreateZoomSpline(transitionalCameraInfo.zoomedTargetOffset:GetXYZ()), self:GetTargetSpline());
self:SetOrientationSpline(TryCreateZoomSpline(transitionalCameraInfo.zoomedYawOffset, transitionalCameraInfo.zoomedPitchOffset, transitionalCameraInfo.zoomedRollOffset), self:GetOrientationSpline());
self:SetMinZoomDistance(transitionalCameraInfo.minZoomDistance);
self:SetMaxZoomDistance(transitionalCameraInfo.maxZoomDistance);
self:SetZoomDistance(transitionalCameraInfo.zoomDistance);
self:SetYaw(transitionalCameraInfo.yaw);
self:SetPitch(transitionalCameraInfo.pitch);
self:SetRoll(transitionalCameraInfo.roll);
if transitionType == CAMERA_TRANSITION_TYPE_IMMEDIATE then
self:SnapAllInterpolatedValues();
end
self:UpdateCameraOrientationAndPosition();
end
function OrbitCameraMixin:SetTarget(x, y, z)
self.targetX, self.targetY, self.targetZ = x, y, z;
end
function OrbitCameraMixin:GetTarget(x, y, z)
return self.targetX, self.targetY, self.targetZ;
end
function OrbitCameraMixin:SetYaw(yaw)
self.yaw = yaw;
end
function OrbitCameraMixin:GetYaw()
return self.yaw;
end
function OrbitCameraMixin:SetPitch(pitch)
self.pitch = pitch;
end
function OrbitCameraMixin:GetPitch()
return self.pitch;
end
function OrbitCameraMixin:SetRoll(roll)
self.roll = roll;
end
function OrbitCameraMixin:GetRoll()
return self.roll;
end
-- where 100% is fully zoomed out
function OrbitCameraMixin:GetZoomPercent()
return self.zoomPercent;
end
function OrbitCameraMixin:SetZoomPercent(zoomPercent)
self.zoomPercent = Saturate(zoomPercent);
end
function OrbitCameraMixin:ZoomByPercent(percent)
self:SetZoomPercent(self:GetZoomPercent() - percent);
end
function OrbitCameraMixin:SetMaxZoomDistance(distance)
self.maxZoomDistance = distance;
if self:GetZoomPercent() then
self:SetZoomDistance(self:GetZoomDistance());
end
end
function OrbitCameraMixin:GetMaxZoomDistance()
return self.maxZoomDistance;
end
function OrbitCameraMixin:SetMinZoomDistance(distance)
self.minZoomDistance = distance;
if self:GetZoomPercent() then
self:SetZoomDistance(self:GetZoomDistance());
end
end
function OrbitCameraMixin:GetMinZoomDistance()
return self.minZoomDistance;
end
function OrbitCameraMixin:SetZoomDistance(distance)
self:SetZoomPercent(self:CalculateZoomPercentFromDistance(distance));
end
function OrbitCameraMixin:CalculateZoomPercentFromDistance(distance)
if self:GetMinZoomDistance() and self:GetMaxZoomDistance() then
return PercentageBetween(distance, self:GetMinZoomDistance(), self:GetMaxZoomDistance());
end
return 0.0;
end
function OrbitCameraMixin:CalculateZoomDistanceFromPercent(percent)
if self:GetMinZoomDistance() and self:GetMaxZoomDistance() then
return Lerp(self:GetMinZoomDistance(), self:GetMaxZoomDistance(), percent);
end
return 0.0;
end
function OrbitCameraMixin:GetZoomDistance()
return self:CalculateZoomDistanceFromPercent(self:GetZoomPercent());
end
-- If min and max values are the same, zoom cannot really happen.
function OrbitCameraMixin:GetZoomAvailable()
if self:GetMinZoomDistance() and self:GetMaxZoomDistance() then
return self:GetMinZoomDistance() ~= self:GetMaxZoomDistance();
end
return false;
end
function OrbitCameraMixin:ZoomBy(distance)
self:SetZoomDistance(self:GetZoomDistance() - distance);
end
-- Expects a three dimensional spline where a point of the curve represents a target position
function OrbitCameraMixin:SetTargetSpline(targetSpline)
self.targetSpline = targetSpline;
end
function OrbitCameraMixin:GetTargetSpline()
return self.targetSpline;
end
-- Expects a three dimensional spline where a point of the curve represents the camera yaw, pitch, roll
function OrbitCameraMixin:SetOrientationSpline(orientationSpline)
self.orientationSpline = orientationSpline;
end
function OrbitCameraMixin:GetOrientationSpline()
return self.orientationSpline;
end
-- Expects an one dimensional spline where a point of the curve represents the camera's distance from the target
function OrbitCameraMixin:SetZoomSpline(zoomSpline)
self.zoomSpline = zoomSpline;
end
function OrbitCameraMixin:GetZoomSpline()
return self.zoomSpline;
end
--[[ These return the simple + spline values ]]--
function OrbitCameraMixin:GetDerivedTarget()
local targetX, targetY, targetZ = self:GetTarget();
local targetSpline = self:GetTargetSpline();
if targetSpline then
return Vector3D_Add(targetX, targetY, targetZ, targetSpline:CalculatePointOnGlobalCurve(1.0 - self:GetZoomPercent()));
end
return targetX, targetY, targetZ;
end
function OrbitCameraMixin:GetDerivedOrientation()
local yaw, pitch, roll = self:GetYaw(), self:GetPitch(), self:GetRoll();
local orientationSpline = self:GetOrientationSpline();
if orientationSpline then
return Vector3D_Add(yaw, pitch, roll, orientationSpline:CalculatePointOnGlobalCurve(1.0 - self:GetZoomPercent()));
end
return yaw, pitch, roll;
end
function OrbitCameraMixin:GetDerivedZoomDistance()
local zoomDistance = self:GetZoomDistance();
local zoomSpline = self:GetZoomSpline();
if zoomSpline then
return zoomDistance + zoomSpline:CalculatePointOnGlobalCurve(1.0 - self:GetZoomPercent());
end
return zoomDistance;
end
function OrbitCameraMixin:SetAlignLightToOrbitDelta(alignLightToOrbitDelta)
if alignLightToOrbitDelta then
self.modelSceneCameraInfo.flags = bit.bor(self.modelSceneCameraInfo.flags, Enum.ModelSceneSetting.AlignLightToOrbitDelta);
else
self.modelSceneCameraInfo.flags = bit.band(self.modelSceneCameraInfo.flags, bit.bnot(Enum.ModelSceneSetting.AlignLightToOrbitDelta));
end
end
function OrbitCameraMixin:ShouldAlignLightToOrbitDelta()
return bit.band(self.modelSceneCameraInfo.flags, Enum.ModelSceneSetting.AlignLightToOrbitDelta) == Enum.ModelSceneSetting.AlignLightToOrbitDelta;
end
--[[
Interpolation API
For each API, "amount" is the percentage to approach in an "ideal" frame (60 fps), such that setting the value to >= 1 would snap to the desired target and <= 0 would freeze the interpolation.
Setting to nil will disable the interpolation.
]]
function OrbitCameraMixin:SetYawInterpolationAmount(yawInterpolationAmount)
self.yawInterpolationAmount = yawInterpolationAmount;
end
function OrbitCameraMixin:GetYawInterpolationAmount()
return self.yawInterpolationAmount;
end
function OrbitCameraMixin:SetPitchInterpolationAmount(pitchInterpolationAmount)
self.pitchInterpolationAmount = pitchInterpolationAmount;
end
function OrbitCameraMixin:GetPitchInterpolationAmount()
return self.pitchInterpolationAmount;
end
function OrbitCameraMixin:SetRollInterpolationAmount(rollInterpolationAmount)
self.rollInterpolationAmount = rollInterpolationAmount;
end
function OrbitCameraMixin:GetRollInterpolationAmount()
return self.rollInterpolationAmount;
end
function OrbitCameraMixin:SetTargetInterpolationAmount(targetInterpolationAmount)
self.targetInterpolationAmount = targetInterpolationAmount;
end
function OrbitCameraMixin:GetTargetInterpolationAmount()
return self.targetInterpolationAmount;
end
function OrbitCameraMixin:SetZoomInterpolationAmount(zoomInterpolationAmount)
self.zoomInterpolationAmount = zoomInterpolationAmount;
end
function OrbitCameraMixin:GetZoomInterpolationAmount()
return self.zoomInterpolationAmount;
end
function OrbitCameraMixin:GetInterpolatedTarget()
if self.interpolatedTargetX then
return self.interpolatedTargetX, self.interpolatedTargetY, self.interpolatedTargetZ;
end
return self:GetDerivedTarget();
end
function OrbitCameraMixin:GetInterpolatedOrientation()
if self.interpolatedYaw then
return self.interpolatedYaw, self.interpolatedPitch, self.interpolatedRoll;
end
return self:GetDerivedOrientation();
end
function OrbitCameraMixin:GetInterpolatedZoomDistance()
if self.interpolatedZoomDistance then
return self.interpolatedZoomDistance;
end
return self:GetDerivedZoomDistance();
end
function OrbitCameraMixin:SnapToTargetInterpolationYaw()
self.interpolatedYaw = nil;
end
function OrbitCameraMixin:SnapToTargetInterpolationPitch()
self.interpolatedPitch = nil;
end
function OrbitCameraMixin:SnapToTargetInterpolationRoll()
self.interpolatedRoll = nil;
end
function OrbitCameraMixin:SnapToTargetInterpolationZoom()
self.interpolatedZoomDistance = nil;
end
function OrbitCameraMixin:SnapToTargetInterpolationTarget()
self.interpolatedTargetX = nil;
self.interpolatedTargetY = nil;
self.interpolatedTargetZ = nil;
end
function OrbitCameraMixin:SnapAllInterpolatedValues()
self:SnapToTargetInterpolationYaw();
self:SnapToTargetInterpolationPitch();
self:SnapToTargetInterpolationRoll();
self:SnapToTargetInterpolationZoom();
self:SnapToTargetInterpolationTarget();
end
ORBIT_CAMERA_MOUSE_MODE_NOTHING = 0;
ORBIT_CAMERA_MOUSE_MODE_YAW_ROTATION = 1;
ORBIT_CAMERA_MOUSE_MODE_PITCH_ROTATION = 2;
ORBIT_CAMERA_MOUSE_MODE_ROLL_ROTATION = 3;
ORBIT_CAMERA_MOUSE_MODE_TARGET_HORIZONTAL = 4;
ORBIT_CAMERA_MOUSE_MODE_TARGET_VERTICAL = 5;
ORBIT_CAMERA_MOUSE_MODE_ZOOM = 6;
ORBIT_CAMERA_MOUSE_PAN_HORIZONTAL = 7;
ORBIT_CAMERA_MOUSE_PAN_VERTICAL = 8;
function OrbitCameraMixin:SetLeftMouseButtonXMode(mouseMode, snap)
self.buttonModes.leftX = mouseMode;
self.buttonModes.leftXinterpolate = not snap;
end
function OrbitCameraMixin:GetLeftMouseButtonXMode()
return self.buttonModes.leftX, not self.buttonModes.leftXinterpolate;
end
function OrbitCameraMixin:SetLeftMouseButtonYMode(mouseMode, snap)
self.buttonModes.leftY = mouseMode;
self.buttonModes.leftYinterpolate = not snap;
end
function OrbitCameraMixin:GetLeftYMouseButtonYMode()
return self.buttonModes.leftY, not self.buttonModes.leftYinterpolate;
end
function OrbitCameraMixin:SetRightMouseButtonXMode(mouseMode, snap)
self.buttonModes.rightX = mouseMode;
self.buttonModes.rightXinterpolate = not snap;
end
function OrbitCameraMixin:GetRightMouseButtonXMode()
return self.buttonModes.rightX, not self.buttonModes.rightXinterpolate;
end
function OrbitCameraMixin:SetRightMouseButtonYMode(mouseMode, snap)
self.buttonModes.rightY = mouseMode;
self.buttonModes.rightYinterpolate = not snap;
end
function OrbitCameraMixin:GetRightMouseButtonYMode()
return self.buttonModes.rightY, not self.buttonModes.rightYinterpolate;
end
function OrbitCameraMixin:SetMouseWheelMode(mouseMode, snap)
self.buttonModes.wheel = mouseMode;
self.buttonModes.wheelInterpolate = not snap;
end
function OrbitCameraMixin:GetMouseWheelMode()
return self.buttonModes.wheel, not self.buttonModes.wheelInterpolate;
end
function OrbitCameraMixin:HandleMouseMovement(mode, delta, snapToValue)
if mode == ORBIT_CAMERA_MOUSE_MODE_YAW_ROTATION then
self:SetYaw(self:GetYaw() - delta);
if snapToValue then
self:SnapToTargetInterpolationYaw();
end
elseif mode == ORBIT_CAMERA_MOUSE_MODE_PITCH_ROTATION then
self:SetPitch(self:GetPitch() - delta);
if snapToValue then
self:SnapToTargetInterpolationPitch();
end
elseif mode == ORBIT_CAMERA_MOUSE_MODE_ROLL_ROTATION then
self:SetRoll(self:GetRoll() - delta);
if snapToValue then
self:SnapToTargetInterpolationRoll();
end
elseif mode == ORBIT_CAMERA_MOUSE_MODE_ZOOM then
self:ZoomByPercent(delta);
if snapToValue then
self:SnapToTargetInterpolationZoom();
end
elseif mode == ORBIT_CAMERA_MOUSE_MODE_TARGET_HORIZONTAL then
local rightX, rightY, rightZ = Vector3D_ScaleBy(delta, self:GetRightVector());
self:SetTarget(Vector3D_Add(rightX, rightY, rightZ, self:GetTarget()));
if snapToValue then
self:SnapToTargetInterpolationTarget();
end
elseif mode == ORBIT_CAMERA_MOUSE_MODE_TARGET_VERTICAL then
local upX, upY, upZ = Vector3D_ScaleBy(delta, self:GetUpVector());
self:SetTarget(Vector3D_Add(upX, upY, upZ, self:GetTarget()));
if snapToValue then
self:SnapToTargetInterpolationTarget();
end
elseif mode == ORBIT_CAMERA_MOUSE_PAN_HORIZONTAL then
self.panningXOffset = self.panningXOffset + delta;
elseif mode == ORBIT_CAMERA_MOUSE_PAN_VERTICAL then
self.panningYOffset = self.panningYOffset + delta;
end
end
function OrbitCameraMixin:ResetDefaultInputModes()
self:SetLeftMouseButtonXMode(ORBIT_CAMERA_MOUSE_MODE_YAW_ROTATION, true);
self:SetLeftMouseButtonYMode(ORBIT_CAMERA_MOUSE_MODE_NOTHING);
self:SetRightMouseButtonXMode(ORBIT_CAMERA_MOUSE_MODE_NOTHING);
self:SetRightMouseButtonYMode(ORBIT_CAMERA_MOUSE_MODE_NOTHING);
self:SetMouseWheelMode(ORBIT_CAMERA_MOUSE_MODE_ZOOM, false);
end
-- "private" function
function OrbitCameraMixin:OnAdded() -- override
self.buttonModes = {};
self:SetTarget(0, 0, 0);
local targetSpline = CreateCatmullRomSpline(3);
targetSpline:AddPoint(0, 0, 0);
targetSpline:AddPoint(0, 0, .5);
self:SetTargetSpline(targetSpline);
self:SetMinZoomDistance(6);
self:SetMaxZoomDistance(10);
self:SetZoomDistance(8);
self:SetYaw(math.pi);
self:SetPitch(0);
self:SetRoll(0);
self:SetZoomInterpolationAmount(.15);
self:SetYawInterpolationAmount(.15);
self:SetPitchInterpolationAmount(.15);
self:SetRollInterpolationAmount(.15);
self:SetTargetInterpolationAmount(.15);
self:ResetDefaultInputModes();
end
function OrbitCameraMixin:GetDeltaModifierForCameraMode(mode)
if mode == ORBIT_CAMERA_MOUSE_MODE_YAW_ROTATION then
return .008;
elseif mode == ORBIT_CAMERA_MOUSE_MODE_PITCH_ROTATION then
return .008;
elseif mode == ORBIT_CAMERA_MOUSE_MODE_ROLL_ROTATION then
return .008;
elseif mode == ORBIT_CAMERA_MOUSE_MODE_ZOOM then
return .1;
elseif mode == ORBIT_CAMERA_MOUSE_MODE_TARGET_HORIZONTAL then
return .05;
elseif mode == ORBIT_CAMERA_MOUSE_MODE_TARGET_VERTICAL then
return .05;
elseif mode == ORBIT_CAMERA_MOUSE_PAN_HORIZONTAL then
return 0.93;
elseif mode == ORBIT_CAMERA_MOUSE_PAN_VERTICAL then
return 0.93;
end
return 0.0;
end
function OrbitCameraMixin:OnUpdate(elapsed) -- override
if self:IsLeftMouseButtonDown() then
local deltaX, deltaY = InputUtil.GetCursorDelta(GetAppropriateTopLevelParent());
self:HandleMouseMovement(self.buttonModes.leftX, deltaX * self:GetDeltaModifierForCameraMode(self.buttonModes.leftX), not self.buttonModes.leftXinterpolate);
self:HandleMouseMovement(self.buttonModes.leftY, -deltaY * self:GetDeltaModifierForCameraMode(self.buttonModes.leftY), not self.buttonModes.leftYinterpolate);
end
if self:IsRightMouseButtonDown() then
local deltaX, deltaY = InputUtil.GetCursorDelta(GetAppropriateTopLevelParent());
self:HandleMouseMovement(self.buttonModes.rightX, deltaX * self:GetDeltaModifierForCameraMode(self.buttonModes.rightX), not self.buttonModes.rightXinterpolate);
self:HandleMouseMovement(self.buttonModes.rightY, -deltaY * self:GetDeltaModifierForCameraMode(self.buttonModes.rightY), not self.buttonModes.rightYinterpolate);
end
self:UpdateInterpolationTargets(elapsed);
self:SynchronizeCamera();
end
function OrbitCameraMixin:SynchronizeCamera() -- override
self:UpdateCameraOrientationAndPosition();
self:UpdateLight();
end
local function InterpolateDimension(lastValue, targetValue, amount, elapsed)
return lastValue and amount and DeltaLerp(lastValue, targetValue, amount, elapsed) or targetValue;
end
function OrbitCameraMixin:UpdateInterpolationTargets(elapsed)
local yaw, pitch, roll = self:GetDerivedOrientation();
local targetX, targetY, targetZ = self:GetDerivedTarget();
local zoomDistance = self:GetDerivedZoomDistance();
self.interpolatedYaw = InterpolateDimension(self.interpolatedYaw, yaw, self.yawInterpolationAmount, elapsed);
self.interpolatedPitch = InterpolateDimension(self.interpolatedPitch, pitch, self.pitchInterpolationAmount, elapsed);
self.interpolatedRoll = InterpolateDimension(self.interpolatedRoll, roll, self.rollInterpolationAmount, elapsed);
self.interpolatedZoomDistance = InterpolateDimension(self.interpolatedZoomDistance, zoomDistance, self.zoomInterpolationAmount, elapsed);
self.interpolatedTargetX = InterpolateDimension(self.interpolatedTargetX, targetX, self.targetInterpolationAmount, elapsed);
self.interpolatedTargetY = InterpolateDimension(self.interpolatedTargetY, targetY, self.targetInterpolationAmount, elapsed);
self.interpolatedTargetZ = InterpolateDimension(self.interpolatedTargetZ, targetZ, self.targetInterpolationAmount, elapsed);
end
function OrbitCameraMixin:UpdateCameraOrientationAndPosition()
local yaw, pitch, roll = self:GetInterpolatedOrientation();
local axisAngleX, axisAngleY, axisAngleZ = Vector3D_CalculateNormalFromYawPitch(yaw, pitch);
local targetX, targetY, targetZ = self:GetInterpolatedTarget();
local zoomDistance = self:GetInterpolatedZoomDistance();
-- Panning start --
-- We want the model to move 1-to-1 with the mouse.
-- Panning formula: dx / hypotenuse * (zoomDistance - 1 / zoomDistance^3)
-- It was experimentally determined that adding the additional fudge factor 1/z^3 resulted in better tracking.
local width = self:GetOwningScene():GetWidth();
local height = self:GetOwningScene():GetHeight();
local scaleFactor = (width ~= 0 and height ~= 0) and math.sqrt(width * width + height * height) or 1;
local zoomFactor = 1;
if zoomDistance > 1 then
zoomFactor = zoomDistance - (1 / (zoomDistance * zoomDistance * zoomDistance));
if zoomFactor < 1 then
zoomFactor = 1;
end
end
local rightX, rightY, rightZ = Vector3D_ScaleBy((self.panningXOffset / scaleFactor) * zoomFactor, self:GetRightVector());
local upX, upY, upZ = Vector3D_ScaleBy((self.panningYOffset / scaleFactor) * zoomFactor, self:GetUpVector());
-- Panning end --
targetX, targetY, targetZ = Vector3D_Add(targetX, targetY, targetZ, rightX, rightY, rightZ);
targetX, targetY, targetZ = Vector3D_Add(targetX, targetY, targetZ, upX, upY, upZ);
self:SetPosition(self:CalculatePositionByDistanceFromTarget(targetX, targetY, targetZ, zoomDistance, axisAngleX, axisAngleY, axisAngleZ));
self:GetOwningScene():SetCameraOrientationByYawPitchRoll(yaw, pitch, roll);
end
function OrbitCameraMixin:UpdateLight()
if self:ShouldAlignLightToOrbitDelta() then
local owningScene = self:GetOwningScene();
owningScene:SetLightDirection(self:GetForwardVector());
end
end
function OrbitCameraMixin:CalculatePositionByDistanceFromTarget(targetX, targetY, targetZ, zoomDistance, axisAngleX, axisAngleY, axisAngleZ)
local towardsPosX, towardsPosY, towardsPosZ = Vector3D_ScaleBy(-zoomDistance, axisAngleX, axisAngleY, axisAngleZ);
return Vector3D_Add(towardsPosX, towardsPosY, towardsPosZ, targetX, targetY, targetZ);
end
function OrbitCameraMixin:CalculateTransitionalValues(fromModelSceneCameraInfo, toModelSceneCameraInfo, modificationType)
if fromModelSceneCameraInfo and modificationType == CAMERA_MODIFICATION_TYPE_MAINTAIN then
local zoomedTargetOffsetX, zoomedTargetOffsetY, zoomedTargetOffsetZ = 0, 0, 0;
local targetSpline = self:GetTargetSpline();
if targetSpline and targetSpline:GetNumPoints() > 0 then
zoomedTargetOffsetX, zoomedTargetOffsetY, zoomedTargetOffsetZ = targetSpline:GetPoint(targetSpline:GetNumPoints());
end
local zoomedYawOffset, zoomedPitchOffset, zoomedRollOffset = 0, 0, 0;
local orientationSpline = self:GetOrientationSpline();
if orientationSpline and orientationSpline:GetNumPoints() > 0 then
zoomedYawOffset, zoomedPitchOffset, zoomedRollOffset = orientationSpline:GetPoint(orientationSpline:GetNumPoints());
end
local fromZoomDistancePercent = PercentageBetween(fromModelSceneCameraInfo.zoomDistance, fromModelSceneCameraInfo.minZoomDistance, fromModelSceneCameraInfo.maxZoomDistance);
local toZoomDistancePercent = PercentageBetween(toModelSceneCameraInfo.zoomDistance, toModelSceneCameraInfo.minZoomDistance, toModelSceneCameraInfo.maxZoomDistance);
-- Maintain any modifications made by applying the scene delta
return {
target = CreateVector3D(
(self.targetX - fromModelSceneCameraInfo.target.x) + toModelSceneCameraInfo.target.x,
(self.targetY - fromModelSceneCameraInfo.target.y) + toModelSceneCameraInfo.target.y,
(self.targetZ - fromModelSceneCameraInfo.target.z) + toModelSceneCameraInfo.target.z
),
yaw = (self.yaw - fromModelSceneCameraInfo.yaw) + toModelSceneCameraInfo.yaw,
pitch = (self.pitch - fromModelSceneCameraInfo.pitch) + toModelSceneCameraInfo.pitch,
roll = (self.roll - fromModelSceneCameraInfo.roll) + toModelSceneCameraInfo.roll,
zoomDistance = Lerp(toModelSceneCameraInfo.minZoomDistance, toModelSceneCameraInfo.maxZoomDistance, (self.zoomPercent - fromZoomDistancePercent) + toZoomDistancePercent),
minZoomDistance = (self.minZoomDistance - fromModelSceneCameraInfo.minZoomDistance) + toModelSceneCameraInfo.minZoomDistance,
maxZoomDistance = (self.maxZoomDistance - fromModelSceneCameraInfo.maxZoomDistance) + toModelSceneCameraInfo.maxZoomDistance,
zoomedTargetOffset = CreateVector3D(
(zoomedTargetOffsetX - fromModelSceneCameraInfo.zoomedTargetOffset.x) + toModelSceneCameraInfo.zoomedTargetOffset.x,
(zoomedTargetOffsetY - fromModelSceneCameraInfo.zoomedTargetOffset.y) + toModelSceneCameraInfo.zoomedTargetOffset.y,
(zoomedTargetOffsetZ - fromModelSceneCameraInfo.zoomedTargetOffset.z) + toModelSceneCameraInfo.zoomedTargetOffset.z
),
zoomedYawOffset = (zoomedYawOffset - fromModelSceneCameraInfo.zoomedYawOffset) + toModelSceneCameraInfo.zoomedYawOffset,
zoomedPitchOffset = (zoomedPitchOffset - fromModelSceneCameraInfo.zoomedPitchOffset) + toModelSceneCameraInfo.zoomedPitchOffset,
zoomedRollOffset = (zoomedRollOffset - fromModelSceneCameraInfo.zoomedRollOffset) + toModelSceneCameraInfo.zoomedRollOffset;
};
end
-- Nothing to transition from, just go directly to the values
return toModelSceneCameraInfo;
end
function OrbitCameraMixin:OnMouseWheel(delta) -- override
self:HandleMouseMovement(self.buttonModes.wheel, delta * self:GetDeltaModifierForCameraMode(self.buttonModes.wheel), not self.buttonModes.wheelInterpolate);
end
function OrbitCameraMixin:AdjustYaw(deltaX, deltaY, rotationScalar)
local xRotation = rotationScalar or self:GetDeltaModifierForCameraMode(self.buttonModes.leftX);
self:HandleMouseMovement(self.buttonModes.leftX, deltaX * xRotation, not self.buttonModes.leftXinterpolate);
local yRotatation = rotationScalar or self:GetDeltaModifierForCameraMode(self.buttonModes.leftY);
self:HandleMouseMovement(self.buttonModes.leftY, -deltaY * yRotatation, not self.buttonModes.leftYinterpolate);
end
@@ -107,6 +107,12 @@ function GameTooltip_AddNormalLine(tooltip, text, wrap, leftOffset)
GameTooltip_AddColoredLine(tooltip, text, NORMAL_FONT_COLOR, wrap, leftOffset);
end
-- Retail's takes `...` and passes it on; named parameters keep Lua 5.0 from
-- building an `arg` table per call.
function GameTooltip_AddBodyLine(tooltip, text, wrap, leftOffset)
GameTooltip_AddNormalLine(tooltip, text, wrap, leftOffset);
end
function GameTooltip_AddHighlightLine(tooltip, text, wrap, leftOffset)
GameTooltip_AddColoredLine(tooltip, text, HIGHLIGHT_FONT_COLOR, wrap, leftOffset);
end
@@ -8,6 +8,12 @@ function InputUtil.GetCursorPosition(parent)
return x / scale, y / scale;
end
function InputUtil.GetCursorDelta(parent)
local x, y = GetCursorDelta();
local scale = parent:GetEffectiveScale();
return x / scale, y / scale;
end
function InputUtil.AnchorRegionToCursor(region, point)
local parent = GetAppropriateTopLevelParent();
local x, y = InputUtil.GetCursorPosition(parent);
@@ -6,8 +6,9 @@
-- (scrapping, runeforging, soulbinds, keystones, item upgrades,
-- recrafting, bank tabs), and the stock bag and character frames never
-- ask for it.
-- - No ItemUtil.CreateItemTransmogInfo or ItemTransmogInfoMixin: there is
-- no transmogrification.
-- - ItemTransmogInfoMixin's appearanceID is an item ID and its illusionID a
-- SpellItemEnchantment ID, as the ModelSceneActor ItemTransmogInfo
-- methods read and write them.
-- - ItemUtil.GetItemDetails has no currency path: there are no currencies.
-- - No ItemUtil.GetCraftingReagentCount: it picks between the reagent bank
-- and the account bank, and neither exists.
@@ -95,6 +96,10 @@ function ItemUtil.FilterOwnedItems(itemIDs)
return filtered;
end
function ItemUtil.CreateItemTransmogInfo(appearanceID, secondaryAppearanceID, illusionID)
return CreateAndInitFromMixin(ItemTransmogInfoMixin, appearanceID, secondaryAppearanceID, illusionID);
end
function ItemUtil.TransformItemIDsToItems(itemIDs)
local items = {};
for index, itemID in ipairs(itemIDs) do
@@ -194,3 +199,42 @@ function ItemUtil.GetEquipSlotTexture(equipSlot)
return nil;
end
ItemTransmogInfoMixin = {};
function ItemTransmogInfoMixin:Init(appearanceID, secondaryAppearanceID, illusionID)
self.appearanceID = appearanceID;
self.secondaryAppearanceID = secondaryAppearanceID or Constants.Transmog.NoTransmogID;
self.illusionID = illusionID or Constants.Transmog.NoTransmogID;
end
function ItemTransmogInfoMixin:IsEqual(itemTransmogInfo)
if not itemTransmogInfo then
return false;
end
return self.appearanceID == itemTransmogInfo.appearanceID and self.secondaryAppearanceID == itemTransmogInfo.secondaryAppearanceID and self.illusionID == itemTransmogInfo.illusionID;
end
function ItemTransmogInfoMixin:Clear()
self.appearanceID = Constants.Transmog.NoTransmogID;
self.secondaryAppearanceID = Constants.Transmog.NoTransmogID;
self.illusionID = Constants.Transmog.NoTransmogID;
end
-- There is no slot info in ItemTransmogInfo so the following 3 MainHand functions must be used with correct itemTransmogInfo at call site
function ItemTransmogInfoMixin:ConfigureSecondaryForMainHand(isLegionArtifact)
if isLegionArtifact then
self.secondaryAppearanceID = Constants.Transmog.MainHandTransmogIsPairedWeapon;
else
self.secondaryAppearanceID = Constants.Transmog.MainHandTransmogIsIndividualWeapon;
end
end
function ItemTransmogInfoMixin:IsMainHandIndividualWeapon()
return self.secondaryAppearanceID == Constants.Transmog.MainHandTransmogIsIndividualWeapon;
end
function ItemTransmogInfoMixin:IsMainHandPairedWeapon()
-- paired weapon can be value Constants.Transmog.MainHandTransmogIsPairedWeapon or greater
return not self:IsMainHandIndividualWeapon();
end
@@ -224,6 +224,9 @@ end
-- extended `GameTooltip:SetHyperlink` (src/tooltip/Hyperlink.cpp), which
-- builds the same tooltip `GameTooltip:SetSpellByID` builds, so the handler
-- passes the payload through instead of rebuilding the tooltip itself.
-- Control-click is retail's dress-up click: DressUpLink previews a mount
-- spell, and nothing else happens, as retail's modified click shows no
-- tooltip.
------------------------------------------------------------------------------
LinkUtil.RegisterLinkHandler(LinkTypes.Spell, function(link, text, linkData, contextData)
local spellID = tonumber(string.match(link, "^spell:(%d+)"));
@@ -234,6 +237,10 @@ LinkUtil.RegisterLinkHandler(LinkTypes.Spell, function(link, text, linkData, con
ChatFrameEditBox:Insert(text);
return LinkProcessorResponse.Handled;
end
if IsControlKeyDown() then
DressUpLink(text);
return LinkProcessorResponse.Handled;
end
local tooltip = ItemRefTooltip or GameTooltip;
tooltip:SetOwner(UIParent, "ANCHOR_PRESERVE");
tooltip:SetHyperlink(link);
@@ -250,13 +257,14 @@ end);
-- links. Route spell links Turtle would drop back through SetItemRef so the
-- Spell handler above shows them; Turtle-shaped links
-- (`spell:<id>:<bookType>:<player>`) pass through to Turtle's handler
-- untouched. On a stock client the original already forwards everything to
-- SetItemRef, so both branches behave the same there.
-- untouched, except a Control-click, which previews the mount here. On a
-- stock client the original already forwards everything to SetItemRef, so
-- both branches behave the same there.
------------------------------------------------------------------------------
local originalChatFrameOnHyperlinkShow = ChatFrame_OnHyperlinkShow;
function ChatFrame_OnHyperlinkShow(link, text, button)
if type(link) == "string" and strsub(link, 1, 6) == "spell:" and
not string.find(link, "^spell:(%d+):(%w+):(%w+)") then
(IsControlKeyDown() or not string.find(link, "^spell:(%d+):(%w+):(%w+)")) then
SetItemRef(link, text, button);
return;
end
@@ -74,6 +74,10 @@ OPTION_TOOLTIP_INTERACT_ICONS = "Legt fest, welche Symbole über NSCs und Gegens
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "Automatischen Angriff stoppen, sobald das Ziel gewechselt wird.";
KEY_BINDING_NAME_AND_KEY = "%s (%s)";
KEY_BINDING_TOOLTIP = "<Zum Löschen der Belegung rechtsklicken>";
RESET_POSITION = "Zurücksetzen";
ROTATE_LEFT = "Linksdrehung";
ROTATE_RIGHT = "Rechtsdrehung";
ROTATE_TOOLTIP = "Linksklicken und ziehen, um den Charakter zu drehen.";
SETTINGS_BIND_KEY_TO_COMMAND_OR_CANCEL = "Tastaturbelegung für \"%s\" zuweisen oder mit %s abbrechen";
SET_FOCUS = "Fokus setzen";
FOCUSTARGET = "Ziel als Fokus festlegen";
@@ -17,7 +17,7 @@ CLASSICAPI_AUTO_DISMOUNT_TOOLTIP = "Dismount when you cast a spell, use an objec
CLASSICAPI_AUTO_STAND = "Auto Stand";
CLASSICAPI_AUTO_STAND_TOOLTIP = "Stand up when you cast a spell, loot, or open the flight map while you sit.";
CLASSICAPI_AUTO_UNSHIFT = "Auto Cancel Form";
CLASSICAPI_AUTO_UNSHIFT_TOOLTIP = "Leave your form when you cast a spell that you cannot cast in it, talk to an NPC, use an object or an item, or open the flight map.";
CLASSICAPI_AUTO_UNSHIFT_TOOLTIP = "Leave your form when you cast a spell that you cannot cast in it, use an item, or open the flight map. Also lets you talk to NPCs and use objects in druid forms and Ghost Wolf.";
CLASSICAPI_BAGS_AND_ITEMS = "Bags and Items";
CLASSICAPI_CASTING_AND_ATTACKING = "Casting and Attacking";
CLASSICAPI_COMBAT_LOG_RETENTION = "Combat Log Retention";
@@ -164,6 +164,10 @@ OPTION_TOOLTIP_INTERACT_ICONS = "Controls which icons appear above NPCs and obje
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "Stop auto attacking when you switch targets.";
KEY_BINDING_NAME_AND_KEY = "%s (%s)";
KEY_BINDING_TOOLTIP = "<Right click to unbind>";
RESET_POSITION = "Reset Position";
ROTATE_LEFT = "Rotate Left";
ROTATE_RIGHT = "Rotate Right";
ROTATE_TOOLTIP = "Left-click on character and drag to rotate.";
SETTINGS_BIND_KEY_TO_COMMAND_OR_CANCEL = "Assign Binding for \"%s\" or Press %s to Cancel";
SET_FOCUS = "Set Focus";
FOCUS_TOKEN_NOT_FOUND = "<no focus>";
@@ -74,6 +74,10 @@ OPTION_TOOLTIP_INTERACT_ICONS = "Controla los iconos que aparecen sobre los PNJ
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "Detiene el ataque automático al cambiar de objetivo.";
KEY_BINDING_NAME_AND_KEY = "%s (%s)";
KEY_BINDING_TOOLTIP = "<Haz clic derecho para quitar la asignación>";
RESET_POSITION = "Restablecer posición";
ROTATE_LEFT = "Rotar a la izquierda";
ROTATE_RIGHT = "Rotar a la derecha";
ROTATE_TOOLTIP = "Haz clic izquierdo en el personaje y arrastra para rotar.";
SETTINGS_BIND_KEY_TO_COMMAND_OR_CANCEL = "Asignar atajo para \"%s\" o pulsar %s para cancelar";
SET_FOCUS = "Establecer foco";
FOCUSTARGET = "Establecer objetivo";
@@ -74,6 +74,10 @@ OPTION_TOOLTIP_INTERACT_ICONS = "Contrôle les icônes qui s’affichent au-dess
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "Interrompt l'attaque automatique lorsque vous changez de cible.";
KEY_BINDING_NAME_AND_KEY = "%s (%s)";
KEY_BINDING_TOOLTIP = "<Clic droit pour retirer>";
RESET_POSITION = "Position par défaut";
ROTATE_LEFT = "Pivoter à gauche";
ROTATE_RIGHT = "Pivoter à droite";
ROTATE_TOOLTIP = "Cliquez sur le personnage et faites glisser la souris pour le faire pivoter.";
SETTINGS_BIND_KEY_TO_COMMAND_OR_CANCEL = "Assigner un raccourci pour « %s » ou appuyer sur %s pour annuler";
SET_FOCUS = "Définir la focalisation";
FOCUSTARGET = "Définir la cible de focalisation";
@@ -5,6 +5,10 @@ DECIMAL_SEPERATOR = ".";
FIRST_NUMBER = "천";
FIRST_NUMBER_CAP = "만";
FIRST_NUMBER_CAP_NO_SPACE = "만";
RESET_POSITION = "위치 초기화";
ROTATE_LEFT = "왼쪽으로 회전";
ROTATE_RIGHT = "오른쪽으로 회전";
ROTATE_TOOLTIP = "마우스 왼쪽 버튼으로 클릭 후 끌어 옮기면 캐릭터를 회전할 수 있습니다.";
SECOND_NUMBER = "만";
SECOND_NUMBER_CAP = "억";
SECOND_NUMBER_CAP_NO_SPACE = "억";
@@ -19,7 +19,7 @@ CLASSICAPI_AUTO_DISMOUNT_TOOLTIP = "Спешиваться при примене
CLASSICAPI_AUTO_STAND = "Автоматически вставать";
CLASSICAPI_AUTO_STAND_TOOLTIP = "Вставать при применении заклинания, сборе добычи или открытии карты полетов, если вы сидите.";
CLASSICAPI_AUTO_UNSHIFT = "Автоматически снимать облик";
CLASSICAPI_AUTO_UNSHIFT_TOOLTIP = "Покидать свой облик при применении заклинания, которое в нем недоступно, разговоре с NPC, использовании объекта или предмета, а также при открытии карты полетов.";
CLASSICAPI_AUTO_UNSHIFT_TOOLTIP = "Покидать свой облик при применении заклинания, которое в нем недоступно, использовании предмета, а также при открытии карты полетов. Также позволяет говорить с NPC и использовать объекты в обликах друида и призрачного волка.";
CLASSICAPI_BAGS_AND_ITEMS = "Сумки и предметы";
CLASSICAPI_CASTING_AND_ATTACKING = "Заклинания и атаки";
CLASSICAPI_COMBAT_LOG_RETENTION = "Хранение журнала боя";
@@ -166,6 +166,10 @@ OPTION_TOOLTIP_INTERACT_ICONS = "Определяет, какие значки
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "Прекращать автоатаку при смене цели.";
KEY_BINDING_NAME_AND_KEY = "%s (%s)";
KEY_BINDING_TOOLTIP = "<ПКМ, чтобы снять привязку>";
RESET_POSITION = "Сброс позиции";
ROTATE_LEFT = "Вращение влево";
ROTATE_RIGHT = "Вращение вправо";
ROTATE_TOOLTIP = "Зажмите ЛКМ и тащите курсор, чтобы вращать изображение.";
SETTINGS_BIND_KEY_TO_COMMAND_OR_CANCEL = "Назначьте клавишу для \"%s\" или нажмите %s для отмены";
SET_FOCUS = "Запомнить цель";
FOCUS_TOKEN_NOT_FOUND = "<фокус отсутствует>";
@@ -19,7 +19,7 @@ CLASSICAPI_AUTO_DISMOUNT_TOOLTIP = "骑乘状态下施放法术、使用物体
CLASSICAPI_AUTO_STAND = "自动站起";
CLASSICAPI_AUTO_STAND_TOOLTIP = "坐下状态下施放法术、拾取或打开飞行地图时自动站起。";
CLASSICAPI_AUTO_UNSHIFT = "自动取消形态";
CLASSICAPI_AUTO_UNSHIFT_TOOLTIP = "施放当前形态无法施放的法术、与NPC对话、使用物体或物品、或打开飞行地图时自动取消该形态。";
CLASSICAPI_AUTO_UNSHIFT_TOOLTIP = "施放当前形态无法施放的法术、使用物品或打开飞行地图时自动取消该形态。同时允许在德鲁伊形态和幽魂之狼形态下与NPC对话和使用物体。";
CLASSICAPI_BAGS_AND_ITEMS = "背包与物品";
CLASSICAPI_CASTING_AND_ATTACKING = "施法与攻击";
CLASSICAPI_COMBAT_LOG_RETENTION = "战斗记录保留时间";
@@ -163,6 +163,10 @@ OPTION_TOOLTIP_INTERACT_ICONS = "控制交互按键启用后NPC和物体上方
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "当你切换目标时停止自动攻击。";
KEY_BINDING_NAME_AND_KEY = "%s(%s)";
KEY_BINDING_TOOLTIP = "<右键解除键位>";
RESET_POSITION = "重置位置";
ROTATE_LEFT = "左转";
ROTATE_RIGHT = "右转";
ROTATE_TOOLTIP = "左键点击并拖曳以旋转角色";
SETTINGS_BIND_KEY_TO_COMMAND_OR_CANCEL = "设置 \"%s\"的快捷键,或者按 %s 取消";
SET_FOCUS = "设置焦点";
FOCUSTARGET = "设定焦点目标";
@@ -74,6 +74,10 @@ OPTION_TOOLTIP_INTERACT_ICONS = "在啟用互動鍵功能後,控制出現在NP
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "當你切換目標時停止自動攻擊。";
KEY_BINDING_NAME_AND_KEY = "%s(%s)";
KEY_BINDING_TOOLTIP = "<右鍵點擊以解除綁定>";
RESET_POSITION = "重置位置";
ROTATE_LEFT = "向左旋轉";
ROTATE_RIGHT = "向右旋轉";
ROTATE_TOOLTIP = "滑鼠左鍵點擊角色並拖曳以旋轉角度。";
SETTINGS_BIND_KEY_TO_COMMAND_OR_CANCEL = "為\"%s\"指配綁定或是按下%s以取消";
SET_FOCUS = "設定專注目標";
FOCUSTARGET = "設定鎖定目標";
+88 -38
View File
@@ -19,6 +19,7 @@
#include "frame/ScriptArgs.h"
#include "frame/UnitEvent.h"
#include "profile/ScriptTime.h"
#include "storm/Memory.h"
#include <cstdint>
#include <cstring>
@@ -55,6 +56,10 @@ struct Reservation {
int slot; // -1 until claimed
const Game::Doc::Event *doc; // API documentation; nullptr = undocumented
int fixedId; // an `EngineName`'s engine id (its slot for good); -1 = claimed
// The SStrDup'd name `Claim` wrote into `slot`'s entry; nullptr while
// unclaimed. `RetryClaims` compares it against the live entry to notice
// that the engine rebuilt the table underneath a cached slot.
const char *claimed;
};
Reservation g_reserved[MAX_RESERVED];
int g_reservedCount = 0;
@@ -63,7 +68,7 @@ bool g_writesEnabled = false;
// claim) is logged once per session instead of silently dropping the
// event — the failure mode that would otherwise rot unnoticed. Re-armed on
// `/reload` (the table is rebuilt fresh). In practice this never triggers:
// the engine leaves ~115 claimable gaps below 549 (see `TryClaim`) against
// the engine leaves ~115 claimable gaps below 549 (see `Claim`) against
// our ~77 reservations, and `Grow` adds room past that. The log is
// purely a backstop if some future install somehow drains the gap pool.
bool g_warnedTableFull = false;
@@ -90,23 +95,6 @@ char *SStrDup(const char *s) {
return fn(s, __FILE__, __LINE__);
}
// Storm allocator/free — the same pair the engine uses for the event-table
// buffer (see RebuildEventTable), so its reload teardown `SMemFree`s our
// grown buffer exactly as its own. Both `__stdcall`, 4 args (`ret 0x10`).
// SMemAlloc flag bit 8 zero-fills (matching SetEventCount's fresh entries).
void *SMemAlloc(unsigned size) {
using SMemAlloc_t = void *(__stdcall *)(unsigned size, const char *file,
int line, int flags);
auto fn = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEM_ALLOC);
return fn(size, __FILE__, __LINE__, 8 /* zero-fill */);
}
void SMemFree(void *ptr) {
using SMemFree_t = int(__stdcall *)(void *ptr, const char *file, int line,
int flags);
auto fn = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
fn(ptr, __FILE__, __LINE__, 0);
}
// True when slot `id` of the live table can take one of our names: its name
// is NULL (no one owns it) AND the engine never signals it by number. A NULL
// name alone is not enough — the glue band (ids below GLUE_EVENT_COUNT) and
@@ -147,23 +135,39 @@ int ClaimDemand() {
// tail. Below 549 only nampower writes, at 369 and 540, both before any Lua
// runs, so skip-occupied steps over them. Claiming low keeps us out of
// everyone's way and independent of their clamp / hook order.
int TryClaim(const char *eventName) {
//
// Claims only into the IN-GAME table. `CGGameUI::Initialize` (0x0048FE00)
// calls `RegisterScriptFunctions` (our `EnableWrites` hook) and only THEN
// `SetScriptEventTable(0xBE1198, 0x225)`, so between the two the live table
// is still glue's 26-entry one. A `Frame::RegisterEvent` in that window
// (a third-party DLL registering a frame from its own LoadScriptFunctions
// hook — issue #134) used to grow the glue table and claim into it; the
// engine then freed that buffer and our cached ids silently aliased engine
// events in the real table. The in-game build always passes 0x225 names
// (clamps by other DLLs only enlarge it), so a smaller count is not it.
bool GameTableInstalled(int count) {
return count >= Offsets::EVENT_NAME_TABLE_STATIC_COUNT;
}
void Claim(Reservation &r) {
if (!g_writesEnabled)
return -1;
return;
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || count <= 0)
return -1;
if (base == nullptr || !GameTableInstalled(count))
return;
for (int i = 0; i < count; ++i) {
if (Claimable(base, i)) {
auto **namePtr = Game::Ptr<const char *>(
base + i * Offsets::EVENT_ENTRY_STRIDE,
Offsets::OFF_EVENT_ENTRY_NAME);
char *copy = SStrDup(eventName);
char *copy = SStrDup(r.name);
if (copy == nullptr)
return -1;
return;
*namePtr = copy;
return i;
r.slot = i;
r.claimed = copy;
return;
}
}
// Writes are enabled and the table is valid, but every slot is taken —
@@ -173,9 +177,39 @@ int TryClaim(const char *eventName) {
Debug::Log::Printf(
"[event] table full (%d slots, no NULL left) — cannot claim '%s'"
" and any further custom events; even Grow couldn't make room.",
count, eventName ? eventName : "?");
count, r.name);
}
return -1;
}
// True when a claimed reservation's slot no longer holds its name: the
// engine rebuilt the table (every entry re-`SStrDup`'d at a fresh buffer)
// without our `PrepareForReload` seeing it. The pointer test is the cheap
// common case; the strcmp covers an allocator handing the engine our freed
// block for a different name.
bool Stale(const Reservation &r) {
if (r.claimed == nullptr)
return false;
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || r.slot >= count)
return true;
const char *live = Game::Read<const char *>(
base + r.slot * Offsets::EVENT_ENTRY_STRIDE, Offsets::OFF_EVENT_ENTRY_NAME);
if (live == r.claimed)
return false;
return live == nullptr || std::strcmp(live, r.name) != 0;
}
// Forget every claimed slot: the table they were claimed in is gone. The
// engine freed our name copies with its per-entry teardown, so nothing to
// release here. Re-arms the per-build one-shots.
void ForgetClaims() {
for (int i = 0; i < g_reservedCount; ++i) {
g_reserved[i].slot = g_reserved[i].fixedId;
g_reserved[i].claimed = nullptr;
}
g_warnedTableFull = false;
g_growLatched = false;
}
// On-demand event-table grow. Correctly TIMED: called once per in-game
@@ -205,12 +239,14 @@ int TryClaim(const char *eventName) {
void Grow() {
if (g_growLatched || !g_writesEnabled)
return;
g_growLatched = true; // one attempt per build, whether or not it grows
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
if (base == nullptr || !GameTableInstalled(count))
return; // still glue's table — the in-game build hasn't run yet
g_growLatched = true; // one attempt per build, whether or not it grows
const int demand = ClaimDemand();
if (base == nullptr || count <= 0 || demand <= 0)
if (demand <= 0)
return;
int nulls = 0;
@@ -242,8 +278,12 @@ void Grow() {
constexpr int kMargin = 16;
const int deficit = (nulls < demand) ? (demand - nulls) : 0;
const int newCount = count + deficit + kMargin;
// Storm's heap, like the engine's own table buffer (see RebuildEventTable),
// so its reload teardown SMemFrees ours the same way; zero-filled, like
// SetEventCount's fresh entries.
auto *newBuf = static_cast<uint8_t *>(
SMemAlloc(static_cast<unsigned>(newCount) * Offsets::EVENT_ENTRY_STRIDE));
Storm::Alloc(static_cast<size_t>(newCount) * Offsets::EVENT_ENTRY_STRIDE, __FILE__,
__LINE__, true));
if (newBuf == nullptr)
return; // alloc failed — keep the old table; claiming still works
@@ -271,7 +311,7 @@ void Grow() {
Game::Ref<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR) = newBuf;
Game::Ref<int>(Offsets::VAR_EVENT_TABLE_CAP) = newCount;
Game::Ref<int>(Offsets::VAR_EVENT_TABLE_COUNT) = newCount;
SMemFree(base);
Storm::Free(base, __FILE__, __LINE__);
Debug::Log::Printf("[event] grew table %d -> %d (%d NULL, %d reserved%s)",
count, newCount, nulls, demand,
@@ -320,6 +360,7 @@ AutoReserve::AutoReserve(const char *name, const Game::Doc::Event *doc) {
g_reserved[g_reservedCount].slot = -1;
g_reserved[g_reservedCount].doc = doc;
g_reserved[g_reservedCount].fixedId = -1;
g_reserved[g_reservedCount].claimed = nullptr;
index_ = g_reservedCount;
++g_reservedCount;
}
@@ -339,7 +380,7 @@ EngineName::EngineName(int id, const char *name, const Game::Doc::Event *doc) {
if (*slot != nullptr)
return; // the engine names it; never take an engine event's slot
*slot = name;
g_reserved[g_reservedCount] = {name, id, doc, id};
g_reserved[g_reservedCount] = {name, id, doc, id, nullptr};
++g_reservedCount;
}
@@ -428,6 +469,18 @@ void FireValues(int slot, void *L, int firstArgIdx, int nargs) {
}
void RetryClaims() {
// A table rebuilt behind our back (no FrameScript_Initialize in between)
// leaves every cached slot pointing at whatever engine event now sits at
// that id. Check the claims against the live table and start over if any
// moved — we run before the original registers, so no frame has joined a
// chain in the new table yet.
for (int i = 0; i < g_reservedCount; ++i) {
if (Stale(g_reserved[i])) {
Debug::Log::Printf("[event] table rebuilt under cached slots — re-claiming");
ForgetClaims();
break;
}
}
// Grow the table first if the low gap pool is short (dormant valve).
// Runs at most once per table build; on the first call every chain is
// still empty (this is the first registration), which is the only safe
@@ -435,18 +488,15 @@ void RetryClaims() {
Grow();
for (int i = 0; i < g_reservedCount; ++i) {
if (g_reserved[i].slot < 0)
g_reserved[i].slot = TryClaim(g_reserved[i].name);
Claim(g_reserved[i]);
}
}
void EnableWrites() { g_writesEnabled = true; }
void PrepareForReload() {
for (int i = 0; i < g_reservedCount; ++i)
g_reserved[i].slot = g_reserved[i].fixedId;
ForgetClaims();
g_writesEnabled = false;
g_warnedTableFull = false; // fresh table after the rebuild — re-arm
g_growLatched = false; // re-arm the one-shot grow for the new table
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
+1 -1
View File
@@ -201,7 +201,7 @@ inline void FireIdSuccess(int eventID, int id, bool success) {
// catch any reservations that couldn't claim earlier.
void RetryClaims();
// Internal: permit `TryClaim` to write to the event table. Held
// Internal: permit `Claim` to write to the event table. Held
// closed until `LoadScriptFunctions_h` returns, so the boot phase
// (during which the engine and SuperWoWhook fire many internal
// `RegisterEvent` calls) can't trigger our writes. Writing during
+2 -13
View File
@@ -39,19 +39,8 @@ namespace {
// FUN_FONTSTRING_FONT_HEIGHT — __thiscall(fs, scaled-on-stack); dummy-EDX form.
using FsFontHeight_t = float(__fastcall *)(void *fs, void *edx, int scaled);
using GetScaledScreenAspect_t = float(__cdecl *)();
// Declared double so the quotient is read from ST0 with `fstp qword`, as
// GetFont stores it, not narrowed to float.
using DivideByScreenScaleX_t = double(__stdcall *)(float);
// GetFont's push (0x0079D4A5..0x0079D4C4). `aspect * 1024` is exact, so the
// float product rounds once, as GetFont's `fstp dword` does.
double ToUiHeight(float internal) {
const float aspect =
reinterpret_cast<GetScaledScreenAspect_t>(Offsets::FUN_COORD_GET_SCALED_SCREEN_ASPECT)();
const float scaled = aspect * static_cast<float>(Offsets::UI_COORD_SCALE_UNIT) * internal;
return reinterpret_cast<DivideByScreenScaleX_t>(Offsets::FUN_COORD_DIVIDE_BY_SCREEN_SCALE_X)(scaled);
}
// GetFont's push (0x0079D4A5..0x0079D4C4).
double ToUiHeight(float internal) { return Game::InternalToPixel(internal); }
void *FontStringSelf(void *L) { return Game::Lua::ResolveFontString(L); }
void *FontSelf(void *L) { return Game::Lua::ResolveTypedObject(L, 1, Offsets::VAR_FONT_LUA_TYPE_ID); }
+2 -5
View File
@@ -15,6 +15,7 @@
#include "Game.h"
#include "Offsets.h"
#include "frame/Layout.h"
#include <cstdint>
@@ -24,9 +25,6 @@ namespace {
// FUN_HANDLE_RELEASE — DecRef a ref-counted handle. __fastcall(handle).
using HandleRelease_t = void(__fastcall *)(void *handle);
// FUN_LAYOUT_RESIZE — region layout resize (0 = invalidate). __thiscall(region,
// int); dummy-EDX form.
using LayoutInvalidate_t = void(__fastcall *)(void *region, void *edx, int arg);
} // namespace
@@ -39,8 +37,7 @@ void Invalidate(void *fs) {
Game::Ref<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK) = nullptr;
}
Game::Ref<uint32_t>(fs, Offsets::OFF_FONTSTRING_DIRTY_FLAGS) &= ~1u;
void *region = reinterpret_cast<void *>(reinterpret_cast<uintptr_t>(fs) + 0x24);
reinterpret_cast<LayoutInvalidate_t>(Offsets::FUN_LAYOUT_RESIZE)(region, nullptr, 0);
Frame::Layout::Resize(fs, false);
}
} // namespace FontString::Layout
+20 -34
View File
@@ -54,6 +54,8 @@
#include "Offsets.h"
#include "frame/ClipChildren.h"
#include "frame/Destruction.h"
#include "frame/Layout.h"
#include "gx/Device.h"
#include "texture/Load.h"
#include <cstdint>
@@ -63,19 +65,8 @@ namespace Frame::AlphaGradient {
namespace {
using Matrix_t = void(__fastcall *)(float *m);
using SetProjection_t = void(__fastcall *)(const float *m);
using GetViewport_t = void(__fastcall *)(float *minX, float *maxX, float *minY, float *maxY, float *minZ,
float *maxZ);
using SetViewport_t = void(__stdcall *)(float minX, float maxX, float minY, float maxY, float minZ, float maxZ);
using Convert_t = void(__fastcall *)(float *outX, float *outY, float x, float y);
using GetRect_t = int(__fastcall *)(void *layout, void *edx, float *out);
using GetRenderable_t = void *(__fastcall *)(void *hTex, int force, void *);
using RsSet_t = void(__fastcall *)(int selector, int value);
using RsSetPtr_t = void(__fastcall *)(int selector, const void *value);
using StateOp_t = void(__fastcall *)();
using TexMtxLoad_t = void(__fastcall *)(int unit, const float *m);
using TexMtxOp_t = void(__fastcall *)(int unit);
using SetFrameFlag_t = void(__fastcall *)(void *frame, void *edx, uint32_t mask, int on);
// A rect: {bottom, left, top, right}.
@@ -135,7 +126,7 @@ void *RampTexture() {
if (g_ramp == nullptr)
g_ramp = Texture::CreateFromPixels("ClassicAPI\\AlphaGradient", 2, 2, kRampTexels, Texture::DefaultFlags());
return g_ramp != nullptr
? reinterpret_cast<GetRenderable_t>(Offsets::FUN_TEXTURE_GET_RENDERABLE)(g_ramp, 1, nullptr)
? Texture::DeviceTexture(g_ramp)
: nullptr;
}
@@ -155,13 +146,12 @@ void RampColumn(const Stage &s, int axis, float vMin, float vMax, float col[4])
}
void Load(const Stage &s) {
float p[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
reinterpret_cast<Matrix_t>(Offsets::FUN_GX_GET_PROJECTION)(p);
float minX, maxX, minY, maxY, minZ, maxZ;
reinterpret_cast<GetViewport_t>(Offsets::FUN_GX_GET_VIEWPORT)(&minX, &maxX, &minY, &maxY, &minZ, &maxZ);
Gx::Matrix p;
Gx::GetProjection(p);
const Gx::Viewport vp = Gx::GetViewport();
float u[4], v[4];
RampColumn(s, 0, minX, maxX, u);
RampColumn(s, 1, minY, maxY, v);
RampColumn(s, 0, vp.minX, vp.maxX, u);
RampColumn(s, 1, vp.minY, vp.maxY, v);
float m[16];
for (int r = 0; r < 4; ++r) {
const float *row = p + r * 4;
@@ -170,7 +160,7 @@ void Load(const Stage &s) {
m[r * 4 + 2] = row[3];
m[r * 4 + 3] = 0.0f;
}
reinterpret_cast<TexMtxLoad_t>(Offsets::FUN_GX_TEXMTX_LOAD)(s.unit, m);
Gx::SetTransform(s.unit, m);
}
// Appends the stages of `frame`'s gradient at g_stages[g_count + n]. Returns
@@ -180,11 +170,9 @@ int AddStages(const void *frame, int n, int lastUnit) {
if (it == g_gradients.end())
return n;
float rect[4];
void *layout = const_cast<uint8_t *>(static_cast<const uint8_t *>(frame) + Offsets::OFF_REGION_ANCHOR);
if (reinterpret_cast<GetRect_t>(Offsets::FUN_REGION_GET_RECT)(layout, nullptr, rect) == 0)
if (!Frame::Layout::GetRect(frame, rect))
return n;
const float scale = Game::Read<float>(frame, Offsets::OFF_REGION_EFFECTIVE_SCALE);
const auto toNdc = reinterpret_cast<Convert_t>(Offsets::FUN_COORD_DDC_TO_NDC);
for (int index = 0; index < 2; ++index) {
const float lx = it->second.x[index], ly = it->second.y[index];
if (!(lx > 0.0f) && !(ly > 0.0f))
@@ -198,8 +186,8 @@ int AddStages(const void *frame, int n, int lastUnit) {
const float dy = Game::PixelToInternal(ly * scale) * (index == 0 ? -1.0f : 1.0f);
Stage &s = g_stages[g_count + n];
s.unit = 1 + g_count + n;
toNdc(&s.p0[0], &s.p0[1], ex, ey);
toNdc(&s.p1[0], &s.p1[1], ex + dx, ey + dy);
Frame::Layout::DdcToNdc(&s.p0[0], &s.p0[1], ex, ey);
Frame::Layout::DdcToNdc(&s.p1[0], &s.p1[1], ex + dx, ey + dy);
s.on[0] = lx > 0.0f && s.p1[0] != s.p0[0];
s.on[1] = ly > 0.0f && s.p1[1] != s.p0[1];
if (s.on[0] || s.on[1])
@@ -355,16 +343,14 @@ int Begin(const void *frame, bool ancestors) {
void *ramp = RampTexture();
if (ramp == nullptr)
return 0;
const auto rs = reinterpret_cast<RsSet_t>(Offsets::FUN_GX_RS_SET);
const auto rsPtr = reinterpret_cast<RsSetPtr_t>(Offsets::FUN_GX_RS_SET_PTR);
reinterpret_cast<StateOp_t>(Offsets::FUN_GX_STATE_PUSH)();
Gx::PushStates();
for (int i = g_count; i < g_count + n; ++i) {
const int unit = g_stages[i].unit;
rsPtr(Offsets::GXRS_TEXTURE0 + unit, ramp);
rs(Offsets::GXRS_COMBINER0 + unit, Offsets::GXRS_COMBINE_MODULATE);
rs(Offsets::GXRS_TEXGEN0 + unit, Offsets::GXRS_TEXGEN_CAMERA_POS);
rs(Offsets::GXRS_TEXXFORM0 + unit, Offsets::GXRS_TEXXFORM_PROJECTED);
reinterpret_cast<TexMtxOp_t>(Offsets::FUN_GX_TEXMTX_PUSH)(unit);
Gx::SetState(Offsets::GXRS_TEXTURE0 + unit, ramp);
Gx::SetState(Offsets::GXRS_COMBINER0 + unit, Offsets::GXRS_COMBINE_MODULATE);
Gx::SetState(Offsets::GXRS_TEXGEN0 + unit, Offsets::GXRS_TEXGEN_CAMERA_POS);
Gx::SetState(Offsets::GXRS_TEXXFORM0 + unit, Offsets::GXRS_TEXXFORM_PROJECTED);
Gx::PushTransform(unit);
Load(g_stages[i]);
}
g_count += n;
@@ -375,9 +361,9 @@ void End(int stages) {
if (stages == 0)
return;
for (int i = g_count - 1; i >= g_count - stages; --i)
reinterpret_cast<TexMtxOp_t>(Offsets::FUN_GX_TEXMTX_POP)(g_stages[i].unit);
Gx::PopTransform(g_stages[i].unit);
g_count -= stages;
reinterpret_cast<StateOp_t>(Offsets::FUN_GX_STATE_POP)();
Gx::PopStates();
}
int StagesInUse() { return g_count; }
+4 -11
View File
@@ -30,6 +30,7 @@
#include "Game.h"
#include "Offsets.h"
#include "frame/Layout.h"
#include <cfloat>
#include <cstdint>
@@ -43,20 +44,12 @@ struct Rect {
float bottom, left, top, right;
};
using GetRect_t = int(__fastcall *)(void *layout, void *edx, Rect *out);
using LayoutDirty_t = int(__fastcall *)(void *layout);
using Resize_t = void(__fastcall *)(void *layout, void *edx, int force);
void *LayoutOf(void *region) { return static_cast<uint8_t *>(region) + Offsets::OFF_REGION_ANCHOR; }
// Grows `rect` by a region's own rect, resolving a pending layout first.
void AddRegionRect(void *region, Rect &rect) {
void *layout = LayoutOf(region);
if (reinterpret_cast<LayoutDirty_t>(Offsets::FUN_REGION_LAYOUT_DIRTY)(layout) != 0)
reinterpret_cast<Resize_t>(Offsets::FUN_LAYOUT_RESIZE)(layout, nullptr, 1);
Rect own{};
if (reinterpret_cast<GetRect_t>(Offsets::FUN_REGION_GET_RECT)(layout, nullptr, &own) == 0)
float r[4];
if (!Frame::Layout::GetResolvedRect(region, r))
return;
const Rect own{r[0], r[1], r[2], r[3]};
rect.left = own.left < rect.left ? own.left : rect.left;
rect.right = own.right > rect.right ? own.right : rect.right;
rect.bottom = own.bottom < rect.bottom ? own.bottom : rect.bottom;
+3 -5
View File
@@ -34,6 +34,7 @@
#include "Game.h"
#include "Offsets.h"
#include "frame/Layout.h"
#include <cstdint>
#include <unordered_map>
@@ -54,10 +55,7 @@ std::unordered_map<void *, Insets> g_insets;
using CalcRect_t = int(__thiscall *)(void *layout, float *rect);
CalcRect_t g_origCalcRect = nullptr;
using Invalidate_t = void(__thiscall *)(void *layout, int arg);
void *LayoutOf(void *frame) { return static_cast<uint8_t *>(frame) + Offsets::OFF_REGION_ANCHOR; }
void *FrameOf(void *layout) { return static_cast<uint8_t *>(layout) - Offsets::OFF_REGION_ANCHOR; }
// A length in the frame's own units (what GetLeft returns) in layout units:
// the inverse of Script GetLeft's conversion.
@@ -70,7 +68,7 @@ int __fastcall CalcRect_h(void *layout, void * /*edx*/, float *rect) {
const uint32_t flags = Game::Read<uint32_t>(layout, Offsets::OFF_LAYOUT_FLAGS);
if ((flags & Offsets::LAYOUT_FLAG_CLAMPED) == 0 || g_insets.empty())
return g_origCalcRect(layout, rect);
void *frame = FrameOf(layout);
void *frame = Frame::Layout::OwnerOf(layout);
const auto it = g_insets.find(frame);
if (it == g_insets.end())
return g_origCalcRect(layout, rect);
@@ -144,7 +142,7 @@ int __fastcall Script_SetClampRectInsets(void *L) {
else
g_insets[frame] = in;
// Re-lay the frame out, as SetClampedToScreen does after changing the clamp.
reinterpret_cast<Invalidate_t>(Offsets::FUN_LAYOUT_RESIZE)(LayoutOf(frame), 0);
Frame::Layout::Resize(frame, false);
return 0;
}
+11 -27
View File
@@ -66,10 +66,13 @@
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "frame/Layout.h"
#include "frame/AlphaGradient.h"
#include "frame/ClipChildren.h"
#include "frame/Destruction.h"
#include "frame/LayerChange.h"
#include "frame/ParentChange.h"
#include "gx/Device.h"
#include "xml/FrameLoad.h"
#include <cstdint>
@@ -85,14 +88,8 @@ using BatchOp_t = void(__fastcall *)(void *batch);
using Callback_t = void(__fastcall *)(void *arg);
using AddCallback_t = void(__fastcall *)(void *batch, void *edx, Callback_t fn, void *arg);
using SetFrameFlag_t = void(__fastcall *)(void *frame, void *edx, uint32_t mask, int on);
using NotifyLayerChanged_t = void(__fastcall *)(void *frame, void *edx, int layer);
using HitTest_t = int(__fastcall *)(void *frame, void *edx, const float *point);
using Matrix_t = void(__fastcall *)(float *m);
using GetViewport_t = void(__fastcall *)(float *minX, float *maxX, float *minY, float *maxY, float *minZ,
float *maxZ);
using SetViewport_t = void(__stdcall *)(float minX, float maxX, float minY, float maxY, float minZ, float maxZ);
using SetupOrtho_t = void(__fastcall *)(const float *rect, const float *point, int unused);
using Convert_t = void(__fastcall *)(float *outX, float *outY, float x, float y);
// A hit rect: {bottom, left, top, right}.
enum { BOTTOM, LEFT, TOP, RIGHT };
@@ -218,18 +215,11 @@ bool ClipRect(const void *root, float rect[4]) {
// The ScrollFrame's draw (0x00787220) with `rect` in place of its hit rect
// and `draw(arg)` in place of its scroll child's.
void DrawInside(float rect[4], Callback_t draw, void *arg) {
float projection[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
float view[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
reinterpret_cast<Matrix_t>(Offsets::FUN_GX_GET_PROJECTION)(projection);
reinterpret_cast<Matrix_t>(Offsets::FUN_GX_GET_VIEW)(view);
float vpMinX, vpMaxX, vpMinY, vpMaxY, vpMinZ, vpMaxZ;
reinterpret_cast<GetViewport_t>(Offsets::FUN_GX_GET_VIEWPORT)(&vpMinX, &vpMaxX, &vpMinY, &vpMaxY, &vpMinZ,
&vpMaxZ);
const auto toDdc = reinterpret_cast<Convert_t>(Offsets::FUN_COORD_NDC_TO_DDC);
const Gx::SavedTransforms saved;
const Gx::Viewport &vp = saved.SavedViewport();
float minX = 0, minY = 0, maxX = 0, maxY = 0;
toDdc(&minX, &minY, vpMinX, vpMinY);
toDdc(&maxX, &maxY, vpMaxX, vpMaxY);
Frame::Layout::NdcToDdc(&minX, &minY, vp.minX, vp.minY);
Frame::Layout::NdcToDdc(&maxX, &maxY, vp.maxX, vp.maxY);
if (minX > rect[LEFT])
rect[LEFT] = minX;
if (maxX < rect[RIGHT])
@@ -241,9 +231,8 @@ void DrawInside(float rect[4], Callback_t draw, void *arg) {
const float point[2] = {0, 0};
reinterpret_cast<SetupOrtho_t>(Offsets::FUN_SETUP_ORTHO_TRANSFORMS)(rect, point, 0);
const auto toNdc = reinterpret_cast<Convert_t>(Offsets::FUN_COORD_DDC_TO_NDC);
toNdc(&rect[LEFT], &rect[BOTTOM], rect[LEFT], rect[BOTTOM]);
toNdc(&rect[RIGHT], &rect[TOP], rect[RIGHT], rect[TOP]);
Frame::Layout::DdcToNdc(&rect[LEFT], &rect[BOTTOM], rect[LEFT], rect[BOTTOM]);
Frame::Layout::DdcToNdc(&rect[RIGHT], &rect[TOP], rect[RIGHT], rect[TOP]);
if (rect[LEFT] < 0.0f) {
rect[RIGHT] -= rect[LEFT];
rect[LEFT] = 0.0f;
@@ -261,14 +250,9 @@ void DrawInside(float rect[4], Callback_t draw, void *arg) {
rect[TOP] = 1.0f;
}
if (rect[LEFT] < rect[RIGHT] && rect[BOTTOM] < rect[TOP]) {
reinterpret_cast<SetViewport_t>(Offsets::FUN_GX_SET_VIEWPORT)(rect[LEFT], rect[RIGHT], rect[BOTTOM],
rect[TOP], 0.0f, 1.0f);
Gx::SetViewport({rect[LEFT], rect[RIGHT], rect[BOTTOM], rect[TOP], 0.0f, 1.0f});
draw(arg);
}
reinterpret_cast<Matrix_t>(Offsets::FUN_GX_SET_PROJECTION)(projection);
reinterpret_cast<Matrix_t>(Offsets::FUN_GX_SET_VIEW)(view);
reinterpret_cast<SetViewport_t>(Offsets::FUN_GX_SET_VIEWPORT)(vpMinX, vpMaxX, vpMinY, vpMaxY, vpMinZ, vpMaxZ);
}
void __fastcall DrawClipped(void *arg) {
@@ -345,7 +329,7 @@ const Game::HookAutoRegister _hitTestHook{Offsets::FUN_FRAME_HIT_TEST, reinterpr
void MarkLayers(void *frame) {
for (int layer = 0; layer < Offsets::FRAME_LAYER_COUNT; ++layer)
reinterpret_cast<NotifyLayerChanged_t>(Offsets::FUN_FRAME_NOTIFY_LAYER_CHANGED)(frame, nullptr, layer);
Frame::LayerChange::NotifyFrame(frame, layer);
}
// Marks every layer of each frame under `frame` changed.
+56 -165
View File
@@ -83,9 +83,12 @@
#include "api/Documentation.h"
#include "cvar/Factory.h"
#include "frame/ExtraScripts.h"
#include "frame/LayerChange.h"
#include "frame/Layout.h"
#include "frame/Region.h"
#include "frame/RenderBatch.h"
#include "frame/ScriptArgs.h"
#include "frame/Subclass.h"
#include "text/NumericFormatter.h"
#include "texture/Load.h"
#include "time/Clock.h"
@@ -185,9 +188,7 @@ struct Rect {
};
const Rect &RectOf(void *frame) { return *Game::Ptr<Rect>(frame, Offsets::OFF_REGION_RECT); }
void *FrameOfLayout(void *layout) {
return static_cast<uint8_t *>(layout) - Offsets::OFF_FRAME_LAYOUT_SUBOBJECT;
}
using Frame::Layout::OwnerOf;
// The alpha the frame draws with — 4.3.4's m_alpha * m_parentAlpha / 255.
// 1.12's alpha setter copies one byte down the tree instead of multiplying.
@@ -224,28 +225,29 @@ uint32_t ColorByte(double c) {
return static_cast<uint32_t>(c * 255.0 + 0.5);
}
// ---- vtables ----------------------------------------------------------------
// ---- the class ----------------------------------------------------------------
// An MSVC vtable with its RTTI locator in front, as the engine's are laid out.
template <size_t N>
struct VTable {
const void *locator;
const void *slots[N];
};
VTable<Offsets::VTBL_SIMPLEFRAME_SLOTS> g_vtable;
VTable<Offsets::VTBL_SIMPLEFRAME_LAYOUT_SLOTS> g_layoutVtable;
// CSimpleFrame's own slot, for the base call of an override.
template <typename Fn>
Fn Base(uintptr_t slot) {
return Game::Read<Fn>(reinterpret_cast<const void *>(Offsets::VTBL_SIMPLEFRAME), slot);
}
template <typename Fn>
Fn LayoutBase(uintptr_t slot) {
return Game::Read<Fn>(reinterpret_cast<const void *>(Offsets::VTBL_SIMPLEFRAME_LAYOUT), slot);
// CGCooldown::~CGCooldown, before CSimpleFrame's deleting destructor.
void Destroy(void *frame) {
Fields &f = F(frame);
for (Slot &s : f.slots) {
Texture::ReleaseHandle(s.texture);
s.texture = nullptr;
}
Text::NumericFormatter::Release(f.countdownFormatter);
f.countdownFormatter = nullptr;
delete f.countdownText;
f.countdownText = nullptr;
}
Frame::Subclass::Class<Offsets::VTBL_SIMPLEFRAME_SLOTS, Offsets::VTBL_SIMPLEFRAME_LAYOUT_SLOTS>
g_class{"Cooldown",
Offsets::VTBL_SIMPLEFRAME,
Offsets::VTBL_SIMPLEFRAME_LAYOUT,
Offsets::FUN_SIMPLEFRAME_CONSTRUCTOR,
kObjectSize,
&Destroy};
// 1.12's CSimpleFrame::Show / Hide, as Script Show / Hide make them.
void Show(void *frame) {
using Slot_t = void(__fastcall *)(void *self, void *edx);
@@ -259,10 +261,7 @@ void Hide(void *frame) {
Game::Read<Slot_t>(Game::Read<void *>(frame, 0), Offsets::VTBL_FRAME_HIDE)(frame, nullptr);
}
void NotifyLayerChanged(void *frame, int layer) {
using Fn_t = void(__fastcall *)(void *self, void *edx, int layer);
reinterpret_cast<Fn_t>(Offsets::FUN_FRAME_NOTIFY_LAYER_CHANGED)(frame, nullptr, layer);
}
using Frame::LayerChange::NotifyFrame;
// ---- textures ---------------------------------------------------------------
@@ -371,8 +370,6 @@ void MapStarTexCoords(Fields &f, const Rect &r) {
void AnimateVerts(void *frame) {
Fields &f = F(frame);
const Rect &r = RectOf(frame);
const float cx = (r.minX + r.maxX) * 0.5f;
const float cy = (r.minY + r.maxY) * 0.5f;
if (!f.flashing) {
const float elapsed = static_cast<float>(f.elapsed);
@@ -482,7 +479,7 @@ void SetSlotTexture(void *frame, TextureSlot slot, const char *file, const uint3
if (base != nullptr)
s.base = *base;
f.texturesSet |= 1u << slot;
NotifyLayerChanged(frame, kSlotLayer[slot]);
NotifyFrame(frame, kSlotLayer[slot]);
UpdateColors(frame);
}
@@ -633,25 +630,12 @@ void UpdateCountdown(void *frame) {
// ---- the overrides ------------------------------------------------------------
using DeletingDestructor_t = void *(__fastcall *)(void *self, void *edx, uint32_t flags);
using GetScriptMethod_t = int(__fastcall *)(void *self, void *edx, void *L, const char *name);
using IsA_t = bool(__fastcall *)(void *self, void *edx, int typeId);
using IsObjectType_t = bool(__fastcall *)(void *self, void *edx, const char *name);
using SetFrameAlpha_t = void(__fastcall *)(void *self, void *edx, uint8_t alpha);
using OnFrameUpdate_t = void(__fastcall *)(void *self, void *edx, float elapsedSec);
using EmitLayer_t = void(__fastcall *)(void *self, void *edx, void *batch, int layer);
using Frame::Subclass::EmitLayer_t;
using Frame::Subclass::LoadXML_t;
using Frame::Subclass::OnFrameUpdate_t;
using Frame::Subclass::SetFrameAlpha_t;
using OnFrameSizeChanged_t = void(__fastcall *)(void *layout, void *edx, const float *oldRect);
using LoadXML_t = uintptr_t(__fastcall *)(void *layout, void *edx, const void *node,
void *status);
using PushMethod_t = int(__fastcall *)(void *L, const char *name, void *hash);
// The class's method hash and type id (Offsets.h "Per-class method hashes").
uint32_t g_hash[Offsets::METHOD_HASH_SIZE / 4];
int g_typeId = 0;
uintptr_t TypeIdVar() { return reinterpret_cast<uintptr_t>(&g_typeId); }
const Frame::ExtraScripts::Script g_onCooldownDone{"oncooldowndone", TypeIdVar()};
const Frame::ExtraScripts::Script g_onCooldownDone{"oncooldowndone", g_class.TypeIdVar()};
// From the update walk, where the engine runs OnUpdate.
void RunOnCooldownDone(void *frame) {
@@ -664,52 +648,15 @@ void RunOnCooldownDone(void *frame) {
Frame::ScriptArgs::RunSlotWithValues(L, slot, frame, Game::Lua::GetTop(L) + 1, 0);
}
// CGCooldown::~CGCooldown, then CSimpleFrame's deleting destructor.
void *__fastcall DeletingDestructor(void *self, void *, uint32_t flags) {
Fields &f = F(self);
for (Slot &s : f.slots) {
Texture::ReleaseHandle(s.texture);
s.texture = nullptr;
}
Text::NumericFormatter::Release(f.countdownFormatter);
f.countdownFormatter = nullptr;
delete f.countdownText;
f.countdownText = nullptr;
return Base<DeletingDestructor_t>(Offsets::VTBL_FRAME_DELETING_DESTRUCTOR)(self, nullptr,
flags);
}
void *__fastcall LayoutDeletingDestructor(void *layout, void *, uint32_t flags) {
return DeletingDestructor(FrameOfLayout(layout), nullptr, flags);
}
int __fastcall GetScriptMethod(void *self, void *, void *L, const char *name) {
if (reinterpret_cast<PushMethod_t>(Offsets::FUN_FRAMESCRIPT_PUSH_METHOD)(L, name, g_hash))
return 1;
return Base<GetScriptMethod_t>(Offsets::VTBL_FRAME_GET_SCRIPT_METHOD)(self, nullptr, L, name);
}
bool __fastcall IsA(void *self, void *, int typeId) {
return (g_typeId != 0 && typeId == g_typeId) ||
Base<IsA_t>(Offsets::VTBL_FRAME_IS_A)(self, nullptr, typeId);
}
bool __fastcall IsObjectType(void *self, void *, const char *name) {
return (name != nullptr && _stricmp(name, "Cooldown") == 0) ||
Base<IsObjectType_t>(Offsets::VTBL_FRAME_IS_OBJECT_TYPE)(self, nullptr, name);
}
const char *__fastcall GetObjectType(void *, void *) { return "Cooldown"; }
// OFFSET (4.3.4): 0x009FF570 (UpdateAlpha)
void __fastcall SetFrameAlpha(void *self, void *, uint8_t alpha) {
Base<SetFrameAlpha_t>(Offsets::VTBL_FRAME_SET_FRAME_ALPHA)(self, nullptr, alpha);
g_class.Base<SetFrameAlpha_t>(Offsets::VTBL_FRAME_SET_FRAME_ALPHA)(self, nullptr, alpha);
UpdateColors(self);
}
// OFFSET (4.3.4): 0x009FF5A0 (OnLayerUpdate)
void __fastcall OnFrameUpdate(void *self, void *, float elapsedSec) {
Base<OnFrameUpdate_t>(Offsets::VTBL_FRAME_ON_FRAME_UPDATE)(self, nullptr, elapsedSec);
g_class.Base<OnFrameUpdate_t>(Offsets::VTBL_FRAME_ON_FRAME_UPDATE)(self, nullptr, elapsedSec);
Fields &f = F(self);
// 4.3.4: `now <= start ? 0 : now - start`, compared wrap-safe. A paused
// frame reads the time it was paused at.
@@ -757,7 +704,7 @@ void __fastcall OnFrameUpdate(void *self, void *, float elapsedSec) {
// OFFSET (4.3.4): 0x009FE830 (OnFrameRender)
void __fastcall EmitLayer(void *self, void *, void *batch, int layer) {
Base<EmitLayer_t>(Offsets::VTBL_FRAME_EMIT_LAYER)(self, nullptr, batch, layer);
g_class.Base<EmitLayer_t>(Offsets::VTBL_FRAME_EMIT_LAYER)(self, nullptr, batch, layer);
Fields &f = F(self);
using RenderBatch::QueueStrip;
void *swipe = f.slots[SLOT_SWIPE].texture;
@@ -777,9 +724,9 @@ void __fastcall EmitLayer(void *self, void *, void *batch, int layer) {
// OFFSET (4.3.4): 0x009FF580
void __fastcall OnFrameSizeChanged(void *layout, void *, const float *oldRect) {
LayoutBase<OnFrameSizeChanged_t>(Offsets::VTBL_LAYOUT_ON_FRAME_SIZE_CHANGED)(layout, nullptr,
g_class.LayoutBase<OnFrameSizeChanged_t>(Offsets::VTBL_LAYOUT_ON_FRAME_SIZE_CHANGED)(layout, nullptr,
oldRect);
ResetVerts(FrameOfLayout(layout));
ResetVerts(OwnerOf(layout));
}
// The stock CooldownFrameTemplate: a Model template (Cooldown.xml, 1.12).
@@ -800,16 +747,15 @@ void LoadModernTemplate(void *layout) {
nullptr);
reinterpret_cast<SetAllPoints_t>(Offsets::FUN_LAYOUT_SET_ALL_POINTS)(layout, nullptr, parent,
1);
Hide(FrameOfLayout(layout));
Hide(OwnerOf(layout));
}
// A texture element's <Color> child into `color`, read as CSimpleTexture::
// LoadXML reads one (it passes its load status too); false when it has none.
bool XmlTextureColor(const void *node, void *status, uint32_t &color) {
using ReadColor_t = int(__fastcall *)(const void *node, uint32_t *bgra, void *status);
for (const void *c = Xml::FirstChild(node); c != nullptr; c = Xml::NextSibling(c)) {
if (Xml::TagIs(c, "Color")) {
reinterpret_cast<ReadColor_t>(Offsets::FUN_XML_READ_COLOR)(c, &color, status);
Xml::ReadColor(c, color, status);
return true;
}
}
@@ -826,10 +772,10 @@ uintptr_t __fastcall LoadXML(void *layout, void *, const void *node, void *statu
return 0;
}
const uintptr_t result =
LayoutBase<LoadXML_t>(Offsets::VTBL_XML_NODE_LOADER)(layout, nullptr, node, status);
g_class.LayoutBase<LoadXML_t>(Offsets::VTBL_XML_NODE_LOADER)(layout, nullptr, node, status);
if (node == nullptr)
return result;
Fields &f = F(FrameOfLayout(layout));
Fields &f = F(OwnerOf(layout));
if (const char *reverse = Xml::Attr(node, "reverse"))
f.reverse = Game::StringToFlag(reverse);
if (const char *drawEdge = Xml::Attr(node, "drawEdge"))
@@ -853,7 +799,7 @@ uintptr_t __fastcall LoadXML(void *layout, void *, const void *node, void *statu
if (!Xml::TagIs(c, kSlotTag[slot]))
continue;
uint32_t color = 0;
SetSlotTexture(FrameOfLayout(layout), static_cast<TextureSlot>(slot),
SetSlotTexture(OwnerOf(layout), static_cast<TextureSlot>(slot),
Xml::Attr(c, "file"),
XmlTextureColor(c, status, color) ? &color : nullptr);
}
@@ -862,44 +808,23 @@ uintptr_t __fastcall LoadXML(void *layout, void *, const void *node, void *statu
}
void BuildVTables() {
if (g_vtable.slots[0] != nullptr)
if (!Frame::Subclass::BuildVTables<g_class>())
return;
std::memcpy(&g_vtable, reinterpret_cast<const void *>(Offsets::VTBL_SIMPLEFRAME - 4),
sizeof(g_vtable));
std::memcpy(&g_layoutVtable,
reinterpret_cast<const void *>(Offsets::VTBL_SIMPLEFRAME_LAYOUT - 4),
sizeof(g_layoutVtable));
auto set = [](const void **slots, uintptr_t slot, const void *fn) { slots[slot / 4] = fn; };
const void **v = g_vtable.slots;
set(v, Offsets::VTBL_FRAME_DELETING_DESTRUCTOR, reinterpret_cast<const void *>(&DeletingDestructor));
set(v, Offsets::VTBL_FRAME_GET_SCRIPT_METHOD, reinterpret_cast<const void *>(&GetScriptMethod));
set(v, Offsets::VTBL_FRAME_IS_A, reinterpret_cast<const void *>(&IsA));
set(v, Offsets::VTBL_FRAME_IS_OBJECT_TYPE, reinterpret_cast<const void *>(&IsObjectType));
set(v, Offsets::VTBL_FRAME_GET_OBJECT_TYPE, reinterpret_cast<const void *>(&GetObjectType));
set(v, Offsets::VTBL_FRAME_SET_FRAME_ALPHA, reinterpret_cast<const void *>(&SetFrameAlpha));
set(v, Offsets::VTBL_FRAME_ON_FRAME_UPDATE, reinterpret_cast<const void *>(&OnFrameUpdate));
set(v, Offsets::VTBL_FRAME_EMIT_LAYER, reinterpret_cast<const void *>(&EmitLayer));
const void **l = g_layoutVtable.slots;
set(l, Offsets::VTBL_LAYOUT_ON_FRAME_SIZE_CHANGED,
reinterpret_cast<const void *>(&OnFrameSizeChanged));
set(l, Offsets::VTBL_LAYOUT_DELETING_DESTRUCTOR,
reinterpret_cast<const void *>(&LayoutDeletingDestructor));
set(l, Offsets::VTBL_XML_NODE_LOADER, reinterpret_cast<const void *>(&LoadXML));
auto fn = [](auto f) { return reinterpret_cast<const void *>(f); };
g_class.vtable.Set(Offsets::VTBL_FRAME_SET_FRAME_ALPHA, fn(&SetFrameAlpha));
g_class.vtable.Set(Offsets::VTBL_FRAME_ON_FRAME_UPDATE, fn(&OnFrameUpdate));
g_class.vtable.Set(Offsets::VTBL_FRAME_EMIT_LAYER, fn(&EmitLayer));
g_class.layoutVtable.Set(Offsets::VTBL_LAYOUT_ON_FRAME_SIZE_CHANGED, fn(&OnFrameSizeChanged));
g_class.layoutVtable.Set(Offsets::VTBL_XML_NODE_LOADER, fn(&LoadXML));
}
// OFFSET (4.3.4): 0x008C6390 (CGCooldown::Create) — the "Cooldown" factory.
void *__fastcall CreateCooldown(void *parent) {
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
using Constructor_t = void(__fastcall *)(void *self, void *edx, void *parent);
BuildVTables();
// Zero-filled: 4.3.4's constructor zeroes every CGCooldown field.
void *frame = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEM_ALLOC)(
kObjectSize, __FILE__, __LINE__, 0x08);
void *frame = Frame::Subclass::New<g_class>(parent);
if (frame == nullptr)
return nullptr;
reinterpret_cast<Constructor_t>(Offsets::FUN_SIMPLEFRAME_CONSTRUCTOR)(frame, nullptr, parent);
Game::Ref<const void *>(frame, 0) = g_vtable.slots;
Game::Ref<const void *>(frame, Offsets::OFF_FRAME_LAYOUT_SUBOBJECT) = g_layoutVtable.slots;
Fields &f = F(frame);
f.modRate = 1.0f;
f.drawSwipe = 1;
@@ -917,7 +842,7 @@ void *__fastcall CreateCooldown(void *parent) {
// ---- the methods (CGCooldown_Scripts.cpp, then the later clients') -------------------
void *ResolveCooldown(void *L) { return Game::Lua::ResolveTypedObject(L, 1, TypeIdVar()); }
void *ResolveCooldown(void *L) { return Game::Lua::ResolveTypedObject(L, 1, g_class.TypeIdVar()); }
// Seconds to ms on the GetTime() epoch, wrapping like the tick does.
uint32_t Ms(double seconds) { return static_cast<uint32_t>(static_cast<int64_t>(seconds * 1000.0)); }
@@ -1046,7 +971,7 @@ int __fastcall Script_SetDrawSwipe(void *L) {
const uint32_t drawSwipe = Game::Lua::ToFlag(L, 2, true);
if (drawSwipe != f.drawSwipe) {
f.drawSwipe = drawSwipe;
NotifyLayerChanged(frame, kSlotLayer[SLOT_SWIPE]);
NotifyFrame(frame, kSlotLayer[SLOT_SWIPE]);
}
return 0;
}
@@ -1308,7 +1233,7 @@ int __fastcall Script_SetDrawEdge(void *L) {
const uint32_t drawEdge = Game::Lua::ToFlag(L, 2, true);
if (drawEdge != f.drawEdge) {
f.drawEdge = drawEdge;
NotifyLayerChanged(frame, kSlotLayer[SLOT_EDGE]);
NotifyFrame(frame, kSlotLayer[SLOT_EDGE]);
}
return 0;
}
@@ -1561,55 +1486,21 @@ const Game::Doc::Method g_methodDocs[] = {
{"GetCountdownFormatter", &kGetCountdownFormatter},
};
// The engine's static initializer for a class hash (Model's 0x0076F5F0).
void InitMethodHash() {
if (g_hash[0] != 0)
return;
g_hash[0] = Offsets::VTBL_METHOD_HASH_ALLOCATOR;
g_hash[1] = 0xC; // node link offset
g_hash[2] = reinterpret_cast<uintptr_t>(&g_hash[2]);
g_hash[3] = reinterpret_cast<uintptr_t>(&g_hash[2]) | 1;
g_hash[9] = 0xFFFFFFFF; // no buckets yet
}
void RegisterLuaFunctions() {
using RegisterFactory_t = int(__fastcall *)(const char *type, void *(__fastcall *)(void *),
int unique);
// The engine's frame-type table outlives the Lua state, so on a /reload
// the name is already there and the registrar keeps the first one. This
// pass runs before FrameXML loads (CGGameUI::Initialize calls
// This pass runs before FrameXML loads (CGGameUI::Initialize calls
// LoadScriptFunctions first), like the engine's own game-UI list
// FUN_00495940, where 4.3.4 registers "Cooldown".
InitMethodHash();
g_countdownForCooldowns = CVar::Factory::Register(
"countdownForCooldowns",
"Whether to use number countdown instead of radial swipe for action button cooldowns or not.",
Offsets::CVAR_CATEGORY_GAME, "0");
reinterpret_cast<RegisterFactory_t>(Offsets::FUN_FRAMEXML_REGISTER_FACTORY)(
"Cooldown", &CreateCooldown, 0);
Api::Documentation::NameWidgetSystem(g_hash, "FrameAPICooldown");
Game::Lua::RegisterFrameMethods(g_hash, g_methods, g_methodDocs);
Frame::Subclass::RegisterFactory(g_class.name, &CreateCooldown);
Api::Documentation::NameWidgetSystem(g_class.hash.Registry(), "FrameAPICooldown");
Game::Lua::RegisterFrameMethods(g_class.hash.Registry(), g_methods, g_methodDocs);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
// The hash's functions are references into the game Lua state, so they are
// released with the engine's own class hashes, at the end of the game UI's
// unregister pass while that state is still open.
using UnregisterScriptFunctions_t = void(__cdecl *)();
using UnregisterMethods_t = void(__fastcall *)(void *hash);
UnregisterScriptFunctions_t UnregisterScriptFunctions_o = nullptr;
void __cdecl UnregisterScriptFunctions_h() {
UnregisterScriptFunctions_o();
reinterpret_cast<UnregisterMethods_t>(Offsets::FUN_FRAMESCRIPT_UNREGISTER_METHODS)(g_hash);
}
const Game::HookAutoRegister _unregisterHook{
Offsets::FUN_GAMEUI_UNREGISTER_SCRIPT_FUNCTIONS,
reinterpret_cast<void *>(&UnregisterScriptFunctions_h),
reinterpret_cast<void **>(&UnregisterScriptFunctions_o)};
} // namespace
} // namespace Frame::Cooldown
+37
View File
@@ -0,0 +1,37 @@
// 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
// The engine's two draw-layer invalidates: what to call after a change to
// what a frame or a region draws, so the next batch rebuild picks it up.
#include "Offsets.h"
namespace Frame::LayerChange {
// CSimpleFrame::NotifyLayerChanged (FUN_FRAME_NOTIFY_LAYER_CHANGED): one of
// the frame's own draw layers.
inline void NotifyFrame(void *frame, int layer) {
using Fn_t = void(__fastcall *)(void *frame, void *edx, int layer);
reinterpret_cast<Fn_t>(Offsets::FUN_FRAME_NOTIFY_LAYER_CHANGED)(frame, nullptr, layer);
}
// CScriptRegion::NotifyLayerChanged (FUN_REGION_NOTIFY_LAYER_CHANGED): the
// layer the region draws in.
inline void NotifyRegion(void *region) {
using Fn_t = void(__fastcall *)(void *region);
reinterpret_cast<Fn_t>(Offsets::FUN_REGION_NOTIFY_LAYER_CHANGED)(region);
}
} // namespace Frame::LayerChange
+82
View File
@@ -0,0 +1,82 @@
// 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
// The layout sub-object every region and frame has (4.3.4's CLayoutFrame, at
// OFF_REGION_ANCHOR, which is OFF_FRAME_LAYOUT_SUBOBJECT for a frame) and the
// engine calls on it. Each call here takes the region or frame itself.
#include "Offsets.h"
#include <cstdint>
namespace Frame::Layout {
static_assert(Offsets::OFF_REGION_ANCHOR == Offsets::OFF_FRAME_LAYOUT_SUBOBJECT,
"a frame's layout sub-object is where a region's is");
// The layout sub-object: `this` for SetPoint and the calls below.
inline void *Of(const void *object) {
return const_cast<uint8_t *>(static_cast<const uint8_t *>(object) + Offsets::OFF_REGION_ANCHOR);
}
// Layout units (DDC, the space rects are stored in) to the device's 0..1
// (NDC) and back: each axis scaled by the screen's (VAR_COORD_DDC_*_SCALE).
// Either out pointer may be null, and may alias its input.
inline void DdcToNdc(float *outX, float *outY, float x, float y) {
using Fn = void(__fastcall *)(float *, float *, float, float);
reinterpret_cast<Fn>(Offsets::FUN_COORD_DDC_TO_NDC)(outX, outY, x, y);
}
inline void NdcToDdc(float *outX, float *outY, float x, float y) {
using Fn = void(__fastcall *)(float *, float *, float, float);
reinterpret_cast<Fn>(Offsets::FUN_COORD_NDC_TO_DDC)(outX, outY, x, y);
}
// The region or frame a layout sub-object belongs to, for the layout
// vtable's overrides and hooks.
inline void *OwnerOf(void *layout) {
return static_cast<uint8_t *>(layout) - Offsets::OFF_REGION_ANCHOR;
}
// CLayoutFrame::GetRect (FUN_REGION_GET_RECT): {bottom, left, top, right} in
// layout units. False while the object has no valid rect.
inline bool GetRect(const void *object, float (&rect)[4]) {
using Fn = int(__thiscall *)(void *, float *);
return reinterpret_cast<Fn>(Offsets::FUN_REGION_GET_RECT)(Of(object), rect) != 0;
}
// IsResizePending (FUN_REGION_LAYOUT_DIRTY): a layout change waits for the
// layout pass.
inline bool IsResizePending(const void *object) {
using Fn = int(__thiscall *)(void *);
return reinterpret_cast<Fn>(Offsets::FUN_REGION_LAYOUT_DIRTY)(Of(object)) != 0;
}
// CLayoutFrame::Resize (FUN_LAYOUT_RESIZE): `now` lays the object out at
// once; otherwise it is marked to be laid out later, the path SetPoint takes.
inline void Resize(void *object, bool now) {
using Fn = void(__thiscall *)(void *, int);
reinterpret_cast<Fn>(Offsets::FUN_LAYOUT_RESIZE)(Of(object), now ? 1 : 0);
}
// The edge getters' read (Script GetLeft 0x007A1A19): a pending resize run
// now, then the rect. A hidden object, which the layout pass skips, gets a
// rect this way.
inline bool GetResolvedRect(void *object, float (&rect)[4]) {
if (IsResizePending(object))
Resize(object, true);
return GetRect(object, rect);
}
} // namespace Frame::Layout
+3 -8
View File
@@ -72,6 +72,7 @@
#include "Game.h"
#include "HookSecureFunc.h"
#include "Offsets.h"
#include "frame/Layout.h"
#include "frame/ScriptBindings.h"
#include <cstdint>
@@ -104,13 +105,10 @@ int __fastcall Script_SetSize(void *L) {
}
using LayoutExtent_t = float(__thiscall *)(void *layout);
using LayoutDirty_t = int(__fastcall *)(void *layout);
using LayoutResize_t = void(__fastcall *)(void *layout, void *edx, int force);
using LayoutGetRect_t = int(__fastcall *)(void *layout, void *edx, float *out);
// The explicit size of `self` in layout `slot`; 0 when none is set.
float ExplicitExtent(void *self, int slot) {
void *layout = static_cast<uint8_t *>(self) + Offsets::OFF_REGION_ANCHOR;
void *layout = Frame::Layout::Of(self);
auto *vtable = Game::Read<uintptr_t *>(layout, 0);
return reinterpret_cast<LayoutExtent_t>(vtable[slot / sizeof(uintptr_t)])(layout);
}
@@ -145,13 +143,10 @@ void PushUnscaledExtent(void *L, bool ignoreRect, int slot, bool vertical) {
void *self = Game::Lua::ResolveRegion(L);
if (self == nullptr)
return;
void *layout = static_cast<uint8_t *>(self) + Offsets::OFF_REGION_ANCHOR;
float extent = ExplicitExtent(self, slot);
if (extent == 0.0f && !ignoreRect) {
if (reinterpret_cast<LayoutDirty_t>(Offsets::FUN_REGION_LAYOUT_DIRTY)(layout) != 0)
reinterpret_cast<LayoutResize_t>(Offsets::FUN_LAYOUT_RESIZE)(layout, nullptr, 1);
float rect[4] = {0.0f, 0.0f, 0.0f, 0.0f}; // {bottom, left, top, right}
if (reinterpret_cast<LayoutGetRect_t>(Offsets::FUN_REGION_GET_RECT)(layout, nullptr, rect) != 0)
if (Frame::Layout::GetResolvedRect(self, rect))
extent = (vertical ? rect[2] - rect[0] : rect[3] - rect[1]) /
Game::Read<float>(self, Offsets::OFF_REGION_EFFECTIVE_SCALE);
}
+2 -2
View File
@@ -44,6 +44,7 @@
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "frame/Layout.h"
#include "baselib/Ascii.h"
#include "frame/Destruction.h"
#include "frame/MotionFocus.h"
@@ -63,7 +64,6 @@ using EnableEvent_t = void(__thiscall *)(void *frame, int kind, int priority);
using DisableEvent_t = void(__thiscall *)(void *frame, int kind);
using Button_t = void(__thiscall *)(void *frame, const void *event);
using HitTest_t = int(__thiscall *)(void *frame, const float *point);
using NDCToDDC_t = void(__fastcall *)(float *outX, float *outY, float x, float y);
// Frames with their mouse bit on and that half off.
std::unordered_set<void *> g_clickOff;
@@ -119,7 +119,7 @@ void ClickTargets(uint8_t *ctx, std::vector<void *> &out) {
void *const primary = Game::Read<void *>(ctx, Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
const float *mouse = Game::Ptr<float>(ctx, Offsets::OFF_UI_CONTEXT_MOUSE_POSITION);
float point[2] = {0.0f, 0.0f};
reinterpret_cast<NDCToDDC_t>(Offsets::FUN_COORD_NDC_TO_DDC)(&point[0], &point[1], mouse[0], mouse[1]);
Frame::Layout::NdcToDdc(&point[0], &point[1], mouse[0], mouse[1]);
const auto hitTest = reinterpret_cast<HitTest_t>(Offsets::FUN_FRAME_HIT_TEST);
bool pastPrimary = primary == nullptr;
+2 -8
View File
@@ -44,6 +44,7 @@
#include "Game.h"
#include "Offsets.h"
#include "frame/GetPointGuard.h"
#include "frame/Layout.h"
#include <cstdint>
@@ -55,9 +56,6 @@ using StringToPoint_t = int(__fastcall *)(const char *name, int *point);
using RemoveDependent_t = void(__fastcall *)(void *relative, void *edx, void *layout, int mask);
using GetRelative_t = void *(__thiscall *)(void *node);
using DeleteNode_t = void(__thiscall *)(void *node, int flags);
using LayoutDirty_t = int(__fastcall *)(void *layout);
using Resize_t = void(__fastcall *)(void *layout, void *edx, int force);
using GetRect_t = int(__fastcall *)(void *layout, void *edx, float *out);
using SetPoint_t = void(__thiscall *)(void *anchor, int point, void *relAnchor, int relPoint,
float x, float y, int flag);
@@ -154,12 +152,8 @@ int __fastcall Script_IsRectValid(void *L) {
void *region = Game::Lua::ResolveRegion(L);
if (region == nullptr)
return 0;
void *layout = static_cast<uint8_t *>(region) + Offsets::OFF_REGION_ANCHOR;
if (reinterpret_cast<LayoutDirty_t>(Offsets::FUN_REGION_LAYOUT_DIRTY)(layout) != 0)
reinterpret_cast<Resize_t>(Offsets::FUN_LAYOUT_RESIZE)(layout, nullptr, 1);
float rect[4];
Game::Lua::PushBoolean(
L, reinterpret_cast<GetRect_t>(Offsets::FUN_REGION_GET_RECT)(layout, nullptr, rect) != 0);
Game::Lua::PushBoolean(L, Frame::Layout::GetResolvedRect(region, rect));
return 1;
}
+2 -3
View File
@@ -15,12 +15,12 @@
#include "Game.h"
#include "Offsets.h"
#include "texture/Load.h"
namespace Frame::RenderBatch {
namespace {
using GetRenderable_t = void *(__fastcall *)(void *texture, int load, void *callback);
using ReserveQuads_t = void(__thiscall *)(void *array, uint32_t extra, int round);
struct QuadRecord {
@@ -40,8 +40,7 @@ void QueueStrip(void *batch, void *texture, Blend blend, const C3Vector *positio
const C2Vector *texCoords, const uint32_t *color) {
// Loading on demand, as the texture emit does: a texture still streaming
// is finished here, so the first rebuild already draws it.
void *device = reinterpret_cast<GetRenderable_t>(Offsets::FUN_TEXTURE_GET_RENDERABLE)(
texture, 1, nullptr);
void *device = Texture::DeviceTexture(texture);
if (device == nullptr)
return;
reinterpret_cast<ReserveQuads_t>(Offsets::FUN_RENDERBATCH_RESERVE_QUADS)(
+66
View File
@@ -0,0 +1,66 @@
// 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 "frame/Subclass.h"
namespace Frame::Subclass {
namespace {
// Constant-initialized, so every MethodHash's constructor can link in during
// static initialization whatever the order of the translation units.
MethodHash *g_hashes = nullptr;
using RegisterFactory_t = int(__fastcall *)(const char *type, Factory_t factory, int unique);
using PushMethod_t = int(__fastcall *)(void *L, const char *name, void *hash);
using UnregisterMethods_t = void(__fastcall *)(void *hash);
using UnregisterScriptFunctions_t = void(__cdecl *)();
UnregisterScriptFunctions_t UnregisterScriptFunctions_o = nullptr;
// The hashes' functions are references into the game Lua state.
void __cdecl UnregisterScriptFunctions_h() {
UnregisterScriptFunctions_o();
MethodHash::ReleaseAll();
}
const Game::HookAutoRegister _unregisterHook{
Offsets::FUN_GAMEUI_UNREGISTER_SCRIPT_FUNCTIONS,
reinterpret_cast<void *>(&UnregisterScriptFunctions_h),
reinterpret_cast<void **>(&UnregisterScriptFunctions_o)};
} // namespace
void RegisterFactory(const char *type, Factory_t factory) {
reinterpret_cast<RegisterFactory_t>(Offsets::FUN_FRAMEXML_REGISTER_FACTORY)(type, factory, 0);
}
MethodHash::MethodHash() : words_{}, next_(g_hashes) {
words_[0] = Offsets::VTBL_METHOD_HASH_ALLOCATOR;
words_[1] = 0xC; // node link offset
words_[2] = reinterpret_cast<uintptr_t>(&words_[2]);
words_[3] = reinterpret_cast<uintptr_t>(&words_[2]) | 1;
words_[9] = 0xFFFFFFFF; // no buckets yet
g_hashes = this;
}
bool MethodHash::PushMethod(void *L, const char *name) {
return reinterpret_cast<PushMethod_t>(Offsets::FUN_FRAMESCRIPT_PUSH_METHOD)(L, name, words_) !=
0;
}
void MethodHash::ReleaseAll() {
for (MethodHash *h = g_hashes; h != nullptr; h = h->next_)
reinterpret_cast<UnregisterMethods_t>(Offsets::FUN_FRAMESCRIPT_UNREGISTER_METHODS)(h->words_);
}
} // namespace Frame::Subclass
+214
View File
@@ -0,0 +1,214 @@
// 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
// The parts every frame class this DLL adds shares: copied engine vtables with
// overrides in place, a method hash of the class's own, its type id and
// type-name overrides, and the factory (Offsets.h "Subclassing CSimpleFrame",
// "Per-class method hashes"). Frame::Cooldown and Model::Scene are built on it.
#include "Game.h"
#include "Offsets.h"
#include "frame/Layout.h"
#include "storm/Memory.h"
#include <cstddef>
#include <cstdint>
#include <cstring>
namespace Frame::Subclass {
// An MSVC vtable with its RTTI locator in front, as the engine's are laid out.
template <size_t N>
struct VTable {
const void *locator;
const void *slots[N];
bool Built() const { return slots[0] != nullptr; }
// A copy of the engine vtable at `address` (its first slot).
void CopyFrom(uintptr_t address) {
std::memcpy(this, reinterpret_cast<const void *>(address - 4), sizeof(*this));
}
// `slot` is a byte offset (Offsets::VTBL_*).
void Set(uintptr_t slot, const void *fn) { slots[slot / 4] = fn; }
};
// The function in byte offset `slot` of the engine vtable at `address`, for
// an override's base call.
template <typename Fn>
Fn Slot(uintptr_t address, uintptr_t slot) {
return Game::Read<Fn>(reinterpret_cast<const void *>(address), slot);
}
// A frame class's method hash, built the way the engine's static initializer
// builds one (Model's 0x0076F5F0). Every hash is released where the engine
// releases its own class hashes, at the end of CGGameUI::UnregisterScriptFunctions,
// while the game Lua state is still open. Declare one at file scope.
class MethodHash {
public:
MethodHash();
MethodHash(const MethodHash &) = delete;
MethodHash &operator=(const MethodHash &) = delete;
// What Game::Lua::RegisterFrameMethods and the documentation take.
void *Registry() { return words_; }
// FrameScriptPushMethod: pushes the method and answers true when the
// hash has it.
bool PushMethod(void *L, const char *name);
static void ReleaseAll();
private:
uint32_t words_[Offsets::METHOD_HASH_SIZE / 4];
MethodHash *next_;
};
// CSimpleFrame's own slot, for the base call of an override that answers as
// a Frame whatever engine class the frame extends.
template <typename Fn>
Fn FrameBase(uintptr_t slot) {
return Slot<Fn>(Offsets::VTBL_SIMPLEFRAME, slot);
}
// A frame class this DLL adds: what it is and what it extends, then the
// state the engine and the shared overrides below keep for it. Declare one
// at file scope; the overrides take it as their template argument.
template <size_t Slots, size_t LayoutSlots>
struct Class {
const char *name; // what GetObjectType gives and IsObjectType takes
uintptr_t engineVtable; // the engine class it extends: its vtable,
uintptr_t engineLayoutVtable; // its layout vtable,
uintptr_t constructor; // and its constructor, __thiscall (frame, parent)
size_t size; // the whole object, the engine class's part and ours
void (*destroy)(void *frame); // releases our part, before the engine's destructor
int typeId = 0; // the engine's type id for the class, once it names one
MethodHash hash;
VTable<Slots> vtable;
VTable<LayoutSlots> layoutVtable;
// What Game::Lua::ResolveTypedObject and Frame::ExtraScripts take.
uintptr_t TypeIdVar() { return reinterpret_cast<uintptr_t>(&typeId); }
// The engine class's own slot, for the base call of an override.
template <typename Fn>
Fn Base(uintptr_t slot) const {
return Slot<Fn>(engineVtable, slot);
}
template <typename Fn>
Fn LayoutBase(uintptr_t slot) const {
return Slot<Fn>(engineLayoutVtable, slot);
}
};
// ---- the overrides every class has --------------------------------------------
using DeletingDestructor_t = void *(__fastcall *)(void *self, void *edx, uint32_t flags);
using GetScriptMethod_t = int(__fastcall *)(void *self, void *edx, void *L, const char *name);
using IsA_t = bool(__fastcall *)(void *self, void *edx, int typeId);
using IsObjectType_t = bool(__fastcall *)(void *self, void *edx, const char *name);
// The slots a class overrides for itself, for their base calls.
using SetFrameAlpha_t = void(__fastcall *)(void *self, void *edx, uint8_t alpha);
using OnFrameUpdate_t = void(__fastcall *)(void *self, void *edx, float elapsedSec);
using EmitLayer_t = void(__fastcall *)(void *self, void *edx, void *batch, int layer);
// The layout vtable's node loader (Offsets::VTBL_XML_NODE_LOADER).
using LoadXML_t = uintptr_t(__fastcall *)(void *layout, void *edx, const void *node, void *status);
// Our part released, then the engine class's deleting destructor.
template <auto &C>
void *__fastcall DeletingDestructor(void *self, void *, uint32_t flags) {
C.destroy(self);
return C.template Base<DeletingDestructor_t>(Offsets::VTBL_FRAME_DELETING_DESTRUCTOR)(
self, nullptr, flags);
}
template <auto &C>
void *__fastcall LayoutDeletingDestructor(void *layout, void *, uint32_t flags) {
return DeletingDestructor<C>(Layout::OwnerOf(layout), nullptr, flags);
}
// The class's methods, then a Frame's: none of the engine class's own
// (ModelScene has no Model methods).
template <auto &C>
int __fastcall GetScriptMethod(void *self, void *, void *L, const char *name) {
if (C.hash.PushMethod(L, name))
return 1;
return FrameBase<GetScriptMethod_t>(Offsets::VTBL_FRAME_GET_SCRIPT_METHOD)(self, nullptr, L,
name);
}
// The class, then what a Frame is (retail's ModelScene is not a Model).
template <auto &C>
bool __fastcall IsA(void *self, void *, int typeId) {
return (C.typeId != 0 && typeId == C.typeId) ||
FrameBase<IsA_t>(Offsets::VTBL_FRAME_IS_A)(self, nullptr, typeId);
}
template <auto &C>
bool __fastcall IsObjectType(void *self, void *, const char *name) {
return (name != nullptr && Game::StrCmpI(name, C.name) == 0) ||
FrameBase<IsObjectType_t>(Offsets::VTBL_FRAME_IS_OBJECT_TYPE)(self, nullptr, name);
}
template <auto &C>
const char *__fastcall GetObjectType(void *, void *) {
return C.name;
}
// Copies the engine class's two vtables and puts in the overrides above, with
// CSimpleFrame's script names (+0x0C) to match its methods. False when they
// are built already; on true the caller puts in its own overrides.
template <auto &C>
bool BuildVTables() {
if (C.vtable.Built())
return false;
C.vtable.CopyFrom(C.engineVtable);
C.layoutVtable.CopyFrom(C.engineLayoutVtable);
auto fn = [](auto f) { return reinterpret_cast<const void *>(f); };
C.vtable.Set(Offsets::VTBL_FRAME_DELETING_DESTRUCTOR, fn(&DeletingDestructor<C>));
C.vtable.Set(Offsets::VTBL_FRAME_GET_SCRIPT_METHOD, fn(&GetScriptMethod<C>));
C.vtable.Set(Offsets::VTBL_FRAME_SCRIPT_RESOLVER,
FrameBase<const void *>(Offsets::VTBL_FRAME_SCRIPT_RESOLVER));
C.vtable.Set(Offsets::VTBL_FRAME_IS_A, fn(&IsA<C>));
C.vtable.Set(Offsets::VTBL_FRAME_IS_OBJECT_TYPE, fn(&IsObjectType<C>));
C.vtable.Set(Offsets::VTBL_FRAME_GET_OBJECT_TYPE, fn(&GetObjectType<C>));
C.layoutVtable.Set(Offsets::VTBL_LAYOUT_DELETING_DESTRUCTOR,
fn(&LayoutDeletingDestructor<C>));
return true;
}
// A new frame of the class, shaped like the engine's factories (the "Frame"
// one, 0x006EEC10): a Storm block for the whole object, zero-filled, the
// engine class's constructor, then our vtables, which BuildVTables must have
// built. Null when the allocation fails.
template <auto &C>
void *New(void *parent) {
using Constructor_t = void(__fastcall *)(void *self, void *edx, void *parent);
void *frame = Storm::Alloc(C.size, __FILE__, __LINE__, true);
if (frame == nullptr)
return nullptr;
reinterpret_cast<Constructor_t>(C.constructor)(frame, nullptr, parent);
Game::Ref<const void *>(frame, 0) = C.vtable.slots;
Game::Ref<const void *>(frame, Offsets::OFF_FRAME_LAYOUT_SUBOBJECT) = C.layoutVtable.slots;
return frame;
}
using Factory_t = void *(__fastcall *)(void *parent);
// Adds the frame type to CreateFrame and XML (FUN_FRAMEXML_REGISTER_FACTORY).
// The engine's type table outlives the Lua state, so on a /reload the name
// is there already and the engine keeps the first factory.
void RegisterFactory(const char *type, Factory_t factory);
} // namespace Frame::Subclass
+157
View File
@@ -0,0 +1,157 @@
// 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
// The graphics device's transforms: its projection and view matrices and its
// viewport (Offsets.h, at FUN_GX_GET_PROJECTION). The engine's own draws that
// leave the UI pass's transforms (SimpleModelDraw 0x0076D240, the ScrollFrame
// draw 0x00787220, the portrait bake) save all three first and restore them
// after; SavedTransforms does that for a scope.
#include "Offsets.h"
#include "gx/Matrix.h"
#include <cstdint>
namespace Gx {
inline void GetProjection(Matrix &m) {
reinterpret_cast<void(__fastcall *)(float *)>(Offsets::FUN_GX_GET_PROJECTION)(m);
}
inline void SetProjection(const Matrix &m) {
reinterpret_cast<void(__fastcall *)(const float *)>(Offsets::FUN_GX_SET_PROJECTION)(m);
}
inline void GetView(Matrix &m) {
reinterpret_cast<void(__fastcall *)(float *)>(Offsets::FUN_GX_GET_VIEW)(m);
}
inline void SetView(const Matrix &m) {
reinterpret_cast<void(__fastcall *)(const float *)>(Offsets::FUN_GX_SET_VIEW)(m);
}
// In NDC, 0 to 1.
struct Viewport {
float minX, maxX, minY, maxY, minZ, maxZ;
};
inline Viewport GetViewport() {
using Fn = void(__fastcall *)(float *, float *, float *, float *, float *, float *);
Viewport v{};
reinterpret_cast<Fn>(Offsets::FUN_GX_GET_VIEWPORT)(&v.minX, &v.maxX, &v.minY, &v.maxY, &v.minZ,
&v.maxZ);
return v;
}
inline void SetViewport(const Viewport &v) {
using Fn = void(__stdcall *)(float, float, float, float, float, float);
reinterpret_cast<Fn>(Offsets::FUN_GX_SET_VIEWPORT)(v.minX, v.maxX, v.minY, v.maxY, v.minZ,
v.maxZ);
}
// ---- render states (GXRS_*) -----------------------------------------------------
// The device journals each state written after a push; the pop puts every one
// back. The engine brackets each region-layer draw in a pair.
inline void PushStates() { reinterpret_cast<void(__fastcall *)()>(Offsets::FUN_GX_STATE_PUSH)(); }
inline void PopStates() { reinterpret_cast<void(__fastcall *)()>(Offsets::FUN_GX_STATE_POP)(); }
// The render states written in a scope, put back when it ends.
class SavedStates {
public:
SavedStates() { PushStates(); }
~SavedStates() { PopStates(); }
SavedStates(const SavedStates &) = delete;
SavedStates &operator=(const SavedStates &) = delete;
};
inline void SetState(int state, int value) {
reinterpret_cast<void(__fastcall *)(int, int)>(Offsets::FUN_GX_RS_SET)(state, value);
}
// A pointer state: a texture bind.
inline void SetState(int state, const void *value) {
reinterpret_cast<void(__fastcall *)(int, const void *)>(Offsets::FUN_GX_RS_SET_PTR)(state, value);
}
// A colour state (fog colour, material).
inline void SetStateWord(int state, uint32_t value) {
reinterpret_cast<void(__fastcall *)(int, int, uint32_t)>(Offsets::FUN_GX_RS_SET_WORD)(state, 0, value);
}
inline void SetStateFloat(int state, float value) {
reinterpret_cast<void(__fastcall *)(int, int, float)>(Offsets::FUN_GX_RS_SET_FLOAT)(state, 0, value);
}
inline int GetState(int state) {
int value = 0;
reinterpret_cast<void(__fastcall *)(int, int *)>(Offsets::FUN_GX_RS_GET_INT)(state, &value);
return value;
}
// ---- transform stacks -----------------------------------------------------------
// Each texture unit has a matrix stack, and stack 8 is the world transform.
inline void PushTransform(int unit) {
reinterpret_cast<void(__fastcall *)(int)>(Offsets::FUN_GX_TEXMTX_PUSH)(unit);
}
inline void PushTransform(int unit, const float *m) {
reinterpret_cast<void(__fastcall *)(int, const float *)>(Offsets::FUN_GX_TEXMTX_PUSH_LOAD)(unit, m);
}
inline void PopTransform(int unit) {
reinterpret_cast<void(__fastcall *)(int)>(Offsets::FUN_GX_TEXMTX_POP)(unit);
}
// Over the top of the unit's stack.
inline void SetTransform(int unit, const float *m) {
reinterpret_cast<void(__fastcall *)(int, const float *)>(Offsets::FUN_GX_TEXMTX_LOAD)(unit, m);
}
// ---- clear ----------------------------------------------------------------------
inline uint32_t GetClearColor() {
uint32_t argb = 0;
reinterpret_cast<uint32_t *(__fastcall *)(uint32_t *)>(Offsets::FUN_GX_GET_CLEAR_COLOR)(&argb);
return argb;
}
inline void SetClearColor(uint32_t argb) {
reinterpret_cast<void(__stdcall *)(uint32_t)>(Offsets::FUN_GX_SET_CLEAR_COLOR)(argb);
}
// `mask` 1 the colour, 2 the depth.
inline void Clear(uint32_t mask) { reinterpret_cast<void(__fastcall *)(uint32_t)>(Offsets::FUN_GX_CLEAR)(mask); }
// The near and far plane points at the viewport fractions `a` (across) and
// `b` (up), through the device view and projection: relative to the eye when
// the view leaves it out, as a camera's does.
inline void UnprojectScreenPoint(float (&nearPoint)[3], float (&farPoint)[3], float a, float b) {
using Fn = void(__fastcall *)(float *, float *, float, float);
reinterpret_cast<Fn>(Offsets::FUN_UNPROJECT_SCREEN_POINT)(nearPoint, farPoint, a, b);
}
// The projection, view and viewport, put back when the scope ends.
class SavedTransforms {
public:
SavedTransforms() : viewport_(GetViewport()) {
GetProjection(projection_);
GetView(view_);
}
~SavedTransforms() {
SetProjection(projection_);
SetView(view_);
SetViewport(viewport_);
}
SavedTransforms(const SavedTransforms &) = delete;
SavedTransforms &operator=(const SavedTransforms &) = delete;
const Viewport &SavedViewport() const { return viewport_; }
private:
Matrix projection_;
Matrix view_;
Viewport viewport_;
};
} // namespace Gx
+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/>.
#pragma once
// The engine's 4x4 matrix helpers (Offsets.h FUN_MATRIX_*, FUN_TRANSFORM_*).
// Matrices are row-vector: a point is a row times the matrix, rows 0..2 are
// the scaled axes and row 3 the translation. Each transform below
// pre-multiplies, so the one called last applies to a point first.
#include "Offsets.h"
#include <cstring>
namespace Gx {
using Matrix = float[16];
inline void Identity(float *m) {
std::memset(m, 0, sizeof(Matrix));
m[0] = m[5] = m[10] = m[15] = 1.0f;
}
// TranslateMatrix: row 3 += t · rows 0..2.
inline void Translate(float *m, const float (&t)[3]) {
reinterpret_cast<void(__thiscall *)(float *, const float *)>(Offsets::FUN_MATRIX_TRANSLATE)(m, t);
}
// RotateMatrix: by `angle` radians about the unit `axis`.
inline void Rotate(float *m, float angle, const float (&axis)[3]) {
reinterpret_cast<void(__thiscall *)(float *, float, const float *, int)>(Offsets::FUN_MATRIX_ROTATE)(
m, angle, axis, 1);
}
// By the quaternion {x, y, z, w}.
inline void RotateQuaternion(float *m, const float *q) {
reinterpret_cast<void(__thiscall *)(float *, const float *)>(Offsets::FUN_MATRIX_ROTATE_QUATERNION)(m, q);
}
inline void Scale(float *m, float s) {
reinterpret_cast<void(__thiscall *)(float *, float)>(Offsets::FUN_MATRIX_SCALE)(m, s);
}
// MultiplyMatrix4: out = a × b.
inline void Multiply(float *out, const float *a, const float *b) {
reinterpret_cast<float *(__fastcall *)(float *, const float *, const float *)>(Offsets::FUN_MATRIX_MULTIPLY)(
out, a, b);
}
// m = m × b.
inline void MultiplyInPlace(float *m, const float *b) {
reinterpret_cast<float *(__thiscall *)(float *, const float *)>(Offsets::FUN_MATRIX_MULTIPLY_IN_PLACE)(m, b);
}
// InvertAffineMatrix: the inverse of a rotation, uniform `scale` and
// translation.
inline void InvertAffine(const float *m, float *out, float scale) {
reinterpret_cast<void(__thiscall *)(const float *, float *, float)>(Offsets::FUN_MATRIX_INVERT_AFFINE)(
m, out, scale);
}
// The point through the matrix (`in` and `out` may be the same).
inline void TransformPoint(const float (&in)[3], const float *m, float (&out)[3]) {
float v[3] = {in[0], in[1], in[2]};
reinterpret_cast<float *(__fastcall *)(float *, float *, const float *)>(Offsets::FUN_TRANSFORM_POINT_IN_PLACE)(
out, v, m);
}
// The box {min xyz, max xyz} through the matrix, as the box around its
// transformed corners.
inline void TransformBox(const float *m, const float (&box)[6], float (&out)[6]) {
reinterpret_cast<void(__fastcall *)(const float *, const float *, float *)>(Offsets::FUN_TRANSFORM_BOX)(m, box,
out);
}
} // namespace Gx
+97
View File
@@ -0,0 +1,97 @@
// 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/>.
// `GetCursorDelta()` -> deltaX, deltaY
//
// Retail's: how far the cursor moved since the last frame, zero when it did
// not, in GetCursorPosition's units (independent of the UI scale; InputUtil.
// GetCursorDelta divides by a frame's effective scale). The orbit camera of
// a ModelScene drags with it.
//
// The cursor is sampled once a frame, at the head of the UI render callback,
// before any OnUpdate runs, the way the in-game GetCursorPosition (0x0048B820)
// reads it: the top's NDC position (OFF_UI_CONTEXT_MOUSE_POSITION) through
// NDCToDDC, then the engine's script push (Game::InternalToPixel). The delta is
// this frame's sample less the last frame's, so every OnUpdate of a frame sees
// the same value.
#include "Game.h"
#include "Offsets.h"
#include "frame/Layout.h"
#include "tick/FrameTick.h"
namespace Input::CursorDelta {
namespace {
struct Position {
double x = 0.0;
double y = 0.0;
};
Position g_previous;
Position g_current;
bool g_sampled = false;
// What GetCursorPosition pushes now, or false before the UI top exists.
bool ReadCursor(Position &out) {
const void *top = Game::Read<const void *>(Offsets::VAR_UI_CONTEXT_PTR);
if (top == nullptr)
return false;
const float ndcX = Game::Read<float>(top, Offsets::OFF_UI_CONTEXT_MOUSE_POSITION);
const float ndcY = Game::Read<float>(top, Offsets::OFF_UI_CONTEXT_MOUSE_POSITION + 4);
float ddcX = 0.0f;
float ddcY = 0.0f;
Frame::Layout::NdcToDdc(&ddcX, &ddcY, ndcX, ndcY);
out = {Game::InternalToPixel(ddcX), Game::InternalToPixel(ddcY)};
return true;
}
void Sample() {
Position now;
if (!ReadCursor(now))
return;
g_previous = g_sampled ? g_current : now;
g_current = now;
g_sampled = true;
}
const Tick::FrameTick::AutoSubscribeBefore _tick{&Sample};
int __fastcall Script_GetCursorDelta(void *L) {
Game::Lua::PushNumber(L, g_current.x - g_previous.x);
Game::Lua::PushNumber(L, g_current.y - g_previous.y);
return 2;
}
const Game::Doc::Field kRets[] = {
Game::Doc::Req("deltaX", "number"),
Game::Doc::Req("deltaY", "number"),
};
const Game::Doc::Function kDoc{"Returns how far the cursor moved since the last frame.", {},
kRets, "Input"};
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("GetCursorDelta", &Script_GetCursorDelta, &kDoc);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("GetCursorDelta", &Script_GetCursorDelta, &kDoc);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace Input::CursorDelta
+2 -3
View File
@@ -93,6 +93,7 @@
#include "Game.h"
#include "Offsets.h"
#include "debug/Log.h"
#include "storm/Memory.h"
#include <windows.h>
@@ -114,7 +115,6 @@ namespace {
using FileRead_t = int(__stdcall *)(int unused, const char *path, void **outBuf,
unsigned int *outSize, unsigned int extraBytes,
int flag1, int flag2);
using SMemFree_t = void(__stdcall *)(void *ptr, const char *file, int line, int flags);
// MPQ enumerator (see Offsets::FUN_MPQ_ENUM_FILES). The callback
// receives each listfile entry's full path in ecx; returning 0 STOPS
@@ -301,7 +301,6 @@ void ScanPathGetters(Collector &c) {
int WriteFiles(const std::vector<std::string> &files, size_t prefixLen,
const char *dstRoot, int *skipped) {
auto FileRead = reinterpret_cast<FileRead_t>(Offsets::FUN_FILE_READ);
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
int written = 0;
int missed = 0;
@@ -327,7 +326,7 @@ int WriteFiles(const std::vector<std::string> &files, size_t prefixLen,
static_cast<unsigned long>(GetLastError()), dst.c_str());
}
SMemFree(buf, __FILE__, __LINE__, 0);
Storm::Free(buf, __FILE__, __LINE__);
}
if (skipped != nullptr)
*skipped = missed;
+61 -4
View File
@@ -34,6 +34,15 @@
// state machine — we no longer need `WaitingOpen` / `WaitingClose` /
// per-tick `VAR_LOOT_GUID` reads, because the controller hook *is* the
// state transition.
//
// Loot mode remembers corpses whose window opened EMPTY but which stay
// LOOTABLE: the server keeps the flag while any loot is left (another
// member's quest item, condition-gated loot) yet never sends this player a
// slot it can't take, so an empty window is the server's own "nothing here
// for you". Later walks skip such a corpse. A corpse whose window held
// anything (bags full, a roll or master-loot assignment in progress) is not
// remembered and is retried, because those refusals clear on their own.
// See `s_emptyCorpses` for when an entry is forgotten.
#include "../Game.h"
#include "../Offsets.h"
@@ -45,6 +54,7 @@
#include "../time/Clock.h"
#include "Request.h"
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
@@ -68,6 +78,8 @@ constexpr uint32_t kStepTimeoutMs = 3000;
// fire unmodified.
constexpr int EVENT_LOOT_OPENED = 0x10B;
constexpr int EVENT_LOOT_CLOSED = 0x10D;
// Observed, never swallowed (see `s_emptyCorpses`).
constexpr int EVENT_QUEST_LOG_UPDATE = 0x134;
enum class State {
Idle,
@@ -100,6 +112,21 @@ struct ScanCtx {
ScanCtx s_scan;
// Loot-mode corpses whose window opened empty while the unit stayed
// LOOTABLE (see the file comment). Outlives a walk. An entry is forgotten:
// - on the first world tick where its unit is gone (despawned, out of
// view) or no longer LOOTABLE (alive again after a respawn
// with the same GUID, or emptied by someone else), so a corpse back in
// view or a fresh corpse is opened again;
// - all at once on QUEST_LOG_UPDATE: quest items are per-player loot, so
// a quest change can turn an empty window into one with an item in it.
std::vector<uint64_t> s_emptyCorpses;
bool IsKnownEmpty(uint64_t guid) {
return std::find(s_emptyCorpses.begin(), s_emptyCorpses.end(), guid) !=
s_emptyCorpses.end();
}
// === Typedefs for engine helpers ===
using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
@@ -130,6 +157,19 @@ using LootController_t = void(__fastcall *)(int lootStructPtr,
int coin,
int unk);
// Run every world tick, so no gone-and-back or respawn-and-die-again
// can happen between two checks.
void PruneEmptyCorpses() {
s_emptyCorpses.erase(
std::remove_if(s_emptyCorpses.begin(), s_emptyCorpses.end(),
[](uint64_t guid) {
void *unit = Object::ByGuid(Offsets::TYPEMASK_UNIT,
guid, nullptr, 0);
return unit == nullptr || !Request::IsLootable(unit);
}),
s_emptyCorpses.end());
}
// === Scrape ===
// Reads the post-population `VAR_LOOT_SLOTS` table into a LootEntry.
@@ -325,6 +365,10 @@ void __fastcall LootController_h(int lootStructPtr, int coin, int unk) {
LootEntry entry;
entry.guid = s_scan.currentGuid;
ScrapeCurrentLoot(&entry);
// Each corpse is queued once per walk and known-empty ones are not
// queued in loot mode, so this never adds a duplicate.
if (s_scan.lootMode && entry.coin == 0 && entry.items.empty())
s_emptyCorpses.push_back(entry.guid);
s_scan.results.push_back(std::move(entry));
// Loot mode: actually take everything (coin + all slots) before the
@@ -355,9 +399,19 @@ const Game::HookAutoRegister _hookController{
const Event::SignalHook::AutoSubscribe _lootIntercept{&SuppressLootEvents};
// === Tick (refusals and timeouts) ===
// Observes only: the event still fires.
bool ForgetEmptyCorpsesOnQuestChange(int eventID) {
if (eventID == EVENT_QUEST_LOG_UPDATE)
s_emptyCorpses.clear();
return false;
}
const Event::SignalHook::AutoSubscribe _questIntercept{&ForgetEmptyCorpsesOnQuestChange};
// === Tick (memory upkeep, refusals and timeouts) ===
void OnTick() {
PruneEmptyCorpses();
if (s_scan.state == State::Idle)
return;
const bool timedOut = Time::Clock::Elapsed(s_scan.stepStartMs) >= kStepTimeoutMs;
@@ -396,6 +450,7 @@ const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
struct EnumCtx {
void *player;
std::vector<uint64_t> *out;
bool skipKnownEmpty;
};
int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
@@ -404,17 +459,19 @@ int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
return 1;
if (!Request::IsLootable(obj))
return 1;
if (ctx->skipKnownEmpty && IsKnownEmpty(guid))
return 1;
if (!Request::InReach(ctx->player, obj))
return 1;
ctx->out->push_back(guid);
return 1;
}
void BuildQueue(void *player, size_t maxCount) {
void BuildQueue(void *player, size_t maxCount, bool skipKnownEmpty) {
s_scan.queue.clear();
auto Enum = reinterpret_cast<ClntObjMgrEnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
EnumCtx ctx{player, &s_scan.queue};
EnumCtx ctx{player, &s_scan.queue, skipKnownEmpty};
Enum(reinterpret_cast<ClntObjMgrEnumVisibleObjectsCallback_t>(&EnumCallback),
&ctx);
// Cap to `maxCount` corpses (0 = unlimited). Order is engine
@@ -440,7 +497,7 @@ bool BeginWalk(bool lootMode, size_t maxCount) {
s_scan.results.clear();
s_scan.player = player;
s_scan.lootMode = lootMode;
BuildQueue(player, maxCount);
BuildQueue(player, maxCount, /*skipKnownEmpty=*/lootMode);
if (s_scan.queue.empty()) {
Complete(); // nothing nearby — fire LOOT_SCAN_COMPLETED immediately
+7 -2
View File
@@ -84,8 +84,6 @@ Vec3 GetVector(void *camera, uint32_t id) {
return v;
}
namespace {
void SetVector(void *camera, uint32_t id, const Vec3 &v) {
reinterpret_cast<SetVector_t>(Offsets::FUN_CAMERA_SET_VECTOR)(camera, id, &v.x, 0);
}
@@ -98,6 +96,13 @@ void SetScalar(void *camera, uint32_t id, float value) {
reinterpret_cast<SetScalar_t>(Offsets::FUN_CAMERA_SET_SCALAR)(camera, id, value);
}
void SetDeviceTransforms(void *camera, const float (&rect)[4]) {
using Fn = void(__fastcall *)(void *camera, const float *rect, uint32_t unused);
reinterpret_cast<Fn>(Offsets::FUN_CAMERA_SET_DEVICE_TRANSFORMS)(camera, rect, 0);
}
namespace {
// ConfigureCamera's own evaluation pass: facing 0, rebuild the root
// transform, animate the scene, restore the facing.
void AnimateUnrotated(void *pane, void *model) {
+10 -1
View File
@@ -21,7 +21,16 @@ struct Vec3 {
// The M2 model's camera with id `id` (0 = portrait), or null when it has none.
void *CameraById(void *model, uint32_t id);
// A camera's vector property (Offsets::CAMERA_PROP_EYE / _TARGET).
// A camera's properties (Offsets::CAMERA_PROP_*): vectors (eye, target) and
// scalars (clips, field of view, roll, yaw).
Vec3 GetVector(void *camera, uint32_t id);
void SetVector(void *camera, uint32_t id, const Vec3 &v);
float GetScalar(void *camera, uint32_t id);
void SetScalar(void *camera, uint32_t id, float value);
// CameraSetDeviceTransforms (FUN_CAMERA_SET_DEVICE_TRANSFORMS): the device's
// projection and view from the camera, framed over `rect` {bottom, left,
// top, right}.
void SetDeviceTransforms(void *camera, const float (&rect)[4]);
} // namespace Model::Camera
+53 -13
View File
@@ -96,8 +96,8 @@ constexpr uint32_t kGeosetGroupDefaults[16] = {1, 101, 201, 301, 401, 501,
using CharCompCreate_t = void *(__cdecl *)();
using CharCompDestroy_t = void(__fastcall *)(void *builder);
using CharCompSetInfo_t = char(__thiscall *)(void *builder, const CharCompInfo *info);
using CharCompSetItem_t = void(__thiscall *)(void *builder, int slot, int itemDisplayID);
using CharCompPump_t = char(__thiscall *)(void *builder, int *unused);
using CharCompSetItem_t = void(__thiscall *)(void *builder, uint32_t slot, uint32_t itemDisplayID);
using CharCompPump_t = char(__thiscall *)(void *builder, int force);
// A pending / finished dress for one Model frame. The model loads async, and
// the engine only dresses a LOADED model (its poll gate FUN_00607da0 checks
@@ -301,10 +301,32 @@ const uint8_t *DressRecord(uint32_t displayID) {
return bake != nullptr && bake[0] != '\0' ? extraRec : nullptr;
}
void *CreateDisplayModel(void *scene, uint32_t displayID, const char **path) {
using ApplyDisplay_t = void(__fastcall *)(void *model, const uint8_t *display,
const uint8_t *modelData);
const uint8_t *display = DBC::Record(Offsets::VAR_CREATUREDISPLAYINFO_RECORDS,
Offsets::VAR_CREATUREDISPLAYINFO_COUNT, displayID);
if (display == nullptr)
return nullptr;
const uint8_t *modelData = DBC::Record(
Offsets::VAR_CREATUREMODELDATA_RECORDS, Offsets::VAR_CREATUREMODELDATA_COUNT,
Game::Read<uint32_t>(display, Offsets::OFF_CREATUREDISPLAYINFO_MODEL_ID));
if (modelData == nullptr)
return nullptr;
const char *file = Game::Read<const char *>(modelData, Offsets::OFF_CREATUREMODELDATA_MODEL_PATH);
if (path != nullptr)
*path = file;
void *model = M2::CreateModel(scene, file);
if (model != nullptr)
reinterpret_cast<ApplyDisplay_t>(Offsets::FUN_APPLY_CREATURE_DISPLAY)(model, display,
modelData);
return model;
}
// Configure + feed equipment, the engine's order: SetInfo, then the 10 Extra
// equipment slots. Field-for-field mirror of FUN_005fb200's NPC branch.
void *CreateDresser(void *instance, const uint8_t *extraRec) {
void *builder = reinterpret_cast<CharCompCreate_t>(Offsets::FUN_CHARCOMP_CREATE)();
void *builder = NewDresser();
if (builder == nullptr)
return nullptr;
@@ -333,12 +355,10 @@ void *CreateDresser(void *instance, const uint8_t *extraRec) {
DestroyDresser(builder); // releases the model ref already copied in
return nullptr;
}
auto setItem = reinterpret_cast<CharCompSetItem_t>(Offsets::FUN_CHARCOMP_SET_ITEM);
for (int slot = 0; slot < 10; ++slot) {
const auto displayID =
static_cast<int>(field(Offsets::OFF_CREATUREDISPLAYINFOEXTRA_EQUIP + slot * 4));
for (uint32_t slot = 0; slot < 10; ++slot) {
const uint32_t displayID = field(Offsets::OFF_CREATUREDISPLAYINFOEXTRA_EQUIP + slot * 4);
if (displayID != 0)
setItem(builder, slot, displayID);
SetDresserItem(builder, slot, displayID);
}
return builder;
}
@@ -348,16 +368,20 @@ void *CreateDresser(void *instance, const uint8_t *extraRec) {
// black. Each child gets the frame's own light/fog callback; it reads the
// frame's live light struct, so later SetLight calls reach them too.
void LightChildren(void *frame, void *instance) {
const void *lightFogCb =
reinterpret_cast<const void *>(Offsets::FUN_SIMPLEMODELFFX_LIGHT_FOG_CB);
const void *callback = Game::Read<const void *>(instance, Offsets::OFF_MODEL_INSTANCE_RENDER_CB);
void *user = Game::Read<void *>(instance, Offsets::OFF_MODEL_INSTANCE_RENDER_CB_USER);
if (callback == nullptr) {
callback = reinterpret_cast<const void *>(Offsets::FUN_SIMPLEMODELFFX_LIGHT_FOG_CB);
user = frame;
}
for (auto *child = Game::Read<uint8_t *>(instance, Offsets::OFF_MODEL_INSTANCE_CHILD_HEAD);
child != nullptr;
child = Game::Read<uint8_t *>(child, Offsets::OFF_MODEL_INSTANCE_CHILD_NEXT))
M2::SetRenderCallback(child, lightFogCb, frame);
M2::SetRenderCallback(child, callback, user);
}
bool PumpDresser(void *dresser) {
return reinterpret_cast<CharCompPump_t>(Offsets::FUN_CHARCOMP_PUMP)(dresser, nullptr) != 0;
bool PumpDresser(void *dresser, bool force) {
return reinterpret_cast<CharCompPump_t>(Offsets::FUN_CHARCOMP_PUMP)(dresser, force ? 1 : 0) != 0;
}
void DestroyDresser(void *dresser) {
@@ -365,4 +389,20 @@ void DestroyDresser(void *dresser) {
reinterpret_cast<CharCompDestroy_t>(Offsets::FUN_CHARCOMP_DESTROY)(dresser);
}
void *NewDresser() { return reinterpret_cast<CharCompCreate_t>(Offsets::FUN_CHARCOMP_CREATE)(); }
void CloneDresser(void *dresser, void *model, void *source) {
using Clone_t = bool(__thiscall *)(void *dresser, void *model, void *source);
reinterpret_cast<Clone_t>(Offsets::FUN_CHARCOMP_CLONE)(dresser, model, source);
}
void UnequipDresserSlot(void *dresser, uint32_t slot) {
using Unequip_t = void(__thiscall *)(void *dresser, uint32_t slot);
reinterpret_cast<Unequip_t>(Offsets::FUN_CHARCOMP_UNEQUIP_SLOT)(dresser, slot);
}
void SetDresserItem(void *dresser, uint32_t slot, uint32_t displayID) {
reinterpret_cast<CharCompSetItem_t>(Offsets::FUN_CHARCOMP_SET_ITEM)(dresser, slot, displayID);
}
} // namespace Model::DisplayInfo
+24 -4
View File
@@ -18,17 +18,37 @@ namespace Model::DisplayInfo {
// null when the display gets no character customization.
const uint8_t *DressRecord(uint32_t displayID);
// A new model of the display in `scene`, skinned as AttachDisplayModel skins
// one (CreateModelFromPath on the CreatureModelData path, then
// ApplyCreatureDisplay), that the caller owns one reference on. Null for a
// display with no CreatureDisplayInfo or CreatureModelData row. Undressed: a
// DressRecord display still needs CreateDresser once it has loaded. `path`,
// when given, receives the model's file path (DBC memory).
void *CreateDisplayModel(void *scene, uint32_t displayID, const char **path = nullptr);
// The engine character compositor, configured to dress the LOADED model
// `instance` from `extraRec` (a DressRecord) and fed its equipment, or null
// when the engine pool is empty or rejects the info. It holds a reference on
// the model. Pump it with PumpDresser until that returns true; DestroyDresser
// releases it (null is a no-op).
// releases it (null is a no-op). `force` loads every texture at once,
// ignoring the engine's per-frame load budget.
void *CreateDresser(void *instance, const uint8_t *extraRec);
bool PumpDresser(void *dresser);
bool PumpDresser(void *dresser, bool force = false);
void DestroyDresser(void *dresser);
// Gives every model attached to `instance` the light and fog callback that
// `frame` gives its own model, so the children are lit like it.
// The compositor's own calls, for a dresser built another way. A new one
// (FUN_CHARCOMP_CREATE), null when the pool is empty.
void *NewDresser();
// FUN_CHARCOMP_CLONE: `dresser` dresses `model` as `source` dresses its own;
// the caller gives the dresser its reference on `model` first.
void CloneDresser(void *dresser, void *model, void *source);
// One component slot cleared, or set to an ItemDisplayInfo ID.
void UnequipDresserSlot(void *dresser, uint32_t slot);
void SetDresserItem(void *dresser, uint32_t slot, uint32_t displayID);
// Gives every model attached to `instance` the light and fog callback of
// `instance` itself, else the one `frame` gives its own model, so the
// children are lit like it.
void LightChildren(void *frame, void *instance);
} // namespace Model::DisplayInfo
+237 -316
View File
@@ -13,25 +13,23 @@
// `DressUpModel:UndressSlot(slot)`, the 4.3.4 `DressUpModel:TryOn(item)`
// (DressUpModelFrame_Scripts.cpp), and retail's TryOn result, IsGeoReady,
// SetSheathed / GetSheathed and SetAutoDress / GetAutoDress.
// SetSheathed / GetSheathed, SetAutoDress / GetAutoDress, IsSlotAllowed /
// IsSlotVisible and the ItemTransmogInfo methods.
//
// UndressSlot is one step of vanilla's own Undress (DressUpModel::ClearModel
// 0x00504490), which 4.3.4 split per slot: a component slot is cleared with
// UnequipCharacterSlot, a held lane is detached with DetachHeldItemSlot and
// zeroed. The slot map is 4.3.4's, from inventory slot to component slot.
// That step, the lanes and the weapon hanging below are Model::Outfit's,
// which the ModelScene actor shares.
//
// TryOn keeps vanilla's worker (TryOnItem, FUN_DRESSUPMODEL_TRY_ON_ITEM) and
// takes 4.3.4's argument forms in front of it: an item ID, an item link or
// `item:` string, a trade-skill `enchant:` link (the item its recipe makes),
// or the name of an item the player carries. Anything else shows
// takes the arguments Model::TryOn reads in front of it. No item shows
// ERR_NOT_EQUIPPABLE, as 4.3.4 does.
//
// Retail's hand argument is an inventory slot name, matched the way
// GetInventorySlotInfo matches it (SStrCmpI over the PaperDollItemFrame
// rows): MainHandSlot or SecondaryHandSlot. The worker picks a hand at one
// site (SITE_TRY_ON_HAND_CHOICE); a named hand sends a held item down that
// hand's path, so the worker's own eviction of an incompatible other lane
// runs, as 4.3.4's does for a forced hand.
// The worker picks a hand at one site (SITE_TRY_ON_HAND_CHOICE); a named hand
// sends a held item down that hand's path, so the worker's own eviction of an
// incompatible other lane runs, as 4.3.4's does for a forced hand.
//
// The enchant glow is the player's AttachEnchantVisual step on the item the
// worker hangs: FindChild at the attachment AttachItemModel used, then
@@ -41,11 +39,10 @@
// the link's enchant field, as 4.3.4 passes it. The worker's cache callback
// re-runs with neither hand nor enchant, as 4.3.4's does.
//
// 4.3.4's worker (`TryOnWithVisual`, 0x00A05010) tries on what a recipe makes:
// an item with no inventory type whose spell teaches a CREATE_ITEM spell is
// swapped for the made item. Vanilla's worker shows ERR_NOT_EQUIPPABLE
// instead. `TryOnItem` adds the swap in front of the worker, for our TryOn
// and for the worker's cache-load callback, so an uncached pattern works too.
// Vanilla's worker shows ERR_NOT_EQUIPPABLE for a recipe, where 4.3.4's
// tries on what it makes. `TryOnItem` adds that swap (Model::TryOn::WornItem)
// in front of the worker, for our TryOn and for the worker's cache-load
// callback, so an uncached pattern works too.
//
// TryOn's result (retail's ItemTryOnReason) is read from the same gates the
// worker passes: an uncached record is DataPending (the worker has queued the
@@ -63,30 +60,34 @@
//
// Auto-dress off undresses the pane with ClearModel right after
// SetModelInstance clones the unit, unless Dress() is the one rebuilding it.
//
// The ItemTransmogInfo methods are the ModelScene actor's (Outfit::Worn, the
// item ID as the appearance, the glowing enchant as the illusion) on what the
// pane wears: the unit's visible items when SetModelInstance builds it, then
// each slot the worker fills (an armor arm's component slot, or the lane
// TryOnAttach hangs) and each lane it evicts. A read first drops every slot
// the pane no longer shows (Outfit::TrimWorn), which covers the engine's own
// Undress. SetItemTransmogInfo is TryOn without the worker's
// ERR_NOT_EQUIPPABLE: the worker shows it only in its default arm, once the
// record is cached, so a quiet try-on of such an item skips the worker, then
// or when its cache callback runs.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "Common.h"
#include "item/Arg.h"
#include "item/Dressable.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include "model/ItemDisplay.h"
#include "model/M2.h"
#include "model/Outfit.h"
#include "model/TryOn.h"
#include "object/Resolve.h"
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unordered_map>
#include <unordered_set>
namespace Model::DressUp {
namespace {
using UnequipSlot_t = void(__thiscall *)(void *component, uint32_t slot);
using DetachHeldItem_t = void(__fastcall *)(void *model, uint32_t bodySlot, uint32_t sheatheType,
bool shield);
using AttachItemModel_t = int(__fastcall *)(void *model, const void *record, uint32_t bodySlot,
@@ -95,116 +96,38 @@ using AttachItemModel_t = int(__fastcall *)(void *model, const void *record, uin
using TryOnItem_t = void(__thiscall *)(void *pane, uint32_t itemID);
using SetModelInstance_t = void(__thiscall *)(void *pane, void *model);
using ClearModel_t = void(__thiscall *)(void *pane);
using GetHeldItemDisplay_t = const void *(__thiscall *)(void *unit, uint32_t hand);
using FindChild_t = void *(__thiscall *)(void *model, int attachId);
using GetVisibleItem_t = const void *(__thiscall *)(void *player, uint32_t slot);
using GetDescVisual_t = int(__thiscall *)(const void *visibleItem);
using DisplayError_t = void(__cdecl *)(uint32_t id, ...);
// Body slots of the two held lanes.
constexpr uint32_t kBodySlotMainHand = 0xF;
constexpr uint32_t kBodySlotOffHand = 0x10;
// Retail's Enum.ItemTryOnReason.
constexpr int kTryOnSuccess = 0;
constexpr int kTryOnWrongRace = 1;
constexpr int kTryOnNotEquippable = 2;
constexpr int kTryOnDataPending = 3;
// A held lane (OFF_DRESSUPMODEL_*_HAND_LANE).
struct HeldLane {
uint8_t itemClass; // 0 = empty
uint8_t unknown1[2];
uint8_t inventoryType;
uint8_t sheatheType;
uint8_t unknown5[3];
};
static_assert(sizeof(HeldLane) == 8, "ClearModel zeroes 8 bytes per lane");
struct Lane {
uintptr_t offset;
uint32_t bodySlot;
};
constexpr Lane kLanes[2] = {
{Offsets::OFF_DRESSUPMODEL_MAIN_HAND_LANE, kBodySlotMainHand},
{Offsets::OFF_DRESSUPMODEL_OFF_HAND_LANE, kBodySlotOffHand},
};
static_assert(Offsets::OFF_DRESSUPMODEL_OFF_HAND_LANE ==
Offsets::OFF_DRESSUPMODEL_MAIN_HAND_LANE + sizeof(Outfit::HeldLane),
"the pane's two lanes are one Outfit::Lanes");
struct PaneState {
bool autoDress = true;
bool sheathSet = false; // SetSheathed was called; weapons follow the two below
bool sheathed = false;
bool hideWeapons = false;
const void *laneDisplay[2] = {}; // ItemDisplayInfo row on each lane, or null
uint32_t laneVisual[2] = {}; // enchant ItemVisuals row on each lane, or 0
Outfit::Weapons weapons;
Outfit::Worn worn = {};
std::unordered_set<uint32_t> quietPending; // quiet try-ons waiting for their record
};
std::unordered_map<void *, PaneState> g_panes;
void *g_tryOnPane = nullptr; // the pane whose TryOnItem worker is running
uint32_t g_tryOnBodySlot = 0; // the hand TryOn named (kBodySlot*), or 0
uint32_t g_tryOnEnchant = 0; // the SpellItemEnchantment TryOn gave, or 0
bool g_tryOnQuiet = false; // the try-on shows no ERR_NOT_EQUIPPABLE
uint32_t g_tryOnItem = 0; // the item the worker is putting on
bool g_dressing = false; // Dress() is rebuilding the pane
uintptr_t g_mainHandPath = Offsets::TRY_ON_MAIN_HAND_PATH;
uintptr_t g_offHandPath = Offsets::TRY_ON_OFF_HAND_PATH;
HeldLane &LaneOf(void *pane, int i) {
return Game::Ref<HeldLane>(pane, kLanes[i].offset);
Outfit::Lanes &LanesOf(void *pane) {
return Game::Ref<Outfit::Lanes>(pane, Offsets::OFF_DRESSUPMODEL_MAIN_HAND_LANE);
}
int LaneIndex(uint32_t bodySlot) {
return bodySlot == kBodySlotOffHand ? 1 : 0;
}
// The glow `enchantID` gives an item shown with `display`: none when the
// display has its own.
uint32_t EnchantVisual(const void *display, uint32_t enchantID) {
if (display == nullptr ||
Model::ItemDisplay::OwnVisual(static_cast<const uint8_t *>(display)) != 0)
return 0;
const uint8_t *enchant = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
return enchant != nullptr
? Game::Read<uint32_t>(enchant, Offsets::OFF_SPELLITEMENCHANT_ITEM_VISUAL)
: 0;
}
// AttachEnchantVisual's step (0x005EED50): the glow on the item hung at
// `attachId`.
void HangVisual(void *model, int attachId, uint32_t visual) {
if (visual == 0 || attachId < 0)
return;
if (void *item = reinterpret_cast<FindChild_t>(Offsets::FUN_M2MODEL_FIND_CHILD)(model,
attachId))
M2::AttachItemVisuals(item, visual);
}
// DetachHeldItemSlot on lane `i`'s item.
void DetachLane(void *model, int i, const HeldLane &lane) {
reinterpret_cast<DetachHeldItem_t>(Offsets::FUN_DETACH_HELD_ITEM_SLOT)(
model, kLanes[i].bodySlot, lane.sheatheType,
lane.inventoryType == Offsets::INVTYPE_SHIELD);
}
// The unit's AttachHandItem step on each occupied lane: detach it, then hang
// it in the hand or the sheath, or leave it off.
void ApplyWeapons(void *pane, const PaneState &s) {
void *model = Pane::Model(pane);
if (model == nullptr)
return;
for (int i = 0; i < 2; ++i) {
const HeldLane &lane = LaneOf(pane, i);
if (lane.itemClass == 0 || s.laneDisplay[i] == nullptr)
continue;
DetachLane(model, i, lane);
if (s.hideWeapons)
continue;
const int attachId = reinterpret_cast<AttachItemModel_t>(Offsets::FUN_ATTACH_ITEM_MODEL)(
model, s.laneDisplay[i], kLanes[i].bodySlot, lane.sheatheType, s.sheathed,
lane.inventoryType == Offsets::INVTYPE_SHIELD,
lane.inventoryType == Offsets::INVTYPE_RANGEDRIGHT);
HangVisual(model, attachId, s.laneVisual[i]);
}
Outfit::HangWeapons(Pane::Model(pane), LanesOf(pane), s.weapons, s.sheathed, s.hideWeapons);
}
// Slot 36, after the original has cloned the unit and copied its lanes.
@@ -214,30 +137,11 @@ void __fastcall SetModelInstance(void *pane, void * /*edx*/, void *model) {
if (model == nullptr)
return;
PaneState &s = g_panes[pane];
s.laneDisplay[0] = s.laneDisplay[1] = nullptr;
s.laneVisual[0] = s.laneVisual[1] = 0;
// The original filled the lanes from this unit, resolved the same way.
const uint64_t guid = Game::Read<uint64_t>(pane, Offsets::OFF_PLAYERMODEL_UNIT_GUID);
if (void *unit = Object::ByGuid(Offsets::TYPEMASK_UNIT, guid)) {
const auto get = Game::Read<GetHeldItemDisplay_t>(
Game::Read<const void *>(unit, 0), Offsets::VTBL_UNIT_GET_HELD_ITEM_DISPLAY);
s.laneDisplay[0] = get(unit, 0);
s.laneDisplay[1] = get(unit, 1);
}
// AttachHandEnchant's glow (0x005EED00), for players only: the visible
// item in the lane's slot (0xF / 0x10, the same numbers as the body
// slots), and its visual unless the display hangs its own (-1).
if (void *player = Object::ByGuid(Offsets::TYPEMASK_PLAYER, guid)) {
for (int i = 0; i < 2; ++i) {
const void *item = reinterpret_cast<GetVisibleItem_t>(
Offsets::FUN_UNIT_GET_VISIBLE_ITEM)(player, kLanes[i].bodySlot);
if (item == nullptr || Game::Read<uint32_t>(item, Offsets::OFF_VISIBLE_ITEM_ITEM_ID) == 0)
continue;
const int visual =
reinterpret_cast<GetDescVisual_t>(Offsets::FUN_VISIBLE_ITEM_GET_DESC_VISUAL)(item);
s.laneVisual[i] = visual > 0 ? static_cast<uint32_t>(visual) : 0;
}
}
const auto guid = Game::Read<uint64_t>(pane, Offsets::OFF_PLAYERMODEL_UNIT_GUID);
Outfit::ReadWeapons(guid, s.weapons);
Outfit::ReadWorn(guid != 0 ? ::Object::ByGuid(Offsets::TYPEMASK_UNIT, guid) : nullptr, guid,
s.weapons, s.worn);
if (!s.autoDress && !g_dressing) {
reinterpret_cast<ClearModel_t>(Offsets::FUN_DRESSUPMODEL_CLEAR_MODEL)(pane);
return;
@@ -246,24 +150,26 @@ void __fastcall SetModelInstance(void *pane, void * /*edx*/, void *model) {
ApplyWeapons(pane, s);
}
// TryOnItem's install: record the lane's row and glow, honor SetSheathed,
// then hang the glow.
// TryOnItem's install: record the lane's row, glow and item, honor
// SetSheathed, then hang the glow.
int __fastcall TryOnAttach(void *model, const void *record, uint32_t bodySlot,
uint32_t sheatheType, bool sheathed, bool shield, bool ranged) {
const auto attach = reinterpret_cast<AttachItemModel_t>(Offsets::FUN_ATTACH_ITEM_MODEL);
if (g_tryOnPane == nullptr)
return attach(model, record, bodySlot, sheatheType, sheathed, shield, ranged);
PaneState &s = g_panes[g_tryOnPane];
const int lane = LaneIndex(bodySlot);
s.laneDisplay[lane] = record;
s.laneVisual[lane] = EnchantVisual(record, g_tryOnEnchant);
const int lane = Outfit::LaneIndex(bodySlot);
s.weapons.display[lane] = record;
s.weapons.visual[lane] = Outfit::EnchantVisual(record, g_tryOnEnchant);
s.worn[(lane == 1 ? Offsets::INVSLOT_OFFHAND : Offsets::INVSLOT_MAINHAND) - 1] = {
g_tryOnItem, s.weapons.visual[lane] != 0 ? g_tryOnEnchant : 0};
if (s.sheathSet) {
if (s.hideWeapons)
return -1;
sheathed = s.sheathed;
}
const int attachId = attach(model, record, bodySlot, sheatheType, sheathed, shield, ranged);
HangVisual(model, attachId, s.laneVisual[lane]);
Outfit::HangVisual(model, attachId, s.weapons.visual[lane]);
return attachId;
}
@@ -273,9 +179,10 @@ int __fastcall TryOnAttach(void *model, const void *record, uint32_t bodySlot,
void __fastcall TryOnEvict(void *model, uint32_t bodySlot, uint32_t sheatheType, bool shield) {
if (g_tryOnPane != nullptr) {
PaneState &s = g_panes[g_tryOnPane];
const int lane = LaneIndex(bodySlot);
s.laneDisplay[lane] = nullptr;
s.laneVisual[lane] = 0;
const int lane = Outfit::LaneIndex(bodySlot);
s.weapons.display[lane] = nullptr;
s.weapons.visual[lane] = 0;
s.worn[(lane == 1 ? Offsets::INVSLOT_OFFHAND : Offsets::INVSLOT_MAINHAND) - 1] = {};
}
reinterpret_cast<DetachHeldItem_t>(Offsets::FUN_DETACH_HELD_ITEM_SLOT)(model, bodySlot,
sheatheType, shield);
@@ -302,33 +209,6 @@ __declspec(naked) void HandChoiceThunk() {
}
}
// ClearModel's lane step: detach the held item and zero the lane.
void ClearLane(void *pane, int i) {
HeldLane &lane = LaneOf(pane, i);
if (lane.itemClass == 0)
return;
DetachLane(Pane::Model(pane), i, lane);
lane = HeldLane{};
}
// Component slot for a 0-based inventory slot, or -1 (4.3.4 UndressSlot).
int ComponentSlot(int slot0) {
switch (slot0) {
case 0: return 0; // head
case 2: return 1; // shoulder
case 3: return 2; // shirt
case 4: return 3; // chest
case 5: return 4; // waist
case 6: return 5; // legs
case 7: return 6; // feet
case 8: return 7; // wrist
case 9: return 8; // hands
case 14: return 10; // back
case 18: return 9; // tabard
default: return -1;
}
}
int __fastcall Script_UndressSlot(void *L) {
void *pane = Game::Lua::ResolveDressUpModel(L);
if (pane == nullptr)
@@ -337,166 +217,87 @@ int __fastcall Script_UndressSlot(void *L) {
Game::Lua::Error(L, "Usage: %s:UndressSlot(slotIndex)", Game::Lua::ObjectDisplayName(pane));
return 0;
}
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 2)) - 1;
void *component = Game::Read<void *>(pane, Offsets::OFF_DRESSUPMODEL_COMPONENT);
if (Pane::Model(pane) == nullptr || component == nullptr)
return 0;
if (slot0 + 1 == Offsets::INVSLOT_MAINHAND) {
ClearLane(pane, 0);
} else if (slot0 + 1 == Offsets::INVSLOT_OFFHAND) {
ClearLane(pane, 1);
} else {
const int compSlot = ComponentSlot(slot0);
if (compSlot >= 0)
reinterpret_cast<UnequipSlot_t>(Offsets::FUN_CHARCOMP_UNEQUIP_SLOT)(
component, static_cast<uint32_t>(compSlot));
}
Outfit::UndressSlot(Pane::Model(pane),
Game::Read<void *>(pane, Offsets::OFF_DRESSUPMODEL_COMPONENT),
LanesOf(pane), static_cast<int>(Game::Lua::ToNumber(L, 2)));
return 0;
}
// The item a trade-skill recipe makes, from an `enchant:<spellID>` link: a
// TRADESPELL whose first effect makes an item (4.3.4's test).
uint32_t RecipeItem(const char *link) {
const char *enchant = std::strstr(link, "enchant:");
if (enchant == nullptr)
return 0;
const long spellID = std::strtol(enchant + 8, nullptr, 10);
if (spellID <= 0)
return 0;
const uint8_t *spell = DBC::Record(Offsets::VAR_SPELL_RECORDS, Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (spell == nullptr ||
(Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_ATTRIBUTES) &
Offsets::SPELL_ATTR_TRADESPELL) == 0)
return 0;
return Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_EFFECT_ITEM_TYPE);
}
// The item the worker is given: a cached recipe item becomes what it makes.
uint32_t WornItem(uint32_t itemID) {
const uint8_t *record = Item::PeekRecord(itemID);
if (record != nullptr &&
Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE) == 0) {
const uint32_t made = Item::Dressable::RecipeItem(record);
if (made != 0)
return made;
}
return itemID;
}
// `__thiscall(pane, itemID)` as `__fastcall` with the EDX slot unused, so it
// can stand at the worker's call sites.
void __fastcall TryOnItem(void *pane, void *, uint32_t itemID) {
g_tryOnPane = pane;
reinterpret_cast<TryOnItem_t>(Offsets::FUN_DRESSUPMODEL_TRY_ON_ITEM)(pane, WornItem(itemID));
g_tryOnPane = nullptr;
}
// The lane a hand name picks: the PaperDollItemFrame row it names
// (GetInventorySlotInfo's match), if that row is a held slot; else 0.
uint32_t NamedHand(const char *name) {
const auto *records = Game::Read<const uint8_t *>(Offsets::VAR_PAPERDOLLITEMFRAME_RECORDS);
const int count = Game::Read<int>(Offsets::VAR_PAPERDOLLITEMFRAME_COUNT);
if (records == nullptr)
return 0;
for (int i = 0; i < count; ++i) {
const uint8_t *row = records + i * Offsets::OFF_PAPERDOLLITEMFRAME_RECORD_STRIDE;
if (Game::StrCmpI(Game::Read<const char *>(row, Offsets::OFF_PAPERDOLLITEMFRAME_SLOT_NAME),
name) != 0)
continue;
switch (Game::Read<int>(row, Offsets::OFF_PAPERDOLLITEMFRAME_SLOT_NUMBER)) {
case Offsets::INVSLOT_MAINHAND: return kBodySlotMainHand;
case Offsets::INVSLOT_OFFHAND: return kBodySlotOffHand;
default: return 0;
}
}
return 0;
// The worker's armor gate: the component and the ready byte.
bool ArmorReady(void *pane) {
return Game::Read<void *>(pane, Offsets::OFF_DRESSUPMODEL_COMPONENT) != nullptr &&
Game::Read<uint8_t>(pane, Offsets::OFF_DRESSUPMODEL_READY) != 0;
}
// What the worker did with `itemID`, from the gates it passes (0x00504540).
int TryOnResult(void *pane, uint32_t itemID) {
const uint8_t *record = Item::PeekRecord(itemID);
if (record == nullptr)
return kTryOnDataPending;
return TryOn::kDataPending;
if (Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID) == 0 ||
Pane::Model(pane) == nullptr)
return kTryOnNotEquippable;
switch (Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE)) {
case Offsets::INVTYPE_HEAD:
case Offsets::INVTYPE_SHOULDER:
case Offsets::INVTYPE_BODY:
case Offsets::INVTYPE_CHEST:
case Offsets::INVTYPE_WAIST:
case Offsets::INVTYPE_LEGS:
case Offsets::INVTYPE_FEET:
case Offsets::INVTYPE_WRIST:
case Offsets::INVTYPE_HAND:
case Offsets::INVTYPE_CLOAK:
case Offsets::INVTYPE_TABARD:
case Offsets::INVTYPE_ROBE:
// The armor arms: the component and the ready byte.
return Game::Read<void *>(pane, Offsets::OFF_DRESSUPMODEL_COMPONENT) != nullptr &&
Game::Read<uint8_t>(pane, Offsets::OFF_DRESSUPMODEL_READY) != 0
? kTryOnSuccess
: kTryOnNotEquippable;
case Offsets::INVTYPE_WEAPON:
case Offsets::INVTYPE_SHIELD:
case Offsets::INVTYPE_RANGED:
case Offsets::INVTYPE_2HWEAPON:
case Offsets::INVTYPE_WEAPONMAINHAND:
case Offsets::INVTYPE_WEAPONOFFHAND:
case Offsets::INVTYPE_HOLDABLE:
case Offsets::INVTYPE_THROWN:
case Offsets::INVTYPE_RANGEDRIGHT:
return kTryOnSuccess; // the held arm
return TryOn::kNotEquippable;
switch (Outfit::ArmOf(Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE))) {
case Outfit::Arm::Armor:
return ArmorReady(pane) ? TryOn::kSuccess : TryOn::kNotEquippable;
case Outfit::Arm::Held:
return TryOn::kSuccess;
default:
return kTryOnNotEquippable;
return TryOn::kNotEquippable;
}
}
// `__thiscall(pane, itemID)` as `__fastcall` with the EDX slot unused, so it
// can stand at the worker's call sites. An armor arm the worker took wears
// the item in its slot.
void __fastcall TryOnItem(void *pane, void *, uint32_t itemID) {
PaneState &s = g_panes[pane];
const bool quiet = g_tryOnQuiet || s.quietPending.erase(itemID) != 0;
const uint32_t item = TryOn::WornItem(itemID);
const uint8_t *record = Item::PeekRecord(item);
uint32_t componentSlot = 0;
const Outfit::Arm arm =
record != nullptr
? Outfit::ArmOf(Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE),
&componentSlot)
: Outfit::Arm::Nothing;
if (record == nullptr) {
if (quiet)
s.quietPending.insert(item);
} else if (quiet && arm == Outfit::Arm::NotEquippable) {
return;
}
g_tryOnPane = pane;
g_tryOnItem = item;
reinterpret_cast<TryOnItem_t>(Offsets::FUN_DRESSUPMODEL_TRY_ON_ITEM)(pane, item);
g_tryOnPane = nullptr;
g_tryOnItem = 0;
if (arm == Outfit::Arm::Armor && TryOnResult(pane, item) == TryOn::kSuccess)
s.worn[Outfit::InvSlotOfComponent(componentSlot) - 1] = {item, 0};
}
// TryOn's step: the worker with the request's hand, enchant and quiet.
int TryOnRequest(void *pane, const TryOn::Request &request) {
g_tryOnBodySlot = request.bodySlot;
g_tryOnEnchant = request.enchant;
g_tryOnQuiet = request.quiet;
TryOnItem(pane, nullptr, request.itemID);
g_tryOnBodySlot = g_tryOnEnchant = 0;
g_tryOnQuiet = false;
return TryOnResult(pane, TryOn::WornItem(request.itemID));
}
int __fastcall Script_TryOn(void *L) {
void *pane = Game::Lua::ResolveDressUpModel(L);
if (pane == nullptr)
return 0;
// Read before the name search below, which stomps the Lua stack.
const uint32_t bodySlot = Game::Lua::Type(L, 3) == Game::Lua::TYPE_STRING
? NamedHand(Game::Lua::ToString(L, 3))
: 0;
const bool enchantGiven = Game::Lua::IsNumber(L, 4) != 0;
uint32_t enchant = enchantGiven ? static_cast<uint32_t>(Game::Lua::ToNumber(L, 4)) : 0;
uint32_t itemID = 0;
if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_NUMBER) {
itemID = static_cast<uint32_t>(Game::Lua::ToNumber(L, 2));
} else if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_STRING) {
// Copied: the name search below stomps the Lua stack.
char s[256];
std::snprintf(s, sizeof(s), "%s", Game::Lua::ToString(L, 2));
if (std::strstr(s, "enchant:") != nullptr) {
itemID = RecipeItem(s);
} else {
// An item link, `item:` string or numeric string resolves to an ID;
// any other string is the name of an item the player carries.
const Item::Arg::Resolved arg = Item::Arg::ResolveString(s);
Item::Location::ByGUIDResult found{};
if (arg.itemID != 0) {
itemID = static_cast<uint32_t>(arg.itemID);
if (!enchantGiven)
enchant = static_cast<uint32_t>(arg.enchant);
} else if (arg.name != nullptr && Item::Location::FindByArg(L, arg, &found))
itemID = static_cast<uint32_t>(Item::ID::FromCGItem(found.item));
}
}
if (itemID == 0) {
reinterpret_cast<DisplayError_t>(Offsets::FUN_GAMEUI_DISPLAY_ERROR)(
Offsets::GAME_ERROR_NOT_EQUIPPABLE);
Game::Lua::PushNumber(L, kTryOnNotEquippable);
const TryOn::Request request = TryOn::ReadArgs(L);
if (request.itemID == 0) {
TryOn::ShowNotEquippable();
Game::Lua::PushNumber(L, TryOn::kNotEquippable);
return 1;
}
g_tryOnBodySlot = bodySlot;
g_tryOnEnchant = enchant;
TryOnItem(pane, nullptr, itemID);
g_tryOnBodySlot = g_tryOnEnchant = 0;
Game::Lua::PushNumber(L, TryOnResult(pane, WornItem(itemID)));
Game::Lua::PushNumber(L, TryOnRequest(pane, request));
return 1;
}
@@ -552,6 +353,81 @@ int __fastcall Script_GetAutoDress(void *L) {
return 1;
}
// What the pane wears now: nothing without a model, and no slot it no
// longer shows.
const Outfit::Worn &WornOf(void *pane) {
PaneState &s = g_panes[pane];
if (Pane::Model(pane) == nullptr) {
for (Outfit::Appearance &slot : s.worn)
slot = {};
}
Outfit::TrimWorn(Game::Read<void *>(pane, Offsets::OFF_DRESSUPMODEL_COMPONENT), LanesOf(pane),
s.worn);
return s.worn;
}
int __fastcall Script_GetItemTransmogInfo(void *L) {
void *pane = Game::Lua::ResolveDressUpModel(L);
if (pane == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: %s:GetItemTransmogInfo(inventorySlot)",
Game::Lua::ObjectDisplayName(pane));
return 0;
}
const int invSlot = static_cast<int>(Game::Lua::ToNumber(L, 2));
if (Pane::Model(pane) == nullptr || invSlot < 1 || invSlot > Outfit::kInventorySlots)
return 0;
TryOn::PushTransmogInfo(L, WornOf(pane)[invSlot - 1]);
return 1;
}
int __fastcall Script_GetItemTransmogInfoList(void *L) {
void *pane = Game::Lua::ResolveDressUpModel(L);
if (pane == nullptr)
return 0;
TryOn::PushTransmogInfoList(L, WornOf(pane));
return 1;
}
// TryOn with the info's item and enchant, quiet. No appearance changes
// nothing.
int __fastcall Script_SetItemTransmogInfo(void *L) {
void *pane = Game::Lua::ResolveDressUpModel(L);
if (pane == nullptr)
return 0;
TryOn::Request request;
if (!TryOn::ReadTransmogInfo(L, Game::Lua::ObjectDisplayName(pane), request))
return 0;
Game::Lua::PushNumber(L, request.itemID == 0 ? TryOn::kNotEquippable
: TryOnRequest(pane, request));
return 1;
}
// The slots the worker's arms put an item in. No 1.12 model hides a slot it
// takes, so IsSlotVisible gives the same.
int SlotQuery(void *L, const char *usage) {
void *pane = Game::Lua::ResolveDressUpModel(L);
if (pane == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, usage, Game::Lua::ObjectDisplayName(pane));
return 0;
}
Game::Lua::PushBool(L, Pane::Model(pane) != nullptr &&
Outfit::SlotAllowed(static_cast<int>(Game::Lua::ToNumber(L, 2)),
ArmorReady(pane)));
return 1;
}
int __fastcall Script_IsSlotAllowed(void *L) {
return SlotQuery(L, "Usage: %s:IsSlotAllowed(slot)");
}
int __fastcall Script_IsSlotVisible(void *L) {
return SlotQuery(L, "Usage: %s:IsSlotVisible(slot)");
}
const Game::Lua::FrameMethodEntry g_methods[] = {
{"UndressSlot", &Script_UndressSlot},
{"TryOn", &Script_TryOn},
@@ -561,6 +437,11 @@ const Game::Lua::FrameMethodEntry g_methods[] = {
{"GetSheathed", &Script_GetSheathed},
{"SetAutoDress", &Script_SetAutoDress},
{"GetAutoDress", &Script_GetAutoDress},
{"GetItemTransmogInfo", &Script_GetItemTransmogInfo},
{"GetItemTransmogInfoList", &Script_GetItemTransmogInfoList},
{"SetItemTransmogInfo", &Script_SetItemTransmogInfo},
{"IsSlotAllowed", &Script_IsSlotAllowed},
{"IsSlotVisible", &Script_IsSlotVisible},
};
const Game::Doc::Field kUndressArgs[] = {
@@ -609,18 +490,58 @@ const Game::Doc::Field kEnabledRets[] = {
const Game::Doc::Function kGetAutoDress{
"Returns whether the model wears its unit's equipment when the unit is set.", {},
kEnabledRets};
const Game::Doc::Field kTransmogInfoRets[] = {
Game::Doc::Req("itemTransmogInfo", "ItemTransmogInfo",
"What the slot wears: appearanceID is the item ID, illusionID the enchant whose "
"glow a weapon shows. Nil when the model shows nothing."),
};
const Game::Doc::Function kGetItemTransmogInfo{
"Returns what the model wears in one inventory slot.", kUndressArgs, kTransmogInfoRets};
const Game::Doc::Field kTransmogInfoListRets[] = {
Game::Doc::Req("infoList", "table", "An ItemTransmogInfo for each inventory slot 1 to 19."),
};
const Game::Doc::Function kGetItemTransmogInfoList{
"Returns what the model wears in each inventory slot.", {}, kTransmogInfoListRets};
const Game::Doc::Field kSetTransmogInfoArgs[] = {
Game::Doc::Req("transmogInfo", "ItemTransmogInfo",
"appearanceID is an item ID, illusionID a SpellItemEnchantment ID."),
Game::Doc::Opt("inventorySlots", "number", nullptr, "16 or 17: the hand that holds a weapon."),
Game::Doc::Opt("ignoreChildItems", "bool", "false", "Has no effect."),
};
const Game::Doc::Function kSetItemTransmogInfo{
"Puts an item on the model, as TryOn does. Shows no error. An appearanceID of 0 changes "
"nothing.",
kSetTransmogInfoArgs, kTryOnRets};
const Game::Doc::Field kSlotAllowedRets[] = {
Game::Doc::Req("allowed", "bool"),
};
const Game::Doc::Function kIsSlotAllowed{
"Returns whether TryOn can put an item in an inventory slot of the model. The hands are "
"always allowed. An armor slot is allowed when TryOn can put armor on the model.",
kUndressArgs, kSlotAllowedRets};
const Game::Doc::Field kSlotVisibleRets[] = {
Game::Doc::Req("visible", "bool"),
};
const Game::Doc::Function kIsSlotVisible{
"Returns whether an item in an inventory slot shows on the model. Same as IsSlotAllowed.",
kUndressArgs, kSlotVisibleRets};
const Game::Doc::Method g_methodDocs[] = {
{"UndressSlot", &kUndressSlot}, {"TryOn", &kTryOn},
{"Dress", &kDress}, {"IsGeoReady", &kIsGeoReady},
{"SetSheathed", &kSetSheathed}, {"GetSheathed", &kGetSheathed},
{"SetAutoDress", &kSetAutoDress}, {"GetAutoDress", &kGetAutoDress},
{"GetItemTransmogInfo", &kGetItemTransmogInfo},
{"GetItemTransmogInfoList", &kGetItemTransmogInfoList},
{"SetItemTransmogInfo", &kSetItemTransmogInfo},
{"IsSlotAllowed", &kIsSlotAllowed},
{"IsSlotVisible", &kIsSlotVisible},
};
const Game::Lua::EnumIntegerEntry kItemTryOnReason[] = {
{"Success", kTryOnSuccess},
{"WrongRace", kTryOnWrongRace},
{"NotEquippable", kTryOnNotEquippable},
{"DataPending", kTryOnDataPending},
{"Success", TryOn::kSuccess},
{"WrongRace", TryOn::kWrongRace},
{"NotEquippable", TryOn::kNotEquippable},
{"DataPending", TryOn::kDataPending},
};
void RegisterLuaFunctions() {
+318
View File
@@ -19,6 +19,7 @@
#include "Game.h"
#include "Offsets.h"
#include <cstddef>
#include <cstdint>
namespace Model::M2 {
@@ -50,6 +51,19 @@ inline void *CreateModel(void *scene, const char *path) {
return reinterpret_cast<Fn>(Offsets::FUN_M2SCENE_CREATE_MODEL)(scene, path, 0);
}
// A new instance of `source`'s model in `scene` without its item attachments
// (helm, shoulders, held items), the way the portrait bake copies a unit:
// CreateModelFromModel (FUN_M2SCENE_COPY_MODEL), then DetachAllItemModels
// (FUN_DETACH_ALL_ITEM_MODELS). The caller owns one reference, or null.
inline void *CopyWithoutItems(void *scene, void *source) {
using Copy_t = void *(__thiscall *)(void *, void *, uint32_t);
using DetachAll_t = void(__fastcall *)(void *);
void *copy = reinterpret_cast<Copy_t>(Offsets::FUN_M2SCENE_COPY_MODEL)(scene, source, 0);
if (copy != nullptr)
reinterpret_cast<DetachAll_t>(Offsets::FUN_DETACH_ALL_ITEM_MODELS)(copy);
return copy;
}
inline void PlayAnimation(void *model, int keyBone, uint32_t id, int variation, int offset,
float speed, int blend, int primary) {
using Fn = void(__thiscall *)(void *, int, uint32_t, int, int, float, int, int);
@@ -57,6 +71,45 @@ inline void PlayAnimation(void *model, int keyBone, uint32_t id, int variation,
offset, speed, blend, primary);
}
// The key bone's sequence as it plays (FUN_M2MODEL_GET_KEY_BONE_ANIM_STATE).
struct AnimState {
uint32_t id;
uint32_t variation;
uint32_t position; // ms into the sequence
float speed;
uint32_t lo;
uint32_t hi;
uint32_t latch;
};
static_assert(sizeof(AnimState) == Offsets::M2_KEY_BONE_ANIM_STATE_SIZE);
static_assert(offsetof(AnimState, id) == Offsets::OFF_M2_KEY_BONE_ANIM_STATE_ID);
static_assert(offsetof(AnimState, variation) == Offsets::OFF_M2_KEY_BONE_ANIM_STATE_VARIATION);
static_assert(offsetof(AnimState, position) == Offsets::OFF_M2_KEY_BONE_ANIM_STATE_POSITION);
static_assert(offsetof(AnimState, speed) == Offsets::OFF_M2_KEY_BONE_ANIM_STATE_SPEED);
static_assert(offsetof(AnimState, lo) == Offsets::OFF_M2_KEY_BONE_ANIM_STATE_LO);
static_assert(offsetof(AnimState, hi) == Offsets::OFF_M2_KEY_BONE_ANIM_STATE_HI);
static_assert(offsetof(AnimState, latch) == Offsets::OFF_M2_KEY_BONE_ANIM_STATE_LATCH);
// Waits for an unloaded instance.
inline AnimState GetAnimState(void *model, int keyBone) {
using Fn = void(__thiscall *)(void *, int, AnimState *);
AnimState state;
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_GET_KEY_BONE_ANIM_STATE)(model, keyBone, &state);
return state;
}
// Where the key bone's animation stands, in ms into its sequence. Waits for
// an unloaded instance.
inline int GetAnimPosition(void *model, int keyBone) {
return static_cast<int>(GetAnimState(model, keyBone).position);
}
// Moves the key bone's animation to `ms` into its sequence, keeping its speed.
inline void SetAnimPosition(void *model, int keyBone, int ms) {
using Fn = void(__thiscall *)(void *, int, int);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_SET_KEY_BONE_ANIM_TIME)(model, keyBone, ms);
}
inline void SetAnimDoneCallback(void *model, const void *callback, uint32_t userLow,
uint32_t userHigh) {
using Fn = void(__thiscall *)(void *, const void *, uint32_t, uint32_t);
@@ -69,6 +122,220 @@ inline void SetVisible(void *model, bool visible) {
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_SET_VISIBLE)(model, visible ? 1 : 0);
}
inline void SetAlpha(void *model, float alpha) {
using Fn = void(__thiscall *)(void *, float);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_SET_ALPHA)(model, alpha);
}
// The tint the model is drawn with, {r, g, b}; white is none.
inline void SetDiffuse(void *model, const float (&rgb)[3]) {
using Fn = void(__thiscall *)(void *, const float *);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_SET_DIFFUSE_COLOR)(model, rgb);
}
// CM2Model::FindChild: the model hung at attachment `attachId`, or null.
inline void *FindChild(void *model, int attachId) {
using Fn = void *(__thiscall *)(void *, int);
return reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_FIND_CHILD)(model, attachId);
}
// The model this one is attached to, or null.
inline void *Parent(const void *model) {
return Game::Read<void *>(model, Offsets::OFF_M2MODEL_PARENT);
}
// CM2Model::Attach: `child` on the parent's attachment `id`. The child leaves
// the animate list (the parent animates it) and the parent takes a reference
// on it.
inline void Attach(void *child, void *parent, uint32_t id) {
using Fn = void(__thiscall *)(void *, void *, uint32_t);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_ATTACH)(child, parent, id);
}
// Whether `child` may hang on `parent`: two models, and the parent is not the
// child or hung (at any depth) from it. Animate recurses through the parent
// chain (EnsureAnimated, 0x00714000), so a cycle never ends.
inline bool CanAttach(const void *child, const void *parent) {
if (child == nullptr || parent == nullptr)
return false;
for (const void *m = parent; m != nullptr; m = Parent(m))
if (m == child)
return false;
return true;
}
// CM2Model::HasAttachment. Waits for an unloaded model file.
inline bool HasAttachment(void *model, uint32_t id) {
using Fn = int(__thiscall *)(void *, uint32_t);
return reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_HAS_ATTACHMENT)(model, id) != 0;
}
// CM2Model::GetAttachmentPosition: the attachment's point in the space the
// root model's base matrix places it in (a scene's actor coordinates). The
// model must have the attachment (HasAttachment).
inline void GetAttachmentPosition(void *model, uint32_t id, float (&out)[3]) {
using Fn = float *(__thiscall *)(void *, float *, uint32_t);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_GET_ATTACHMENT_POSITION)(model, out, id);
}
// CM2Model::GetAttachmentOffset: the attachment's position in model space,
// not posed by any animation. The model must have the attachment
// (HasAttachment). Waits for an unloaded model file.
inline void GetAttachmentOffset(void *model, uint32_t id, float (&out)[3]) {
using Fn = void(__thiscall *)(void *, float *, uint32_t);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_GET_ATTACHMENT_OFFSET)(model, out, id);
}
// The animation-event callback (Offsets::FUN_M2MODEL_SET_EVENT_CALLBACK).
inline void SetEventCallback(void *model, const void *callback, uint32_t userLow,
uint32_t userHigh) {
using Fn = void(__thiscall *)(void *, const void *, uint32_t, uint32_t);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_SET_EVENT_CALLBACK)(model, callback, userLow,
userHigh);
}
// CM2Model::HasEvent. Waits for an unloaded model file.
inline bool HasEvent(void *model, uint32_t tag) {
using Fn = int(__thiscall *)(void *, uint32_t);
return reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_HAS_EVENT)(model, tag) != 0;
}
// CM2Model::GetEventPosition: the event's point in the scene, as
// GetAttachmentPosition's. Waits for an unloaded model file.
inline void GetEventPosition(void *model, uint32_t tag, float (&out)[3]) {
using Fn = float *(__thiscall *)(void *, float *, uint32_t);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_GET_EVENT_POSITION)(model, out, tag);
}
inline bool HasLiveParticles(void *model) {
using Fn = int(__thiscall *)(void *);
return reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_HAS_LIVE_PARTICLES)(model) != 0;
}
// CM2Model::Detach, when the model is attached: off its parent, which drops
// its reference on it. Detach itself drops that reference unconditionally,
// so the world's Dismount (0x00607CE0) tests the parent first, as this does.
inline void Detach(void *model) {
using Fn = void(__fastcall *)(void *);
if (Parent(model) != nullptr)
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_DETACH)(model);
}
// The model's overall bounds {min xyz, max xyz}. Waits for an unloaded file.
inline void GetBoundingBox(void *model, float (&box)[6]) {
using Fn = void(__thiscall *)(void *, float *);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_GET_BOUNDING_BOX)(model, box);
}
// The model's collision box {min xyz, max xyz}. Waits for an unloaded file.
inline void GetCollisionBox(void *model, float (&box)[6]) {
using Fn = void(__thiscall *)(void *, float *);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_GET_COLLISION_BOX)(model, box);
}
// The model's bounding sphere {centre xyz, radius}. Waits for an unloaded file.
inline void GetBoundingSphere(void *model, float (&sphere)[4]) {
using Fn = void(__thiscall *)(void *, float *);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_GET_BOUNDING_SPHERE)(model, sphere);
}
// CM2Model::HasAnimation: whether the model has a sequence for the
// animation. Waits for an unloaded file.
inline bool HasAnimation(void *model, uint32_t animation) {
using Fn = bool(__thiscall *)(void *, uint32_t);
return reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_HAS_ANIMATION)(model, animation);
}
// An animation's sequence record (FUN_M2MODEL_GET_SEQUENCE_INFO), all zero
// when the model has no sequence for it.
struct SequenceInfo {
uint32_t flags;
uint32_t length; // ms
float speed; // the move speed times the model's scale
float bounds[6]; // {min xyz, max xyz}
uint8_t rest[Offsets::M2_SEQUENCE_INFO_SIZE - 0x24];
};
static_assert(sizeof(SequenceInfo) == Offsets::M2_SEQUENCE_INFO_SIZE);
static_assert(offsetof(SequenceInfo, length) == Offsets::OFF_M2_SEQUENCE_INFO_LENGTH);
static_assert(offsetof(SequenceInfo, speed) == Offsets::OFF_M2_SEQUENCE_INFO_SPEED);
static_assert(offsetof(SequenceInfo, bounds) == Offsets::OFF_M2_SEQUENCE_INFO_BOUNDS);
// Waits for an unloaded file.
inline SequenceInfo GetSequenceInfo(void *model, uint32_t animation, uint32_t variation) {
using Fn = void(__thiscall *)(void *, uint32_t, uint32_t, SequenceInfo *);
SequenceInfo info{};
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_GET_SEQUENCE_INFO)(model, animation, variation, &info);
return info;
}
// The bounds of the animation's sequence, all zero when the model has none.
// Waits for an unloaded file.
inline void GetSequenceBounds(void *model, uint32_t animation, uint32_t variation,
float (&box)[6]) {
const SequenceInfo info = GetSequenceInfo(model, animation, variation);
for (int i = 0; i < 6; ++i)
box[i] = info.bounds[i];
}
// The world pick's steps (Offsets::FUN_M2SCENE_RAY_HIT): ray hits on, the
// candidates on the scene's hit list, then the ray.
inline void EnableRayHits(void *scene) {
using Fn = void(__fastcall *)(void *);
reinterpret_cast<Fn>(Offsets::FUN_M2SCENE_ENABLE_RAY_HITS)(scene);
}
// Puts the model on its scene's hit list (skinned triangles), with `value`
// for RayHit to return.
inline void SetHitTest(void *model, uintptr_t value) {
using Fn = void(__thiscall *)(void *, int, int, uintptr_t);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_SET_HIT_TEST_MODE)(model, Offsets::M2_HIT_TEST_SKINNED,
0, value);
}
// The hit value of the nearest triangle on p0..p1 (view space), else 0.
// Empties the hit list.
inline uintptr_t RayHit(void *scene, const float (&p0)[3], const float (&p1)[3]) {
using Fn = uintptr_t(__thiscall *)(void *, const float *, const float *, float *, int);
float fraction = 1.0f;
return reinterpret_cast<Fn>(Offsets::FUN_M2SCENE_RAY_HIT)(scene, p0, p1, &fraction, 1);
}
// The base matrix (row vectors: rows 0..2 the scaled axes, row 3 the
// translation).
inline void GetBaseTransform(void *model, float (&m)[16]) {
using Fn = void(__thiscall *)(void *, float *);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_GET_BASE_TRANSFORM)(model, m);
}
// CM2Model::SetBaseTransform: the base matrix as given.
inline void SetBaseTransform(void *model, const float (&m)[16]) {
using Fn = void(__thiscall *)(void *, const float *);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_SET_BASE_TRANSFORM)(model, m);
}
// CM2Model::SetBaseTransformTRS: identity, translated to `position`, rotated
// by `angle` about `axis`, then each row scaled.
inline void SetBaseTRS(void *model, const float (&position)[3], float angle, const float (&axis)[3],
const float (&scale)[3]) {
using Fn = void(__thiscall *)(void *, const float *, float, const float *, const float *);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_SET_BASE_TRS)(model, position, angle, axis, scale);
}
// The base matrix in place, for the engine's matrix calls after SetBaseTRS.
inline float *BaseMatrix(void *model) { return Game::Ptr<float>(model, Offsets::OFF_M2MODEL_BASE_MATRIX); }
inline float GetAlpha(void *model) {
using Fn = float(__thiscall *)(void *);
return reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_GET_ALPHA)(model);
}
inline void *FirstChild(const void *model) {
return Game::Read<void *>(model, Offsets::OFF_MODEL_INSTANCE_CHILD_HEAD);
}
inline void *NextSibling(const void *model) {
return Game::Read<void *>(model, Offsets::OFF_MODEL_INSTANCE_CHILD_NEXT);
}
inline void SetAnimated(void *model, bool animated) {
using Fn = void(__thiscall *)(void *, int);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_SET_ANIMATED)(model, animated ? 1 : 0);
@@ -79,11 +346,24 @@ inline void AnimateScene(void *scene, const float *cameraPos) {
reinterpret_cast<Fn>(Offsets::FUN_M2SCENE_ANIMATE)(scene, cameraPos);
}
// M2SceneDraw: pass 0 the opaque models, 1 the translucent ones.
inline void DrawScene(void *scene, uint32_t pass) {
using Fn = void(__thiscall *)(void *, uint32_t);
reinterpret_cast<Fn>(Offsets::FUN_M2SCENE_DRAW)(scene, pass);
}
inline void AttachItemVisuals(void *model, uint32_t visualID) {
using Fn = void(__fastcall *)(void *, uint32_t);
reinterpret_cast<Fn>(Offsets::FUN_ATTACH_ITEM_VISUALS)(model, visualID);
}
// CM2Model::SetDrawCallback: a callback the model's draw runs as one of its
// elements, in list 0 when `listZero`; a null callback removes it.
inline void SetDrawCallback(void *model, const void *callback, void *userData, bool listZero) {
using Fn = void(__thiscall *)(void *, const void *, void *, int);
reinterpret_cast<Fn>(Offsets::FUN_M2MODEL_SET_DRAW_CALLBACK)(model, callback, userData, listZero ? 1 : 0);
}
inline void SetRenderCallback(void *model, const void *callback, void *userData) {
using Fn = void(__thiscall *)(void *, const void *, void *);
reinterpret_cast<Fn>(Offsets::FUN_MODEL_INSTANCE_SET_RENDER_CB)(model, callback, userData);
@@ -102,6 +382,44 @@ inline uint16_t LookupIndex(void *model, uintptr_t tableOffset, uint32_t id) {
return reinterpret_cast<Fn>(Offsets::FUN_M2_LOOKUP_INDEX)(id, Data(model) + tableOffset);
}
// ---- a model light (M2LIGHT_SIZE bytes) -----------------------------------------
inline void InitLight(void *light) {
reinterpret_cast<void(__fastcall *)(void *)>(Offsets::FUN_M2LIGHT_INITIALIZE)(light);
}
// 0 directional, 1 point.
inline void SetLightType(void *light, int type) {
reinterpret_cast<void(__thiscall *)(void *, int)>(Offsets::FUN_M2LIGHT_SET_TYPE)(light, type);
}
inline void SetLightEnabled(void *light, bool enabled) {
reinterpret_cast<void(__thiscall *)(void *, int)>(Offsets::FUN_M2LIGHT_SET_ENABLED)(light, enabled ? 1 : 0);
}
inline void SetLightDirection(void *light, const float *v) {
reinterpret_cast<void(__thiscall *)(void *, const float *)>(Offsets::FUN_M2LIGHT_SET_DIRECTION)(light, v);
}
inline void SetLightPosition(void *light, const float *v) {
reinterpret_cast<void(__thiscall *)(void *, const float *)>(Offsets::FUN_M2LIGHT_SET_POSITION)(light, v);
}
// ---- animation classes (Offsets::FUN_IS_*_ANIMATION) -----------------------------
// The unit's tests of an AnimationData ID. The death test is __stdcall, the
// others __fastcall.
inline bool IsDeathAnimation(int anim) {
return reinterpret_cast<int(__stdcall *)(int)>(Offsets::FUN_IS_DEATH_ANIMATION)(anim) != 0;
}
namespace Detail {
inline bool AnimTest(uintptr_t fn, int anim) { return reinterpret_cast<int(__fastcall *)(int)>(fn)(anim) != 0; }
} // namespace Detail
inline bool IsBowAnimation(int anim) { return Detail::AnimTest(Offsets::FUN_IS_BOW_ANIMATION, anim); }
inline bool IsRifleAnimation(int anim) { return Detail::AnimTest(Offsets::FUN_IS_RIFLE_ANIMATION, anim); }
inline bool IsThrownAnimation(int anim) { return Detail::AnimTest(Offsets::FUN_IS_THROWN_ANIMATION, anim); }
inline bool IsEmoteAnimation(int anim) { return Detail::AnimTest(Offsets::FUN_IS_EMOTE_ANIMATION, anim); }
inline bool IsSpellCastAnimation(int anim) { return Detail::AnimTest(Offsets::FUN_IS_SPELL_CAST_ANIMATION, anim); }
inline bool IsMoveAnimation(int anim) { return Detail::AnimTest(Offsets::FUN_IS_MOVE_ANIMATION, anim); }
} // namespace Model::M2
namespace Model::Pane {
+265
View File
@@ -0,0 +1,265 @@
// 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_ModelInfo.GetModelSceneInfoByID(modelSceneID)` -> modelSceneType,
// modelCameraIDs, modelActorsIDs, flags
// `C_ModelInfo.GetModelSceneCameraInfoByID(modelSceneCameraID)` -> UIModelSceneCameraInfo
// `C_ModelInfo.GetModelSceneActorInfoByID(modelActorID)` -> UIModelSceneActorInfo
// `C_ModelInfo.GetModelSceneActorDisplayInfoByID(modelActorDisplayID)` -> UIModelSceneActorDisplayInfo
//
// Retail's getters read its UiModelScene DB2s; these read the rows embedded
// in model/SceneData.h, shaped as retail returns them. An unknown ID returns
// nothing, as retail's do (MayReturnNothing). The vector3 fields are
// Vector3DMixin objects (CreateVector3D, Util/Vector3D.lua). From a retail
// probe (docs/notes/model-scene.md § Data): the actor flags split into the
// three useCenterForOrigin booleans, and a NormalizedScale of 0 is a nil
// normalizeScaleAggressiveness; a display ID of 0 is likewise nil. Retail's
// active-scene bookkeeping (Add/ClearActiveModelScene[Actor]) does nothing in
// public clients and is the embedded addon's (Compat.lua).
#include "Game.h"
#include "model/SceneData.h"
#include <cstdint>
namespace Model::ModelInfo {
namespace {
using namespace Model::SceneData;
// Enum.UIModelSceneActorFlag.
constexpr uint32_t kUseCenterForOriginX = 0x2;
constexpr uint32_t kUseCenterForOriginY = 0x4;
constexpr uint32_t kUseCenterForOriginZ = 0x8;
template <typename Row, size_t N>
const Row *Find(const Row (&rows)[N], uint32_t id) {
for (const Row &row : rows)
if (row.id == id)
return &row;
return nullptr;
}
bool IdArg(void *L, uint32_t &id) {
if (!Game::Lua::IsNumber(L, 1))
return false;
id = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
return true;
}
// `t[key] = CreateVector3D(v)` on the table at the top of the stack.
void SetFieldVector3D(void *L, const char *key, const Vec3 &v) {
Game::Lua::PushString(L, key);
Game::Lua::PushString(L, "CreateVector3D");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
Game::Lua::PushNumber(L, v.x);
Game::Lua::PushNumber(L, v.y);
Game::Lua::PushNumber(L, v.z);
Game::Lua::Call(L, 3, 1);
Game::Lua::SetTable(L, -3);
}
// An array of the IDs of every row of `rows` in `sceneID`, in ID order.
template <typename Row, size_t N>
void PushSceneIDs(void *L, const Row (&rows)[N], uint32_t sceneID) {
Game::Lua::NewTable(L);
int n = 0;
for (const Row &row : rows) {
if (row.sceneID != sceneID)
continue;
Game::Lua::PushNumber(L, row.id);
Game::Lua::RawSetI(L, -2, ++n);
}
}
int __fastcall Script_GetModelSceneInfoByID(void *L) {
uint32_t id = 0;
const SceneRow *scene = IdArg(L, id) ? Find(kScenes, id) : nullptr;
if (scene == nullptr)
return 0;
Game::Lua::PushNumber(L, scene->uiSystemType);
PushSceneIDs(L, kCameras, id);
PushSceneIDs(L, kActors, id);
Game::Lua::PushNumber(L, scene->flags);
return 4;
}
int __fastcall Script_GetModelSceneCameraInfoByID(void *L) {
uint32_t id = 0;
const CameraRow *camera = IdArg(L, id) ? Find(kCameras, id) : nullptr;
if (camera == nullptr)
return 0;
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "modelSceneCameraID", camera->id);
Game::Lua::SetFieldString(L, "scriptTag", camera->scriptTag);
Game::Lua::SetFieldString(L, "cameraType", "OrbitCamera");
SetFieldVector3D(L, "target", camera->target);
Game::Lua::SetFieldNumber(L, "yaw", camera->yaw);
Game::Lua::SetFieldNumber(L, "pitch", camera->pitch);
Game::Lua::SetFieldNumber(L, "roll", camera->roll);
Game::Lua::SetFieldNumber(L, "zoomDistance", camera->zoomDistance);
Game::Lua::SetFieldNumber(L, "minZoomDistance", camera->minZoomDistance);
Game::Lua::SetFieldNumber(L, "maxZoomDistance", camera->maxZoomDistance);
SetFieldVector3D(L, "zoomedTargetOffset", camera->zoomedTargetOffset);
Game::Lua::SetFieldNumber(L, "zoomedYawOffset", camera->zoomedYawOffset);
Game::Lua::SetFieldNumber(L, "zoomedPitchOffset", camera->zoomedPitchOffset);
Game::Lua::SetFieldNumber(L, "zoomedRollOffset", camera->zoomedRollOffset);
Game::Lua::SetFieldNumber(L, "flags", camera->flags);
return 1;
}
int __fastcall Script_GetModelSceneActorInfoByID(void *L) {
uint32_t id = 0;
const ActorRow *actor = IdArg(L, id) ? Find(kActors, id) : nullptr;
if (actor == nullptr)
return 0;
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "modelActorID", actor->id);
Game::Lua::SetFieldString(L, "scriptTag", actor->scriptTag);
SetFieldVector3D(L, "position", actor->position);
Game::Lua::SetFieldNumber(L, "yaw", actor->yaw);
Game::Lua::SetFieldNumber(L, "pitch", actor->pitch);
Game::Lua::SetFieldNumber(L, "roll", actor->roll);
if (actor->normalizedScale != 0.0f)
Game::Lua::SetFieldNumber(L, "normalizeScaleAggressiveness", actor->normalizedScale);
Game::Lua::SetFieldBool(L, "useCenterForOriginX", (actor->flags & kUseCenterForOriginX) != 0);
Game::Lua::SetFieldBool(L, "useCenterForOriginY", (actor->flags & kUseCenterForOriginY) != 0);
Game::Lua::SetFieldBool(L, "useCenterForOriginZ", (actor->flags & kUseCenterForOriginZ) != 0);
if (actor->displayID != 0)
Game::Lua::SetFieldNumber(L, "modelActorDisplayID", actor->displayID);
return 1;
}
int __fastcall Script_GetModelSceneActorDisplayInfoByID(void *L) {
uint32_t id = 0;
const ActorDisplayRow *display = IdArg(L, id) ? Find(kActorDisplays, id) : nullptr;
if (display == nullptr)
return 0;
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "animation", display->animation);
Game::Lua::SetFieldNumber(L, "animationVariation", display->animationVariation);
Game::Lua::SetFieldNumber(L, "animSpeed", display->animSpeed);
if (display->animKitID != 0)
Game::Lua::SetFieldNumber(L, "animationKitID", display->animKitID);
if (display->spellVisualKitID != 0)
Game::Lua::SetFieldNumber(L, "spellVisualKitID", display->spellVisualKitID);
Game::Lua::SetFieldNumber(L, "alpha", display->alpha);
Game::Lua::SetFieldNumber(L, "scale", display->scale);
return 1;
}
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kActorDisplayInfoFields[] = {
Game::Doc::Req("animation", "number"),
Game::Doc::Req("animationVariation", "number"),
Game::Doc::Req("animSpeed", "number"),
Game::Doc::Opt("animationKitID", "number"),
Game::Doc::Opt("spellVisualKitID", "number"),
Game::Doc::Req("alpha", "number"),
Game::Doc::Req("scale", "number"),
};
const Game::Doc::Structure kActorDisplayInfo{"UIModelSceneActorDisplayInfo", "ModelInfo",
kActorDisplayInfoFields};
const Game::Doc::Field kActorInfoFields[] = {
Game::Doc::Req("modelActorID", "number"),
Game::Doc::Req("scriptTag", "cstring"),
Game::Doc::Req("position", "vector3"),
Game::Doc::Req("yaw", "number"),
Game::Doc::Req("pitch", "number"),
Game::Doc::Req("roll", "number"),
Game::Doc::Opt("normalizeScaleAggressiveness", "number"),
Game::Doc::Req("useCenterForOriginX", "bool"),
Game::Doc::Req("useCenterForOriginY", "bool"),
Game::Doc::Req("useCenterForOriginZ", "bool"),
Game::Doc::Opt("modelActorDisplayID", "number"),
};
const Game::Doc::Structure kActorInfo{"UIModelSceneActorInfo", "ModelInfo", kActorInfoFields};
const Game::Doc::Field kCameraInfoFields[] = {
Game::Doc::Req("modelSceneCameraID", "number"),
Game::Doc::Req("scriptTag", "cstring"),
Game::Doc::Req("cameraType", "cstring"),
Game::Doc::Req("target", "vector3"),
Game::Doc::Req("yaw", "number"),
Game::Doc::Req("pitch", "number"),
Game::Doc::Req("roll", "number"),
Game::Doc::Req("zoomDistance", "number"),
Game::Doc::Req("minZoomDistance", "number"),
Game::Doc::Req("maxZoomDistance", "number"),
Game::Doc::Req("zoomedTargetOffset", "vector3"),
Game::Doc::Req("zoomedYawOffset", "number"),
Game::Doc::Req("zoomedPitchOffset", "number"),
Game::Doc::Req("zoomedRollOffset", "number"),
Game::Doc::Req("flags", "ModelSceneSetting"),
};
const Game::Doc::Structure kCameraInfo{"UIModelSceneCameraInfo", "ModelInfo", kCameraInfoFields};
const Game::Doc::Field kSceneIDArg[] = {Game::Doc::Req("modelSceneID", "number")};
const Game::Doc::Field kSceneInfoRets[] = {
Game::Doc::Req("modelSceneType", "ModelSceneType"),
Game::Doc::Req("modelCameraIDs", "table"),
Game::Doc::Req("modelActorsIDs", "table"),
Game::Doc::Req("flags", "number"),
};
const Game::Doc::Function kGetModelSceneInfoByID{
"Returns a model scene's type and the IDs of its cameras and actors.", kSceneIDArg,
kSceneInfoRets};
const Game::Doc::Field kCameraIDArg[] = {Game::Doc::Req("modelSceneCameraID", "number")};
const Game::Doc::Field kCameraInfoRet[] = {
Game::Doc::Req("modelSceneCameraInfo", "UIModelSceneCameraInfo")};
const Game::Doc::Function kGetModelSceneCameraInfoByID{"Returns a model scene camera's settings.",
kCameraIDArg, kCameraInfoRet};
const Game::Doc::Field kActorIDArg[] = {Game::Doc::Req("modelActorID", "number")};
const Game::Doc::Field kActorInfoRet[] = {Game::Doc::Req("actorInfo", "UIModelSceneActorInfo")};
const Game::Doc::Function kGetModelSceneActorInfoByID{
"Returns where a model scene actor stands and how it is scaled.", kActorIDArg,
kActorInfoRet};
const Game::Doc::Field kActorDisplayIDArg[] = {Game::Doc::Req("modelActorDisplayID", "number")};
const Game::Doc::Field kActorDisplayInfoRet[] = {
Game::Doc::Req("actorDisplayInfo", "UIModelSceneActorDisplayInfo")};
const Game::Doc::Function kGetModelSceneActorDisplayInfoByID{
"Returns the animation, alpha and scale a model scene actor starts with.",
kActorDisplayIDArg, kActorDisplayInfoRet};
// Enum.ModelSceneSetting: the camera rows' flags, which OrbitCameraMixin
// tests on every update (ShouldAlignLightToOrbitDelta).
const Game::Lua::EnumIntegerEntry kModelSceneSetting[] = {
{"AlignLightToOrbitDelta", 1},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum("Enum", "ModelSceneSetting", kModelSceneSetting, "UIModelInfo");
Game::Lua::RegisterTableFunction("C_ModelInfo", "GetModelSceneInfoByID",
&Script_GetModelSceneInfoByID, &kGetModelSceneInfoByID);
Game::Lua::RegisterTableFunction("C_ModelInfo", "GetModelSceneCameraInfoByID",
&Script_GetModelSceneCameraInfoByID,
&kGetModelSceneCameraInfoByID);
Game::Lua::RegisterTableFunction("C_ModelInfo", "GetModelSceneActorInfoByID",
&Script_GetModelSceneActorInfoByID,
&kGetModelSceneActorInfoByID);
Game::Lua::RegisterTableFunction("C_ModelInfo", "GetModelSceneActorDisplayInfoByID",
&Script_GetModelSceneActorDisplayInfoByID,
&kGetModelSceneActorDisplayInfoByID);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Model::ModelInfo
+488
View File
@@ -0,0 +1,488 @@
// 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 outfit calls of a character model copy: DressUpModel's own steps
// (SetModelInstance 0x00504350, ClearModel 0x00504490, 4.3.4's per-slot
// UndressSlot) and the unit's AttachHandItem step, on whatever holds the
// component and the lanes. benilla ui/dressup and charactermodel/equipment.
//
// The slot map is 4.3.4's UndressSlot map, from inventory slot to component
// slot.
//
// What a slot wears (Worn) is read from the unit's visible items, the item
// IDs the engine keeps per equipment slot for every player, and kept only
// while the model shows it: an armor slot while the component holds a record
// for it (so a hidden helm or cloak wears nothing), a hand while its lane
// holds an item. A weapon's enchant is the one behind the glow the engine
// shows, picked as GetItemDescVisual (0x0062EC70) picks it.
//
// TryOn is DressUpModel::TryOnItem's arms (0x00504540, benilla ui/dressup)
// for a holder that is not a pane, the ModelScene actor: the pane runs the
// engine's worker itself (model/DressUp.cpp). Two differences:
// - An evicted lane is zeroed, as Undress zeroes one. The worker leaves its
// record, which only makes a later try-on detach it again.
// - A tabard keeps no guild emblem. The worker's emblem step reads the
// pane's player.
#include "model/Outfit.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "model/DisplayInfo.h"
#include "model/ItemDisplay.h"
#include "model/M2.h"
#include "object/Resolve.h"
#include "unit/Identity.h"
#include <iterator>
namespace Model::Outfit {
namespace {
using DetachHeldItem_t = void(__fastcall *)(void *model, uint32_t bodySlot, uint32_t sheatheType,
bool shield);
using AttachItemModel_t = int(__fastcall *)(void *model, const void *record, uint32_t bodySlot,
uint32_t sheatheType, bool sheathed, bool shield,
bool ranged);
using GetHeldItemDisplay_t = const void *(__thiscall *)(void *unit, uint32_t hand);
using GetHeldItemLane_t = const HeldLane *(__thiscall *)(void *unit, uint32_t hand, int one);
using GetVisibleItem_t = const void *(__thiscall *)(void *player, uint32_t slot);
using GetDescVisual_t = int(__thiscall *)(const void *visibleItem);
using CanHoldBoth_t = bool(__fastcall *)(uint32_t mainType, uint32_t offType, bool dualWield);
constexpr uint32_t kBodySlots[2] = {kBodySlotMainHand, kBodySlotOffHand};
// The component slots ClearModel clears (0..11).
constexpr uint32_t kComponentSlots = 12;
// The inventory slot of each component slot 0..10 (4.3.4's UndressSlot map,
// inverted); slot 11 has no inventory slot.
constexpr int kInvSlotOfComponent[] = {1, 3, 4, 5, 6, 7, 8, 9, 10, 19, 15};
// The ItemDisplayInfo record the component shows in `slot`, or null.
const void *ComponentRecord(void *component, uint32_t slot) {
return Game::Read<const void *>(component, Offsets::OFF_CHARCOMP_ITEM_RECORDS + slot * 4);
}
template <typename Fn>
Fn VirtualOf(void *object, uintptr_t slot) {
return Game::Read<Fn>(Game::Read<const void *>(object, 0), slot);
}
// DetachHeldItemSlot on lane `i`'s item.
void DetachLane(void *model, int i, const HeldLane &lane) {
reinterpret_cast<DetachHeldItem_t>(Offsets::FUN_DETACH_HELD_ITEM_SLOT)(
model, kBodySlots[i], lane.sheatheType, lane.inventoryType == Offsets::INVTYPE_SHIELD);
}
// ClearModel's lane step: detach the held item and zero the lane.
void ClearLane(void *model, Lanes &lanes, int i) {
if (lanes[i].itemClass == 0)
return;
DetachLane(model, i, lanes[i]);
lanes[i] = HeldLane{};
}
// TryOnItem's hand for a held type: the named one, else a second one-hander
// in the off hand of a dual wielder, and each type's own hand.
int HandFor(uint32_t type, const Lanes &lanes, const HeldTryOn &held) {
if (held.bodySlot != 0)
return LaneIndex(held.bodySlot);
switch (type) {
case Offsets::INVTYPE_WEAPON:
return lanes[0].itemClass != 0 &&
(lanes[0].inventoryType == Offsets::INVTYPE_WEAPON ||
lanes[0].inventoryType == Offsets::INVTYPE_WEAPONMAINHAND) &&
held.dualWield
? 1
: 0;
case Offsets::INVTYPE_SHIELD:
case Offsets::INVTYPE_RANGED:
case Offsets::INVTYPE_WEAPONOFFHAND:
case Offsets::INVTYPE_HOLDABLE:
return 1;
default: // two-hand, main-hand, thrown and right-hand ranged
return 0;
}
}
// The unit's AttachHandItem step on lane `i`: detach it, then hang it in the
// hand or the sheath, or leave it off.
void HangLane(void *model, int i, const HeldLane &lane, const Weapons &weapons, bool sheathed,
bool hide) {
if (lane.itemClass == 0 || weapons.display[i] == nullptr)
return;
DetachLane(model, i, lane);
if (hide)
return;
const int attachId = reinterpret_cast<AttachItemModel_t>(Offsets::FUN_ATTACH_ITEM_MODEL)(
model, weapons.display[i], kBodySlots[i], lane.sheatheType, sheathed,
lane.inventoryType == Offsets::INVTYPE_SHIELD,
lane.inventoryType == Offsets::INVTYPE_RANGEDRIGHT);
HangVisual(model, attachId, weapons.visual[i]);
}
} // namespace
int ComponentSlotOf(int invSlot) {
for (int slot = 0; slot < static_cast<int>(std::size(kInvSlotOfComponent)); ++slot) {
if (kInvSlotOfComponent[slot] == invSlot)
return slot;
}
return -1;
}
int InvSlotOfComponent(uint32_t componentSlot) {
return componentSlot < std::size(kInvSlotOfComponent) ? kInvSlotOfComponent[componentSlot] : 0;
}
bool SlotAllowed(int invSlot, bool armorReady) {
if (invSlot == Offsets::INVSLOT_MAINHAND || invSlot == Offsets::INVSLOT_OFFHAND)
return true;
return ComponentSlotOf(invSlot) >= 0 && armorReady;
}
void ReadLanes(void *unit, Lanes &lanes) {
const auto get = VirtualOf<GetHeldItemLane_t>(unit, Offsets::VTBL_UNIT_GET_HELD_ITEM_LANE);
for (uint32_t hand = 0; hand < 2; ++hand) {
const HeldLane *lane = get(unit, hand, 1);
lanes[hand] = lane != nullptr ? *lane : HeldLane{};
}
}
void ReadWeapons(uint64_t guid, Weapons &weapons) {
weapons = Weapons{};
if (void *unit = Object::ByGuid(Offsets::TYPEMASK_UNIT, guid)) {
const auto get =
VirtualOf<GetHeldItemDisplay_t>(unit, Offsets::VTBL_UNIT_GET_HELD_ITEM_DISPLAY);
weapons.display[0] = get(unit, 0);
weapons.display[1] = get(unit, 1);
}
// AttachHandEnchant's glow (0x005EED00), for players only: the visible
// item in the lane's slot (0xF / 0x10, the same numbers as the body
// slots), and its visual unless the display hangs its own (-1).
void *player = Object::ByGuid(Offsets::TYPEMASK_PLAYER, guid);
if (player == nullptr)
return;
for (int i = 0; i < 2; ++i) {
const void *item =
reinterpret_cast<GetVisibleItem_t>(Offsets::FUN_UNIT_GET_VISIBLE_ITEM)(player,
kBodySlots[i]);
if (item == nullptr || Game::Read<uint32_t>(item, Offsets::OFF_VISIBLE_ITEM_ITEM_ID) == 0)
continue;
const int visual =
reinterpret_cast<GetDescVisual_t>(Offsets::FUN_VISIBLE_ITEM_GET_DESC_VISUAL)(item);
weapons.visual[i] = visual > 0 ? static_cast<uint32_t>(visual) : 0;
}
}
void *CloneComponent(void *model, void *unit) {
void *source = Game::Read<void *>(unit, Offsets::OFF_CGUNIT_CHARACTER_COMPONENT);
if (source == nullptr)
return nullptr;
void *component = DisplayInfo::NewDresser();
if (component == nullptr)
return nullptr;
M2::AddRef(model); // the component's reference
DisplayInfo::CloneDresser(component, model, source);
return component;
}
void HangVisual(void *model, int attachId, uint32_t visual) {
if (visual == 0 || attachId < 0)
return;
if (void *item = M2::FindChild(model, attachId))
M2::AttachItemVisuals(item, visual);
}
void HangWeapons(void *model, const Lanes &lanes, const Weapons &weapons, bool sheathed,
bool hide) {
if (model == nullptr)
return;
for (int i = 0; i < 2; ++i)
HangLane(model, i, lanes[i], weapons, sheathed, hide);
}
void SetSheatheType(void *model, Lanes &lanes, int i, const Weapons &weapons,
uint8_t sheatheType, bool sheathed, bool hide) {
if (model == nullptr || lanes[i].itemClass == 0)
return;
DetachLane(model, i, lanes[i]);
lanes[i].sheatheType = sheatheType;
HangLane(model, i, lanes[i], weapons, sheathed, hide);
}
uint8_t SheatheTypeFor(uint8_t own, uint32_t category) {
switch (category) {
case kSheatheBack:
if (own == Offsets::ITEM_SHEATH_HIP)
return static_cast<uint8_t>(Offsets::ITEM_SHEATH_BACK);
return own;
case kSheatheSide:
if (own == Offsets::ITEM_SHEATH_BACK || own == Offsets::ITEM_SHEATH_LARGE_BACK)
return static_cast<uint8_t>(Offsets::ITEM_SHEATH_HIP);
return own;
case kSheatheHide:
return static_cast<uint8_t>(Offsets::ITEM_SHEATH_NONE);
default:
return own;
}
}
uint32_t EnchantVisual(const void *display, uint32_t enchantID) {
if (display == nullptr ||
Model::ItemDisplay::OwnVisual(static_cast<const uint8_t *>(display)) != 0)
return 0;
const uint8_t *enchant = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
return enchant != nullptr
? Game::Read<uint32_t>(enchant, Offsets::OFF_SPELLITEMENCHANT_ITEM_VISUAL)
: 0;
}
Arm ArmOf(uint32_t inventoryType, uint32_t *componentSlot) {
uint32_t slot = 0;
switch (inventoryType) {
case Offsets::INVTYPE_HEAD: slot = 0; break;
case Offsets::INVTYPE_SHOULDER: slot = 1; break;
case Offsets::INVTYPE_BODY: slot = 2; break;
case Offsets::INVTYPE_CHEST:
case Offsets::INVTYPE_ROBE: slot = 3; break;
case Offsets::INVTYPE_WAIST: slot = 4; break;
case Offsets::INVTYPE_LEGS: slot = 5; break;
case Offsets::INVTYPE_FEET: slot = 6; break;
case Offsets::INVTYPE_WRIST: slot = 7; break;
case Offsets::INVTYPE_HAND: slot = 8; break;
case Offsets::INVTYPE_TABARD: slot = 9; break;
case Offsets::INVTYPE_CLOAK: slot = 10; break;
case Offsets::INVTYPE_WEAPON:
case Offsets::INVTYPE_SHIELD:
case Offsets::INVTYPE_RANGED:
case Offsets::INVTYPE_2HWEAPON:
case Offsets::INVTYPE_WEAPONMAINHAND:
case Offsets::INVTYPE_WEAPONOFFHAND:
case Offsets::INVTYPE_HOLDABLE:
case Offsets::INVTYPE_THROWN:
case Offsets::INVTYPE_RANGEDRIGHT:
return Arm::Held;
case Offsets::INVTYPE_NECK:
case Offsets::INVTYPE_FINGER:
case Offsets::INVTYPE_TRINKET:
case Offsets::INVTYPE_RELIC:
return Arm::Nothing;
default:
return Arm::NotEquippable;
}
if (componentSlot != nullptr)
*componentSlot = slot;
return Arm::Armor;
}
bool CanDualWield(uint64_t guid) {
return guid != 0 && guid == Unit::Identity::PlayerGuid() &&
reinterpret_cast<int(__cdecl *)()>(Offsets::FUN_PLAYER_CAN_DUAL_WIELD)() != 0;
}
int TryOn(void *model, void *component, bool armorReady, Lanes &lanes, Weapons &weapons,
const uint8_t *record, const HeldTryOn &held) {
const auto displayID = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
if (model == nullptr || displayID == 0)
return 0;
const auto type = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
uint32_t componentSlot = 0;
switch (ArmOf(type, &componentSlot)) {
case Arm::Armor:
if (component == nullptr || !armorReady)
return 0;
DisplayInfo::UnequipDresserSlot(component, componentSlot);
DisplayInfo::SetDresserItem(component, componentSlot, displayID);
return kInvSlotOfComponent[componentSlot];
case Arm::Held:
break;
default:
return 0;
}
const int lane = HandFor(type, lanes, held);
const int other = 1 - lane;
if (lanes[other].itemClass != 0) {
const uint32_t mainType = lane == 0 ? type : lanes[0].inventoryType;
const uint32_t offType = lane == 0 ? lanes[1].inventoryType : type;
if (!reinterpret_cast<CanHoldBoth_t>(Offsets::FUN_DRESSUPMODEL_CAN_HOLD_BOTH)(
mainType, offType, held.dualWield)) {
ClearLane(model, lanes, other);
weapons.display[other] = nullptr;
weapons.visual[other] = 0;
}
}
ClearLane(model, lanes, lane);
HeldLane &target = lanes[lane];
target = LaneOf(record);
const uint8_t *display = DBC::Record(Offsets::VAR_ITEMDISPLAYINFO_RECORDS,
Offsets::VAR_ITEMDISPLAYINFO_COUNT, displayID);
weapons.display[lane] = display;
weapons.visual[lane] = EnchantVisual(display, held.enchant);
const int invSlot = lane == 0 ? Offsets::INVSLOT_MAINHAND : Offsets::INVSLOT_OFFHAND;
if (display == nullptr || held.hide)
return invSlot;
const int attachId = reinterpret_cast<AttachItemModel_t>(Offsets::FUN_ATTACH_ITEM_MODEL)(
model, display, kBodySlots[lane], target.sheatheType, held.sheathed,
type == Offsets::INVTYPE_SHIELD, type == Offsets::INVTYPE_RANGEDRIGHT);
HangVisual(model, attachId, weapons.visual[lane]);
return invSlot;
}
void UndressSlot(void *model, void *component, Lanes &lanes, int invSlot) {
if (model == nullptr || component == nullptr)
return;
if (invSlot == Offsets::INVSLOT_MAINHAND) {
ClearLane(model, lanes, 0);
} else if (invSlot == Offsets::INVSLOT_OFFHAND) {
ClearLane(model, lanes, 1);
} else {
const int compSlot = ComponentSlotOf(invSlot);
if (compSlot >= 0)
DisplayInfo::UnequipDresserSlot(component, static_cast<uint32_t>(compSlot));
}
}
void DressSlot(void *model, void *component, bool armorReady, Lanes &lanes, Weapons &weapons,
void *unit, uint64_t guid, int invSlot, bool sheathed, bool hide) {
if (model == nullptr || unit == nullptr)
return;
if (invSlot == Offsets::INVSLOT_MAINHAND || invSlot == Offsets::INVSLOT_OFFHAND) {
const int i = invSlot == Offsets::INVSLOT_OFFHAND ? 1 : 0;
Lanes worn;
Weapons wornWeapons;
ReadLanes(unit, worn);
ReadWeapons(guid, wornWeapons);
ClearLane(model, lanes, i);
lanes[i] = worn[i];
weapons.display[i] = wornWeapons.display[i];
weapons.visual[i] = wornWeapons.visual[i];
HangLane(model, i, lanes[i], weapons, sheathed, hide);
return;
}
const int compSlot = ComponentSlotOf(invSlot);
void *source = Game::Read<void *>(unit, Offsets::OFF_CGUNIT_CHARACTER_COMPONENT);
if (compSlot < 0 || component == nullptr || source == nullptr || !armorReady)
return;
const auto slot = static_cast<uint32_t>(compSlot);
const void *record = ComponentRecord(source, slot);
DisplayInfo::UnequipDresserSlot(component, slot);
if (record != nullptr)
DisplayInfo::SetDresserItem(component, slot, Game::Read<uint32_t>(record, 0));
}
void ReadWorn(void *unit, uint64_t guid, const Weapons &weapons, Worn &worn) {
for (Appearance &slot : worn)
slot = Appearance{};
// Only a player has visible items: a creature's table is not initialized
// (FUN_UNIT_GET_VISIBLE_ITEM's hazard).
void *player = Object::ByGuid(Offsets::TYPEMASK_PLAYER, guid);
if (unit == nullptr || player == nullptr)
return;
const auto visibleItem = reinterpret_cast<GetVisibleItem_t>(Offsets::FUN_UNIT_GET_VISIBLE_ITEM);
const auto itemOf = [&](int invSlot) -> const uint8_t * {
const void *item = visibleItem(player, static_cast<uint32_t>(invSlot - 1));
return static_cast<const uint8_t *>(item);
};
void *component = Game::Read<void *>(unit, Offsets::OFF_CGUNIT_CHARACTER_COMPONENT);
if (component != nullptr) {
for (uint32_t slot = 0; slot < std::size(kInvSlotOfComponent); ++slot) {
const int invSlot = kInvSlotOfComponent[slot];
const uint8_t *item = itemOf(invSlot);
if (item != nullptr && ComponentRecord(component, slot) != nullptr)
worn[invSlot - 1].item = Game::Read<uint32_t>(item, Offsets::OFF_VISIBLE_ITEM_ITEM_ID);
}
}
for (int i = 0; i < 2; ++i) {
const int invSlot = i == 0 ? Offsets::INVSLOT_MAINHAND : Offsets::INVSLOT_OFFHAND;
const uint8_t *item = itemOf(invSlot);
if (item == nullptr || weapons.display[i] == nullptr)
continue;
Appearance &held = worn[invSlot - 1];
held.item = Game::Read<uint32_t>(item, Offsets::OFF_VISIBLE_ITEM_ITEM_ID);
if (weapons.visual[i] == 0)
continue;
// GetItemDescVisual's walk: the first enchant with an ItemVisual.
for (uint32_t e = 0; e < Offsets::VISIBLE_ITEM_ENCHANT_COUNT; ++e) {
const auto id = Game::Read<uint32_t>(item, Offsets::OFF_VISIBLE_ITEM_ENCHANTS + e * 4);
const uint8_t *enchant = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
Offsets::VAR_SPELLITEMENCHANT_COUNT, id);
if (enchant != nullptr &&
Game::Read<uint32_t>(enchant, Offsets::OFF_SPELLITEMENCHANT_ITEM_VISUAL) != 0) {
held.enchant = id;
break;
}
}
}
}
void TrimWorn(void *component, const Lanes &lanes, Worn &worn) {
for (uint32_t slot = 0; slot < std::size(kInvSlotOfComponent); ++slot) {
if (component == nullptr || ComponentRecord(component, slot) == nullptr)
worn[kInvSlotOfComponent[slot] - 1] = Appearance{};
}
if (lanes[0].itemClass == 0)
worn[Offsets::INVSLOT_MAINHAND - 1] = Appearance{};
if (lanes[1].itemClass == 0)
worn[Offsets::INVSLOT_OFFHAND - 1] = Appearance{};
}
HeldLane LaneOf(const uint8_t *record) {
HeldLane lane{};
lane.itemClass = static_cast<uint8_t>(Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_CLASS));
lane.subclass = static_cast<uint8_t>(Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SUBCLASS));
lane.inventoryType = static_cast<uint8_t>(Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE));
lane.sheatheType = static_cast<uint8_t>(Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SHEATH));
return lane;
}
void ReadRanged(void *unit, uint64_t guid, HeldLane &lane, const void *&display, Appearance &worn) {
lane = HeldLane{};
display = nullptr;
worn = Appearance{};
if (unit == nullptr)
return;
const HeldLane *held = VirtualOf<GetHeldItemLane_t>(unit, Offsets::VTBL_UNIT_GET_HELD_ITEM_LANE)(
unit, Offsets::UNIT_HAND_RANGED, 0);
const void *row = VirtualOf<GetHeldItemDisplay_t>(unit, Offsets::VTBL_UNIT_GET_HELD_ITEM_DISPLAY)(
unit, Offsets::UNIT_HAND_RANGED);
if (held == nullptr || held->itemClass == 0 || row == nullptr)
return;
lane = *held;
display = row;
// Only a player has visible items (ReadWorn's hazard).
void *player = Object::ByGuid(Offsets::TYPEMASK_PLAYER, guid);
if (player == nullptr)
return;
if (const void *item = reinterpret_cast<GetVisibleItem_t>(Offsets::FUN_UNIT_GET_VISIBLE_ITEM)(
player, static_cast<uint32_t>(Offsets::INVSLOT_RANGED - 1)))
worn.item = Game::Read<uint32_t>(item, Offsets::OFF_VISIBLE_ITEM_ITEM_ID);
}
void Undress(void *model, void *component, Lanes &lanes, bool weapons) {
if (model == nullptr)
return;
if (component != nullptr) {
for (uint32_t slot = 0; slot < kComponentSlots; ++slot)
DisplayInfo::UnequipDresserSlot(component, slot);
}
if (weapons) {
ClearLane(model, lanes, 0);
ClearLane(model, lanes, 1);
}
}
} // namespace Model::Outfit
+191
View File
@@ -0,0 +1,191 @@
// 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>
// What a copy of a unit's character model wears, apart from its own model:
// the character component that composites its armor, and the two held-item
// lanes. 1.12's DressUpModel keeps these in the pane (benilla ui/dressup);
// the ModelScene actor keeps its own. Both change them with the calls here.
namespace Model::Outfit {
// Body slots of the two held lanes.
constexpr uint32_t kBodySlotMainHand = 0xF;
constexpr uint32_t kBodySlotOffHand = 0x10;
// A held lane, as the unit's VTBL_UNIT_GET_HELD_ITEM_LANE returns it and the
// pane stores it (OFF_DRESSUPMODEL_*_HAND_LANE).
struct HeldLane {
uint8_t itemClass; // 0 = empty
uint8_t subclass; // the item subclass (UNIT_VIRTUAL_ITEM_INFO byte 1)
uint8_t unknown2;
uint8_t inventoryType;
uint8_t sheatheType;
uint8_t unknown5[3];
};
static_assert(sizeof(HeldLane) == 8, "ClearModel zeroes 8 bytes per lane");
using Lanes = HeldLane[2]; // main hand, off hand
// Retail's Enum.TransmogOutfitSlotOptionSheatheCategory: where a held item
// hangs while sheathed.
enum SheatheCategory : uint32_t {
kSheatheDefault = 0, // the item's own sheath point
kSheatheBack = 1,
kSheatheSide = 2,
kSheatheHide = 3, // no sheath point: nothing shows while sheathed
};
// The sheathe type an item whose own type is `own` (OFF_ITEMSTATS_SHEATH)
// hangs with in `category`: Back moves a hip weapon to the back pair, Side
// moves a back weapon to the hip, Hide leaves no sheath point. Every other
// item keeps its own (a shield, an item with no sheath point).
uint8_t SheatheTypeFor(uint8_t own, uint32_t category);
// The row and glow each lane's item is hung with. The lanes keep no display
// row, so whoever re-hangs a weapon keeps these beside them.
struct Weapons {
const void *display[2] = {}; // ItemDisplayInfo row on each lane, or null
uint32_t visual[2] = {}; // enchant ItemVisuals row on each lane, or 0
};
// The lane (0 or 1) of a held body slot.
inline int LaneIndex(uint32_t bodySlot) { return bodySlot == kBodySlotOffHand ? 1 : 0; }
// DressUpModel SetModelInstance's lane copy (0x00504350): the unit's two
// held-item lanes, zero where a hand is empty.
void ReadLanes(void *unit, Lanes &lanes);
// The rows the unit `guid` hangs its held items with (AttachHandItem's
// source), and, for a player, each one's enchant glow (AttachHandEnchant).
void ReadWeapons(uint64_t guid, Weapons &weapons);
// A new character component for `model`, cloned from the unit's, as
// DressUpModel SetModelInstance clones it; null when the unit has none.
// It is pumped and released like a dresser (Model::DisplayInfo::PumpDresser,
// DestroyDresser): both are the engine's character component.
void *CloneComponent(void *model, void *unit);
// AttachEnchantVisual's step (0x005EED50): the glow on the item hung at
// `attachId`.
void HangVisual(void *model, int attachId, uint32_t visual);
// The unit's AttachHandItem step on each occupied lane: detach it, then hang
// it in the hand or the sheath, or leave it off.
void HangWeapons(void *model, const Lanes &lanes, const Weapons &weapons, bool sheathed,
bool hide);
// Lane `i` hung again with `sheatheType`: detached at the type it hangs with
// now (DetachHeldItemSlot finds the sheath point from it), then hung in the
// hand or the sheath, or left off. The lane keeps the new type, so every
// later detach finds the new sheath point.
void SetSheatheType(void *model, Lanes &lanes, int i, const Weapons &weapons,
uint8_t sheatheType, bool sheathed, bool hide);
// The glow `enchantID` gives an item shown with `display` (an ItemDisplayInfo
// row): none when the display hangs its own (GetItemDescVisual's rule).
uint32_t EnchantVisual(const void *display, uint32_t enchantID);
// TryOnItem's dispatch (0x00504540) on an item's inventory type.
enum class Arm {
Armor, // a component slot
Held, // a held-item lane
Nothing, // neck, finger, trinket, relic: nothing, and no error
NotEquippable, // anything else: the worker shows ERR_NOT_EQUIPPABLE
};
// The arm for `inventoryType`; `componentSlot` receives an armor arm's slot.
Arm ArmOf(uint32_t inventoryType, uint32_t *componentSlot = nullptr);
// DressUpModel's dual-wield test: the model shows the active player, and the
// player can dual wield.
bool CanDualWield(uint64_t guid);
// How a held try-on is hung.
struct HeldTryOn {
uint32_t bodySlot = 0; // the hand named (kBodySlot*), or 0 for the worker's choice
bool dualWield = false;
bool sheathed = false;
bool hide = false;
uint32_t enchant = 0; // its glow's SpellItemEnchantment, or 0
};
// TryOnItem's arms on whatever holds the component and the lanes: an armor
// slot is cleared and set on the component, which `armorReady` (the pane's
// ready byte) must allow; a held item evicts an other-lane item it cannot be
// held with, then takes its lane and hangs. The inventory slot (1-based) the
// item went on, or 0.
int TryOn(void *model, void *component, bool armorReady, Lanes &lanes, Weapons &weapons,
const uint8_t *record, const HeldTryOn &held);
// UndressSlot: the item in one inventory slot (1-based) off the model. A
// held lane is detached and zeroed, an armor slot cleared on the component.
// Without a component it does nothing, held lanes included (4.3.4's
// CGDressUpModelFrame::UndressSlot returns first).
void UndressSlot(void *model, void *component, Lanes &lanes, int invSlot);
// The component slot of an inventory slot (1-based), or -1 for a slot the
// component has no record for (the neck, fingers, trinkets, hands held and
// ranged).
int ComponentSlotOf(int invSlot);
// The inventory slot (1-based) of a component slot ArmOf gives, or 0.
int InvSlotOfComponent(uint32_t componentSlot);
// Whether TryOnItem's arms put anything in an inventory slot (1-based): a
// hand takes any held item, an armor slot needs `armorReady` (a component the
// model's ready gate allows).
bool SlotAllowed(int invSlot, bool armorReady);
// What `unit` wears in one inventory slot (1-based), back on the model: an
// armor slot takes the unit's component record (empty when the unit hides
// its helm or cloak), a held lane the unit's lane, row and glow. Armor needs
// a component and `armorReady`.
void DressSlot(void *model, void *component, bool armorReady, Lanes &lanes, Weapons &weapons,
void *unit, uint64_t guid, int invSlot, bool sheathed, bool hide);
// DressUpModel ClearModel (0x00504490): component slots 0..11 cleared, and,
// with `weapons`, both lanes detached and zeroed.
void Undress(void *model, void *component, Lanes &lanes, bool weapons);
// What one inventory slot wears, as retail's ItemTransmogInfo names it: the
// item (1.12's appearance) and, on a hand, the SpellItemEnchantment whose
// glow it shows. Zero for nothing.
struct Appearance {
uint32_t item = 0;
uint32_t enchant = 0;
};
constexpr int kInventorySlots = 19;
using Worn = Appearance[kInventorySlots]; // inventory slot n at [n - 1]
// What the unit `guid` wears where its model shows it: each armor slot its
// component shows and each hand `weapons` (ReadWeapons) holds. Nothing for a
// unit that is not a player, which has no visible items.
void ReadWorn(void *unit, uint64_t guid, const Weapons &weapons, Worn &worn);
// Zeroes each slot of `worn` the model no longer shows: an armor slot the
// component holds no record for (every one without a component), an empty
// lane.
void TrimWorn(void *component, const Lanes &lanes, Worn &worn);
// The held lane an item record gives, as TryOnItem copies it: class,
// subclass, inventory type and sheathe type.
HeldLane LaneOf(const uint8_t *record);
// RefreshRangedWeaponModel's source (0x00611E10): the unit's ranged lane and
// display row (hand 2), and, for a player, the item its ranged slot wears.
// All empty when the unit holds no ranged weapon.
void ReadRanged(void *unit, uint64_t guid, HeldLane &lane, const void *&display, Appearance &worn);
} // namespace Model::Outfit
+44 -85
View File
@@ -51,7 +51,8 @@
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "frame/Layout.h"
#include "gx/Device.h"
#include "model/Camera.h"
#include "model/DisplayInfo.h"
#include "model/M2.h"
@@ -72,25 +73,11 @@ constexpr uint32_t kTexels = kSize * kSize;
using CreateScene_t = void *(__cdecl *)();
using ReleaseScene_t = void(__thiscall *)(void *scene);
using CopyModel_t = void *(__thiscall *)(void *scene, void *source, uint32_t flags);
using ApplyDisplay_t = void(__fastcall *)(void *model, const uint8_t *display,
const uint8_t *modelData);
using DetachAll_t = void(__fastcall *)(void *model);
using DrawScene_t = void(__thiscall *)(void *scene, uint32_t pass);
using SetTransforms_t = void(__fastcall *)(void *camera, const float *rect, uint32_t unused);
using GetMatrix_t = void(__fastcall *)(float *m);
using SetMatrix_t = void(__fastcall *)(const float *m);
using GetViewport_t = void(__fastcall *)(float *, float *, float *, float *, float *, float *);
using SetViewport_t = void(__stdcall *)(float, float, float, float, float, float);
using ScreenSize_t = void(__fastcall *)(float *rect);
using DdcToNdc_t = void(__fastcall *)(float *x, float *y, float inX, float inY);
using DeviceGate_t = int(__cdecl *)();
using MasterGet_t = bool(__fastcall *)(uint32_t cap);
using MasterSet_t = void(__fastcall *)(uint32_t cap, uint32_t on);
using SetClearColor_t = void(__stdcall *)(uint32_t argb);
using Clear_t = void(__fastcall *)(uint32_t mask);
using AlphaMask_t = const uint8_t *(__fastcall *)(uint32_t size);
using GetDeviceTexture_t = void *(__fastcall *)(void *texture, uint32_t wait, void *callback);
using MarkDirty_t = void(__fastcall *)(void *gxTex, const int32_t *rect, uint32_t immediate);
using SetImage_t = void(__thiscall *)(void *region, void *texture);
@@ -152,77 +139,62 @@ bool Bake(Portrait &p) {
void *scene = reinterpret_cast<CreateScene_t>(Offsets::FUN_M2_CREATE_SCENE)();
if (scene == nullptr)
return false;
void *copy = reinterpret_cast<CopyModel_t>(Offsets::FUN_M2SCENE_COPY_MODEL)(scene, source, 0);
void *copy = M2::CopyWithoutItems(scene, source);
if (copy == nullptr) {
reinterpret_cast<ReleaseScene_t>(Offsets::FUN_M2SCENE_RELEASE)(scene);
return false;
}
reinterpret_cast<DetachAll_t>(Offsets::FUN_DETACH_ALL_ITEM_MODELS)(copy);
void *camera = Model::Camera::CameraById(copy, 0);
M2::PlayAnimation(copy, -1, 0, 0, 0, 1.0f, 0, 1);
float projection[16];
float view[16];
float viewport[6];
reinterpret_cast<GetMatrix_t>(Offsets::FUN_GX_GET_PROJECTION)(projection);
reinterpret_cast<GetMatrix_t>(Offsets::FUN_GX_GET_VIEW)(view);
reinterpret_cast<GetViewport_t>(Offsets::FUN_GX_GET_VIEWPORT)(
&viewport[0], &viewport[1], &viewport[2], &viewport[3], &viewport[4], &viewport[5]);
const bool fog = reinterpret_cast<MasterGet_t>(Offsets::FUN_GX_MASTER_ENABLE_GET)(
Offsets::GX_MASTER_FOG);
auto setMaster = reinterpret_cast<MasterSet_t>(Offsets::FUN_GX_MASTER_ENABLE_SET);
setMaster(Offsets::GX_MASTER_FOG, 0);
{
const Gx::SavedTransforms saved;
const float aspect = static_cast<float>((static_cast<double>(x1) - x0) /
(static_cast<double>(y1) - y0));
Frame::Layout::DdcToNdc(&x0, &y0, x0, y0);
Frame::Layout::DdcToNdc(&x1, &y1, x1, y1);
const float aspect = static_cast<float>((static_cast<double>(x1) - x0) /
(static_cast<double>(y1) - y0));
auto toNdc = reinterpret_cast<DdcToNdc_t>(Offsets::FUN_COORD_DDC_TO_NDC);
toNdc(&x0, &y0, x0, y0);
toNdc(&x1, &y1, x1, y1);
// First collection: evaluate the portrait camera.
M2::IsLoaded(copy, 1, 1);
M2::SetAnimated(copy, true);
const float origin[3] = {0.0f, 0.0f, 0.0f};
M2::AnimateScene(scene, origin);
const Model::Camera::Vec3 eye = Model::Camera::GetVector(camera, Offsets::CAMERA_PROP_EYE);
Model::Camera::GetVector(camera, Offsets::CAMERA_PROP_TARGET); // the bake reads it too
const float rect[4] = {0.0f, 0.0f, 1.0f, aspect};
Model::Camera::SetDeviceTransforms(camera, rect);
Gx::SetViewport({x0, x1, y0, y1, 0.0f, 1.0f});
Gx::SetClearColor(0);
Gx::Clear(3);
// First collection: evaluate the portrait camera.
M2::IsLoaded(copy, 1, 1);
M2::SetAnimated(copy, true);
const float origin[3] = {0.0f, 0.0f, 0.0f};
M2::AnimateScene(scene, origin);
const Model::Camera::Vec3 eye = Model::Camera::GetVector(camera, Offsets::CAMERA_PROP_EYE);
Model::Camera::GetVector(camera, Offsets::CAMERA_PROP_TARGET); // the bake reads it too
const float rect[4] = {0.0f, 0.0f, 1.0f, aspect};
reinterpret_cast<SetTransforms_t>(Offsets::FUN_CAMERA_SET_DEVICE_TRANSFORMS)(camera, rect, 0);
reinterpret_cast<SetViewport_t>(Offsets::FUN_GX_SET_VIEWPORT)(x0, x1, y0, y1, 0.0f, 1.0f);
reinterpret_cast<SetClearColor_t>(Offsets::FUN_GX_SET_CLEAR_COLOR)(0);
auto clear = reinterpret_cast<Clear_t>(Offsets::FUN_GX_CLEAR);
clear(3);
// Second collection from the portrait eye, then both passes.
M2::IsReady(copy, 1, 1);
M2::SetAnimated(copy, true);
M2::SetVisible(copy, true);
M2::SetRenderCallback(copy, reinterpret_cast<const void *>(Offsets::FUN_PORTRAIT_LIGHTING),
nullptr);
M2::AnimateScene(scene, &eye.x);
M2::DrawScene(scene, 0);
M2::DrawScene(scene, 1);
// Second collection from the portrait eye, then both passes.
M2::IsReady(copy, 1, 1);
M2::SetAnimated(copy, true);
M2::SetVisible(copy, true);
M2::SetRenderCallback(copy, reinterpret_cast<const void *>(Offsets::FUN_PORTRAIT_LIGHTING),
nullptr);
M2::AnimateScene(scene, &eye.x);
auto draw = reinterpret_cast<DrawScene_t>(Offsets::FUN_M2SCENE_DRAW);
draw(scene, 0);
draw(scene, 1);
const int32_t box[4] = {0, 0, static_cast<int32_t>(kSize), static_cast<int32_t>(kSize)};
TexelArray out{kTexels, kTexels, p.texels.data(), kTexels};
reinterpret_cast<void(__fastcall *)(const int32_t *, TexelArray *)>(
Offsets::FUN_GX_READ_PIXELS)(box, &out);
clear(3);
const auto *mask = reinterpret_cast<const AlphaMask *>(
reinterpret_cast<AlphaMask_t>(Offsets::FUN_PORTRAIT_ALPHA_MASK)(kSize));
if (out.data == p.texels.data()) {
for (uint32_t i = 0; i < kTexels; ++i)
p.texels[i] = (p.texels[i] & 0x00FFFFFFu) | static_cast<uint32_t>(mask->alpha[i]) << 24;
void *gxTex = reinterpret_cast<GetDeviceTexture_t>(Offsets::FUN_TEXTURE_GET_RENDERABLE)(
p.texture, 1, nullptr);
reinterpret_cast<MarkDirty_t>(Offsets::FUN_GX_TEX_MARK_RECT_DIRTY)(gxTex, box, 1);
const int32_t box[4] = {0, 0, static_cast<int32_t>(kSize), static_cast<int32_t>(kSize)};
TexelArray out{kTexels, kTexels, p.texels.data(), kTexels};
reinterpret_cast<void(__fastcall *)(const int32_t *, TexelArray *)>(
Offsets::FUN_GX_READ_PIXELS)(box, &out);
Gx::Clear(3);
const auto *mask = reinterpret_cast<const AlphaMask *>(
reinterpret_cast<AlphaMask_t>(Offsets::FUN_PORTRAIT_ALPHA_MASK)(kSize));
if (out.data == p.texels.data()) {
for (uint32_t i = 0; i < kTexels; ++i)
p.texels[i] = (p.texels[i] & 0x00FFFFFFu) | static_cast<uint32_t>(mask->alpha[i]) << 24;
void *gxTex = Texture::DeviceTexture(p.texture);
reinterpret_cast<MarkDirty_t>(Offsets::FUN_GX_TEX_MARK_RECT_DIRTY)(gxTex, box, 1);
}
}
reinterpret_cast<SetMatrix_t>(Offsets::FUN_GX_SET_PROJECTION)(projection);
reinterpret_cast<SetMatrix_t>(Offsets::FUN_GX_SET_VIEW)(view);
reinterpret_cast<SetViewport_t>(Offsets::FUN_GX_SET_VIEWPORT)(
viewport[0], viewport[1], viewport[2], viewport[3], viewport[4], viewport[5]);
setMaster(Offsets::GX_MASTER_FOG, fog ? 1 : 0);
M2::Release(copy);
reinterpret_cast<ReleaseScene_t>(Offsets::FUN_M2SCENE_RELEASE)(scene);
@@ -270,27 +242,14 @@ Portrait *Acquire(uint32_t displayID) {
auto found = g_portraits.find(displayID);
if (found != g_portraits.end())
return found->second.get();
const uint8_t *display = DBC::Record(Offsets::VAR_CREATUREDISPLAYINFO_RECORDS,
Offsets::VAR_CREATUREDISPLAYINFO_COUNT, displayID);
if (display == nullptr)
return nullptr;
const uint8_t *modelData = DBC::Record(
Offsets::VAR_CREATUREMODELDATA_RECORDS, Offsets::VAR_CREATUREMODELDATA_COUNT,
Game::Read<uint32_t>(display, Offsets::OFF_CREATUREDISPLAYINFO_MODEL_ID));
if (modelData == nullptr)
return nullptr;
void *scene = reinterpret_cast<CreateScene_t>(Offsets::FUN_M2_CREATE_SCENE)();
if (scene == nullptr)
return nullptr;
void *model = M2::CreateModel(
scene, Game::Read<const char *>(modelData, Offsets::OFF_CREATUREMODELDATA_MODEL_PATH));
void *model = Model::DisplayInfo::CreateDisplayModel(scene, displayID);
if (model == nullptr) {
reinterpret_cast<ReleaseScene_t>(Offsets::FUN_M2SCENE_RELEASE)(scene);
return nullptr;
}
reinterpret_cast<ApplyDisplay_t>(Offsets::FUN_APPLY_CREATURE_DISPLAY)(model, display,
modelData);
// The bake's own texture: 64x64, one mip, ARGB8888, flags (1, 0, 0, 0, 0,
// 0, 1, 0), "Portrait1" (0x005257D3).
+2363
View File
File diff suppressed because it is too large Load Diff
+645
View File
@@ -0,0 +1,645 @@
// 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 "Offsets.h"
#include "animation/Object.h"
#include "model/Outfit.h"
#include "model/SceneContact.h"
#include "model/TryOn.h"
#include <cstdint>
#include <memory>
#include <string>
#include <utility>
#include <vector>
// The ModelScene frame (model/Scene.cpp) and its actors (model/SceneActor.cpp).
// docs/notes/model-scene.md has the plan, the retail contract and the probes.
namespace Model::Scene {
class Actor;
namespace World {
struct Ambience;
}
namespace Mover {
struct Body;
}
// The light of a spell's model in flight or left behind, as the world gives
// a missile or an effect a light node of its own: an entity standing on the
// WMO under it (World::Settle / ActorLight), else the frame's light.
struct EntityLight {
explicit EntityLight(void *frame);
~EntityLight();
EntityLight(const EntityLight &) = delete;
EntityLight &operator=(const EntityLight &) = delete;
void *frame;
World::Ambience *ambience;
};
// The model lights as an entity of the scene's WMO through its render
// callback, with its own `light` (model/SceneSpell.cpp).
void LightAsEntity(void *model, std::unique_ptr<EntityLight> &light, void *frame);
// Gives the model the frame's light callback again, frees `light`, and
// hides and releases the model.
void ReleaseLit(void *model, std::unique_ptr<EntityLight> &light);
// The light's re-placement when the model moved, and its ease, each tick.
void SettleLight(void *model, EntityLight *light, float elapsed);
// One PlaySpellVisual on its caster (model/SceneSpell.cpp): the precast, the
// cast, then the missile in flight.
struct SpellCast {
enum class Phase { Precast, Cast, Flight };
uint32_t id = 0; // PlaySpellVisual's cast ID
uint32_t spellID = 0;
// The visual the caster shows (UnitGetSpellVisual's 64 bytes: the
// spell's record, a ranged spell's gaps filled from its weapon's).
uint8_t visual[Offsets::SPELL_VISUAL_SIZE] = {};
// The ammo the shot carries (SMSG_SPELL_GO's): an ItemDisplayInfo id and
// inventory type, 0 for a spell that is not ranged.
uint32_t ammoDisplay = 0;
uint32_t ammoType = 0;
Actor *target = nullptr; // the caster for a cast on itself
Phase phase = Phase::Precast;
float precastLeftSec = 0.0f;
std::vector<void *> precastEffects; // one reference each
bool precastTinted = false; // the precast kit set a tint or alpha
void *missile = nullptr; // one reference, from the cast on
void *missileSound = nullptr;
float queuedSec = 0.0f; // since the missile was made, until its release
float speed = 0.0f;
float position[3] = {};
float destination[3] = {};
int attachment = -1; // the target attachment the launch settled, or -1
bool sticks = false; // shot from a bow, crossbow or thrown weapon
std::unique_ptr<EntityLight> light; // the missile's
};
// A cast that reached its target this frame, for the caster's
// OnSpellVisualImpact.
struct SpellImpact {
uint32_t castID = 0;
uint32_t spellID = 0;
Actor *target = nullptr;
};
// A missile stuck in its target, until its time is up.
struct StuckMissile {
void *model = nullptr; // one reference
float leftSec = 0.0f;
};
// An impact model left at a missile's arrival, until its particles are gone.
struct ImpactModel {
void *model = nullptr; // one reference
int framesShown = 0;
std::unique_ptr<EntityLight> light;
};
// A shard of a spell's ground area (model/SceneArea.cpp): one model of the
// emitter's file, played once where it fell, as the world's shard
// (0x006EBB30). Its callbacks hold its address.
struct AreaShard {
void *frame = nullptr;
void *model = nullptr; // one reference
float position[3] = {};
uint32_t startMs = 0; // the scene clock at which it starts
bool started = false;
bool done = false; // its animation ended
std::unique_ptr<EntityLight> light;
};
// The area kit of a spell's ground area while it runs: the shard emitter
// (0x006EB930) and the kit's looping sound.
struct AreaEmitter {
bool running = false;
const char *path = nullptr; // the shard file, null for a kit without an emitter
float rate = 0.0f; // shards a second
double accumulator = 0.0;
int32_t soundID = 0;
void *loopSound = nullptr;
};
// A ModelSceneActor: one model in its scene frame's M2 scene, placed by the
// scene's paint. A script object (Animation::Object) whose parent is the scene
// frame, which owns it.
class Actor : public Animation::Object {
public:
static Animation::Class s_class;
Actor(void *scene, const char *name);
~Actor();
void *SceneFrame() const { return parent; }
// From the scene frame's update, before the scene clock advances: puts a
// ready model on the scene's animate list.
void Link();
// From the scene frame's update walk: load and dress state, scripts.
// `elapsedSec` is the frame's time (OnUpdate), `sceneSec` the time the
// scene clock moved (casts; 0 while the scene is paused).
void Update(float elapsedSec, float sceneSec);
// From the scene's paint, before the scene animates: the model's
// placement matrix.
void Place();
// The scene frame's alpha changed.
void ApplyAlpha();
// Shows `model` (one reference, which the actor takes over), or nothing.
void SetModel(void *model, const char *path, const uint8_t *dressRecord);
// Shows a copy of the unit's model, dressed and armed per autoDress,
// sheathed and hideWeapons; false (and no model) when it has none.
bool SetUnitModel(uint64_t guid);
// Hangs the hand lanes per MeleeSheathed and hideWeapons, and the ranged
// weapon in hand while it is drawn.
void ApplyWeapons();
// A ranged spell's start (the unit's sheathe state 2): the melee items
// on their sheath points, the ranged weapon in hand, until SetSheathed or
// a new model.
void DrawRanged();
// Leaves sheathe state 2: the ranged weapon leaves the hand.
void SheatheRanged();
// Whether the hand lanes hang at their sheath points: sheathed, or the
// ranged weapon drawn.
bool MeleeSheathed() const { return sheathed || rangedDrawn; }
// Puts the item on the model (Outfit::TryOn), once the model has loaded
// and the item's record is cached; returns the ItemTryOnReason.
int TryOnItem(const Model::TryOn::Request &request);
void ClearModel();
void PlayAnimation();
// Shows the kit (0 for none) on the model, replacing the one shown.
void SetSpellVisualKit(uint32_t kitID, bool oneShot);
// Loads the actor's XML element (an <Actor> template), then runs OnLoad.
void LoadXml(const void *node, void *status);
void *model = nullptr;
std::string modelPath;
const uint8_t *dressRecord = nullptr; // CreatureDisplayInfoExtra, until dressed
float displayScale = 1.0f; // a creature display's CreatureModelScale, else 1
// The CreatureDisplayInfo the model shows (a creature display's, or the
// unit's UNIT_FIELD_DISPLAYID), for its sound data; 0 for a model file.
uint32_t displayID = 0;
bool playAnimationSounds = false; // SetPlayAnimationSounds
// SetModelBySpellArea: the ground area of a spell, as the world shows a
// dynamic object (CGDynamicObject_C). Its SpellVisualEffectName row and
// its radius in yards; null and 0 for any other model.
const uint8_t *areaEffect = nullptr;
float areaRadius = 0.0f;
const uint8_t *areaVisual = nullptr; // the spell's SpellVisual row
// The area's kit, and its shards, which play out after the area goes.
AreaEmitter areaEmitter;
std::vector<std::unique_ptr<AreaShard>> areaShards;
bool shadowShown = false; // SetShadowShown: a blob shadow under the model
// SetHighlight: a unit's light bits (CGObject +0xB8, Offsets
// CGOBJECT_LIGHT_BIT_*); while any is set the model glows in the scene
// light's ambient colour.
uint32_t highlightBits = 0;
// SetSelectionCircle: a selection circle under the model, in this ARGB.
bool selectionCircleShown = false;
uint32_t selectionCircle = 0;
// SetGroundReticle: the world's ground-target reticle at the actor's
// position: its radius as given (0 takes the world's default) and
// whether it shows the in-range texture.
bool groundReticleShown = false;
float groundReticleRadius = 0.0f;
bool groundReticleValid = true;
// The model's emissive colour for the highlight bits.
void ApplyHighlight();
// SetNameText: the name over the head, drawn as the world draws a unit's
// (UpdatePlate 0x006C6E90) from the model's draw callback. Its text and
// ARGB colour; the font string, made at the next draw while nameDirty,
// with the font that made it (the world's name font, destroyed at
// logout before the actors); and the block height, the extra lines times
// the plate scale at that draw.
std::string nameText;
uint32_t nameColor = 0xFFFFFFFFu;
void *nameString = nullptr;
const void *nameFont = nullptr;
bool nameDirty = false;
float nameBlockHeight = 0.0f;
// GetNamePosition (0x00608640) for the model, in scene coordinates;
// false without a loaded model.
bool NamePosition(float (&out)[3]) const;
// From the model's draw callback: UpdatePlate's name half.
void DrawName();
void DropNameString();
World::Ambience *ambience; // its light in the scene's WMO (owned)
// The character component dressing the model: a display's dress, or the
// clone of a unit's.
void *dresser = nullptr;
// A unit's model (SetModelByUnit): the unit and what the copy holds.
uint64_t unitGuid = 0;
Outfit::Lanes lanes = {};
Outfit::Weapons weapons;
Outfit::Worn worn = {}; // what each inventory slot wears (GetItemTransmogInfoList)
bool autoDress = true;
bool sheathed = false;
bool hideWeapons = false;
bool rangedDrawn = false; // the unit's sheathe state 2
// The ranged slot (a ClassicAPI extension): a unit's third held lane,
// inventory slot 18, which every bow, gun, crossbow, wand and thrown
// weapon takes. Its ItemDisplayInfo row, and its model (a unit's +0xD24,
// one reference) while hung: in hand while drawn, else only at a sheath
// point its type has.
Outfit::HeldLane ranged = {};
const void *rangedDisplay = nullptr;
void *rangedModel = nullptr;
// Per lane: the SetSheathedCategory category (Outfit::SheatheCategory),
// and the sheathe type the lane's item has of its own. The lane itself
// holds the type it hangs with.
uint32_t sheatheCategory[2] = {};
uint8_t ownSheathe[2] = {};
bool useUnitSheatheCategory = false; // UseUnitSheatheCategory
// The ammo tried on (an INVTYPE_AMMO item, as a player equips it): its
// ItemDisplayInfo id, 0 for none.
uint32_t ammoDisplay = 0;
// Lane `i` (on `onModel`, the actor's model or the copy being built) was
// just filled with its item's own type: hang it per its category.
void TookLane(void *onModel, int i);
// Hangs lane `i` per its category.
void ApplySheatheCategory(void *onModel, int i);
// DressUpModel's ready byte: the model may take armor (not a shapeshifted
// unit's).
bool armorReady = true;
std::vector<Model::TryOn::Request> pendingTryOns; // waiting for the model or the record
bool loaded = false; // OnModelLoaded has run for the model
bool animFinished = false;
// The client's mover (SetMovementEnabled, a ClassicAPI extension): while
// set, it moves `position` and turns `yaw` through the scene's WMO.
std::unique_ptr<Mover::Body> mover;
// A unit's facings while the mover is on (CGUnit_C +0xC98, +0xC94): the
// display facing the body turns toward, the body facing the model is
// drawn at, the time the display last stood still (+0xCB0), and the body
// pass's bits (+0xD58): 0x1 turning, 0x80 and 0x100 the model's twist
// bones, marked for `bonesOf` once it has loaded. `shuffle` is the pass's
// 0x800 (1) or 0x1000 (-1): the body caught up turning left or right on
// the spot this frame.
struct Facings {
float display = 0.0f;
float body = 0.0f;
uint32_t stamp = 0;
uint32_t bits = 0;
int shuffle = 0;
const void *bonesOf = nullptr;
} facings;
// The yaw the model is drawn at: the body facing while the mover is on.
float ShownYaw() const { return mover != nullptr ? facings.body : yaw; }
float position[3] = {0.0f, 0.0f, 0.0f};
float yaw = 0.0f, pitch = 0.0f, roll = 0.0f;
float scale = 1.0f;
float alpha = 1.0f;
bool shown = true;
// FadeOut: the world's fade of a removed object's model, on the scene
// clock from fadeStartMs; the actor hides when it ends.
bool fadingOut = false;
uint32_t fadeStartMs = 0;
bool useCenterForOrigin[3] = {false, false, false};
uint32_t animation = 0;
int variation = -1;
float animSpeed = 1.0f;
float animOffsetSec = 0.0f;
uint32_t blendOperation = 1; // Enum.ModelBlendOperation: 0 None, 1 Anim
bool paused = false;
bool globalPaused = false;
int heldPositionMs = -1; // the paused frame; -1 until read from a loaded model
uint32_t spellVisualKit = 0;
bool spellVisualKitOneShot = false;
std::vector<void *> kitEffects; // the kit's effect models, one reference each
// The kit's world effect (OFF_SPELLVISUALKIT_WORLD_EFFECT): a model of its
// own in the scene, planted where the actor stood, one reference, lit as
// an entity; `kitWorldDone` once a one-shot effect has ended.
void *kitWorldEffect = nullptr;
float kitWorldPosition[3] = {0.0f, 0.0f, 0.0f};
std::unique_ptr<EntityLight> kitWorldLight;
bool kitWorldDone = false;
// ApplySpellVisualKit calls made before the model loaded: {kit, oneShot}.
std::vector<std::pair<uint32_t, bool>> pendingKits;
// A spell kit's animation, shown in place of `animation` (-1 none): a
// precast's loops, a cast's or an impact's plays once.
int kitAnimation = -1;
bool kitAnimationLoops = false;
// The precast's looping animation (-1 none), which a once-only kit
// animation (a wound) gives back when it ends, as a unit's replays its
// spell's looping kit animation.
int kitLoopAnimation = -1;
bool kitAnimationEnded = false; // a once-only kit animation ended this frame
void PlayKitAnimation(int anim, bool loops);
// The animation the body plays: a spell kit's, else the script's.
int CurrentAnimation() const { return kitAnimation >= 0 ? kitAnimation : static_cast<int>(animation); }
// A release event of the model this frame ($CSL / $CSR / $CST, or $BWR
// from the bow or rifle release point), and its point.
bool releaseEvent = false;
float releasePoint[3] = {};
// The ranged pose, as a unit keeps it: the ammo in hand (one reference,
// nocked at attachment 35 while the bow is drawn), its display, and the
// drawn bit.
void *heldAmmo = nullptr;
uint32_t heldAmmoDisplay = 0;
bool bowDrawn = false;
// The unit fields DrawBowstring reads (the model and the drawn bit), the
// user data of a hung bow's string; each update, before the draw.
alignas(4) uint8_t stringUnit[Offsets::OFF_CGUNIT_STATE_FLAGS + 4] = {};
std::vector<SpellCast> spellCasts;
// The casts that reached their target, in order, until the update runs
// OnSpellVisualImpact for them: outside the casts' step, which a script
// that casts or stops a spell would change under it.
std::vector<SpellImpact> spellImpacts;
// The moves (MoveTo) the mover reported done, until the update runs
// OnMoveFinished for them.
uint32_t movesFinished = 0;
std::vector<ImpactModel> impactModels;
std::vector<StuckMissile> stuckMissiles; // the missiles stuck in this actor
};
// The spell visuals an actor casts (model/SceneSpell.cpp).
namespace Casts {
// Starts the cast and returns its ID (from 1); 0 when the spell, its visual
// or the caster's model is missing. `castTimeMs` < 0 takes the spell's cast
// time.
uint32_t Play(Actor &caster, uint32_t spellID, Actor *target, int castTimeMs);
// Interrupts the casts still preparing; casts already made go on.
void Stop(Actor &caster);
// The model's $BWP: the bow draws and the ammo is nocked, while a ranged
// spell is cast.
void BowPull(Actor &caster);
// The model's $BWR: the weapon plays its release before the missiles go.
void BowRelease(Actor &caster);
// The animation a ranged load or hold goes on to when it ends, or -1 for
// none (the ranged shot cycle).
int RangedAnimationAfter(const Actor &caster, int ended);
// EndRangedPose (0x0060F530): the bow is no longer drawn, the ammo leaves the
// hand, and the weapon stands again unless it plays its release.
void EndRangedPose(Actor &actor);
// From the actor's update, after the scene advance: the casts move on.
void Update(Actor &caster, float elapsedSec);
// From the actor's Link: the released missiles and the impact models go on
// the scene's animate list.
void Link(Actor &caster);
// From the actor's Place: the missiles' placement.
void Place(Actor &caster);
// Drops every cast, missile and impact model of the caster.
void Drop(Actor &caster);
// Stops the missiles' loop sounds (the scene hid; its update stops).
void Silence(Actor &caster);
} // namespace Casts
// The byte a spell visual kit procedure keeps in a float value: the bits of
// the value + 512.0, shifted right 14 (the engine's decode, 0x005D5665 and
// 0x0060DB19; model/SceneArea.cpp).
uint8_t DecodeKitByte(float value);
// The chain effects of a scene's casts (model/SceneChain.cpp): Chain
// Lightning's bolts, drawn by a lightning renderer the scene owns.
namespace Chains {
// From a cast's start: the cast kit's chain procedures (kinds 0 and 12)
// draw a bolt from the caster to the target.
void Start(Actor &caster, Actor &target, const uint8_t *visual, const uint8_t *kit);
// From the scene's update, after the actors': the hops move with their
// ends and end on the scene clock; the bolts animate.
void Update(void *frame, float sceneSec);
// From the scene's paint, after the models: draws the bolts.
void Render(void *frame, const float (&eye)[3]);
// The actor goes: its hops go with it.
void Forget(Actor &actor);
// The scene goes.
void Destroy(void *frame);
} // namespace Chains
// The area kit of an actor that shows a spell's ground area
// (model/SceneArea.cpp): its shard emitter and its looping sound.
namespace Areas {
// From the actor's update, after the scene advance: the kit starts while the
// actor shows an area and is shown, and stops otherwise; shards fall and
// the finished ones go.
void Update(Actor &actor, float sceneSec);
// From the actor's Link: the shards whose start came go on the animate list.
void Link(Actor &actor);
// From the actor's Place: the shards' placement and the loop sound's.
void Place(Actor &actor);
// The kit stops (the model changed): no new shards, the loop fades.
void Stop(Actor &actor);
// Stops the kit and frees every shard (the actor goes).
void Drop(Actor &actor);
// Stops the loop sound (the scene hid; its update stops). It loops again
// at the next update.
void Silence(Actor &actor);
} // namespace Areas
// The unit's movement animations (model/SceneLocomotion.cpp): an actor with
// the mover on picks and plays them as the client's unit does from its own
// mover. Each is a no-op for an actor without the mover, without a loaded
// model, or dead (its own animation a death animation).
namespace Locomotion {
// The movement messages a unit animates (AnimateMoveOpcode 0x0060E480), by
// their 1.12 opcodes.
enum class Move : uint32_t {
StartForward = 0xB5,
StartBackward = 0xB6,
Stop = 0xB7,
StartStrafeLeft = 0xB8,
StartStrafeRight = 0xB9,
StopStrafe = 0xBA,
Jump = 0xBB,
StartTurnLeft = 0xBC,
StartTurnRight = 0xBD,
StopTurn = 0xBE,
RunMode = 0xC2,
WalkMode = 0xC3,
TeleportAck = 0xC7,
StartSwim = 0xCA,
StopSwim = 0xCB,
KnockBack = 0xF0, // CMSG_MOVE_KNOCK_BACK_ACK, after the launch
};
// A movement message for a command the mover took, or a change the actor
// made (ProcessLocalMoveEvent 0x0060E0A0 -> AnimateMoveOpcode).
void OnMove(Actor &actor, Move move);
// The mover's substep hook (Mover::Body::stepped), context the actor: an
// arrival at a spline's end, a landing, a fall or a swim's end.
void OnStep(void *actor, uint32_t oldFlags, bool jumped, bool arrived);
// The body pass's shuffle (the tail of CGUnit_C::Update 0x00607ED0), `turn`
// the turn the body made this frame.
void OnBodyTurn(Actor &actor, float turn);
// The actor's own animation ended: plays what follows it
// (PlayFollowOnAnimation 0x005FC3F0) and returns true; false when the chain
// does not run, and the caller replays it.
bool FollowOn(Actor &actor);
// Picks again (UpdateBaseAnimation 0x005FD9E0).
void Repick(Actor &actor);
} // namespace Locomotion
// The actor model's animation events (model/SceneEvents.cpp).
namespace Events {
// The model event callback (Offsets::FUN_M2MODEL_SET_EVENT_CALLBACK), user
// data the actor: missile releases for Casts, and the event sounds.
void __stdcall OnModelEvent(uint32_t tag, uint32_t data, const float *point, uint32_t unknown,
uint32_t userLow, uint32_t userHigh);
} // namespace Events
// The scene's WMO (SetWorldModel; model/SceneWorld.cpp).
namespace World {
struct Backdrop;
// Whether `path` names a WMO (ends in ".wmo"), which only the scene shows.
bool IsWmoPath(const char *path);
// Null when the engine cannot read `path` safely: it is not a WMO, a file
// does not open, the root's chunks are not in the engine's order or it has
// more groups than the engine holds.
// `mapID` is the map whose light the outdoor water takes (Light.dbc at the
// scene's camera), or kNoMap for the world's light where the player is.
constexpr uint32_t kNoMap = 0xFFFFFFFFu;
Backdrop *Load(const char *path, const float (&placement)[16], uint32_t doodadSet, uint32_t mapID);
// The single WMO of a WMO-only map at the map's own placement; null for any
// other map.
Backdrop *LoadMap(uint32_t mapID);
// SetWorldModel's placement: at `position`, turned by `yaw` about z.
void Placement(const float (&position)[3], float yaw, float (&out)[16]);
void Free(Backdrop *backdrop);
// From the scene's update, before the scene advances: makes the doodads of
// each group parsed since, puts the ready ones on the animate list and
// moves their sounds with the camera, and moves the camera's WMO fog (the
// MFOG records near the camera, faded in and out as the world fades them).
// `sounds` is SetPlayWorldModelSounds.
void Update(Backdrop &backdrop, void *frame, bool sounds, float elapsed);
// Drops the doodads' loop sounds (the scene hid).
void Silence(Backdrop &backdrop);
// From the paint, in place of its depth clear: the world's frame clear and,
// with the camera outside the WMO's interior groups and dry, the world's sky
// (the dome, the sun and moons, the stars, the clouds and the skyboxes) in
// the device's view, lit by the backdrop map's light at the camera.
void ClearAndDrawSky(const Backdrop &backdrop, void *frame, const float (&eye)[3]);
// From the paint, after the animate pass: the WMO in the device's view.
void Draw(Backdrop &backdrop, void *frame, const float (&eye)[3]);
// From the paint, between the scene's opaque and translucent lists: the
// liquids of the WMO's groups (the world draws them between its model lists).
void DrawLiquids(Backdrop &backdrop, void *frame, const float (&eye)[3]);
// Whether the camera is in one of the WMO's liquids: the world then draws the
// liquids after the translucent models.
bool CameraWet(const Backdrop &backdrop);
// How the world lights a unit that stands in a WMO, kept per actor: the
// colour of the face under it, or the sun, and the WMO's lights near it.
struct Ambience;
Ambience *NewAmbience();
void FreeAmbience(Ambience *ambience);
// From the scene's update: places the model in the WMO again when it moved,
// or when more of the WMO has loaded since.
void Settle(const Backdrop &backdrop, Ambience &ambience, void *frame, void *model, float elapsed);
// The actor's lighting slots: the frame's light and fog without a WMO, or
// before the model is placed in this one.
void ActorLight(const Backdrop *backdrop, const Ambience &ambience, void *frame, void *record);
// The TerrainType id of the WMO face under the actor, or -1 (no face, no
// WMO, or not placed in this one).
int Ground(const Backdrop *backdrop, const Ambience &ambience);
// The nearest face of the loaded groups that the segment from `start` to
// `end` (scene space) crosses, of the faces the world's line of sight tests,
// or with `camera` the faces its camera collides with: its fraction along the
// segment and its unit normal, turned to face `start`. False on a miss or
// before the WMO has loaded.
bool Trace(const Backdrop &backdrop, const float (&start)[3], const float (&end)[3], bool camera,
float &fraction, float (&normal)[3]);
// The liquid a point (scene space) is in: the type (0 water, 1 ocean, 2
// magma, 3 slime) and the surface's z, FLT_MAX for a group that is liquid
// throughout. False when the point is not under a liquid surface of the
// group below it, or before the WMO has loaded.
bool LiquidAt(const Backdrop &backdrop, const float (&point)[3], uint32_t &type, float &surface);
// The world query (0x006721B0) a scene body's sweeps make, for
// Contact::Facets: the WMO's collision faces in a box (scene space), then
// its doodads' collision meshes. False without a WMO, before it has loaded,
// or while a group or doodad in the box is still loading, so a body there
// does not move.
bool QueryFacets(void *backdrop, const float (&box)[6], std::vector<Contact::Facet> &out);
// The same query with a swimmer's class (0x30000), for Contact::Facets: the
// water and ocean surfaces of the WMO's liquid groups in a box (scene space),
// as their tiles' triangles. False without a WMO or before it has loaded.
bool QueryLiquidFacets(void *backdrop, const float (&box)[6], std::vector<Contact::Facet> &out);
// Sweeps a body by `move` to its first contact with the WMO's collision
// faces, as the client's mover sweeps a unit's volume (CMovement::SweepVolume
// 0x00632BA0, SceneContact.h): a box `radius` from `feet` on each side and
// `height` tall over a downward point at `feet`. `end` is where the feet
// stop; true when a face was met, `normal` the facet's plane normal (scene
// space). The feet stay put before the WMO has loaded.
bool Sweep(const Backdrop &backdrop, const float (&feet)[3], float radius, float height, const float (&move)[3],
float (&end)[3], float (&normal)[3]);
// From the paint, after Draw: a texture laid over the WMO's faces in `box`
// (scene space), as the world lays a decal on its ground.
void DrawDecal(const Backdrop &backdrop, const float (&box)[6], uint32_t argb, const float (&m)[16], float fade,
uint32_t mask, const float (&eye)[3]);
// The same decal in a scene without a WMO: laid on the flat ground at
// height `z`, one quad over the box.
void DrawDecalOnPlane(const float (&box)[6], uint32_t argb, const float (&m)[16], float fade, float z,
const float (&eye)[3]);
// The scene's fog on the device, for a decal at `point` that the world draws
// in its own fog: the WMO's, or the frame's without one. Inside a state push.
void CommitDecalFog(const Backdrop *backdrop, void *frame, const float (&point)[3], const float (&eye)[3]);
} // namespace World
// The scene's WMO, or null.
World::Backdrop *WorldOf(void *sceneFrame);
// The scene light's ambient colour {r, g, b}: the colour an actor's highlight
// takes, as a unit's takes the world light state's ambient.
const float *LightAmbient(void *sceneFrame);
// The scene clock in milliseconds (the M2 scene's): the actors' movement runs
// on it as a unit's runs on the OS clock, so it stops while the scene is
// paused or hidden, with the animations.
uint32_t ClockMs(void *sceneFrame);
// Whether SetPaused holds the scene.
bool IsPaused(void *sceneFrame);
// The radius the actor's ground-target reticle draws at: its own, or the
// world's default for 0 (Offsets::RETICLE_DEFAULT_RADIUS).
float GroundReticleRadius(const Actor &actor);
// The actor the scene's sounds are heard at (SetSoundListenerActor), or null.
Actor *SoundListener(void *sceneFrame);
// The scene's sounds (model/SceneSound.cpp), heard from the scene camera:
// each plays where its scene point lies from the camera, turned into the
// sound listener's frame, so the engine's cutoff, falloff and panning apply
// as they would to a unit at that distance.
namespace Sounds {
// A scene point as a point of the listener's (the engine's axes), heard at
// the scene's listener actor while SoundListenerAtCharacter is on, else at
// the scene camera.
void Heard(void *sceneFrame, const float (&point)[3], float (&out)[3]);
// Sound::Play::PlayKitIDAt, PlayKitLoopedAt and Move at a scene point.
void *Play(void *sceneFrame, int soundKitID, const float (&point)[3]);
void *Loop(void *sceneFrame, int soundKitID, const float (&point)[3]);
void Move(void *sceneFrame, void *sound, const float (&point)[3]);
} // namespace Sounds
// The scene camera (model/Scene.cpp).
struct View {
float eye[3];
float forward[3];
float up[3];
float farClip;
};
View CameraView(void *sceneFrame);
// The scene's ground terrain (SetGroundTerrain): a TerrainType id, or -1.
int GroundTerrain(void *sceneFrame);
// Registers the actor class in the current Lua state.
void RegisterActorClass();
// The methods' argument checks, for the frame and its actors. True when the
// `count` arguments from `first` are numbers; else the engine's usage error,
// "Usage: <owner>:<usage>".
bool NumberArgs(void *L, int first, int count, const char *owner, const char *usage);
float FloatArg(void *L, int index);
} // namespace Model::Scene
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
// 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 area kit of a spell's ground area (actor:SetModelBySpellArea), as the
// world's dynamic object starts it when it is created (CGDynamicObject_C,
// 0x005D55C0) and stops it when it is deactivated or destroyed:
//
// - The kit is the visual's +0x34 SpellVisualKit. Its first procedure of
// kind 9 starts the shard emitter (0x006ECE30 -> 0x006EB930): the shard
// model is the type's file (the low byte of the bits of the procedure's
// first value + 512.0, shifted right 14, into the seven paths at
// 0x00870E24), and the rate is its second value, scaled by the spell
// detail level (x0.33 at 0, x0.66 at 1).
// - Each frame the emitter adds the frame's seconds times the rate and
// spawns a shard for each whole 1.0 (0x006EBAD0). A shard picks a point of
// the area (the radial draw is not square-rooted, so shards gather at the
// centre) 16.67 yards up, traces up to it from just over the centre and
// stops short of a ceiling, then traces 33.33 yards down to the floor
// (0x006EBB64..0x006EBD20). It starts after 2 * a draw of 0..255 ms, plays
// its animation once, and is freed when it ends (0x006EBEE0, 0x006EBE70).
// Every fourth model event of the shards plays when it is a $SND
// (0x006EBE30).
// - The kit's sound loops on the area (0x005D569C), and stops with a 3.0
// second fade (0x005D56D0).
//
// The world has no pause; a paused scene (or actor) holds its shards still
// and silences the loop, which starts again when the pause ends.
//
// The random draws are the client's own (its random state 0x00882664). The
// traces are the scene's WMO (World::Trace); a scene without a WMO has no
// floor, so there its shards land at the area's height. The scene clock
// times the shards, so they stop while the scene is paused.
#include "model/Scene.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "model/M2.h"
#include "sound/Play.h"
#include "time/Clock.h"
#include <cstring>
namespace Model::Scene::Areas {
namespace {
constexpr float kLoopStopFadeSec = 3.0f; // 0x005D56DD: Sound::Stop2's fade
constexpr uint32_t kSND = 0x444E5324; // $SND, as 0x006EBE42 compares it
uint32_t g_shardEvents = 0; // the shards' event count (the world's 0x00CEE944)
float FromBits(uint32_t bits) {
float value;
std::memcpy(&value, &bits, sizeof value);
return value;
}
uint32_t BitsOf(float value) {
uint32_t bits;
std::memcpy(&bits, &value, sizeof bits);
return bits;
}
void *RandomState() { return reinterpret_cast<void *>(static_cast<uintptr_t>(Offsets::VAR_CLIENT_RANDOM)); }
// A draw in [0, 1): the client's random uint32 as the mantissa of a float in
// [1, 2), less 1.0 (0x006EBB64..0x006EBB8B).
float Draw() {
using Random_t = uint32_t(__thiscall *)(void *state);
const uint32_t r = reinterpret_cast<Random_t>(Offsets::FUN_RANDOM_UINT32)(RandomState());
return FromBits((r & 0x007FFFFFu) | 0x3F800000u) - Game::Read<float>(Offsets::VAR_ONE);
}
// The client's random unit vector, x and y (0x006EBB83).
void Direction(float (&out)[4]) {
using Vector_t = const float *(__fastcall *)(float *out, void *state);
reinterpret_cast<Vector_t>(Offsets::FUN_RANDOM_UNIT_VECTOR2)(out, RandomState());
}
// The rate at the spell detail level (0x006EB95E..0x006EB98F).
float DetailRate(float rate) {
const auto *record = Game::Read<const uint8_t *>(Offsets::VAR_SPELL_EFFECT_LEVEL_RECORD);
if (record == nullptr)
return rate;
const auto level = Game::Read<int32_t>(record, Offsets::OFF_SPELL_EFFECT_LEVEL);
if (level == 0)
return rate * Game::Read<float>(Offsets::VAR_SPELL_EFFECT_LOW_SCALE);
if (level == 1)
return rate * Game::Read<float>(Offsets::VAR_SPELL_EFFECT_MEDIUM_SCALE);
return rate;
}
// The shards' event callback (0x006EBE30): every fourth event, a $SND plays
// its payload at the event's point.
void __stdcall OnShardEvent(uint32_t tag, uint32_t data, const float *point, uint32_t /*unknown*/,
uint32_t userLow, uint32_t /*userHigh*/) {
++g_shardEvents;
if ((g_shardEvents & 3u) != 0 || tag != kSND)
return;
auto *shard = reinterpret_cast<AreaShard *>(static_cast<uintptr_t>(userLow));
const float *at = point != nullptr ? point : shard->position;
const float where[3] = {at[0], at[1], at[2]};
Sounds::Play(shard->frame, static_cast<int32_t>(data), where);
}
// The shards' done callback (0x006EBE70): a natural end (reason 0) frees the
// shard, here at the next update.
void __fastcall OnShardDone(void * /*model*/, uint32_t /*keyBone*/, uint32_t /*animID*/, uint32_t reason,
uint32_t /*overshoot*/, uint32_t userLow, uint32_t /*userHigh*/) {
if (reason == 0)
reinterpret_cast<AreaShard *>(static_cast<uintptr_t>(userLow))->done = true;
}
void FreeShard(AreaShard &shard) {
M2::SetEventCallback(shard.model, nullptr, 0, 0);
M2::SetAnimDoneCallback(shard.model, nullptr, 0, 0);
ReleaseLit(shard.model, shard.light);
shard.model = nullptr;
}
// The area kit's start (0x005D55C0): the emitter of its first kind-9
// procedure and its sound.
void Start(Actor &actor) {
AreaEmitter &emitter = actor.areaEmitter;
emitter = AreaEmitter{};
emitter.running = true;
const uint8_t *kit =
DBC::Record(Offsets::VAR_SPELLVISUALKIT_RECORDS, Offsets::VAR_SPELLVISUALKIT_COUNT,
Game::Read<uint32_t>(actor.areaVisual, Offsets::OFF_SPELLVISUAL_AREA_KIT));
if (kit == nullptr)
return;
for (int i = 0; i < 4; ++i) {
if (Game::Read<int32_t>(kit, Offsets::OFF_SPELLVISUALKIT_PROC_KIND + i * 4) !=
Offsets::SPELLVISUALKIT_PROC_AREA_SHARDS)
continue;
// The engine reads the table with no bound; a type past it has no path.
const uint8_t type = DecodeKitByte(Game::Read<float>(kit, Offsets::OFF_SPELLVISUALKIT_PROC_VALUE + i * 4));
if (type < Offsets::AREA_SHARD_PATH_COUNT) {
emitter.path = Game::Read<const char *>(Offsets::VAR_AREA_SHARD_PATHS + type * 4u);
emitter.rate = DetailRate(Game::Read<float>(kit, Offsets::OFF_SPELLVISUALKIT_PROC_VALUE2 + i * 4));
}
break;
}
emitter.soundID = Game::Read<int32_t>(kit, Offsets::OFF_SPELLVISUALKIT_SOUND);
}
// One shard (0x006EBB30..0x006EBDA9).
void Spawn(Actor &actor) {
void *frame = actor.SceneFrame();
const float radius = actor.areaRadius;
const float *centre = actor.position;
const float u = Draw();
float direction[4] = {};
Direction(direction);
const float us = u * direction[1];
const float high[3] = {centre[0] + u * direction[0] * radius, centre[1] + us * radius,
centre[2] + Game::Read<float>(Offsets::VAR_AREA_SHARD_DROP_HEIGHT)};
const float low[3] = {centre[0], centre[1], centre[2] + Game::Read<float>(Offsets::VAR_ONE_SIXTH)};
const World::Backdrop *world = WorldOf(frame);
float fraction = 1.0f;
float normal[3];
float start[3] = {high[0], high[1], high[2]};
// Short of a ceiling between the centre and the point up there.
if (world != nullptr && World::Trace(*world, low, high, false, fraction, normal)) {
const float share = fraction * Game::Read<float>(Offsets::VAR_AREA_SHARD_HIT_SHARE);
for (int i = 0; i < 3; ++i)
start[i] = low[i] + (high[i] - low[i]) * share;
}
auto shard = std::make_unique<AreaShard>();
shard->frame = frame;
const float below[3] = {start[0], start[1], start[2] - Game::Read<float>(Offsets::VAR_AREA_SHARD_FALL)};
fraction = 1.0f;
if (world == nullptr) {
shard->position[0] = start[0];
shard->position[1] = start[1];
shard->position[2] = centre[2];
} else if (World::Trace(*world, start, below, false, fraction, normal)) {
for (int i = 0; i < 3; ++i)
shard->position[i] = start[i] + (below[i] - start[i]) * fraction;
} else {
std::memcpy(shard->position, centre, sizeof shard->position);
}
void *model = M2::CreateModel(Model::Pane::Scene(frame), actor.areaEmitter.path);
if (model == nullptr)
return;
shard->model = model;
LightAsEntity(model, shard->light, frame);
const auto user = static_cast<uint32_t>(reinterpret_cast<uintptr_t>(shard.get()));
M2::SetEventCallback(model, reinterpret_cast<const void *>(&OnShardEvent), user, 0);
M2::SetAnimDoneCallback(model, reinterpret_cast<const void *>(&OnShardDone), user, 0);
// The wait: 2 * (the bits of draw * 255.0 + 512.0, rounded once, >> 14).
const auto wait = static_cast<float>(static_cast<double>(Draw()) * Game::Read<float>(Offsets::VAR_TWO_FIFTY_FIVE) +
Game::Read<float>(Offsets::VAR_FIVE_TWELVE));
shard->startMs = ClockMs(frame) + 2u * static_cast<uint8_t>(BitsOf(wait) >> 14);
actor.areaShards.push_back(std::move(shard));
}
void StopLoop(AreaEmitter &emitter, float fadeSec) {
if (emitter.loopSound != nullptr) {
Sound::Play::Stop(emitter.loopSound, fadeSec);
emitter.loopSound = nullptr;
}
}
} // namespace
} // namespace Model::Scene::Areas
namespace Model::Scene {
uint8_t DecodeKitByte(float value) {
uint32_t bits;
const float sum = value + Game::Read<float>(Offsets::VAR_FIVE_TWELVE);
std::memcpy(&bits, &sum, sizeof bits);
return static_cast<uint8_t>(bits >> 14);
}
} // namespace Model::Scene
namespace Model::Scene::Areas {
void Stop(Actor &actor) {
StopLoop(actor.areaEmitter, kLoopStopFadeSec);
actor.areaEmitter = AreaEmitter{};
}
void Update(Actor &actor, float sceneSec) {
AreaEmitter &emitter = actor.areaEmitter;
const bool wanted = actor.areaEffect != nullptr && actor.shown;
if (wanted && !emitter.running)
Start(actor);
else if (!wanted && emitter.running)
Stop(actor);
if (emitter.running) {
const bool held = IsPaused(actor.SceneFrame()) || actor.paused || actor.globalPaused;
if (held)
StopLoop(emitter, 0.0f);
else if (emitter.soundID > 0 && emitter.loopSound == nullptr)
emitter.loopSound = Sounds::Loop(actor.SceneFrame(), emitter.soundID, actor.position);
if (emitter.path != nullptr) {
emitter.accumulator += static_cast<double>(sceneSec) * emitter.rate;
while (emitter.accumulator >= 1.0) {
Spawn(actor);
emitter.accumulator -= 1.0;
}
}
}
for (size_t i = 0; i < actor.areaShards.size();) {
AreaShard &shard = *actor.areaShards[i];
if (!shard.done) {
SettleLight(shard.model, shard.light.get(), sceneSec);
++i;
continue;
}
FreeShard(shard);
actor.areaShards.erase(actor.areaShards.begin() + static_cast<ptrdiff_t>(i));
}
}
// The shard's draw callback (0x006EBEE0): nothing before its start, then the
// first draw plays its animation once, and each draw shows and animates it.
void Link(Actor &actor) {
const uint32_t now = ClockMs(actor.SceneFrame());
for (const auto &shard : actor.areaShards) {
if (shard->done || !M2::IsReady(shard->model, 0, 1))
continue;
if (!shard->started) {
if (!Time::Clock::Reached(now, shard->startMs))
continue;
shard->started = true;
M2::PlayAnimation(shard->model, -1, 0, -1, 0, 1.0f, 1, 1);
}
M2::SetVisible(shard->model, true);
M2::SetAnimated(shard->model, true);
}
}
void Place(Actor &actor) {
const float axisZ[3] = {0.0f, 0.0f, 1.0f};
const float scales[3] = {1.0f, 1.0f, 1.0f};
for (const auto &shard : actor.areaShards)
if (shard->started)
M2::SetBaseTRS(shard->model, shard->position, 0.0f, axisZ, scales);
if (actor.areaEmitter.loopSound != nullptr)
Sounds::Move(actor.SceneFrame(), actor.areaEmitter.loopSound, actor.position);
}
void Drop(Actor &actor) {
Stop(actor);
for (const auto &shard : actor.areaShards)
FreeShard(*shard);
actor.areaShards.clear();
}
void Silence(Actor &actor) { StopLoop(actor.areaEmitter, 0.0f); }
} // namespace Model::Scene::Areas
+261
View File
@@ -0,0 +1,261 @@
// 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 chain effects of a scene's casts: the bolts Chain Lightning draws from
// its caster to its target. The world makes them from a kit procedure of
// kind 0 or 12 (0x0060D7FC through the byte table 0x0060DC20 -> 0x006ECB90 ->
// 0x006ECBD0): the procedure's values give the SpellChainEffects row, the
// strand count (at most 3) and a persistent flag; each strand is a
// LightningObject whose hops each get a bolt of the client's lightning
// renderer (Lightning.cpp). Each frame the hop update (0x006EC460) shows a
// hop's bolt between its two ends while the hop lives, creating it with the
// row's shape the first time and moving it after, and destroys it at its
// end; the renderer then updates and draws every bolt (0x006ECA20,
// 0x006ECB20).
//
// A scene owns a renderer of its own and drives it through the engine's
// functions. A scene cast has one target, so a chain here has one hop, from
// the caster to the target, living the row's life (+0x14) from the cast; a
// script that casts again from each target draws a leaping chain. The ends
// are the world's: the caster's $CSL event point, else its position raised
// by 0.75 of its height (0x006EC6F0); the target's attachment that the
// spell visual's +0x24 names, else its position (0x006EC780). A hidden end
// hides the bolt, as a missing object does. The scene clock times the hops,
// so a paused scene holds them. A persistent chain (a channel's) is drawn
// for the row's life too, as a scene cast has no channel to end it.
#include "model/Scene.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "model/M2.h"
#include "texture/Load.h"
#include "time/Clock.h"
#include <cmath>
#include <cstring>
#include <memory>
#include <unordered_map>
#include <vector>
namespace Model::Scene::Chains {
namespace {
constexpr uint32_t kCSL = 0x4C534324; // $CSL, as 0x006EC718 pushes it
struct Hop {
Actor *from = nullptr;
Actor *to = nullptr;
uint32_t attachmentIndex = 0; // the spell visual's +0x24
uint32_t endMs = 0; // the scene clock at which it goes
const uint8_t *record = nullptr;
void *texture = nullptr; // the scene's reference to the row's texture
int handle = -1; // the renderer's bolt, or -1
};
struct State {
alignas(8) uint8_t renderer[Offsets::LIGHTNING_RENDERER_SIZE] = {};
std::vector<Hop> hops;
std::unordered_map<uint32_t, void *> textures; // by chain id, one reference each
};
std::unordered_map<void *, std::unique_ptr<State>> g_states;
State *Find(void *frame) {
const auto it = g_states.find(frame);
return it != g_states.end() ? it->second.get() : nullptr;
}
State &Make(void *frame) {
std::unique_ptr<State> &state = g_states[frame];
if (state == nullptr) {
state = std::make_unique<State>();
reinterpret_cast<void(__thiscall *)(void *)>(Offsets::FUN_LIGHTNING_INIT)(state->renderer);
}
return *state;
}
int CreateBolt(State &state, const float (&start)[3], const float (&end)[3], const uint8_t *record, void *texture) {
using Create_t = int(__thiscall *)(void *renderer, const float *start, const float *end, float segment,
float width, uint32_t argb, float jitter, float period, float extra,
void *texture, void *callback, void *argument);
const float life = static_cast<float>(Game::Read<int32_t>(record, Offsets::OFF_SPELLCHAINEFFECTS_LIFE)) *
Game::Read<float>(Offsets::VAR_ONE_THOUSANDTH);
return reinterpret_cast<Create_t>(Offsets::FUN_LIGHTNING_CREATE_BOLT)(
state.renderer, start, end, Game::Read<float>(record, Offsets::OFF_SPELLCHAINEFFECTS_SEGMENT),
Game::Read<float>(record, Offsets::OFF_SPELLCHAINEFFECTS_WIDTH), Offsets::CHAIN_BOLT_COLOR,
Game::Read<float>(record, Offsets::OFF_SPELLCHAINEFFECTS_JITTER),
Game::Read<float>(record, Offsets::OFF_SPELLCHAINEFFECTS_PERIOD), life, texture, nullptr, nullptr);
}
void ShowBolt(State &state, int handle, bool on) {
reinterpret_cast<void(__thiscall *)(void *, int, int)>(Offsets::FUN_LIGHTNING_SHOW_BOLT)(state.renderer, handle,
on ? 1 : 0);
}
void DestroyBolt(State &state, int handle) {
if (handle != -1)
reinterpret_cast<void(__thiscall *)(void *, int)>(Offsets::FUN_LIGHTNING_DESTROY_BOLT)(state.renderer,
handle);
}
// The caster's end (0x006EC6F0).
bool CasterEnd(const Actor &actor, float (&out)[3]) {
std::memcpy(out, actor.position, sizeof out);
void *model = actor.model;
if (model == nullptr || !M2::IsLoaded(model, 0, 0))
return true;
if (M2::HasEvent(model, kCSL)) {
M2::GetEventPosition(model, kCSL, out);
return true;
}
float m[16];
M2::GetBaseTransform(model, m);
float box[6];
M2::GetBoundingBox(model, box);
const float scale = std::sqrt(m[0] * m[0] + m[1] * m[1] + m[2] * m[2]);
const uint32_t bits = Offsets::CHAIN_CASTER_HEIGHT;
float share;
std::memcpy(&share, &bits, sizeof share);
out[2] += static_cast<float>(static_cast<double>(scale) * (box[5] - box[2]) * share);
return true;
}
// The target's end (0x006EC780): the attachment the visual names, else the
// position. The engine reads the table with no bound; past it, the position.
void TargetEnd(const Actor &actor, uint32_t index, float (&out)[3]) {
std::memcpy(out, actor.position, sizeof out);
void *model = actor.model;
if (model == nullptr || !M2::IsLoaded(model, 0, 0) || index >= Offsets::SPELL_VISUAL_ATTACHMENT_COUNT)
return;
const auto id = Game::Read<uint32_t>(static_cast<uintptr_t>(Offsets::VAR_SPELL_VISUAL_ATTACHMENTS) + index * 4);
if (M2::HasAttachment(model, id))
M2::GetAttachmentPosition(model, id, out);
}
void *ChainTexture(State &state, uint32_t chainID, const uint8_t *record) {
void *&texture = state.textures[chainID];
if (texture == nullptr) {
const char *path = Game::Read<const char *>(record, Offsets::OFF_SPELLCHAINEFFECTS_TEXTURE);
if (path != nullptr && *path != '\0')
texture = ::Texture::LoadByPath(path);
}
return texture;
}
} // namespace
void Start(Actor &caster, Actor &target, const uint8_t *visual, const uint8_t *kit) {
if (kit == nullptr || &target == &caster)
return;
for (int i = 0; i < 4; ++i) {
const auto kind = Game::Read<int32_t>(kit, Offsets::OFF_SPELLVISUALKIT_PROC_KIND + i * 4);
if (kind != Offsets::SPELLVISUALKIT_PROC_CHAIN && kind != Offsets::SPELLVISUALKIT_PROC_CHAIN_ALT)
continue;
const uint8_t chainID = DecodeKitByte(Game::Read<float>(kit, Offsets::OFF_SPELLVISUALKIT_PROC_VALUE + i * 4));
uint32_t strands = DecodeKitByte(Game::Read<float>(kit, Offsets::OFF_SPELLVISUALKIT_PROC_VALUE2 + i * 4));
const uint8_t *record =
DBC::Record(Offsets::VAR_SPELLCHAINEFFECTS_RECORDS, Offsets::VAR_SPELLCHAINEFFECTS_COUNT, chainID);
if (record == nullptr || strands == 0)
continue;
if (strands > Offsets::CHAIN_MAX_STRANDS)
strands = Offsets::CHAIN_MAX_STRANDS;
void *frame = caster.SceneFrame();
State &state = Make(frame);
void *texture = ChainTexture(state, chainID, record);
const uint32_t now = ClockMs(frame);
const auto life = static_cast<uint32_t>(Game::Read<int32_t>(record, Offsets::OFF_SPELLCHAINEFFECTS_LIFE));
for (uint32_t s = 0; s < strands; ++s) {
Hop hop;
hop.from = &caster;
hop.to = &target;
hop.attachmentIndex = Game::Read<uint32_t>(visual, Offsets::OFF_SPELLVISUAL_MISSILE_ATTACHMENT);
hop.endMs = now + life;
hop.record = record;
hop.texture = texture;
state.hops.push_back(hop);
}
}
}
// The hop update (0x006EC460) for each hop, then the renderer's update.
void Update(void *frame, float sceneSec) {
State *state = Find(frame);
if (state == nullptr)
return;
const uint32_t now = ClockMs(frame);
for (size_t i = 0; i < state->hops.size();) {
Hop &hop = state->hops[i];
if (Time::Clock::Reached(now, hop.endMs)) {
DestroyBolt(*state, hop.handle);
state->hops.erase(state->hops.begin() + static_cast<ptrdiff_t>(i));
continue;
}
if (!hop.from->shown || !hop.to->shown) {
if (hop.handle != -1)
ShowBolt(*state, hop.handle, false);
++i;
continue;
}
float start[3];
float end[3];
CasterEnd(*hop.from, start);
TargetEnd(*hop.to, hop.attachmentIndex, end);
if (hop.handle == -1)
hop.handle = CreateBolt(*state, start, end, hop.record, hop.texture);
else
reinterpret_cast<void(__thiscall *)(void *, int, const float *, const float *)>(
Offsets::FUN_LIGHTNING_MOVE_BOLT)(state->renderer, hop.handle, start, end);
ShowBolt(*state, hop.handle, true);
++i;
}
reinterpret_cast<void(__thiscall *)(void *, float)>(Offsets::FUN_LIGHTNING_UPDATE)(state->renderer, sceneSec);
}
void Render(void *frame, const float (&eye)[3]) {
State *state = Find(frame);
if (state == nullptr || state->hops.empty())
return;
reinterpret_cast<void(__thiscall *)(void *, const float *)>(Offsets::FUN_LIGHTNING_RENDER)(state->renderer, eye);
}
void Forget(Actor &actor) {
State *state = Find(actor.SceneFrame());
if (state == nullptr)
return;
for (size_t i = 0; i < state->hops.size();) {
Hop &hop = state->hops[i];
if (hop.from != &actor && hop.to != &actor) {
++i;
continue;
}
DestroyBolt(*state, hop.handle);
state->hops.erase(state->hops.begin() + static_cast<ptrdiff_t>(i));
}
}
void Destroy(void *frame) {
const auto it = g_states.find(frame);
if (it == g_states.end())
return;
State &state = *it->second;
reinterpret_cast<void(__thiscall *)(void *)>(Offsets::FUN_LIGHTNING_DESTROY)(state.renderer);
for (const auto &[id, texture] : state.textures)
if (texture != nullptr)
::Texture::ReleaseHandle(texture);
g_states.erase(it);
}
} // namespace Model::Scene::Chains
+432
View File
@@ -0,0 +1,432 @@
// 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 client mover's contact sweep, function by function (see SceneContact.h).
//
// Precision: the client runs these on the x87 at 53 bits and stores floats,
// so each sum here is a double and each store a float cast, in the operand
// order of the disassembly (benilla-re systems/collision/contact.md has the
// instruction-level notes; its crates/collision/src/resolve_clip.rs is the
// difftested transcription this follows). Where that transcription and the
// binary differ, the binary wins: 0x00632BA0 starts the best distance and
// each face's cap at the length asked for, not at the 1/36 floor
// (0x00632BC3, 0x00632E10); the degenerate-edge test of the prisms returns
// only below 2^-20 (0x006325B5, 0x006330C6 `jnp`); 0x006329E0 divides at the
// parallel bound; and the quad prism tests its side against the vertex
// before the edge with the sum (z, x, y) (0x006330EA, 0x00633126).
#include "model/SceneContact.h"
#include "Game.h"
#include "Offsets.h"
#include "model/ScenePrecision.h"
#include <algorithm>
#include <cfloat>
#include <cstddef>
#include <cmath>
#include <cstring>
#include <xmmintrin.h>
namespace Model::Scene::Contact {
namespace {
using Game::Live;
using Precision::Bits;
using Precision::D;
const Live<float> kSkin{Offsets::VAR_MOVER_SKIN}; // 1/720
const Live<float> kAllBehind{Offsets::VAR_MOVER_ALL_BEHIND}; // -2^-20
const Live<float> kDegenerate{Offsets::VAR_TWO_POW_MINUS_20};
const Live<float> kThirtySixth{Offsets::VAR_ONE_THIRTY_SIXTH};
const Live<float> kNegThirtySixth{Offsets::VAR_MINUS_ONE_THIRTY_SIXTH};
const Live<float> kFacing{Offsets::VAR_MOVER_MINUS_FACING_EPSILON}; // -1e-5
const float kGrowth = Bits(0x3E2AAAAB); // 1/6, the gather's growth
const Live<float> kBevelOut{Offsets::VAR_MOVER_BEVEL_OUT}; // sin 61.6°
const Live<float> kBevelDown{Offsets::VAR_MOVER_BEVEL_DOWN}; // cos 61.6°
const Live<float> kWaist{Offsets::VAR_MOVER_WAIST}; // tan 61.6°
constexpr float kFltMax = FLT_MAX;
const float kTwelfth = Bits(0x3DAAAAAB); // 1/12, the point-over-facet margin
const Live<float> kThreeQuarters{Offsets::VAR_THREE_QUARTERS}; // the swimmer's height
// The volume's faces as vertex indices (0x00631BE0's store block): the five
// box faces as quads, in plane order, then the four bevels as triangles.
constexpr uint8_t kFaces[32] = {1, 2, 6, 5, 3, 4, 8, 7, 2, 3, 7, 6, 4, 1, 5, 8,
5, 6, 7, 8, 0, 1, 2, 0, 3, 4, 0, 2, 3, 0, 4, 1};
// A clip polygon (0xF4 bytes): up to 15 vertices, the plane each one came
// from, and the count.
struct Polygon {
float v[15][3];
int32_t tag[15];
uint32_t count;
};
static_assert(sizeof(Polygon) == 0xF4 && offsetof(Polygon, tag) == 0xB4 && offsetof(Polygon, count) == 0xF0,
"the client's clip polygon");
static_assert(sizeof(Plane) == 0x10, "the client's plane");
struct Volume {
Plane planes[9];
float v[9][3];
};
// ClipPolygonByPlane: keeps the inside of a plane, with a 1/720 band,
// tagging the vertices it adds.
void ClipByPlane(Polygon &poly, const Plane &pl, int32_t tag) {
using Fn = void(__fastcall *)(const Plane *, Polygon *, int32_t);
reinterpret_cast<Fn>(Offsets::FUN_CLIP_POLYGON_BY_PLANE)(&pl, &poly, tag);
}
// ClipPolygonByPlanes: by each plane in turn, false once empty.
bool ClipByPlanes(Polygon &poly, const Plane *planes, int count) {
using Fn = int(__fastcall *)(const Plane *, uint32_t, Polygon *);
return reinterpret_cast<Fn>(Offsets::FUN_CLIP_POLYGON_BY_PLANES)(planes, static_cast<uint32_t>(count), &poly) != 0;
}
// ClipPolygonByPlanesExcept: by every volume plane but one.
bool ClipByPlanesExcept(Polygon &poly, const Plane (&planes)[9], int skip) {
using Fn = int(__fastcall *)(const Plane *, uint32_t, uint32_t, Polygon *);
return reinterpret_cast<Fn>(Offsets::FUN_CLIP_POLYGON_BY_PLANES_EXCEPT)(planes, 9, static_cast<uint32_t>(skip),
&poly) != 0;
}
// PlaneFromTriangle: the normalized plane through three points, normal
// (p1 - p0) × (p2 - p0).
Plane PlaneFromPoints(const float (&p0)[3], const float (&p1)[3], const float (&p2)[3]) {
using Fn = void(__fastcall *)(Plane *, int, const float *, const float *, const float *);
Plane pl;
reinterpret_cast<Fn>(Offsets::FUN_PLANE_FROM_TRIANGLE)(&pl, 0, p0, p1, p2);
return pl;
}
// One edge plane of a face swept along `s` (0x00632460 / 0x00632F80): the
// plane through the edge a→b and a + s, turned so that `other`, a face
// vertex off the edge, is not on its positive side. False for an edge too
// short or parallel to `s`.
bool EdgePlane(const float (&a)[3], const float (&b)[3], const float (&other)[3], const float (&s)[3], bool quad,
Plane &out) {
float moved[3], u[3], w[3];
for (int k = 0; k < 3; ++k)
moved[k] = static_cast<float>(D(a[k]) + s[k]);
for (int k = 0; k < 3; ++k) {
u[k] = static_cast<float>(D(moved[k]) - a[k]);
w[k] = static_cast<float>(D(b[k]) - a[k]);
}
// w × u (0x0063256F: three `fsubp st(1),st`).
const float cz = static_cast<float>(D(u[1]) * w[0] - D(w[1]) * u[0]);
const float cy = static_cast<float>(D(w[2]) * u[0] - D(u[2]) * w[0]);
const float cx = static_cast<float>(D(u[2]) * w[1] - D(u[1]) * w[2]);
if (std::fabs((D(cy) * cy + D(cz) * cz) + D(cx) * cx) < kDegenerate)
return false;
out = PlaneFromPoints(a, b, moved);
float t[3];
for (int k = 0; k < 3; ++k)
t[k] = static_cast<float>(D(other[k]) - a[k]);
const double side = quad ? (D(t[2]) * out.n[2] + D(t[0]) * out.n[0]) + D(t[1]) * out.n[1]
: (D(t[2]) * out.n[2] + D(t[1]) * out.n[1]) + D(t[0]) * out.n[0];
if (side > 0.0) {
for (float &c : out.n)
c = -c;
out.d = -out.d;
}
return true;
}
// BuildTrianglePrism (0x00632460): the edge planes of a bevel swept along s.
bool TrianglePrism(const Volume &volume, const uint8_t *index, const float (&s)[3], Plane (&out)[4]) {
for (int i = 0; i < 3; ++i)
if (!EdgePlane(volume.v[index[i]], volume.v[index[(i + 1) % 3]], volume.v[index[(i + 2) % 3]], s, false,
out[i]))
return false;
return true;
}
// BuildQuadPrism (0x00632F80): the edge planes of a box face swept along s.
bool QuadPrism(const Volume &volume, const uint8_t *index, const float (&s)[3], Plane (&out)[4]) {
for (int i = 0; i < 4; ++i)
if (!EdgePlane(volume.v[index[i]], volume.v[index[(i + 1) & 3]], volume.v[index[(i + 3) & 3]], s, true,
out[i]))
return false;
return true;
}
// BuildVolumePlanes (0x00631440) and CMovement::BuildVolume (0x00631BE0):
// the nine planes and nine vertices of the volume at p.
Volume BuildVolume(const float (&p)[3], float radius, float height) {
const double x = p[0], y = p[1], z = p[2], r = radius, h = height;
const double a = kBevelOut, b = kBevelDown, zero = 0.0;
Volume v{};
const auto set = [&](int i, float nx, float ny, float nz, double d) {
v.planes[i] = Plane{{nx, ny, nz}, static_cast<float>(d)};
};
set(0, -1.0f, 0.0f, 0.0f, x - r);
set(1, 1.0f, 0.0f, 0.0f, -x - r);
set(2, 0.0f, 1.0f, 0.0f, -y - r);
set(3, 0.0f, -1.0f, 0.0f, y - r);
set(4, 0.0f, 0.0f, 1.0f, -z - h);
set(5, -kBevelOut, 0.0f, -kBevelDown, (x * a + z * b) + y * zero);
set(6, kBevelOut, 0.0f, -kBevelDown, (z * b - x * a) + y * zero);
set(7, 0.0f, kBevelOut, -kBevelDown, (x * zero + z * b) - y * a);
set(8, 0.0f, -kBevelOut, -kBevelDown, (x * zero + z * b) + y * a);
const float waist = static_cast<float>(z + r * kWaist);
const float top = static_cast<float>(z + h);
const float lowX = static_cast<float>(x - r), highX = static_cast<float>(x + r);
const float lowY = static_cast<float>(y - r), highY = static_cast<float>(y + r);
const float corners[4][2] = {{lowX, lowY}, {lowX, highY}, {highX, highY}, {highX, lowY}};
std::memcpy(v.v[0], p, sizeof v.v[0]);
for (int i = 0; i < 4; ++i) {
v.v[1 + i][0] = v.v[5 + i][0] = corners[i][0];
v.v[1 + i][1] = v.v[5 + i][1] = corners[i][1];
v.v[1 + i][2] = waist;
v.v[5 + i][2] = top;
}
return v;
}
// PlaneDistanceAlongRay: how far a plane moves along `dir` before it
// reaches q; n·q + d undivided when it runs parallel.
double DistanceAlongRay(const float (&q)[3], const Plane &pl, const float (&dir)[3]) {
using Fn = double(__fastcall *)(const float *, const Plane *, const float *);
return reinterpret_cast<Fn>(Offsets::FUN_PLANE_DISTANCE_ALONG_RAY)(q, &pl, dir);
}
// PolygonContactDistance (0x00632830): the travel until volume face k
// reaches the polygon, 0 when it already touches; nothing when the polygon
// lies wholly behind the face, past 1/36 of the volume's inside.
bool ContactDistance(Polygon &poly, const float (&dir)[3], const Plane (&planes)[9], int k, float &best) {
float m = best;
bool behind = true;
for (uint32_t i = 0; i < poly.count; ++i) {
const double t = DistanceAlongRay(poly.v[i], planes[k], dir);
if (t < m)
m = t > 0.0 ? static_cast<float>(t) : 0.0f;
if (t > kAllBehind)
behind = false;
}
if (behind) {
if (!ClipByPlanesExcept(poly, planes, k))
return false;
for (uint32_t i = 0; i < poly.count; ++i)
if (DistanceAlongRay(poly.v[i], planes[k], dir) > kNegThirtySixth)
behind = false;
if (behind)
return false;
}
if (!(m >= best)) {
best = m;
return true;
}
return false;
}
// FindNearestFacet (0x00632700): of the facets facing against the move, the
// one volume face k reaches first inside its swept prism.
bool NearestFacet(const std::vector<Facet> &list, const float (&dir)[3], const Plane *prism, int prismCount,
const Plane (&planes)[9], int k, float &best, uint32_t &hit) {
bool found = false;
for (uint32_t i = 0; i < list.size(); ++i) {
const Facet &f = list[i];
const double facing = (D(f.plane.n[2]) * dir[2] + D(f.plane.n[1]) * dir[1]) + D(f.plane.n[0]) * dir[0];
if (facing > kFacing)
continue;
Polygon poly;
std::memcpy(poly.v, f.v, sizeof f.v);
poly.tag[0] = poly.tag[1] = poly.tag[2] = -1;
poly.count = 3;
if (!ClipByPlanes(poly, prism, prismCount))
continue;
float t = kFltMax;
if (!ContactDistance(poly, dir, planes, k, t))
continue;
if (t <= best) {
best = t;
hit = i;
found = true;
}
}
return found;
}
// The band rule of 0x00632280 and 0x00632BA0: a face nearer by more than
// 1/720 replaces the list, one within 1/720 joins it, and the nearest leads.
void Band(const Plane &face, float t, Contact &out, float &best) {
if (D(best) - kSkin > t) {
out.faces[0] = face;
out.count = 1;
} else if (D(best) + kSkin > t && out.count < 9) {
out.faces[out.count++] = face;
}
if (t < best) {
best = t;
if (out.count > 1)
std::swap(out.faces[out.count - 1], out.faces[0]);
}
}
// A leading face: its normal along the sweep, or NaN.
bool Leading(double dot) { return dot > 0.0 || std::isnan(dot); }
// CMovement::FindBevelContacts (0x00632280): the four bevels.
void Bevels(const std::vector<Facet> &list, const float (&dir)[3], float length, const float (&s)[3],
const Volume &volume, Contact &out, float &best) {
for (int b = 0; b < 4; ++b) {
const Plane &face = volume.planes[5 + b];
if (!Leading((D(face.n[2]) * s[2] + D(face.n[0]) * s[0]) + D(face.n[1]) * s[1]))
continue;
Plane prism[4];
if (!TrianglePrism(volume, kFaces + 20 + 3 * b, s, prism))
continue;
float t = length;
if (!NearestFacet(list, dir, prism, 3, volume.planes, 5 + b, t, out.hit))
continue;
Band(face, t, out, best);
}
}
bool InBox(const float (&box)[6], const float (&p)[3]) {
for (int i = 0; i < 3; ++i)
if (box[i] > p[i] || p[i] > box[i + 3])
return false;
return true;
}
// CMovement::GatherSweepFacets (0x00631E70): the facets around the volume
// moved by s, unless the cached box already holds it; a swimmer's liquid too.
// The box is the body's full height whichever list the sweep reads.
bool Gather(Facets &facets, bool swimmer, const float (&p)[3], float radius, float height, const float (&s)[3]) {
float e[3];
for (int k = 0; k < 3; ++k)
e[k] = static_cast<float>(D(p[k]) + s[k]);
float low[3] = {static_cast<float>(D(e[0]) - radius), static_cast<float>(D(e[1]) - radius), e[2]};
float high[3] = {static_cast<float>(D(e[0]) + radius), static_cast<float>(D(e[1]) + radius),
static_cast<float>(D(e[2]) + height)};
if (InBox(facets.box, low) && InBox(facets.box, high))
return true;
float query[6];
for (int k = 0; k < 3; ++k) {
const float grownLow = static_cast<float>(D(low[k]) - kGrowth);
const float grownHigh = static_cast<float>(D(high[k]) + kGrowth);
// UnionBoxes (0x006373B0): the cached box on ties and NaN.
query[k] = grownLow > facets.box[k] ? facets.box[k] : grownLow;
query[k + 3] = facets.box[k + 3] > grownHigh ? facets.box[k + 3] : grownHigh;
}
if (!facets.query(facets.context, query, facets.solid))
return false;
if (swimmer)
QueryLiquid(facets, query);
std::memcpy(facets.box, query, sizeof query);
return true;
}
} // namespace
void QueryLiquid(Facets &facets, const float (&box)[6]) {
facets.liquidQuery(facets.context, box, facets.liquid);
for (Facet &f : facets.liquid) {
for (float &c : f.plane.n)
c = -c;
f.plane.d = -f.plane.d;
}
}
Facet MakeFacet(const float (&v0)[3], const float (&v1)[3], const float (&v2)[3]) {
// FlushTriangleBatchFacets (0x00671CC0), its SSE path: the client sets
// s_useSse from the CPU's SSE bit (CMap::Initialize, 0x006920A7), so
// every machine that runs it today scales by rsqrtss, a 12-bit estimate.
Facet f;
std::memcpy(f.v[0], v0, sizeof f.v[0]);
std::memcpy(f.v[1], v1, sizeof f.v[1]);
std::memcpy(f.v[2], v2, sizeof f.v[2]);
const double ax = D(v2[0]) - v0[0], ay = D(v2[1]) - v0[1], az = D(v2[2]) - v0[2];
const float bx = static_cast<float>(D(v1[0]) - v0[0]);
const float by = static_cast<float>(D(v1[1]) - v0[1]);
const double bz = D(v1[2]) - v0[2];
const float nx = static_cast<float>(D(by) * az - bz * ay);
const float ny = static_cast<float>(bz * ax - az * bx);
const float nz = static_cast<float>(ay * bx - D(by) * ax);
const float length2 = static_cast<float>((D(nx) * nx + D(ny) * ny) + D(nz) * nz);
const float k = _mm_cvtss_f32(_mm_rsqrt_ss(_mm_set_ss(length2)));
const double x = D(k) * nx, y = D(k) * ny, z = D(k) * nz;
f.plane.n[0] = static_cast<float>(x);
f.plane.n[1] = static_cast<float>(y);
f.plane.n[2] = static_cast<float>(z);
f.plane.d = static_cast<float>(-((z * v0[2] + y * v0[1]) + x * v0[0]));
return f;
}
bool PointOverFacet(const Facet &facet, const float (&p)[3]) {
// 0x0063364A: the facet's prism along (0, 0, 1), its planes starting
// as (0, 0, 1, 0); 0x00633671 sums (x, z, y) and fails above 1/12.
const float up[3] = {0.0f, 0.0f, 1.0f};
Plane planes[3] = {{{0.0f, 0.0f, 1.0f}, 0.0f}, {{0.0f, 0.0f, 1.0f}, 0.0f}, {{0.0f, 0.0f, 1.0f}, 0.0f}};
for (int i = 0; i < 3; ++i)
if (!EdgePlane(facet.v[i], facet.v[(i + 1) % 3], facet.v[(i + 2) % 3], up, false, planes[i]))
break;
for (const Plane &pl : planes)
if (((D(pl.n[0]) * p[0] + D(pl.n[2]) * p[2]) + D(pl.n[1]) * p[1]) + pl.d > kTwelfth)
return false;
return true;
}
void BaseBox(const float (&p)[3], float radius, float height, float (&box)[6]) {
box[0] = static_cast<float>(D(p[0]) - radius);
box[1] = static_cast<float>(D(p[1]) - radius);
box[2] = p[2];
box[3] = static_cast<float>(D(p[0]) + radius);
box[4] = static_cast<float>(D(p[1]) + radius);
box[5] = static_cast<float>(D(p[2]) + height);
}
bool Sweep(Facets &facets, bool liquid, bool swimmer, const float (&p)[3], float radius, float height,
const float (&dir)[3], float length, Contact &out) {
const std::vector<Facet> &list = liquid ? facets.liquid : facets.solid;
out.count = 0;
if (std::fabs(length) < kDegenerate) {
out.distance = 0.0f;
out.hit = static_cast<uint32_t>(list.size());
return true;
}
// A swimmer meets the liquid with the lower three quarters of its body
// (0x00632C64), so it rests with its head above the surface.
const float volumeHeight = swimmer && liquid ? static_cast<float>(D(height) * kThreeQuarters) : height;
// At least 1/36 of sweep, but every distance is capped at the length.
const float d = length > kThirtySixth ? length : kThirtySixth;
const float s[3] = {static_cast<float>(D(d) * dir[0]), static_cast<float>(D(d) * dir[1]),
static_cast<float>(D(d) * dir[2])};
const Volume volume = BuildVolume(p, radius, volumeHeight);
if (!Gather(facets, swimmer, p, radius, height, s)) {
out.distance = 0.0f;
return false;
}
out.hit = static_cast<uint32_t>(list.size());
float best = length;
Bevels(list, dir, length, s, volume, out, best);
for (int a = 0; a < 5; ++a) {
const Plane &face = volume.planes[a];
if (!Leading((D(s[2]) * face.n[2] + D(s[1]) * face.n[1]) + D(s[0]) * face.n[0]))
continue;
Plane prism[4];
if (!QuadPrism(volume, kFaces + 4 * a, s, prism))
continue;
float t = length;
if (!NearestFacet(list, dir, prism, 4, volume.planes, a, t, out.hit))
continue;
Band(face, t, out, best);
}
out.distance = best < kSkin ? 0.0f : best;
return true;
}
} // namespace Model::Scene::Contact
+99
View File
@@ -0,0 +1,99 @@
// 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
// The client mover's contact geometry, transcribed for the scene's own
// bodies: the volume a unit occupies, the facets of the world around a move,
// and the first contact of the moving volume with them (0x00631440 through
// 0x00632F80). Each function is one client function, with its arithmetic in
// the client's order and precision (53-bit sums, float stores), so a body
// meets a face where the client's mover would. The client's pure geometry
// helpers (the polygon clips, the plane builder, the ray distance) are called,
// not copied; the functions that read the client's mover state (CMovement,
// the lists at 0x00C4E52C) are transcribed over the body's own, which come in
// through Facets::query.
#include <cstdint>
#include <vector>
namespace Model::Scene::Contact {
// A plane: a point q is inside when n·q + d <= 0.
struct Plane {
float n[3];
float d;
};
// A world facet, the 52-byte record of the facet emitter (0x00671CC0): its
// plane, then its three vertices.
struct Facet {
Plane plane;
float v[3][3];
};
static_assert(sizeof(Facet) == 0x34, "the client's facet record");
// The facet the emitter writes for three vertices: normal (v1 - v0) × (v2 -
// v0) normalized, d = -n·v0.
Facet MakeFacet(const float (&v0)[3], const float (&v1)[3], const float (&v2)[3]);
// IsPointOverListedTriangle (0x006335D0): whether p lies at most 1/12
// outside each edge of the facet, the edges extruded straight up, so above
// or below the facet in plan.
bool PointOverFacet(const Facet &facet, const float (&p)[3]);
// The facet lists a sweep reads and the box they cover: the solid list
// (0x00C4E52C), the liquid list a swimmer adds (0x00C4E564), and their cached
// box (0x00C4E5A0, min then max). `query` is the world query (0x006721B0)
// with the mover's class: it empties `solid` and fills it with the facets in
// a box, false when the query fails. `liquidQuery` is the same query with
// the swimmer's class 0x30000, water and ocean surfaces, into `liquid`.
struct Facets {
std::vector<Facet> solid;
std::vector<Facet> liquid;
float box[6];
bool (*query)(void *context, const float (&box)[6], std::vector<Facet> &out);
bool (*liquidQuery)(void *context, const float (&box)[6], std::vector<Facet> &out);
void *context;
};
// The swimmer's liquid query (0x00633E90, 0x006320E0): `liquidQuery` over the
// box, its return not read, then every liquid plane reversed (0x00637330
// and FCHS), so a surface faces down and meets a rising swimmer as a ceiling.
void QueryLiquid(Facets &facets, const float (&box)[6]);
// The volume's base box, as the substep's query frame (0x00633840) starts
// it: (p.x - r, p.y - r, p.z) to (p.x + r, p.y + r, p.z + H).
void BaseBox(const float (&p)[3], float radius, float height, float (&box)[6]);
// What a sweep met: the travel to the first contact, the facet found (an
// index into the list swept, its size when none), and the leading faces of
// the volume that touch within 1/720 of it.
struct Contact {
float distance;
uint32_t hit;
uint32_t count;
Plane faces[9];
};
// CMovement::SweepVolume (0x00632BA0): the volume of a body (a box of
// half-width `radius` up to `height`, over a square pyramid whose apex is
// `p`) swept along the unit vector `dir` for `length`, against the solid
// list or with `liquid` the liquid list. A `swimmer` (flag 0x200000) gathers
// the liquid list too, and its volume against that list is three quarters of
// `height` tall. The hover's branch is not taken: the scene's bodies do not
// hover. False when the facet query fails.
bool Sweep(Facets &facets, bool liquid, bool swimmer, const float (&p)[3], float radius, float height,
const float (&dir)[3], float length, Contact &out);
} // namespace Model::Scene::Contact

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