ui: ClassicAPI's GUID player info, no-toggle cast and stable-id skill rank
Third DLL tranche, from ClassicAPI's side of the work list. Its 70 corpus-called names are mostly **not** functions — `Clear`, `Fire`, `Size`, `Armor`, `Nature`, `Normal`, `None` and the rest are widget methods and enum keys its DLL carries as strings, and several more (`GetSpellSchool`, `RunMacro`, `GetSpellLink`) the corpus defines for itself. Reading the call sites left nine genuine gaps; three are landed here and the rest need state this client does not feed yet. - `GetPlayerInfoByGUID(guid)` -> `localizedClass, englishClass, localizedRace, englishRace, sex, name, realm`. 1.12 has no verb that takes a GUID at all. Answered through the same `Model::unit` as the whole unit surface, so a raw GUID, a `0x`-prefixed one and every other token spelling resolve alike; an unknown GUID is seven nils, not a raise. `realm` is **nil** on purpose: the field means the realm the *named* character is from, which 1.12 has no concept of, and our own realm name there would be a guess that reads as data. - `CastSpellNoToggle(spellId)`: the plain cast by id. The toggle lives in the client's cast path and benilla's queue toggles nothing — a press is a send — so this is what the caller asked for, on the same queue a bar press uses. Same reasoning as nampower's `CastSpellNoQueue`. - `C_SpellBook.GetSkillLineRank(skillLineID)` -> `rank, maxRank`. 1.12 reaches a skill only by its index into the skills *window*, which moves as lines are learnt and categories collapse — a `SkillLine.dbc` id is stable, which is why pfQuest asks by one. It reuses `skills::displayed_ranks`, so the stable-id getter and `GetSkillLineInfo` fold bonuses and the mono override identically and cannot disagree. A line the character has no row for is `nil, nil`, not `0, 0`: untrained and unknown are different answers. All nine gates green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -39,6 +39,31 @@ fn tab_index_of(book: &SpellBookState, slot_0: usize) -> Option<usize> {
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
|
||||
let t = lua.create_table()?;
|
||||
|
||||
// `C_SpellBook.GetSkillLineRank(skillLineID)` -> `rank, maxRank`, the modern client's pair.
|
||||
// 1.12 reaches a skill only by its **index into the skills window** (`GetSkillLineInfo`),
|
||||
// which moves as lines are learnt and as categories collapse; a `SkillLine.dbc` id is stable,
|
||||
// which is why pfQuest asks by one.
|
||||
//
|
||||
// The two numbers are the same ones `GetSkillLineInfo` reports for that line, bonuses folded
|
||||
// the way the reference folds them (`script/skills.rs` carries that arithmetic) — so the
|
||||
// stable-id getter and the index getter cannot disagree. A line the player does not have is
|
||||
// `nil, nil`, not `0, 0`: untrained and unknown are different answers, and the modern API
|
||||
// returns nothing for an id the character has no row for.
|
||||
t.set(
|
||||
"GetSkillLineRank",
|
||||
lua.create_function(|lua, skill_id: Option<u32>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(entry) = skill_id
|
||||
.filter(|&id| id != 0)
|
||||
.and_then(|id| model.skills.entries.iter().find(|e| e.skill_id == id))
|
||||
else {
|
||||
return Ok((None, None));
|
||||
};
|
||||
let (rank, max) = crate::script::skills::displayed_ranks(entry);
|
||||
Ok((Some(rank), Some(max)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// FindSpellBookSlotByID(spellID) -> slotIndex, spellBank ("spell"/"pet"), or nil. The id→slot
|
||||
// direction every other function in this namespace needs.
|
||||
t.set(
|
||||
@@ -138,6 +163,75 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
|
||||
mod tests {
|
||||
use crate::script::{SpellBookState, SpellSlotView, SpellTabView, UiScript};
|
||||
|
||||
/// `C_SpellBook.GetSkillLineRank(skillLineID)`: the stable-id way into a skill, where 1.12
|
||||
/// has only an index into the skills window that moves as lines are learnt and categories
|
||||
/// collapse — which is why pfQuest asks by id. Must agree with `GetSkillLineInfo` for the
|
||||
/// same line, bonuses and the mono override included.
|
||||
#[test]
|
||||
fn get_skill_line_rank_agrees_with_the_index_getter() {
|
||||
// `SkillEntry` has no `Default` on purpose — every column is server data — so the test
|
||||
// names all of them once here.
|
||||
let skill =
|
||||
|skill_id: u32, name: &str, value: u32, max: u32, perm_bonus: i32, mono: bool| {
|
||||
crate::script::SkillEntry {
|
||||
skill_id,
|
||||
name: name.to_string(),
|
||||
value,
|
||||
max,
|
||||
mono,
|
||||
temp_bonus: 0,
|
||||
perm_bonus,
|
||||
min_level: 0,
|
||||
cost_index: 0,
|
||||
category_id: 6,
|
||||
category_name: String::new(),
|
||||
category_order: 0,
|
||||
description: String::new(),
|
||||
abandonable: false,
|
||||
}
|
||||
};
|
||||
let mut s = UiScript::new().unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<(Option<i64>, Option<i64>)>("return C_SpellBook.GetSkillLineRank(164)")
|
||||
.unwrap(),
|
||||
(None, None),
|
||||
"no skills fed yet"
|
||||
);
|
||||
|
||||
s.set_skills(crate::script::SkillsState {
|
||||
entries: vec![
|
||||
skill(164, "Blacksmithing", 210, 300, 5, false),
|
||||
skill(5, "Beast Mastery", 300, 300, 0, true),
|
||||
],
|
||||
});
|
||||
|
||||
// The permanent bonus folds into both ranks, as the index getter folds it.
|
||||
assert_eq!(
|
||||
s.eval::<(Option<i64>, Option<i64>)>("return C_SpellBook.GetSkillLineRank(164)")
|
||||
.unwrap(),
|
||||
(Some(215), Some(305))
|
||||
);
|
||||
// A mono line reports max 1 whatever the server said — the same override.
|
||||
assert_eq!(
|
||||
s.eval::<(Option<i64>, Option<i64>)>("return C_SpellBook.GetSkillLineRank(5)")
|
||||
.unwrap(),
|
||||
(Some(1), Some(1))
|
||||
);
|
||||
// A line the character has no row for is nil, not 0: untrained and unknown differ.
|
||||
for call in [
|
||||
"C_SpellBook.GetSkillLineRank(999)",
|
||||
"C_SpellBook.GetSkillLineRank(0)",
|
||||
"C_SpellBook.GetSkillLineRank()",
|
||||
] {
|
||||
assert_eq!(
|
||||
s.eval::<(Option<i64>, Option<i64>)>(&format!("return {call}"))
|
||||
.unwrap(),
|
||||
(None, None),
|
||||
"{call}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn book() -> SpellBookState {
|
||||
SpellBookState {
|
||||
tabs: vec![
|
||||
|
||||
@@ -9,6 +9,55 @@ use crate::script::Model;
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// `GetPlayerInfoByGUID(guid)` -> `localizedClass, englishClass, localizedRace, englishRace,
|
||||
// sex, name, realm`, the modern client's seven; 1.12 has no verb that takes a GUID at all, so
|
||||
// nothing here is derivable from the stock API. pfUI calls it bare.
|
||||
//
|
||||
// Answered for a unit this client holds a snapshot of, through the same `Model::unit` the
|
||||
// whole unit surface uses — so a raw GUID string, a `0x`-prefixed one, or any other token
|
||||
// spelling resolves identically, and an unknown GUID is seven nils rather than a raise.
|
||||
//
|
||||
// `realm` is **nil**, not the player's own realm name: the field means the realm the *named*
|
||||
// character is from, which only a cross-realm client can know and which 1.12 has no concept
|
||||
// of. The modern API returns an empty string for same-realm, and the consumers treat nil and
|
||||
// "" alike; claiming our realm for an arbitrary GUID would be a guess that reads as data.
|
||||
g.set(
|
||||
"GetPlayerInfoByGUID",
|
||||
lua.create_function(|lua, guid: Option<String>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let found = guid.as_deref().and_then(|g| model.unit(g));
|
||||
let Some(u) = found.filter(|u| u.exists) else {
|
||||
return Ok((None, None, None, None, None, None, None::<String>));
|
||||
};
|
||||
Ok((
|
||||
u.class.clone(),
|
||||
u.class_file.clone(),
|
||||
u.race.clone(),
|
||||
u.race_file.clone(),
|
||||
Some(i64::from(u.sex)),
|
||||
u.name.clone(),
|
||||
None,
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `CastSpellNoToggle(spellId)`: cast without the stock toggle-off, for a spell whose aura is
|
||||
// already up. The toggle lives in the client's cast path (`0x6e5100`'s autorepeat and aura
|
||||
// tests), and benilla's cast queue does not toggle anything — a press is a send — so this is
|
||||
// the plain cast by id, which is exactly what the caller asked for. Same reasoning as
|
||||
// nampower's `CastSpellNoQueue` pair (`compat/nampower.rs`), and the same queue, so a cast
|
||||
// from here and one from a bar are one send.
|
||||
g.set(
|
||||
"CastSpellNoToggle",
|
||||
lua.create_function(|lua, spell_id: Option<u32>| {
|
||||
if let Some(spell_id) = spell_id.filter(|&id| id != 0) {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.spell_casts.push(spell_id);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitGUID(unit): reuses SuperWoW's own `guid_token`/`Model::unit` plumbing, so a GUID from
|
||||
// either round-trips through both.
|
||||
g.set(
|
||||
@@ -86,6 +135,74 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
mod tests {
|
||||
use crate::script::{UiScript, UnitState};
|
||||
|
||||
/// `GetPlayerInfoByGUID`'s seven, for a unit this client holds. 1.12 has no verb that takes a
|
||||
/// GUID at all, so every column here comes from the snapshot the unit surface already carries
|
||||
/// — and the GUID resolves through the same `Model::unit` as every other token spelling, so a
|
||||
/// raw GUID and a `0x`-prefixed one answer alike.
|
||||
#[test]
|
||||
fn get_player_info_by_guid_answers_from_the_unit_snapshot() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
guid: 0x4242,
|
||||
name: Some("Gromgosh".into()),
|
||||
class: Some("Warrior".into()),
|
||||
class_file: Some("WARRIOR".into()),
|
||||
race: Some("Orc".into()),
|
||||
race_file: Some("Orc".into()),
|
||||
sex: 2,
|
||||
is_player: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
|
||||
type Info = (
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
);
|
||||
let token = crate::script::guid_token(0x4242);
|
||||
let got = s
|
||||
.eval::<Info>(&format!(r#"return GetPlayerInfoByGUID("{token}")"#))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
got,
|
||||
(
|
||||
Some("Warrior".into()),
|
||||
Some("WARRIOR".into()),
|
||||
Some("Orc".into()),
|
||||
Some("Orc".into()),
|
||||
Some(2),
|
||||
Some("Gromgosh".into()),
|
||||
// `realm` is the *named* character's realm, which 1.12 has no concept of; our own
|
||||
// realm name here would be a guess that reads as data.
|
||||
None,
|
||||
)
|
||||
);
|
||||
// The same answer without the `0x`, since the token resolver takes both spellings.
|
||||
assert_eq!(
|
||||
s.eval::<Info>(r#"return GetPlayerInfoByGUID("0000000000004242")"#)
|
||||
.unwrap(),
|
||||
got
|
||||
);
|
||||
// A GUID nothing holds: seven nils, not a raise.
|
||||
assert_eq!(
|
||||
s.eval::<Info>(r#"return GetPlayerInfoByGUID("0x00000000DEADBEEF")"#)
|
||||
.unwrap(),
|
||||
(None, None, None, None, None, None, None)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<Info>("return GetPlayerInfoByGUID()").unwrap(),
|
||||
(None, None, None, None, None, None, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_guid_and_unit_token_from_guid_round_trip() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -246,7 +246,7 @@ fn selected_index(model: &Model) -> u32 {
|
||||
/// ```
|
||||
///
|
||||
/// Saturating at 0 keeps a negative permanent bonus from wrapping.
|
||||
fn displayed_ranks(e: &SkillEntry) -> (i64, i64) {
|
||||
pub(in crate::script) fn displayed_ranks(e: &SkillEntry) -> (i64, i64) {
|
||||
let fold = |v: u32| {
|
||||
if v > 0 {
|
||||
(i64::from(v) + i64::from(e.perm_bonus)).max(0)
|
||||
|
||||
@@ -319,6 +319,12 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
|
||||
// `GetQuestLogTitle` answers the title and never the id, so neither the id nor a chat
|
||||
// link for a log row is derivable from the stock API. The id agrees with
|
||||
// `C_QuestLog.GetQuestIDForLogIndex`, which ClassicAPI publishes over the same rows.
|
||||
// ClassicAPI's `GetPlayerInfoByGUID(guid)` and `CastSpellNoToggle(spellId)`
|
||||
// (script/compat/classic_api/unit.rs). 1.12 has no verb that takes a GUID at all, and no
|
||||
// cast path that skips its own toggle — benilla's queue does not toggle, so the second is
|
||||
// the plain cast by id, the same reasoning nampower's `CastSpellNoQueue` carries.
|
||||
"CastSpellNoToggle",
|
||||
"GetPlayerInfoByGUID",
|
||||
"GetQuestIDForLogIndex",
|
||||
"GetQuestLinkForLogIndex",
|
||||
"CancelPlayerAuraSlot",
|
||||
|
||||
Reference in New Issue
Block a user