app+ui: the trinket cooldown — and a stock verb that always said "cold"
Built: GetTrinketCooldown, UseTrinket, GetSpellRangeData, SetLocalRaidTargetIndex, GetQuestLogQuestIds, GetQuestDialogQuestId, CombatLogFlush. The find on the way through is not a nampower verb. **`GetInventoryItemCooldown` is a stock 1.12 verb and it answered `(0, 0, 1)` unconditionally** — right shape, always "cold", so nothing errored and no worn trinket ever drew its cooldown swirl on the character pane. Stock PaperDollFrame.lua:691 calls it on every slot update. Feeding an equipped slot's cooldown for GetTrinketCooldown fixes that one off the same column, and the test runs `CooldownFrame_SetTimer`'s own arithmetic against it. The feed keys its query on the (use spell, item entry) pair exactly as the bag feed does, so one trinket reads the same cooldown from the doll and from a bag. **The risk was the gate, and my first test for it was vacuous.** `feed_char` sits behind a change gate and no descriptor moves when a cooldown is armed, so `Cooldowns::feed_epoch` had to become a gate input. The first test passed with that input deleted: `run_system_once` builds a fresh system each call, so the gate's `Local` memo resets and `vm_reset` opens the gate every time. A test for a gate input has to run the system the way the schedule does. Rewritten with `add_systems` + `update`, it fails with the input removed — verified by probe, and the reason is written at the test. `feed_char` also hit Bevy's sixteen-parameter ceiling, which was a fair signal its parameters wanted grouping by what they are for: `ItemCooldownInputs` and `RepairInputs` replace six. Two decisions recorded at the code: - `GetSpellRangeData(rangeIndex)` answers three values, not a table. Every table-returning verb in this DLL has its key names in the string pool and this row's are not there. `flags` bit 0 is the melee family, where the engine takes the combat-reach sum instead of the authored pair — a caller ignoring the bit reads 0..5 for every melee ability. - `CombatLogFlush()` is a deliberate no-op. Upstream buffers its combat-log writes and this empties the buffer, so what an addon asks for is "my lines are on disk now" — and `ui_chat/logging.rs` writes each line to an unbuffered File as the window shows it, so that holds at all times. Defining it is the honest answer: the capability is present, and a missing global makes `HasFeature` report it absent. Also fed, both one wire column each: `QuestState::quest_id` (the wire names the quest on all three giver panels and 1.12's Lua names it nowhere — a title is not a key) and `SpellDisplay::stack_amount`'s neighbour work from the previous commit. Five names stay out, each now with the field list that makes it a decision rather than a shrug — `GetUnitData` (80 raw descriptor columns, read off the pool, zero call sites, and a partial answer is worse than none), `GetRaidTargets` (the reference keeps a board; this client feeds the mark per unit, and a scan over named tokens would silently omit a marked unit), `DisenchantAll` (a server-paced sequencer, which is the queue), `GetSpellPower`, `StartItemExport` (writes a file — it belongs with the withheld seven). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -215,6 +215,15 @@ pub struct InvSlotView {
|
||||
pub duration_ms: Option<u64>,
|
||||
/// The raw descriptor numbers nampower's equipment getters report ([`ItemRawFields`]).
|
||||
pub raw: ItemRawFields,
|
||||
/// The slot's use cooldown, `(start_ms on the GetTime clock, duration_ms, enabled)`; `None`
|
||||
/// is cold. Backs `GetInventoryItemCooldown` — a **stock 1.12 verb** that answered `(0, 0, 1)`
|
||||
/// unconditionally until this was fed, so the paper doll never drew a trinket's swirl.
|
||||
pub cooldown: Option<(i64, u32, bool)>,
|
||||
/// The same query's three legs, for nampower's `GetTrinketCooldown`.
|
||||
pub cooldown_legs: super::CooldownLegsView,
|
||||
/// The template's on-use spell id, 0 for none: `GetTrinketCooldown`'s `itemActiveSpellId`,
|
||||
/// and the spell the legs above were keyed on.
|
||||
pub use_spell_id: u32,
|
||||
}
|
||||
|
||||
/// The item-descriptor numbers nampower's `GetEquippedItem`/`GetEquippedItems` report, kept apart
|
||||
@@ -876,13 +885,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetInventoryItemCooldown(unit, slot) → (start, duration, enable). Not fed: with no
|
||||
// equipped-item cooldown source it answers `(0, 0, 1)`, no cooldown, as the container verb
|
||||
// does; stock calls it on every slot update (`PaperDollFrame.lua:691`).
|
||||
// GetInventoryItemCooldown(unit, slot) → (start, duration, enable), the triple stock calls on
|
||||
// every slot update (`PaperDollFrame.lua:691`) to drive a worn item's cooldown swirl.
|
||||
//
|
||||
// **This answered `(0, 0, 1)` unconditionally until the doll feed carried a cooldown.** The
|
||||
// shape was right and the number was always "cold", so nothing errored and no trinket ever
|
||||
// swirled on the character pane — the quietest kind of wrong. The feed is
|
||||
// `benilla-app`'s `ui_char::slot_view`, keyed on the (use spell, item entry) pair exactly as
|
||||
// the bag feed's is, so one trinket reads the same cooldown from the doll and from a bag.
|
||||
//
|
||||
// A cold or absent slot is still `(0, 0, 1)`: the caller does arithmetic on these and stock
|
||||
// `CooldownFrame_SetTimer` reads a 0 duration as "hide", so nil would be the wrong shape.
|
||||
// Another unit's slot is cold too — an inspected player's cooldowns are not on the wire.
|
||||
g.set(
|
||||
"GetInventoryItemCooldown",
|
||||
lua.create_function(|_, (_token, _slot): (Option<String>, i64)| {
|
||||
Ok((0.0f64, 0.0f64, 1i64))
|
||||
lua.create_function(|lua, (token, slot): (Option<String>, i64)| {
|
||||
let cooldown = player_inv_slot(lua, &token, slot).and_then(|v| v.cooldown);
|
||||
Ok(match cooldown {
|
||||
Some((start_ms, duration_ms, enabled)) => (
|
||||
start_ms as f64 / 1000.0,
|
||||
f64::from(duration_ms) / 1000.0,
|
||||
i64::from(enabled),
|
||||
),
|
||||
None => (0.0, 0.0, 1),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -194,6 +194,53 @@ fn spell_rec_value(lua: &Lua, value: crate::script::SpellRecValue) -> mlua::Resu
|
||||
})
|
||||
}
|
||||
|
||||
/// Which equipped trinket slot `ident` names: `1`/`2` are the two slots themselves, any other
|
||||
/// number is an item id, and a string is a case-insensitive name match. `None` for an argument
|
||||
/// that names neither slot's contents.
|
||||
///
|
||||
/// One helper for both trinket verbs, the same way [`find_bag_slot`] serves the bag group: a
|
||||
/// getter and an action that disagreed about which of two trinkets the argument meant would be
|
||||
/// worse than either being absent.
|
||||
fn trinket_slot_index(model: &crate::script::Model, ident: Option<&Value>) -> Option<usize> {
|
||||
let token = inv_token(model, None);
|
||||
let occupied = |slot: usize| {
|
||||
model
|
||||
.inv_slot(&token, slot)
|
||||
.filter(|v| v.item_id != 0)
|
||||
.map(|v| (slot, v))
|
||||
};
|
||||
match ident {
|
||||
Some(Value::Integer(1)) => occupied(TRINKET_SLOTS[0]).map(|(s, _)| s),
|
||||
Some(Value::Integer(2)) => occupied(TRINKET_SLOTS[1]).map(|(s, _)| s),
|
||||
Some(Value::Integer(n)) => {
|
||||
let id = u32::try_from(*n).ok()?;
|
||||
TRINKET_SLOTS
|
||||
.into_iter()
|
||||
.filter_map(occupied)
|
||||
.find(|(_, v)| v.item_id == id)
|
||||
.map(|(s, _)| s)
|
||||
}
|
||||
Some(Value::String(s)) => {
|
||||
let want = s.to_str().ok()?.to_lowercase();
|
||||
TRINKET_SLOTS
|
||||
.into_iter()
|
||||
.filter_map(occupied)
|
||||
.find(|(_, v)| v.name.as_deref().is_some_and(|n| n.to_lowercase() == want))
|
||||
.map(|(s, _)| s)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`trinket_slot_index`]'s slot resolved to its view.
|
||||
fn trinket_slot(
|
||||
model: &crate::script::Model,
|
||||
ident: Option<&Value>,
|
||||
) -> Option<crate::script::InvSlotView> {
|
||||
let slot = trinket_slot_index(model, ident)?;
|
||||
model.inv_slot(&inv_token(model, None), slot)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -642,6 +689,125 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// `GetSpellRangeData(rangeIndex)` -> `min, max, flags`, the `SpellRange.dbc` row's own three
|
||||
// columns in order. **A row index, not a spell id**, which is the DLL's own usage string
|
||||
// (`Usage: GetSpellRangeData(rangeIndex)`) — the caller gets the index from
|
||||
// `GetSpellRecField(spellId, "rangeIndex")`, the sibling two lines up.
|
||||
//
|
||||
// 1.12's Lua reaches a range only through `SpellRangeFunc`-formatted tooltip text, and
|
||||
// `C_Spell.IsSpellInRange` answers the *verdict* from the live usable map — so the two yard
|
||||
// numbers have no other path out of this client. `flags` bit 0 is the melee family, where
|
||||
// `GetMinMaxRange` takes the combat-reach sum instead of the authored pair, so a caller that
|
||||
// ignores it will read 0..5 for every melee ability.
|
||||
//
|
||||
// Three returns rather than a table: there are no field-name strings for this row anywhere in
|
||||
// the DLL's pool, where every table-returning verb's keys are in it — so a flat row is what
|
||||
// it answers. Nothing in grom's corpus calls this verb, which is also why the reading is
|
||||
// recorded here rather than argued for.
|
||||
lua.globals().set(
|
||||
"GetSpellRangeData",
|
||||
lua.create_function(|lua, index: Option<u32>| {
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
Ok(
|
||||
match index
|
||||
.and_then(|i| model.spell_range_probe.as_ref().and_then(|probe| probe(i)))
|
||||
{
|
||||
Some((min, max, flags)) => (
|
||||
Some(f64::from(min)),
|
||||
Some(f64::from(max)),
|
||||
Some(i64::from(flags)),
|
||||
),
|
||||
None => (None, None, None),
|
||||
},
|
||||
)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `SetLocalRaidTargetIndex(unitStr, raidTargetIndex)`: a mark only this client sees, where
|
||||
// `SetRaidTarget` asks the server to set one for the group.
|
||||
//
|
||||
// The same queue SuperWoW's own spelling goes through — `SetRaidTarget(unit, index, "local")`,
|
||||
// whose third argument is this flag (`script/party.rs`) — so the two names cannot disagree
|
||||
// about what a local mark is, and a 0 clears as it does there. Out of range is dropped rather
|
||||
// than clamped: the DLL's own message is
|
||||
// `SetLocalRaidTargetIndex: raidTargetIndex must be in range 0-8`, so upstream refuses too.
|
||||
lua.globals().set(
|
||||
"SetLocalRaidTargetIndex",
|
||||
lua.create_function(|lua, (unit, index): (Option<String>, Option<u8>)| {
|
||||
let (Some(unit), Some(index)) = (unit, index.filter(|&i| i <= 8)) else {
|
||||
return Ok(());
|
||||
};
|
||||
let set: mlua::Function = lua.globals().get("SetRaidTarget")?;
|
||||
set.call::<()>((unit, index, 1))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetQuestLogQuestIds()` -> the quest ids in the log, as a 1-based list.
|
||||
//
|
||||
// Header rows are left out: they are not quests, they carry id `0`
|
||||
// (`GetQuestIDForLogIndex`'s own documented answer for one), and a list with zeros in it is a
|
||||
// list a caller has to filter. So the list is ids and the indices are its own, **not** log
|
||||
// indices — `GetQuestIDForLogIndex(i)` is the verb that answers by log index, and it is right
|
||||
// next door. Nothing in grom's corpus calls this one, so the shape is recorded rather than
|
||||
// measured.
|
||||
lua.globals().set(
|
||||
"GetQuestLogQuestIds",
|
||||
lua.create_function(|lua, ()| {
|
||||
let ids: Vec<u32> = {
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
model
|
||||
.quest_log
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| e.quest_id)
|
||||
.filter(|&id| id != 0)
|
||||
.collect()
|
||||
};
|
||||
let t = lua.create_table()?;
|
||||
for (i, id) in ids.into_iter().enumerate() {
|
||||
t.set(i as i64 + 1, id)?;
|
||||
}
|
||||
Ok(t)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetQuestDialogQuestId()` -> the quest id of the open quest giver's panel, or nil.
|
||||
//
|
||||
// The wire names the quest on all three panels (`SMSG_QUESTGIVER_QUEST_DETAILS`,
|
||||
// `_REQUEST_ITEMS`, `_OFFER_REWARD`) and 1.12's Lua names it nowhere — `GetTitleText` answers
|
||||
// the title, and a title is not a key, which is the whole reason an addon wanting to record
|
||||
// "the player accepted quest N" cannot do it on the stock surface. The **greeting** panel is a
|
||||
// list of quests and names none of them, so it answers nil rather than one of the rows.
|
||||
lua.globals().set(
|
||||
"GetQuestDialogQuestId",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
Ok(model
|
||||
.quest
|
||||
.as_ref()
|
||||
.map(|q| q.quest_id)
|
||||
.filter(|&id| id != 0))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `CombatLogFlush()`: upstream buffers its combat-log writes and this empties the buffer, so
|
||||
// what an addon is asking for is "my lines are on disk now".
|
||||
//
|
||||
// Here that postcondition holds at all times: `ui_chat/logging.rs` writes each line to an
|
||||
// unbuffered `std::fs::File` as the window shows it, so there is nothing held back to flush.
|
||||
// Defined rather than left absent because the capability really is present — a caller that
|
||||
// feature-detects this and then reads the file gets what it asked for — and a no-op that says
|
||||
// why is better than a missing global that makes `HasFeature` report the capability absent
|
||||
// when it is not.
|
||||
lua.globals()
|
||||
.set("CombatLogFlush", lua.create_function(|_, ()| Ok(()))?)?;
|
||||
|
||||
// `GetSpellNameAndRankForId(spellID)` -> name, rank. DoiteAuras' hardest-leaned-on nampower
|
||||
// call (18 sites): every aura it tracks by id resolves its label through this before falling
|
||||
// back to a 200-slot spellbook scan. Both values come off the same `Spell.dbc` display row
|
||||
@@ -1305,6 +1471,65 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetTrinketCooldown(slot|itemIdOrName)` -> the same [`cooldown_table`] the two id-keyed
|
||||
// cooldown verbs answer, for whichever trinket the argument names.
|
||||
//
|
||||
// `1` and `2` are the trinket *slots*, which is what the usage string's `slot|itemIdOrName`
|
||||
// means and the numbering `GetTrinkets()` beside it keys its two rows on; anything else is an
|
||||
// item id or a name, matched against the two equipped slots. A number of 1 or 2 is therefore
|
||||
// a slot and never an item id — no real item has either id, so the overload is unambiguous in
|
||||
// practice as well as by the usage string.
|
||||
//
|
||||
// The legs come off the doll feed, which keys its query on the (use spell, item entry) pair
|
||||
// exactly as the bag feed does — so a trinket a player carries a second copy of in a bag reads
|
||||
// one cooldown through both verbs.
|
||||
lua.globals().set(
|
||||
"GetTrinketCooldown",
|
||||
lua.create_function(|lua, ident: Option<Value>| {
|
||||
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
|
||||
let found = {
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
trinket_slot(&model, ident.as_ref())
|
||||
};
|
||||
let (legs, item, use_spell) = found.map_or_else(Default::default, |v| {
|
||||
(v.cooldown_legs, v.item_id, v.use_spell_id)
|
||||
});
|
||||
Ok(Value::Table(cooldown_table(
|
||||
lua,
|
||||
&legs,
|
||||
now,
|
||||
Some((item, (use_spell != 0).then_some(use_spell))),
|
||||
)?))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `UseTrinket(slot|itemIdOrName, [target])`: the same argument grammar, through the stock
|
||||
// `UseInventoryItem` so the send, the self-cast flag and the bind confirmation are the ones
|
||||
// every other item use goes through. `UseItemIdOrName` beside it is the bag-slot sibling.
|
||||
//
|
||||
// `target` is accepted and ignored, as `UseItemIdOrName`'s is: 1.12's `UseInventoryItem` takes
|
||||
// a self-cast flag and not a unit, and the cast's target is resolved from the spell's own
|
||||
// constraints at the send (`spell/cast_target.rs`). Refusing the argument would be worse —
|
||||
// the caller passes whatever the macro said.
|
||||
lua.globals().set(
|
||||
"UseTrinket",
|
||||
lua.create_function(|lua, (ident, _target): (Option<Value>, Option<Value>)| {
|
||||
let slot = {
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
trinket_slot_index(&model, ident.as_ref())
|
||||
};
|
||||
if let Some(slot) = slot {
|
||||
let use_item: mlua::Function = lua.globals().get("UseInventoryItem")?;
|
||||
use_item.call::<()>(slot)?;
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetTrinkets()` (v2.14+) -> the two trinket slots as a table keyed `1` and `2`, the same
|
||||
// row shape `GetEquippedItem` hands back, with an empty slot absent rather than nil-valued
|
||||
// (a nil value in a Lua table *is* absence, and `SuperCleveRoidMacros` walks it with `for i =
|
||||
@@ -2734,6 +2959,246 @@ mod tests {
|
||||
|
||||
/// `GetSpellRecField`: the columns this client has, and **nil for the ones it does not** —
|
||||
/// asserted, because a plausible zero is worse than nothing here (DoiteAuras' Clearcasting
|
||||
/// The five small registered verbs: the range row, the local mark, the two quest readers and
|
||||
/// the flush. Grouped in one test because each is a handful of lines and none has a consumer
|
||||
/// to transcribe — what is pinned is the shape, the unit and the "nothing to say" answer.
|
||||
#[test]
|
||||
fn the_range_row_the_local_mark_the_quest_ids_and_the_flush() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
// `GetSpellRangeData(rangeIndex)`: a ROW INDEX, which is the DLL's own usage string.
|
||||
assert_eq!(
|
||||
s.eval::<(Option<f64>, Option<f64>, Option<i64>)>("return GetSpellRangeData(4)")
|
||||
.unwrap(),
|
||||
(None, None, None),
|
||||
"no probe: nothing, not a 0..0 range every melee check would pass"
|
||||
);
|
||||
s.set_spell_range_probe(Box::new(|index| match index {
|
||||
4 => Some((0.0, 30.0, 0)),
|
||||
1 => Some((0.0, 5.0, 1)),
|
||||
_ => None,
|
||||
}));
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i64)>("return GetSpellRangeData(4)")
|
||||
.unwrap(),
|
||||
(0.0, 30.0, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i64)>("return GetSpellRangeData(1)")
|
||||
.unwrap(),
|
||||
(0.0, 5.0, 1),
|
||||
"flags bit 0 is the melee family, where the engine uses the reach sum and not this \
|
||||
pair — a caller that ignores the bit reads 0..5 for every melee ability"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<Option<f64>>("return GetSpellRangeData(9999)")
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
// `SetLocalRaidTargetIndex` goes through `SetRaidTarget`'s own local flag, so the two
|
||||
// spellings cannot disagree about what a local mark is. Asserted on the queue both write.
|
||||
s.eval::<()>(r#"SetLocalRaidTargetIndex("target", 8)"#)
|
||||
.unwrap();
|
||||
s.eval::<()>(r#"SetRaidTarget("focus", 3, 1)"#).unwrap();
|
||||
// Out of range is dropped, as upstream's own message says it refuses.
|
||||
s.eval::<()>(r#"SetLocalRaidTargetIndex("target", 9)"#)
|
||||
.unwrap();
|
||||
s.eval::<()>(r#"SetLocalRaidTargetIndex("target")"#)
|
||||
.unwrap();
|
||||
let queued: Vec<(String, u8, bool)> = s
|
||||
.take_party_requests()
|
||||
.into_iter()
|
||||
.filter_map(|r| match r {
|
||||
crate::script::PartyRequest::SetRaidTarget { unit, index, local } => {
|
||||
Some((unit, index, local))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
queued,
|
||||
vec![
|
||||
("target".to_string(), 8, true),
|
||||
("focus".to_string(), 3, true),
|
||||
],
|
||||
"the local flag is set, and the two out-of-range calls queued nothing"
|
||||
);
|
||||
|
||||
// `GetQuestLogQuestIds()` is ids, headers left out, 1-based over its own list — not over
|
||||
// log indices, which is what `GetQuestIDForLogIndex` answers by.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return table.getn(GetQuestLogQuestIds())")
|
||||
.unwrap(),
|
||||
0,
|
||||
"an empty log is an empty table, never nil — the caller walks it"
|
||||
);
|
||||
s.set_quest_log(crate::script::QuestLogState {
|
||||
entries: vec![
|
||||
crate::script::QuestLogEntryView {
|
||||
quest_id: 0,
|
||||
title: "Elwynn Forest".into(),
|
||||
is_header: true,
|
||||
..Default::default()
|
||||
},
|
||||
crate::script::QuestLogEntryView {
|
||||
quest_id: 83,
|
||||
title: "Wolves Across the Border".into(),
|
||||
..Default::default()
|
||||
},
|
||||
crate::script::QuestLogEntryView {
|
||||
quest_id: 176,
|
||||
title: "Cloth and Leather Armor".into(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<Vec<u32>>("return GetQuestLogQuestIds()").unwrap(),
|
||||
vec![83, 176],
|
||||
"the header's id 0 is not in the list"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<u32>("return GetQuestIDForLogIndex(2)").unwrap(),
|
||||
83,
|
||||
"and the by-index verb still counts the header, which is why both exist"
|
||||
);
|
||||
|
||||
// `GetQuestDialogQuestId()`: nil with no giver open, nil on the greeting panel (which
|
||||
// names no one quest), the id on a detail panel.
|
||||
assert!(s
|
||||
.eval::<Option<u32>>("return GetQuestDialogQuestId()")
|
||||
.unwrap()
|
||||
.is_none());
|
||||
s.set_quest(Some(crate::script::QuestState {
|
||||
panel: crate::script::QuestPanel::Greeting,
|
||||
quest_id: 0,
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(
|
||||
s.eval::<Option<u32>>("return GetQuestDialogQuestId()")
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"the greeting panel is a list and names none of its rows"
|
||||
);
|
||||
s.set_quest(Some(crate::script::QuestState {
|
||||
panel: crate::script::QuestPanel::Detail,
|
||||
quest_id: 83,
|
||||
..Default::default()
|
||||
}));
|
||||
assert_eq!(
|
||||
s.eval::<Option<u32>>("return GetQuestDialogQuestId()")
|
||||
.unwrap(),
|
||||
Some(83)
|
||||
);
|
||||
|
||||
// `CombatLogFlush()` is a no-op that must still exist: the capability is present (the
|
||||
// writes are unbuffered), and a missing global makes `HasFeature` report it absent.
|
||||
assert!(s.run("CombatLogFlush()").is_ok());
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The trinket pair and `GetInventoryItemCooldown`, which is the **stock** verb this feed was
|
||||
/// really for: it answered `(0, 0, 1)` unconditionally, so no worn trinket ever drew its
|
||||
/// cooldown swirl on the character pane.
|
||||
///
|
||||
/// The three verbs are asserted together because they must agree: one trinket, one cooldown,
|
||||
/// whether it is asked for by slot, by id, by name, or through the stock paper-doll call.
|
||||
#[test]
|
||||
fn the_trinket_verbs_and_the_stock_doll_call_report_one_cooldown() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_now(100.0);
|
||||
let trinket = |item_id: u32, name: &str, legs: crate::script::CooldownLegsView| {
|
||||
crate::script::InvSlotView {
|
||||
item_id,
|
||||
name: Some(name.to_string()),
|
||||
use_spell_id: 11_392,
|
||||
cooldown: legs.individual,
|
||||
cooldown_legs: legs,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
let legs = crate::script::CooldownLegsView {
|
||||
individual: Some((98_000, 120_000, true)),
|
||||
category_id: 0,
|
||||
category: None,
|
||||
gcd_category_id: 0,
|
||||
gcd: None,
|
||||
};
|
||||
let mut slots: crate::script::InventorySlots = Default::default();
|
||||
// 13 and 14 are `INVSLOT_TRINKET1`/`2`, which is how the doll feed numbers them.
|
||||
slots[13] = Some(trinket(13_444, "Hand of Justice", legs));
|
||||
slots[14] = Some(trinket(11_815, "Hand of Justice Too", Default::default()));
|
||||
s.set_inventory_slots(slots);
|
||||
|
||||
// By slot, by id and by name, all three reaching the same row.
|
||||
for call in [
|
||||
"GetTrinketCooldown(1)",
|
||||
"GetTrinketCooldown(13444)",
|
||||
r#"GetTrinketCooldown("hand of justice")"#,
|
||||
] {
|
||||
assert_eq!(
|
||||
s.eval::<(i64, f64, i64, i64)>(&format!(
|
||||
"local c = {call} \
|
||||
return c.isOnCooldown, c.individualRemainingMs, c.itemId, \
|
||||
c.itemActiveSpellId"
|
||||
))
|
||||
.unwrap(),
|
||||
(1, 118_000.0, 13_444, 11_392),
|
||||
"{call}"
|
||||
);
|
||||
}
|
||||
// Slot 2's trinket has no record: a cold table, not the other slot's numbers.
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("local c = GetTrinketCooldown(2) return c.isOnCooldown, c.itemId")
|
||||
.unwrap(),
|
||||
(0, 11_815)
|
||||
);
|
||||
// An argument naming neither slot answers cold with no item at all.
|
||||
for call in [
|
||||
"GetTrinketCooldown(999999)",
|
||||
r#"GetTrinketCooldown("nope")"#,
|
||||
] {
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>(&format!("local c = {call} return c.isOnCooldown, c.itemId"))
|
||||
.unwrap(),
|
||||
(0, 0),
|
||||
"{call}"
|
||||
);
|
||||
}
|
||||
|
||||
// The stock verb, off the same fed triple — seconds, not milliseconds, because that is
|
||||
// what `CooldownFrame_SetTimer` takes.
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i64)>(r#"return GetInventoryItemCooldown("player", 13)"#)
|
||||
.unwrap(),
|
||||
(98.0, 120.0, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i64)>(r#"return GetInventoryItemCooldown("player", 14)"#)
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 1),
|
||||
"a cold slot is still the triple, because the caller does arithmetic on it"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i64)>(r#"return GetInventoryItemCooldown("target", 13)"#)
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 1),
|
||||
"another unit's cooldowns are not on the wire"
|
||||
);
|
||||
// `CooldownFrame_SetTimer`'s own arithmetic (`Cooldown.lua:3`), run against the verb:
|
||||
// this is the number the swirl is drawn from, and it was always 0 before the feed.
|
||||
assert_eq!(
|
||||
s.eval::<f64>(
|
||||
r#"local start, duration, enable = GetInventoryItemCooldown("player", 13)
|
||||
if enable == 1 and duration > 0 then return (start + duration) - GetTime() end
|
||||
return 0"#
|
||||
)
|
||||
.unwrap(),
|
||||
118.0
|
||||
);
|
||||
}
|
||||
|
||||
/// `GetSpellRec` and `GetSpellRecField` over the `Spell.dbc` probe, including the seven
|
||||
/// names the corpus asks for that answered nil before it existed.
|
||||
///
|
||||
|
||||
@@ -246,6 +246,9 @@ mod tests {
|
||||
fn doll_slots() -> crate::script::InventorySlots {
|
||||
let mut slots: crate::script::InventorySlots = Default::default();
|
||||
slots[1] = Some(InvSlotView {
|
||||
cooldown: None,
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
duration_ms: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
@@ -266,6 +269,9 @@ mod tests {
|
||||
raw: Default::default(),
|
||||
});
|
||||
slots[11] = Some(InvSlotView {
|
||||
cooldown: None,
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
duration_ms: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
@@ -286,6 +292,9 @@ mod tests {
|
||||
raw: Default::default(),
|
||||
});
|
||||
slots[19] = Some(InvSlotView {
|
||||
cooldown: None,
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
duration_ms: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
@@ -460,6 +469,9 @@ mod tests {
|
||||
});
|
||||
// An equipped bag in Bag0Slot (id 20): its equip_slots is the four bag slots.
|
||||
slots[20] = Some(InvSlotView {
|
||||
cooldown: None,
|
||||
cooldown_legs: Default::default(),
|
||||
use_spell_id: 0,
|
||||
duration_ms: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
|
||||
@@ -247,8 +247,8 @@ pub use model::{
|
||||
};
|
||||
pub(crate) use model::{Model, PendingTimer};
|
||||
pub use model::{
|
||||
PlayerCacheEntry, SelfCast, SelfMovement, ServerClock, SpellModifierProbe, SpellRecFieldProbe,
|
||||
SpellRecValue, SpellRecordProbe,
|
||||
PlayerCacheEntry, SelfCast, SelfMovement, ServerClock, SpellModifierProbe, SpellRangeProbe,
|
||||
SpellRecFieldProbe, SpellRecValue, SpellRecordProbe,
|
||||
};
|
||||
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
|
||||
pub use pet::{PetActionView, PetStats};
|
||||
|
||||
@@ -71,6 +71,14 @@ 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>>;
|
||||
|
||||
/// One `SpellRange.dbc` row by its index: `(min, max, flags)`, for nampower's
|
||||
/// `GetSpellRangeData(rangeIndex)`. `None` for an index no row occupies.
|
||||
///
|
||||
/// 1.12's Lua reaches a range only through `SpellRangeFunc`-formatted tooltip text, never the two
|
||||
/// yard numbers, and `C_Spell.IsSpellInRange` answers the *verdict* from the live usable map — so
|
||||
/// the yards themselves have no other path out of this client.
|
||||
pub type SpellRangeProbe = Box<dyn Fn(u32) -> Option<(f32, f32, u32)>>;
|
||||
|
||||
/// One `Spell.dbc` column as nampower's `GetSpellRec`/`GetSpellRecField` hands it to Lua.
|
||||
///
|
||||
/// The array variants are a **1-based Lua table of three**, which is not a guess:
|
||||
@@ -829,6 +837,7 @@ pub(crate) struct Model {
|
||||
pub(crate) spell_modifier_probe: Option<SpellModifierProbe>,
|
||||
pub(crate) spell_rec_field_probe: Option<SpellRecFieldProbe>,
|
||||
pub(crate) spell_record_probe: Option<SpellRecordProbe>,
|
||||
pub(crate) spell_range_probe: Option<SpellRangeProbe>,
|
||||
/// 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`).
|
||||
@@ -1733,6 +1742,7 @@ impl Model {
|
||||
spell_modifier_probe: None,
|
||||
spell_rec_field_probe: None,
|
||||
spell_record_probe: None,
|
||||
spell_range_probe: None,
|
||||
spell_mechanic_probe: None,
|
||||
player_cache: HashMap::new(),
|
||||
player_cache_enabled: [true, true],
|
||||
|
||||
@@ -83,12 +83,21 @@ pub struct QuestState {
|
||||
/// `GetQuestBackgroundMaterial`, which the app fills from an item or GameObject source, never
|
||||
/// from the wire; nil reads as "Parchment" (`QuestFrame.lua:598-604`).
|
||||
pub background_material: Option<String>,
|
||||
/// The quest id of whichever panel is live, `0` on the greeting panel and when no quest giver
|
||||
/// is open: nampower's `GetQuestDialogQuestId()`.
|
||||
///
|
||||
/// The wire carries it on all three quest panels — `SMSG_QUESTGIVER_QUEST_DETAILS`,
|
||||
/// `_REQUEST_ITEMS` and `_OFFER_REWARD` each name the quest — and 1.12's own Lua carries it
|
||||
/// nowhere: `GetTitleText` answers the title, and a title is not a key. The greeting panel is
|
||||
/// a *list* of quests and names none of them, so `0` is the honest answer there.
|
||||
pub quest_id: u32,
|
||||
}
|
||||
|
||||
impl Default for QuestState {
|
||||
fn default() -> Self {
|
||||
QuestState {
|
||||
panel: QuestPanel::Detail,
|
||||
quest_id: 0,
|
||||
greeting: String::new(),
|
||||
active_titles: Vec::new(),
|
||||
active_levels: Vec::new(),
|
||||
|
||||
@@ -133,6 +133,11 @@ impl super::UiScript {
|
||||
|
||||
/// Install nampower's `Spell.dbc` row readers, both built from the one name-to-column mapping
|
||||
/// in `benilla-app` (see [`super::SpellRecFieldProbe`]).
|
||||
/// Install nampower's `SpellRange.dbc` reader.
|
||||
pub fn set_spell_range_probe(&mut self, probe: super::SpellRangeProbe) {
|
||||
self.model_mut().spell_range_probe = Some(probe);
|
||||
}
|
||||
|
||||
pub fn set_spell_rec_probes(
|
||||
&mut self,
|
||||
field: super::SpellRecFieldProbe,
|
||||
|
||||
@@ -110,6 +110,11 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
|
||||
// the rest answer nil rather than a plausible zero. DoiteAuras guards each non-name read.
|
||||
"GetSpellRecField",
|
||||
"GetSpellRec",
|
||||
"GetSpellRangeData",
|
||||
"SetLocalRaidTargetIndex",
|
||||
"GetQuestLogQuestIds",
|
||||
"GetQuestDialogQuestId",
|
||||
"CombatLogFlush",
|
||||
// nampower v4's spellbook index helpers (script/compat/nampower.rs), all four of them the
|
||||
// fast paths DoiteAuras takes before falling back to a 200-slot `GetSpellName` scan per
|
||||
// lookup — it does one of those per tracked aura per condition evaluation.
|
||||
@@ -345,6 +350,8 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
|
||||
"GetSpellIdCooldown",
|
||||
"GetItemIconTexture",
|
||||
"GetTrinkets",
|
||||
"GetTrinketCooldown",
|
||||
"UseTrinket",
|
||||
"PlayerIsMoving",
|
||||
"PlayerIsRooted",
|
||||
"PlayerIsSwimming",
|
||||
|
||||
Reference in New Issue
Block a user