ui+app: the bag walk, the modifier fold, and two shapes the DLL's pool corrected
Built: `GetBagItems`, `GetSpellModifiers`, `GetSpellIdCooldown` — plus
fixes to two verbs that already existed and had the wrong *shape*,
found the same way `GetItemIconTexture`'s wrong argument was: by
comparing what we answer against nampower's own string pool.
`GetBagItems()` hands back every occupied bag slot as
`{ [bagIndex] = { [slot] = row } }`. DoiteAuras asks seven times over
and walks both levels with `pairs`, reading `itemId` and `stackCount` —
it is how that addon answers "do I have this item" without 100
`GetContainerItemInfo` calls per condition evaluation. Empty slots and
empty bags are absent, because a nil value in a Lua table *is* absence
and `pairs` is how both levels are read.
`GetSpellModifiers(spellId, modifierType)` answers
`flat, percent, hasModifier`, where percent is the final multiplier (90
means 90% of the original) — the consumer says so in its own words and
multiplies by `percent / 100`. The 26 modifier types come from the
DLL's usage string. The fold is `spell/mods.rs`' existing reader,
reached through a new `spell_modifier_probe` the lifecycle reinstalls
whenever the wire moves the tables: a VM-side reimplementation of a
four-gate 64-bit fold would be free to drift from the mana cost the
action bar greys a button on. The third return is the point — the
consumer tests it before touching either number, and the duration path
multiplies, so `0, 0` for an unmodified spell would zero every debuff
timer that asks.
`GetSpellIdCooldown(spellId)` answers the twenty-key table, from a new
`Cooldowns::legs`: `info`'s three legs kept apart, same walk and same
arms, so the folded number a swirl draws and the legs an addon reads
cannot describe different timers.
**`GetBagItem`'s row had the wrong key names** — `bag`, `slot`,
`count`, `quality`, `link`, `locked`, this client's own
`GetContainerItemInfo` spellings. nampower's row is contiguous at
`0xa8a68` and is the block `GetEquippedItem` already used, so a caller
reading `.stackCount` got nil and no error. One `bag_slot_table` now
serves both, with three columns absent rather than zeroed: this client
resolves a bag slot's enchants to names at the app boundary and keeps
no random-properties id for a container slot, and a zeroed enchant id
would be a claim.
**`GetItemIdCooldown` returned the wrong kind of value** — the stock
`start, duration, enable` triple. At the version we report it is a
table, and the addon layer settles it: `API.GetItemCooldownInfo` and
`API.GetSpellCooldownInfo` return these calls' results *directly* and
their callers index them. `cd.gcdCategoryRemainingMs` is the primary
path of `API.GetGCDRemainingMs`, which the `[gcd]`/`[nogcd]` macro
conditionals divide by 1000 — against a triple that is
`(0.0).gcdCategoryRemainingMs`, which raises. Latent rather than live
(nothing calls those wrappers today), still wrong.
Two things the schedule gates caught and improved:
- The probe refresher started as an exclusive system, which is
ambiguous against everything in Update it is not ordered against:
+274 pairs over the ceiling. `NonSendMut` is all it needs.
- Its memo started as a bare `Local`, which `session.rs`' own gate
refuses — a memo about what this VM has been told must die with the
VM. As a `VmMemo` it now reinstalls on the first frame after a
`/reload`, which is better than the reasoning I had written for the
`Local`: a fresh VM has no closure at all until something installs
one.
One count the earlier sweep got wrong, corrected in the doc:
`GetSpellPower`'s "7 call sites" are BetterCharacterStats' own method
`BCS:GetSpellPower(school)`, not nampower's global. Nothing in the
corpus calls the global at all. A bare name match is not a call site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -1447,6 +1447,8 @@ fn seed_cooldown_filmstrip(
|
||||
slots.insert(
|
||||
slot,
|
||||
benilla_ui::script::ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
petition: None,
|
||||
durability: None,
|
||||
duration_ms: None,
|
||||
@@ -1500,6 +1502,8 @@ fn seed_bag_window(
|
||||
};
|
||||
let slot = |disp: u32, count: u32, id: u32, name: &str, quality: u32| {
|
||||
benilla_ui::script::ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
petition: None,
|
||||
durability: None,
|
||||
duration_ms: None,
|
||||
@@ -1587,6 +1591,8 @@ fn seed_equipped_bags(
|
||||
};
|
||||
let slot =
|
||||
|disp: u32, count: u32, name: &str, quality: u32| benilla_ui::script::ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
petition: None,
|
||||
durability: None,
|
||||
duration_ms: None,
|
||||
|
||||
@@ -101,6 +101,22 @@ pub(crate) fn ui_stamp(at: Instant, anchor: Instant, ui_now: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Cooldowns::legs`]' answer: the three legs of one cooldown query, each with the category id
|
||||
/// it was keyed on. A `None` leg is cold — never a zeroed [`CooldownInfo`], because nampower's
|
||||
/// table reports an `isOn…` flag per leg and "no record" and "a record with nothing left" are
|
||||
/// different answers to it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct CooldownLegs {
|
||||
/// The spell's own recovery, keyed on the (spell, item) pair.
|
||||
pub(crate) individual: Option<CooldownInfo>,
|
||||
/// `Spell.dbc` `Category`, the id the category leg was keyed on; 0 for a spell with none.
|
||||
pub(crate) category_id: u32,
|
||||
pub(crate) category: Option<CooldownInfo>,
|
||||
/// `StartRecoveryCategory`, the id the GCD leg was keyed on.
|
||||
pub(crate) gcd_category_id: u32,
|
||||
pub(crate) gcd: Option<CooldownInfo>,
|
||||
}
|
||||
|
||||
/// The player's cooldown list (the client's `SpellHistory` at `0xcecaec`); the pet list has no
|
||||
/// consumer.
|
||||
#[derive(Resource, Default)]
|
||||
@@ -513,6 +529,84 @@ impl Cooldowns {
|
||||
self.info(spell_id, item_entry, spell, now).remaining_ms > 0
|
||||
}
|
||||
|
||||
/// [`Self::info`]'s three legs kept apart, for nampower's `GetSpellIdCooldown` /
|
||||
/// `GetItemIdCooldown`, which report each separately with its own start, duration, remainder
|
||||
/// and `isOn…` flag (the twenty key names are in the DLL's string pool at `0xa88d4`).
|
||||
///
|
||||
/// Exactly the same walk and exactly the same arms as [`Self::info`] — including its
|
||||
/// `cooldown_query_excluded` early-out, the parked record's "full duration, disabled" arm and
|
||||
/// the GCD leg's indifference to `on_hold` — so the folded answer a cooldown swirl draws and
|
||||
/// the three legs an addon reads can never describe different timers. A second walk with its
|
||||
/// own copy of those arms is how they would come to.
|
||||
pub(crate) fn legs(
|
||||
&self,
|
||||
spell_id: u32,
|
||||
item_entry: u32,
|
||||
spell: Option<&SpellDisplay>,
|
||||
now: Instant,
|
||||
) -> CooldownLegs {
|
||||
let category = spell.map_or(0, |s| s.category);
|
||||
let gcd_category = spell.map_or(0, |s| s.start_recovery_category);
|
||||
let mut out = CooldownLegs {
|
||||
individual: None,
|
||||
category_id: category,
|
||||
category: None,
|
||||
gcd_category_id: gcd_category,
|
||||
gcd: None,
|
||||
};
|
||||
if spell.is_some_and(|s| s.cooldown_query_excluded()) {
|
||||
return out;
|
||||
}
|
||||
// Longest-wins per leg, as `info` picks longest-wins across all three.
|
||||
let consider =
|
||||
|best: &mut Option<CooldownInfo>, timer: &Timer, remaining: Duration, enabled: bool| {
|
||||
let remaining_ms = remaining.as_millis().min(u128::from(u32::MAX)) as u32;
|
||||
if best.is_none_or(|b| remaining_ms > b.remaining_ms) {
|
||||
*best = Some(CooldownInfo {
|
||||
start: timer.start,
|
||||
remaining_ms,
|
||||
duration_ms: timer.duration.as_millis().min(u128::from(u32::MAX)) as u32,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
};
|
||||
for r in &self.records {
|
||||
if r.spell_id == spell_id && r.item_id == item_entry {
|
||||
if r.on_hold {
|
||||
consider(&mut out.individual, &r.recovery, r.recovery.duration, false);
|
||||
} else {
|
||||
consider(
|
||||
&mut out.individual,
|
||||
&r.recovery,
|
||||
r.recovery.remaining(now),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (r.category != 0 && r.category == category) || r.category_wildcard {
|
||||
if r.on_hold {
|
||||
consider(
|
||||
&mut out.category,
|
||||
&r.category_recovery,
|
||||
r.category_recovery.duration,
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
consider(
|
||||
&mut out.category,
|
||||
&r.category_recovery,
|
||||
r.category_recovery.remaining(now),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
if r.gcd_category == gcd_category && !r.gcd.duration.is_zero() {
|
||||
consider(&mut out.gcd, &r.gcd, r.gcd.remaining(now), true);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The running global cooldown as one timer — the record whose GCD leg has the most left,
|
||||
/// with its start and duration. `None` when nothing is on the GCD.
|
||||
///
|
||||
|
||||
@@ -25,7 +25,7 @@ pub(crate) mod validator;
|
||||
// `send_spell_cast` is private to `cast_send`, so no second send path can exist.
|
||||
pub(crate) use cast_send::{CastCommit, CastLadder, TargetedBind};
|
||||
pub(crate) use cast_target::AutoSelfCast;
|
||||
pub(crate) use cooldowns::Cooldowns;
|
||||
pub(crate) use cooldowns::{CooldownInfo, CooldownLegs, Cooldowns};
|
||||
pub(crate) use inflight::{
|
||||
inflight, ActiveChannel, AutoRepeatActive, LocalMoveStart, PendingCast, QueuedMeleeSpell,
|
||||
SPELL_INTERRUPT_MOVEMENT,
|
||||
|
||||
@@ -73,10 +73,17 @@ impl SpellMod {
|
||||
|
||||
/// The two tables and the class family the gate compares against (`0xcead60`, `0xcecb30`,
|
||||
/// `0xcecaac`). Written only by the wire, cleared only at world-enter, read live.
|
||||
#[derive(Resource)]
|
||||
#[derive(Resource, Clone)]
|
||||
pub(crate) struct SpellModifiers {
|
||||
flat: [i32; CELLS],
|
||||
pct: [i32; CELLS],
|
||||
/// Bumped on every write and on the world-enter clear: what
|
||||
/// `ui_script::lifecycle::refresh_spell_modifier_probe` watches, so nampower's
|
||||
/// `GetSpellModifiers` probe is reinstalled exactly when the tables it folds have moved.
|
||||
///
|
||||
/// The class family counts as a write: the gate compares against it, so a spell that read
|
||||
/// `None` before the avatar resolved reads a real pair after.
|
||||
generation: u64,
|
||||
/// The local player's class spell-family (`ChrClasses.dbc` field 15); 0 until the avatar
|
||||
/// resolves, which the reader's `SpellFamilyName != 0` gate covers.
|
||||
class_family: u32,
|
||||
@@ -88,6 +95,7 @@ impl Default for SpellModifiers {
|
||||
flat: [0; CELLS],
|
||||
pct: [0; CELLS],
|
||||
class_family: 0,
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,10 +119,19 @@ impl SpellModifiers {
|
||||
} else {
|
||||
self.pct[cell] = value;
|
||||
}
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// The counter a VM-side probe watches; see [`Self::generation`].
|
||||
pub(crate) fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// Set the gate's class family (`0x6e6ca0`).
|
||||
pub(crate) fn set_class_family(&mut self, family: u32) {
|
||||
if self.class_family != family {
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
self.class_family = family;
|
||||
}
|
||||
|
||||
@@ -123,6 +140,7 @@ impl SpellModifiers {
|
||||
self.flat = [0; CELLS];
|
||||
self.pct = [0; CELLS];
|
||||
self.class_family = 0;
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// `GetSpellModifiers 0x6e6b30` for one spell and op. `None` covers all four of its false
|
||||
@@ -161,6 +179,24 @@ impl SpellModifiers {
|
||||
pub(crate) fn apply(&self, d: &SpellDisplay, op: u8, value: i32) -> i32 {
|
||||
self.modifiers(d, op).map_or(value, |m| m.apply(value))
|
||||
}
|
||||
|
||||
/// [`Self::modifiers`] as the pair nampower's `GetSpellModifiers(spellId, modifierType)`
|
||||
/// hands to Lua: `(flat, percent)`, where `percent` is the **final multiplier** the reader
|
||||
/// already added 100 to — `SuperCleveRoidMacros` states that in its own words ("Nampower
|
||||
/// returns percent as final multiplier (90 = 90% of original, not -10% change)",
|
||||
/// `Utility.lua:6932`) and multiplies by `percent / 100`.
|
||||
///
|
||||
/// `None` passes through unchanged, and it has to: the consumer's third return is a
|
||||
/// has-modifier flag it tests before using either number
|
||||
/// (`if not hasModifier or (flat == 0 and percent == 0) then return nil end`), and a `(0, 0)`
|
||||
/// pair would make every unmodified spell look like one whose duration is multiplied by zero.
|
||||
/// That is the same trap [`Self::modifiers`]' own doc names for the reference's callers.
|
||||
///
|
||||
/// The one reader of the tables stays the one reader: the Lua probe calls this rather than
|
||||
/// folding a pushed copy, so nampower's answer and the power cost cannot disagree.
|
||||
pub(crate) fn modifier_pair(&self, d: &SpellDisplay, op: u8) -> Option<(i32, i32)> {
|
||||
self.modifiers(d, op).map(|m| (m.flat, m.pct))
|
||||
}
|
||||
}
|
||||
|
||||
/// Entering the world clears the tables and class family: `Spell_C::SystemInitialize 0x6e7150`,
|
||||
@@ -209,6 +245,74 @@ mod tests {
|
||||
mods
|
||||
}
|
||||
|
||||
/// [`SpellModifiers::modifier_pair`], nampower's `GetSpellModifiers` return, and the
|
||||
/// generation that makes the Lua probe follow it.
|
||||
///
|
||||
/// The `None` is the contract: the consumer's third return is a has-modifier flag it tests
|
||||
/// before touching either number, and the duration path multiplies by `percent / 100`. A
|
||||
/// `(0, 0)` pair for an unmodified spell would zero every debuff timer that asks.
|
||||
#[test]
|
||||
fn the_modifier_pair_is_none_until_a_cell_applies_and_the_generation_follows() {
|
||||
let mut mods = mage_tables();
|
||||
let seen = mods.generation();
|
||||
|
||||
let fireball = mage(&[0]);
|
||||
assert_eq!(
|
||||
mods.modifier_pair(&fireball, OP_COST),
|
||||
None,
|
||||
"an empty table is no modifier, not a zeroing one"
|
||||
);
|
||||
|
||||
// −30 mana flat on bit 0's spells, which is Improved Fireball-shaped.
|
||||
mods.set(true, 0, OP_COST, -30);
|
||||
assert_eq!(
|
||||
mods.modifier_pair(&fireball, OP_COST),
|
||||
Some((-30, 100)),
|
||||
"100 is the identity multiplier the reader adds once, not a doubling"
|
||||
);
|
||||
assert_ne!(
|
||||
mods.generation(),
|
||||
seen,
|
||||
"the write moved the generation, which is what makes the Lua probe reinstall"
|
||||
);
|
||||
|
||||
// A percentage on the same cell: the pair carries both, the percent already +100.
|
||||
mods.set(false, 0, OP_COST, -40);
|
||||
assert_eq!(mods.modifier_pair(&fireball, OP_COST), Some((-30, 60)));
|
||||
// And the gates still shut it out: a different op, a spell with no family bits, and a
|
||||
// spell of another class all read None.
|
||||
assert_eq!(mods.modifier_pair(&fireball, 1), None);
|
||||
assert_eq!(mods.modifier_pair(&mage(&[]), OP_COST), None);
|
||||
assert_eq!(
|
||||
mods.modifier_pair(
|
||||
&SpellDisplay {
|
||||
spell_family: 7,
|
||||
spell_family_flags: 1,
|
||||
..Default::default()
|
||||
},
|
||||
OP_COST
|
||||
),
|
||||
None,
|
||||
"a warlock spell against a mage's tables"
|
||||
);
|
||||
|
||||
// The class family counts as a write, because the gate compares against it.
|
||||
let before = mods.generation();
|
||||
mods.set_class_family(3);
|
||||
assert_eq!(
|
||||
mods.generation(),
|
||||
before,
|
||||
"an unchanged family is not a write"
|
||||
);
|
||||
mods.set_class_family(7);
|
||||
assert_ne!(mods.generation(), before);
|
||||
assert_eq!(
|
||||
mods.modifier_pair(&fireball, OP_COST),
|
||||
None,
|
||||
"and the gate really moved with it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Uses the asymmetric pair (5, 14): a transposed index would serve bit 14's spell instead.
|
||||
#[test]
|
||||
fn the_index_is_mask_bit_times_29_plus_op() {
|
||||
|
||||
@@ -1255,6 +1255,8 @@ mod bind_confirm_tests {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
|
||||
@@ -662,6 +662,16 @@ fn resolve_slot(
|
||||
.info(u.spell_id, entry, sd, now)
|
||||
.ui_triple(now, ui_now)
|
||||
}),
|
||||
use_spell_id: t.use_spell.map_or(0, |u| u.spell_id),
|
||||
// The same query's three legs, for nampower's `GetItemIdCooldown`, off the same `now`.
|
||||
cooldown_legs: t.use_spell.map_or_else(Default::default, |u| {
|
||||
let sd = spells.and_then(|s| s.get(u.spell_id));
|
||||
crate::ui_script::cooldown_legs_view(
|
||||
&cooldowns.legs(u.spell_id, entry, sd, now),
|
||||
now,
|
||||
ui_now,
|
||||
)
|
||||
}),
|
||||
// Uses left, from the instance's own countdown of the pool block [`charge_pool`] names;
|
||||
// `ITEM_FIELD_SPELL_CHARGES` is signed the same way the template is (negative consumes
|
||||
// the item), so the magnitude is the count. Absent from the descriptor — which our own
|
||||
|
||||
@@ -161,6 +161,13 @@ fn slot_view(
|
||||
.cooldowns
|
||||
.info(spell_id, 0, Some(d), now)
|
||||
.ui_triple(anchor, ui_now),
|
||||
// The same query's legs, so nampower's `GetSpellIdCooldown` answers for a pet spell as
|
||||
// it does for a player one — the slot shape is shared and so is the arithmetic.
|
||||
cooldown_legs: crate::ui_script::cooldown_legs_view(
|
||||
&bar.cooldowns.legs(spell_id, 0, Some(d), now),
|
||||
anchor,
|
||||
ui_now,
|
||||
),
|
||||
// Off the raw word; `0x4bdd65` also requires a `Spell.dbc` record, which every slot has.
|
||||
autocast: Some((entry.autocast_allowed(), entry.autocast_on())),
|
||||
packed: entry.packed,
|
||||
|
||||
@@ -1056,6 +1056,8 @@ fn shipped_bag_frame_drives_end_to_end() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
|
||||
@@ -369,6 +369,8 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
@@ -443,6 +445,8 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
@@ -547,6 +551,8 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
@@ -570,6 +576,8 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
slots.insert(
|
||||
2,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
@@ -750,6 +758,8 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
@@ -833,6 +843,8 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
@@ -962,6 +974,8 @@ fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
|
||||
@@ -80,6 +80,8 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
benilla_ui::script::ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
|
||||
@@ -312,6 +312,8 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
|
||||
@@ -14,6 +14,8 @@ fn one_item_backpack(item_id: u32, name: &str, quality: u32) -> ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
|
||||
@@ -12,6 +12,8 @@ fn one_item_backpack() -> ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
@@ -244,6 +246,8 @@ fn escape_closes_an_open_stack_split_frame() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
|
||||
@@ -756,6 +756,8 @@ fn backpack() -> ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
|
||||
@@ -38,6 +38,7 @@ fn install_boot_vm(world: &mut World) {
|
||||
install_addon_asset_resolvers(world, &mut script);
|
||||
install_spell_catalog_probe(world, &mut script);
|
||||
install_item_icon_probe(world, &mut script);
|
||||
install_spell_modifier_probe(world, &mut script);
|
||||
seed_creature_info(world, &mut script);
|
||||
load_global_strings(world, &script);
|
||||
load_emote_tokens(world, &script);
|
||||
@@ -203,6 +204,75 @@ fn install_item_icon_probe(world: &World, script: &mut UiScript) {
|
||||
script.set_item_icon_probe(Box::new(move |display_id| icons.get(&display_id).cloned()));
|
||||
}
|
||||
|
||||
/// nampower's `GetSpellModifiers(spellId, modifierType)`: the `(flat, percent)` pair
|
||||
/// `spell/mods.rs`'s own reader folds out of the two `i32[64][29]` talent-modifier tables the
|
||||
/// wire fills.
|
||||
///
|
||||
/// The closure captures a **copy** of those tables and calls the real reader on it, rather than
|
||||
/// pushing the cells to Lua for a second fold. The arithmetic has four gates and a 64-bit sum,
|
||||
/// and `SpellModifiers::modifiers` is already its one implementation — the one the power cost is
|
||||
/// computed with — so a VM-side reimplementation would be free to drift from the number a player
|
||||
/// sees on their own action bar.
|
||||
///
|
||||
/// A copy means it goes stale, which is what [`refresh_spell_modifier_probe`] is for: the tables
|
||||
/// are live state (every `SMSG_SET_*_SPELL_MODIFIER`, and the class family as the avatar
|
||||
/// resolves), and they arrive **after** world entry has built this VM. Without the refresh this
|
||||
/// probe would answer `None` for the whole session, which is exactly the answer a client with no
|
||||
/// talents gives — a silent wrong one.
|
||||
fn install_spell_modifier_probe(world: &World, script: &mut UiScript) {
|
||||
let Some(mods) = world.get_resource::<crate::spell::SpellModifiers>() else {
|
||||
return;
|
||||
};
|
||||
let Some(spells) = world.get_resource::<crate::ui_action::Spells>() else {
|
||||
return;
|
||||
};
|
||||
let displays = spells.catalog.displays_shared();
|
||||
let snapshot = mods.clone();
|
||||
script.set_spell_modifier_probe(Box::new(move |spell_id, op| {
|
||||
snapshot.modifier_pair(displays.get(&spell_id)?, op)
|
||||
}));
|
||||
}
|
||||
|
||||
/// Reinstall [`install_spell_modifier_probe`]'s closure when the tables behind it move.
|
||||
///
|
||||
/// Gated on the resource's own generation rather than Bevy change detection, so a write that
|
||||
/// stores the value a cell already held does not rebuild the closure — and so the gate reads the
|
||||
/// same way as `Cooldowns::feed_epoch`, which is the precedent here. The rebuild copies ~15 KB
|
||||
/// and happens at login and on a respec, not per frame.
|
||||
///
|
||||
/// **Not an exclusive system**, although swapping a closure on the VM would be the obvious reason
|
||||
/// to make it one: an exclusive system in `Update` is ambiguous against every system it is not
|
||||
/// explicitly ordered against, and this one cost 274 pairs against
|
||||
/// `the_schedules_have_no_more_undeclared_orders_than_the_ceilings_say`'s ceiling. `NonSendMut`
|
||||
/// is all it needs.
|
||||
///
|
||||
/// The memo is a [`VmMemo`], not a bare `Local`, which
|
||||
/// `session.rs`'s own gate insists on and is right to: a memo about what this VM has been told
|
||||
/// must die with the VM. Here it means a fresh VM reinstalls the probe on its first frame even at
|
||||
/// an unchanged generation, which is exactly what a `/reload` needs — the new VM has no closure at
|
||||
/// all until something installs one.
|
||||
pub(crate) fn refresh_spell_modifier_probe(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mods: Option<Res<crate::spell::SpellModifiers>>,
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
mut seen: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
) {
|
||||
let (Some(mut script), Some(mods), Some(spells)) = (script, mods, spells) else {
|
||||
return;
|
||||
};
|
||||
let generation = mods.generation();
|
||||
let seen = seen.get(&script);
|
||||
if *seen == Some(generation) {
|
||||
return;
|
||||
}
|
||||
*seen = Some(generation);
|
||||
let displays = spells.catalog.displays_shared();
|
||||
let snapshot = mods.clone();
|
||||
script.set_spell_modifier_probe(Box::new(move |spell_id, op| {
|
||||
snapshot.modifier_pair(displays.get(&spell_id)?, op)
|
||||
}));
|
||||
}
|
||||
|
||||
/// Install [`install_spell_probe`] from the world's catalog, if it has one. The boot VM built at
|
||||
/// `Startup` runs before `ui_action`'s loader has inserted [`crate::ui_action::Spells`] (its
|
||||
/// `insert_resource` is a command, applied at the end of that schedule), so the character-screen
|
||||
@@ -1223,3 +1293,97 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod spell_modifier_probe_tests {
|
||||
use super::*;
|
||||
use benilla_formats::{SpellCatalog, SpellDisplay};
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Improved Fireball-shaped: a mage spell (family 3) with family bit 0 set.
|
||||
const FIREBALL: u32 = 133;
|
||||
|
||||
fn app() -> App {
|
||||
let mut app = App::new();
|
||||
let mut spells = crate::ui_action::Spells::empty_for_tests();
|
||||
spells.catalog = SpellCatalog::from_displays(HashMap::from([(
|
||||
FIREBALL,
|
||||
SpellDisplay {
|
||||
spell_family: 3,
|
||||
spell_family_flags: 1,
|
||||
..Default::default()
|
||||
},
|
||||
)]));
|
||||
app.insert_resource(spells);
|
||||
let mut mods = crate::spell::SpellModifiers::default();
|
||||
mods.set_class_family(3);
|
||||
app.insert_resource(mods);
|
||||
app.insert_non_send_resource(UiScript::new().unwrap());
|
||||
app
|
||||
}
|
||||
|
||||
fn ask(app: &App) -> (i64, i64, i64) {
|
||||
app.world()
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval("return GetSpellModifiers(133, 14)")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The whole chain, which no unit test on either side can see: a wire write moves the tables,
|
||||
/// the generation moves with it, the refresher reinstalls the closure, and Lua's
|
||||
/// `GetSpellModifiers` answers the pair `spell/mods.rs`' own reader folded.
|
||||
///
|
||||
/// Worth a test of its own because every link fails quietly. A probe never installed, a
|
||||
/// generation never bumped, or a closure holding a copy from before the talents arrived all
|
||||
/// read as "this spell has no modifier" — which is also the correct answer for most spells,
|
||||
/// so nothing would look wrong.
|
||||
#[test]
|
||||
fn a_wire_write_reaches_lua_through_a_reinstalled_probe() {
|
||||
let mut app = app();
|
||||
// No probe yet: the honest nothing.
|
||||
assert_eq!(ask(&app), (0, 0, 0), "no probe installed yet");
|
||||
|
||||
app.world_mut()
|
||||
.run_system_once(refresh_spell_modifier_probe)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ask(&app),
|
||||
(0, 0, 0),
|
||||
"installed, and the tables really are empty"
|
||||
);
|
||||
|
||||
// The wire writes a cell. Until the refresher runs again the closure holds the old copy —
|
||||
// which is exactly the staleness the generation exists to catch, so it is asserted.
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::spell::SpellModifiers>()
|
||||
.set(true, 0, crate::spell::OP_COST, -30);
|
||||
assert_eq!(
|
||||
ask(&app),
|
||||
(0, 0, 0),
|
||||
"the installed copy predates the write"
|
||||
);
|
||||
|
||||
app.world_mut()
|
||||
.run_system_once(refresh_spell_modifier_probe)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ask(&app),
|
||||
(-30, 100, 1),
|
||||
"the reinstalled closure folds the new cell, and 100 is the identity multiplier"
|
||||
);
|
||||
|
||||
// A fresh VM gets its own closure, although the generation has not moved: the memo is a
|
||||
// `VmMemo`, so it died with the old VM.
|
||||
app.insert_non_send_resource(UiScript::new().unwrap());
|
||||
assert_eq!(ask(&app), (0, 0, 0), "a new VM has no closure at all");
|
||||
app.world_mut()
|
||||
.run_system_once(refresh_spell_modifier_probe)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ask(&app),
|
||||
(-30, 100, 1),
|
||||
"and the first frame after a /reload installs one"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,29 @@ pub(crate) struct UiClock {
|
||||
pub(crate) ui_now: f64,
|
||||
}
|
||||
|
||||
/// [`crate::spell::CooldownLegs`] on the VM's `GetTime()` clock, the one conversion both feeds
|
||||
/// that carry it use — the spellbook's and the bags'.
|
||||
///
|
||||
/// Here rather than in either feed because both must spell it the same way: a leg's remainder is
|
||||
/// derived in the Lua binding from `GetTime()`, so if the two feeds disagreed about the start, the
|
||||
/// same item's cooldown would read differently through `GetSpellIdCooldown` and
|
||||
/// `GetItemIdCooldown`.
|
||||
pub(crate) fn cooldown_legs_view(
|
||||
legs: &crate::spell::CooldownLegs,
|
||||
anchor: std::time::Instant,
|
||||
ui_now: f64,
|
||||
) -> benilla_ui::script::CooldownLegsView {
|
||||
let leg =
|
||||
|info: Option<crate::spell::CooldownInfo>| info.and_then(|i| i.ui_triple(anchor, ui_now));
|
||||
benilla_ui::script::CooldownLegsView {
|
||||
individual: leg(legs.individual),
|
||||
category_id: legs.category_id,
|
||||
category: leg(legs.category),
|
||||
gcd_category_id: legs.gcd_category_id,
|
||||
gcd: leg(legs.gcd),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pre-boot pair, replaced by [`lifecycle::seed_vm_clock`] at the first VM.
|
||||
impl Default for UiClock {
|
||||
fn default() -> Self {
|
||||
@@ -337,6 +360,14 @@ impl Plugin for UiScriptPlugin {
|
||||
)
|
||||
// In the feed phase, so an `OnUpdate` reads this frame's `GetFramerate()`.
|
||||
.add_systems(Update, feed_framerate.in_set(UiFeed))
|
||||
// nampower's `GetSpellModifiers` probe, reinstalled when the wire moves the talent
|
||||
// tables behind it (login, a respec). In the feed phase for the ordinary reason —
|
||||
// the VM must answer out of this frame's state — and exclusive because it swaps a
|
||||
// closure on the VM.
|
||||
.add_systems(
|
||||
Update,
|
||||
lifecycle::refresh_spell_modifier_probe.in_set(UiFeed),
|
||||
)
|
||||
// `WOW_FLASH_CHURN=1` only; a no-op otherwise. After the tick, because what it
|
||||
// watches is the visibility the frame's handlers just produced — a reading taken
|
||||
// before them would miss the very edges it exists to count.
|
||||
|
||||
@@ -122,6 +122,8 @@ fn seed_items(s: &mut UiScript) {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
charges: 0,
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
|
||||
@@ -218,9 +218,17 @@ fn feed_spellbook(
|
||||
// `GetTime` clock, frame-stable per arm, so a running cooldown never churns the diff.
|
||||
let (anchor, ui_now) = (clock.anchor, clock.ui_now);
|
||||
for slot in &mut fresh.slots {
|
||||
let d = spells.catalog.get(slot.spell_id);
|
||||
slot.cooldown = cooldowns
|
||||
.info(slot.spell_id, 0, spells.catalog.get(slot.spell_id), anchor)
|
||||
.info(slot.spell_id, 0, d, anchor)
|
||||
.ui_triple(anchor, ui_now);
|
||||
// The same query's three legs, for nampower's `GetSpellIdCooldown`. Off the same `anchor`
|
||||
// as the folded triple above, so the two cannot describe different timers.
|
||||
slot.cooldown_legs = crate::ui_script::cooldown_legs_view(
|
||||
&cooldowns.legs(slot.spell_id, 0, d, anchor),
|
||||
anchor,
|
||||
ui_now,
|
||||
);
|
||||
}
|
||||
if fresh != memory.pushed {
|
||||
gate.audit("feed_spellbook", "the spellbook snapshot");
|
||||
@@ -374,6 +382,9 @@ fn build_book(
|
||||
};
|
||||
slots.push(SpellSlotView {
|
||||
spell_id,
|
||||
// Both cooldown fields are filled by the one walk below, which is where the
|
||||
// `anchor` lives; this is the slot's identity only.
|
||||
cooldown_legs: benilla_ui::script::CooldownLegsView::default(),
|
||||
name: d.map(|d| d.name.clone()).unwrap_or_default(),
|
||||
rank: d.and_then(|d| d.rank.clone()),
|
||||
texture,
|
||||
|
||||
@@ -169,6 +169,122 @@ fn equipped_table(lua: &Lua, view: &crate::script::InvSlotView) -> mlua::Result<
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// nampower's cooldown table: twenty keys, read off the DLL's own string pool in table order
|
||||
/// (`0xa88d4`), shared by `GetSpellIdCooldown` and `GetItemIdCooldown` because upstream's addon
|
||||
/// layer says so in as many words — `API.GetItemCooldownInfo` "returns same table structure as
|
||||
/// `GetSpellCooldownInfo`" (`NampowerAPI.lua:2029`).
|
||||
///
|
||||
/// Every `*RemainingMs` is derived **here**, from `GetTime()` and the leg's own absolute start,
|
||||
/// rather than pushed: a remainder is the one column that changes every frame, and pushing it
|
||||
/// would churn the spellbook diff sixty times a second for no gain (the feed's own note says the
|
||||
/// absolute start is what keeps a running cooldown out of that diff).
|
||||
///
|
||||
/// `isOnCooldown`/`cooldownRemainingMs` are the **folded** answer — the longest of the three,
|
||||
/// which is the number the reference's `GetCooldownInfo 0x6e13e0` returns and the number a
|
||||
/// cooldown swirl draws. A caller that wants to know *why* a spell is cold reads the legs.
|
||||
///
|
||||
/// `enable` is folded into the flags the other way round from the stock triple: a parked record
|
||||
/// (`enabled == false`, the grey-while-held arm) is still **on cooldown** — it has its full
|
||||
/// duration and no end — and upstream has no column for the distinction, so this reports it as on
|
||||
/// rather than hiding it.
|
||||
fn cooldown_table(
|
||||
lua: &Lua,
|
||||
legs: &crate::script::CooldownLegsView,
|
||||
now: f64,
|
||||
item: Option<(u32, Option<u32>)>,
|
||||
) -> mlua::Result<mlua::Table> {
|
||||
let t = lua.create_table()?;
|
||||
// `(remaining_ms, start_s, duration_ms, is_on)` for one leg, all four derived from the same
|
||||
// triple so they cannot disagree about whether it is running.
|
||||
let leg = |l: Option<(i64, u32, bool)>| match l {
|
||||
Some((start_ms, duration_ms, _)) => {
|
||||
let start_s = start_ms as f64 / 1000.0;
|
||||
let remaining = ((start_s + f64::from(duration_ms) / 1000.0 - now) * 1000.0).max(0.0);
|
||||
(remaining.round(), start_s, duration_ms, 1i64)
|
||||
}
|
||||
None => (0.0, 0.0, 0, 0),
|
||||
};
|
||||
let individual = leg(legs.individual);
|
||||
let category = leg(legs.category);
|
||||
let gcd = leg(legs.gcd);
|
||||
let folded = individual.0.max(category.0).max(gcd.0);
|
||||
t.set("isOnCooldown", i64::from(folded > 0.0))?;
|
||||
t.set("cooldownRemainingMs", folded)?;
|
||||
for (prefix, (remaining, start_s, duration_ms, on), id) in [
|
||||
("individual", individual, None),
|
||||
("category", category, Some(("categoryId", legs.category_id))),
|
||||
(
|
||||
"gcdCategory",
|
||||
gcd,
|
||||
Some(("gcdCategoryId", legs.gcd_category_id)),
|
||||
),
|
||||
] {
|
||||
if let Some((key, value)) = id {
|
||||
t.set(key, value)?;
|
||||
}
|
||||
t.set(format!("{prefix}StartS"), start_s)?;
|
||||
t.set(format!("{prefix}DurationMs"), duration_ms)?;
|
||||
t.set(format!("{prefix}RemainingMs"), remaining)?;
|
||||
// `isOnIndividualCooldown`, `isOnCategoryCooldown`, `isOnGcdCategoryCooldown` — the DLL's
|
||||
// own capitalisation, which is why the first letter is upper-cased here rather than the
|
||||
// prefix being spelled twice.
|
||||
let mut cap = prefix.to_string();
|
||||
cap[..1].make_ascii_uppercase();
|
||||
t.set(format!("isOn{cap}Cooldown"), on)?;
|
||||
}
|
||||
// The three v2.23+ item columns, absent entirely for a spell query — upstream adds them only
|
||||
// on the item side, and an `itemId` of 0 on a spell's table would read as a real item.
|
||||
if let Some((item_id, use_spell)) = item {
|
||||
t.set("itemId", item_id)?;
|
||||
t.set("itemHasActiveSpell", i64::from(use_spell.is_some()))?;
|
||||
t.set("itemActiveSpellId", use_spell.unwrap_or(0))?;
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// One bag slot as nampower's item row, **its own key names**, read off the DLL's string pool
|
||||
/// (`0xa8a68`, the block that opens `itemId, permanentEnchantId, tempEnchantId,
|
||||
/// randomPropertiesId, stackCount, duration, spellChargesRemaining, flags, durability,
|
||||
/// maxDurability, tempEnchantmentTimeLeftMs, tempEnchantmentCharges` and carries `bagIndex`,
|
||||
/// `slotIndex` for the bag variants). The same block [`equipped_table`] emits, which is the point:
|
||||
/// `GetEquippedItem`, `GetBagItem` and `GetBagItems` hand back one shape, as upstream's three do.
|
||||
///
|
||||
/// Three columns are **absent rather than zeroed**: `permanentEnchantId`, `tempEnchantId` and
|
||||
/// `randomPropertiesId`. This client resolves a bag slot's enchants to `SpellItemEnchantment`
|
||||
/// *names* at the app boundary ([`crate::script::EnchantView`] carries no id) and keeps no random
|
||||
/// -properties id for a container slot at all, where the equipped path keeps the raw descriptor
|
||||
/// beside the view. A nil is what a Lua table lookup gives for a key that is not there, and the
|
||||
/// consumers read `itemId` and `stackCount`; a zeroed enchant id would be a claim that the item
|
||||
/// has none.
|
||||
fn bag_slot_table(
|
||||
lua: &Lua,
|
||||
bag: i64,
|
||||
slot: u32,
|
||||
s: &crate::script::ContainerSlot,
|
||||
) -> mlua::Result<mlua::Table> {
|
||||
let t = lua.create_table()?;
|
||||
t.set("bagIndex", bag)?;
|
||||
t.set("slotIndex", slot)?;
|
||||
t.set("itemId", s.item_id)?;
|
||||
t.set("stackCount", s.count)?;
|
||||
t.set("texture", s.texture.clone())?;
|
||||
t.set("flags", s.flags)?;
|
||||
let (cur, max) = s.durability.unwrap_or((0, 0));
|
||||
t.set("durability", cur)?;
|
||||
t.set("maxDurability", max)?;
|
||||
t.set("spellChargesRemaining", s.charges)?;
|
||||
// `duration` is `ITEM_FIELD_DURATION`, which the reference and the server both count in whole
|
||||
// seconds (`SMSG_ITEM_TIME_UPDATE`); this client keeps it in ms for its tooltip countdown.
|
||||
t.set("duration", s.duration_ms.unwrap_or(0) / 1000)?;
|
||||
let temp = s.enchants.iter().find(|e| e.slot == 1);
|
||||
t.set(
|
||||
"tempEnchantmentTimeLeftMs",
|
||||
temp.and_then(|e| e.remaining_ms).unwrap_or(0),
|
||||
)?;
|
||||
t.set("tempEnchantmentCharges", temp.map_or(0, |e| e.charges))?;
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// The item cache row for an id, asking for it exactly as `GetItemInfo` does when it is a miss, so
|
||||
/// the call that follows the query's answer succeeds.
|
||||
///
|
||||
@@ -926,10 +1042,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetBagItem(bag, slot)` (v2.14+) -> that slot as a table, nil for an empty or unknown one.
|
||||
// The columns are the ones a bag slot actually carries here; `GetItemStats(itemId)` beside it
|
||||
// is the whole cache row, so this deliberately does not re-derive template fields it would
|
||||
// have to guess at.
|
||||
// `GetBagItem(bagIndex, slot)` -> that slot as a table, nil for an empty or unknown one, in
|
||||
// [`bag_slot_table`]'s key names — which are the DLL's own, not a set derived from
|
||||
// `GetContainerItemInfo`'s returns. `GetItemStats(itemId)` beside it is the whole cache row,
|
||||
// so this deliberately does not re-derive template fields it would have to guess at.
|
||||
lua.globals().set(
|
||||
"GetBagItem",
|
||||
lua.create_function(|lua, (bag, slot): (Option<i64>, Option<u32>)| {
|
||||
@@ -950,37 +1066,126 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let Some(s) = found else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let t = lua.create_table()?;
|
||||
t.set("bag", bag)?;
|
||||
t.set("slot", slot)?;
|
||||
t.set("itemId", s.item_id)?;
|
||||
t.set("count", s.count)?;
|
||||
t.set("texture", s.texture.clone())?;
|
||||
t.set("quality", s.quality)?;
|
||||
t.set("link", s.link.clone())?;
|
||||
t.set("locked", s.locked)?;
|
||||
Ok(Value::Table(t))
|
||||
Ok(Value::Table(bag_slot_table(lua, bag, slot, &s)?))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetItemIdCooldown(itemId)` (v2.14+) -> `start, duration, enable` on the `GetTime` clock,
|
||||
// `GetContainerItemCooldown`'s triple for whichever bag slot holds that item. A cold or
|
||||
// absent item answers `0, 0, 1`, which is what the stock verb answers for a slot with no
|
||||
// cooldown — the consumer does arithmetic on these, so nil would be the wrong shape.
|
||||
// `GetBagItems()` -> every occupied bag slot at once, as `{ [bagIndex] = { [slot] = row } }`.
|
||||
// DoiteAuras asks for the whole picture seven times over and walks it exactly that way —
|
||||
// `bags[b]`, then `for bslot, itemInfo in pairs(bagData)`, reading `itemInfo.itemId` and
|
||||
// `itemInfo.stackCount` (`Modules/DoiteConditions.lua:1262-1300`,
|
||||
// `DoiteAuras.lua:952`/`:996`, `Modules/DoiteEdit.lua:5951`/`:7368`). It is also why that
|
||||
// addon can answer "do I have this item" without 100 `GetContainerItemInfo` calls per
|
||||
// condition evaluation.
|
||||
//
|
||||
// **Empty slots are absent, and an empty bag with them.** A nil value in a Lua table *is*
|
||||
// absence, and every consumer here iterates with `pairs` rather than counting to
|
||||
// `GetContainerNumSlots` — so a bag of nils would make `pairs` yield nothing useful and an
|
||||
// explicit `false` would make `itemInfo.itemId` raise. A bag the player does not have is
|
||||
// absent for the same reason.
|
||||
lua.globals().set(
|
||||
"GetBagItems",
|
||||
lua.create_function(|lua, ()| {
|
||||
let out = lua.create_table()?;
|
||||
let bags: Vec<(i64, Vec<(u32, crate::script::ContainerSlot)>)> = {
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
(0i64..=4)
|
||||
.filter_map(|bag| {
|
||||
let c = model.containers.get(&bag)?;
|
||||
let mut slots: Vec<(u32, crate::script::ContainerSlot)> = c
|
||||
.slots
|
||||
.iter()
|
||||
.filter(|(_, s)| s.item_id != 0)
|
||||
.map(|(slot, s)| (*slot, s.clone()))
|
||||
.collect();
|
||||
// Ascending slot, which costs nothing and makes a `pairs` walk's order
|
||||
// reproducible for anything that logs or diffs it.
|
||||
slots.sort_unstable_by_key(|(slot, _)| *slot);
|
||||
(!slots.is_empty()).then_some((bag, slots))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (bag, slots) in bags {
|
||||
let t = lua.create_table()?;
|
||||
for (slot, s) in slots {
|
||||
t.set(slot, bag_slot_table(lua, bag, slot, &s)?)?;
|
||||
}
|
||||
out.set(bag, t)?;
|
||||
}
|
||||
Ok(out)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetSpellIdCooldown(spellId)` and `GetItemIdCooldown(itemIdOrName)` (both v2.18+) -> one
|
||||
// [`cooldown_table`], never a triple.
|
||||
//
|
||||
// **The shape was wrong here, and the addon layer is what says so.**
|
||||
// `API.GetSpellCooldownInfo` and `API.GetItemCooldownInfo` *return these calls directly*
|
||||
// (`NampowerAPI.lua:1950`, `:2035`) and their callers index the result —
|
||||
// `cd.gcdCategoryRemainingMs` is the primary path of `API.GetGCDRemainingMs`, which the
|
||||
// `[gcd]` and `[nogcd]` macro conditionals divide by 1000. A `start, duration, enable`
|
||||
// triple makes that `(0.0).gcdCategoryRemainingMs`, which raises. The twenty key names are
|
||||
// the DLL's own; see `cooldown_table`.
|
||||
//
|
||||
// A spell outside the player's book, or an item not carried, answers a **cold table** rather
|
||||
// than nil: the wrappers' own no-native fallback builds `{ isOnCooldown = 0,
|
||||
// cooldownRemainingMs = 0 }`, so a table is the shape the consumer is written for, and an
|
||||
// absent record and a lapsed one are the same answer to the question asked.
|
||||
//
|
||||
// The reach is the player's book and the player's bags, which is narrower than upstream's —
|
||||
// it reads the client's whole `SpellHistory`, where this reads the per-slot legs the
|
||||
// spellbook and bag feeds already carry. For a spell the player has not learnt that is the
|
||||
// same answer either way (no record), and the one consumer here asks about a book spell by
|
||||
// construction: `API.GetGCDRemainingMs` probes with `GetSpellName(1, BOOKTYPE_SPELL)`.
|
||||
lua.globals().set(
|
||||
"GetSpellIdCooldown",
|
||||
lua.create_function(|lua, spell_id: Option<u32>| {
|
||||
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
|
||||
let legs = {
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
spell_id
|
||||
.filter(|&id| id != 0)
|
||||
.and_then(|id| {
|
||||
model
|
||||
.spellbook
|
||||
.slots
|
||||
.iter()
|
||||
.chain(model.pet_book.slots.iter())
|
||||
.find(|s| s.spell_id == id)
|
||||
})
|
||||
.map(|s| s.cooldown_legs)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
Ok(Value::Table(cooldown_table(lua, &legs, now, None)?))
|
||||
})?,
|
||||
)?;
|
||||
lua.globals().set(
|
||||
"GetItemIdCooldown",
|
||||
lua.create_function(|lua, item_id: Option<Value>| {
|
||||
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
let cooldown = find_bag_slot(&model, item_id.as_ref())
|
||||
.and_then(|(bag, slot)| model.container_cooldowns.get(&(bag, slot)).copied())
|
||||
.filter(|&(start, duration, enabled)| start + duration > now || !enabled);
|
||||
Ok(match cooldown {
|
||||
Some((start, duration, enabled)) => (start, duration, i32::from(enabled)),
|
||||
None => (0.0, 0.0, 1),
|
||||
})
|
||||
let found = {
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
find_bag_slot(&model, item_id.as_ref()).and_then(|(bag, slot)| {
|
||||
model
|
||||
.containers
|
||||
.get(&bag)
|
||||
.and_then(|c| c.slots.get(&slot))
|
||||
.map(|s| (s.cooldown_legs, s.item_id, s.use_spell_id))
|
||||
})
|
||||
};
|
||||
let (legs, item, use_spell) = found.unwrap_or_default();
|
||||
Ok(Value::Table(cooldown_table(
|
||||
lua,
|
||||
&legs,
|
||||
now,
|
||||
Some((item, (use_spell != 0).then_some(use_spell))),
|
||||
)?))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -1035,6 +1240,54 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetSpellModifiers(spellId, modifierType)` -> `flat, percent, hasModifier`. The DLL's own
|
||||
// usage string enumerates all 26 modifier types it accepts — `0=DAMAGE, 1=DURATION,
|
||||
// 2=THREAT, 3=ATTACK_POWER, 4=CHARGES, 5=RANGE, 6=RADIUS, 7=CRITICAL_CHANCE, 8=ALL_EFFECTS,
|
||||
// 9=NOT_LOSE_CASTING_TIME, 10=CASTING_TIME, 11=COOLDOWN, 12=SPEED, 14=COST,
|
||||
// 15=CRIT_DAMAGE_BONUS, 16=RESIST_MISS_CHANCE, 17=JUMP_TARGETS, 18=CHANCE_OF_SUCCESS,
|
||||
// 19=ACTIVATION_TIME, 20=EFFECT_PAST_FIRST, 21=CASTING_TIME_OLD, 22=DOT, 23=HASTE,
|
||||
// 24=SPELL_BONUS_DAMAGE, 27=MULTIPLE_VALUE, 28=RESIST_DISPEL_CHANCE` — which is the same
|
||||
// `SpellModOp` space `benilla-app`'s `spell/mods.rs` stores all 29 of, and the same numbers
|
||||
// `SuperCleveRoidMacros` mirrors as `API.MODIFIER_*` constants (`NampowerAPI.lua:2491-2517`).
|
||||
//
|
||||
// `percent` is the **final multiplier**, not a delta: the consumer's own comment says so
|
||||
// ("Nampower returns percent as final multiplier (90 = 90% of original, not -10% change)",
|
||||
// `Utility.lua:6932`) and it multiplies by `percent / 100`. That is exactly the number the
|
||||
// reference's reader produces, which adds the `+100` once at `0x6e6bb4`.
|
||||
//
|
||||
// **`hasModifier` is the whole point of the third return.** `0, 0, 0` and "a modifier whose
|
||||
// sums happen to be zero" are different answers, and the consumer tests the flag before
|
||||
// touching either number (`if not hasModifier or (flat == 0 and percent == 0) then return
|
||||
// nil end`). Answering `0, 100, 0` for an unmodified spell would be the trap `spell/mods.rs`
|
||||
// names at its own reader: a bare pair zeroes — or here, *multiplies by zero* — every spell
|
||||
// no talent touches.
|
||||
//
|
||||
// 1.12 has no way to ask: the tables are written by `SMSG_SET_FLAT_SPELL_MODIFIER` and read
|
||||
// only inside the client's own cost and duration arithmetic, with no binding at all. The fold
|
||||
// runs in `spell/mods.rs`'s one reader through `Model::spell_modifier_probe`, so this cannot
|
||||
// disagree with the mana cost the action bar greys a button on.
|
||||
lua.globals().set(
|
||||
"GetSpellModifiers",
|
||||
lua.create_function(|lua, (spell_id, op): (Option<u32>, Option<u32>)| {
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
let found = spell_id
|
||||
.filter(|&id| id != 0)
|
||||
.zip(op.and_then(|o| u8::try_from(o).ok()))
|
||||
.and_then(|(id, op)| {
|
||||
model
|
||||
.spell_modifier_probe
|
||||
.as_ref()
|
||||
.and_then(|probe| probe(id, op))
|
||||
});
|
||||
Ok(match found {
|
||||
Some((flat, pct)) => (flat, pct, 1),
|
||||
None => (0, 0, 0),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ── nampower's own cast state: `GetCurrentCastingInfo` and `GetCastInfo` ──────────────────
|
||||
//
|
||||
// The two most-called nampower functions in grom's corpus, and the pair every other cast
|
||||
@@ -1359,32 +1612,97 @@ mod tests {
|
||||
"not carried"
|
||||
);
|
||||
|
||||
// `GetBagItem` reports the slot it was asked about, not the found one.
|
||||
// `GetBagItem` reports the slot it was asked about, not the found one, under the DLL's
|
||||
// own key names — `stackCount` and `bagIndex`/`slotIndex`, never `count`/`bag`/`slot`.
|
||||
// Asserted by name because that is the whole contract: a caller reading `.stackCount`
|
||||
// off a row that spells it `count` gets nil and no error.
|
||||
assert_eq!(
|
||||
s.eval::<u32>(r#"return GetBagItem(2, 1).count"#).unwrap(),
|
||||
20
|
||||
s.eval::<(u32, i64, u32)>(
|
||||
r#"local r = GetBagItem(2, 1) return r.stackCount, r.bagIndex, r.slotIndex"#
|
||||
)
|
||||
.unwrap(),
|
||||
(20, 2, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<u32>(r#"return GetBagItem(0, 5).itemId"#).unwrap(),
|
||||
1234
|
||||
);
|
||||
assert!(
|
||||
s.eval::<Option<u32>>(r#"return GetBagItem(2, 1).count"#)
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"`count` is this client's own `GetContainerItemInfo` spelling, not nampower's — a \
|
||||
row carrying keys the real DLL has not got would let an addon written here break on \
|
||||
a real install"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<Option<mlua::Value>>("return GetBagItem(0, 7)")
|
||||
.unwrap()
|
||||
.is_none_or(|v| v.is_nil()));
|
||||
|
||||
// A cold item answers the stock triple, not nil: the consumer does arithmetic on these.
|
||||
// `GetBagItems()` is the same rows in one walk, keyed bag then slot, with empty slots and
|
||||
// empty bags **absent** — DoiteAuras iterates both levels with `pairs`.
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i64)>("return GetItemIdCooldown(1234)")
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 1)
|
||||
s.eval::<(u32, u32, u32)>(
|
||||
"local b = GetBagItems() \
|
||||
return b[0][5].itemId, b[0][9].itemId, b[2][1].stackCount"
|
||||
)
|
||||
.unwrap(),
|
||||
(1234, 555, 20)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i64)>("return GetItemIdCooldown(4242)")
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 1),
|
||||
"not carried reads cold, as an absent slot does"
|
||||
s.eval::<Vec<i64>>(
|
||||
"local out = {} for bag in pairs(GetBagItems()) do table.insert(out, bag) end \
|
||||
table.sort(out) return out"
|
||||
)
|
||||
.unwrap(),
|
||||
vec![0, 2],
|
||||
"bags 1, 3 and 4 are empty, so they are not there at all"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("local n = 0 for _ in pairs(GetBagItems()[0]) do n = n + 1 end return n")
|
||||
.unwrap(),
|
||||
2,
|
||||
"two occupied slots in the backpack, and nothing for the other fourteen"
|
||||
);
|
||||
// DoiteAuras' own walk (`DoiteConditions.lua:1266-1300`), looking for item 555 by id.
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, i64)>(
|
||||
r#"
|
||||
local bags, found, fb, fs = GetBagItems(), 0, -1, -1
|
||||
local b = 0
|
||||
while b <= 4 do
|
||||
local bagData = bags[b]
|
||||
if bagData then
|
||||
for bslot, itemInfo in pairs(bagData) do
|
||||
if itemInfo and itemInfo.itemId == 555 then
|
||||
found = found + (tonumber(itemInfo.stackCount) or 1)
|
||||
if fb < 0 then fb, fs = b, bslot end
|
||||
end
|
||||
end
|
||||
end
|
||||
b = b + 1
|
||||
end
|
||||
return found, fb, fs
|
||||
"#
|
||||
)
|
||||
.unwrap(),
|
||||
(1, 0, 9)
|
||||
);
|
||||
|
||||
// A cold item answers a cold **table**, not nil and not a triple: the wrappers return
|
||||
// this call's result directly and their callers index it.
|
||||
for call in ["GetItemIdCooldown(1234)", "GetItemIdCooldown(4242)"] {
|
||||
assert_eq!(
|
||||
s.eval::<(i64, f64, f64)>(&format!(
|
||||
"local c = {call} \
|
||||
return c.isOnCooldown, c.cooldownRemainingMs, c.gcdCategoryRemainingMs"
|
||||
))
|
||||
.unwrap(),
|
||||
(0, 0.0, 0.0),
|
||||
"{call} — not carried reads cold, as an absent slot does"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `GetItemIconTexture(displayInfoId)` reads an `ItemDisplayInfo` row id, **not** an item id:
|
||||
@@ -1431,6 +1749,244 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `GetSpellIdCooldown` and `GetItemIdCooldown` answer a **table**, and the twenty keys are
|
||||
/// asserted by name through `SuperCleveRoidMacros`' own wrappers — which return these calls'
|
||||
/// results *directly* (`NampowerAPI.lua:1950`, `:2035`) and whose callers then index them.
|
||||
///
|
||||
/// This is the round-trip that was broken: a `start, duration, enable` triple made
|
||||
/// `API.GetGCDRemainingMs`'s primary path `(0.0).gcdCategoryRemainingMs`, which raises.
|
||||
#[test]
|
||||
fn the_cooldown_tables_carry_three_legs_and_a_folded_answer() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_now(100.0);
|
||||
// `NampowerAPI.lua:1898-1921`'s GCD probe, reduced to its native path.
|
||||
s.run(
|
||||
r#"
|
||||
function GetGCDRemainingMs()
|
||||
local cd = GetSpellIdCooldown(_gcdProbeSpellId)
|
||||
if cd and cd.gcdCategoryRemainingMs and cd.gcdCategoryRemainingMs > 0 then
|
||||
return cd.gcdCategoryRemainingMs
|
||||
end
|
||||
return 0
|
||||
end
|
||||
_gcdProbeSpellId = 133
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Nothing fed: a cold table, so the probe reads 0 rather than raising.
|
||||
assert_eq!(s.eval::<f64>("return GetGCDRemainingMs()").unwrap(), 0.0);
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, i64, i64)>(
|
||||
"local c = GetSpellIdCooldown(133) \
|
||||
return c.isOnCooldown, c.isOnIndividualCooldown, c.isOnCategoryCooldown, \
|
||||
c.isOnGcdCategoryCooldown"
|
||||
)
|
||||
.unwrap(),
|
||||
(0, 0, 0, 0)
|
||||
);
|
||||
|
||||
// Fireball: no cooldown of its own, the ordinary 1.5 s GCD under category 133, armed at
|
||||
// t=99.4 so 900 ms of it is left at t=100.
|
||||
s.set_spellbook(crate::script::SpellBookState {
|
||||
slots: vec![crate::script::SpellSlotView {
|
||||
spell_id: 133,
|
||||
name: "Fireball".into(),
|
||||
cooldown: Some((99_400, 1_500, true)),
|
||||
cooldown_legs: crate::script::CooldownLegsView {
|
||||
individual: None,
|
||||
category_id: 0,
|
||||
category: None,
|
||||
gcd_category_id: 133,
|
||||
gcd: Some((99_400, 1_500, true)),
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return GetGCDRemainingMs()").unwrap(),
|
||||
900.0,
|
||||
"the one live consumer's primary path, which it then divides by 1000"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(i64, f64, i64, f64, f64, i64)>(
|
||||
"local c = GetSpellIdCooldown(133) \
|
||||
return c.isOnCooldown, c.cooldownRemainingMs, c.gcdCategoryId, \
|
||||
c.gcdCategoryStartS, c.gcdCategoryDurationMs, c.isOnGcdCategoryCooldown"
|
||||
)
|
||||
.unwrap(),
|
||||
(1, 900.0, 133, 99.4, 1_500.0, 1),
|
||||
"the folded answer is the longest leg; the GCD leg carries its own category id, a \
|
||||
start in GetTime SECONDS and a duration in MILLISECONDS, which is the mixed-unit \
|
||||
table upstream really hands back"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(i64, f64)>(
|
||||
"local c = GetSpellIdCooldown(133) \
|
||||
return c.isOnIndividualCooldown, c.individualRemainingMs"
|
||||
)
|
||||
.unwrap(),
|
||||
(0, 0.0),
|
||||
"Fireball has no cooldown of its own — the leg is cold, not a copy of the folded one"
|
||||
);
|
||||
// A spell query carries none of the three item columns: an itemId of 0 on a spell's
|
||||
// table would read as a real item.
|
||||
assert!(s
|
||||
.eval::<Option<i64>>("return GetSpellIdCooldown(133).itemId")
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
// Past the end, the remainders floor at 0 and the flags clear, off `GetTime()` alone —
|
||||
// the feed pushes absolute starts and nothing per frame.
|
||||
s.set_now(101.5);
|
||||
assert_eq!(
|
||||
s.eval::<(i64, f64)>(
|
||||
"local c = GetSpellIdCooldown(133) \
|
||||
return c.isOnCooldown, c.gcdCategoryRemainingMs"
|
||||
)
|
||||
.unwrap(),
|
||||
(0, 0.0)
|
||||
);
|
||||
|
||||
// An item: the same table plus the three v2.23+ columns.
|
||||
s.set_now(100.0);
|
||||
s.set_container(
|
||||
0,
|
||||
Some(crate::script::ContainerState {
|
||||
name: None,
|
||||
num_slots: 16,
|
||||
slots: [(
|
||||
1u32,
|
||||
crate::script::ContainerSlot {
|
||||
item_id: 13_444,
|
||||
count: 1,
|
||||
use_spell_id: 11_392,
|
||||
cooldown_legs: crate::script::CooldownLegsView {
|
||||
individual: Some((98_000, 120_000, true)),
|
||||
category_id: 4,
|
||||
category: Some((98_000, 30_000, true)),
|
||||
gcd_category_id: 0,
|
||||
gcd: None,
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(i64, f64, f64, f64, i64, i64, i64)>(
|
||||
"local c = GetItemIdCooldown(13444) \
|
||||
return c.isOnCooldown, c.cooldownRemainingMs, c.individualRemainingMs, \
|
||||
c.categoryRemainingMs, c.categoryId, c.itemId, c.itemActiveSpellId"
|
||||
)
|
||||
.unwrap(),
|
||||
(1, 118_000.0, 118_000.0, 28_000.0, 4, 13_444, 11_392),
|
||||
"the two legs are reported apart and the folded answer is the longer"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetItemIdCooldown(13444).itemHasActiveSpell")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
// By name as well as by id, through the same `find_bag_slot` every verb here shares.
|
||||
s.set_item_template(
|
||||
13_444,
|
||||
crate::script::ItemTemplateView {
|
||||
name: "Major Fire Protection Potion".into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>(
|
||||
r#"return GetItemIdCooldown("major fire protection potion").individualRemainingMs"#
|
||||
)
|
||||
.unwrap(),
|
||||
118_000.0
|
||||
);
|
||||
}
|
||||
|
||||
/// `GetSpellModifiers(spellId, modifierType)` through `SuperCleveRoidMacros`' own wrapper,
|
||||
/// which is the only reader of it here. The third return is the point: the wrapper tests it
|
||||
/// before touching either number, and the duration path multiplies by `percent / 100`, so an
|
||||
/// unmodified spell answering `0, 0, 1` would make every debuff's duration zero.
|
||||
#[test]
|
||||
fn get_spell_modifiers_answers_a_pair_and_a_has_modifier_flag() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
// `NampowerAPI.lua:2520-2532` and `Utility.lua:6913-6946`, transcribed.
|
||||
s.run(
|
||||
r#"
|
||||
function GetModifiers(spellId, modifierType)
|
||||
if not GetSpellModifiers then return 0, 0, false end
|
||||
if not spellId or spellId == 0 then return 0, 0, false end
|
||||
local flat, percent, ret = GetSpellModifiers(spellId, modifierType)
|
||||
return flat or 0, percent or 0, ret and ret ~= 0
|
||||
end
|
||||
function ModifiedDuration(spellId, baseDuration)
|
||||
local flat, percent, hasModifier = GetModifiers(spellId, 1)
|
||||
if not hasModifier or (flat == 0 and percent == 0) then return nil end
|
||||
local modified = baseDuration + (flat or 0)
|
||||
if percent and percent ~= 0 then modified = modified * (percent / 100) end
|
||||
return modified
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// No probe at all: the honest nothing, which the wrapper turns into "leave the duration
|
||||
// alone" rather than into a zero.
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, i64)>("return GetSpellModifiers(1120, 1)")
|
||||
.unwrap(),
|
||||
(0, 0, 0)
|
||||
);
|
||||
assert!(s
|
||||
.eval::<Option<f64>>("return ModifiedDuration(1120, 18)")
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
// Improved Corruption-shaped: Drain Soul's duration unchanged, Corruption's cast time
|
||||
// cut. Only (1120, op 1) and (172, op 10) answer, so the gate is visible.
|
||||
s.set_spell_modifier_probe(Box::new(|spell_id, op| match (spell_id, op) {
|
||||
(1120, 1) => Some((6, 100)),
|
||||
(172, 10) => Some((0, 60)),
|
||||
_ => None,
|
||||
}));
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, i64)>("return GetSpellModifiers(1120, 1)")
|
||||
.unwrap(),
|
||||
(6, 100, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return ModifiedDuration(1120, 18)").unwrap(),
|
||||
24.0,
|
||||
"flat first, then the percentage — and 100 is the identity multiplier, not a doubling"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, i64)>("return GetSpellModifiers(172, 10)")
|
||||
.unwrap(),
|
||||
(0, 60, 1),
|
||||
"percent is the final multiplier: 60 means 60% of the original"
|
||||
);
|
||||
// A spell the probe has nothing for, and an op it has nothing for, both read as no
|
||||
// modifier rather than as a zeroing one.
|
||||
for call in [
|
||||
"GetSpellModifiers(172, 1)",
|
||||
"GetSpellModifiers(1120, 10)",
|
||||
"GetSpellModifiers(0, 1)",
|
||||
"GetSpellModifiers()",
|
||||
] {
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, i64)>(&format!("return {call}"))
|
||||
.unwrap(),
|
||||
(0, 0, 0),
|
||||
"{call}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// nampower's three posture reads and `IsAuraHidden`, in the shapes its own doc lines give:
|
||||
/// `1` or **nil**, never a boolean. `SuperCleveRoidMacros`'s `NampowerAPI.lua` gates each on
|
||||
/// `HasFeature`, which checks the version *and* `_G[name] ~= nil`, so a wrong return shape
|
||||
|
||||
@@ -43,6 +43,14 @@ pub struct ContainerSlot {
|
||||
pub durability: Option<(u32, u32)>,
|
||||
/// The use cooldown, `(start_ms on the GetTime clock, duration_ms, enabled)`; `None` if cold.
|
||||
pub cooldown: Option<(i64, u32, bool)>,
|
||||
/// The same query's three legs kept apart, for nampower's `GetItemIdCooldown`. Fed beside
|
||||
/// [`Self::cooldown`] from the one walk.
|
||||
pub cooldown_legs: super::CooldownLegsView,
|
||||
/// The template's on-use spell id, 0 for an item with none: `GetItemIdCooldown`'s
|
||||
/// `itemActiveSpellId`, and the spell the cooldown legs above were keyed on — which is why it
|
||||
/// rides here rather than being looked up again. Not the whole spell block: the other two or
|
||||
/// three are equip and proc triggers, which no cooldown is queried against.
|
||||
pub use_spell_id: u32,
|
||||
/// The instance carries `ITEM_FIELD_ITEM_TEXT_ID` (a mail copy): `GetContainerItemInfo`'s
|
||||
/// `readable`, which shows the Inspect cursor (`ContainerFrame.lua:638`).
|
||||
pub readable: bool,
|
||||
@@ -736,6 +744,8 @@ mod tests {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
duration_ms: None,
|
||||
charges: 0,
|
||||
petition: None,
|
||||
@@ -979,6 +989,8 @@ mod tests {
|
||||
state.slots.insert(
|
||||
5,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
duration_ms: None,
|
||||
charges: 0,
|
||||
petition: None,
|
||||
|
||||
@@ -572,6 +572,8 @@ mod tests {
|
||||
slots.insert(
|
||||
1,
|
||||
crate::script::container::ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
duration_ms: None,
|
||||
charges: 0,
|
||||
petition: None,
|
||||
|
||||
@@ -313,6 +313,8 @@ mod tests {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
duration_ms: None,
|
||||
charges: 0,
|
||||
petition: None,
|
||||
@@ -497,6 +499,8 @@ mod tests {
|
||||
slots.insert(
|
||||
3,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
duration_ms: None,
|
||||
charges: 0,
|
||||
petition: None,
|
||||
|
||||
@@ -246,7 +246,7 @@ pub use model::{
|
||||
SpellProbe, SpellUsable, TextureProbe, TextureSizeProbe,
|
||||
};
|
||||
pub(crate) use model::{Model, PendingTimer};
|
||||
pub use model::{PlayerCacheEntry, SelfCast, SelfMovement, ServerClock};
|
||||
pub use model::{PlayerCacheEntry, SelfCast, SelfMovement, ServerClock, SpellModifierProbe};
|
||||
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
|
||||
pub use pet::{PetActionView, PetStats};
|
||||
pub use pvp::{HonorState, InspectHonorData};
|
||||
@@ -266,7 +266,8 @@ pub use skills::{SkillEntry, SkillsState};
|
||||
pub use social::{FriendInfo, SocialRequest, SocialState, WhoInfo};
|
||||
pub use sound::{MusicRequest, SoundRequest};
|
||||
pub use spellbook::{
|
||||
resolve_spell_by_name, PetBookState, SpellBookState, SpellSlotView, SpellTabView,
|
||||
resolve_spell_by_name, CooldownLegsView, PetBookState, SpellBookState, SpellSlotView,
|
||||
SpellTabView,
|
||||
};
|
||||
pub use stable::{StableIntent, StablePetSlot, StableState, NUM_STABLE_SLOTS};
|
||||
pub use summon::SummonConfirmUiState;
|
||||
|
||||
@@ -71,6 +71,19 @@ pub type SpellProbe = Box<dyn Fn(u32) -> Option<SpellLookup>>;
|
||||
/// through (`benilla-app`'s `cursor.rs`). `None` where the row or its icon is absent.
|
||||
pub type ItemIconProbe = Box<dyn Fn(u32) -> Option<String>>;
|
||||
|
||||
/// nampower's `GetSpellModifiers(spellId, modifierType)`: the `(flat, percent)` pair the client's
|
||||
/// own `GetSpellModifiers 0x6e6b30` folds out of its two talent-modifier tables, or `None` for
|
||||
/// any of that reader's four false exits (the spell has no family, it is not the player's class
|
||||
/// family, it carries `AttributesEx3` bit 29, or both sums came out zero).
|
||||
///
|
||||
/// **A probe, not a pushed snapshot, and reinstalled when the tables change.** The arithmetic has
|
||||
/// four gates and a 64-bit fold, and it is already written once in `benilla-app`'s
|
||||
/// `spell/mods.rs` — the only reader of the two `i32[64][29]` tables the wire fills. Pushing the
|
||||
/// tables here and folding them again would be a second implementation of that reader, free to
|
||||
/// drift from the one the power cost is computed with. So the closure calls the real one, and
|
||||
/// `ui_script/lifecycle.rs` reinstalls it whenever the wire writes a cell.
|
||||
pub type SpellModifierProbe = Box<dyn Fn(u32, u8) -> Option<(i32, i32)>>;
|
||||
|
||||
/// A spell id to its `Spell.dbc` `Mechanic` column and that mechanic's enUS name from
|
||||
/// `SpellMechanic.dbc`, for ClassicAPI's `C_Spell.GetSpellMechanicByID`.
|
||||
///
|
||||
@@ -785,6 +798,7 @@ pub(crate) struct Model {
|
||||
/// `SpellIcon.dbc` lookup ([`SpellIconProbe`]).
|
||||
pub(crate) spell_icon_probe: Option<SpellIconProbe>,
|
||||
pub(crate) item_icon_probe: Option<ItemIconProbe>,
|
||||
pub(crate) spell_modifier_probe: Option<SpellModifierProbe>,
|
||||
/// Creature display id to model path ([`CreatureModelProbe`]).
|
||||
pub(crate) creature_model_probe: Option<CreatureModelProbe>,
|
||||
/// The pet's spellbook, a second array as in the reference (`0xb700f0`, `0xb6f098`).
|
||||
@@ -1686,6 +1700,7 @@ impl Model {
|
||||
texture_probe: None,
|
||||
spell_probe: None,
|
||||
item_icon_probe: None,
|
||||
spell_modifier_probe: None,
|
||||
spell_mechanic_probe: None,
|
||||
player_cache: HashMap::new(),
|
||||
player_cache_enabled: [true, true],
|
||||
|
||||
@@ -33,6 +33,27 @@ pub struct SpellTabView {
|
||||
pub num_spells: u32,
|
||||
}
|
||||
|
||||
/// One cooldown query's three legs, each on the `GetTime()` clock as
|
||||
/// `(start_ms, duration_ms, enabled)` and `None` when that leg holds no record — nampower's
|
||||
/// `GetSpellIdCooldown`/`GetItemIdCooldown` report an `isOn…` flag per leg, and "no record" and
|
||||
/// "a record with nothing left" are different answers to it.
|
||||
///
|
||||
/// The arithmetic is `benilla-app`'s `spell/cooldowns.rs`, which folds the same three into the one
|
||||
/// number a cooldown swirl draws; this carries them apart. Starts and durations are absolute, so
|
||||
/// a running cooldown never churns the diff of whatever snapshot holds this, and a remainder is
|
||||
/// derived in the binding from `GetTime()` rather than pushed.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CooldownLegsView {
|
||||
/// The spell's (or item's) own recovery.
|
||||
pub individual: Option<(i64, u32, bool)>,
|
||||
/// `Spell.dbc` `Category`, the id the category leg was keyed on; 0 for none.
|
||||
pub category_id: u32,
|
||||
pub category: Option<(i64, u32, bool)>,
|
||||
/// `StartRecoveryCategory`, the id the GCD leg was keyed on.
|
||||
pub gcd_category_id: u32,
|
||||
pub gcd: Option<(i64, u32, bool)>,
|
||||
}
|
||||
|
||||
/// One spell in the flat book, every field resolved by the app.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SpellSlotView {
|
||||
@@ -51,6 +72,10 @@ pub struct SpellSlotView {
|
||||
/// `(start_ms on the GetTime clock, duration_ms, enabled)`, the triple the action bar carries;
|
||||
/// `None` is cold. The start is absolute, so a running cooldown never churns the book diff.
|
||||
pub cooldown: Option<(i64, u32, bool)>,
|
||||
/// The same query's three legs kept apart, for nampower's `GetSpellIdCooldown`. Fed beside
|
||||
/// [`Self::cooldown`] from the one walk, so the folded number a swirl draws and the legs an
|
||||
/// addon reads cannot describe different timers.
|
||||
pub cooldown_legs: CooldownLegsView,
|
||||
/// `GetSpellAutocast`'s `(allowed, enabled)`, pet book only, read off the pet's raw word
|
||||
/// (`0x4bd160`, bits 31 and 30).
|
||||
pub autocast: Option<(bool, bool)>,
|
||||
@@ -99,6 +124,13 @@ impl super::UiScript {
|
||||
self.model_mut().spell_icon_probe = Some(probe);
|
||||
}
|
||||
|
||||
/// Install (or replace) nampower's `GetSpellModifiers` fold. Replaced rather than set once:
|
||||
/// the tables behind it are live wire state, so `ui_script/lifecycle.rs` calls this again
|
||||
/// whenever a `SMSG_SET_*_SPELL_MODIFIER` lands.
|
||||
pub fn set_spell_modifier_probe(&mut self, probe: super::SpellModifierProbe) {
|
||||
self.model_mut().spell_modifier_probe = Some(probe);
|
||||
}
|
||||
|
||||
/// Replace the live per-spell usability map ([`super::SpellUsable`]), nampower's
|
||||
/// `IsSpellUsable`/`IsSpellInRange` source. Pushed by the action feed, which already holds
|
||||
/// every input the reference's own usable walk reads.
|
||||
|
||||
@@ -338,7 +338,10 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
|
||||
"UseItemIdOrName",
|
||||
"FindPlayerItemSlot",
|
||||
"GetBagItem",
|
||||
"GetBagItems",
|
||||
"GetSpellModifiers",
|
||||
"GetItemIdCooldown",
|
||||
"GetSpellIdCooldown",
|
||||
"GetItemIconTexture",
|
||||
"GetTrinkets",
|
||||
"PlayerIsMoving",
|
||||
|
||||
@@ -64,6 +64,8 @@ fn merchant_compare_renders_the_compare_shape_and_no_hover_seats_a_plate() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
duration_ms: None,
|
||||
charges: 0,
|
||||
petition: None,
|
||||
|
||||
+51
-3
@@ -578,7 +578,6 @@ because it is in the same family — it would flush our own `WoWCombatLog.txt`,
|
||||
| Name | What is missing |
|
||||
|---|---|
|
||||
| `GetSpellRangeData(rangeIndex)` | A `SpellRange.dbc` **row index**, per the DLL's own usage string — not a spell id, which is how this row read until the usage strings were compared against it. `IsSpellInRange` answers the *verdict* from the live usable map, never the yards, so the table is genuinely absent. One more probe beside `install_spell_extras_probes` |
|
||||
| `GetSpellModifiers(spellId)` | `spell/mods.rs` stores the server's `SPELLMOD_*` cells but publishes no reader |
|
||||
| `GetUnitData(unit)` | The whole descriptor as a table, where `GetUnitField` answers one field. Needs a decision about which fields: the real one returns GUID-typed columns (charm, summon, charmedBy) this client's field reader does not type |
|
||||
| `GetTrinketCooldown()` | Equipped-slot cooldowns. `container_cooldowns` is keyed `(bag, slot)`; the equipped slots are not in it |
|
||||
|
||||
@@ -678,6 +677,55 @@ variable, and `reference_ui.rs`'s beyond-1.12 event gate reads the string litera
|
||||
it had been blind to that pair for as long as they had existed. Both fire sites are literals now,
|
||||
with their rows; verified by probe (renaming either row makes the gate name it).
|
||||
|
||||
### The bag, modifier and cooldown group — and two more shapes the string pool corrected
|
||||
|
||||
**Built: `GetBagItems`, `GetSpellModifiers`, `GetSpellIdCooldown`.** Plus two fixes to verbs that
|
||||
already existed and had the wrong *shape*, both found the same way as `GetItemIconTexture`'s wrong
|
||||
argument — by comparing what we answer against the DLL's own string pool.
|
||||
|
||||
| Verb | What it needed |
|
||||
|---|---|
|
||||
| `GetBagItems()` | every occupied bag slot as `{ [bagIndex] = { [slot] = row } }`. DoiteAuras asks seven times over and walks both levels with `pairs`, reading `itemInfo.itemId` and `itemInfo.stackCount` — it is how that addon answers "do I have this item" without 100 `GetContainerItemInfo` calls per condition evaluation. Empty slots and empty bags are **absent**, because a nil value in a Lua table *is* absence and `pairs` is how both levels are read |
|
||||
| `GetSpellModifiers(spellId, modifierType)` | `flat, percent, hasModifier`, where `percent` is the **final multiplier** (90 means 90% of the original) — the consumer says so in its own words and multiplies by `percent / 100`. The 26 modifier types come from the DLL's usage string. The fold is `spell/mods.rs`' existing reader, reached through a new `Model::spell_modifier_probe` the lifecycle **reinstalls** whenever the wire moves the tables: a VM-side reimplementation of a four-gate 64-bit fold would be free to drift from the mana cost the action bar greys a button on |
|
||||
| `GetSpellIdCooldown(spellId)` | the twenty-key cooldown table, from a new `Cooldowns::legs` — `info`'s three legs kept apart, same walk and same arms, so the folded number a swirl draws and the legs an addon reads cannot describe different timers. Fed beside the folded triple by the spellbook, the pet book and the bag feeds |
|
||||
|
||||
**`GetBagItem`'s row had the wrong key names.** It answered `bag`, `slot`, `count`, `texture`,
|
||||
`quality`, `link`, `locked` — this client's own `GetContainerItemInfo` spellings. nampower's row is
|
||||
`itemId, permanentEnchantId, tempEnchantId, randomPropertiesId, stackCount, duration,
|
||||
spellChargesRemaining, flags, durability, maxDurability, tempEnchantmentTimeLeftMs,
|
||||
tempEnchantmentCharges`, plus `bagIndex`/`slotIndex` — contiguous at `0xa8a68`, and the same block
|
||||
`GetEquippedItem` already used. So a caller reading `.stackCount` got nil and no error. Now one
|
||||
`bag_slot_table` serves `GetBagItem` and `GetBagItems`, with three columns **absent rather than
|
||||
zeroed**: this client resolves a bag slot's enchants to `SpellItemEnchantment` *names* at the app
|
||||
boundary and keeps no random-properties id for a container slot, so `permanentEnchantId`,
|
||||
`tempEnchantId` and `randomPropertiesId` are not there. A zeroed enchant id would be a claim.
|
||||
|
||||
**`GetItemIdCooldown` returned the wrong *kind* of value.** It answered `start, duration, enable` —
|
||||
the stock triple. At the version we report it is a table, and the addon layer is what settles it:
|
||||
`API.GetItemCooldownInfo` and `API.GetSpellCooldownInfo` **return these calls' results directly**
|
||||
(`NampowerAPI.lua:1950`, `:2035`) and their callers index them. `cd.gcdCategoryRemainingMs` is the
|
||||
primary path of `API.GetGCDRemainingMs`, which the `[gcd]` and `[nogcd]` macro conditionals divide
|
||||
by 1000 — against a triple that is `(0.0).gcdCategoryRemainingMs`, which raises. Nothing in the
|
||||
corpus calls those two wrappers today, so the bug was latent rather than live; it was still wrong.
|
||||
|
||||
Two decisions written down at the code:
|
||||
|
||||
- **Every `*RemainingMs` is derived in the binding, from `GetTime()` and the leg's absolute
|
||||
start.** A remainder is the one column that changes every frame, and pushing it would churn the
|
||||
spellbook diff sixty times a second — the feed's own note says the absolute start is what keeps a
|
||||
running cooldown out of that diff.
|
||||
- **The reach is the player's book, pet book and bags, which is narrower than upstream's.** It
|
||||
reads the client's whole `SpellHistory`; this reads the per-slot legs those feeds already carry.
|
||||
For a spell the player has not learnt the answer is the same either way, and the one live
|
||||
consumer asks about a book spell by construction (`API.GetGCDRemainingMs` probes with
|
||||
`GetSpellName(1, BOOKTYPE_SPELL)`).
|
||||
|
||||
**One count the earlier sweep got wrong, worth correcting here**: `GetSpellPower`'s "7 call sites"
|
||||
are `BetterCharacterStats`' own method `BCS:GetSpellPower(school)`, not nampower's global. Nothing
|
||||
in the corpus calls the global at all — the only mention is a comment in
|
||||
`SuperCleveRoidMacros/ClassicAPI.lua:272` saying it reads the same client field. A bare name match
|
||||
is not a call site.
|
||||
|
||||
### The whole registered list, accounted for
|
||||
|
||||
With the registration table read (the section above), the surface is no longer a count of what a
|
||||
@@ -694,10 +742,10 @@ comm -23 /tmp/np /tmp/ours
|
||||
|
||||
| Bucket | Count | Names |
|
||||
|---|---|---|
|
||||
| **Built** | 34 | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByName`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `FindPlayerItemSlot`, `GetBagItem`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdForName`, `GetSpellNameAndRankForId`, `GetSpellRecField`, `GetSpellSlotTypeIdForName`, `GetTrinkets`, `GetUnitField`, `GetUnitGUID`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `SetMouseoverUnit`, `UseItemIdOrName` |
|
||||
| **Built** | 37 | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByName`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `FindPlayerItemSlot`, `GetBagItem`, `GetBagItems`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdCooldown`, `GetSpellIdForName`, `GetSpellModifiers`, `GetSpellNameAndRankForId`, `GetSpellRecField`, `GetSpellSlotTypeIdForName`, `GetTrinkets`, `GetUnitField`, `GetUnitGUID`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `SetMouseoverUnit`, `UseItemIdOrName` |
|
||||
| **Withheld pending grom's say-so** | 8 | `WriteCustomFile`, `ReadCustomFile`, `CustomFileExists`, `ImportFile`, `ExportFile`, `ExecuteCustomLuaFile`, `StartItemExport` (writes a file of its own), `EncryptPassword` + `EncryptedServerLogin` |
|
||||
| **Not ours to add** | 4 | `QueueSpellByName`, `QueueScript`, `ChannelStopCastingNextTick`, `LearnTalentRank` |
|
||||
| **To build, in corpus-call order** | 16 | `GetSpellPower` (7 call sites outside the API layer), `GetBagItems` (7), `GetSpellModifiers` (6), `DisenchantAll` (5), `GetSpellIdCooldown` (3), then `GetSpellRec`, `GetTrinketCooldown`, `UseTrinket`, `GetUnitData`, `GetSpellRangeData`, `CombatLogFlush`, `GetRaidTargets`, `SetLocalRaidTargetIndex`, `GetQuestLogQuestIds`, `GetQuestDialogQuestId` (0 each — present for completeness, not need) |
|
||||
| **To build, in corpus-call order** | 13 | `DisenchantAll` (5 call sites outside the API layer), then `GetSpellPower`, `GetSpellRec`, `GetTrinketCooldown`, `UseTrinket`, `GetUnitData`, `GetSpellRangeData`, `CombatLogFlush`, `GetRaidTargets`, `SetLocalRaidTargetIndex`, `GetQuestLogQuestIds`, `GetQuestDialogQuestId`, `StartItemExport` (0 each — present for completeness, not need) |
|
||||
|
||||
The two that were at the head of that last bucket are now built — see "The cast feed" above for
|
||||
why they came first and what each decision cost.
|
||||
|
||||
Reference in New Issue
Block a user