Compare commits

...
129 Commits
Author SHA1 Message Date
Brues 4ce271c7eb docs(model): document the scene clock and scene timers 2026-11-10 13:55:11 -06:00
Brues 7cc6f45790 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-10 13:53:40 -06:00
Brues dccf6050ca docs(model): document scene:GetActorsInRadius 2026-11-10 12:37:21 -06:00
Brues 33a8000579 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-10 12:37:20 -06:00
Brues 1aa60be6f6 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-10 12:27:43 -06:00
Brues c8f9c23fa4 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-10 12:27:43 -06:00
Brues 3b1a0f849e 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-10 11:20:05 -06:00
Brues 8d01e5e30a 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-10 11:11:30 -06:00
Brues 80de069b41 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-10 10:37:15 -06:00
Brues 493e80548e 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-10 02:05:07 -06:00
Brues be3c8a0a42 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-10 01:38:43 -06:00
Brues 7b9fc5f076 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-09 23:13:03 -06:00
Brues 11fb22f964 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-09 23:08:28 -06:00
Brues f9ac402e98 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-09 21:12:46 -06:00
Brues c0cc787925 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-09 20:57:51 -06:00
Brues 90061e73a4 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-09 20:17:07 -06:00
Brues 01367b9b26 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-09 19:38:20 -06:00
Brues 248249c089 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-09 16:49:40 -06:00
Brues dc0f929050 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-09 16:24:02 -06:00
Brues 25292427d6 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-09 16:10:57 -06:00
Brues 78b4ece8a6 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-09 15:46:35 -06:00
Brues c5785b8579 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-09 15:12:05 -06:00
Brues 7e7f6f0bf6 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-09 14:08:39 -06:00
Brues a6b5982b64 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-09 13:23:25 -06:00
Brues 398b773fc4 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-09 13:07:14 -06:00
Brues aed68f24b8 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-09 12:49:03 -06:00
Brues 8f08220346 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-09 11:51:32 -06:00
Brues 3bbead72c8 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-09 11:51:32 -06:00
Brues 316bb0dc95 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-09 11:51:32 -06:00
Brues 6a88c2ee37 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-09 11:51:32 -06:00
Brues 3616990a2f 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-09 11:51:32 -06:00
Brues c6e76306ab 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-09 11:51:32 -06:00
Brues 846aa44330 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-09 11:51:32 -06:00
Brues 683864246b 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-09 11:51:32 -06:00
Brues 8ff1549b83 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-09 11:51:32 -06:00
Brues e4c2da3be3 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-09 11:51:32 -06:00
Brues 1148be8cdc 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-09 11:51:31 -06:00
Brues 4294a2bbfa 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-09 11:51:31 -06:00
Brues 5bc3110818 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-09 11:51:31 -06:00
Brues 87e8d06ef6 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-09 11:51:31 -06:00
Brues 01b7bf3f12 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-09 11:51:31 -06:00
Brues 62ac523be3 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-09 11:51:31 -06:00
Brues 6f6beca9c7 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-09 11:51:31 -06:00
Brues ae03c22180 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-09 11:51:31 -06:00
Brues d68a7418dc 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-09 11:51:31 -06:00
Brues e309f6d32e 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-09 11:51:30 -06:00
Brues 14e43af371 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-09 11:51:30 -06:00
Brues 3cbef80a2b 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-09 11:51:30 -06:00
Brues 800d90d386 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-09 11:51:30 -06:00
Brues 81ffc12766 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-09 11:51:30 -06:00
Brues b2c7d63085 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-09 11:51:30 -06:00
Brues b48ffe42fb 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-09 11:51:30 -06:00
Brues 0e27295854 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-09 11:51:29 -06:00
Brues 708226a929 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-09 11:51:29 -06:00
Brues 230913519e 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-09 11:51:29 -06:00
Brues ce96c56c4a 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-09 11:51:29 -06:00
Brues e2ae028492 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-09 11:51:29 -06:00
Brues 1260ce3f64 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-09 11:51:28 -06:00
Brues 32c04f8126 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-09 11:51:28 -06:00
Brues 5fe9ecfbdb 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-09 11:51:28 -06:00
Brues 8a17b86478 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-09 11:51:28 -06:00
Brues e0207ddd09 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-09 11:51:28 -06:00
Brues 6b78320aa8 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-09 11:51:28 -06:00
Brues 1a84d01d4a 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-09 11:51:28 -06:00
Brues d22d10316b 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-09 11:51:27 -06:00
Brues f58cccc86f 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-09 11:51:27 -06:00
Brues b08d2fe813 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-09 11:51:27 -06:00
Brues f0f784bd70 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-09 11:51:27 -06:00
Brues 72fc61dc6b 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-09 11:51:27 -06:00
Brues e68f68ca47 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-09 11:51:27 -06:00
Brues 627c86f5ba 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-09 11:51:27 -06:00
Brues b644567cf9 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-09 11:51:27 -06:00
Brues 891ef4f1e6 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-09 11:51:27 -06:00
Brues 9142325a9e 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-09 11:51:26 -06:00
Brues 561c03cbe9 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-09 11:51:26 -06:00
Brues 0296267f0d 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-09 11:51:26 -06:00
Brues cb459acc28 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-09 11:51:26 -06:00
Brues b30735ecf9 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-09 11:51:26 -06:00
Brues 0e681855b7 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-09 11:51:26 -06:00
Brues 8bb6055e05 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-09 11:51:26 -06:00
Brues e7918018a2 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-09 11:51:26 -06:00
Brues 1e1989d230 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-09 11:51:26 -06:00
Brues be93a6f969 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-09 11:51:25 -06:00
Brues 640ebd0ab0 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-09 11:51:25 -06:00
Brues 4bb31eb98d 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-09 11:51:25 -06:00
Brues be21ff50f6 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-09 11:51:25 -06:00
Brues d7103de564 feat(spell): count set bonuses in GetSpellBonusHealing
Set-bonus spells are applied server-side as auras the client never sees
as buffs or known spells, so the derived healing sum missed them —
Lawbringer (3) is "Increase Healing 31" (9317), Judgement (3) is 57
(18037). Add a pass that counts the equipped members of each set from
the item record's set id (OFF_ITEMSTATS_ITEM_SET), reads ItemSet.dbc's
bonus spells and member thresholds, and runs every met bonus through
the same aura decoder — the rule the item tooltip's set-bonus lines use
at 0x52DFB9.

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

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

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

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

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

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

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

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

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

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

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

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

* docs(loot): describe the LootAllCorpses corpse skip

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes:

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

Every site is patched only when it holds the expected stock bytes, which
both the Octo and stock 1.12.1 exes do.
2026-11-04 16:44:44 -06:00
Brues c0b403ba05 feat(model): add PlayerModel:ApplySpellVisualKit
PlayerModel:ApplySpellVisualKit(spellVisualKitID [, oneShot]) shows a
SpellVisualKit's effects on the pane's model.

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

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

The effects are children of the pane's model, so SetUnit, SetItem or
a hide and show clears them.
2026-11-04 15:32:23 -06:00
Brues f6b98bc3ec fix(dbc): reject negative IDs in DBC::Record
DBC::Record checked the ID against the store's count as a signed int.
A DBC column uses -1 for "none", and -1 passed that check, so the
helper returned records[0xFFFFFFFF]. On 32-bit that wraps to
records[-1], the heap word in front of the index array. A caller that
passed an unchecked column read a garbage "record": an empty
SpellVisualKit effect column gave the pointer 0xF and crashed.

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

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

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

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

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

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

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

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

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

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

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

IsLoggedIn is now false on glue and during addon load, and true from
the PLAYER_LOGIN handlers on, through world transfers.
UnitGUID("player") keeps its char-select fallback.
2026-11-04 13:02:26 -06:00
Brues 4726fd7c85 feat(model): add DressUpModel try-on hand, enchant glow, sheathing and auto-dress
TryOn now takes retail's (item [, handSlotName, spellEnchantID]) and
returns an Enum.ItemTryOnReason. The pane gains IsGeoReady, SetSheathed /
GetSheathed and SetAutoDress / GetAutoDress.

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

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

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

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

DressUpItemLink now passes the whole link to DressUpItem, so a
Control-click shows the link's enchant, and skips a cached item that
IsDressableItem rejects. DressUpItem stays, since Blizzard_AuctionUI and
Turtle both replace it.
2026-11-04 10:16:07 -06:00
Brues 3e0c40184b feat(cursor): add expanded cursors for NPCs and the fishing bobber
Adds the expandedCursors cvar (default 0; AddOn Options > ClassicAPI >
Interface > Cursor). At 1 the hover cursor shows what an NPC offers: an
anvil for a repairer, a hearthstone for an innkeeper, a horseshoe for a
stable master, and a quest giver's ! or ? (a blue ? for a repeatable
reward). The player's fishing bobber shows a fish hook in place of the
Interact gear. Each has a gray form beyond reach or when not usable.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

* docs(api): document static creature portrait functions

* Update API.md

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

All are registered on both Lua states.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

C_Item.GetItemData's bagFamily now goes through BitmaskForRecord, so a
bag whose family field is empty reports the same family there as it does
from GetItemFamily (a Soul Pouch was 0 there, 0x4 here).
2026-11-02 21:03:01 -06:00
305 changed files with 30705 additions and 3454 deletions
+1
View File
@@ -10,3 +10,4 @@
*.png binary
*.jpg binary
*.ico binary
*.blp binary
+9 -7
View File
@@ -76,13 +76,14 @@ endif()
add_library(${PROJECT_NAME} SHARED
${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES})
# Embed the addons under `src/embed/AddOns/` and `src/bindings/Bindings.xml` as RCDATA
# resources. The file-read hook in `src/addons/Embedded.cpp` serves the
# addon files when the engine asks for paths under
# `Interface\AddOns\<addon>\`, so the addons load even when the user
# doesn't have them on disk. `src/bindings/Inject.cpp` splices the bindings
# XML (our TARGETING-group additions) into the engine's
# `Interface\FrameXML\Bindings.xml` at load time.
# Embed the addons under `src/embed/AddOns/`, `src/bindings/Bindings.xml` and
# the textures under `src/embed/Textures/` as RCDATA resources. The file-read
# hook in `src/addons/Embedded.cpp` serves the addon files when the engine
# asks for paths under `Interface\AddOns\<addon>\`, so the addons load even
# when the user doesn't have them on disk. `src/bindings/Inject.cpp` splices
# the bindings XML (our TARGETING-group additions) into the engine's
# `Interface\FrameXML\Bindings.xml` at load time. `src/texture/Builtin.cpp`
# serves each texture at its game path when no file has it.
include(cmake/embed_resources.cmake)
embed_resources(
TARGET ${PROJECT_NAME}
@@ -90,6 +91,7 @@ embed_resources(
ADDONS "!!!ClassicAPI" "ClassicAPI_DebugTools" "ClassicAPI_EventTrace"
ADDON_VERSION "${CLASSICAPI_TOC_VERSION}"
BINDINGS_XML "${CMAKE_CURRENT_SOURCE_DIR}/src/bindings/Bindings.xml"
TEXTURES_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/src/embed/Textures"
OUT_DIR "${CMAKE_BINARY_DIR}/generated")
target_compile_definitions(
+22 -14
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. |
| 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`. |
@@ -51,7 +52,7 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo`, `IsItemAction`, `HasBonusActionBar`, `C_ActionBar.EnableActionRangeCheck` |
| [Action](docs/API.md#action) | `GetActionInfo`, `IsItemAction`, `IsStackableAction`, `HasBonusActionBar`, `C_ActionBar.EnableActionRangeCheck`, `C_ActionBar.GetSpell`, `C_ActionBar.GetItemActionOnEquipSpellID`, `C_ActionBar.IsStackableAction`, `C_ActionBar.IsMacroActionWithShowTooltip`, `C_ActionBar.FindSpellActionButtons`, `C_ActionBar.HasSpellActionButtons`, `C_ActionBar.IsOnBarOrSpecialBar` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnInterfaceVersion`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded`, `C_AddOns.LoadAddOn` |
| [APIDocumentation](docs/API.md#apidocumentation) | `/classicapi` in-game browser, `C_APIDocumentation.GetSystem`, `C_APIDocumentation.GetSystems` |
| [Animation](docs/API.md#animation) | `region:CreateAnimationGroup`, `region:GetAnimationGroups`, `region:StopAnimating`, `SetMaxAnimFramerate`, `GetMaxAnimFramerate`, `AnimationGroup` and `Animation` / `Alpha` / `Translation` / `Scale` / `Rotation` / `Path` / `ControlPoint` methods |
@@ -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`, `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`, `: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` |
@@ -106,13 +109,15 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount`, `C_LossOfControl.GetSchoolLockout` |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `GetCallstackHeight`, `GetErrorCallstackHeight`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `SetErrorCallstackHeight`, `strcmputf8i`, `strconcat`, `string.concat`, `string.gmatch`, `string.gsub` (table replacement), `string.join`, `string.match`, `string.reverse`, `string.split`, `string.trim`, `strjoin`, `strlenutf8`, `strmatch`, `strreplace`, `strrev`, `strsplit`, `strsplittable`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args), `wipe`, `xpcall` (argument forwarding) |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `debuglocals`, `debugstack` (coroutine argument), `difftime`, `GetCallstackHeight`, `GetErrorCallstackHeight`, `gmatch`, `math.cosh`, `math.fmod`, `math.huge`, `math.modf`, `math.sinh`, `math.tanh`, `Mixin`, `newproxy`, `scrub`, `select`, `SetErrorCallstackHeight`, `strbyte` (range args), `strcmputf8i`, `strconcat`, `string.byte` (range args), `string.concat`, `string.format` (two-digit `%n$`), `string.gmatch`, `string.gsub` (table replacement, `%0`), `string.join`, `string.match`, `string.reverse`, `string.split`, `string.trim`, `strjoin`, `strlenutf8`, `strmatch`, `strreplace`, `strrev`, `strsplit`, `strsplittable`, `strtrim`, `table.count`, `table.maxn`, `table.removemulti`, `table.wipe`, `unpack` (range args), `wipe`, `xpcall` (argument forwarding) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `C_Macro.SetMacroDisplay`, `DeleteMacro` / `EditMacro` / `GetMacroInfo` / `PickupMacro` by name, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroBody`, `GetMacroIcons`, `GetMacroItem`, `GetMacroItemIcons`, `GetMacroSpell`, `GetRunningMacro`, `GetRunningMacroButton`, `RunMacroText`, `SetMacroItem`, `SetMacroSpell`, `StopMacro` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.CanSetUserWaypointOnMap`, `C_Map.ClearUserWaypoint`, `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetFallbackWorldMapID`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapArtLayers`, `C_Map.GetMapArtLayerTextures`, `C_Map.GetMapChildrenInfo`, `C_Map.GetMapInfo`, `C_Map.GetMapInfoAtPosition`, `C_Map.GetMapOverlays`, `C_Map.GetMapPosFromWorldPos`, `C_Map.GetMapRectOnMap`, `C_Map.GetMapWorldSize`, `C_Map.GetPlayerMapPosition`, `C_Map.GetUserWaypoint`, `C_Map.GetUserWaypointFromHyperlink`, `C_Map.GetUserWaypointHyperlink`, `C_Map.GetUserWaypointPositionForMap`, `C_Map.GetWorldPosFromMapPos`, `C_Map.HasUserWaypoint`, `C_Map.MapHasArt`, `C_Map.SetUserWaypoint` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredAreaIDsAtPosition`, `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems`, `GetBuybackItemLink` |
| [Model](docs/API.md#model) | `DressUpModel:TryOn` (links, recipes, names), `DressUpModel:UndressSlot`, `Model:SetGlow`, `PlayerModel:RefreshCamera`, `PlayerModel:SetCamDistanceScale`, `PlayerModel:SetCreature`, `PlayerModel:SetDisplayInfo`, `PlayerModel:SetPortraitZoom`, `PlayerModel:SetRotation` (`animate`) |
| [Model](docs/API.md#model) | `DressUpModel:TryOn` (links, recipes, names, hand, enchant glow, result), `DressUpModel:UndressSlot`, `DressUpModel:IsGeoReady`, `DressUpModel:SetSheathed` / `GetSheathed`, `DressUpModel:SetAutoDress` / `GetAutoDress`, `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` |
@@ -131,7 +136,7 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetDirectionFinished`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer`, `InteractUnit`, `C_PlayerInteractionManager.InteractUnit`, `IsTargetLoose` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
| [Texture](docs/API.md#texture) | `C_Texture.GetAtlasElementID`, `C_Texture.GetAtlasElements`, `C_Texture.GetAtlasExists`, `C_Texture.GetAtlasID`, `C_Texture.GetAtlasInfo`, `C_Texture.RegisterAtlas` |
| [Texture](docs/API.md#texture) | `C_Texture.GetAtlasElementID`, `C_Texture.GetAtlasElements`, `C_Texture.GetAtlasExists`, `C_Texture.GetAtlasID`, `C_Texture.GetAtlasInfo`, `C_Texture.RegisterAtlas`, `SetPortraitTextureFromCreatureDisplayID` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTickTime`, `GetTimeCached` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
@@ -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` |
@@ -211,20 +217,21 @@ for what each command does, and
Each setting is saved in `WTF\Config.wtf`. Read and write them with `GetCVar`
/ `SetCVar` or `/console`. Click a group name for the full rules. To change
these settings and the player cache in the game, open the game menu and
click AddOn Options, then ClassicAPI.
these settings, the player cache and the ClassicAPI key bindings in the game,
open the game menu and click AddOn Options, then ClassicAPI.
| Group | CVar | Default | What it does |
|-------|------|---------|--------------|
| [Bindings](docs/API.md#actionbuttonusekeydown) | `ActionButtonUseKeyDown` | `0` | `1` uses an action-bar key binding when you press the key. `0` uses it when you release the key. |
| [Combat](docs/API.md#stopautoattackontargetchange) | `stopAutoAttackOnTargetChange` | `0` | `1` stops melee auto-attack, Auto Shot and wand shots when you change target. |
| [Combat log](docs/API.md#kept-entries) | `combatLogRetentionTime` | `300` | The number of seconds that the combat log keeps an entry. |
| [Cast settings](docs/API.md#cast-settings-autostand-autodismount-autounshift) | `autoStand` | `0` | `1` makes you stand up when you cast while you sit. |
| | `autoDismount` | `0` | `1` makes you dismount when you cast while you are mounted. |
| | `autoUnshift` | `0` | `1` makes you leave your form when a spell cannot be cast in it. |
| [Stand, dismount and form](docs/API.md#stand-dismount-and-form-settings-autostand-autodismount-autounshift) | `autoStand` | `0` | `1` makes you stand up when you cast, loot or open the flight map while you sit. |
| | `autoDismount` | `0` | `1` makes you dismount when you cast, use an object, loot, attack or open the flight map while you are mounted. |
| | `autoUnshift` | `0` | `1` makes you leave your form when you cast a spell that cannot be cast in it, use an item, or open the flight map. It also lets you talk to NPCs and use objects in druid forms and Ghost Wolf. |
| [Bag sorting](docs/API.md#c_containergetsortbagsrighttoleft--c_containersetsortbagsrighttoleftenable) | `sortBagsRightToLeft` | `0` | `1` makes a sort fill your bags from the last slot of the last bag. |
| | `backpackAutosortDisabled` | `0` | `1` keeps the items in your backpack in place when you sort your bags. |
| | `bankAutosortDisabled` | `0` | `1` keeps the items in the main bank slots in place when you sort your bank. |
| [Expanded cursors](docs/API.md#expanded-cursors-expandedcursors) | `expandedCursors` | `0` | `1` makes the cursor show what you can do with an NPC or an object: an anvil for repairs, a hearthstone for an inn, a horseshoe for a stable, `!` or `?` for quests, and a fish hook for your fishing bobber. |
| [Equipment sets](docs/API.md#equipment-sets-on-item-tooltips) | `dontShowEquipmentSetsOnItems` | `0` | `1` removes the Equipment Sets line from item tooltips. |
| [Name completion](docs/API.md#name-completion-settings) | `autoCompleteResortNamesOnRecency` | `1` | `1` puts the players that you whispered first in `C_AutoComplete` results, the most recent first. `0` uses alphabetical order. |
| | `autoCompleteWhenEditingFromCenter` | `1` | `1` also matches the text after the cursor when you type inside a name. |
@@ -397,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>
@@ -504,10 +512,10 @@ the embedded addon.
## Lua errors
ClassicAPI replaces the game's Lua error box with a Lua error window. The
window opens when an error occurs. It shows the message, the time, the count
and the stack of each error. You can select and copy the text. The arrow
buttons show the previous and the next error. The **Reload UI** button
reloads the interface.
window opens when an error occurs. It shows the message, the time, the count,
the stack and the local variables of each error. You can select and copy the
text. The arrow buttons show the previous and the next error. The
**Reload UI** button reloads the interface.
If you use `!BugGrabber` (with BugSack), BugGrabber gets the errors and the
window does not open. If an addon sets its own error handler, the window also
+52 -12
View File
@@ -1,5 +1,5 @@
# embed_resources.cmake — embed the addons and the bindings XML as Win32
# RCDATA resources.
# embed_resources.cmake — embed the addons, the bindings XML and the
# built-in textures as Win32 RCDATA resources.
#
# Runs at configure time. It writes two files into OUT_DIR:
#
@@ -16,20 +16,27 @@
# constexpr File kFiles[] = { ... };
# constexpr size_t kFileCount = ...;
# }
# namespace Texture::Builtin::Manifest {
# struct File { const char *path; int resourceId; int index; };
# constexpr File kFiles[] = { ... };
# constexpr size_t kFileCount = ...;
# }
#
# Each resource is a pack: one gzip stream of several files, which
# pack_files.cmake joins at build time into `OUT_DIR/embed/<name>.pack.gz`.
# Resource 1 is the bindings XML, a pack of one; then each addon is one
# pack, in ADDONS order. A pack compresses each file against the files
# before it, so the addon text packs to about 22% of its size, against 27%
# for one gzip stream per file. The DLL inflates a whole pack with the
# engine's zlib on first use (src/embed/Resource.cpp), so the
# load-on-demand addon costs nothing until it loads.
# pack, in ADDONS order; then the textures, one pack. A pack compresses each
# file against the files before it, so the addon text packs to about 22% of
# its size, against 27% for one gzip stream per file. The DLL inflates a
# whole pack with the engine's zlib on first use (src/embed/Resource.cpp),
# so the load-on-demand addon costs nothing until it loads.
#
# File paths are relative to ADDONS_ROOT, in backslash form
# Addon file paths are relative to ADDONS_ROOT, in backslash form
# (`!!!ClassicAPI\Util\Print.lua`) — the suffix the engine builds after
# `Interface\AddOns\`. The DLL compares them ignoring case. A file's index
# is its place in its addon's pack, which is in path order.
# `Interface\AddOns\`. Texture paths are relative to TEXTURES_ROOT, where
# each file sits at its game path (`Interface\Cursor\Quest.blp`), for
# src/texture/Builtin.cpp. The DLL compares both ignoring case. A file's
# index is its place in its pack, which is in path order.
#
# Each addon's `.toc` is the one file that is not embedded verbatim: its
# `## Version:` line is stamped with ADDON_VERSION into a copy under
@@ -46,7 +53,8 @@
#
# Usage:
# embed_resources(TARGET <t> ADDONS_ROOT <dir> ADDONS <folder names...>
# ADDON_VERSION <v> BINDINGS_XML <file> OUT_DIR <dir>)
# ADDON_VERSION <v> BINDINGS_XML <file>
# TEXTURES_ROOT <dir> OUT_DIR <dir>)
set(_EMBED_PACK_SCRIPT "${CMAKE_CURRENT_LIST_DIR}/pack_files.cmake")
@@ -81,7 +89,8 @@ function(_embed_write_if_changed path content)
endfunction()
function(embed_resources)
cmake_parse_arguments(E "" "TARGET;ADDONS_ROOT;ADDON_VERSION;BINDINGS_XML;OUT_DIR"
cmake_parse_arguments(E ""
"TARGET;ADDONS_ROOT;ADDON_VERSION;BINDINGS_XML;TEXTURES_ROOT;OUT_DIR"
"ADDONS" ${ARGN})
set(rc "// Generated by cmake/embed_resources.cmake - do not edit.\n\n")
@@ -125,6 +134,22 @@ function(embed_resources)
math(EXPR id "${id} + 1")
endforeach()
file(GLOB_RECURSE textures CONFIGURE_DEPENDS RELATIVE
"${E_TEXTURES_ROOT}" "${E_TEXTURES_ROOT}/*")
list(SORT textures) # deterministic indexes
set(texture_paths "")
set(texture_manifest "")
set(index 0)
foreach(rel ${textures})
list(APPEND texture_paths "${E_TEXTURES_ROOT}/${rel}")
string(REPLACE "/" "\\\\" rel_bs "${rel}")
string(APPEND texture_manifest " { \"${rel_bs}\", ${id}, ${index} },\n")
math(EXPR index "${index} + 1")
endforeach()
_embed_pack("${E_OUT_DIR}" "Textures" ${texture_paths})
string(APPEND rc "${id} RCDATA \"${gz}\"\n")
list(APPEND inputs "${gz}")
_embed_write_if_changed("${E_OUT_DIR}/embedded.rc" "${rc}")
_embed_write_if_changed("${E_OUT_DIR}/embedded_resources.h"
"// Generated by cmake/embed_resources.cmake — do not edit.
@@ -159,6 +184,21 @@ ${manifest}};
constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]);
} // namespace Addons::Embedded::Manifest
namespace Texture::Builtin::Manifest {
struct File {
const char *path; // the game path
int resourceId; // the textures' pack
int index; // its place in the pack
};
constexpr File kFiles[] = {
${texture_manifest}};
constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]);
} // namespace Texture::Builtin::Manifest
")
add_custom_target(${E_TARGET}_embed DEPENDS ${inputs})
+3299 -962
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -178,9 +178,9 @@ behaviour the comment describes (`push 0; mov edx, 1; mov ecx, L`) is
| `0x006F4450` | `lua_next` | fastcall | calls `luaH_next` at `0x006FA2A0` | ★ |
| `0x006F44E0` | `lua_concat` | fastcall | calls `luaV_concat` at `0x006F84D0` | ★ |
| `0x006F4560` | `lua_newuserdata` | fastcall | tag 7; calls `luaS_newudata` at `0x006F9E30` | ★ |
| `0x006F45B0` | `lua_getupvalue` | fastcall | reads upvalue from C closure | ◦ |
| `0x006F4660` | `lua_setupvalue` | fastcall | | ◦ |
| `0x006F46E0` | *internal upvalue helper*| | shared by get/set above | ◦ |
| `0x006F45B0` | `lua_pushupvalues` | fastcall | pushes the running C closure's upvalues, returns how many | ★ |
| `0x006F4660` | `lua_getupvalue` | fastcall | `(L, funcindex, n)`: pushes upvalue n, returns its name or null | ★ |
| `0x006F46E0` | `aux_upvalue` | | finds upvalue n of a function, returns its name | ★ |
## Auxiliary library (`luaL_*`)
+27
View File
@@ -64,6 +64,17 @@ bool RedirectJcc(uintptr_t site, uintptr_t expected, const void *target) {
return PatchBytes(reinterpret_cast<uint8_t *>(site) + 2, &rel, sizeof rel);
}
bool RedirectSlot(uintptr_t slot, uintptr_t expected, const void *target) {
uintptr_t current = 0;
std::memcpy(&current, reinterpret_cast<const void *>(slot), sizeof current);
const uintptr_t to = reinterpret_cast<uintptr_t>(target);
if (current == to)
return true;
if (current != expected)
return false;
return PatchBytes(reinterpret_cast<void *>(slot), &to, sizeof to);
}
bool ReplaceWithCall(uintptr_t site, const uint8_t *original, size_t len, const void *target) {
uint8_t code[64];
if (len < 5 || len > sizeof code)
@@ -102,4 +113,20 @@ void EnsureParentDirs(const char *relPath) {
}
}
char FoldPathChar(char c) {
if (c == '/')
return '\\';
if (c >= 'A' && c <= 'Z')
return static_cast<char>(c + ('a' - 'A'));
return c;
}
bool SamePath(const char *a, const char *b) {
for (; FoldPathChar(*a) == FoldPathChar(*b); ++a, ++b) {
if (*a == '\0')
return true;
}
return false;
}
} // namespace Common
+11
View File
@@ -28,6 +28,10 @@ bool RedirectCall(uintptr_t site, uintptr_t expected, const void *target);
// at `site` to `target` when it still reaches `expected`. Same contract.
bool RedirectJcc(uintptr_t site, uintptr_t expected, const void *target);
// RedirectCall for a function-pointer slot (a vtable entry): points the slot
// at `slot` to `target` when it still holds `expected`. Same contract.
bool RedirectSlot(uintptr_t slot, uintptr_t expected, const void *target);
// Writes `call target` plus NOP padding over the `len` bytes (5 to 64) of
// code at `site` when they still hold `original`; true when the site
// now calls `target`. A site holding anything else (another DLL's patch, a
@@ -45,4 +49,11 @@ void BoundedCopy(char *dst, const char *src, size_t n);
// directory. Directories that already exist are a no-op.
void EnsureParentDirs(const char *relPath);
// `c` as game paths compare it: '/' as '\', and A-Z as a-z.
char FoldPathChar(char c);
// Whether `a` and `b` are the same game path: equal after FoldPathChar, so
// any case and either slash.
bool SamePath(const char *a, const char *b);
} // namespace Common
+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();
+34 -16
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) {
@@ -73,12 +81,14 @@ namespace Lua {
F(GetTable, lua_gettable, LUA_GET_TABLE) \
F(RawGet, lua_rawget, LUA_RAW_GET) \
F(RawGetI, lua_rawgeti, LUA_RAWGETI) \
F(RawSetI, lua_rawseti, LUA_RAW_SET_I) \
F(SetTable, lua_settable, LUA_SET_TABLE) \
F(RawSet, lua_rawset, LUA_RAW_SET) \
F(GetMetatable,lua_getmetatable,LUA_GET_METATABLE)\
F(SetMetatable,lua_setmetatable,LUA_SET_METATABLE)\
F(Insert, lua_insert, LUA_INSERT) \
F(Remove, lua_remove, LUA_REMOVE) \
F(Replace, lua_replace, LUA_REPLACE) \
F(GetTop, lua_gettop, LUA_GET_TOP) \
F(SetTop, lua_settop, LUA_SET_TOP) \
F(Call, lua_call, LUA_CALL) \
@@ -98,6 +108,9 @@ namespace Lua {
F(Resume, lua_resume, LUA_RESUME) \
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
F(CheckLString,luaL_checklstring,LUAL_CHECK_LSTRING)\
F(CheckNumber, luaL_checknumber,LUAL_CHECK_NUMBER)\
F(OptNumber, luaL_optnumber, LUAL_OPT_NUMBER) \
F(SetN, luaL_setn, LUAL_SETN) \
F(Ref, luaL_ref, LUA_REF_REF) \
F(Unref, luaL_unref, LUA_REF_UNREF) \
@@ -247,6 +260,12 @@ void *ResolveEditBox(void *L, int idx, bool raiseError) {
void *ResolveModel(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_MODEL_LUA_TYPE_ID, raiseError);
}
void *ResolvePlayerModel(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_PLAYERMODEL_LUA_TYPE_ID, raiseError);
}
void *ResolveDressUpModel(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_DRESSUPMODEL_LUA_TYPE_ID, raiseError);
}
// Looks up `_G[name]`. If absent, creates a fresh table and binds it.
// Leaves the resulting table on top of the stack.
@@ -356,11 +375,11 @@ void RegisterGlueFunction(const char *name, CFunction func, const Doc::Function
Api::Documentation::RecordGlobal(name, doc);
}
void RegisterFrameMethods(void *context, const FrameMethodEntry *table, int count,
const Doc::Method *docs, int docCount) {
void RegisterFrameMethods(void *context, ArrayRef<FrameMethodEntry> methods,
Doc::MethodList docs) {
auto fn = reinterpret_cast<RegisterFrameMethods_t>(Offsets::FUN_REGISTER_FRAME_METHODS);
fn(table, count, context);
Api::Documentation::RecordFrameMethods(context, table, count, docs, docCount);
fn(methods.data, methods.count, context);
Api::Documentation::RecordFrameMethods(context, methods, docs);
}
// Registers `func` at `_G[tableName][methodName]`. If the namespace
@@ -426,17 +445,16 @@ static bool SetIntegerTable(const char *parent, const char *sub,
return true;
}
void RegisterIntegerEnum(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count,
void RegisterIntegerEnum(const char *parent, const char *sub, ArrayRef<EnumIntegerEntry> entries,
const char *docSystem) {
if (SetIntegerTable(parent, sub, entries, count))
Api::Documentation::RecordEnum(parent, sub, entries, count, docSystem);
if (SetIntegerTable(parent, sub, entries.data, entries.count))
Api::Documentation::RecordEnum(parent, sub, entries, docSystem);
}
void RegisterConstants(const char *sub, const EnumIntegerEntry *entries, int count,
void RegisterConstants(const char *sub, ArrayRef<EnumIntegerEntry> entries,
const char *docSystem) {
if (SetIntegerTable("Constants", sub, entries, count))
Api::Documentation::RecordConstants(sub, entries, count, docSystem);
if (SetIntegerTable("Constants", sub, entries.data, entries.count))
Api::Documentation::RecordConstants(sub, entries, docSystem);
}
void SetFieldNumber(void *L, const char *key, double value) {
+63 -18
View File
@@ -58,6 +58,18 @@ inline T &Ref(uintptr_t address) {
return *reinterpret_cast<T *>(address);
}
// An engine constant at a fixed VA, read each time it is used, so the value is
// the one the engine's own code reads: a runtime patch shows, and so does a
// global the engine's static initializers fill after this DLL loads. Converts
// to T, so it stands in a float expression as a float would.
//
// const Game::Live<float> kSkin{Offsets::VAR_MOVER_SKIN};
template <typename T>
struct Live {
uintptr_t address;
operator T() const { return Read<T>(address); }
};
// Calls `fn(object)` for each object of one of a frame's intrusive lists,
// OFF_FRAME_REGION_LIST or OFF_FRAME_CHILD_LIST: node+4 next, node+8 object,
// a NULL or low-bit-1 link ends it - the walk the engine's own frame code
@@ -89,8 +101,10 @@ void *ResolveUnitToken(const char *token);
// UI pixels <-> internal layout (anchor) units. Script_SetPoint converts its
// pixel offsets with `px * [VAR_COORD_DDC_WIDTH_SCALE] / ([VAR_COORD_ASPECT_COMPENSATION]
// * UI_COORD_SCALE_UNIT)`; Script_GetLeft and Script_GetStringWidth push the
// inverse. A region's LOCAL pixels must be multiplied by its effective scale
// * [FLOAT_SCRIPT_COORD_SCALE])`; every script coordinate push (GetLeft,
// GetStringWidth, GetFont's height, GetCursorPosition, OnCursorChanged) is the
// inverse, `DivideByScreenScaleX(GetScaledScreenAspect() * 1024 * value)`,
// which InternalToPixel calls in that order. A region's LOCAL pixels must be multiplied by its effective scale
// (OFF_REGION_EFFECTIVE_SCALE) first. Both return 0 while the scale globals
// are unset, so an early caller gets a zero offset rather than a wild one.
float PixelToInternal(float px);
@@ -147,6 +161,18 @@ int StrCmpI(const char *a, const char *b, int n = 0x7FFFFFFF);
// literals + file-scope arrays): no heap, no dynamic init.
// - Summaries are user-facing text: one sentence, plain words, present
// tense, no patch versions.
// A view over a file-scope `const T[]` that takes its count from the array's
// type, so no call site passes one; `{}` means none.
template <typename T>
struct ArrayRef {
const T *data = nullptr;
int count = 0;
constexpr ArrayRef() = default;
template <size_t N>
constexpr ArrayRef(const T (&a)[N]) : data(a), count(static_cast<int>(N)) {}
};
namespace Doc {
struct Field {
@@ -172,14 +198,7 @@ constexpr Field Vararg(const char *type, const char *doc = nullptr) {
return {"...", type, true, nullptr, doc};
}
// A view over a file-scope `const Field[]`; `{}` means none.
struct FieldList {
const Field *data = nullptr;
int count = 0;
constexpr FieldList() = default;
template <size_t N>
constexpr FieldList(const Field (&a)[N]) : data(a), count(static_cast<int>(N)) {}
};
using FieldList = ArrayRef<Field>;
// A registered function. Positional init: `{summary, args, rets}` for a
// table function, `{summary, args, rets, "XGlobals"}` for a global, and
@@ -200,6 +219,7 @@ struct Method {
const char *name;
const Function *doc;
};
using MethodList = ArrayRef<Method>;
// A table shape a function returns or accepts → `Tables{Type="Structure"}`
// in `system`. Self-registering: nothing binds a structure to Lua, so it
@@ -240,7 +260,9 @@ constexpr int UpvalueIndex(int i) { return GLOBALS_INDEX - i; }
// `lua_call` / `lua_pcall` nresults sentinel meaning "all".
constexpr int MULTRET = -1;
// Type tag values returned by `Type()` (lua_type).
// Type tag values returned by `Type()` (lua_type). TYPE_NONE is an absent
// argument (past the top of the stack).
constexpr int TYPE_NONE = -1;
constexpr int TYPE_NIL = 0;
constexpr int TYPE_BOOLEAN = 1;
constexpr int TYPE_LIGHTUSERDATA = 2;
@@ -270,6 +292,8 @@ using lua_rawget_t = void(__fastcall *)(void *L, int idx);
// Pushes t[n] for the table at idx, raw (no metamethods). With idx =
// REGISTRY_INDEX it pushes a value stored under an integer ref.
using lua_rawgeti_t = void(__fastcall *)(void *L, int idx, int n);
// Pops the top value into t[n] for the table at idx, raw.
using lua_rawseti_t = void(__fastcall *)(void *L, int idx, int n);
using lua_settable_t = void(__fastcall *)(void *L, int idx);
using lua_rawset_t = void(__fastcall *)(void *L, int idx);
// Pushes the metatable of the value at idx and returns 1, or pushes
@@ -280,6 +304,8 @@ using lua_getmetatable_t = int(__fastcall *)(void *L, int idx);
using lua_setmetatable_t = int(__fastcall *)(void *L, int idx);
using lua_insert_t = void(__fastcall *)(void *L, int idx);
using lua_remove_t = void(__fastcall *)(void *L, int idx);
// Pops the top value into slot idx.
using lua_replace_t = void(__fastcall *)(void *L, int idx);
using lua_gettop_t = int(__fastcall *)(void *L);
using lua_settop_t = void(__fastcall *)(void *L, int idx);
using lua_call_t = void(__fastcall *)(void *L, int nargs, int nresults);
@@ -313,6 +339,11 @@ using lua_newthread_t = void *(__fastcall *)(void *L);
using lua_resume_t = int(__fastcall *)(void *L, int nargs);
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
// The auxlib argument readers: each raises the engine's own "bad argument"
// error. CheckLString's `len` may be null.
using luaL_checklstring_t = const char *(__fastcall *)(void *L, int narg, unsigned int *len);
using luaL_checknumber_t = double(__fastcall *)(void *L, int narg);
using luaL_optnumber_t = double(__fastcall *)(void *L, int narg, double def);
using luaL_setn_t = void(__fastcall *)(void *L, int t, int n);
// Pops the top value into the table at `t` under a fresh integer key and
// returns the key (LUA_REFNIL, -1, for a nil). With REGISTRY_INDEX it anchors
@@ -358,12 +389,14 @@ extern const lua_newtable_t NewTable;
extern const lua_gettable_t GetTable;
extern const lua_rawget_t RawGet;
extern const lua_rawgeti_t RawGetI;
extern const lua_rawseti_t RawSetI;
extern const lua_settable_t SetTable;
extern const lua_rawset_t RawSet;
extern const lua_getmetatable_t GetMetatable;
extern const lua_setmetatable_t SetMetatable;
extern const lua_insert_t Insert;
extern const lua_remove_t Remove;
extern const lua_replace_t Replace;
extern const lua_gettop_t GetTop;
extern const lua_settop_t SetTop;
extern const lua_call_t Call;
@@ -383,6 +416,9 @@ extern const lua_newthread_t NewThread;
extern const lua_resume_t Resume;
extern const lua_yield_t Yield;
extern const luaL_argerror_t ArgError;
extern const luaL_checklstring_t CheckLString;
extern const luaL_checknumber_t CheckNumber;
extern const luaL_optnumber_t OptNumber;
extern const luaL_setn_t SetN;
extern const luaL_ref_t Ref;
extern const luaL_unref_t Unref;
@@ -465,6 +501,8 @@ void *ResolveTexture(void *L, int idx = 1, bool raiseError = true);
void *ResolveFontString(void *L, int idx = 1, bool raiseError = true);
void *ResolveEditBox(void *L, int idx = 1, bool raiseError = true);
void *ResolveModel(void *L, int idx = 1, bool raiseError = true);
void *ResolvePlayerModel(void *L, int idx = 1, bool raiseError = true);
void *ResolveDressUpModel(void *L, int idx = 1, bool raiseError = true);
// Every name these registrars bind is ALSO bound under `_G.ClassicAPI` —
// `ClassicAPI.GetSpellInfo`, `ClassicAPI.C_Item.IsBound` — as an escape
@@ -509,10 +547,11 @@ struct FrameMethodEntry {
// Registers a batch of methods on a per-frame-type registry (e.g.
// GameTooltipMethodRegistry for `tooltip:Foo()` calls). `context` is the
// registry address — see Offsets::VAR_*_METHOD_REGISTRY. `docs` pairs
// descriptors to the entries by NAME (any order, any subset).
void RegisterFrameMethods(void *context, const FrameMethodEntry *table, int count,
const Doc::Method *docs = nullptr, int docCount = 0);
// registry address — see Offsets::VAR_*_METHOD_REGISTRY. `methods` and
// `docs` are file-scope arrays; `docs` pairs descriptors to the entries by
// NAME (any order, any subset).
void RegisterFrameMethods(void *context, ArrayRef<FrameMethodEntry> methods,
Doc::MethodList docs = {});
// Registers `func` at `_G[tableName][methodName]`, creating the namespace
// table if it doesn't already exist. This is how modern WoW C_*-style APIs
@@ -543,14 +582,14 @@ struct EnumIntegerEntry {
// `Enum.AddOnSecurityStatus = { Secure=0, Insecure=1, ... }` shapes.
// `docSystem` is the documentation System the enumeration belongs to
// ("SpellBook", "Item", …); the entries themselves are the documentation.
void RegisterIntegerEnum(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count,
// `entries` is a file-scope array.
void RegisterIntegerEnum(const char *parent, const char *sub, ArrayRef<EnumIntegerEntry> entries,
const char *docSystem = nullptr);
// Registers `Constants[sub] = { entries }`, Blizzard's engine constant
// tables (`Constants.MoneyFormattingConstants`). Documented as a "Constants"
// table of `docSystem`.
void RegisterConstants(const char *sub, const EnumIntegerEntry *entries, int count,
void RegisterConstants(const char *sub, ArrayRef<EnumIntegerEntry> entries,
const char *docSystem);
// Set `t[key] = value` on the table currently at stack[-1] (the most
@@ -763,6 +802,12 @@ bool RunHookRegistrations();
// issue #33, Frame::Attributes). A pure-C++ cache keyed by spellID/itemID is
// NOT fragile and must not register.
//
// It runs AFTER the frames are gone: CGGameUI::Shutdown destroys every frame
// (and each Model frame's scene with every model in it) before
// CGGameUI::Initialize resets the Lua state. So a cleanup here only FORGETS
// pointers; it never releases an engine object tied to a frame. Release those
// when the frame dies (Model::Teardown for Model frames).
//
// Same lifetime + ordering rules as ModuleAutoRegister: static-init chaining,
// undefined cross-TU order — fine, because each callback only clears its OWN
// state. This is the "clear stale state" category only; a save-before-teardown
+2147 -64
View File
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Where a spell sits on the bars.
//
// FindSpellActionButtons(spell [, set]) the 1-based slots holding the
// spell as a spell action, or nothing when none does.
// HasSpellActionButtons(spell [, set]) whether any slot does.
// IsOnBarOrSpecialBar(spell [, set]) the same, or the spell is on the
// shapeshift bar or the pet bar.
//
// A slot matches on the spell ID it stores, as 4.3.4's PutActionInSlot
// compares (IsSpell && GetSpell == spellId): a macro or an item that casts
// the spell is not the spell, and a rank matches only itself. The special
// bars are the engine's own lists — the shapeshift bar's spell array that
// GetShapeshiftFormInfo reads, and the pet bar's spell entries
// (GetPetActionInfo's types 1-5) while a pet bar is up.
//
// `set` is Enum.ActionBarSet, a mask whose Mkb bit is the one set of slots
// this client has; Gamepad alone finds no slot.
#include "Game.h"
#include "Offsets.h"
#include "spell/Arg.h"
#include <cstdint>
namespace Action::Find {
namespace {
constexpr int ACTION_BAR_SET_MKB = 1;
constexpr int ACTION_BAR_SET_GAMEPAD = 2;
constexpr int ACTION_BAR_SET_ALL = ACTION_BAR_SET_MKB | ACTION_BAR_SET_GAMEPAD;
// The spell at stack[1] and whether the set at stack[2] (default All)
// covers this client's slots. 0 when the spell does not resolve.
int Args(void *L, bool *searchSlots) {
const int set = Game::Lua::IsNumber(L, 2) ? static_cast<int>(Game::Lua::ToNumber(L, 2))
: ACTION_BAR_SET_ALL;
*searchSlots = (set & ACTION_BAR_SET_MKB) != 0;
return Spell::Arg::ResolveSpellID(L, 1);
}
bool SlotHolds(int slot0, int spellID) {
const uint32_t entry = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_ACTION_TABLE))[slot0];
return (entry & Offsets::ACTION_TYPE_MASK) == Offsets::ACTION_TYPE_SPELL &&
entry == static_cast<uint32_t>(spellID);
}
bool AnySlotHolds(int spellID) {
for (int slot0 = 0; slot0 < Offsets::ACTION_TABLE_MAX_SLOTS; ++slot0) {
if (SlotHolds(slot0, spellID))
return true;
}
return false;
}
bool OnShapeshiftBar(int spellID) {
const int count = Game::Read<int>(Offsets::VAR_SHAPESHIFT_BAR_SPELL_COUNT);
const auto *spells = Game::Read<const uint32_t *>(Offsets::VAR_SHAPESHIFT_BAR_SPELLS);
if (spells == nullptr)
return false;
for (int i = 0; i < count; ++i) {
if (spells[i] == static_cast<uint32_t>(spellID))
return true;
}
return false;
}
bool OnPetBar(int spellID) {
if (Game::Read<uint64_t>(Offsets::VAR_PET_GUID) == 0)
return false;
const auto *slots = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PET_ACTION_SLOTS));
for (int i = 0; i < Offsets::PET_ACTION_SLOT_COUNT; ++i) {
const uint32_t type = (slots[i] >> 24) & 0x3F;
if (type >= Offsets::PET_ACTION_TYPE_SPELL_FIRST &&
type <= Offsets::PET_ACTION_TYPE_SPELL_LAST &&
(slots[i] & 0xFFFF) == static_cast<uint32_t>(spellID))
return true;
}
return false;
}
int __fastcall Script_FindSpellActionButtons(void *L) {
bool searchSlots;
const int spellID = Args(L, &searchSlots);
if (spellID <= 0 || !searchSlots || !AnySlotHolds(spellID))
return 0;
Game::Lua::NewTable(L);
int index = 0;
for (int slot0 = 0; slot0 < Offsets::ACTION_TABLE_MAX_SLOTS; ++slot0) {
if (!SlotHolds(slot0, spellID))
continue;
Game::Lua::PushNumber(L, static_cast<double>(++index));
Game::Lua::PushNumber(L, static_cast<double>(slot0 + 1));
Game::Lua::RawSet(L, -3);
}
return 1;
}
int __fastcall Script_HasSpellActionButtons(void *L) {
bool searchSlots;
const int spellID = Args(L, &searchSlots);
Game::Lua::PushBool(L, spellID > 0 && searchSlots && AnySlotHolds(spellID));
return 1;
}
int __fastcall Script_IsOnBarOrSpecialBar(void *L) {
bool searchSlots;
const int spellID = Args(L, &searchSlots);
Game::Lua::PushBool(L, spellID > 0 && ((searchSlots && AnySlotHolds(spellID)) ||
OnShapeshiftBar(spellID) || OnPetBar(spellID)));
return 1;
}
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("spellID", "SpellIdentifier", "A spell ID, spell link, or spell name."),
Game::Doc::Opt("setToSearch", "ActionBarSet", "All"),
};
const Game::Doc::Field kFindRets[] = {
Game::Doc::Req("slots", "table", "The 1-based action slots; nothing when no slot "
"holds the spell."),
};
const Game::Doc::Function kFindSpellActionButtons{
"The action slots that hold the spell as a spell action.",
kArgs, kFindRets};
const Game::Doc::Field kHasRets[] = {
Game::Doc::Req("hasSpellActionButtons", "bool"),
};
const Game::Doc::Function kHasSpellActionButtons{
"Whether any action slot holds the spell as a spell action.",
kArgs, kHasRets};
const Game::Doc::Field kOnBarRets[] = {
Game::Doc::Req("isOnBarOrSpecialBar", "bool"),
};
const Game::Doc::Function kIsOnBarOrSpecialBar{
"Whether the spell is in an action slot, on the shapeshift bar, or on the pet bar.",
kArgs, kOnBarRets};
const Game::Lua::EnumIntegerEntry kActionBarSet[] = {
{"None", 0},
{"Mkb", ACTION_BAR_SET_MKB},
{"Gamepad", ACTION_BAR_SET_GAMEPAD},
{"All", ACTION_BAR_SET_ALL},
};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_ActionBar", "FindSpellActionButtons",
&Script_FindSpellActionButtons, &kFindSpellActionButtons);
Game::Lua::RegisterTableFunction("C_ActionBar", "HasSpellActionButtons",
&Script_HasSpellActionButtons, &kHasSpellActionButtons);
Game::Lua::RegisterTableFunction("C_ActionBar", "IsOnBarOrSpecialBar",
&Script_IsOnBarOrSpecialBar, &kIsOnBarOrSpecialBar);
Game::Lua::RegisterIntegerEnum("Enum", "ActionBarSet", kActionBarSet, "ActionBar");
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Action::Find
+61
View File
@@ -38,11 +38,24 @@
// through to an empty return. There is no bag-item tag: items are always
// by-itemID (0x80), and an earlier reading of the 0x40 "miss" as a bag item
// was wrong.
//
// `C_ActionBar.GetSpell(slot)` — the spell the slot stands for, 0 for none.
// It is the engine's own resolver (FUN_ACTION_SLOT_TO_SPELL), which every
// action reader (cooldown, usable, range, current) goes through: a spell
// action is its spell, a macro is its cached primary spell, and an item is
// its first on-use spell — retail's ActionButton.lua calls GetSpell for
// exactly that item case. The spellbook flag it also hands back is not part
// of the answer.
//
// `C_ActionBar.IsMacroActionWithShowTooltip(slot)` — the slot holds a macro
// whose body carries `#showtooltip`, by the parse `Macro::ShowTooltip` hands
// the tooltip over on. `#show` is not it.
#include "Game.h"
#include "Offsets.h"
#include "action/Slot.h"
#include "equipmentset/Data.h"
#include "macro/ShowTooltip.h"
#include <cstdint>
@@ -126,10 +139,58 @@ int __fastcall Script_GetActionInfo(void *L) {
return 0;
}
int __fastcall Script_GetSpell(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_ActionBar.GetSpell(actionID)");
return 0;
}
// The resolver bounds-checks the slot itself and answers 0 outside it.
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
uint32_t petFlag = 0;
const uint32_t spellID = reinterpret_cast<SlotToSpell_t>(
static_cast<uintptr_t>(Offsets::FUN_ACTION_SLOT_TO_SPELL))(
static_cast<uint32_t>(slot0), &petFlag);
Game::Lua::PushNumber(L, static_cast<double>(spellID));
return 1;
}
int __fastcall Script_IsMacroActionWithShowTooltip(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_ActionBar.IsMacroActionWithShowTooltip(actionID)");
return 0;
}
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
const uint32_t macroID = Action::Slot::MacroIDForSlot(slot0);
Game::Lua::PushBool(L, macroID != 0 && Macro::ShowTooltip::HasShowTooltipDirective(macroID));
return 1;
}
const Game::Doc::Field kGetSpellArgs[] = {
Game::Doc::Req("actionID", "luaIndex"),
};
const Game::Doc::Field kGetSpellRets[] = {
Game::Doc::Req("spellID", "number"),
};
const Game::Doc::Function kGetSpellDoc{
"The spell an action stands for: a spell's own ID, a macro's spell, or an item's "
"on-use spell; 0 when there is none.",
kGetSpellArgs, kGetSpellRets};
const Game::Doc::Field kIsMacroActionWithShowTooltipRets[] = {
Game::Doc::Req("isMacroActionWithShowTooltip", "bool"),
};
const Game::Doc::Function kIsMacroActionWithShowTooltipDoc{
"Whether the action is a macro with an explicit #showtooltip line.",
kGetSpellArgs, kIsMacroActionWithShowTooltipRets};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetActionInfo", &Script_GetActionInfo);
Game::Lua::RegisterTableFunction("C_ActionBar", "GetSpell", &Script_GetSpell, &kGetSpellDoc);
Game::Lua::RegisterTableFunction("C_ActionBar", "IsMacroActionWithShowTooltip",
&Script_IsMacroActionWithShowTooltip,
&kIsMacroActionWithShowTooltipDoc);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+148
View File
@@ -0,0 +1,148 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// The item behind an action slot, 4.3.4's CGActionBar::GetItem: an item-by-ID
// action's item, or the item a macro's `#showtooltip` / `#show` resolved to
// (1.12 keeps no item per macro, so `Macro::ShowTooltip` holds it). Both
// readers below peek the item cache and never query it, as 4.3.4 does.
//
// IsStackableAction the item's max stack is more than 1 —
// 4.3.4's IsStackableAucItem. The global
// answers 1/nil, the C_ActionBar form a bool.
// GetItemActionOnEquipSpellID the item's first on-equip spell (trigger 1),
// nil when it has none.
#include "Game.h"
#include "Offsets.h"
#include "item/Record.h"
#include "macro/ShowTooltip.h"
#include <cstdint>
namespace Action::SlotItem {
namespace {
uint32_t ItemForSlot(int slot0) {
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
return 0;
const uint32_t entry = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_ACTION_TABLE))[slot0];
if ((entry & Offsets::ACTION_TYPE_MASK) == Offsets::ACTION_TYPE_ITEM_BY_ID)
return entry & Offsets::ACTION_PAYLOAD_MASK_ITEM_BY_ID;
Macro::ShowTooltip::Info info;
if (Macro::ShowTooltip::ForSlot(slot0, &info) &&
info.target == Macro::ShowTooltip::Target::Item)
return static_cast<uint32_t>(info.itemID);
return 0;
}
// Reads the 1-based slot at stack[1]; false (after the usage error) when it
// is not a number.
bool SlotArg(void *L, const char *usage, int *slot0) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, usage);
return false;
}
*slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
return true;
}
bool IsStackable(int slot0) {
const uint8_t *record = ::Item::PeekRecord(ItemForSlot(slot0));
return record != nullptr && Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_STACKABLE) > 1;
}
int __fastcall Script_IsStackableAction(void *L) {
int slot0;
if (!SlotArg(L, "Usage: IsStackableAction(slot)", &slot0))
return 0;
if (IsStackable(slot0))
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
int __fastcall Script_C_IsStackableAction(void *L) {
int slot0;
if (!SlotArg(L, "Usage: C_ActionBar.IsStackableAction(actionID)", &slot0))
return 0;
Game::Lua::PushBool(L, IsStackable(slot0));
return 1;
}
int __fastcall Script_GetItemActionOnEquipSpellID(void *L) {
int slot0;
if (!SlotArg(L, "Usage: C_ActionBar.GetItemActionOnEquipSpellID(actionID)", &slot0))
return 0;
const uint8_t *record = ::Item::PeekRecord(ItemForSlot(slot0));
if (record == nullptr)
return 0;
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i) {
const uint32_t spellID = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_ID + i * 4);
const uint32_t trigger = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_TRIGGER + i * 4);
if (spellID != 0 && trigger == Offsets::ITEM_SPELLTRIGGER_ON_EQUIP) {
Game::Lua::PushNumber(L, static_cast<double>(spellID));
return 1;
}
}
return 0;
}
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kSlotArgs[] = {
Game::Doc::Req("slot", "number", "The action slot, 1 to 120."),
};
const Game::Doc::Field kIsStackableRets[] = {
Game::Doc::Opt("isStackable", "number", nullptr,
"1 if the slot's item stacks, else nil."),
};
const Game::Doc::Function kIsStackableAction{
"Returns 1 if an action slot holds an item that stacks.",
kSlotArgs, kIsStackableRets, "ActionBar"};
const Game::Doc::Field kActionIDArgs[] = {
Game::Doc::Req("actionID", "luaIndex"),
};
const Game::Doc::Field kCIsStackableRets[] = {
Game::Doc::Req("isStackableAction", "bool"),
};
const Game::Doc::Function kCIsStackableAction{
"Whether the action's item stacks.",
kActionIDArgs, kCIsStackableRets};
const Game::Doc::Field kOnEquipRets[] = {
Game::Doc::Opt("onEquipSpellID", "number", nullptr,
"Nil when the action has no item or its item has no on-equip spell."),
};
const Game::Doc::Function kGetItemActionOnEquipSpellID{
"The spell the action's item grants while equipped.",
kActionIDArgs, kOnEquipRets};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsStackableAction", &Script_IsStackableAction,
&kIsStackableAction);
Game::Lua::RegisterTableFunction("C_ActionBar", "IsStackableAction",
&Script_C_IsStackableAction, &kCIsStackableAction);
Game::Lua::RegisterTableFunction("C_ActionBar", "GetItemActionOnEquipSpellID",
&Script_GetItemActionOnEquipSpellID,
&kGetItemActionOnEquipSpellID);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Action::SlotItem
+11 -30
View File
@@ -65,6 +65,7 @@
#include "embedded_resources.h"
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "addons/Embedded.h"
@@ -76,6 +77,7 @@
#include "addons/TocRewrite.h"
#include "bindings/Inject.h"
#include "embed/Resource.h"
#include "storm/Memory.h"
#include <cstdint>
#include <cstdio>
@@ -103,26 +105,12 @@ constexpr const char *kDevMarkerFile = ".classicapi-dev";
// paths are case-insensitive.
constexpr const char *kAddOnsPrefix = "Interface\\AddOns\\";
char NormalizeChar(char c) {
if (c == '/') return '\\';
if (c >= 'A' && c <= 'Z') return static_cast<char>(c + 32);
return c;
}
bool PathEqualsCI(const char *a, const char *b) {
while (*a && *b) {
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
++a; ++b;
}
return *a == *b;
}
// `path` with the case-insensitive `prefix` stripped, NULL when it does
// not start with it.
const char *StripPrefixCI(const char *path, const char *prefix) {
while (*prefix) {
if (*path == '\0') return nullptr;
if (NormalizeChar(*path) != NormalizeChar(*prefix)) return nullptr;
if (Common::FoldPathChar(*path) != Common::FoldPathChar(*prefix)) return nullptr;
++path; ++prefix;
}
return path;
@@ -130,7 +118,7 @@ const char *StripPrefixCI(const char *path, const char *prefix) {
int IndexOfAddon(const char *name) {
for (size_t i = 0; i < kAddonCount; ++i) {
if (PathEqualsCI(name, kAddons[i]))
if (Common::SamePath(name, kAddons[i]))
return static_cast<int>(i);
}
return -1;
@@ -144,7 +132,7 @@ int MatchAddonPath(const char *path, const char **relPath) {
if (rel == nullptr) return -1;
for (size_t i = 0; i < kAddonCount; ++i) {
const char *rest = StripPrefixCI(rel, kAddons[i]);
if (rest != nullptr && NormalizeChar(*rest) == '\\') {
if (rest != nullptr && Common::FoldPathChar(*rest) == '\\') {
*relPath = rel;
return static_cast<int>(i);
}
@@ -155,12 +143,10 @@ int MatchAddonPath(const char *path, const char **relPath) {
// The embedded bytes for manifest path `relPath`; `data` is NULL when it
// is not embedded.
Embed::Bytes LookupEmbedded(const char *relPath) {
for (size_t i = 0; i < Manifest::kFileCount; ++i) {
if (PathEqualsCI(relPath, Manifest::kFiles[i].path))
return Embed::File(Manifest::kFiles[i].resourceId,
Manifest::kFiles[i].index);
}
return {nullptr, 0};
const Manifest::File *file = Embed::FindFile(Manifest::kFiles, relPath);
if (file == nullptr)
return {nullptr, 0};
return Embed::File(file->resourceId, file->index);
}
// Engine file I/O + Storm allocator — see addons/EngineIO.h for the shapes and
@@ -168,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).
@@ -292,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.
@@ -335,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
+1 -5
View File
@@ -408,11 +408,7 @@ static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnInterfaceVersion",
&Script_GetAddOnInterfaceVersion,
&kGetAddOnInterfaceVersion);
Game::Lua::RegisterIntegerEnum(
"Enum", "AddOnSecurityStatus",
kAddOnSecurityStatusEntries,
sizeof(kAddOnSecurityStatusEntries) /
sizeof(kAddOnSecurityStatusEntries[0]));
Game::Lua::RegisterIntegerEnum("Enum", "AddOnSecurityStatus", kAddOnSecurityStatusEntries);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+2 -3
View File
@@ -34,6 +34,7 @@
#include "addons/EngineIO.h"
#include "addons/Toc.h"
#include "luasyntax/AddonNamespace.h"
#include "storm/Memory.h"
#include <cstddef>
#include <cstdint>
@@ -51,7 +52,6 @@ using IsLoadedFn_t = uint8_t(__fastcall *)(const char *name);
// FUN_FILE_READ address routes through Embedded's file-read hook, which forwards
// every non-`!!!ClassicAPI` path to the original untouched — what we want here.
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemFreeFn;
// True iff the addon's `.toc` has a line `## AllowAddOnTableAccess: <nonzero>`.
// The value is read as a TOC boolean flag: parse the leading integer, true iff
@@ -87,8 +87,7 @@ bool TocAllowsTableAccess(const char *name) {
allowed = anyDigit && value != 0;
}
auto smemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
smemFree(buf, __FILE__, __LINE__, 0);
Storm::Free(buf, __FILE__, __LINE__);
return allowed;
}
+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;
}
+7 -19
View File
@@ -14,8 +14,10 @@
#include "addons/EngineIO.h"
#include "addons/TocRewrite.h"
#include "addons/Toc.h"
#include "localization/Locale.h"
#include "Offsets.h"
#include "storm/Memory.h"
#include "turtle/Detect.h"
#include <cstddef>
@@ -27,24 +29,12 @@ namespace AddOns::TocRewrite {
namespace {
using AddOns::EngineIO::SMemAllocFn;
using AddOns::EngineIO::SMemFreeFn;
using AddOns::Toc::EqCI;
using AddOns::Toc::Lower;
bool IsSpace(char c) { return c == ' ' || c == '\t'; }
// The client locale code ("enUS", "frFR", …) — the exact string
// GetLocale() returns (Offsets.h VAR_LOCALE_NAME_TABLE derivation).
const char *ClientLocale() {
uint32_t idx = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
if (idx > 7) idx = 0; // slot 8 overreads the string pool — clamp
const char *const *table = reinterpret_cast<const char *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_NAME_TABLE));
const char *s = table[idx];
return (s != nullptr) ? s : "enUS";
}
using Localization::Locale::Client;
// True iff the comma-separated `list` (spaces allowed) contains `token` (CI).
bool ListContains(const char *list, size_t listLen, const char *token) {
@@ -66,7 +56,7 @@ bool ListContains(const char *list, size_t listLen, const char *token) {
const char *VariableValue(const char *head, size_t headLen) {
if (EqCI(head, headLen, "Family")) return "Classic";
if (EqCI(head, headLen, "Game")) return "Vanilla";
if (EqCI(head, headLen, "TextLocale")) return ClientLocale();
if (EqCI(head, headLen, "TextLocale")) return Client();
return nullptr;
}
@@ -158,7 +148,7 @@ bool EvalCondition(const char *head, size_t headLen, const char *args,
if (EqCI(head, headLen, "ExcludeLoadGameType"))
return !ListContainsGameType(args, argsLen);
if (EqCI(head, headLen, "AllowLoadTextLocale"))
return ListContains(args, argsLen, ClientLocale());
return ListContains(args, argsLen, Client());
if (EqCI(head, headLen, "AllowLoad"))
return ListContains(args, argsLen, "game"); // addon files: in-game only
return false;
@@ -297,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;
}
+4 -5
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;
}
@@ -560,9 +560,8 @@ void RegisterLuaFunctions() {
RegisterClass(Anim::s_rotationClass);
RegisterClass(Anim::s_pathClass);
RegisterClass(ControlPoint::s_class);
Game::Lua::RegisterFrameMethods(reinterpret_cast<void *>(Offsets::VAR_REGION_METHOD_REGISTRY), kRegionMethods,
static_cast<int>(sizeof(kRegionMethods) / sizeof(kRegionMethods[0])),
kRegionDocs, static_cast<int>(sizeof(kRegionDocs) / sizeof(kRegionDocs[0])));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_REGION_METHOD_REGISTRY), kRegionMethods, kRegionDocs);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+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
+12 -7
View File
@@ -665,10 +665,15 @@ void RecordTable(const char *table, const char *name, const Function *doc) {
Record(KIND_TABLE, table, name, nullptr, doc);
}
void RecordFrameMethods(const void *registry, const Game::Lua::FrameMethodEntry *table,
int count, const Method *docs, int docCount) {
void RecordFrameMethods(const void *registry,
Game::ArrayRef<Game::Lua::FrameMethodEntry> methods,
Game::Doc::MethodList docList) {
if (g_pass == ENV_NONE)
return;
const Game::Lua::FrameMethodEntry *table = methods.data;
const int count = methods.count;
const Method *docs = docList.data;
const int docCount = docList.count;
for (int i = 0; i < count; ++i) {
const Function *doc = nullptr;
for (int d = 0; d < docCount; ++d) {
@@ -720,14 +725,14 @@ void RecordValueTable(Kind kind, const char *parent, const char *sub,
} // namespace
void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntegerEntry *entries,
int count, const char *system) {
RecordValueTable(KIND_ENUM, parent, sub, entries, count, system);
void RecordEnum(const char *parent, const char *sub,
Game::ArrayRef<Game::Lua::EnumIntegerEntry> entries, const char *system) {
RecordValueTable(KIND_ENUM, parent, sub, entries.data, entries.count, system);
}
void RecordConstants(const char *sub, const Game::Lua::EnumIntegerEntry *entries, int count,
void RecordConstants(const char *sub, Game::ArrayRef<Game::Lua::EnumIntegerEntry> entries,
const char *system) {
RecordValueTable(KIND_CONSTANTS, "Constants", sub, entries, count, system);
RecordValueTable(KIND_CONSTANTS, "Constants", sub, entries.data, entries.count, system);
}
} // namespace Api::Documentation
+6 -5
View File
@@ -32,14 +32,15 @@ void EndPass();
void RecordGlobal(const char *name, const Game::Doc::Function *doc);
void RecordTable(const char *table, const char *name, const Game::Doc::Function *doc);
void RecordFrameMethods(const void *registry, const Game::Lua::FrameMethodEntry *table,
int count, const Game::Doc::Method *docs, int docCount);
void RecordFrameMethods(const void *registry,
Game::ArrayRef<Game::Lua::FrameMethodEntry> methods,
Game::Doc::MethodList docs);
// Names the widget system of a method hash this DLL owns (a frame class it
// adds), alongside the engine registries the exporter already knows.
void NameWidgetSystem(const void *registry, const char *system);
void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntegerEntry *entries,
int count, const char *system);
void RecordConstants(const char *sub, const Game::Lua::EnumIntegerEntry *entries, int count,
void RecordEnum(const char *parent, const char *sub,
Game::ArrayRef<Game::Lua::EnumIntegerEntry> entries, const char *system);
void RecordConstants(const char *sub, Game::ArrayRef<Game::Lua::EnumIntegerEntry> entries,
const char *system);
} // namespace Api::Documentation
+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
+4 -6
View File
@@ -457,12 +457,10 @@ static void RegisterLuaFunctions() {
&Script_GetAutoCompleteResults, &kGetAutoCompleteResults);
Game::Lua::RegisterTableFunction("C_AutoComplete", "IsRecognizedName", &Script_IsRecognizedName,
&kIsRecognizedName);
Game::Lua::RegisterIntegerEnum("Enum", "AutoCompleteEntryFlag", kEntryFlagEntries,
sizeof(kEntryFlagEntries) / sizeof(kEntryFlagEntries[0]),
"AutoComplete");
Game::Lua::RegisterIntegerEnum("Enum", "AutoCompletePriority", kPriorityEntries,
sizeof(kPriorityEntries) / sizeof(kPriorityEntries[0]),
"AutoComplete");
Game::Lua::RegisterIntegerEnum(
"Enum", "AutoCompleteEntryFlag", kEntryFlagEntries, "AutoComplete");
Game::Lua::RegisterIntegerEnum(
"Enum", "AutoCompletePriority", kPriorityEntries, "AutoComplete");
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+221
View File
@@ -0,0 +1,221 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `debuglocals([level [, skipFunctionsAndUserdata]])` — the locals and then
// the upvalues of a call level as text, one `name=value` line each, for an
// error report. 4.3.4's Script_debuglocals and the FrameScript_PackVariable
// it formats with, in retail's spacing (probed: `n=7`, `s="hi"`,
// `t=<table>{`, ` k=1`, `}`; 4.3.4 writes `n = 7` and `t = <table> {`):
// - Only while an error handler runs; otherwise it returns nothing. 4.3.4
// tests a flag its error function sets around the handler call. 1.12's
// error function sets none, but while a handler runs that function's
// level is on the stack (ErrorCallstack::ErrorFunctionLevel).
// - `level` counts as lua_getstack and debugstack do: 1 is the caller.
// Less than 1 is 1.
// - A table prints as `name=<table>{`, or with the frame's name for a
// frame (t[0], as FrameScript_GetObject reads it), then its fields one
// space in at the top level only, then `}`. A function prints where it
// is defined. The text stops at 4095 bytes, each line at 1023.
// Retail's second argument, which its Blizzard_ScriptErrors passes as true,
// leaves out the function and userdata values, at the top level and in
// tables.
//
// A number key in int range prints truncated with %d, as 4.3.4's
// IsIntegerNumber tests only the range: 2.5 prints as 2. Two departures from
// 4.3.4, both where it would fail inside the handler: a number key outside
// that range is formatted from a copy, not converted in place (that breaks
// lua_next), and a key that is not a number or string prints as its type in
// angle brackets (4.3.4 formats a null string).
//
// Retail also lists `(*temporary)` lines: 5.1's lua_getlocal names each stack
// slot past a level's locals so, a C level's arguments included (an
// `error("x")` report shows `(*temporary)="x"`). 1.12's lua_getlocal
// (0x6FBB20) is 5.0's, which names declared locals only. Those slots are
// the compiler's registers, which 5.0 and 5.1 allocate differently, so they
// are left out rather than imitated.
//
// Registered on both Lua states, like the base-library helpers.
#include "Game.h"
#include "Offsets.h"
#include "baselib/ErrorCallstack.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
namespace BaseLib::DebugLocals {
namespace {
namespace Lua = Game::Lua;
using lua_getlocal_t = const char *(__fastcall *)(void *L, const void *ar, int n);
using lua_getupvalue_t = const char *(__fastcall *)(void *L, int funcindex, int n);
const auto GetLocal = reinterpret_cast<lua_getlocal_t>(Offsets::LUA_GET_LOCAL);
const auto GetUpvalue = reinterpret_cast<lua_getupvalue_t>(Offsets::LUA_GET_UPVALUE);
constexpr size_t kTextMax = 4095; // 4.3.4's 4096-byte buffer, less its NUL
constexpr size_t kLineSize = 1024;
struct Packer {
void *L;
bool skipFunctionsAndUserdata;
std::string text;
void Line(const char *fmt, const char *indent, const char *name, const char *value = "",
int number = 0) {
char line[kLineSize];
std::snprintf(line, sizeof line, fmt, indent, name, value, number);
const size_t room = kTextMax - text.size();
text.append(line, std::min(room, std::strlen(line)));
}
// The value on the stack top, as 4.3.4's FrameScript_PackVariable.
void Pack(const char *name, int depth) {
const char *indent = depth > 0 ? " " : "";
switch (Lua::Type(L, -1)) {
case Lua::TYPE_NIL:
Line("%s%s=nil\n", indent, name);
break;
case Lua::TYPE_BOOLEAN:
Line("%s%s=%s\n", indent, name, Lua::ToBoolean(L, -1) ? "true" : "false");
break;
case Lua::TYPE_LIGHTUSERDATA:
case Lua::TYPE_USERDATA:
if (!skipFunctionsAndUserdata)
Line("%s%s=<userdata>\n", indent, name);
break;
case Lua::TYPE_NUMBER:
Line("%s%s=%s\n", indent, name, Lua::ToString(L, -1)); // a copy: safe to convert
break;
case Lua::TYPE_STRING:
Line("%s%s=\"%s\"\n", indent, name, Lua::ToString(L, -1));
break;
case Lua::TYPE_TABLE:
PackTable(name, depth, indent);
break;
case Lua::TYPE_FUNCTION:
if (!skipFunctionsAndUserdata)
PackFunction(name, indent);
break;
default:
Line("%s%s=<%s>\n", indent, name, Lua::TypeName(L, Lua::Type(L, -1)));
break;
}
}
void PackTable(const char *name, int depth, const char *indent) {
void *object = Lua::ResolveObject(L, -1);
Line("%s%s=%s{\n", indent, name, object ? Lua::ObjectDisplayName(object) : "<table>");
if (depth == 0) {
Lua::PushNil(L);
while (Lua::Next(L, -2) != 0) {
char key[32];
const char *keyName = key;
const int keyType = Lua::Type(L, -2);
const double number = keyType == Lua::TYPE_NUMBER ? Lua::ToNumber(L, -2) : 0;
if (keyType == Lua::TYPE_NUMBER && std::isfinite(number) && number >= -2147483648.0 &&
number <= 2147483647.0) {
std::snprintf(key, sizeof key, "%d", static_cast<int>(number));
} else if (keyType == Lua::TYPE_STRING) {
keyName = Lua::ToString(L, -2);
} else if (keyType == Lua::TYPE_NUMBER) {
Lua::PushValue(L, -2);
std::snprintf(key, sizeof key, "%s", Lua::ToString(L, -1));
Lua::SetTop(L, -2);
} else {
std::snprintf(key, sizeof key, "<%s>", Lua::TypeName(L, keyType));
}
Pack(keyName, 1);
Lua::SetTop(L, -2); // the value; the key stays for lua_next
}
}
Line("%s}\n", indent, "");
}
void PackFunction(const char *name, const char *indent) {
alignas(4) uint8_t ar[Offsets::SIZEOF_LUA_DEBUG];
Lua::PushValue(L, -1);
Lua::GetInfo(L, ">nS", ar); // pops the copy
const auto field = [&ar](uintptr_t offset) { return Game::Read<const char *>(ar, offset); };
const char *source = field(Offsets::OFF_LUA_DEBUG_SOURCE);
const int line = Game::Read<int>(ar, Offsets::OFF_LUA_DEBUG_LINEDEFINED);
if (const char *fn = field(Offsets::OFF_LUA_DEBUG_NAME)) {
char named[kLineSize];
std::snprintf(named, sizeof named, "%s() defined %s", fn, source ? source : "?");
Line("%s%s=%s:%d\n", indent, name, named, line);
} else {
Line("%s%s=<function> defined %s:%d\n", indent, name, source ? source : "?", line);
}
}
};
int __fastcall Script_debuglocals(void *L) {
if (ErrorCallstack::ErrorFunctionLevel(L) == 0)
return 0;
int level = static_cast<int>(Lua::ToNumber(L, 1));
if (level <= 0)
level = 1;
Packer packer{L, Lua::ToBoolean(L, 2) != 0, {}};
alignas(4) uint8_t ar[Offsets::SIZEOF_LUA_DEBUG];
if (Lua::GetStack(L, level, ar)) {
const char *name;
for (int i = 1; (name = GetLocal(L, ar, i)) != nullptr; i++) {
packer.Pack(name, 0);
Lua::SetTop(L, -2);
}
Lua::GetInfo(L, "f", ar); // the level's function
for (int i = 1; (name = GetUpvalue(L, -1, i)) != nullptr; i++) {
packer.Pack(name, 0);
Lua::SetTop(L, -2);
}
Lua::SetTop(L, -2);
}
Lua::PushLString(L, packer.text.data(), static_cast<unsigned int>(packer.text.size()));
return 1;
}
const Game::Doc::Field kDebuglocalsArgs[] = {
Game::Doc::Opt("level", "number", "1", "The call level, as debugstack counts it."),
Game::Doc::Opt("skipFunctionsAndUserdata", "boolean", "false",
"Leaves out function and userdata values."),
};
const Game::Doc::Field kDebuglocalsRets[] = {
Game::Doc::Opt("locals", "string", nullptr,
"One `name=value` line for each local and upvalue. Nothing outside an "
"error handler."),
};
const Game::Doc::Function kDebuglocals{
"The local variables and upvalues of a call level as text, for an error report.",
kDebuglocalsArgs, kDebuglocalsRets, "LuaGlobals"};
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("debuglocals", &Script_debuglocals, &kDebuglocals);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("debuglocals", &Script_debuglocals, &kDebuglocals);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::DebugLocals
+102
View File
@@ -0,0 +1,102 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `debugstack([thread,] [start [, count1 [, count2]]])` — the stack of a
// coroutine, as 4.3.4's Script_debugstack takes it: with a thread first,
// the levels are that thread's and `start` defaults to 0 (the thread's
// innermost level) instead of 1.
//
// 1.12's debugstack (FUN_SCRIPT_DEBUGSTACK) has no thread argument, and a
// thread falls through its argument checks so that it returns the thread
// itself: `msg .. debugstack(co)` raises "attempt to concatenate a thread
// value" in the coroutine error paths of WeakAuras, Questie's ThreadLib,
// MRT and Zygor.
//
// The engine's function still does the work, so its text and its head and
// tail windows stay exactly as they are. It reads levels through six calls
// (five lua_getstack, one lua_getinfo), and those calls are redirected to
// read from the state Script_debugstack names for the call: the thread, or
// the calling state when there is none. Script_debugstack replaces the
// global and is the engine function's only way in, so the state is set for
// every call; an error part way cannot leave a thread behind for the next.
//
// Registered on both Lua states, like the base-library helpers.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <initializer_list>
namespace BaseLib::DebugStack {
namespace {
namespace Lua = Game::Lua;
// The state whose levels the engine's debugstack reads, or null for its own.
void *g_levelsOf = nullptr;
int __fastcall GetStack(void *L, int level, void *ar) {
return Lua::GetStack(g_levelsOf ? g_levelsOf : L, level, ar);
}
int __fastcall GetInfo(void *L, const char *what, void *ar) {
return Lua::GetInfo(g_levelsOf ? g_levelsOf : L, what, ar);
}
int __fastcall Script_debugstack(void *L) {
const auto engineDebugStack =
reinterpret_cast<Lua::CFunction>(Offsets::FUN_SCRIPT_DEBUGSTACK);
g_levelsOf = nullptr;
if (Lua::Type(L, 1) == Lua::TYPE_THREAD) {
g_levelsOf = Lua::ToThread(L, 1);
Lua::Remove(L, 1);
if (!Lua::IsNumber(L, 1)) { // start defaults to 0 for a thread
Lua::PushNumber(L, 0);
Lua::Insert(L, 1);
}
}
const int results = engineDebugStack(L);
g_levelsOf = nullptr;
return results;
}
void Apply() {
const auto getStack = reinterpret_cast<const void *>(&GetStack);
for (const uintptr_t site :
{Offsets::CALL_DEBUGSTACK_GETSTACK_FIRST, Offsets::CALL_DEBUGSTACK_GETSTACK_TAIL_PROBE,
Offsets::CALL_DEBUGSTACK_GETSTACK_TAIL_START, Offsets::CALL_DEBUGSTACK_GETSTACK_TAIL_SKIP,
Offsets::CALL_DEBUGSTACK_GETSTACK_NEXT})
Common::RedirectCall(site, Offsets::LUA_GET_STACK, getStack);
Common::RedirectCall(Offsets::CALL_DEBUGSTACK_GETINFO, Offsets::LUA_GET_INFO,
reinterpret_cast<const void *>(&GetInfo));
}
void RegisterInGame() {
Apply();
Lua::RegisterGlobalFunction("debugstack", &Script_debugstack);
}
void RegisterGlue() {
Apply();
Lua::RegisterGlueFunction("debugstack", &Script_debugstack);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::DebugStack
+58
View File
@@ -0,0 +1,58 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `difftime(t2 [, t1])` — the seconds from `t1` (0 by default) to `t2`, the
// companion of the engine's `date` and `time` globals. 4.3.4's
// Script_difftime: C `difftime` over the two arguments as time_t.
//
// Registered on BOTH states, as the engine registers `date` and `time`.
#include "Game.h"
#include <ctime>
namespace BaseLib::DiffTime {
namespace {
int __fastcall Script_difftime(void *L) {
const auto t2 = static_cast<time_t>(Game::Lua::CheckNumber(L, 1));
const auto t1 = static_cast<time_t>(Game::Lua::OptNumber(L, 2, 0));
Game::Lua::PushNumber(L, std::difftime(t2, t1));
return 1;
}
const Game::Doc::Field kDifftimeArgs[] = {
Game::Doc::Req("time2", "number", "A time in seconds, as `time()` returns."),
Game::Doc::Opt("time1", "number", "0"),
};
const Game::Doc::Field kDifftimeRets[] = {
Game::Doc::Req("seconds", "number", "time2 minus time1."),
};
const Game::Doc::Function kDifftime{"The number of seconds from time1 to time2.",
kDifftimeArgs, kDifftimeRets, "LuaGlobals"};
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("difftime", &Script_difftime, &kDifftime);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("difftime", &Script_difftime, &kDifftime);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::DiffTime
+14 -15
View File
@@ -40,12 +40,26 @@
#include "Game.h"
#include "Offsets.h"
#include "baselib/ErrorCallstack.h"
#include <cstdint>
#include <optional>
namespace BaseLib::ErrorCallstack {
int ErrorFunctionLevel(void *L) {
const auto errorFunction = reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_FRAMESCRIPT_CALL_ERROR_HANDLER);
alignas(4) uint8_t ar[Offsets::SIZEOF_LUA_DEBUG];
for (int level = 1; Game::Lua::GetStack(L, level, ar); ++level) {
Game::Lua::GetInfo(L, "f", ar); // the level's function; nil for a lost tail call
const bool found = Game::Lua::ToCFunction(L, -1) == errorFunction;
Game::Lua::SetTop(L, -2);
if (found)
return level;
}
return 0;
}
namespace {
std::optional<int> g_setHeight;
@@ -59,21 +73,6 @@ int Height(void *L) {
return height;
}
// The innermost level whose function is the engine's error function, or 0
// when no error is being reported.
int ErrorFunctionLevel(void *L) {
const auto errorFunction = reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_FRAMESCRIPT_CALL_ERROR_HANDLER);
alignas(4) uint8_t ar[Offsets::SIZEOF_LUA_DEBUG];
for (int level = 1; Game::Lua::GetStack(L, level, ar); ++level) {
Game::Lua::GetInfo(L, "f", ar); // the level's function; nil for a lost tail call
const bool found = Game::Lua::ToCFunction(L, -1) == errorFunction;
Game::Lua::SetTop(L, -2);
if (found)
return level;
}
return 0;
}
int __fastcall Script_GetCallstackHeight(void *L) {
Game::Lua::PushNumber(L, Height(L));
return 1;
+23
View File
@@ -0,0 +1,23 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace BaseLib::ErrorCallstack {
// The innermost call level whose function is the engine's error function
// (FUN_FRAMESCRIPT_CALL_ERROR_HANDLER), or 0 when no error is being
// reported. Nonzero exactly while an error handler runs.
int ErrorFunctionLevel(void *L);
} // namespace BaseLib::ErrorCallstack
+22 -1
View File
@@ -16,13 +16,16 @@
// - `math.fmod(x, y)` — floating-point remainder of `x / y`.
// - `math.modf(x)` — split into integral + fractional parts.
// - `math.huge` — positive infinity constant.
// - `math.cosh(x)`, `math.sinh(x)`, `math.tanh(x)` — hyperbolic functions.
//
// `math.fmod` is a pure rename: Lua 5.0 ships the exact same C function under
// the name `math.mod`; 5.1 renamed the Lua-facing entry to `math.fmod`. We
// register it as a direct alias of the engine's `math.mod` C function
// (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too, so code
// written for either name works. `modf` and `huge` are genuinely new in 5.1
// (not in 1.12's mathlib table), so we implement/define them here.
// (not in 1.12's mathlib table), so we implement/define them here, as are the
// hyperbolic functions (5.1's math_cosh/sinh/tanh: luaL_checknumber, the C
// function, one number).
#include "Game.h"
#include "Offsets.h"
@@ -53,9 +56,27 @@ int __fastcall Script_math_modf(void *L) {
return 2;
}
int __fastcall Script_math_cosh(void *L) {
Game::Lua::PushNumber(L, std::cosh(Game::Lua::CheckNumber(L, 1)));
return 1;
}
int __fastcall Script_math_sinh(void *L) {
Game::Lua::PushNumber(L, std::sinh(Game::Lua::CheckNumber(L, 1)));
return 1;
}
int __fastcall Script_math_tanh(void *L) {
Game::Lua::PushNumber(L, std::tanh(Game::Lua::CheckNumber(L, 1)));
return 1;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("math", "fmod", Script_math_fmod);
Game::Lua::RegisterTableFunction("math", "modf", &Script_math_modf);
Game::Lua::RegisterTableFunction("math", "cosh", &Script_math_cosh);
Game::Lua::RegisterTableFunction("math", "sinh", &Script_math_sinh);
Game::Lua::RegisterTableFunction("math", "tanh", &Script_math_tanh);
// `math.huge` — a numeric constant, not a function. Fetch the (now
// guaranteed-present) `math` table and set the field directly.
+78
View File
@@ -0,0 +1,78 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `newproxy([false | true | proxy])` — Lua 5.1's empty userdata (luaB_newproxy,
// lbaselib.c). `false` or nothing: no metatable. `true`: a new metatable of
// its own. Another proxy: share that proxy's metatable. Only metatables made
// by `newproxy(true)` count; any other argument raises "boolean or proxy
// expected". 5.0 has userdata and their `__gc`, only not this function.
//
// As in 5.1, the closure's upvalue is a weak table that marks the valid
// metatables (weak[m] = true), and is its own metatable with __mode "kv".
//
// Registered on BOTH states (language-level helper, both are 5.0).
#include "Game.h"
namespace BaseLib::NewProxy {
namespace {
namespace Lua = Game::Lua;
int __fastcall Script_newproxy(void *L) {
Lua::SetTop(L, 1);
Lua::NewUserdata(L, 0); // the proxy, at 2
if (Lua::ToBoolean(L, 1) == 0)
return 1; // no metatable
if (Lua::Type(L, 1) == Lua::TYPE_BOOLEAN) {
Lua::NewTable(L); // a new metatable m ...
Lua::PushValue(L, -1);
Lua::PushBoolean(L, 1);
Lua::RawSet(L, Lua::UpvalueIndex(1)); // ... marked valid: weak[m] = true
} else {
bool valid = false;
if (Lua::GetMetatable(L, 1)) {
Lua::RawGet(L, Lua::UpvalueIndex(1));
valid = Lua::ToBoolean(L, -1) != 0;
Lua::SetTop(L, -2);
}
if (!valid)
Lua::ArgError(L, 1, "boolean or proxy expected");
Lua::GetMetatable(L, 1);
}
Lua::SetMetatable(L, 2);
return 1;
}
void Register() {
void *L = Lua::State();
if (L == nullptr)
return;
Lua::PushString(L, "newproxy");
Lua::NewTable(L); // the weak table w
Lua::PushString(L, "__mode");
Lua::PushString(L, "kv");
Lua::RawSet(L, -3);
Lua::PushValue(L, -1);
Lua::SetMetatable(L, -2); // w is its own metatable
Lua::PushCClosure(L, &Script_newproxy, 1);
Lua::SetTable(L, Lua::GLOBALS_INDEX);
}
const Game::ModuleAutoRegister _autoreg{&Register};
const Game::GlueModuleAutoRegister _autoregGlue{&Register};
} // namespace
} // namespace BaseLib::NewProxy
+63
View File
@@ -0,0 +1,63 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `scrub(...)` — returns its arguments, each one that is not a boolean,
// number or string replaced by nil. 4.3.4's Script_scrub.
//
// Registered on both Lua states, like the base-library helpers.
#include "Game.h"
namespace BaseLib::Scrub {
namespace {
namespace Lua = Game::Lua;
int __fastcall Script_scrub(void *L) {
const int top = Lua::GetTop(L);
Lua::CheckStack(L, 1);
for (int i = 1; i <= top; i++) {
const int type = Lua::Type(L, i);
if (type != Lua::TYPE_BOOLEAN && type != Lua::TYPE_NUMBER && type != Lua::TYPE_STRING) {
Lua::PushNil(L);
Lua::Replace(L, i);
}
}
return top;
}
const Game::Doc::Field kScrubArgs[] = {
Game::Doc::Vararg("any"),
};
const Game::Doc::Field kScrubRets[] = {
Game::Doc::Vararg("any", "Each argument, or nil when it is not a boolean, number or string."),
};
const Game::Doc::Function kScrub{
"Returns its arguments, with each one that is not a boolean, number or string replaced by nil.",
kScrubArgs, kScrubRets, "LuaGlobals"};
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("scrub", &Script_scrub, &kScrub);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("scrub", &Script_scrub, &kScrub);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::Scrub
+86
View File
@@ -0,0 +1,86 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `string.format` takes two-digit argument numbers (`%10$s`).
//
// 1.12's str_format (0x7FD3D0) is Lua 4.0's: a spec may start with `n$`, but
// only one digit, `isdigit(p[0]) && p[1] == '$'`. In "%10$s" the "10" is then
// read as a width and the '$' as the conversion, which raises "invalid
// option in `format'". 4.3.4's str_format adds a second branch for two
// digits, `isdigit(p[1]) && p[2] == '$'`, and its combat log strings need it
// (ACTION_SPELL_PERIODIC_LEECH_RESULT is "(%10$s %8$s Gained)").
//
// The thunk below stands in for the engine's one-digit branch and adds the
// two-digit one. Everything else stays the engine's: its locale isdigit on
// the first digit (already tested before the site), `arg++` after the site,
// the flag/width/precision scan from EBX, and every conversion. Every caller
// goes through it: `string.format`, `format`, and the C callers of
// FUN_LUA_STR_FORMAT (Frame::FormattedText, Text::NumericFormatter).
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace BaseLib::StringFormat {
namespace {
static_assert(Offsets::LUA_STR_FORMAT_FRAME_ARG == 8, "ArgNumberThunk writes [ebp - 8]");
// ESI = the spec after '%', whose first byte is a digit. Sets the argument
// number in [ebp - 8] and moves EBX past the `n$`; leaves both alone when
// the spec has no `n$`.
__declspec(naked) void ArgNumberThunk() {
__asm {
cmp byte ptr [esi + 1], '$'
jne twoDigits
movsx eax, byte ptr [esi]
sub eax, '0'
mov dword ptr [ebp - 8], eax
lea ebx, [esi + 2]
ret
twoDigits:
movzx eax, byte ptr [esi + 1]
sub eax, '0'
cmp eax, 9
ja done
cmp byte ptr [esi + 2], '$'
jne done
movsx ecx, byte ptr [esi]
sub ecx, '0'
lea ecx, [ecx + ecx * 4]
lea eax, [eax + ecx * 2]
mov dword ptr [ebp - 8], eax
lea ebx, [esi + 3]
done:
ret
}
}
// Runs on every registration pass; a site already patched no longer matches
// and is skipped.
void Apply() {
static const uint8_t kOneDigit[18] = {0x80, 0x7E, 0x01, 0x24, 0x75, 0x0C, 0x0F, 0xBE, 0x06,
0x83, 0xE8, 0x30, 0x89, 0x45, 0xF8, 0x8D, 0x5E, 0x02};
Common::ReplaceWithCall(Offsets::SITE_LUA_STR_FORMAT_ARG_NUMBER, kOneDigit, sizeof kOneDigit,
reinterpret_cast<const void *>(&ArgNumberThunk));
}
const Game::ModuleAutoRegister _autoreg{&Apply};
const Game::GlueModuleAutoRegister _glueAutoreg{&Apply};
} // namespace
} // namespace BaseLib::StringFormat
+163
View File
@@ -0,0 +1,163 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `string.gsub` takes 5.1's string and table replacements.
//
// 1.12's str_gsub (0x7FD0E0) is Lua 5.0's, and differs from 5.1 in its
// replacement argument:
// - it takes a string or a function, not a table;
// - `%0` in a replacement string, and `%1` when the pattern has no
// captures, raise "invalid capture index" (5.1: the whole match);
// - a function that returns nil or false deletes the match (5.1 keeps it),
// and one that returns a table deletes it too (5.1 raises an error).
//
// The first two only ever raised errors, so 5.1's behavior cannot break a
// 1.12 addon. The third does: SuperAPI's CombatText_AddMessage returns nil
// to delete the player's own " [0x...]" GUID from heal text. A function
// replacement therefore stays the engine's own add_s.
//
// str_gsub itself stays: its match loop, anchor, count and buffer are the
// same in both. Two parts are replaced:
// - the argument gate (SITE_LUA_STR_GSUB_REPL_CHECK) becomes CheckReplacement;
// - the one add_s call (CALL_LUA_STR_GSUB_ADD_S) goes to AddValue: 5.1's
// add_s for a string or number, 5.1's table lookup for a table, and the
// engine's add_s for a function. They use the engine's capture helpers.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace BaseLib::StringGsub {
namespace {
namespace Lua = Game::Lua;
// Lua 5.0's MatchState, as str_gsub builds it on its stack (str_format's
// spec scan reads the same layout: level at +0x0C, capture[0].len at +0x14).
struct MatchState {
const char *srcInit;
const char *srcEnd;
void *L;
int level;
struct {
const char *init;
int len;
} capture[32];
};
using add_s_t = void(__fastcall *)(MatchState *ms, void *b, const char *s, const char *e);
using push_onecapture_t = void(__fastcall *)(MatchState *ms, int i);
using luaL_addlstring_t = void(__fastcall *)(void *b, const char *s, unsigned int len);
using luaL_addvalue_t = void(__fastcall *)(void *b);
const auto EngineAddS = reinterpret_cast<add_s_t>(Offsets::FUN_LUA_STR_ADD_S);
const auto EnginePushOneCapture =
reinterpret_cast<push_onecapture_t>(Offsets::FUN_LUA_STR_PUSH_ONECAPTURE);
const auto AddLString = reinterpret_cast<luaL_addlstring_t>(Offsets::LUAL_ADD_LSTRING);
const auto AddBufferValue = reinterpret_cast<luaL_addvalue_t>(Offsets::LUAL_ADD_VALUE);
// 5.1's push_onecapture: capture `i`, or the whole match for `i == 0` when
// the pattern has no captures.
void PushOneCapture(MatchState *ms, int i, const char *s, const char *e) {
if (i < ms->level)
EnginePushOneCapture(ms, i);
else if (i == 0)
Lua::PushLString(ms->L, s, static_cast<unsigned int>(e - s));
else
Lua::Error(ms->L, "invalid capture index");
}
// 5.1's add_s: a replacement string, where `%0` is the whole match and `%1`
// to `%9` are captures.
void AddString(MatchState *ms, void *b, const char *s, const char *e) {
void *L = ms->L;
const char *news = Lua::ToString(L, 3);
const unsigned int l = Lua::StrLen(L, 3);
for (unsigned int i = 0; i < l; i++) {
if (news[i] != '%') {
AddLString(b, news + i, 1);
continue;
}
i++; // skip the escape
const char c = news[i];
if (c < '0' || c > '9') {
AddLString(b, news + i, 1);
} else if (c == '0') {
AddLString(b, s, static_cast<unsigned int>(e - s));
} else {
PushOneCapture(ms, c - '1', s, e);
AddBufferValue(b);
}
}
}
// 5.1's add_value for a string or table, the engine's add_s for a function.
// Called as add_s: __fastcall, ret 8.
void __fastcall AddValue(MatchState *ms, void *b, const char *s, const char *e) {
void *L = ms->L;
switch (Lua::Type(L, 3)) {
case Lua::TYPE_FUNCTION:
// A result that is not a string or number deletes the match.
EngineAddS(ms, b, s, e);
return;
case Lua::TYPE_TABLE:
break;
default: // number or string
AddString(ms, b, s, e);
return;
}
PushOneCapture(ms, 0, s, e);
Lua::GetTable(L, 3);
if (!Lua::ToBoolean(L, -1)) {
Lua::SetTop(L, -2);
Lua::PushLString(L, s, static_cast<unsigned int>(e - s)); // keep the match
} else if (!Lua::IsString(L, -1)) {
Lua::Error(L, "invalid replacement value (a %s)", Lua::TypeName(L, Lua::Type(L, -1)));
}
AddBufferValue(b);
}
// 5.1's gate on the replacement argument. Called in place of the engine's,
// with ECX = L.
void __fastcall CheckReplacement(void *L) {
const int t = Lua::Type(L, 3);
if (t != Lua::TYPE_NUMBER && t != Lua::TYPE_STRING && t != Lua::TYPE_FUNCTION &&
t != Lua::TYPE_TABLE)
Lua::ArgError(L, 3, "string/function/table expected");
}
// Runs on every registration pass; a site already patched no longer matches
// and is skipped.
void Apply() {
static const uint8_t kGate[60] = {
0xE8, 0x0D, 0x5F, 0xEF, 0xFF, 0x83, 0xF8, 0x03, 0x7C, 0x21, 0xBA, 0x03,
0x00, 0x00, 0x00, 0x8B, 0xCF, 0xE8, 0x9C, 0x63, 0xEF, 0xFF, 0x85, 0xC0,
0x75, 0x22, 0xBA, 0x03, 0x00, 0x00, 0x00, 0x8B, 0xCF, 0xE8, 0x7C, 0x62,
0xEF, 0xFF, 0x83, 0xF8, 0x06, 0x74, 0x11, 0x68, 0x84, 0x23, 0x88, 0x00,
0xBA, 0x03, 0x00, 0x00, 0x00, 0x8B, 0xCF, 0xE8, 0x76, 0x76, 0xEF, 0xFF,
};
Common::ReplaceWithCall(Offsets::SITE_LUA_STR_GSUB_REPL_CHECK, kGate, sizeof kGate,
reinterpret_cast<const void *>(&CheckReplacement));
Common::RedirectCall(Offsets::CALL_LUA_STR_GSUB_ADD_S, Offsets::FUN_LUA_STR_ADD_S,
reinterpret_cast<const void *>(&AddValue));
}
const Game::ModuleAutoRegister _autoreg{&Apply};
const Game::GlueModuleAutoRegister _glueAutoreg{&Apply};
} // namespace
} // namespace BaseLib::StringGsub
+38 -124
View File
@@ -15,13 +15,13 @@
//
// - `string.match(s, pattern [, init])` — first-match extraction (5.1),
// also the global `strmatch`, as `string.find` is `strfind`.
// - `string.gmatch(s, pattern)` — match iterator (5.1).
// - `string.gsub` TABLE replacement — 5.1 form (see the shim below).
// - `string.gmatch(s, pattern)` — match iterator (5.1), also `gmatch`.
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
// - `strjoin(delimiter, ...)` — WoW global; join varargs.
// - `strtrim(str [, chars])` — WoW global; trim a char set.
// - `strreplace(str, find, replace)` — plain-text replace (see below).
// - `string.reverse(s)` / `strrev(s)` — 5.1 string function, missing in 5.0.
// - `string.byte(s, i, j)` / `strbyte` — 5.1's range form (5.0 reads only i).
// - `strlenutf8(s)` — WoW global; characters, not bytes.
// - `strcmputf8i(a, b)` — WoW global; case-insensitive UTF-8 order.
// - `strsplittable(sep, str [, pieces])`— WoW global; `strsplit` into a table.
@@ -341,126 +341,39 @@ int __fastcall Script_string_reverse(void *L) {
return 1;
}
// --- string.gsub table-replacement upgrade (Lua 5.1) ------------------------
//
// 5.1 allows a TABLE as gsub's replacement: each match looks up the first
// capture (the whole match when the pattern declares no captures) in the
// table; a nil/false value keeps the original match; string/number values
// substitute. This engine's gsub (FUN_LUA_STR_GSUB) is stock 5.0 —
// string/function only ("string or function expected" otherwise), and its
// add_value appends NOTHING when a replacement function returns a
// non-string (match → empty; verified by decode, see Offsets.h). So a
// plain table→closure adapter would EAT unmatched tokens where 5.1 keeps
// them, and the closure can't reconstruct the whole match from capture
// args alone.
//
// The shim therefore rewrites the pattern with one OUTER capture —
// `pat` → `(pat)`, keeping a leading `^` and an unescaped trailing `$`
// outside (both are anchors only at their positional ends; inside the
// parens they'd turn literal) — so the adapter closure always receives
// the whole match as arg 1, the pattern's own first capture as arg 2, and
// can hand arg 1 back to reproduce 5.1's keep-match for missing keys.
// Patterns containing a `%1`..`%9` back-reference can't be wrapped (the
// outer capture would renumber what `%N` refers to); those fall back to
// an unwrapped adapter whose missing-key behavior is the engine's own
// (match → empty) — the one documented divergence, and the combination
// (table replacement + pattern back-references) is essentially absent
// from real code. String/function replacements tail-call the engine gsub
// untouched.
// `string.byte(s [, i [, j]])` / `strbyte` — the codes of bytes i..j, as
// 4.3.4's str_byte (j defaults to i). 1.12's (0x7FC210) is 5.0's: it reads
// only `i` and pushes one code. With `j` absent the two give the same
// result for every `i`. Positions truncate toward zero as the engine's
// str_byte and str_sub do. The engine's lua_checkstack (0x6F2F30) refuses
// more than 2048 stack slots, so the longest range is about 2000 bytes.
// True when the pattern contains a `%1`..`%9` back-reference. Escape-aware:
// the char after any `%` is consumed, so `%%1` (literal percent, then '1')
// doesn't count.
bool PatternHasBackref(const char *p, unsigned len) {
for (unsigned i = 0; i + 1 < len; ++i) {
if (p[i] != '%')
continue;
const char c = p[i + 1];
if (c >= '1' && c <= '9')
return true;
++i; // skip the escaped char
}
return false;
// 5.1's posrelat: a negative position counts from the end; before the start is 0.
long PosRelat(long pos, long len) {
if (pos < 0)
pos += len + 1;
return pos >= 0 ? pos : 0;
}
// Adapter for the wrapped-pattern path: arg 1 = whole match, arg 2 = the
// pattern's own first capture (when it declared any). Upvalue 1 = the
// replacement table. Missing/false value → return the whole match (5.1's
// keep-match).
int __fastcall GsubTableLookupWrapped(void *L) {
const int nargs = Game::Lua::GetTop(L);
Game::Lua::PushValue(L, nargs >= 2 ? 2 : 1); // the 5.1 lookup key
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1)); // tbl[key]
const int t = Game::Lua::Type(L, -1);
if (t == Game::Lua::TYPE_NIL ||
(t == Game::Lua::TYPE_BOOLEAN && Game::Lua::ToBoolean(L, -1) == 0))
Game::Lua::PushValue(L, 1); // keep the original match
return 1; // top value is the result
}
// Adapter for back-reference patterns (unwrapped): arg 1 is already the
// 5.1 key. A missing key returns nil → the engine appends nothing.
int __fastcall GsubTableLookupPlain(void *L) {
Game::Lua::PushValue(L, 1);
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1));
return 1;
}
int __fastcall Script_string_gsub(void *L) {
const auto engineGsub =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GSUB);
if (Game::Lua::Type(L, 3) != Game::Lua::TYPE_TABLE)
return engineGsub(L); // string/function/error paths untouched
const char *pat = Game::Lua::ToString(L, 2);
if (pat == nullptr)
return engineGsub(L); // let the engine raise its own arg error
const unsigned patLen = Game::Lua::StrLen(L, 2);
Game::Lua::SetTop(L, 4); // normalize to (s, pat, tbl, n) — gsub reads ≤4
if (!PatternHasBackref(pat, patLen)) {
// Build `(body)` with the anchors outside the capture.
unsigned b = 0, e = patLen;
std::string wrapped;
wrapped.reserve(patLen + 3);
if (e > b && pat[0] == '^') {
wrapped += '^';
b = 1;
}
bool anchoredBack = false;
if (e > b && pat[e - 1] == '$') {
// `$` is an anchor only when not %-escaped: an even number of
// `%` immediately before it means the `$` itself is unescaped.
unsigned pc = 0;
for (int idx = static_cast<int>(e) - 2;
idx >= static_cast<int>(b) && pat[idx] == '%'; --idx)
++pc;
anchoredBack = (pc % 2) == 0;
if (anchoredBack)
--e;
}
wrapped += '(';
wrapped.append(pat + b, e - b);
wrapped += ')';
if (anchoredBack)
wrapped += '$';
Game::Lua::PushLString(L, wrapped.data(),
static_cast<unsigned int>(wrapped.size())); // 5
Game::Lua::PushValue(L, 3); // 6: tbl
Game::Lua::PushCClosure(L, &GsubTableLookupWrapped, 1); // 6: closure
Game::Lua::Insert(L, 2); // closure→2: s, closure, pat, tbl, n, wrapped
Game::Lua::Insert(L, 2); // wrapped→2: s, wrapped, closure, pat, tbl, n
Game::Lua::Remove(L, 4); // drop the original pattern
Game::Lua::Remove(L, 4); // drop the table → (s, wrapped, closure, n)
} else {
Game::Lua::PushValue(L, 3); // 5: tbl
Game::Lua::PushCClosure(L, &GsubTableLookupPlain, 1); // 5: closure
Game::Lua::Insert(L, 3); // → s, pat, closure, tbl, n
Game::Lua::Remove(L, 4); // → s, pat, closure, n
}
return engineGsub(L);
int __fastcall Script_string_byte(void *L) {
unsigned int l = 0;
const char *s = Game::Lua::CheckLString(L, 1, &l);
const long len = static_cast<long>(l);
long posi = PosRelat(static_cast<long>(Game::Lua::OptNumber(L, 2, 1)), len);
long pose =
PosRelat(static_cast<long>(Game::Lua::OptNumber(L, 3, static_cast<double>(posi))), len);
if (posi <= 0)
posi = 1;
if (pose > len)
pose = len;
if (posi > pose)
return 0;
const int n = static_cast<int>(pose - posi + 1);
if (!Game::Lua::CheckStack(L, n))
Game::Lua::Error(L, "stack overflow (%s)", "string slice too long");
for (int i = 0; i < n; i++)
Game::Lua::PushNumber(L, static_cast<unsigned char>(s[posi + i - 1]));
return n;
}
// --- Documentation ----------------------------------------------------------
@@ -515,27 +428,28 @@ const Game::Doc::Function kStrconcat{
void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
Game::Lua::RegisterTableFunction("string", "gsub", &Script_string_gsub);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterTableFunction("string", "byte", &Script_string_byte);
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
Game::Lua::RegisterTableFunction("string", "trim", &Script_strtrim);
Game::Lua::RegisterTableFunction("string", "join", &Script_strjoin);
Game::Lua::RegisterTableFunction("string", "concat", &Script_strconcat, &kStrconcat);
// 4.3.4's compat.lua binds each short global and its string-table twin
// to ONE function by assignment (`gsub = str.gsub`, `strmatch =
// to ONE function by assignment (`gmatch = str.gmatch`, `strmatch =
// str.match`, `strrev = str.reverse`, `str.trim = strtrim`, `str.split =
// strsplit`, `str.join = strjoin`), so `strtrim == string.trim`. A second
// Register* call would mint a second closure over the same C function, so
// the globals alias the table entries by value instead. The string-table
// half is what lets `(" x "):trim()` work and libraries capture it at
// file scope (AceConfigCmd-3.0: `strtrim = string.trim`); the bare `gsub`
// gets the table-replacement upgrade by being the same function.
Game::Lua::RegisterGlobalAlias("gsub", "string", "gsub");
// file scope (AceConfigCmd-3.0: `strtrim = string.trim`). 1.12's own
// compat.lua set `strbyte` from the old string.byte, so it is re-aliased.
Game::Lua::RegisterGlobalAlias("gmatch", "string", "gmatch");
Game::Lua::RegisterGlobalAlias("strmatch", "string", "match");
Game::Lua::RegisterGlobalAlias("strsplit", "string", "split");
Game::Lua::RegisterGlobalAlias("strjoin", "string", "join");
Game::Lua::RegisterGlobalAlias("strtrim", "string", "trim");
Game::Lua::RegisterGlobalAlias("strrev", "string", "reverse");
Game::Lua::RegisterGlobalAlias("strbyte", "string", "byte");
Game::Lua::RegisterGlobalAlias("strconcat", "string", "concat", &kStrconcat);
Game::Lua::RegisterGlobalFunction("strreplace", &Script_strreplace);
Game::Lua::RegisterGlobalFunction("strlenutf8", &Script_strlenutf8, &kStrlenutf8);
+5 -21
View File
@@ -47,7 +47,9 @@
// section's header follows.
#include "Inject.h"
#include "Common.h"
#include "Offsets.h"
#include "storm/Memory.h"
#include "embed/Resource.h"
#include "embedded_resources.h"
@@ -212,28 +214,12 @@ const char *FindSplicePosition(const char *content, size_t size,
return FindOutsideComment(content, end, "</Bindings>");
}
char NormalizeChar(char c) {
if (c == '/') return '\\';
if (c >= 'A' && c <= 'Z') return static_cast<char>(c + 32);
return c;
}
bool PathEqualsCI(const char *a, const char *b) {
while (*a != '\0' && *b != '\0') {
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
++a; ++b;
}
return *a == *b;
}
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn original) {
if (path == nullptr || !PathEqualsCI(path, kFrameXMLBindingsPath))
if (path == nullptr || !Common::SamePath(path, kFrameXMLBindingsPath))
return false;
// Pull the real file through the original reader with the caller's
@@ -270,10 +256,8 @@ bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
const size_t splicePos = static_cast<size_t>(splice - content);
const size_t newSize = origSize + config.innerSize;
auto SMemAlloc = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEM_ALLOC);
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
void *newBuf = SMemAlloc(newSize + extraBytes, __FILE__, __LINE__, 0);
void *newBuf = Storm::Alloc(newSize + extraBytes, __FILE__, __LINE__);
if (newBuf == nullptr) {
// Allocation failed — fall back to the original buffer so the
// engine still loads its own bindings.
@@ -290,7 +274,7 @@ bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
if (extraBytes > 0)
std::memset(dest + newSize, 0, extraBytes);
SMemFree(origBuf, __FILE__, __LINE__, 0);
Storm::Free(origBuf, __FILE__, __LINE__);
if (outBuf != nullptr) *outBuf = newBuf;
if (outSize != nullptr) *outSize = newSize;
+2 -2
View File
@@ -46,6 +46,7 @@
#include "baselib/Ascii.h"
#include "frame/ClickEvents.h"
#include "input/Modifier.h"
#include "storm/Memory.h"
#include "xml/FrameLoad.h"
#include <cstdint>
@@ -219,8 +220,7 @@ void LoadModifiedClicks(const char *path, void *status) {
return;
void *tree = reinterpret_cast<TreeLoad_t>(Offsets::FUN_XML_TREE_LOAD)(
static_cast<const char *>(data), static_cast<uint32_t>(size));
reinterpret_cast<AddOns::EngineIO::SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE)(data, __FILE__,
__LINE__, 0);
Storm::Free(data, __FILE__, __LINE__);
if (tree == nullptr)
return; // the engine loader reports the parse error
const void *root = Game::Read<const void *>(tree, 0);
+21 -9
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);
@@ -497,9 +511,7 @@ const Game::Lua::EnumIntegerEntry kSwingTypeEntries[] = {
};
void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum(
"Enum", "PlayerSwingType", kSwingTypeEntries,
sizeof(kSwingTypeEntries) / sizeof(kSwingTypeEntries[0]), "SwingTimer");
Game::Lua::RegisterIntegerEnum("Enum", "PlayerSwingType", kSwingTypeEntries, "SwingTimer");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+2 -14
View File
@@ -45,6 +45,7 @@
#include "time/Clock.h"
#include "unit/Focus.h"
#include "unit/Identity.h"
#include "unit/Power.h"
#include <ctime>
@@ -617,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;
@@ -684,9 +674,7 @@ void SetItem(Entry &e, int itemId) {
}
int DisplayAmount(int amount, int powerType) {
using Divisor_t = int(__fastcall *)(int powerType);
const int divisor = reinterpret_cast<Divisor_t>(
static_cast<uintptr_t>(Offsets::FUN_POWER_DISPLAY_DIVISOR))(powerType);
const int divisor = static_cast<int>(Unit::Power::DisplayDivisor(powerType));
return divisor != 0 ? amount / divisor : amount;
}
-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;
+2 -6
View File
@@ -145,12 +145,8 @@ void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("ConsoleGetAllCommands", &Script_GetAllCommands);
Game::Lua::RegisterGlobalFunction("ConsolePrintAllMatchingCommands",
&Script_ConsolePrintAllMatchingCommands);
Game::Lua::RegisterIntegerEnum("Enum", "ConsoleCommandType", kCommandTypeEntries,
sizeof(kCommandTypeEntries) /
sizeof(kCommandTypeEntries[0]));
Game::Lua::RegisterIntegerEnum("Enum", "ConsoleCategory", kCategoryEntries,
sizeof(kCategoryEntries) /
sizeof(kCategoryEntries[0]));
Game::Lua::RegisterIntegerEnum("Enum", "ConsoleCommandType", kCommandTypeEntries);
Game::Lua::RegisterIntegerEnum("Enum", "ConsoleCategory", kCategoryEntries);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+2 -2
View File
@@ -49,6 +49,7 @@
#include "Game.h"
#include "Offsets.h"
#include "console/Registry.h"
#include "input/Modifier.h"
#include <algorithm>
#include <cstdint>
@@ -63,7 +64,6 @@ namespace {
using Handler_t = int(__fastcall *)(const void *data, void *param);
using RegisterEx_t = void(__fastcall *)(int eventId, Handler_t handler, void *param,
float priority);
using IsKeyDown_t = int(__fastcall *)(int key);
using GetInputLine_t = uintptr_t(__fastcall *)();
using MakeCommandCurrent_t = void(__fastcall *)(uintptr_t line, const char *text);
using SetInputString_t = void(__fastcall *)(const char *text);
@@ -205,7 +205,7 @@ int __fastcall OnKeyDown(const void *data, void * /*param*/) {
return 1;
if (Game::Read<uint32_t>(Offsets::VAR_CONSOLE_VISIBLE) == 0)
return 1;
if (reinterpret_cast<IsKeyDown_t>(Offsets::FUN_GAMEUI_IS_KEY_DOWN)(toggleKey) != 0)
if (Input::Modifier::IsKeyDown(toggleKey))
return 1;
const uintptr_t line =
+104
View File
@@ -0,0 +1,104 @@
// 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 "container/Bag.h"
#include "Game.h"
#include "Offsets.h"
#include "equipmentset/Locations.h"
#include "item/BagFamily.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include "unit/Identity.h"
namespace Container::Bag {
namespace {
bool PlayerRange(int first, int count, View *out) {
auto *inv = Unit::Identity::PlayerInventoryManager();
if (inv == nullptr)
return false;
*out = {inv, first, count, 0};
return true;
}
} // namespace
// The main bank resolves only while a banker is open — 3.3.5 gates it on
// `CGBankInfo::m_banker` the same way. Its GUIDs sit in the player's list
// from login, but the server refuses every bank move without a banker and
// the engine's own slot reader (`0x6228a0`) hides slots 39..68 on the same
// banker-GUID test. Bank bags get that gate from `BagContainer`, whose
// engine GUID getter answers 0 for them while the bank is closed.
bool ViewFor(int bagID, View *out) {
if (bagID == Offsets::BAG_INDEX_BACKPACK)
return PlayerRange(Offsets::INVMGR_BACKPACK_FIRST_SLOT,
Offsets::BACKPACK_NUM_SLOTS, out);
if (bagID == Offsets::BAG_INDEX_BANK) {
if (!EquipmentSet::Locations::BankerOpen())
return false;
return PlayerRange(Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
Offsets::INVMGR_BANK_MAIN_LAST_SLOT -
Offsets::INVMGR_BANK_MAIN_FIRST_SLOT + 1,
out);
}
const uint8_t *bag = Item::Location::BagContainer(bagID);
auto *inv = static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (inv == nullptr)
return false;
const int slots = static_cast<int>(
Game::Read<uint32_t>(inv, Offsets::OFF_INVMGR_SLOT_COUNT));
if (slots <= 0)
return false;
// The bag's own family, as 3.3.5's `CGItem_C::GetBagFamily` reads it (0
// while its record isn't cached). `BitmaskForRecord` supplies the family
// a vanilla bag's empty field leaves out, so a quiver reports one rather
// than reading as general-purpose, and agrees with `C_Item.GetItemFamily`.
uint32_t family = 0;
const int bagItemID = Item::ID::FromCGItem(bag);
if (bagItemID > 0) {
if (auto *rec = Item::PeekRecord(static_cast<uint32_t>(bagItemID)))
family = Item::BagFamily::BitmaskForRecord(rec);
}
*out = {inv, 0, slots, family};
return true;
}
uint64_t SlotGuid(const View &view, int slotIndex0) {
const uint32_t linear = static_cast<uint32_t>(view.firstLinear + slotIndex0);
if (linear >= Game::Read<uint32_t>(view.invMgr, Offsets::OFF_INVMGR_SLOT_COUNT))
return 0;
auto *guids = Game::Read<const uint64_t *>(view.invMgr, Offsets::OFF_INVMGR_GUID_ARRAY);
return guids != nullptr ? guids[linear] : 0;
}
int CountFree(const View &view) {
if (view.invMgr != Unit::Identity::PlayerInventoryManager()) {
using CountFreeSlots_t = uint32_t(__thiscall *)(const uint8_t *invMgr);
auto countFreeSlots =
reinterpret_cast<CountFreeSlots_t>(Offsets::FUN_INVMGR_COUNT_FREE_SLOTS);
return static_cast<int>(countFreeSlots(view.invMgr));
}
int free = 0;
for (int i = 0; i < view.slotCount; ++i) {
if (SlotGuid(view, i) == 0)
free++;
}
return free;
}
} // namespace Container::Bag
+47
View File
@@ -0,0 +1,47 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
namespace Container::Bag {
// A container's storage, addressed without Lua: the inventory list holding
// its slot GUIDs, the linear index its first slot sits at, its slot count,
// and the BagFamily bitmask it accepts (0 = anything). The backpack and the
// main bank live inside the player's own inventory list at different linear
// bases; equipped bags and bank bags each have a list of their own.
struct View {
const uint8_t *invMgr;
int firstLinear;
int slotCount;
uint32_t family;
};
// Resolves Lua container `bagID` — backpack 0, bags 1..4, main bank -1, bank
// bags 5..10 — to its `View`. False for the keyring, an out-of-range ID, an
// empty bag slot, and any bank container while no banker is open. Pure C — no
// Lua stack.
bool ViewFor(int bagID, View *out);
// GUID of the item in 0-based slot `slotIndex0` of `view`, 0 when empty.
uint64_t SlotGuid(const View &view, int slotIndex0);
// Empty slots in `view`. A bag's own list is counted by the engine
// (`FUN_INVMGR_COUNT_FREE_SLOTS`, 3.3.5's `CGContainer_C::GetFreeSlots`); a
// range of the player's list (backpack, main bank) is walked slot by slot,
// the way 3.3.5's `Script_GetContainerNumFreeSlots` walks it.
int CountFree(const View &view);
} // namespace Container::Bag
+1 -2
View File
@@ -42,8 +42,7 @@ const Game::Lua::EnumIntegerEntry kEntries[] = {
};
void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum("Enum", "BagIndex", kEntries,
sizeof(kEntries) / sizeof(kEntries[0]), "BagIndexConstants");
Game::Lua::RegisterIntegerEnum("Enum", "BagIndex", kEntries, "BagIndexConstants");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+29 -115
View File
@@ -12,94 +12,21 @@
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "Game.h"
#include "Offsets.h"
#include "item/BagFamily.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include <cstdint>
#include <vector>
#include "container/Bag.h"
namespace Container::FreeSlots {
namespace {
// Resolves the bag's metadata: total slot count + BagFamily bitfield.
// `C_Container.GetContainerNumFreeSlots(bagID)` — `(numFreeSlots,
// bagFamily)`. 3.3.5's `Script_GetContainerNumFreeSlots` (`0x005D7590`):
// the backpack and, at a banker, the main bank report family 0; bags 1..4
// and bank bags 5..10 report the bag's own family. A container that isn't
// there — keyring, an empty bag slot, the bank while it's closed — returns
// only `0`, as 3.3.5 does and as retail's nilable `bagFamily` documents.
//
// - bagID 0 (backpack) → (16, 0). Vanilla 1.12 backpack is fixed at 16
// slots and unfamilied (0). 3.3.5's `Script_GetContainerNumFreeSlots`
// hardcodes both values for the backpack path too.
// - bagID 1..4 → looks up the equipped bag at INVSLOT_BAGn, reads
// `m_containerSlots` and `m_bagFamily` from the cached `ItemStats_C`
// record. If no bag equipped or item not cached: (0, 0).
// - other bagIDs (bank, keyring) → (0, 0). Not supported in this
// implementation.
struct BagInfo {
int slotCount;
int bagType;
};
BagInfo ResolveBagInfo(int bagID) {
if (bagID == 0)
return {Offsets::BACKPACK_NUM_SLOTS, 0};
if (bagID < 1 || bagID > 4)
return {0, 0};
const int equipSlot = Offsets::INVSLOT_BAG1 + bagID - 1;
auto *bagItem = Item::Location::ResolveEquipmentSlot(equipSlot);
if (bagItem == nullptr)
return {0, 0};
const int itemID = Item::ID::FromCGItem(bagItem);
if (itemID == 0)
return {0, 0};
auto *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return {0, 0};
const int slots = static_cast<int>(*reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS));
// BagFamily bitmask: the item's field if populated, else derived from
// the container subclass — Turtle leaves bags' m_bagFamily at 0, so a
// Soul Bag would otherwise report family 0 and get miscounted as a
// general-purpose bag by CalculateTotalNumberOfFreeBagSlots. Shared
// with C_Item.GetItemFamily so a bag and its contents agree. See
// `Item::BagFamily`.
const int family =
static_cast<int>(Item::BagFamily::BitmaskForRecord(record));
return {slots, family};
}
// Walks the bag and counts empty slots. Each `Item::Location::ResolveBag`
// call stomps Lua stack[1]/[2] (a requirement of the engine's
// `PackBagSlot` helper), but we own the stack here — Lua just called us
// — and the caller's bagID arg has already been read into a local. So
// it's safe to repeatedly clobber the stack across iterations.
int CountFreeSlotsInBag(void *L, int bagID, int slotCount) {
int free = 0;
for (int slot = 1; slot <= slotCount; slot++) {
if (Item::Location::ResolveBag(L, bagID, slot) == nullptr)
free++;
}
return free;
}
// `C_Container.GetContainerNumFreeSlots(bagID)` — returns
// `(numberOfFreeSlots, bagType)`. `bagID` is 0..4 (0 = backpack,
// 1..4 = equipped bag slots). For invalid or unsupported bag IDs
// (negative bank/keyring indices, out-of-range values), returns
// `(0, 0)` — matching 3.3.5's behavior of always pushing two values
// rather than nil/nothing.
//
// `bagType` is the `BagFamily` bitmask in modern encoding. Bags leave the
// raw family field empty in Turtle's data (Soul/Herb/quiver/ammo pouch/…
// all read 0), so it's derived from the bag's class + subclass via
// `Item::BagFamily`: a Soul Bag reports `0x4`, a quiver `0x1`, an ammo
// pouch `0x2` — matching what `C_Item.GetItemFamily` reports for both the
// bag and the items it holds. See that helper for the Turtle custom-family
// caveat (Meat/Fish/Leather/Mining, IDs 10–13).
// `bagFamily` is the modern bitmask; see `Container::Bag::ViewFor` for how
// a vanilla bag with an empty family field gets one.
int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
@@ -108,11 +35,13 @@ int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const BagInfo info = ResolveBagInfo(bagID);
const int free = (info.slotCount > 0) ? CountFreeSlotsInBag(L, bagID, info.slotCount) : 0;
Game::Lua::PushNumber(L, static_cast<double>(free));
Game::Lua::PushNumber(L, static_cast<double>(info.bagType));
Container::Bag::View view;
if (!Container::Bag::ViewFor(bagID, &view)) {
Game::Lua::PushNumber(L, 0.0);
return 1;
}
Game::Lua::PushNumber(L, static_cast<double>(Container::Bag::CountFree(view)));
Game::Lua::PushNumber(L, static_cast<double>(view.family));
return 2;
}
@@ -120,11 +49,8 @@ int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
// as an array of slot numbers in ascending order, rather than
// `GetContainerNumFreeSlots`'s count of them. A full bag returns an
// empty table; a bag that isn't there returns nothing, which is how
// callers tell "no free slots" from "no such bag".
//
// `bagID` is 0..4 like the rest of this file (0 = backpack, 1..4 =
// equipped bag slots); bank and keyring indices are the "no such bag"
// case.
// callers tell "no free slots" from "no such bag". Takes the same
// containers as `GetContainerNumFreeSlots`.
int __fastcall Script_C_Container_GetContainerFreeSlots(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
@@ -133,24 +59,17 @@ int __fastcall Script_C_Container_GetContainerFreeSlots(void *L) {
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const BagInfo info = ResolveBagInfo(bagID);
if (info.slotCount <= 0)
Container::Bag::View view;
if (!Container::Bag::ViewFor(bagID, &view))
return 0; // nothing — no such bag
// Collect before building the table. Each `ResolveBag` stomps stack
// slots 1 and 2, so the table cannot be on the stack during the walk.
std::vector<int> freeSlots;
freeSlots.reserve(static_cast<size_t>(info.slotCount));
for (int slot = 1; slot <= info.slotCount; ++slot) {
if (Item::Location::ResolveBag(L, bagID, slot) == nullptr)
freeSlots.push_back(slot);
}
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
for (size_t i = 0; i < freeSlots.size(); ++i) {
int n = 0;
for (int i = 0; i < view.slotCount; ++i) {
if (Container::Bag::SlotGuid(view, i) != 0)
continue;
Game::Lua::PushNumber(L, static_cast<double>(++n));
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(freeSlots[i]));
Game::Lua::RawSet(L, -3);
}
return 1;
@@ -165,18 +84,13 @@ int __fastcall Script_C_Container_GetContainerFreeSlots(void *L) {
// free, family = C_Container.GetContainerNumFreeSlots(bag)
// if family == 0 then total = total + free end
//
// Takes no arguments. Specialty bags are correctly excluded: their family
// comes from `Item::BagFamily` (container-subclass fallback), so a Soul
// Bag / Herb Bag / Meat Bag reports a non-zero family and its slots are
// left out of the general-purpose total — matching retail's FrameXML
// behavior. (An earlier version miscounted them because Turtle leaves the
// raw family field at 0 for bags.)
// Takes no arguments.
int __fastcall Script_C_Container_CalculateTotalNumberOfFreeBagSlots(void *L) {
int total = 0;
for (int bagID = 0; bagID <= 4; bagID++) {
const BagInfo info = ResolveBagInfo(bagID);
if (info.slotCount > 0 && info.bagType == 0)
total += CountFreeSlotsInBag(L, bagID, info.slotCount);
Container::Bag::View view;
if (Container::Bag::ViewFor(bagID, &view) && view.family == 0)
total += Container::Bag::CountFree(view);
}
Game::Lua::PushNumber(L, static_cast<double>(total));
return 1;
+31 -13
View File
@@ -55,6 +55,7 @@
#include "item/Location.h"
#include "item/Record.h"
#include "item/TypeInfo.h"
#include "localization/Locale.h"
#include "unit/Identity.h"
#include <algorithm>
@@ -86,12 +87,29 @@ char16_t ToLower(char16_t c) {
return c;
}
// Appends the lowercased `c` with 4.3.4's accent folding for `locale`
// (frFR 2, deDE 3, esES/esMX 6/7, ruRU 8); æ, œ and ß become two letters.
// Returns the new length.
size_t AppendFolded(int locale, char16_t c, char16_t *out, size_t n) {
switch (locale) {
case 2: // frFR
// 4.3.4's accent-folding sets. 4.3.4 picks one by its locale index (frFR 2,
// deDE 3, esES/esMX 6/7, ruRU 8); 1.12's index has other slots, so the set
// is picked by locale name.
enum class Folding { None, French, German, Spanish, Russian };
Folding ClientFolding() {
using Localization::Locale::Is;
if (Is("frFR"))
return Folding::French;
if (Is("deDE"))
return Folding::German;
if (Is("esES") || Is("esMX"))
return Folding::Spanish;
if (Is("ruRU"))
return Folding::Russian;
return Folding::None;
}
// Appends the lowercased `c` with `folding`'s accent folding; æ, œ and ß
// become two letters. Returns the new length.
size_t AppendFolded(Folding folding, char16_t c, char16_t *out, size_t n) {
switch (folding) {
case Folding::French:
switch (c) {
case 0xE0: case 0xE2: case 0xE4: c = u'a'; break;
case 0xE6: out[n++] = u'a'; c = u'e'; break;
@@ -103,14 +121,13 @@ size_t AppendFolded(int locale, char16_t c, char16_t *out, size_t n) {
case 0xF9: case 0xFA: case 0xFB: case 0xFC: c = u'u'; break;
}
break;
case 3: // deDE
case Folding::German:
if (c == 0xDF) {
out[n++] = u's';
c = u's';
}
break;
case 6: // esES
case 7: // esMX
case Folding::Spanish:
switch (c) {
case 0xE1: c = u'a'; break;
case 0xE9: c = u'e'; break;
@@ -120,10 +137,12 @@ size_t AppendFolded(int locale, char16_t c, char16_t *out, size_t n) {
case 0xF1: c = u'n'; break;
}
break;
case 8: // ruRU
case Folding::Russian:
if (c == 0x451)
c = 0x435;
break;
case Folding::None:
break;
}
out[n++] = c;
return n;
@@ -141,11 +160,10 @@ size_t Fold(const char *text, char16_t (&out)[kFoldCap]) {
// One unit short of the buffer: a full conversion stores no terminator.
reinterpret_cast<Convert_t>(Offsets::FUN_SUNI_CONVERT_UTF8_TO_16)(
wide, kFoldCap - 1, text, 0x7FFFFFFF, nullptr, nullptr);
const int locale =
*reinterpret_cast<const int *>(static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
const Folding folding = ClientFolding();
size_t n = 0;
for (size_t i = 0; wide[i] != 0 && n + 2 < kFoldCap; ++i)
n = AppendFolded(locale, ToLower(wide[i]), out, n);
n = AppendFolded(folding, ToLower(wide[i]), out, n);
out[n] = 0;
return n;
}
+7 -90
View File
@@ -60,18 +60,16 @@
#include "Game.h"
#include "Offsets.h"
#include "container/Bag.h"
#include "cvar/Factory.h"
#include "equipmentset/Locations.h"
#include "item/BagFamily.h"
#include "item/CGItem.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include "item/Swap.h"
#include "object/Resolve.h"
#include "tick/WorldTick.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <algorithm>
#include <cstdint>
@@ -82,6 +80,8 @@ namespace Container::SortBags {
namespace {
using BagView = Container::Bag::View;
// The two sets this can arrange. Both go through the same merge,
// snapshot, comparator and placement.
constexpr int kInventoryBags[] = {0, 1, 2, 3, 4};
@@ -258,96 +258,13 @@ bool Precedes(const Entry &a, const Entry &b) {
return a.itemID < b.itemID; // total order, so the result is stable
}
// A bag's storage, addressed without Lua: the invMgr holding its GUID
// array, the linear index its first slot sits at, and the family it
// accepts. The backpack lives inside the player's own invMgr; bags 1..4
// each have one of their own.
struct BagView {
const uint8_t *invMgr;
int firstLinear;
int slotCount;
uint32_t family;
};
// The backpack and the main bank both live inside the player's own
// invMgr, at different linear bases. Equipped bags and bank bags each
// have an invMgr of their own, reached through the bag item.
//
// The bank resolves only while a banker is open: the server refuses every
// bank move without one, and the engine's own slot reader (`0x6228a0`)
// hides slots 39..68 on the same banker-GUID test. The main bank's GUIDs
// sit in the player's invMgr from login, so without this gate a closed-
// bank sort would plan and send moves the server rejects one by one.
bool ViewForBag(int bagID, BagView *out) {
if ((bagID == -1 || bagID >= 5) && !EquipmentSet::Locations::BankerOpen())
return false;
if (bagID == 0) {
auto *inv = Unit::Identity::PlayerInventoryManager();
if (inv == nullptr)
return false;
*out = {inv, Offsets::INVMGR_BACKPACK_FIRST_SLOT,
Offsets::BACKPACK_NUM_SLOTS, 0};
return true;
}
if (bagID == -1) {
auto *inv = Unit::Identity::PlayerInventoryManager();
if (inv == nullptr)
return false;
*out = {inv, Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
Offsets::INVMGR_BANK_MAIN_LAST_SLOT -
Offsets::INVMGR_BANK_MAIN_FIRST_SLOT + 1,
0};
return true;
}
// Resolve the bag item that owns the container.
int equipSlot = 0;
if (bagID >= 1 && bagID <= 4)
equipSlot = Offsets::INVSLOT_BAG1 + bagID - 1;
else if (bagID >= 5 && bagID <= 10)
equipSlot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT + 1 + (bagID - 5);
else
return false;
auto *bagItem = Item::Location::ResolveEquipmentSlot(equipSlot);
if (bagItem == nullptr)
return false;
auto *bagInv = static_cast<const uint8_t *>(
Item::Location::ContainerInventory(bagItem));
if (bagInv == nullptr)
return false;
const int slots = static_cast<int>(
Game::Read<uint32_t>(bagInv, Offsets::OFF_INVMGR_SLOT_COUNT));
if (slots <= 0)
return false;
// A specialty bag only accepts its own family, and Turtle leaves the
// raw field empty on bags — `BitmaskForRecord` derives it from the
// container subclass, so a quiver reports a family here rather than
// reading as general-purpose. Shared with `C_Item.GetItemFamily`, so
// a bag and the items it holds agree.
uint32_t family = 0;
const int bagItemID = Item::ID::FromCGItem(bagItem);
if (bagItemID > 0) {
auto *rec = Item::PeekRecord(static_cast<uint32_t>(bagItemID));
if (rec != nullptr)
family = Item::BagFamily::BitmaskForRecord(rec);
}
*out = {bagInv, 0, slots, family};
return true;
}
const uint8_t *ItemByGuid(uint64_t guid) {
return static_cast<const uint8_t *>(
Object::ByGuid(Offsets::TYPEMASK_ITEM, guid, "SortBags", 0));
}
const uint8_t *ItemAt(const BagView &v, int slotIndex0, uint64_t *guidOut) {
auto *guids = *reinterpret_cast<const uint64_t *const *>(
v.invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
if (guids == nullptr)
return nullptr;
const uint64_t guid = guids[v.firstLinear + slotIndex0];
const uint64_t guid = Container::Bag::SlotGuid(v, slotIndex0);
if (guid == 0)
return nullptr;
*guidOut = guid;
@@ -417,7 +334,7 @@ std::vector<Group> GatherGroups(const int *bags, int bagCount) {
for (int b = 0; b < bagCount; ++b) {
const int bag = bags[b];
BagView view;
if (!ViewForBag(bag, &view))
if (!Container::Bag::ViewFor(bag, &view))
continue;
for (int i = 0; i < view.slotCount; ++i) {
uint64_t guid = 0;
@@ -590,7 +507,7 @@ bool Place(const int *bags, int bagCount, int mergesPending) {
for (int b = 0; b < bagCount; ++b) {
const int bag = bags[b];
BagView view;
if (!ViewForBag(bag, &view))
if (!Container::Bag::ViewFor(bag, &view))
continue;
for (int i = 0; i < view.slotCount; ++i) {
const int slot = i + 1;
@@ -958,7 +875,7 @@ int __fastcall Script_SortBagsBags(void *L) {
int pushed = 0;
for (int b = 0; b < count; ++b) {
BagView view;
if (!ViewForBag(bags[b], &view))
if (!Container::Bag::ViewFor(bags[b], &view))
continue;
Game::Lua::PushNumber(L, bags[b]);
Game::Lua::PushNumber(L, view.slotCount);
+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
+2 -4
View File
@@ -27,10 +27,8 @@ const Game::Lua::EnumIntegerEntry kMoneyFormattingConstants[] = {
};
void RegisterLuaFunctions() {
Game::Lua::RegisterConstants("MoneyFormattingConstants", kMoneyFormattingConstants,
sizeof(kMoneyFormattingConstants) /
sizeof(kMoneyFormattingConstants[0]),
"MoneyConstants");
Game::Lua::RegisterConstants(
"MoneyFormattingConstants", kMoneyFormattingConstants, "MoneyConstants");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+1 -3
View File
@@ -119,9 +119,7 @@ void OnTick() {
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum(
"Enum", "UICursorType", kCursorTypeEntries,
sizeof(kCursorTypeEntries) / sizeof(kCursorTypeEntries[0]));
Game::Lua::RegisterIntegerEnum("Enum", "UICursorType", kCursorTypeEntries);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+118
View File
@@ -0,0 +1,118 @@
// 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 cursor modes later clients added, shown on 1.12's cursor, and the
// `expandedCursors` cvar that turns them on (0 by default: they change what
// 1.12 shows).
//
// The engine's cursor is a table of 42 images of 32x32, one per mode;
// SendCursorImage copies slot VAR_CURSOR_MODE to the device. SetCursorMode
// stores the mode it is given and sends, so a mode past 41 must never reach
// it. Slot 41 is how the engine shows an image outside the table:
// SetCustomCursor loads a named BLP into it, stores mode 41, sends it and
// keeps the name, and returns at once while that name is already showing. The
// game object classifier uses it for a record-named cursor.
//
// Show() calls SetCustomCursor with the mode's path, so a file of that name (a
// patch MPQ, a loose file) wins, and a repeat each frame costs one name
// compare. 1.12 has none of these files, so on a miss Show() does what
// SetCustomCursor does after its load, with the image embedded in the DLL
// (Texture::Builtin), which a texture load of the same path also gets.
//
// The files are 4.3.4's Interface\Cursor images, under 4.3.4's names. 4.3.4
// already ships StableMaster.blp and Fishing.blp, but only as art a record
// could name. StableMaster is a mode in retail's table (Enum.Cursormode
// StablemasterCursor, listed after Ping and Enchant), not in 4.3.4's; no
// client's table has Fishing.
#include "cursor/Extended.h"
#include "Common.h"
#include "Game.h"
#include "cvar/Factory.h"
#include "texture/Builtin.h"
#include <cstring>
namespace Cursor::Extended {
namespace {
using SetCustomCursor_t = uint32_t(__fastcall *)(const char *path);
using SendCursorImage_t = void(__cdecl *)();
// In mode order, the Unable forms after the others.
const char *const kPaths[] = {
"Interface\\Cursor\\RepairNPC.blp",
"Interface\\Cursor\\Innkeeper.blp",
"Interface\\Cursor\\Quest.blp",
"Interface\\Cursor\\QuestRepeatable.blp",
"Interface\\Cursor\\QuestTurnIn.blp",
"Interface\\Cursor\\StableMaster.blp",
"Interface\\Cursor\\Fishing.blp",
"Interface\\Cursor\\UnableRepairNPC.blp",
"Interface\\Cursor\\UnableInnkeeper.blp",
"Interface\\Cursor\\UnableQuest.blp",
"Interface\\Cursor\\UnableQuestRepeatable.blp",
"Interface\\Cursor\\UnableQuestTurnIn.blp",
"Interface\\Cursor\\UnableStableMaster.blp",
"Interface\\Cursor\\UnableFishing.blp",
};
static_assert(sizeof kPaths / sizeof kPaths[0] == 2 * kUnableOffset, "one path per mode");
CVar::Factory::Handle g_enabled = nullptr;
void Register() {
g_enabled = CVar::Factory::Register(
"expandedCursors",
"Show what an NPC or object offers on the cursor: anvil for repairs, ! and ? for quests, "
"hook for your bobber",
Offsets::CVAR_CATEGORY_GAME, "0");
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
bool Enabled() { return CVar::Factory::GetInt(g_enabled) != 0; }
bool Is(uint32_t mode) { return mode >= kRepairNpc && mode < kRepairNpc + 2 * kUnableOffset; }
bool IsUnable(uint32_t mode) { return Is(mode) && mode >= kRepairNpc + kUnableOffset; }
bool IsQuest(uint32_t mode) {
if (!Is(mode))
return false;
const uint32_t kind = IsUnable(mode) ? mode - kUnableOffset : mode;
return kind >= kQuest && kind <= kQuestTurnIn;
}
const char *Path(uint32_t mode) { return kPaths[mode - kRepairNpc]; }
void Show(uint32_t mode) {
const char *path = Path(mode);
if (reinterpret_cast<SetCustomCursor_t>(Offsets::FUN_SET_CUSTOM_CURSOR)(path) != 0)
return;
const Texture::Builtin::Image *image = Texture::Builtin::Find(path);
if (image == nullptr || image->width != Offsets::CURSOR_IMAGE_SIZE ||
image->height != Offsets::CURSOR_IMAGE_SIZE)
return;
Game::Ref<uint32_t>(Offsets::VAR_CURSOR_MODE) = Offsets::CURSOR_MODE_CUSTOM;
std::memcpy(reinterpret_cast<void *>(Offsets::VAR_CURSOR_IMAGE_CUSTOM), image->texels,
Offsets::CURSOR_IMAGE_SIZE * Offsets::CURSOR_IMAGE_SIZE * sizeof(uint32_t));
reinterpret_cast<SendCursorImage_t>(Offsets::FUN_SEND_CURSOR_IMAGE)();
Common::BoundedCopy(reinterpret_cast<char *>(Offsets::VAR_CUSTOM_CURSOR_NAME), path,
Offsets::CURSOR_FILE_SIZE);
}
} // namespace Cursor::Extended
+57
View File
@@ -0,0 +1,57 @@
// 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 <cstdint>
namespace Cursor::Extended {
// The cursor modes later clients added that 1.12 has no image for, numbered
// on from the engine's last mode (CURSOR_MODE_CUSTOM): 4.3.4's RepairNPC,
// Innkeeper and quest cursors, retail's StableMaster, and the fish hook for
// the fishing bobber. As with the engine's modes, each has an Unable form,
// kUnableOffset further on.
constexpr uint32_t kRepairNpc = Offsets::CURSOR_MODE_CUSTOM + 1;
constexpr uint32_t kInnkeeper = kRepairNpc + 1;
constexpr uint32_t kQuest = kRepairNpc + 2;
constexpr uint32_t kQuestRepeatable = kRepairNpc + 3;
constexpr uint32_t kQuestTurnIn = kRepairNpc + 4;
constexpr uint32_t kStableMaster = kRepairNpc + 5;
constexpr uint32_t kFishing = kRepairNpc + 6;
constexpr uint32_t kUnableOffset = 7;
// Whether the `expandedCursors` cvar asks for these modes. The pickers that
// give them (cursor/Npc, cursor/Fishing) keep 1.12's cursor while it is 0.
bool Enabled();
// Whether `mode` is one of these, in either form.
bool Is(uint32_t mode);
// Whether `mode` is one of these in its Unable form.
bool IsUnable(uint32_t mode);
// Whether `mode` is a quest giver's cursor (Quest, QuestRepeatable,
// QuestTurnIn), in either form.
bool IsQuest(uint32_t mode);
// The image file of one of these modes, `Interface\Cursor\<its name>.blp`.
const char *Path(uint32_t mode);
// Shows one of these modes as the cursor. The engine's SetCursorMode cannot,
// so its hook (cursor/Hover) sends them here.
void Show(uint32_t mode);
} // namespace Cursor::Extended
+67
View File
@@ -0,0 +1,67 @@
// 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/>.
// With `expandedCursors` at 1 (Cursor::Extended::Enabled), the cursor over
// the player's fishing bobber is a fish hook in place of the Interact gear.
//
// A game object's cursor is data. GetCursor (FUN_GAMEOBJECT_GET_CURSOR) gives
// the cursor its record names, else its type handler's: BaseHandlerGetCursor
// for most types, which gives a lock's LockType cursor, else Interact. The
// record's name comes from the server, and tortoise-wow sends it empty; even
// 3.3.5's data names none for the bobber (AzerothCore's template 35591), and
// a bobber has no lock, so every client shows the gear. Later clients ship
// the hook (Interface\Cursor\Fishing.blp) only for data to name.
//
// The fishing node handler (type 17) gets its own cursor slot, the way
// 4.3.4's guild bank type overrides its cursor. The slot still runs
// BaseHandlerGetCursor, which decides everything (0 for a bobber the player
// is not channelling, Unable when not usable), and swaps only the image. The
// classifier passes the mode to SetCursorMode, whose hook (cursor/Hover)
// shows it.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "cursor/Extended.h"
#include <cstdint>
namespace Cursor::Fishing {
namespace {
using HandlerGetCursor_t = uint32_t(__thiscall *)(void *handler);
uint32_t __fastcall FishingNodeCursor(void *handler, void * /*edx*/) {
const uint32_t mode =
reinterpret_cast<HandlerGetCursor_t>(Offsets::FUN_GO_HANDLER_BASE_GET_CURSOR)(handler);
if (!Cursor::Extended::Enabled())
return mode;
if (mode == Offsets::CURSOR_MODE_INTERACT)
return Cursor::Extended::kFishing;
if (mode == Offsets::CURSOR_MODE_INTERACT + Offsets::CURSOR_MODE_UNABLE_OFFSET)
return Cursor::Extended::kFishing + Cursor::Extended::kUnableOffset;
return mode;
}
void Patch() {
Common::RedirectSlot(Offsets::SLOT_FISHING_NODE_HANDLER_GET_CURSOR,
Offsets::FUN_GO_HANDLER_BASE_GET_CURSOR,
reinterpret_cast<const void *>(&FishingNodeCursor));
}
const Game::ModuleAutoRegister _autoreg{&Patch};
} // namespace
} // namespace Cursor::Fishing
+23 -1
View File
@@ -27,14 +27,21 @@
// The spell-targeting and mouselook legs of 0x4828D0 are left out: they
// answer for the mouse, not for the object.
//
// The unit classifier and the fishing node's handler can also pick a mode of
// Cursor::Extended (cursor/Npc, cursor/Fishing). The engine's SetCursorMode
// cannot show one, so the same hook hands those to Cursor::Extended::Show,
// and while capturing records them like any other.
//
// The images are the engine's own: `Interface\Cursor\<name>.blp` from its
// cursor name table, and a game object's named cursor from its record
// (FormatGameObjectCursorFile, `Unable` when not usable).
// (FormatGameObjectCursorFile, `Unable` when not usable). An extended mode's
// image is its embedded file (Cursor::Extended::Path).
#include "cursor/Hover.h"
#include "Game.h"
#include "Offsets.h"
#include "cursor/Extended.h"
#include <cstdio>
@@ -58,6 +65,10 @@ void __fastcall SetCursorMode_h(uint32_t mode) {
g_captured = mode;
return;
}
if (Cursor::Extended::Is(mode)) {
Cursor::Extended::Show(mode);
return;
}
SetCursorMode_o(mode);
}
@@ -122,6 +133,17 @@ bool ImagePath(void *object, uint32_t mode, bool lowPriority, char *buf, std::si
std::snprintf(buf, size, "%s", file);
return true;
}
if (Cursor::Extended::Is(mode)) {
// Retail shows no icon for a quest giver's cursor, not even the
// low-priority pointer (checked in game, 2026-11-03, with the soft
// interact icon and SoftTargetLowPriorityIcons 1). Blizzard_NamePlates
// shows that icon exactly when SetUnitCursorTexture returns true, so
// the refusal is the function's own.
if (Cursor::Extended::IsQuest(mode))
return false;
std::snprintf(buf, size, "%s", Cursor::Extended::Path(mode));
return true;
}
if (mode >= Offsets::CURSOR_MODE_CUSTOM)
return false;
const char *name = Game::Read<const char *>(Offsets::VAR_CURSOR_NAMES + 4 * mode);
+7 -6
View File
@@ -18,9 +18,9 @@
namespace Cursor::Hover {
// The cursor mode (Offsets::CURSOR_MODE_*) the world cursor would show with
// the mouse over `object` — a unit, player, game object or corpse — or 0
// when it shows none. Computes it without showing it.
// The cursor mode (Offsets::CURSOR_MODE_* or Cursor::Extended) the world
// cursor would show with the mouse over `object` — a unit, player, game
// object or corpse — or 0 when it shows none. Computes it without showing it.
uint32_t Mode(void *object);
// For a game object's named cursor (CURSOR_MODE_CUSTOM): whether the player
@@ -28,9 +28,10 @@ uint32_t Mode(void *object);
bool IsUsable(void *object);
// Writes the image file of `mode` over `object` into `buf`. False, with
// `buf` untouched, for no mode, and for the base (resting) mode unless
// `lowPriority` — retail's low-priority image, the plain pointer over a unit
// with nothing to do (SetUnitCursorTexture's includeLowPriority).
// `buf` untouched, for no mode, for a quest giver's cursor (retail shows no
// icon for one), and for the base (resting) mode unless `lowPriority` —
// retail's low-priority image, the plain pointer over a unit with nothing to
// do (SetUnitCursorTexture's includeLowPriority).
bool ImagePath(void *object, uint32_t mode, bool lowPriority, char *buf, std::size_t size);
} // namespace Cursor::Hover
+187
View File
@@ -0,0 +1,187 @@
// 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/>.
// With `expandedCursors` at 1 (Cursor::Extended::Enabled), the cursor over an
// NPC shows what it offers, as in later clients: an anvil for a repairer, a
// hearthstone for an innkeeper, a horseshoe for a stable master, and a quest
// giver's ! or ? (Cursor::Extended). At 0, the default, it stays 1.12's.
//
// 1.12 picks the cursor for a hovered unit in ClassifyHoveredUnit
// (FUN_WORLD_FRAME_CLASSIFY_UNIT). For a unit the player can interact with,
// its NPC flag chain takes the lowest flag bit first: gossip, quest giver,
// vendor, flight master and so on. It never tests the repair flag, and shows
// Interact for an innkeeper. 4.3.4's CursorTrackUnit first asks the quest
// giver's status (GetQuestGiverCursor), then tests the services from repair
// down, with vendor and gossip near the end.
//
// The thunk stands in for the head of 1.12's chain
// (SITE_CLASSIFY_UNIT_NPC_FLAGS). Everything the engine decides before it
// stays the engine's: mouselook, spell targeting, charm, whether the unit can
// be interacted with now, and whether it is beyond the interact reach (EBX,
// the Unable form). With the cvar at 0 the thunk does the instruction it
// replaced and the stock chain runs. The pick goes to the engine's
// SetCursorMode, whose hook (cursor/Hover) shows an extended mode and records
// every mode while the soft-interact code asks for the hover verdict.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "cursor/Extended.h"
#include <cstdint>
namespace Cursor::Npc {
namespace {
// UNIT_NPC_FLAGS bits, as 1.12's chain tests them (tortoise-wow
// UnitDefines.h names them).
constexpr uint32_t kGossip = 0x1;
constexpr uint32_t kVendor = 0x4;
constexpr uint32_t kFlightMaster = 0x8;
constexpr uint32_t kTrainer = 0x10;
constexpr uint32_t kSpiritHealer = 0x20;
constexpr uint32_t kSpiritGuide = 0x40;
constexpr uint32_t kInnkeeper = 0x80;
constexpr uint32_t kBanker = 0x100;
constexpr uint32_t kPetitioner = 0x200;
constexpr uint32_t kTabardDesigner = 0x400;
constexpr uint32_t kBattleMaster = 0x800;
constexpr uint32_t kAuctioneer = 0x1000;
constexpr uint32_t kStableMaster = 0x2000;
constexpr uint32_t kRepair = 0x4000;
// Quest giver statuses (OFF_CGUNIT_QUEST_STATUS). 1.12 numbers them its own
// way (tortoise-wow QuestDef.h); its status-to-marker table (0x80C454) shows
// each one's meaning: 3 a grey ?, 4 a blue ?, 5 a yellow !, 6 and 7 a yellow
// ?. 0 none, 1 unavailable (grey !) and 2 chat (no marker) have no cursor,
// as 4.3.4's none and unavailable have none.
constexpr uint32_t kStatusIncomplete = 3;
constexpr uint32_t kStatusRewardRepeatable = 4;
constexpr uint32_t kStatusAvailable = 5;
constexpr uint32_t kStatusRewardOld = 6;
constexpr uint32_t kStatusReward = 7;
using IsQuestGiverToTalk_t = uint32_t(__stdcall *)(void *unit);
using SetCursorMode_t = void(__fastcall *)(uint32_t mode);
uintptr_t g_classifyReturn = Offsets::CLASSIFY_UNIT_RETURN;
// 4.3.4's quest cursor for a status (GetQuestGiverCursor), matched by the
// status's meaning: 4.3.4 gives Quest to an available and to an incomplete
// quest, QuestRepeatable to a repeatable reward (its blue ?), and
// QuestTurnIn to a reward (its yellow ?).
uint32_t QuestMode(uint32_t status) {
switch (status) {
case kStatusIncomplete:
case kStatusAvailable:
return Cursor::Extended::kQuest;
case kStatusRewardRepeatable:
return Cursor::Extended::kQuestRepeatable;
case kStatusRewardOld:
case kStatusReward:
return Cursor::Extended::kQuestTurnIn;
default:
return 0;
}
}
// 4.3.4's order (CursorTrackUnit) in 1.12's flag bits, or 0 for none.
// 4.3.4's guild master, guild banker and spell click tests have no 1.12
// flag, and its flight master test also reads an icon type; for that flag
// 1.12 shows Taxi, as here. 4.3.4 also takes a cursor that the creature's
// cache record names (IconName), which 1.12's creature query does not carry.
// A stable master gets retail's StableMaster in 4.3.4's place for it, where
// 4.3.4 shows Speak. Where 4.3.4 tests gossip, this also takes the quest
// giver 1.12 would speak to (IsQuestGiverToTalk), so a status without a
// quest cursor keeps 1.12's Speak.
uint32_t ServiceMode(void *unit) {
if (const uint32_t quest = QuestMode(Game::Read<uint32_t>(unit, Offsets::OFF_CGUNIT_QUEST_STATUS)))
return quest;
const uint32_t flags =
Game::Read<uint32_t>(Game::Read<const void *>(unit, Offsets::OFF_UNIT_DESCRIPTOR),
Offsets::OFF_UNIT_DESCRIPTOR_NPC_FLAGS);
if (flags & kRepair)
return Cursor::Extended::kRepairNpc;
if (flags & kInnkeeper)
return Cursor::Extended::kInnkeeper;
if (flags & kFlightMaster)
return Offsets::CURSOR_MODE_TAXI;
if (flags & kTrainer)
return Offsets::CURSOR_MODE_TRAINER;
if (flags & (kSpiritHealer | kSpiritGuide))
return Offsets::CURSOR_MODE_SPEAK;
if (flags & kBanker)
return Offsets::CURSOR_MODE_BUY;
if (flags & (kPetitioner | kTabardDesigner | kBattleMaster))
return Offsets::CURSOR_MODE_SPEAK;
if (flags & kAuctioneer)
return Offsets::CURSOR_MODE_BUY;
if (flags & kStableMaster)
return Cursor::Extended::kStableMaster;
if (flags & kVendor)
return Offsets::CURSOR_MODE_PICKUP;
if ((flags & kGossip) ||
reinterpret_cast<IsQuestGiverToTalk_t>(Offsets::FUN_IS_QUEST_GIVER_TO_TALK)(unit) != 0)
return Offsets::CURSOR_MODE_SPEAK;
return 0;
}
// True when it set the mode. `unable` is the engine's EBX: 1 beyond the
// interact reach, else 0. A unit with no service gets the base mode, where
// the stock chain ends too (ShowBaseCursor).
bool __stdcall Pick(void *unit, uint32_t unable) {
if (!Cursor::Extended::Enabled())
return false;
uint32_t mode = ServiceMode(unit);
if (mode == 0)
mode = Game::Read<uint32_t>(Offsets::VAR_CURSOR_BASE_MODE);
else if (unable != 0)
mode += Cursor::Extended::Is(mode) ? Cursor::Extended::kUnableOffset
: Offsets::CURSOR_MODE_UNABLE_OFFSET;
reinterpret_cast<SetCursorMode_t>(Offsets::FUN_SET_CURSOR_MODE)(mode);
return true;
}
static_assert(Offsets::OFF_UNIT_DESCRIPTOR == 0x110, "NpcFlagsThunk replays [esi + 0x110]");
// Stands in for `mov ecx, [esi + 0x110]`, the head of the chain: the unit in
// ESI, the Unable flag in EBX. When Pick set the mode, it leaves the
// classifier through the chain's epilogue; otherwise it does the instruction
// it replaced and the chain runs.
__declspec(naked) void NpcFlagsThunk() {
__asm {
push ebx
push esi
call Pick
test al, al
jnz picked
mov ecx, dword ptr [esi + 110h]
ret
picked:
add esp, 4
jmp dword ptr [g_classifyReturn]
}
}
void Patch() {
static const uint8_t kFlagsRead[6] = {0x8B, 0x8E, 0x10, 0x01, 0x00, 0x00};
Common::ReplaceWithCall(Offsets::SITE_CLASSIFY_UNIT_NPC_FLAGS, kFlagsRead, sizeof kFlagsRead,
reinterpret_cast<const void *>(&NpcFlagsThunk));
}
const Game::ModuleAutoRegister _autoreg{&Patch};
} // namespace
} // namespace Cursor::Npc
+5 -3
View File
@@ -25,11 +25,13 @@ const char *NormalizeEmpty(const char *s) {
} // namespace
// The id is compared unsigned: a DBC column's "none" is -1, which a signed
// compare passes as less than the count and then indexes records[-1]. The
// engine rejects it with a signed `jl` before its `jg` against the count
// (0x00472797). An unloaded store's count is -1, so it takes no id.
const uint8_t *Record(uintptr_t recordsVar, uintptr_t countVar, uint32_t id) {
if (id == 0)
return nullptr;
const int count = *reinterpret_cast<const int *>(countVar);
if (static_cast<int>(id) > count)
if (id == 0 || count <= 0 || id > static_cast<uint32_t>(count))
return nullptr;
const uint8_t *const *records =
*reinterpret_cast<const uint8_t *const *const *>(recordsVar);
+2 -1
View File
@@ -39,7 +39,8 @@ namespace DBC {
// Resolves `id` to its record pointer in the table whose records-base
// global is at `recordsVar` and count global at `countVar`. Returns
// `nullptr` for `id == 0`, OOR ids (> count), and unpopulated slots.
// `nullptr` for `id == 0`, OOR ids (> count, which includes -1 and every
// other negative id), and unpopulated slots.
// Caller reads whatever fields it needs off the returned record.
//
// Use this when `LocalizedField` / `StringField` don't fit — e.g. for
+2 -3
View File
@@ -206,9 +206,8 @@ void RegisterLuaFunctions() {
&Script_GetSelfResurrectOptions, &kGetSelfResurrectOptions);
Game::Lua::RegisterTableFunction("C_DeathInfo", "UseSelfResurrectOption",
&Script_UseSelfResurrectOption, &kUseSelfResurrectOption);
Game::Lua::RegisterIntegerEnum("Enum", "SelfResurrectOptionType", kOptionTypeEntries,
sizeof(kOptionTypeEntries) / sizeof(kOptionTypeEntries[0]),
"DeathInfo");
Game::Lua::RegisterIntegerEnum(
"Enum", "SelfResurrectOptionType", kOptionTypeEntries, "DeathInfo");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+1 -3
View File
@@ -47,7 +47,6 @@
#include <cmath>
#include <cstdio>
#include <iterator>
namespace Duration::Object {
@@ -451,8 +450,7 @@ const Game::Doc::Function kCreateDuration{"Creates a zero duration object that c
void RegisterLuaFunctions() {
CreateMetatable(Lua::State());
Lua::RegisterTableFunction("C_DurationUtil", "CreateDuration", &Script_CreateDuration, &kCreateDuration);
Lua::RegisterIntegerEnum("Enum", "DurationTimeModifier", kModifiers, static_cast<int>(std::size(kModifiers)),
"LuaDurationObjectShared");
Lua::RegisterIntegerEnum("Enum", "DurationTimeModifier", kModifiers, "LuaDurationObjectShared");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+2 -3
View File
@@ -58,7 +58,6 @@
#include <cmath>
#include <cstdio>
#include <cstring>
#include <iterator>
#include <string>
#include <vector>
@@ -780,8 +779,8 @@ void RegisterLuaFunctions() {
CreateMetatable(Lua::State());
Lua::RegisterTableFunction("C_DurationUtil", "CreateDurationTextBinding", &Script_CreateDurationTextBinding,
&kCreateDurationTextBinding);
Lua::RegisterIntegerEnum("Enum", "DurationTextBindingProperty", kProperties,
static_cast<int>(std::size(kProperties)), "DurationTextBindingShared");
Lua::RegisterIntegerEnum(
"Enum", "DurationTextBindingProperty", kProperties, "DurationTextBindingShared");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+2 -2
View File
@@ -125,8 +125,8 @@ const Game::Lua::FrameMethodEntry g_methods[] = {
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+1 -3
View File
@@ -83,9 +83,7 @@ const Game::Doc::Method g_methodDocs[] = {
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])), g_methodDocs,
static_cast<int>(sizeof(g_methodDocs) / sizeof(g_methodDocs[0])));
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods, g_methodDocs);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+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)(
+1 -3
View File
@@ -86,9 +86,7 @@ void RegisterLuaFunctions() {
Common::RedirectCall(Offsets::CALL_EDITBOX_IME_COMPOSITION_INSERT, Offsets::FUN_EDITBOX_INSERT,
reinterpret_cast<const void *>(&CompositionInsert));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])), g_methodDocs,
static_cast<int>(sizeof(g_methodDocs) / sizeof(g_methodDocs[0])));
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods, g_methodDocs);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+1 -2
View File
@@ -30,8 +30,7 @@ const Game::Lua::EnumIntegerEntry kEntries[] = {
};
void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum("Enum", "InputContext", kEntries,
sizeof(kEntries) / sizeof(kEntries[0]), "InputConstants");
Game::Lua::RegisterIntegerEnum("Enum", "InputContext", kEntries, "InputConstants");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+1 -3
View File
@@ -234,9 +234,7 @@ const Game::Lua::FrameMethodEntry g_methods[] = {
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY),
g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
@@ -1,6 +1,7 @@
## Interface: 11200
## Title: !!!ClassicAPI
## Notes: Library to emulate aspects of the modern WoW API on 1.12.1 / Lua 5.0.
## Notes-ruRU: Библиотека, эмулирующая аспекты современного API WoW на 1.12.1 / Lua 5.0.
## Version: DEV
## Author: Brues
## X-Website: https://github.com/brues-code/ClassicAPI
@@ -143,6 +144,18 @@ 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
Menu\Classic\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
@@ -0,0 +1,212 @@
-- 4.3.4's DressUpItemLink (FrameXML/ModelFrames.lua): the whole link goes to
-- the model, so DressUpModel:TryOn shows the link's enchant glow, and an item
-- the model cannot wear does not open the window. 1.12.1's version passes
-- only the item ID.
--
-- Differences from the 4.3.4 source:
-- - The link goes through DressUpItem, which 4.3.4 folded in. Here
-- Blizzard_AuctionUI replaces DressUpItem to show its own model, and
-- some clients add steps to it (Turtle dresses the tabard).
-- - An uncached item skips the IsDressableItem test, which reads only the
-- cache. 1.12.1 opens no tooltip before a chat link's Control-click, so
-- the test would drop most chat links; TryOn loads the item instead.
-- - 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.
-- 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
@@ -23,15 +23,15 @@
-- - The text is a ScrollingEditBoxTemplate with a MinimalScrollBar, the
-- ScrollBox pair retail uses for scrolling edit boxes, not retail's
-- ScrollFrameTemplate, which ClassicAPI does not port.
-- - The stack is retail's GetErrorData without the locals, taken in the
-- SetText slot: GetErrorCallstackHeight finds the code that failed
-- however many handlers sit between it and the slot. 1.12 has no
-- debuglocals.
-- - The stack and locals are retail's GetErrorData, taken in the SetText
-- slot: GetErrorCallstackHeight finds the code that failed however many
-- handlers sit between it and the slot. No |K strings to remove, and no
-- C_StringUtil.EscapeDecimalNonPrintables.
-- - No scriptErrors cvar (1.12 always shows errors), no LUA_WARNING, no
-- gamepad legend.
-- - Lua 5.0: no `...` expression, no # operator.
local function GetErrorStack()
local function GetErrorData()
-- Example of how debug stack level is calculated
-- Current stack: [1, 2, 3, 4, 5] (current function is at 1, total current height is 5)
-- Stack at time of error: [1, 2] (these are currently now index 4 and 5, but at the time of error the stack height is 2)
@@ -40,13 +40,20 @@ local function GetErrorStack()
local errorCallStackHeight = GetErrorCallstackHeight();
local errorStackOffset = errorCallStackHeight and (errorCallStackHeight - 1);
local debugStackLevel = currentStackHeight - (errorStackOffset or 0);
return debugstack(debugStackLevel);
local skipFunctionsAndUserdata = true;
local stack = debugstack(debugStackLevel);
local locals = debuglocals(debugStackLevel, skipFunctionsAndUserdata);
return stack, locals;
end
local ERROR_FORMAT = "|cffffd200Message:|r|cffffffff %s|r\n"
.. "|cffffd200Time:|r|cffffffff %s|r\n"
.. "|cffffd200Count:|r|cffffffff %s|r\n"
.. "|cffffd200Stack:|r\n"
.. "|cffffffff%s|r\n"
.. "|cffffd200Locals:|r\n"
.. "|cffffffff%s|r";
local INDEX_ORDER_FORMAT = "%d / %d";
@@ -68,15 +75,16 @@ function ScriptErrorsFrame_OnLoad(self)
ScrollUtil.RegisterScrollBoxWithScrollBar(scrollBox, self.ScrollBar);
ScriptErrors_Message.SetText = function(_, message)
ScriptErrorsFrame_OnError(message, GetErrorStack());
ScriptErrorsFrame_OnError(message, GetErrorData());
end
ScriptErrors.Show = function() end
end
function ScriptErrorsFrame_OnError(message, stack)
function ScriptErrorsFrame_OnError(message, stack, locals)
local self = ScriptErrorsFrame;
message = tostring(message);
stack = stack or "";
locals = locals or "<none>";
local messageKey = message .. "\n" .. stack;
local index = self.seen[messageKey];
@@ -84,7 +92,7 @@ function ScriptErrorsFrame_OnError(message, stack)
local errorData = self.errorData[index];
errorData.count = errorData.count + 1;
else
table.insert(self.errorData, { count = 1, message = message, time = date(), stack = stack });
table.insert(self.errorData, { count = 1, message = message, time = date(), stack = stack, locals = locals });
index = table.getn(self.errorData);
self.seen[messageKey] = index;
end
@@ -106,7 +114,7 @@ function ScriptErrorsFrame_Update()
-- SetText puts the cursor and the scroll back at the top.
local errorData = self.errorData[self.index];
if ( errorData ) then
self.ScrollingEditBox:SetText(string.format(ERROR_FORMAT, errorData.message, errorData.time, errorData.count, errorData.stack));
self.ScrollingEditBox:SetText(string.format(ERROR_FORMAT, errorData.message, errorData.time, errorData.count, errorData.stack, errorData.locals));
self.ScrollingEditBox:GetEditBox():HighlightText(0);
else
self.ScrollingEditBox:ClearText();
@@ -1,20 +1,22 @@
-- ClassicAPI's own entry in the AddOn Options window
-- (FrameXML\InterfaceOptions.lua): a ClassicAPI page with one page under it
-- for each feature. Every cvar that ClassicAPI registers has a control here,
-- and so does the player cache.
-- and so do the player cache and ClassicAPI's key bindings.
--
-- Each control reads and writes exactly one setting, and writes it only when
-- the player uses that control. So the pages have no okay, cancel or default:
-- Okay, Cancel and Escape only close the window, and Defaults does nothing
-- here. A page reads its settings again each time it is shown.
-- here. A page reads its settings again each time it is shown. A key binding
-- saves at once into the binding set in use (account or character).
--
-- A page's controls scroll under its heading in a ScrollBox, so a page holds
-- as many as its feature has. Dropdowns and number boxes sit on the same row
-- as their label.
--
-- Every string is a global from locales\enUS.lua: retail's GlobalStrings key
-- where retail has the string (ENABLE_INTERACT_TEXT and the like), a
-- CLASSICAPI_ one where it does not.
-- Every string is a global: the client's own (KEY_BOUND, NOT_BOUND), or one
-- from locales\enUS.lua - retail's GlobalStrings key where retail has the
-- string (ENABLE_INTERACT_TEXT and the like), a CLASSICAPI_ one where it
-- does not.
--
-- Lua 5.0: no `...` expression or select().
@@ -72,13 +74,20 @@ local rememberSetting = {
local LABEL_X = 48
local CONTROL_X = 200
-- pfUI's Interface Options skin finds the controls by these names.
-- pfUI's Interface Options skin finds the controls and the page scroll bars
-- by these names.
local numControls = 0
local function NextName()
numControls = numControls + 1
return "ClassicAPIOptionsControl" .. numControls
end
local numScrollBars = 0
local function NextScrollBarName()
numScrollBars = numScrollBars + 1
return "ClassicAPIOptionsScrollBar" .. numScrollBars
end
local function RefreshControls(page)
page.updating = true
for _, control in ipairs(page.controls) do
@@ -114,7 +123,7 @@ local function NewPage(name, parent, title, subtitle)
scrollBox:SetPoint("TOPLEFT", page, "TOPLEFT", 0, top)
scrollBox:SetPoint("BOTTOMRIGHT", page, "BOTTOMRIGHT", -20, 8)
scrollBox:EnableMouseWheel(true)
local scrollBar = CreateFrame("EventFrame", nil, page, "MinimalScrollBar")
local scrollBar = CreateFrame("EventFrame", NextScrollBarName(), page, "MinimalScrollBar")
scrollBar:SetPoint("TOPLEFT", scrollBox, "TOPRIGHT", 4, 0)
scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", 4, 0)
@@ -302,6 +311,215 @@ local function AddSlider(page, label, tooltip, setting, minValue, maxValue, step
AddControl(page, slider, setting, 56)
end
-- ---------------------------------------------------------------------------
-- Key bindings
-- ---------------------------------------------------------------------------
-- A key binding row, as on retail's settings pages: the binding's name, then
-- a button for each of its two keys. Left click a button and press a key, a
-- mouse button or the wheel to bind it there; Escape cancels. Right click a
-- button to unbind its key.
--
-- While a button listens, `listener` covers the screen above the window and
-- takes every key, click and wheel turn, as retail's KeybindListener and its
-- input blocker do. A left or right click on it cancels. The binding itself
-- is 1.12's Key Bindings window's: the same chord order, the same check for
-- an action that loses its last key, the same put-back when SetBinding
-- refuses a key.
--
-- Messages go on the page's output line, under its controls.
local LISTEN_BUTTONS = {
MiddleButton = "BUTTON3",
Button4 = "BUTTON4",
Button5 = "BUTTON5",
}
-- Keys that only modify another key, as the Key Bindings window skips them.
local IGNORED_KEYS = { UNKNOWN = true, SHIFT = true, CTRL = true, ALT = true }
local listener = CreateFrame("Button", nil, UIParent)
listener:SetAllPoints(UIParent)
listener:SetFrameStrata("FULLSCREEN_DIALOG")
listener:EnableMouse(true)
listener:EnableMouseWheel(true)
listener:EnableKeyboard(true)
listener:RegisterForClicks("LeftButtonUp", "RightButtonUp", "MiddleButtonUp", "Button4Up", "Button5Up")
listener:Hide()
local function BindingName(action)
return GetBindingText(action, "BINDING_NAME_")
end
-- GetBindingKey's keys as a list: a row's button `i` shows keys[i].
local function BindingKeys(action)
return { GetBindingKey(action) }
end
local function StopListening()
local row = listener.row
if not row then return end
listener.row = nil
listener:Hide()
row.page.output:SetText("")
RefreshControls(row.page)
end
local function StartListening(row, index)
-- A number box that keeps the focus would take the key instead.
for _, control in ipairs(row.page.controls) do
if control.ClearFocus then control:ClearFocus() end
end
listener.row = row
listener.index = index
listener:Show()
row.page.output:SetText(format(SETTINGS_BIND_KEY_TO_COMMAND_OR_CANCEL, BindingName(row.action), GetBindingText("ESCAPE", "KEY_")))
RefreshControls(row.page)
end
-- 1.12's chord order: ALT-CTRL-SHIFT-key.
local function Chord(key)
if IsShiftKeyDown() then key = "SHIFT-" .. key end
if IsControlKeyDown() then key = "CTRL-" .. key end
if IsAltKeyDown() then key = "ALT-" .. key end
return key
end
local function Bind(key)
if IGNORED_KEYS[key] then return end
local chord = Chord(key)
local current = GetBindingAction(chord)
if current == "SCREENSHOT" then
Screenshot()
return
end
local row, index = listener.row, listener.index
StopListening()
if key == "ESCAPE" and current == "TOGGLEGAMEMENU" then return end
local action = row.action
local message = KEY_BOUND
if current ~= "" and current ~= action then
local old1, old2 = GetBindingKey(current)
if (not old1 or old1 == chord) and (not old2 or old2 == chord) then
message = format(KEY_UNBOUND_ERROR, BindingName(current))
end
end
-- Unbind both keys, then bind them back in order with the new key in
-- the slot that was clicked.
local keys = BindingKeys(action)
for i = 1, 2 do
if keys[i] then SetBinding(keys[i]) end
end
for i = 1, 2 do
local bind = (i == index) and chord or keys[i]
if bind and not SetBinding(bind, action) then
if keys[i] then SetBinding(keys[i], action) end
message = KEYBINDINGFRAME_MOUSEWHEEL_ERROR
end
end
SaveBindings(GetCurrentBindingSet())
RefreshControls(row.page)
row.page.output:SetText(message)
end
listener:SetScript("OnKeyDown", function() Bind(arg1) end)
listener:SetScript("OnMouseWheel", function()
Bind(arg1 > 0 and "MOUSEWHEELUP" or "MOUSEWHEELDOWN")
end)
listener:SetScript("OnClick", function()
local button = LISTEN_BUTTONS[arg1]
if button then Bind(button) else StopListening() end
end)
local function Unbind(row, index)
local key = BindingKeys(row.action)[index]
if key then
SetBinding(key)
SaveBindings(GetCurrentBindingSet())
end
GameTooltip:Hide()
row.page.output:SetText("")
RefreshControls(row.page)
end
-- The page's output line, made with its first key binding row. It takes the
-- bottom of the page from the ScrollBox.
local function EnsureOutput(page)
if page.output then return end
local output = page:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
output:SetPoint("BOTTOMLEFT", page, "BOTTOMLEFT", 16, 12)
output:SetPoint("RIGHT", page, "RIGHT", -16, 0)
output:SetJustifyH("LEFT")
page.scrollBox:SetPoint("BOTTOMRIGHT", page, "BOTTOMRIGHT", -20, 30)
page.output = output
page:SetScript("OnHide", function()
if listener.row and listener.row.page == this then StopListening() end
this.output:SetText("")
end)
end
local function AddKeybinding(page, action)
EnsureOutput(page)
AddRowLabel(page, BindingName(action))
local row = { page = page, action = action, buttons = {} }
for i = 1, 2 do
local button = CreateFrame("Button", NextName(), page.content, "UIPanelButtonTemplate2")
button:SetWidth(86)
button:SetHeight(22)
if i == 1 then
button:SetPoint("TOPLEFT", page.content, "TOPLEFT", CONTROL_X, page.y - 5)
else
button:SetPoint("LEFT", row.buttons[1], "RIGHT", 0, 0)
end
button:SetTextFontObject(GameFontHighlightSmall)
button:SetHighlightFontObject(GameFontHighlightSmall)
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
button.index = i
button:SetScript("OnClick", function()
if arg1 == "RightButton" then
Unbind(row, this.index)
else
StartListening(row, this.index)
end
end)
button:SetScript("OnEnter", function()
local key = BindingKeys(action)[this.index]
if not key then return end
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(format(KEY_BINDING_NAME_AND_KEY, BindingName(action), GetBindingText(key, "KEY_")),
HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
GameTooltip:AddLine(KEY_BINDING_TOOLTIP, NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b, 1)
GameTooltip:Show()
end)
button:SetScript("OnLeave", function() GameTooltip:Hide() end)
row.buttons[i] = button
end
row.Update = function(self)
local keys = BindingKeys(action)
for i, button in ipairs(self.buttons) do
if keys[i] then
button:SetText(GetBindingText(keys[i], "KEY_"))
button:SetAlpha(1)
else
button:SetText(NORMAL_FONT_COLOR_CODE .. NOT_BOUND .. FONT_COLOR_CODE_CLOSE)
button:SetAlpha(0.8)
end
if listener.row == self and listener.index == i then
button:LockHighlight()
else
button:UnlockHighlight()
end
end
end
AddControl(page, row, nil, 32)
end
-- ---------------------------------------------------------------------------
-- ClassicAPI
-- ---------------------------------------------------------------------------
@@ -316,6 +534,8 @@ local main = NewPage("ClassicAPI", nil, "ClassicAPI " .. version, CLASSICAPI_OPT
local interact = NewPage(CLASSICAPI_INTERACT_KEY, main.name)
AddCheckbox(interact, ENABLE_INTERACT_TEXT, OPTION_TOOLTIP_ENABLE_INTERACT, SoftTargetSetting("SoftTargetInteract"))
AddKeybinding(interact, "INTERACTTARGET")
AddKeybinding(interact, "INTERACTMOUSEOVER")
AddDropDown(interact, CLASSICAPI_INTERACT_DIRECTION, CLASSICAPI_INTERACT_DIRECTION_TOOLTIP, NumberCVarSetting("SoftTargetInteractArc"), ARC_CHOICES)
AddNumberBox(interact, CLASSICAPI_INTERACT_RANGE, CLASSICAPI_RANGE_TOOLTIP, NumberCVarSetting("SoftTargetInteractRange"))
AddCheckbox(interact, CLASSICAPI_STRICT_INTERACT_RANGE, CLASSICAPI_STRICT_INTERACT_RANGE_TOOLTIP, CVarSetting("SoftTargetInteractRangeIsHard"))
@@ -378,12 +598,26 @@ AddCheckbox(bags, CLASSICAPI_IGNORE_BACKPACK, CLASSICAPI_IGNORE_BACKPACK_TOOLTIP
AddCheckbox(bags, CLASSICAPI_IGNORE_BANK, CLASSICAPI_IGNORE_BANK_TOOLTIP, CVarSetting("bankAutosortDisabled"))
AddCheckbox(bags, CLASSICAPI_HIDE_EQUIPMENT_SETS, CLASSICAPI_HIDE_EQUIPMENT_SETS_TOOLTIP, CVarSetting("dontShowEquipmentSetsOnItems"))
AddHeader(bags, LOOT)
AddKeybinding(bags, "AOELOOT")
-- ---------------------------------------------------------------------------
-- Interface
-- ---------------------------------------------------------------------------
local interface = NewPage(CLASSICAPI_INTERFACE, main.name)
-- The label ends with some of the cursors the setting shows. The DLL serves
-- each image at its Interface\Cursor path, and the markup stays out of the
-- translated string.
local expandedCursorIcons = ""
for _, name in ipairs({ "RepairNPC", "Innkeeper", "StableMaster", "Quest", "QuestTurnIn", "Fishing" }) do
expandedCursorIcons = expandedCursorIcons .. " |TInterface\\Cursor\\" .. name .. ":16|t"
end
AddHeader(interface, CLASSICAPI_CURSOR)
AddCheckbox(interface, CLASSICAPI_EXPANDED_CURSORS .. expandedCursorIcons, CLASSICAPI_EXPANDED_CURSORS_TOOLTIP, CVarSetting("expandedCursors"))
AddHeader(interface, CLASSICAPI_NAMEPLATES)
AddSlider(interface, CLASSICAPI_TARGET_NAMEPLATE_ALPHA, CLASSICAPI_TARGET_NAMEPLATE_ALPHA_TOOLTIP, NumberCVarSetting("nameplateSelectedAlpha"), 0, 1, 0.05)
AddSlider(interface, CLASSICAPI_OTHER_NAMEPLATE_ALPHA, CLASSICAPI_OTHER_NAMEPLATE_ALPHA_TOOLTIP, NumberCVarSetting("nameplateNotSelectedAlpha"), 0, 1, 0.05)
@@ -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
@@ -8,6 +8,9 @@ BINDING_NAME_FOCUSTARGET = "Fokus setzen";
BINDING_NAME_INTERACTMOUSEOVER = "Mit Mouseover interagieren";
BINDING_NAME_INTERACTTARGET = "Mit Ziel interagieren";
BINDING_NAME_TARGETFOCUS = "Fokus anvisieren";
CLASSICAPI_CURSOR = "Mauszeiger";
CLASSICAPI_EXPANDED_CURSORS = "Erweiterte Mauszeiger anzeigen";
CLASSICAPI_EXPANDED_CURSORS_TOOLTIP = "Wenn Ihr mit der Maus auf einen NSC oder ein Objekt zeigt, stellt der Mauszeiger dar, was Ihr damit tun könnt. Zum Beispiel einen Amboss für Reparaturen, ! oder ? für Quests und einen Haken für Euren Angelschwimmer.";
COMBAT_LABEL = "Kampf";
COUNTDOWN_FOR_COOLDOWNS_TEXT = "Zahlen für Abklingzeiten anzeigen";
CONFIRM_DELETE_EQUIPMENT_SET = "Seid Ihr sicher, dass Ihr das Ausrüstungsset \"%s\" löschen wollt?";
@@ -69,6 +72,13 @@ OPTION_TOOLTIP_COUNTDOWN_FOR_COOLDOWNS = "Markieren, um für Abklingzeiten einen
OPTION_TOOLTIP_ENABLE_INTERACT = "Erlaubt Interaktion mit NSCs und Objekten per Tastendruck";
OPTION_TOOLTIP_INTERACT_ICONS = "Legt fest, welche Symbole über NSCs und Gegenständen angezeigt werden, wenn die Interaktionstastenfunktion aktiviert ist.";
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";
GEARSETS_POPUP_TEXT = "Setnamen eingeben (Max. 16 Zeichen):";
@@ -13,15 +13,16 @@ CLASSICAPI_ACTION_TARGETING = "Action Targeting";
CLASSICAPI_ADVANCED = "Advanced";
CLASSICAPI_ALL_SOFT_TARGET_ICONS = "Icons for All Soft Targets";
CLASSICAPI_AUTO_DISMOUNT = "Auto Dismount";
CLASSICAPI_AUTO_DISMOUNT_TOOLTIP = "Dismount when you cast a spell while you are mounted.";
CLASSICAPI_AUTO_DISMOUNT_TOOLTIP = "Dismount when you cast a spell, use an object, loot, attack, or open the flight map while you are mounted.";
CLASSICAPI_AUTO_STAND = "Auto Stand";
CLASSICAPI_AUTO_STAND_TOOLTIP = "Stand up when you cast a spell while you sit.";
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.";
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";
CLASSICAPI_COMBAT_LOG_RETENTION_TOOLTIP = "How many seconds the combat log keeps entries for addons.";
CLASSICAPI_CURSOR = "Cursor";
CLASSICAPI_DIRECTION_ANY = "Any direction";
CLASSICAPI_DIRECTION_CONE = "Within 30 degrees";
CLASSICAPI_DIRECTION_FRONT = "Directly in front";
@@ -30,6 +31,8 @@ CLASSICAPI_ENEMY_DIRECTION = "Enemy Direction";
CLASSICAPI_ENEMY_ICON = "Show Enemy Icon";
CLASSICAPI_ENEMY_ICON_TOOLTIP = "Shows an icon over the enemy that action targeting picks.";
CLASSICAPI_ENEMY_RANGE = "Enemy Range";
CLASSICAPI_EXPANDED_CURSORS = "Show Expanded Cursors";
CLASSICAPI_EXPANDED_CURSORS_TOOLTIP = "When you point at an NPC or an object, the cursor shows what you can do with it. For example, an anvil for repairs, ! or ? for quests, and a hook for your fishing bobber.";
CLASSICAPI_FRIEND_DIRECTION = "Friend Direction";
CLASSICAPI_FRIEND_ICON = "Show Friend Icon";
CLASSICAPI_FRIEND_ICON_TOOLTIP = "Shows an icon over the friendly unit that friendly action targeting picks.";
@@ -159,6 +162,13 @@ OPTION_TOOLTIP_COUNTDOWN_FOR_COOLDOWNS = "Check this to show a number countdown
OPTION_TOOLTIP_ENABLE_INTERACT = "Allows you to interact with NPCs and objects using a keypress";
OPTION_TOOLTIP_INTERACT_ICONS = "Controls which icons appear above NPCs and objects when the Interact Key feature is enabled";
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "Stop auto attacking when you switch targets.";
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>";
FOCUSTARGET = "Focus Target";
@@ -8,6 +8,9 @@ BINDING_NAME_FOCUSTARGET = "Establecer objetivo";
BINDING_NAME_INTERACTMOUSEOVER = "Interactuar al pasar el ratón";
BINDING_NAME_INTERACTTARGET = "Interactuar con objetivo";
BINDING_NAME_TARGETFOCUS = "Seleccionar enfocado";
CLASSICAPI_CURSOR = "Cursor";
CLASSICAPI_EXPANDED_CURSORS = "Mostrar cursores ampliados";
CLASSICAPI_EXPANDED_CURSORS_TOOLTIP = "Al pasar el ratón sobre un PNJ o un objeto, el cursor muestra lo que puedes hacer con él. Por ejemplo, un yunque para las reparaciones, ! o ? para las misiones y un anzuelo para tu corcho de pesca.";
COMBAT_LABEL = "Combate";
COUNTDOWN_FOR_COOLDOWNS_TEXT = "Mostrar tiempos de reutilización en cifras";
CONFIRM_DELETE_EQUIPMENT_SET = "¿Seguro que quieres eliminar el equipamiento %s?";
@@ -69,6 +72,13 @@ OPTION_TOOLTIP_COUNTDOWN_FOR_COOLDOWNS = "Activa esta opción para mostrar una c
OPTION_TOOLTIP_ENABLE_INTERACT = "Te permite interactuar con PNJ y objetos pulsando una tecla.";
OPTION_TOOLTIP_INTERACT_ICONS = "Controla los iconos que aparecen sobre los PNJ y objetos cuando se habilita la opción de tecla Interactuar";
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";
GEARSETS_POPUP_TEXT = "Nombra este equipamiento (Máx. 16 carac.):";
@@ -8,6 +8,9 @@ BINDING_NAME_FOCUSTARGET = "Définir cible";
BINDING_NAME_INTERACTMOUSEOVER = "Interagir par survol de la souris";
BINDING_NAME_INTERACTTARGET = "Interagir avec la cible";
BINDING_NAME_TARGETFOCUS = "Reprendre cible";
CLASSICAPI_CURSOR = "Curseur";
CLASSICAPI_EXPANDED_CURSORS = "Afficher les curseurs étendus";
CLASSICAPI_EXPANDED_CURSORS_TOOLTIP = "Quand vous survolez un PNJ ou un objet, le curseur indique ce que vous pouvez en faire. Par exemple, une enclume pour les réparations, ! ou ? pour les quêtes et un hameçon pour votre flotteur de pêche.";
COMBAT_LABEL = "Combat";
COUNTDOWN_FOR_COOLDOWNS_TEXT = "Temps de recharge en chiffres";
CONFIRM_DELETE_EQUIPMENT_SET = "Voulez-vous vraiment supprimer l'ensemble d'équipement %s ?";
@@ -69,6 +72,13 @@ OPTION_TOOLTIP_COUNTDOWN_FOR_COOLDOWNS = "Cochez pour afficher les temps de rech
OPTION_TOOLTIP_ENABLE_INTERACT = "Vous permet d’interagir avec les PNJ et les objets en appuyant sur une touche";
OPTION_TOOLTIP_INTERACT_ICONS = "Contrôle les icônes qui s’affichent au-dessus des PNJ et des objets quand la fonction Commande d’interaction est activée";
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";
ITEMS_VARIABLE_QUANTITY = "%d |4objet:objets;";
@@ -5,8 +5,18 @@ 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 = "억";
THIRD_NUMBER = "억";
FOURTH_NUMBER = "조";
KEY_BINDING_NAME_AND_KEY = "%s (%s)";
KEY_BINDING_TOOLTIP = "<지정을 해제하려면 오른쪽 클릭>";
SETTINGS_BIND_KEY_TO_COMMAND_OR_CANCEL = "\"%s\" 단축키 지정 또는 %s 눌러 취소";
CLASSICAPI_CURSOR = "커서";
CLASSICAPI_EXPANDED_CURSORS = "확장 커서 표시";
CLASSICAPI_EXPANDED_CURSORS_TOOLTIP = "NPC나 물체를 가리키면 커서가 할 수 있는 행동을 보여줍니다. 예를 들어 수리는 모루, 퀘스트는 ! 또는 ?, 낚시찌는 낚싯바늘로 표시됩니다.";

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