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:
2026-10-23 21:45:42 -06:00
co-authored by Claude Opus 5
parent 801bc09c05
commit de3a3411e7
4 changed files with 218 additions and 1 deletions
@@ -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();
+1 -1
View File
@@ -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",