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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user