Compare commits

...
3 Commits
Author SHA1 Message Date
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
15 changed files with 713 additions and 248 deletions
+3 -3
View File
@@ -219,9 +219,9 @@ open the game menu and click AddOn Options, then ClassicAPI.
| [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, talk to an NPC, use an object or an item, or open the flight map. |
| [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. |
+31 -4
View File
@@ -963,7 +963,7 @@ build instructions.
- [`UnitCastingDuration(unit)` / `UnitChannelDuration(unit)`](#unitcastingdurationunit--unitchanneldurationunit)
- [`C_Spell.GetSpellLevelInfo(spellID)`](#c_spellgetspelllevelinfospellid)
- [`GetSpellRequiredTargetLevel(spellID)`](#getspellrequiredtargetlevelspellid)
- [Cast settings (`autoStand`, `autoDismount`, `autoUnshift`)](#cast-settings-autostand-autodismount-autounshift)
- [Stand, dismount and form settings (`autoStand`, `autoDismount`, `autoUnshift`)](#stand-dismount-and-form-settings-autostand-autodismount-autounshift)
- [SpellActivationOverlay](#spellactivationoverlay)
- [`C_SpellActivationOverlay.IsSpellOverlayed(spellID)` / `IsSpellOverlayed(spellID)`](#c_spellactivationoverlayisspelloverlayedspellid--isspelloverlayedspellid)
@@ -22474,10 +22474,11 @@ with `SPELL_FAILED_LOWLEVEL`; higher ranks auto-downrank.
> 1.12 client's `Spell.dbc`, so the `spellLevel` rule is the only
> client-readable target-level mechanism.
### Cast settings (`autoStand`, `autoDismount`, `autoUnshift`)
### Stand, dismount and form settings (`autoStand`, `autoDismount`, `autoUnshift`)
Three settings change what occurs when your state blocks a cast. Each one is
`0` (the default) or `1`. At `0`, the cast fails with the usual error.
Three settings change what occurs when your state blocks a cast or another
action. Each one is `0` (the default) or `1`. At `0`, the action fails with
the usual error.
| Setting | At `1`, before the cast |
|---|---|
@@ -22493,6 +22494,32 @@ cast can leave. For example, Healing Touch in Bear Form and Renew in
Shadowform. A spell that needs a different form or stance ("Must be in ...")
still fails, and you stay in your form.
The settings also apply to these actions:
| Action | `autoStand` | `autoDismount` | `autoUnshift` |
|---|---|---|---|
| Talk to an NPC | — | — | You leave the form. |
| Use an object | — | You dismount. You stay mounted at a mailbox. | You leave the form. |
| Loot a creature | You stand up. | You dismount. | — |
| Loot a corpse | You stand up. | — | — |
| Use an item | — | — | You leave the form. |
| Attack | — | You dismount. | — |
| Open the flight map | You stand up. | You dismount. | You leave the form. |
A form that can talk and use objects, for example Shadowform, stays on when
you talk to an NPC or use an object. While `autoUnshift` is `1`, the cursor
over an NPC or an object shows its usual icon in such a form. While
`autoDismount` is `1`, the cursor shows the attack icon over an enemy while
you are mounted.
You dismount to attack only when the target is in melee range. Farther away,
the attack does not start and "Out of range." shows. With click-to-move on,
no error shows and you walk to the target; click the target again when you
arrive.
To open a locked object, such as a chest, you cast a spell. Thus the cast
rules above apply.
```
/console autoDismount 1
```
+121 -24
View File
@@ -2894,23 +2894,14 @@ enum Offsets {
UNIT_POWER_MIN_TYPE = 0,
UNIT_POWER_MAX_TYPE = 4, // happiness; types 5/6 only valid post-WotLK
// Per-power-type display divisor. Raw descriptor values get
// divided by this before they're surfaced through Lua. Vanilla
// 1.12 stores rage as 0..1000 (internally) and divides by 10 to
// show 0..100; happiness is stored at 1000x scale and divides
// by 1000. Engine reads from `Script_UnitMana` at
// `0x006E7130 + type * 4` (= `0x0086F978`). Same trick the
// 3.3.5/4.x clients use; the table is per-client, the design is
// stable.
//
// [0] = 1 MANA
// [1] = 10 RAGE
// [2] = 1 FOCUS
// [3] = 1 ENERGY
// [4] = 1000 HAPPINESS
VAR_UNIT_POWER_DIVISOR_TABLE = 0x0086F978,
// GetPowerDisplayDivisor — `int __fastcall(int powerType)`, the reader of
// the table above (1 for a negative type). benilla-re spell symbols.
// GetPowerDisplayDivisor — `uint32_t __fastcall(int powerType)`: the
// divisor that turns a stored power amount (descriptor POWER/MAXPOWER,
// Spell.dbc ManaCost, the cost helper's answer) into displayed units.
// Returns `[0x0086F978 + 4·type]`, or 1 for a negative type (verified by
// disassembly). Table {1, 10, 1, 1, 1000}: rage is stored in tenths
// (Shield Slam's 20 rage is 200), happiness at 1000x. Same table as
// 3.3.5's `s_powerDisplayMod`. Read it only through
// `Unit::Power::DisplayDivisor`.
FUN_POWER_DISPLAY_DIVISOR = 0x006E7130,
OFF_UNIT_FIELD_LEVEL = 0x70,
// UNIT_FIELD_BYTES_0 dword at +0x78; byte 3 (= +0x7B) is the unit's
@@ -3259,13 +3250,6 @@ enum Offsets {
// 4.3.4 `GetSpellInfo` reports this instead of the cost (with
// isFunnel = true) when it is positive.
FUN_GET_SPELL_POWER_PER_SECOND = 0x006E3300,
// Divisor that turns a stored power amount into its displayed units:
// `__fastcall uint32_t(int powerType)` → `[0x0086F978 + 4·type]`, 1 for a
// negative type. Table {1, 10, 1, 1, 1000} — rage is stored in tenths
// (Heroic Strike's 15 rage is 150 in Spell.dbc). Verified by disassembly;
// same table as 3.3.5's `s_powerDisplayMod`.
FUN_GET_POWER_DISPLAY_DIVISOR = 0x006E7130,
// The engine's spell-castability verdict for the LOCAL player — the
// function behind `IsUsableAction` for a player spell slot (the spell
// branch of the per-slot recompute `FUN_004E5050`) and behind the
@@ -3390,6 +3374,115 @@ enum Offsets {
// state locally; a no-op while mounted, on a taxi, dead, channeling or
// moving (benilla-re objectlayer/standstate#the-sit-gate).
FUN_PLAYER_SEND_STAND_STATE = 0x005ED430,
// CGUnit_C::IsShapeShifted — `char __thiscall(const void *unit)`, no
// args: the form byte names a SpellShapeshiftForm row without flag bit
// 0 (a stance is not a shapeshift). Disassembled.
FUN_UNIT_IS_SHAPESHIFTED = 0x0060E9F0,
// The two interaction form gates, each `call FUN_UNIT_IS_SHAPESHIFTED`
// (ECX = the player) followed by a test for SHAPESHIFT_FORM_FLAG_CAN_-
// INTERACT; shifted without it refuses (disassembled):
// the NPC right-click (CGPlayer_C::HandleOnRightClick, 0x005F0130):
// ERR_CANT_INTERACT_SHAPESHIFTED, before the range check and walk;
// the game-object use check (the handler's vtable slot 6, 0x005F3130,
// 4.3.4's CGGameObject_C_TypeBase::CanUseNow; every type but door and
// chair): error 0x1A0, only for an object with no Lock row.
CALL_NPC_INTERACT_IS_SHAPESHIFTED = 0x005F0150,
CALL_GAMEOBJECT_CAN_USE_IS_SHAPESHIFTED = 0x005F31E1,
// CGUnit_C::CanInteractNow (0x00606880, `this` = the player, ECX = EDI)
// has the same form test at this call. Its only caller is the hover
// cursor (0x00482310, a .text call scan), which shows the NPC's service
// cursor only when it passes; 4.3.4's CursorTrackUnit asks the same.
CALL_UNIT_CAN_INTERACT_NOW_IS_SHAPESHIFTED = 0x006068B4,
// The use check's mount rung, the same no-Lock-row branch: `mov ecx,
// [eax+0x1FC]` (8B 88 FC 01 00 00, the player's mount display), then
// `test ecx,ecx; jle pass`, then the mailbox test; refuses 0x19F
// (ERR_NOT_WHILE_MOUNTED, no 1.12 text). EDI = the player; EAX is dead.
SITE_GAMEOBJECT_CAN_USE_MOUNT_READ = 0x005F31A8,
// 4.3.4's IsUsableMounted — `bool __thiscall(const void *const *fields)`,
// ECX = &object->descriptor: GAMEOBJECT_TYPE_ID is 19, the mailbox.
FUN_GAMEOBJECT_USABLE_MOUNTED = 0x0047CFF0,
OFF_GAMEOBJECT_DESCRIPTOR = 0x110,
// A type handler's object pointer (benilla-re objectlayer/gameobjects).
OFF_GAMEOBJECT_HANDLER_OBJECT = 0x4,
// The default use (slot 7, 0x005F33E0, 4.3.4's TypeBase::Use): its
// unlocked, no-spell branch sends CMSG_GAMEOBJ_USE, opening with `mov
// edi,0x7FF9E4` (BF E4 F9 7F 00, the CDataStore vtable). [EBP-4] = the
// handler; EAX, ECX and EDX are dead there.
SITE_GAMEOBJECT_USE_SEND = 0x005F35DF,
// The two types whose slot 7 replaces the default use (vtable dump):
// the mailbox (opens the mail frame) and text (opens the page). Both
// `char __thiscall(void *handler)`, no args, return 1.
SLOT_GAMEOBJECT_MAILBOX_USE = 0x0080BDBC,
FUN_GAMEOBJECT_MAILBOX_USE = 0x005F6820,
SLOT_GAMEOBJECT_TEXT_USE = 0x0080B954,
FUN_GAMEOBJECT_TEXT_USE = 0x005F58C0,
// The rest of 4.3.4's CanAutoStand / CanAutoDismount / CanAutoUnshift
// sites, each found as the matching 1.12 gate (disassembled):
//
// Stand-state reads before a loot, `call [reg+0xA4]` (GetStandState, 6
// bytes) with ECX = the player, then `test eax,eax; jnz` to
// ERR_LOOT_NOTSTANDING (4.3.4 CGUnit_C::OnRightClick / CGCorpse_C::
// OnRightClick): the unit right-click's loot step (FF 90 A4 00 00 00)
// and the corpse right-click (0x005D6BF0, FF 92 A4 00 00 00).
SITE_UNIT_LOOT_STAND_CALL = 0x0060BFB7,
SITE_CORPSE_LOOT_STAND_CALL = 0x005D6C3B,
// The unit right-click's loot step skips a mounted player: `mov eax,
// [ecx+0x1FC]` (8B 81 FC 01 00 00), `test eax,eax; jg skin`. ESI = the
// player; ECX and EDX are dead. 4.3.4 has no such test there and its
// LootUnit dismounts instead.
SITE_UNIT_LOOT_MOUNT_READ = 0x0060BF98,
// CGPlayer_C::LootUnit (0x005DF2A0) past its reach / walk-up, before the
// loot window close and CMSG_LOOT: `push 0; xor dl,dl; mov cl,1` (6A 00
// 32 D2 B1 01) for FUN_0048F200(1, 0, 0). ESI = the player; EAX is
// dead. 4.3.4's LootUnit dismounts at this point.
SITE_LOOT_UNIT_SEND = 0x005DF3B3,
// The item use's form refusal (0x005D8D00, 4.3.4 CGItem_C::Use):
// `push 0x15E` (68 5E 01 00 00, ERR_NO_ITEMS_WHILE_SHAPESHIFTED), then
// `jmp` to the error tail. ESI = the player; EAX, ECX, EDX are dead.
// ITEM_USE_FORM_PASSED is where the form test's pass jumps. Turtle's exe
// makes the refusal unreachable (`jmp` over the form test at
// 0x005D9139); the stock 1.12.1 exe reaches it.
SITE_ITEM_USE_FORM_REFUSAL = 0x005D914D,
ITEM_USE_FORM_PASSED = 0x005D9157,
// The mount reads in 4.3.4's CGUnit_C::CanAttackNow, inlined in 1.12
// (each followed by `test; jg refuse`):
// the unit right-click's attack step (8B 82 FC 01 00 00), player ESI,
// EAX out, ECX and EDX dead;
// the hover cursor's attack cursor (8B 82 FC 01 00 00), player EDI,
// EAX out, ECX and EDX dead;
// StartAttack (0x005ECB70, 8B 81 FC 01 00 00), player ESI, EAX out,
// ECX and EDX dead;
// the attack resolver (0x00612DF0, 4.3.4 VerifyAttackIconPressed's
// ERR_ATTACK_MOUNTED, 8B 81 FC 01 00 00), player EDI, EAX out;
// the resolver's can-attack step (8B 90 FC 01 00 00), player ECX
// (live after), EDX out, EAX dead.
SITE_RIGHT_CLICK_ATTACK_MOUNT_READ = 0x0060C1B4,
SITE_CURSOR_ATTACK_MOUNT_READ = 0x00482659,
SITE_START_ATTACK_MOUNT_READ = 0x005ECC06,
SITE_ATTACK_RESOLVER_MOUNT_READ = 0x00613039,
SITE_ATTACK_RESOLVER_CAN_ATTACK_MOUNT_READ = 0x00613142,
// StartAttack once every test passed, before the swing: `mov eax,[esi+
// 0xC8C]` (8B 86 8C 0C 00 00), ESI = the player, EDI = the target; ECX
// and EDX are dead. START_ATTACK_RETURN is its plain epilogue. 4.3.4's
// CombatModeEnter dismounts here.
SITE_START_ATTACK_PASSED = 0x005ECC56,
START_ATTACK_RETURN = 0x005ECD9A,
// ERR_OUT_OF_RANGE's message id (benilla-re tables/messages.tsv).
GAME_ERROR_OUT_OF_RANGE = 125,
// CGPlayer_C::CanAutoInteract — `bool __thiscall(const void *player)`,
// no args: a live active mover with the click-to-move cvar on
// (disassembled). 4.3.4's right-click passes it as CombatModeEnter's
// `silent`, so the out-of-reach error stays quiet while the player walks.
FUN_PLAYER_CAN_AUTO_INTERACT = 0x0060F900,
// The taxi map opener (0x004DB710) ends like 4.3.4's: TAXIMAP_OPENED,
// then tutorial 34 through FUN_TRIGGER_TUTORIAL (`void __fastcall(int
// id, int)`) at this call; 4.3.4 then stands, dismounts and leaves the
// form when the map has more than one node (VAR_TAXI_MAP_NODE_COUNT,
// the count the opener's TaxiNodeSetCurrent loop runs to).
CALL_TAXI_MAP_OPENED_TUTORIAL = 0x004DBAA2,
FUN_TRIGGER_TUTORIAL = 0x004B5390,
VAR_TAXI_MAP_NODE_COUNT = 0x00BB4AA8,
// Effective spell/channel duration (ms):
// `__fastcall int(const void *spellRecord, int pet /*0 = player,
@@ -11932,6 +12025,10 @@ enum Offsets {
VAR_SPELL_SHAPESHIFT_FORM_COUNT = 0x00C0D778,
OFF_SPELL_SHAPESHIFT_FORM_FLAGS = 0x2C,
SHAPESHIFT_FORM_FLAG_NO_AUTO_UNSHIFT = 0x100,
// Flag 0x8: the form may talk to NPCs and use objects (Shadowform's 0x9).
// Tested by both interaction gates (`test byte [eax+0x2C],0x8` at
// 0x005F017F / 0x005F3210) and 4.3.4's HandleOnRightClick / CanUseNow.
SHAPESHIFT_FORM_FLAG_CAN_INTERACT = 0x8,
// Control-loss aura effect types (EffectApplyAuraName values), used by
// C_LossOfControl to classify a debuff. Values are authoritative from the
+2 -3
View File
@@ -45,6 +45,7 @@
#include "time/Clock.h"
#include "unit/Focus.h"
#include "unit/Identity.h"
#include "unit/Power.h"
#include <ctime>
@@ -684,9 +685,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;
}
@@ -13,11 +13,11 @@ 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, talk to an NPC, use an object or an item, or open the flight map.";
CLASSICAPI_BAGS_AND_ITEMS = "Bags and Items";
CLASSICAPI_CASTING_AND_ATTACKING = "Casting and Attacking";
CLASSICAPI_COMBAT_LOG_RETENTION = "Combat Log Retention";
@@ -15,11 +15,11 @@ CLASSICAPI_ACTION_TARGETING = "动作瞄准";
CLASSICAPI_ADVANCED = "高级";
CLASSICAPI_ALL_SOFT_TARGET_ICONS = "所有软目标图标";
CLASSICAPI_AUTO_DISMOUNT = "自动下马";
CLASSICAPI_AUTO_DISMOUNT_TOOLTIP = "骑乘状态下施放法术时自动下马。";
CLASSICAPI_AUTO_DISMOUNT_TOOLTIP = "骑乘状态下施放法术、使用物体、拾取、攻击或打开飞行地图时自动下马。";
CLASSICAPI_AUTO_STAND = "自动站起";
CLASSICAPI_AUTO_STAND_TOOLTIP = "坐下状态下施放法术时自动站起。";
CLASSICAPI_AUTO_STAND_TOOLTIP = "坐下状态下施放法术、拾取或打开飞行地图时自动站起。";
CLASSICAPI_AUTO_UNSHIFT = "自动取消形态";
CLASSICAPI_AUTO_UNSHIFT_TOOLTIP = "施放当前形态无法施放的法术时自动取消该形态。";
CLASSICAPI_AUTO_UNSHIFT_TOOLTIP = "施放当前形态无法施放的法术、与NPC对话、使用物体或物品、或打开飞行地图时自动取消该形态。";
CLASSICAPI_BAGS_AND_ITEMS = "背包与物品";
CLASSICAPI_CASTING_AND_ATTACKING = "施法与攻击";
CLASSICAPI_COMBAT_LOG_RETENTION = "战斗记录保留时间";
+5 -98
View File
@@ -15,6 +15,7 @@
#include "../Offsets.h"
#include "../guid/Guid.h"
#include "../object/Resolve.h"
#include "Request.h"
#include <cstdint>
@@ -22,22 +23,6 @@ namespace Loot::Nearby {
namespace {
// Engine interact-range constants, read out of `.rdata` once and
// pinned here as `constexpr`. Both are referenced by `FUN_005EC110`
// (the master interact-state checker) and propagated through
// `FUN_005DF130` / `FUN_005DF2A0` (CMSG_LOOT senders). Yards units;
// values verified at runtime addresses `DAT_0080B058` and
// `DAT_0080A1E8`.
//
// INTERACT_REACH (1.333…) is added to `target_radius + player_radius`
// for the unclamped range; modeled as the "reach beyond your touch"
// constant.
// MIN_INTERACT_RANGE (5.0) clamps the result so even tiny
// targets with near-zero bounding radius still get the standard
// 5-yard right-click reach.
constexpr float INTERACT_REACH = 1.3333333f;
constexpr float MIN_INTERACT_RANGE = 5.0f;
// `ClntObjMgrEnumVisibleObjects(callback, context)` — walks the engine's
// visible-object table. The callback shape's `void *unusedEdx` slot
// absorbs the second `__fastcall` register so MSVC lays out the trailing
@@ -49,84 +34,6 @@ using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
using ClntObjMgrEnumVisibleObjects_t =
int(__fastcall *)(ClntObjMgrEnumVisibleObjectsCallback_t cb, void *ctx);
// `__thiscall(self, outBuf) → const C3Vector *` at vtable slot 5
// (byte offset `0x14`). Both CGUnit_C and CGPlayer_C populate the
// caller-provided 12-byte buffer with the unit's world position and
// return it. Same call shape `FUN_005EC110` uses to read the
// player / target positions when deciding interact range.
struct C3Vector {
float x, y, z;
};
using GetPosition_t = const C3Vector *(__thiscall *)(const void *self,
C3Vector *outBuf);
constexpr int VTBL_GET_POSITION_OFFSET = 0x14;
// Reads `(unit.m_objectFields[+0x224] & UNIT_DYNFLAG_LOOTABLE) != 0`
// — the server-driven bit it sets when the local player has loot
// rights on this unit. Works uniformly for live and dead CGUnit
// subclasses; the bit is simply 0 for non-lootable targets.
//
// Don't call `FUN_CGCORPSE_IS_LOOTABLE` here even though the engine
// itself uses it in `FUN_005D6BF0`: that helper reads `+0x78` of
// m_objectFields, which is the CORPSE dynamic-flags slot for
// CGCorpse_C but the BYTES_0 (race / class / gender / power type)
// slot for CGUnit_C. Routing live units through it returns the low
// bit of *race* — yields false positives for any Human / Dwarf /
// Undead / Gnome player and false negatives for dead mobs.
bool IsLootable(const void *unit) {
auto *fields = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
const uint32_t flags =
Game::Read<uint32_t>(fields, Offsets::OFF_UNIT_FIELD_DYNAMIC_FLAGS);
return (flags & Offsets::UNIT_DYNFLAG_LOOTABLE) != 0;
}
// Reads the bounding-radius float from a unit's m_objectFields. The
// engine uses this as the contribution from both sides (player + target)
// in its interact-range computation.
float BoundingRadius(const void *unit) {
auto *fields = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
return Game::Read<float>(fields, Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
}
// Invokes the unit's virtual `GetPosition` (vtable slot at offset
// `0x14`). The engine's signature is `__thiscall(this, outBuf) →
// outBuf`; we pass our own 12-byte stack buffer and read x/y/z out
// of the returned pointer.
const C3Vector *GetPosition(const void *unit, C3Vector *outBuf) {
auto vtable = *reinterpret_cast<void *const *const *>(unit);
auto fn = reinterpret_cast<GetPosition_t>(
*reinterpret_cast<const void *const *>(
reinterpret_cast<const uint8_t *>(vtable) +
VTBL_GET_POSITION_OFFSET));
return fn(unit, outBuf);
}
// Mirrors the range portion of `FUN_005EC110`. We skip the engine's
// player-state checks (mounted / casting / prior-interaction GUID
// gate) because they fire intermittently during normal play and would
// make the enumerator return an empty list whenever the player has
// already clicked something — surprising for a "what's lootable
// nearby" reader. The pure distance test is what controls cursor
// highlight, which is the contract the user asked for.
bool InInteractRange(const void *player, const void *target) {
C3Vector pBuf{};
C3Vector tBuf{};
const C3Vector *pPos = GetPosition(player, &pBuf);
const C3Vector *tPos = GetPosition(target, &tBuf);
if (pPos == nullptr || tPos == nullptr)
return false;
const float dx = pPos->x - tPos->x;
const float dy = pPos->y - tPos->y;
const float dz = pPos->z - tPos->z;
const float distSq = dx * dx + dy * dy + dz * dz;
float range = BoundingRadius(player) + BoundingRadius(target) + INTERACT_REACH;
if (range < MIN_INTERACT_RANGE)
range = MIN_INTERACT_RANGE;
return distSq <= range * range;
}
// Only `TYPEMASK_UNIT` carries lootable-mob state in 1.12 — dead
// creatures stay as CGCreature_C objects with the lootable bit flipped
// on, rather than transitioning to a separate corpse object. Dead
@@ -135,7 +42,7 @@ bool InInteractRange(const void *player, const void *target) {
struct ScanCtx {
void *L;
const void *player; // resolved once outside the iterator
void *player; // resolved once outside the iterator
int idx; // 1-based slot of the next entry to append
};
@@ -144,10 +51,10 @@ int __fastcall ScanCallback(ScanCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
if (obj == nullptr)
return 1; // GUID isn't a unit; continue
if (!IsLootable(obj))
if (!Request::IsLootable(obj))
return 1; // unit isn't lootable to us; continue
if (!InInteractRange(ctx->player, obj))
if (!Request::InReach(ctx->player, obj))
return 1; // out of click-loot range; continue
char guidBuf[Guid::STRING_SIZE];
@@ -180,7 +87,7 @@ int __fastcall Script_GetNearbyLootableUnits(void *L) {
// Resolve the local player once — the canonical CGPlayer_C the
// engine's own interact-range checks use, distinct from the
// `VAR_LOCAL_PLAYER_PTR` global. Bail if unavailable.
const void *player = Game::ResolveUnitToken("player");
void *player = Game::ResolveUnitToken("player");
if (player == nullptr)
return 1;
+35 -2
View File
@@ -15,6 +15,7 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../unit/Position.h"
namespace Loot::Request {
@@ -31,8 +32,11 @@ using CloseLootInner_t = void(__fastcall *)(int sendRelease,
char earlyReturnOnGameObject,
char showError);
uint64_t HeldTarget(const void *player) {
return Game::Read<uint64_t>(player, Offsets::OFF_PLAYER_LOOT_TARGET_GUID);
// The per-unit reach `CanLootNow` adds for each side (`[m_objectFields +
// 0x1F0]`).
float Reach(const void *unit) {
auto *fields = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
return Game::Read<float>(fields, Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
}
} // namespace
@@ -42,6 +46,35 @@ bool IsMounted(const void *player) {
return Game::Read<uint32_t>(fields, Offsets::OFF_UNIT_FIELD_MOUNTDISPLAYID) != 0;
}
bool IsLootable(const void *unit) {
auto *fields = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
return (Game::Read<uint32_t>(fields, Offsets::OFF_UNIT_FIELD_DYNAMIC_FLAGS) &
Offsets::UNIT_DYNFLAG_LOOTABLE) != 0;
}
bool InReach(void *player, void *target) {
float p[3];
float t[3];
if (!Unit::Position::Read(player, p) || !Unit::Position::Read(target, t))
return false;
// `CanLootNow`'s reach for a unit (`0x005EC142`..`0x005EC17E`), with the
// engine's own constants read live.
float reach = Reach(target) + Reach(player) +
Game::Read<float>(static_cast<uintptr_t>(Offsets::VAR_MELEE_REACH_LEEWAY));
const float minReach =
Game::Read<float>(static_cast<uintptr_t>(Offsets::VAR_MELEE_REACH_MIN));
if (reach < minReach)
reach = minReach;
const float dx = p[0] - t[0];
const float dy = p[1] - t[1];
const float dz = p[2] - t[2];
return dx * dx + dy * dy + dz * dz <= reach * reach;
}
uint64_t HeldTarget(const void *player) {
return Game::Read<uint64_t>(player, Offsets::OFF_PLAYER_LOOT_TARGET_GUID);
}
bool Send(void *player, void *target, uint64_t guid) {
if (IsMounted(player))
return false;
+17 -2
View File
@@ -28,11 +28,26 @@ namespace Loot::Request {
// (`CGUnit_C::Interact`, `0x0060BEA0`) applies before its loot leg.
bool IsMounted(const void *player);
// Whether the server grants us loot on `unit` (`UNIT_DYNFLAG_LOOTABLE`).
bool IsLootable(const void *unit);
// Whether `target` is within the reach `CanLootNow` (`0x005EC110`) demands
// when the player is not walked up: both units' `+0x1F0` reach plus the
// engine's live leeway, at least its live minimum, against the 3D distance.
// `Send` never walks, so a target outside this reach is one the server
// refuses with "too far away".
bool InReach(void *player, void *target);
// The player's held loot target. The engine writes it when the request goes
// out and clears it when the server answers with a failure or a release, so
// a caller waiting on `guid` sees the refusal as this no longer being `guid`.
uint64_t HeldTarget(const void *player);
// Sends the loot request the way a right-click would: refused while mounted
// (the click's gate, which `FUN_CMSG_LOOT_UNIT` itself lacks), never
// auto-walks. Returns whether the request went out — false when mounted or
// when the engine's own pre-checks refused (another loot held, falling, not
// in control, ...), in which case no response is coming.
// when the engine's own pre-checks refused (another loot held, moving,
// falling, not in control, ...), in which case no response is coming.
bool Send(void *player, void *target, uint64_t guid);
// For a caller that gave up waiting: if the player still holds `guid` as
+63 -74
View File
@@ -42,6 +42,7 @@
#include "../guid/Guid.h"
#include "../object/Resolve.h"
#include "../tick/WorldTick.h"
#include "../time/Clock.h"
#include "Request.h"
#include <cstdint>
@@ -55,12 +56,11 @@ namespace {
constexpr const char *kEventCompleted = "LOOT_SCAN_COMPLETED";
const Event::Custom::AutoReserve _reserve{kEventCompleted};
// Per-step timeout for the controller hook to fire on a queued
// CMSG_LOOT. WorldTick fires at the client frame rate (~30-60 Hz),
// so 180 ticks ≈ 3-6s wall clock. Long enough for high-latency
// servers, short enough that a single dropped response doesn't hang
// the whole scan.
constexpr int kStepTimeoutTicks = 180;
// How long one step may wait: for the server to answer a CMSG_LOOT it
// never answers, or for the engine to accept a send it keeps refusing
// (the player is moving). Long enough for high-latency servers, short
// enough that a dropped response doesn't hang the walk.
constexpr uint32_t kStepTimeoutMs = 3000;
// Event IDs the engine fires through `FUN_FIRE_EVENT_NO_ARGS` for
// loot-window transitions. The hook swallows these only when the
@@ -71,6 +71,7 @@ constexpr int EVENT_LOOT_CLOSED = 0x10D;
enum class State {
Idle,
WaitingToSend, // the engine refused the send for now; retried each tick
WaitingForResponse, // CMSG_LOOT sent; awaiting our controller hook
};
@@ -90,7 +91,7 @@ struct ScanCtx {
State state = State::Idle;
void *player = nullptr;
uint64_t currentGuid = 0;
int ticksSinceTransition = 0;
uint32_t stepStartMs = 0;
bool suppressEvents = false; // read by the FireEvent hook
bool lootMode = false; // LootAllCorpses (true) vs ScanNearbyLoot (false)
std::vector<uint64_t> queue;
@@ -128,55 +129,6 @@ using LootMoney_t = void(__fastcall *)(void *player);
using LootController_t = void(__fastcall *)(int lootStructPtr,
int coin,
int unk);
struct C3Vector {
float x, y, z;
};
using GetPosition_t = const C3Vector *(__thiscall *)(const void *self,
C3Vector *outBuf);
constexpr int VTBL_GET_POSITION_OFFSET = 0x14;
constexpr float INTERACT_REACH = 1.3333333f;
constexpr float MIN_INTERACT_RANGE = 5.0f;
// === Range / lootability helpers (identical to Loot::Nearby) ===
bool IsLootableUnit(const void *unit) {
auto *fields = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
const uint32_t flags =
Game::Read<uint32_t>(fields, Offsets::OFF_UNIT_FIELD_DYNAMIC_FLAGS);
return (flags & Offsets::UNIT_DYNFLAG_LOOTABLE) != 0;
}
float BoundingRadius(const void *unit) {
auto *fields = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
return Game::Read<float>(fields, Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
}
const C3Vector *GetPosition(const void *unit, C3Vector *outBuf) {
auto vtable = *reinterpret_cast<void *const *const *>(unit);
auto fn = reinterpret_cast<GetPosition_t>(
*reinterpret_cast<const void *const *>(
reinterpret_cast<const uint8_t *>(vtable) +
VTBL_GET_POSITION_OFFSET));
return fn(unit, outBuf);
}
bool InInteractRange(const void *player, const void *target) {
C3Vector pBuf{};
C3Vector tBuf{};
const C3Vector *pPos = GetPosition(player, &pBuf);
const C3Vector *tPos = GetPosition(target, &tBuf);
if (pPos == nullptr || tPos == nullptr)
return false;
const float dx = pPos->x - tPos->x;
const float dy = pPos->y - tPos->y;
const float dz = pPos->z - tPos->z;
const float distSq = dx * dx + dy * dy + dz * dz;
float range = BoundingRadius(player) + BoundingRadius(target) + INTERACT_REACH;
if (range < MIN_INTERACT_RANGE)
range = MIN_INTERACT_RANGE;
return distSq <= range * range;
}
// === Scrape ===
@@ -278,17 +230,37 @@ void TryStartNext() {
StartLoot(guid);
}
void StartLoot(uint64_t guid) {
void *target = Object::ByGuid(Offsets::TYPEMASK_UNIT, guid, nullptr, 0);
// Not sent (mounted mid-walk, or the engine's pre-checks refused): no
// response is coming, so skip without waiting out the timeout.
if (target == nullptr || !Request::Send(s_scan.player, target, guid)) {
void BeginStep(State state) {
s_scan.state = state;
s_scan.stepStartMs = Time::Clock::NowMs();
}
// Sends the current corpse's loot request. The corpse is checked again
// here, not only when the queue was built: the walk takes a round trip per
// corpse, and by now the corpse may be gone, looted, or out of reach
// because the player moved. The server refuses an out-of-reach corpse
// with an error, so skip it instead.
//
// When the engine refuses the send itself (the player is moving or
// falling, ...), the corpse stays current and `OnTick` retries it: the
// refusal is about the player, not the corpse, so skipping would drop
// every corpse left in the queue.
void SendCurrent() {
void *target =
Object::ByGuid(Offsets::TYPEMASK_UNIT, s_scan.currentGuid, nullptr, 0);
if (target == nullptr || !Request::IsLootable(target) ||
!Request::InReach(s_scan.player, target)) {
TryStartNext();
return;
}
if (Request::Send(s_scan.player, target, s_scan.currentGuid))
BeginStep(State::WaitingForResponse);
}
void StartLoot(uint64_t guid) {
s_scan.currentGuid = guid;
s_scan.state = State::WaitingForResponse;
s_scan.ticksSinceTransition = 0;
BeginStep(State::WaitingToSend);
SendCurrent();
}
// === Hooks ===
@@ -383,18 +355,35 @@ const Game::HookAutoRegister _hookController{
const Event::SignalHook::AutoSubscribe _lootIntercept{&SuppressLootEvents};
// === Tick (timeout only) ===
// === Tick (refusals and timeouts) ===
void OnTick() {
if (s_scan.state == State::Idle)
return;
++s_scan.ticksSinceTransition;
if (s_scan.ticksSinceTransition >= kStepTimeoutTicks) {
// Controller hook never fired — the server answered with a
// failure, or never answered. A failure already cleared the
// held target; an unanswered request still holds it, which
// would block all looting, so release it. Skip this corpse.
s_scan.suppressEvents = false;
const bool timedOut = Time::Clock::Elapsed(s_scan.stepStartMs) >= kStepTimeoutMs;
if (s_scan.state == State::WaitingToSend) {
// Still refused: the player has not stopped moving, or mounted,
// or lost control. Every corpse left would be refused the same
// way, so end the walk.
if (timedOut) {
Complete();
return;
}
SendCurrent();
return;
}
// The server refused the corpse ("too far away", not ours, ...): its
// failure response and its release response both clear the held
// target, and no window opens. Go on to the next corpse now.
if (Request::HeldTarget(s_scan.player) != s_scan.currentGuid) {
TryStartNext();
return;
}
if (timedOut) {
// No answer at all. The held target would block all looting, so
// release it, and skip this corpse.
Request::ReleaseIfUnanswered(s_scan.player, s_scan.currentGuid);
TryStartNext();
}
@@ -405,7 +394,7 @@ const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
// === Enumeration ===
struct EnumCtx {
const void *player;
void *player;
std::vector<uint64_t> *out;
};
@@ -413,9 +402,9 @@ int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
void *obj = Object::ByGuid(Offsets::TYPEMASK_UNIT, guid, nullptr, 0);
if (obj == nullptr)
return 1;
if (!IsLootableUnit(obj))
if (!Request::IsLootable(obj))
return 1;
if (!InInteractRange(ctx->player, obj))
if (!Request::InReach(ctx->player, obj))
return 1;
ctx->out->push_back(guid);
return 1;
+403 -8
View File
@@ -35,10 +35,52 @@
// for the mount / form aura (1.12 has no CMSG_CANCEL_MOUNT_AURA; cancelling
// the mount aura is how its own UI dismounts). The server applies the
// cancel / stand before the cast packet that follows it.
//
// autoUnshift and autoDismount also cover interaction, as in 4.3.4:
// - NPC right-click: 4.3.4's CGPlayer_C::HandleOnRightClick cancels a form
// without SHAPESHIFT_FORM_FLAG_CAN_INTERACT in place of
// ERR_CANT_INTERACT_SHAPESHIFTED, before its range check, so we do the
// same at 1.12's gate. Neither client gates an NPC on the mount. The
// hover cursor asks CanInteractNow, whose form test 4.3.4 also passes
// on CanAutoUnshift, so the NPC's service cursor shows while shifted.
// - game objects: for an object with no Lock row, 4.3.4's CanUseNow lets
// the mount pass when CanAutoDismount holds and the form when
// CanAutoUnshift holds. The 1.12 use check is also the hover cursor's
// query, so it only passes. Then TypeBase::Use, on its no-spell branch
// only, dismounts (unless the object is usable mounted, the mailbox) and
// cancels the form before CMSG_GAMEOBJ_USE. 4.3.4 does that with no
// cvar test, the gate having run; we keep the cvars, so with them off
// an object with a Lock row is still used as 1.12 uses it. 1.12's
// mailbox and text types replace that use slot, which 4.3.4 does not,
// so they get the same step. A locked object (a chest, a vein) opens by
// a spell whose cast passes the caster check above.
//
// The remaining 4.3.4 sites:
// - loot (autoStand): CGUnit_C / CGCorpse_C::OnRightClick stand up in
// place of ERR_LOOT_NOTSTANDING. 1.12's two loot steps read the stand
// state the same way, so the read stands the player and reports 0.
// - loot (autoDismount): 4.3.4's unit right-click has no mount test on
// its loot step, and LootUnit dismounts before CMSG_LOOT. 1.12's loot
// step skips a mounted player, so its read passes, and 1.12's LootUnit
// dismounts at the same point. A corpse is not looted mounted in either.
// - item use (autoUnshift): CGItem_C::Use leaves the form in place of
// ERR_NO_ITEMS_WHILE_SHAPESHIFTED; we do that at 1.12's refusal.
// - attack (autoDismount): CanAttackNow (the attack cursor, the right-click
// attack) and VerifyAttackIconPressed (ERR_ATTACK_MOUNTED) pass a mount
// when CanAutoDismount holds; CombatModeEnter then dismounts in melee
// reach, else shows ERR_OUT_OF_RANGE and does not attack. 1.12 inlines
// the CanAttackNow mount test in four places and has the resolver's
// refusal; each read passes, and StartAttack, past all its tests, runs
// the CombatModeEnter step. The reach is Combat::Swing::InMeleeRange,
// 4.3.4's GetMeleeRange formula on the engine's own constants.
// - taxi: the taxi map's open stands, dismounts and leaves the form when
// it has more than one node. 4.3.4 does that with no cvar; here each
// action needs its cvar, so with them off the map opens as in 1.12.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "combat/Swing.h"
#include "cvar/Factory.h"
#include "dbc/Lookup.h"
#include "spell/Cancel.h"
@@ -56,6 +98,16 @@ using CheckForm_t = char(__fastcall *)(void *unit, void *edx,
const uint8_t *record, int *reason);
using GetStandState_t = int(__fastcall *)(const void *unit, void *edx);
using SendStandState_t = void(__fastcall *)(const void *player, void *edx, uint32_t state);
using IsShapeShifted_t = char(__fastcall *)(const void *unit);
using UsableMounted_t = bool(__fastcall *)(const void *descriptorSlot);
using ObjectUse_t = char(__fastcall *)(void *handler);
using DisplayError_t = void(__cdecl *)(int id, ...);
using TriggerTutorial_t = void(__fastcall *)(int id, int arg);
using CanAutoInteract_t = bool(__fastcall *)(const void *player);
// Continuation addresses the naked thunks load from memory.
uintptr_t g_itemUseFormPassed = Offsets::ITEM_USE_FORM_PASSED;
uintptr_t g_startAttackReturn = Offsets::START_ATTACK_RETURN;
constexpr int kGetStandStateSlot = 0xA4 / 4;
@@ -112,10 +164,276 @@ bool CanAutoUnshift(const void *unit) {
Offsets::SHAPESHIFT_FORM_FLAG_NO_AUTO_UNSHIFT) == 0;
}
int StandState(const void *unit) {
auto getStandState =
(*reinterpret_cast<const GetStandState_t *const *>(unit))[kGetStandStateSlot];
return getStandState(unit, nullptr);
}
int MountDisplay(const void *unit) {
return Game::Read<int>(Fields(unit), Offsets::OFF_UNIT_FIELD_MOUNTDISPLAYID);
}
// The engine's actions: the stand setter, and CMSG_CANCEL_AURA for the
// mount / form aura.
void Stand(const void *player) {
reinterpret_cast<SendStandState_t>(Offsets::FUN_PLAYER_SEND_STAND_STATE)(player, nullptr, 0);
}
void Dismount() { Spell::Cancel::CancelPlayerAura(Offsets::SPELL_AURA_MOUNTED); }
void CancelForm(const void *unit) {
Spell::Cancel::CancelPlayerAura(Offsets::SPELL_AURA_MOD_SHAPESHIFT, Form(unit));
}
char IsShapeShifted(const void *unit) {
return reinterpret_cast<IsShapeShifted_t>(Offsets::FUN_UNIT_IS_SHAPESHIFTED)(unit);
}
// The gates' test after IsShapeShifted: a form with no row, or one
// without the interact flag.
bool FormBlocksInteraction(const void *unit) {
const uint8_t *form = DBC::Record(Offsets::VAR_SPELL_SHAPESHIFT_FORM_RECORDS,
Offsets::VAR_SPELL_SHAPESHIFT_FORM_COUNT,
static_cast<uint32_t>(Form(unit)));
return form == nullptr ||
(Game::Read<uint32_t>(form, Offsets::OFF_SPELL_SHAPESHIFT_FORM_FLAGS) &
Offsets::SHAPESHIFT_FORM_FLAG_CAN_INTERACT) == 0;
}
// True when the gate would refuse and autoUnshift takes the form off instead.
bool UnshiftsForInteraction(const void *player) {
return IsShapeShifted(player) && FormBlocksInteraction(player) && CanAutoUnshift(player);
}
// The NPC right-click's IsShapeShifted: leaves the form and reports "not
// shifted", as 4.3.4's HandleOnRightClick does.
char __fastcall IsShapeShiftedForTalk(const void *player) {
if (UnshiftsForInteraction(player)) {
CancelForm(player);
return 0;
}
return IsShapeShifted(player);
}
// The game-object use check's and CanInteractNow's IsShapeShifted (4.3.4's
// CanUseNow / CanInteractNow): the same verdict, nothing done. Both are
// hover-cursor queries, so the cursor shows the service while shifted.
char __fastcall IsShapeShiftedForQuery(const void *player) {
return UnshiftsForInteraction(player) ? 0 : IsShapeShifted(player);
}
// The mount reads of the gates 4.3.4 passes on CanAutoDismount (an object
// use, a loot, an attack): the mount display, or 0 when the action that
// follows dismounts first.
int __fastcall MountDisplayForAction(const void *player) {
const int display = MountDisplay(player);
return display > 0 && CanAutoDismount(player) ? 0 : display;
}
// Stands in for `mov ecx,[eax+0x1FC]` at SITE_GAMEOBJECT_CAN_USE_MOUNT_READ,
// player in EDI. EAX and EDX are dead there; ECX takes the display.
__declspec(naked) void UseMountReadThunk() {
__asm {
mov ecx, edi
call MountDisplayForAction
mov ecx, eax
ret
}
}
// Stand in for a `mov eax,[reg+0x1FC]` mount read, the player in ESI or EDI;
// EAX takes the display, every other register is kept.
__declspec(naked) void MountReadEsiThunk() {
__asm {
push ecx
push edx
mov ecx, esi
call MountDisplayForAction
pop edx
pop ecx
ret
}
}
__declspec(naked) void MountReadEdiThunk() {
__asm {
push ecx
push edx
mov ecx, edi
call MountDisplayForAction
pop edx
pop ecx
ret
}
}
// Stands in for `mov edx,[eax+0x1FC]` at SITE_ATTACK_RESOLVER_CAN_ATTACK_-
// MOUNT_READ, player in ECX, which the can-attack call after it still uses.
__declspec(naked) void ResolverMountReadThunk() {
__asm {
push ecx
call MountDisplayForAction
mov edx, eax
pop ecx
ret
}
}
// The loot steps' GetStandState call (ECX = the player): stands the player
// up and reports 0 when autoStand allows, as 4.3.4's OnRightClick does.
int __fastcall StandStateForLoot(const void *player) {
const int state = StandState(player);
if (state != 0 && CanAutoStand(player)) {
Stand(player);
return 0;
}
return state;
}
// LootUnit before CMSG_LOOT: 4.3.4's dismount.
void __fastcall DismountForLoot(const void *player) {
if (MountDisplay(player) > 0 && CanAutoDismount(player))
Dismount();
}
// Stands in for `push 0; xor dl,dl; mov cl,1` at SITE_LOOT_UNIT_SEND, player
// in ESI: the dismount, then the replaced code with its push kept under the
// return address.
__declspec(naked) void LootSendThunk() {
__asm {
mov ecx, esi
call DismountForLoot
pop eax
push 0
push eax
xor dl, dl
mov cl, 1
ret
}
}
// The item use's form refusal: leaves the form and lets the use go on when
// autoUnshift allows.
bool __fastcall UnshiftForItem(const void *player) {
if (!CanAutoUnshift(player))
return false;
CancelForm(player);
return true;
}
// Stands in for `push 0x15E` at SITE_ITEM_USE_FORM_REFUSAL, player in ESI:
// continues at ITEM_USE_FORM_PASSED, or pushes the error for the `jmp` to
// the error tail that follows.
__declspec(naked) void ItemFormRefusalThunk() {
__asm {
mov ecx, esi
call UnshiftForItem
test al, al
jz refuse
mov eax, g_itemUseFormPassed
mov [esp], eax
ret
refuse:
pop eax
push 0x15E
jmp eax
}
}
// StartAttack past its tests: 4.3.4 CombatModeEnter's dismount, in melee
// reach only; out of reach it does not attack, and shows ERR_OUT_OF_RANGE
// unless click-to-move is walking the player there (4.3.4's `silent`).
bool __fastcall PrepareAttack(const void *player, void *target) {
if (MountDisplay(player) <= 0 || !CanAutoDismount(player))
return true;
if (!Combat::Swing::InMeleeRange(const_cast<void *>(player), target)) {
if (!reinterpret_cast<CanAutoInteract_t>(Offsets::FUN_PLAYER_CAN_AUTO_INTERACT)(player))
reinterpret_cast<DisplayError_t>(Offsets::FUN_GAMEUI_DISPLAY_ERROR)(
Offsets::GAME_ERROR_OUT_OF_RANGE);
return false;
}
Dismount();
return true;
}
// Stands in for `mov eax,[esi+0xC8C]` at SITE_START_ATTACK_PASSED, player in
// ESI, target in EDI: on a refusal, returns from StartAttack.
__declspec(naked) void StartAttackPassedThunk() {
__asm {
mov ecx, esi
mov edx, edi
call PrepareAttack
test al, al
jz abort
mov eax, [esi + 0xC8C]
ret
abort:
mov eax, g_startAttackReturn
mov [esp], eax
ret
}
}
// The taxi map opener's tutorial call, its last step: then 4.3.4's
// stand, dismount and form cancel for a map with more than one node.
void __fastcall TaxiMapOpenedTutorial(int id, int arg) {
reinterpret_cast<TriggerTutorial_t>(Offsets::FUN_TRIGGER_TUTORIAL)(id, arg);
if (Game::Read<int>(static_cast<uintptr_t>(Offsets::VAR_TAXI_MAP_NODE_COUNT)) <= 1)
return;
const void *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return;
if (StandState(player) != 0 && CanAutoStand(player))
Stand(player);
if (MountDisplay(player) > 0 && CanAutoDismount(player))
Dismount();
if (IsShapeShifted(player) && CanAutoUnshift(player))
CancelForm(player);
}
// 4.3.4 TypeBase::Use's step before CMSG_GAMEOBJ_USE: dismount unless the
// object is usable mounted, then leave a form that cannot interact.
void __fastcall PrepareObjectUse(const void *handler) {
const void *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return;
const uint8_t *object =
Game::Read<const uint8_t *>(handler, Offsets::OFF_GAMEOBJECT_HANDLER_OBJECT);
if (MountDisplay(player) > 0 && CanAutoDismount(player) &&
!reinterpret_cast<UsableMounted_t>(Offsets::FUN_GAMEOBJECT_USABLE_MOUNTED)(
object + Offsets::OFF_GAMEOBJECT_DESCRIPTOR))
Dismount();
if (UnshiftsForInteraction(player))
CancelForm(player);
}
// Stands in for `mov edi,0x7FF9E4` at SITE_GAMEOBJECT_USE_SEND, handler at
// [EBP-4]. EAX, ECX and EDX are dead there.
__declspec(naked) void UseSendThunk() {
__asm {
mov ecx, [ebp - 4]
call PrepareObjectUse
mov edi, 0x7FF9E4
ret
}
}
// The mailbox and text use slots: the same step, then the type's own use.
char __fastcall MailboxUse(void *handler) {
PrepareObjectUse(handler);
return reinterpret_cast<ObjectUse_t>(Offsets::FUN_GAMEOBJECT_MAILBOX_USE)(handler);
}
char __fastcall TextUse(void *handler) {
PrepareObjectUse(handler);
return reinterpret_cast<ObjectUse_t>(Offsets::FUN_GAMEOBJECT_TEXT_USE)(handler);
}
// The mount rung's read: the mount display, or 0 when the cast will
// dismount first.
int __fastcall MountDisplayForCast(const void *caster) {
const int display = Game::Read<int>(Fields(caster), Offsets::OFF_UNIT_FIELD_MOUNTDISPLAYID);
const int display = MountDisplay(caster);
if (display > 0 && CanAutoDismount(caster)) {
g_pending.dismount = true;
return 0;
@@ -141,9 +459,7 @@ __declspec(naked) void MountReadThunk() {
// SITE_CASTER_STATE_STAND_CALL, caster in ECX: the stand state, or 0 when
// the cast will stand first.
int __fastcall StandStateForCast(const void *caster) {
auto getStandState =
(*reinterpret_cast<const GetStandState_t *const *>(caster))[kGetStandStateSlot];
const int state = getStandState(caster, nullptr);
const int state = StandState(caster);
if (state != 0 && CanAutoStand(caster)) {
g_pending.stand = true;
return 0;
@@ -184,12 +500,11 @@ char __fastcall CheckCasterState(void *caster, void *, const uint8_t *record, vo
if (!passed)
return 0;
if (pending.stand)
reinterpret_cast<SendStandState_t>(Offsets::FUN_PLAYER_SEND_STAND_STATE)(
caster, nullptr, 0);
Stand(caster);
if (pending.dismount)
Spell::Cancel::CancelPlayerAura(Offsets::SPELL_AURA_MOUNTED);
Dismount();
if (pending.unshift)
Spell::Cancel::CancelPlayerAura(Offsets::SPELL_AURA_MOD_SHAPESHIFT, Form(caster));
CancelForm(caster);
return 1;
}
@@ -201,6 +516,86 @@ void Register() {
g_autoDismount = CVar::Factory::Register("autoDismount", "Automatically dismount when needed", Offsets::CVAR_CATEGORY_GAME, "0");
g_autoUnshift = CVar::Factory::Register("autoUnshift", "Automatically leave shapeshift form when needed", Offsets::CVAR_CATEGORY_GAME, "0");
// Interaction. The use check passes the mount and form only together
// with the step at every use; without it the object would be used still
// mounted or shifted.
Common::RedirectCall(Offsets::CALL_NPC_INTERACT_IS_SHAPESHIFTED,
Offsets::FUN_UNIT_IS_SHAPESHIFTED,
reinterpret_cast<const void *>(&IsShapeShiftedForTalk));
static const uint8_t kUseSend[5] = {0xBF, 0xE4, 0xF9, 0x7F, 0x00};
if (Common::ReplaceWithCall(Offsets::SITE_GAMEOBJECT_USE_SEND, kUseSend, sizeof kUseSend,
reinterpret_cast<const void *>(&UseSendThunk)) &&
Common::RedirectSlot(Offsets::SLOT_GAMEOBJECT_MAILBOX_USE,
Offsets::FUN_GAMEOBJECT_MAILBOX_USE,
reinterpret_cast<const void *>(&MailboxUse)) &&
Common::RedirectSlot(Offsets::SLOT_GAMEOBJECT_TEXT_USE, Offsets::FUN_GAMEOBJECT_TEXT_USE,
reinterpret_cast<const void *>(&TextUse))) {
static const uint8_t kUseMountRead[6] = {0x8B, 0x88, 0xFC, 0x01, 0x00, 0x00};
Common::ReplaceWithCall(Offsets::SITE_GAMEOBJECT_CAN_USE_MOUNT_READ, kUseMountRead,
sizeof kUseMountRead,
reinterpret_cast<const void *>(&UseMountReadThunk));
Common::RedirectCall(Offsets::CALL_GAMEOBJECT_CAN_USE_IS_SHAPESHIFTED,
Offsets::FUN_UNIT_IS_SHAPESHIFTED,
reinterpret_cast<const void *>(&IsShapeShiftedForQuery));
}
Common::RedirectCall(Offsets::CALL_UNIT_CAN_INTERACT_NOW_IS_SHAPESHIFTED,
Offsets::FUN_UNIT_IS_SHAPESHIFTED,
reinterpret_cast<const void *>(&IsShapeShiftedForQuery));
// Loot. The loot step passes the mount only together with LootUnit's
// dismount.
static const uint8_t kUnitStandCall[6] = {0xFF, 0x90, 0xA4, 0x00, 0x00, 0x00};
static const uint8_t kCorpseStandCall[6] = {0xFF, 0x92, 0xA4, 0x00, 0x00, 0x00};
Common::ReplaceWithCall(Offsets::SITE_UNIT_LOOT_STAND_CALL, kUnitStandCall,
sizeof kUnitStandCall,
reinterpret_cast<const void *>(&StandStateForLoot));
Common::ReplaceWithCall(Offsets::SITE_CORPSE_LOOT_STAND_CALL, kCorpseStandCall,
sizeof kCorpseStandCall,
reinterpret_cast<const void *>(&StandStateForLoot));
static const uint8_t kLootSend[6] = {0x6A, 0x00, 0x32, 0xD2, 0xB1, 0x01};
static const uint8_t kLootMountRead[6] = {0x8B, 0x81, 0xFC, 0x01, 0x00, 0x00};
if (Common::ReplaceWithCall(Offsets::SITE_LOOT_UNIT_SEND, kLootSend, sizeof kLootSend,
reinterpret_cast<const void *>(&LootSendThunk)))
Common::ReplaceWithCall(Offsets::SITE_UNIT_LOOT_MOUNT_READ, kLootMountRead,
sizeof kLootMountRead,
reinterpret_cast<const void *>(&MountReadEsiThunk));
// Item use.
static const uint8_t kItemFormRefusal[5] = {0x68, 0x5E, 0x01, 0x00, 0x00};
Common::ReplaceWithCall(Offsets::SITE_ITEM_USE_FORM_REFUSAL, kItemFormRefusal,
sizeof kItemFormRefusal,
reinterpret_cast<const void *>(&ItemFormRefusalThunk));
// Attack. The mount reads pass only together with StartAttack's
// dismount; without it the attack would start mounted.
static const uint8_t kStartAttackPassed[6] = {0x8B, 0x86, 0x8C, 0x0C, 0x00, 0x00};
if (Common::ReplaceWithCall(Offsets::SITE_START_ATTACK_PASSED, kStartAttackPassed,
sizeof kStartAttackPassed,
reinterpret_cast<const void *>(&StartAttackPassedThunk))) {
static const uint8_t kEdxMountRead[6] = {0x8B, 0x82, 0xFC, 0x01, 0x00, 0x00};
static const uint8_t kEcxMountRead[6] = {0x8B, 0x81, 0xFC, 0x01, 0x00, 0x00};
static const uint8_t kEaxMountReadToEdx[6] = {0x8B, 0x90, 0xFC, 0x01, 0x00, 0x00};
Common::ReplaceWithCall(Offsets::SITE_START_ATTACK_MOUNT_READ, kEcxMountRead,
sizeof kEcxMountRead,
reinterpret_cast<const void *>(&MountReadEsiThunk));
Common::ReplaceWithCall(Offsets::SITE_RIGHT_CLICK_ATTACK_MOUNT_READ, kEdxMountRead,
sizeof kEdxMountRead,
reinterpret_cast<const void *>(&MountReadEsiThunk));
Common::ReplaceWithCall(Offsets::SITE_CURSOR_ATTACK_MOUNT_READ, kEdxMountRead,
sizeof kEdxMountRead,
reinterpret_cast<const void *>(&MountReadEdiThunk));
Common::ReplaceWithCall(Offsets::SITE_ATTACK_RESOLVER_MOUNT_READ, kEcxMountRead,
sizeof kEcxMountRead,
reinterpret_cast<const void *>(&MountReadEdiThunk));
Common::ReplaceWithCall(Offsets::SITE_ATTACK_RESOLVER_CAN_ATTACK_MOUNT_READ,
kEaxMountReadToEdx, sizeof kEaxMountReadToEdx,
reinterpret_cast<const void *>(&ResolverMountReadThunk));
}
// Taxi.
Common::RedirectCall(Offsets::CALL_TAXI_MAP_OPENED_TUTORIAL, Offsets::FUN_TRIGGER_TUTORIAL,
reinterpret_cast<const void *>(&TaxiMapOpenedTutorial));
// The rungs only note what to do; the caster-check call runs it. Without
// that call a passed rung would send the cast still mounted or seated.
if (!Common::RedirectCall(Offsets::CALL_TRY_CAST_CHECK_CASTER_STATE,
+2 -3
View File
@@ -20,6 +20,7 @@
#include "spell/Lookup.h"
#include "spell/Range.h"
#include "unit/Position.h"
#include "unit/Power.h"
#include <cstdint>
#include <cstdio>
@@ -147,10 +148,8 @@ static bool ReadSpellInfo(int spellID, int pet, SpellInfoData &out) {
out.powerType = Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_POWER_TYPE);
using PowerHelper_t = int32_t(__fastcall *)(int spellID, int pet);
using DisplayDivisor_t = uint32_t(__fastcall *)(int powerType);
using CastTime_t = int32_t(__fastcall *)(int spellID, int pet, int allowNegative);
const auto divisor = static_cast<int32_t>(reinterpret_cast<DisplayDivisor_t>(
static_cast<uintptr_t>(Offsets::FUN_GET_POWER_DISPLAY_DIVISOR))(out.powerType));
const auto divisor = static_cast<int32_t>(Unit::Power::DisplayDivisor(out.powerType));
const int32_t cost = reinterpret_cast<PowerHelper_t>(
static_cast<uintptr_t>(Offsets::FUN_GET_SPELL_COST))(spellID, pet) / divisor;
const int32_t costPerSecond = reinterpret_cast<PowerHelper_t>(
+8 -4
View File
@@ -26,7 +26,9 @@
// type Enum.PowerType (0 mana, 1 rage, 2 focus, 3 energy,
// 4 happiness) — the spell's PowerType (+0x7C)
// name power token ("MANA", "RAGE", …)
// cost effective cost (see above)
// cost effective cost (see above), in displayed units — the
// helper answers in stored units (rage in tenths), so it
// is divided by `Unit::Power::DisplayDivisor`
// minCost == cost; vanilla has no "optional" cost component
// costPercent ManaCostPercent (+0x270) — the spell's %-of-base-
// resource cost, or 0 for a flat cost
@@ -49,7 +51,8 @@ namespace {
using GetSpellCost_t = uint32_t(__fastcall *)(int spellID, int pet);
// The engine's cost helper, in STORED units (rage in tenths); 0xFFFFFFFF on
// error. Callers divide by `Unit::Power::DisplayDivisor` before surfacing it.
uint32_t EffectiveCost(int spellID) {
return reinterpret_cast<GetSpellCost_t>(Offsets::FUN_GET_SPELL_COST)(spellID, 0);
}
@@ -60,11 +63,12 @@ int __fastcall Script_GetSpellPowerCost(void *L) {
if (rec == nullptr)
return 0; // nil — spell not found
const uint32_t cost = EffectiveCost(spellID);
if (cost == 0xFFFFFFFF)
const uint32_t storedCost = EffectiveCost(spellID);
if (storedCost == 0xFFFFFFFF)
return 0; // engine error (no player context, etc.)
const int powerType = Game::Read<int>(rec, Offsets::OFF_SPELL_RECORD_POWER_TYPE);
const uint32_t cost = storedCost / Unit::Power::DisplayDivisor(powerType);
const int costPercent = Game::Read<int>(rec, Offsets::OFF_SPELL_RECORD_MANA_COST_PERCENT);
if (cost == 0 && costPercent == 0)
return 0; // nil — no resource cost
+11 -17
View File
@@ -19,7 +19,7 @@
// is the same: read POWER[type] / MAXPOWER[type] off the unit's
// descriptor, defaulting `type` to the unit's primary power (the
// `UNIT_FIELD_BYTES_0` byte 3 at `descriptor + 0x7B`), then divide
// by the per-power-type display divisor (`VAR_UNIT_POWER_DIVISOR_TABLE`)
// by the engine's per-power-type display divisor (`DisplayDivisor`)
// before pushing. Rage internally stores 0..1000 but displays 0..100
// (divisor 10); happiness uses divisor 1000. Same divisor logic
// vanilla's own `Script_UnitMana` (`0x00517670`) applies.
@@ -58,6 +58,12 @@ const char *PowerTypeToken(int type) {
return "UNKNOWN";
}
unsigned int DisplayDivisor(int type) {
using Divisor_t = unsigned int(__fastcall *)(int powerType);
return reinterpret_cast<Divisor_t>(
static_cast<uintptr_t>(Offsets::FUN_POWER_DISPLAY_DIVISOR))(type);
}
namespace {
const uint8_t *Descriptor(const uint8_t *unit) {
@@ -73,18 +79,6 @@ const uint8_t *ResolveUnit(const char *token) {
return static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
}
// Per-power-type display divisor. Vanilla stores rage as 0..1000
// internally and displays 0..100 (divide by 10); happiness is
// stored at 1000x scale. Same table the engine's own `Script_UnitMana`
// (`0x00517670`) reads at `VAR_UNIT_POWER_DIVISOR_TABLE`.
uint32_t PowerDivisor(int type) {
if (type < 0)
return 1;
auto *table = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_UNIT_POWER_DIVISOR_TABLE));
return table[type];
}
// `Enum.PowerType` — modern Blizzard-style enum table. Slot 4 is
// `Happiness` in 1.12 (pet happiness 0..3); modern retail relabels
@@ -167,7 +161,7 @@ double RosterPowerFallback(void *L, RosterKind kind) {
return 0.0;
const uint32_t divisor =
Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(s.powerType);
Game::Lua::ToBoolean(L, 3) ? 1u : DisplayDivisor(s.powerType);
const uint32_t curDisp = s.power / divisor;
const uint32_t maxDisp = s.maxPower / divisor;
switch (kind) {
@@ -202,7 +196,7 @@ static int __fastcall Script_UnitPower(void *L) {
desc + Offsets::OFF_UNIT_FIELD_POWER1 + type * 4);
// `unmodified` (arg 3, truthy) returns the raw internal value; otherwise
// apply the per-type display divisor. Matches retail's third UnitPower arg.
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(type);
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : DisplayDivisor(type);
Game::Lua::PushNumber(L, static_cast<double>(raw / divisor));
return 1;
}
@@ -223,7 +217,7 @@ static int __fastcall Script_UnitPowerMax(void *L) {
}
const uint32_t raw = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_MAXPOWER1 + type * 4);
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(type);
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : DisplayDivisor(type);
Game::Lua::PushNumber(L, static_cast<double>(raw / divisor));
return 1;
}
@@ -255,7 +249,7 @@ static int __fastcall Script_UnitPowerMissing(void *L) {
desc + Offsets::OFF_UNIT_FIELD_POWER1 + type * 4);
const uint32_t max = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_MAXPOWER1 + type * 4);
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(type);
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : DisplayDivisor(type);
const uint32_t curDisp = cur / divisor;
const uint32_t maxDisp = max / divisor;
Game::Lua::PushNumber(
+6
View File
@@ -20,4 +20,10 @@ namespace Unit::Power {
// Used by `UnitPowerType`'s second return and by spell power-cost APIs.
const char *PowerTypeToken(int type);
// The engine's divisor from a stored power amount to displayed units
// (`FUN_POWER_DISPLAY_DIVISOR`): 10 for rage, 1000 for happiness, 1 otherwise.
// Every power amount surfaced to Lua — unit power, spell costs, combat-log
// amounts — goes through this.
unsigned int DisplayDivisor(int type);
} // namespace Unit::Power