app+ui+protocol: the four measured-out names, and the unit descriptor by name

Each of these had a reason for being out. Each reason turned out to be
answerable, and two of them were simply wrong.

`GetSpellPower(school)` is **delegated to `GetSpellBonusDamage`**, which
this client already answered — one number under two mods' names, as
SuperCleveRoidMacros says itself ("Reads the same client field
nampower's GetSpellPower does"). Delegating is the only way to guarantee
they cannot drift, and the test asserts equality across all seven
schools and the off-range values rather than a literal. The earlier
"needs a descriptor band fed" was wrong: it was already fed.

`GetRaidTargets()` is the 8-slot **board**, now pushed beside the party
roster. The earlier reason was right about the scan it rejected and
wrong about the alternative: `GroupState::raid_targets` is the
reference's own table and copying it costs eight words a push. A mark
with nothing on it is absent, because a zero GUID is a string that
passes `if t[4]`.

`LearnTalentRank` is one `LearnTalent` press per missing rank, which is
what a player clicking the button does. The earlier reason — the burst
cannot be checked before it is sent — is true and is not a reason to
withhold it: the check that matters is the server's and the client has
never had another. What can be checked locally is, so a rank already
held queues nothing and one past the maximum is clamped.

`GetUnitData` is the whole descriptor, from a new raw block on
`UnitState`. The earlier reason — "a decision about which fields" — is
now a table of seventy columns under the DLL's own names, read off its
string pool in UnitFields order; `GetUnitField`'s scalar half reads the
same block, so the two cannot answer differently for one name, and a
test walks the table asserting exactly that.

**The indices are derived and the derivation is checked.** Only thirteen
`FIELD_UNIT_*` constants existed here; the rest come from 1.12's block
sizes. Those thirteen span index 22 to 147 and every one lands where the
layout puts it — thirteen independent confirmations, run as a test
rather than asserted in a comment, with two more that no columns overlap
and that nothing falls inside the aura blocks' 47..=124.

Three decisions at that mapping:

- An unstreamed column is absent, not zero. `ObjectFields::at` is
  `get_raw`'s strictness rather than `get_u32`'s absent-is-zero
  convenience: `health = 0` for a unit whose health has not arrived is a
  number a caller divides by.
- `aura`/`auraApplications` come from the filtered per-unit aura list,
  not the raw slots — the same place `GetUnitField` has always answered
  them from. An unfiltered second answer would be a different unit by
  the same name.
- `status` and `zoneId` are left out of nampower's seven computed
  columns: nothing says what `status` is a status *of*, and a zone is
  only knowable for the player. The other five are filled by the caller,
  which knows them.

`SpellRecValue` is now `RecValue`: it serves both record readers, and a
second copy of the same four shapes would drift.

58 of 62 registered names. The four left are `DisenchantAll` and the
queue trio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-24 12:08:46 -06:00
co-authored by Claude Opus 5
parent e716709bca
commit b652c90a4b
15 changed files with 918 additions and 50 deletions
+4
View File
@@ -249,6 +249,10 @@ pub(super) fn feed_party(
leader_guid: group.leader,
own_guid: self_guid.unwrap_or(0),
raid,
// The board itself, for nampower's `GetRaidTargets`. Copied rather than derived from the
// members: a mark can sit on a mob nobody is grouped with, which is most of what marks
// are for.
raid_targets: group.raid_targets,
loot_method,
master_looter,
loot_threshold,
+1
View File
@@ -486,6 +486,7 @@ fn arbitrate_pointer_over_ui(
/// The session lifecycle: the VM's birth, identity, death and reload.
mod lifecycle;
mod spell_rec;
pub(crate) mod unit_rec;
pub(crate) use lifecycle::{
end_ui_session, ingame_ui_up, install_spell_extras_probes, install_spell_probe,
run_pending_reload, setup_script, AddOnIdentity, LeavingWorldArmed, PendingEntryUiLoad,
@@ -62,6 +62,7 @@ fn push_raid(s: &mut UiScript, raid: Vec<RaidMemberInfo>) {
})
.collect();
s.set_party(PartyState {
raid_targets: [0; 8],
members,
leader_index: 0,
leader_guid: 0, // the player leads; their guid is unset in this fixture
+22 -24
View File
@@ -13,20 +13,20 @@
//! differently for one name — `GetSpellRec` is [`FIELDS`] walked through [`field`], nothing more.
use benilla_formats::SpellDisplay;
use benilla_ui::script::SpellRecValue;
use benilla_ui::script::RecValue;
/// A three-element array as the 1-based Lua table every consumer walks:
/// `for i = 1, 3 do local em = effectMechanics[i]` (`SuperCleveRoidMacros/Utility.lua:7778`).
fn ints<T: Copy + Into<i64>>(a: &[T; 3]) -> SpellRecValue {
SpellRecValue::Ints(a.iter().map(|&v| v.into()).collect())
fn ints<T: Copy + Into<i64>>(a: &[T; 3]) -> RecValue {
RecValue::Ints(a.iter().map(|&v| v.into()).collect())
}
fn nums(a: &[f32; 3]) -> SpellRecValue {
SpellRecValue::Nums(a.iter().map(|&v| f64::from(v)).collect())
fn nums(a: &[f32; 3]) -> RecValue {
RecValue::Nums(a.iter().map(|&v| f64::from(v)).collect())
}
fn int(v: impl Into<i64>) -> Option<SpellRecValue> {
Some(SpellRecValue::Int(v.into()))
fn int(v: impl Into<i64>) -> Option<RecValue> {
Some(RecValue::Int(v.into()))
}
/// Every name this mapping answers, in `Spell.dbc` column order — which is also the key order
@@ -119,10 +119,10 @@ pub(crate) const FIELDS: &[&str] = &[
/// because `SuperCleveRoidMacros` asks by it (`GetSpellRecField(spellId, "rank")`); both answer
/// the same string. A spell with no rank line answers the empty string rather than nothing, as
/// the reference's own `GetSpellName` second return does.
pub(crate) fn field(d: &SpellDisplay, name: &str) -> Option<SpellRecValue> {
pub(crate) fn field(d: &SpellDisplay, name: &str) -> Option<RecValue> {
Some(match name {
"name" => SpellRecValue::Str(d.name.clone()),
"nameSubtext" | "rank" => SpellRecValue::Str(d.rank.clone().unwrap_or_default()),
"name" => RecValue::Str(d.name.clone()),
"nameSubtext" | "rank" => RecValue::Str(d.rank.clone().unwrap_or_default()),
"school" => return int(d.school),
"category" => return int(d.category),
"castUI" => return int(d.cast_ui),
@@ -154,17 +154,15 @@ pub(crate) fn field(d: &SpellDisplay, name: &str) -> Option<SpellRecValue> {
"manaCostPerlevel" => return int(d.mana_cost_per_level),
"manaPerSecond" => return int(d.mana_per_second),
"rangeIndex" => return int(d.range_index),
"speed" => SpellRecValue::Num(f64::from(d.speed)),
"speed" => RecValue::Num(f64::from(d.speed)),
"modalNextSpell" => return int(d.modal_next_spell),
"stackAmount" => return int(d.stack_amount),
// The two totem slots and the eight reagent pairs, as their own 1-based tables — the
// same shape the effect arrays take, since upstream's record holds all of them as C
// arrays and Lua has one way to spell an array.
"totem" => SpellRecValue::Ints(d.totems.iter().map(|&v| i64::from(v)).collect()),
"reagent" => SpellRecValue::Ints(d.reagents.iter().map(|&(id, _)| i64::from(id)).collect()),
"reagentCount" => {
SpellRecValue::Ints(d.reagents.iter().map(|&(_, n)| i64::from(n)).collect())
}
"totem" => RecValue::Ints(d.totems.iter().map(|&v| i64::from(v)).collect()),
"reagent" => RecValue::Ints(d.reagents.iter().map(|&(id, _)| i64::from(id)).collect()),
"reagentCount" => RecValue::Ints(d.reagents.iter().map(|&(_, n)| i64::from(n)).collect()),
"equippedItemClass" => return int(d.equipped_item_class),
"equippedItemSubClassMask" => return int(d.equipped_item_subclass_mask),
"equippedItemInventoryTypeMask" => return int(d.equipped_item_inventory_type_mask),
@@ -180,7 +178,7 @@ pub(crate) fn field(d: &SpellDisplay, name: &str) -> Option<SpellRecValue> {
// truncated so a hypothetical high bit would be visible as a wrong number rather than
// silently dropped.
#[allow(clippy::cast_possible_wrap)]
"spellFamilyFlags" => SpellRecValue::Int(d.spell_family_flags as i64),
"spellFamilyFlags" => RecValue::Int(d.spell_family_flags as i64),
"preventionType" => return int(d.prevention_type),
"stanceBarOrder" => return int(d.stance_bar_order),
"effect" => ints(&d.effects.map(|v| v as i64)),
@@ -251,20 +249,20 @@ mod tests {
};
assert_eq!(
field(&d, "effectMechanic"),
Some(SpellRecValue::Ints(vec![0, 12, 0]))
Some(RecValue::Ints(vec![0, 12, 0]))
);
assert_eq!(
field(&d, "effectApplyAuraName"),
Some(SpellRecValue::Ints(vec![3, 0, 0]))
Some(RecValue::Ints(vec![3, 0, 0]))
);
assert_eq!(
field(&d, "effectMiscValue"),
Some(SpellRecValue::Ints(vec![-1, 7, 0])),
Some(RecValue::Ints(vec![-1, 7, 0])),
"signed, and the sign survives"
);
assert_eq!(
field(&d, "effectRealPointsPerLevel"),
Some(SpellRecValue::Nums(vec![0.0, 1.5, 0.0]))
Some(RecValue::Nums(vec![0.0, 1.5, 0.0]))
);
// The reagent pair splits into two arrays of eight, ids and counts in slot order.
let d = SpellDisplay {
@@ -282,11 +280,11 @@ mod tests {
};
assert_eq!(
field(&d, "reagent"),
Some(SpellRecValue::Ints(vec![17_030, 0, 0, 0, 0, 0, 0, 0]))
Some(RecValue::Ints(vec![17_030, 0, 0, 0, 0, 0, 0, 0]))
);
assert_eq!(
field(&d, "reagentCount"),
Some(SpellRecValue::Ints(vec![1, 0, 0, 0, 0, 0, 0, 0]))
Some(RecValue::Ints(vec![1, 0, 0, 0, 0, 0, 0, 0]))
);
}
@@ -294,7 +292,7 @@ mod tests {
#[test]
fn a_rankless_spell_answers_an_empty_rank() {
let d = SpellDisplay::default();
assert_eq!(field(&d, "rank"), Some(SpellRecValue::Str(String::new())));
assert_eq!(field(&d, "rank"), Some(RecValue::Str(String::new())));
assert_eq!(
field(&d, "nameSubtext"),
field(&d, "rank"),
@@ -389,6 +389,7 @@ fn left_clicking_the_player_frame_targets_self() {
);
s.set_party(benilla_ui::script::PartyState {
raid_targets: [0; 8],
members: vec![benilla_ui::script::PartyMemberInfo {
name: "Alice".into(),
guid: 0xA11CE,
@@ -463,6 +464,7 @@ fn raid_mark_clicks_through_the_nested_level() {
);
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
s.set_party(benilla_ui::script::PartyState {
raid_targets: [0; 8],
members: vec![benilla_ui::script::PartyMemberInfo {
name: "Alice".into(),
guid: 0xA11CE,
@@ -1184,6 +1186,7 @@ fn the_party_art_paints_over_the_bars() {
// The roster shows a party row, not the unit: `PartyMemberFrame_UpdateMember` gates on
// `GetPartyMember(id)` and hides the row otherwise (`PartyMemberFrame.lua:42-57`).
s.set_party(benilla_ui::script::PartyState {
raid_targets: [0; 8],
members: vec![benilla_ui::script::PartyMemberInfo {
name: "Onepriest".into(),
guid: 0x0_0B12,
@@ -1813,6 +1816,7 @@ fn the_player_frame_wears_the_leader_and_master_looter_icons() {
assert!(!shown(&s, master), "solo: no master-looter icon");
let party = |leader_index: u32, master_looter: Option<u32>, method: &str| PartyState {
raid_targets: [0; 8],
members: vec![PartyMemberInfo {
name: "Brisca".into(),
guid: 0x7A17,
@@ -0,0 +1,308 @@
//! nampower's unit-descriptor readers: the name-to-field mapping behind `GetUnitData(unitToken)`
//! and `GetUnitField(unitToken, fieldName)`.
//!
//! **The names are the DLL's own**, read off its string pool (`0x92620`–`0x92a60`) in `UnitFields`
//! order, the same technique and the same kind of evidence as the spell record's
//! (`docs/ADDON_COMPAT.md`).
//!
//! **The indices are derived, and the derivation is checked.** Only thirteen `FIELD_UNIT_*`
//! constants existed in this tree; the rest come from 1.12's own `UnitFields` layout, where every
//! block's size is known. Those thirteen are spread across the whole block (22, 28, 34, 35, 46,
//! 47, 95, 113, 133, 138, 143, 144, 147) and every one of them lands exactly where the layout
//! puts it — thirteen independent confirmations, which is why this is a derivation and not a
//! guess. [`tests::the_derived_indices_agree_with_every_constant_the_tree_already_had`] is that
//! check, run rather than asserted in a comment.
//!
//! **Scalars and fixed arrays only.** The four aura blocks are *not* here: `GetUnitField` has
//! always answered `aura` and `auraApplications` from the filtered per-unit aura list, and
//! `GetUnitData` answers them the same way, so the two cannot disagree about which auras a unit
//! has. Reading the raw slots here as well would be a second, differently-filtered answer to one
//! question.
use benilla_protocol::ObjectFields;
use benilla_ui::script::RecValue;
/// `OBJECT_END`: every `UnitFields` index is relative to this.
const OBJECT_END: u16 = 6;
/// The 1.12 `UnitFields` block, as `(name, index, kind)` in field order — the key order
/// `GetUnitData`'s table is built in.
///
/// **What is deliberately absent**, beyond the aura blocks the module note explains: nampower's
/// own list ends with seven computed columns that are not descriptor fields at all — `status`,
/// `zoneId`, `posX`, `posY`, `guid`, `power`, `maxPower`. Five of those are added by
/// [`unit_fields`]'s caller because this client knows them (`ui_unit::raw_unit_fields`: the
/// object guid, the position pair, and the **active** power type's own slot). `status` and
/// `zoneId` are left out: nothing in the string pool or the corpus says what `status` is a status
/// *of*, and a zone is only knowable for the player — `GetZoneText` is that answer, and
/// inventing a per-unit one would be a number a caller could not tell was wrong.
///
/// Sizes are the layout's own: the seven leading GUID pairs are two dwords each, `power1..5` and
/// `maxPower1..5` five, the virtual-item slots three and six, `resistances` and the two power-cost
/// bands seven. The aura blocks occupy 47..=124 and are deliberately absent (see the module note).
const FIELDS: &[(&str, u16, Kind)] = &[
("charm", OBJECT_END, Kind::Guid),
("summon", OBJECT_END + 2, Kind::Guid),
("charmedBy", OBJECT_END + 4, Kind::Guid),
("summonedBy", OBJECT_END + 6, Kind::Guid),
("createdBy", OBJECT_END + 8, Kind::Guid),
// `UNIT_FIELD_TARGET`, which nampower's own name list does not carry — included because it is
// the one GUID column in this block an addon is most likely to want, and naming it costs
// nothing a caller asking for a name that upstream lacks would not already get as nil.
("target", OBJECT_END + 10, Kind::Guid),
("persuaded", OBJECT_END + 12, Kind::Guid),
("channelObject", OBJECT_END + 14, Kind::Guid),
("health", 22, Kind::Int),
("power1", 23, Kind::Int),
("power2", 24, Kind::Int),
("power3", 25, Kind::Int),
("power4", 26, Kind::Int),
("power5", 27, Kind::Int),
("maxHealth", 28, Kind::Int),
("maxPower1", 29, Kind::Int),
("maxPower2", 30, Kind::Int),
("maxPower3", 31, Kind::Int),
("maxPower4", 32, Kind::Int),
("maxPower5", 33, Kind::Int),
("level", 34, Kind::Int),
("factionTemplate", 35, Kind::Int),
("bytes0", 36, Kind::Int),
("virtualItemDisplay", 37, Kind::Ints(3)),
("virtualItemInfo", 40, Kind::Ints(6)),
("flags", 46, Kind::Int),
("auraState", 125, Kind::Int),
// `UNIT_FIELD_BASEATTACKTIME` is two dwords; upstream names them separately.
("baseAttackTime", 126, Kind::Int),
("offhandAttackTime", 127, Kind::Int),
("rangedAttackTime", 128, Kind::Int),
("boundingRadius", 129, Kind::Num),
("combatReach", 130, Kind::Num),
("displayId", 131, Kind::Int),
("nativeDisplayId", 132, Kind::Int),
("mountDisplayId", 133, Kind::Int),
("minDamage", 134, Kind::Num),
("maxDamage", 135, Kind::Num),
("minOffhandDamage", 136, Kind::Num),
("maxOffhandDamage", 137, Kind::Num),
("bytes1", 138, Kind::Int),
("petNumber", 139, Kind::Int),
("petNameTimestamp", 140, Kind::Int),
("petExperience", 141, Kind::Int),
("petNextLevelExp", 142, Kind::Int),
("dynamicFlags", 143, Kind::Int),
("channelSpell", 144, Kind::Int),
("modCastSpeed", 145, Kind::Num),
("createdBySpell", 146, Kind::Int),
("npcFlags", 147, Kind::Int),
("npcEmoteState", 148, Kind::Int),
("trainingPoints", 149, Kind::Int),
("stat0", 150, Kind::Int),
("stat1", 151, Kind::Int),
("stat2", 152, Kind::Int),
("stat3", 153, Kind::Int),
("stat4", 154, Kind::Int),
("resistances", 155, Kind::Ints(7)),
("baseMana", 162, Kind::Int),
("baseHealth", 163, Kind::Int),
("bytes2", 164, Kind::Int),
("attackPower", 165, Kind::Int),
("attackPowerMods", 166, Kind::Int),
("attackPowerMultiplier", 167, Kind::Num),
("rangedAttackPower", 168, Kind::Int),
("rangedAttackPowerMods", 169, Kind::Int),
("rangedAttackPowerMultiplier", 170, Kind::Num),
("minRangedDamage", 171, Kind::Num),
("maxRangedDamage", 172, Kind::Num),
("powerCostModifier", 173, Kind::Ints(7)),
("powerCostMultiplier", 180, Kind::Nums(7)),
];
/// How one column's dwords are read.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Kind {
/// One dword, as an integer.
Int,
/// One dword, as a float.
Num,
/// Two dwords, low then high, written as a GUID token.
Guid,
/// `n` dwords as a 1-based table of integers.
Ints(usize),
/// `n` dwords as a 1-based table of floats.
Nums(usize),
}
/// The key the caller fills with `OBJECT_FIELD_GUID`, which is not in the unit block.
pub(crate) const GUID_KEY: &str = "guid";
/// Read every column this mapping names off `fields`, as `(name, value)` pairs in field order.
///
/// A column the descriptor has not sent is **absent** rather than zero: an unstreamed field and a
/// field holding zero are different facts, and `GetUnitData` reporting `health = 0` for a unit
/// whose health has not arrived would be a number a caller acts on. Absence in a Lua table is nil,
/// which is what the caller's own guard already tests.
pub(crate) fn unit_fields(fields: &ObjectFields) -> Vec<(&'static str, RecValue)> {
let mut out = Vec::with_capacity(FIELDS.len() + 5);
for &(name, at, kind) in FIELDS {
let value = match kind {
Kind::Int => fields.at(at).map(|v| RecValue::Int(i64::from(v))),
Kind::Num => fields
.at(at)
.map(|v| RecValue::Num(f64::from(f32::from_bits(v)))),
Kind::Guid => match (fields.at(at), fields.at(at + 1)) {
// Both halves or neither: a guid assembled from one streamed dword is a guid that
// names nothing, and the zero guid is a real answer meaning "none".
(Some(lo), Some(hi)) => Some(RecValue::Str(benilla_ui::script::guid_token(
u64::from(lo) | u64::from(hi) << 32,
))),
_ => None,
},
Kind::Ints(n) => (0..n)
.map(|i| fields.at(at + i as u16).map(i64::from))
.collect::<Option<Vec<i64>>>()
.map(RecValue::Ints),
Kind::Nums(n) => (0..n)
.map(|i| {
fields
.at(at + i as u16)
.map(|v| f64::from(f32::from_bits(v)))
})
.collect::<Option<Vec<f64>>>()
.map(RecValue::Nums),
};
if let Some(value) = value {
out.push((name, value));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// The thirteen `FIELD_UNIT_*` constants this tree already had, against the derived layout.
///
/// This is the test that makes the rest of the table trustworthy. The indices in [`FIELDS`]
/// are derived from 1.12's `UnitFields` block sizes, not read off anything — and these
/// thirteen are spread from 22 to 147, so a derivation that got any block's size wrong would
/// put at least one of them in the wrong place.
#[test]
fn the_derived_indices_agree_with_every_constant_the_tree_already_had() {
use benilla_protocol::field as f;
let at = |name: &str| {
FIELDS
.iter()
.find(|(n, _, _)| *n == name)
.map(|(_, i, _)| *i)
.unwrap_or_else(|| panic!("{name} is not in FIELDS"))
};
for (name, known) in [
("health", f::FIELD_UNIT_HEALTH),
("maxHealth", f::FIELD_UNIT_MAXHEALTH),
("level", f::FIELD_UNIT_LEVEL),
("factionTemplate", f::FIELD_UNIT_FACTIONTEMPLATE),
("flags", f::FIELD_UNIT_FLAGS),
("mountDisplayId", f::FIELD_UNIT_MOUNTDISPLAYID),
("bytes1", f::FIELD_UNIT_BYTES_1),
("dynamicFlags", f::FIELD_UNIT_DYNAMIC_FLAGS),
("channelSpell", f::FIELD_UNIT_CHANNEL_SPELL),
("npcFlags", f::FIELD_UNIT_NPC_FLAGS),
] {
assert_eq!(at(name), known, "{name}");
}
// The three aura blocks bracket the gap this table leaves: nothing in FIELDS may fall
// inside 47..=124, which is where they live.
assert_eq!(f::FIELD_UNIT_AURA, 47);
assert_eq!(f::FIELD_UNIT_AURAFLAGS, 95);
assert_eq!(f::FIELD_UNIT_AURAAPPLICATIONS, 113);
for &(name, at, kind) in FIELDS {
let span = match kind {
Kind::Int | Kind::Num => 1u16,
Kind::Guid => 2,
Kind::Ints(n) | Kind::Nums(n) => n as u16,
};
for i in at..at + span {
assert!(
!(47..=124).contains(&i),
"{name} overlaps the aura blocks at index {i}"
);
}
}
// And no two columns overlap, which a hand-written offset table is exactly the thing to
// get wrong.
let mut claimed: Vec<(u16, &str)> = Vec::new();
for &(name, at, kind) in FIELDS {
let span = match kind {
Kind::Int | Kind::Num => 1u16,
Kind::Guid => 2,
Kind::Ints(n) | Kind::Nums(n) => n as u16,
};
for i in at..at + span {
if let Some((_, other)) = claimed.iter().find(|(j, _)| *j == i) {
panic!("{name} and {other} both claim index {i}");
}
claimed.push((i, name));
}
}
// The block ends where 1.12's `UNIT_END` does, bar the last modifier field nothing names.
let last = claimed.iter().map(|(i, _)| *i).max().expect("fields");
assert_eq!(last, 186, "the last named column is powerCostMultiplier[6]");
}
/// An unstreamed column is **absent**, not zero — and a GUID needs both its halves.
#[test]
fn an_unstreamed_column_is_absent_and_a_guid_needs_both_halves() {
use benilla_protocol::field as f;
let fields = ObjectFields::from_pairs(&[
(f::FIELD_UNIT_HEALTH, 4321),
(f::FIELD_UNIT_LEVEL, 60),
(129, 1.5f32.to_bits()), // boundingRadius, a real float
(OBJECT_END + 10, 0xdead), // target's low half only
]);
let got = unit_fields(&fields);
let find = |name: &str| got.iter().find(|(n, _)| *n == name).map(|(_, v)| v.clone());
assert_eq!(find("health"), Some(RecValue::Int(4321)));
assert_eq!(find("level"), Some(RecValue::Int(60)));
assert_eq!(
find("boundingRadius"),
Some(RecValue::Num(1.5)),
"a float column is read as a float, not as its bit pattern"
);
assert_eq!(
find("maxHealth"),
None,
"unstreamed is absent — `maxHealth = 0` is a number a caller would divide by"
);
assert_eq!(
find("target"),
None,
"one half of a GUID names nothing, so neither half is reported"
);
// A fully streamed GUID pair, including the zero that really means "none".
let fields = ObjectFields::from_pairs(&[
(OBJECT_END + 10, 0xdead),
(OBJECT_END + 11, 0xf130),
(OBJECT_END, 0),
(OBJECT_END + 1, 0),
]);
let got = unit_fields(&fields);
let find = |name: &str| got.iter().find(|(n, _)| *n == name).map(|(_, v)| v.clone());
assert_eq!(
find("target"),
Some(RecValue::Str(benilla_ui::script::guid_token(
// The high dword is 0xf130, so the guid is 0x0000_f130_0000_dead — the halves
// join at 32 bits, which is the arithmetic this row exists to pin.
0x0000_f130_0000_dead
)))
);
assert_eq!(
find("charm"),
Some(RecValue::Str(benilla_ui::script::guid_token(0))),
"a streamed zero guid is a real answer: nothing is charming this unit"
);
}
}
+51
View File
@@ -876,6 +876,52 @@ pub(crate) fn unit_reaction(
/// Build a unit snapshot from a streamed descriptor, its cached name and its `UnitReaction`
/// (`1..=7`, or `0` where none is resolved, as for `"player"`); `classes` feeds the relic column.
/// The descriptor columns [`UnitState::fields`] carries: the unit block
/// (`ui_script/unit_rec.rs`) plus the four this client knows and that block does not.
///
/// The four: `guid` (`OBJECT_FIELD_GUID`, an object column rather than a unit one), `posX`/`posY`
/// (movement, which the descriptor never carries) and `power`/`maxPower` — the **active** power
/// type's own slot, which upstream lists among its computed columns and which saves a caller
/// reading `bytes0` to learn which of `power1..5` to look at.
///
/// `status` and `zoneId`, upstream's other two computed columns, are deliberately absent:
/// nothing in the string pool or the corpus says what `status` is a status *of*, and a zone is
/// only knowable for the player (`GetZoneText` is that answer). `unit_rec.rs`'s own doc carries
/// the same list.
fn raw_unit_fields(
store: &ObjectStore,
position: Option<(f32, f32, f32)>,
) -> Vec<(&'static str, benilla_ui::script::RecValue)> {
use benilla_ui::script::RecValue;
let mut out = crate::ui_script::unit_rec::unit_fields(&store.0);
// `OBJECT_FIELD_GUID` is index 0/1, the one column every object type carries. Both halves
// or neither, as `unit_rec`'s own GUID columns are read.
if let (Some(lo), Some(hi)) = (store.0.at(0), store.0.at(1)) {
out.push((
crate::ui_script::unit_rec::GUID_KEY,
RecValue::Str(benilla_ui::script::guid_token(
u64::from(lo) | u64::from(hi) << 32,
)),
));
}
// The active power type's slot, 0..=4 as `UNIT_FIELD_BYTES_0`'s power byte numbers them.
let slot = u16::from(store.0.unit_power_type());
if slot < 5 {
if let Some(v) = store.0.at(23 + slot) {
out.push(("power", RecValue::Int(i64::from(v))));
}
if let Some(v) = store.0.at(29 + slot) {
out.push(("maxPower", RecValue::Int(i64::from(v))));
}
}
if let Some((x, y, _)) = position {
out.push(("posX", RecValue::Num(f64::from(x))));
out.push(("posY", RecValue::Num(f64::from(y))));
}
out
}
pub(crate) fn snapshot(
store: &ObjectStore,
name: Option<String>,
@@ -888,6 +934,11 @@ pub(crate) fn snapshot(
let class_id = store.0.unit_class();
let class = class_id.and_then(class_names);
UnitState {
// nampower's raw descriptor columns under its own names, for `GetUnitData` and
// `GetUnitField` (`ui_script/unit_rec.rs`). Built here because this is the one place a
// token's `ObjectStore` and its resolved snapshot are both in hand, and because a column
// being *absent* has to mean "the wire has not sent it" — which only the store knows.
fields: raw_unit_fields(store, position),
exists: true,
// A live descriptor is `0x468460` having succeeded, all of `UnitIsVisible` (`0x516030`).
has_object: true,
@@ -586,6 +586,18 @@ impl ObjectFields {
.is_some_and(|w| w & (1u32 << (index % 32)) != 0)
}
/// One field's raw dword by index, `None` for a field the wire has not sent.
///
/// The generic reader behind nampower's by-name descriptor verbs (`benilla-app`'s
/// `ui_script/unit_rec.rs`): a mapping from a field *name* to an index needs this, where
/// every other reader here is a named accessor for one column. **`get_raw`'s strictness, not
/// `get_u32`'s**: absent and zero are different facts to a caller asking by name, and
/// `get_u32`'s absent-is-zero-on-a-created-store convenience would report a health of 0 for a
/// unit whose health has not arrived.
pub fn at(&self, index: u16) -> Option<u32> {
self.get_raw(index)
}
fn get_raw(&self, index: u16) -> Option<u32> {
self.contains(index)
.then(|| self.values[usize::from(index)])
+446 -7
View File
@@ -169,10 +169,10 @@ fn equipped_table(lua: &Lua, view: &crate::script::InvSlotView) -> mlua::Result<
Ok(t)
}
/// One [`crate::script::SpellRecValue`] as Lua sees it: a number or a string, or a 1-based table
/// One [`crate::script::RecValue`] as Lua sees it: a number or a string, or a 1-based table
/// for an array column.
fn spell_rec_value(lua: &Lua, value: crate::script::SpellRecValue) -> mlua::Result<Value> {
use crate::script::SpellRecValue as V;
fn spell_rec_value(lua: &Lua, value: crate::script::RecValue) -> mlua::Result<Value> {
use crate::script::RecValue as V;
Ok(match value {
V::Int(n) => Value::Integer(n),
V::Num(n) => Value::Number(n),
@@ -497,6 +497,78 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// (`Utility.lua:78-83`) comments it "via Nampower GetUnitGUID (v3.0+)" and documents
// "Returns: normalized GUID string, or nil". Its absence was 11 raises in one session — that
// helper is on the macro-resolution path, so every affected macro press raised.
// `GetUnitData(unitToken, [copy])` -> the unit's whole descriptor as a table, or nil for a
// unit this client holds no snapshot for.
//
// The scalar columns are the raw block `benilla-app`'s `ui_script/unit_rec.rs` fills, under
// nampower's own names, read off its string pool in `UnitFields` order — and the indices are
// *derived* from 1.12's block sizes, checked against the thirteen `FIELD_UNIT_*` constants
// this tree already had (that module's own test).
//
// **`aura` and `auraApplications` come from the same place `GetUnitField` answers them
// from**, which is the filtered per-unit aura list and not the raw slots. That is deliberate
// in both directions: the two verbs must not disagree about which auras a unit has, and the
// filter drops exactly the auras a tracker does not want (hidden-from-the-aura-bar and
// tracking auras — see `GetUnitField`'s own fidelity note). An unfiltered second answer here
// would be a different unit, by the same name.
//
// A column the wire has not sent is **absent**, which in a Lua table is nil: unstreamed and
// zero are different facts, and `health = 0` for a unit whose health has not arrived is a
// number a caller divides by. `copy` is accepted and ignored, as `GetSpellRec`'s is — every
// table built here is already fresh.
lua.globals().set(
"GetUnitData",
lua.create_function(|lua, (token, _copy): (Option<String>, Option<Value>)| {
let (rows, auras) = {
let model = lua
.app_data_ref::<crate::script::Model>()
.expect("model app_data");
let Some(token) = token else {
return Ok(Value::Nil);
};
let Some(unit) = model.unit(&token) else {
return Ok(Value::Nil);
};
// The same token-then-GUID fold `GetUnitField` makes, so one call that works
// there works here.
let auras = model
.auras
.get(&token)
.or_else(|| {
(unit.guid != 0)
.then(|| model.token_for_guid(unit.guid))
.flatten()
.and_then(|t| model.auras.get(t))
})
.map(|list| {
list.iter()
.map(|a| (i64::from(a.slot) + 1, a.spell_id, a.count))
.collect::<Vec<_>>()
})
.unwrap_or_default();
(unit.fields.clone(), auras)
};
let t = lua.create_table()?;
for (name, value) in rows {
t.set(name, spell_rec_value(lua, value)?)?;
}
// Both aura tables are always present, empty for a unit carrying none: the reference
// reads a descriptor block that exists for every streamed unit and is simply full of
// zeros, so "no auras" is an empty answer and not a missing one — the same reasoning
// `GetUnitField` carries, and the same raise it was fixed for.
let (ids, apps) = (lua.create_table()?, lua.create_table()?);
for (slot, spell_id, count) in auras {
ids.set(slot, spell_id)?;
// The wire's own encoding: the byte is `stacks - 1`, which pfUI reads back as
// `+ 1`. Reporting `stacks` would show every debuff one stack too many.
apps.set(slot, i64::from(count.saturating_sub(1)))?;
}
t.set("aura", ids)?;
t.set("auraApplications", apps)?;
Ok(Value::Table(t))
})?,
)?;
// GetUnitField(unit, field) → a table keyed by **1-based aura slot**. nampower v3+'s window
// onto the raw `UNIT_FIELD_AURA` block, and the reason it exists is that those slots are
// STABLE: a 1.12 getter walks the compacted, filtered list, so every index shifts the moment
@@ -535,9 +607,27 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
let applications = match field.as_str() {
"aura" => false,
"auraApplications" => true,
// Any other field: nothing, rather than an empty table that would read as
// "this unit has no auras" to a caller that checked the wrong name.
_ => return Ok(mlua::Value::Nil),
// Every other name is a scalar descriptor column, answered from the raw block
// `benilla-app`'s `ui_script/unit_rec.rs` fills — the same block `GetUnitData`
// builds its whole table from, so the two cannot answer differently for one
// name. A name that block does not carry is nil, which is both an unstreamed
// column and a name upstream has that this client does not (that module's own
// doc lists them).
other => {
let model = lua
.app_data_ref::<crate::script::Model>()
.expect("model app_data");
let found = token.as_deref().and_then(|t| model.unit(t)).and_then(|u| {
u.fields
.iter()
.find(|(n, _)| *n == other)
.map(|(_, v)| v.clone())
});
return match found {
Some(value) => spell_rec_value(lua, value),
None => Ok(mlua::Value::Nil),
};
}
};
let model = lua
.app_data_ref::<crate::script::Model>()
@@ -808,6 +898,98 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals()
.set("CombatLogFlush", lua.create_function(|_, ()| Ok(()))?)?;
// `GetSpellPower(school)` -> that school's flat spell-damage bonus, 1-based (1 physical,
// 2 holy, 3 fire, 4 nature, 5 frost, 6 shadow, 7 arcane).
//
// **Delegated to `GetSpellBonusDamage`, which this client already answers**, rather than
// reading the same descriptor pair a second time. They are one number under two mods' names —
// `SuperCleveRoidMacros` says so itself ("Reads the same client field nampower's
// `GetSpellPower` does", `ClassicAPI.lua:272`) — and delegating is the only way to guarantee
// they cannot drift. Off-range answers 0 rather than raising, which is that verb's documented
// shape and what pfUI's `GetSpellBonusDamage(i) or 0` already assumes.
lua.globals().set(
"GetSpellPower",
lua.create_function(|lua, school: Option<i64>| {
let bonus: mlua::Function = lua.globals().get("GetSpellBonusDamage")?;
bonus.call::<i64>(school)
})?,
)?;
// `GetRaidTargets()` -> the 8-slot raid-target board as `{ [mark] = guidToken }`, a mark with
// nothing on it absent.
//
// **The board, not a scan of named units.** `GetRaidTargetIndex(unit)` answers the other
// direction and can only report a mark on a unit some token already names; this is the
// reference's own table, so a skull on a mob nobody has targeted is in it — which is most of
// what marks are for.
//
// Absent rather than a zero GUID for an empty mark: a caller walks this with `pairs` or tests
// `t[8]`, and `"0x0000000000000000"` is a string that passes both. The GUID spelling is the
// one every other `compat/` verb writes, so a mark can be compared against a `UnitGUID`
// return without converting.
lua.globals().set(
"GetRaidTargets",
lua.create_function(|lua, ()| {
let board = {
let model = lua
.app_data_ref::<crate::script::Model>()
.expect("model app_data");
model.party.raid_targets
};
let t = lua.create_table()?;
for (i, guid) in board.into_iter().enumerate() {
if guid != 0 {
t.set(i as i64 + 1, crate::script::guid_token(guid))?;
}
}
Ok(t)
})?,
)?;
// `LearnTalentRank(talentPage, talentIndex, rank)`: learn that talent up to `rank`.
//
// 1.12 has only `LearnTalent(tab, index)`, which learns the **next** rank — the server reads
// the current one and adds one — so reaching a named rank means pressing it once per missing
// rank. That is exactly what a player clicking the button does, and the server's own rules
// (points available, prerequisites, the tier gate) apply to each press the same way.
//
// **The earlier reason for leaving this out was that the burst cannot be checked before it is
// sent**, which is true and is not a reason to withhold it: the check that matters is the
// server's, the client has never had another, and a refused press costs a red line. What this
// *can* check locally is cheaper and worth doing — the talent's own current and maximum rank
// are in the fed snapshot, so a call that asks for a rank already held queues nothing, and one
// that asks past the maximum is clamped to it rather than sending presses the server will
// refuse.
//
// No return value, as `LearnTalent` has none.
lua.globals().set(
"LearnTalentRank",
lua.create_function(
|lua, (page, index, rank): (Option<u32>, Option<u32>, Option<u32>)| {
let (Some(page), Some(index), Some(rank)) = (page, index, rank) else {
return Ok(());
};
let mut model = lua
.app_data_mut::<crate::script::Model>()
.expect("model app_data");
// The talent's own row, which is 1-based on both axes as `GetTalentInfo` is.
let found = page
.checked_sub(1)
.and_then(|t| model.talents.talents.get(t as usize))
.and_then(|rows| index.checked_sub(1).and_then(|i| rows.get(i as usize)))
.map(|t| (t.rank, t.max_rank));
let Some((have, max)) = found else {
return Ok(());
};
let want = rank.min(max);
for _ in have..want {
model.talent_learns.push((page, index));
}
Ok(())
},
)?,
)?;
// ── nampower's credential pair ────────────────────────────────────────────────────────────
//
// `EncryptPassword(password)` and `EncryptedServerLogin(username, encryptedPasswordBase64String)`.
@@ -3227,6 +3409,263 @@ 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
/// `GetUnitData` and `GetUnitField`'s scalar half, over one fed block — asserted together,
/// because the point of building them off one block is that they cannot disagree.
#[test]
fn the_unit_record_verbs_read_one_block_and_agree_name_by_name() {
use crate::script::RecValue as V;
let mut s = UiScript::new().unwrap();
// No unit: both verbs are nil, and neither raises.
assert!(s
.eval::<Option<mlua::Value>>(r#"return GetUnitData("target")"#)
.unwrap()
.is_none_or(|v| v.is_nil()));
assert!(s
.eval::<Option<mlua::Value>>(r#"return GetUnitField("target", "health")"#)
.unwrap()
.is_none_or(|v| v.is_nil()));
s.set_unit(
"target",
Some(crate::script::UnitState {
exists: true,
has_object: true,
guid: 0xf130_0000_4500_002a,
name: Some("Kobold Geomancer".into()),
fields: vec![
("health", V::Int(410)),
("maxHealth", V::Int(410)),
("level", V::Int(10)),
("flags", V::Int(0x0000_0008)),
("boundingRadius", V::Num(0.3)),
("target", V::Str(crate::script::guid_token(0x2a))),
("resistances", V::Ints(vec![30, 0, 0, 0, 0, 0, 0])),
("powerCostMultiplier", V::Nums(vec![1.0; 7])),
],
..Default::default()
}),
);
// Every shape, by name: an integer, a float, a GUID token, and two 1-based arrays.
assert_eq!(
s.eval::<(i64, i64, f64, String)>(
r#"local d = GetUnitData("target")
return d.health, d.level, d.boundingRadius, d.target"#
)
.unwrap(),
(410, 10, 0.3, crate::script::guid_token(0x2a))
);
assert_eq!(
s.eval::<(i64, f64, i64)>(
r#"local d = GetUnitData("target")
return d.resistances[1], d.powerCostMultiplier[3],
table.getn(d.resistances)"#
)
.unwrap(),
(30, 1.0, 7),
"an array column is a 1-based table of its own length"
);
// The two verbs agree for every scalar name the block carries. This is the property.
assert!(
s.eval::<bool>(
r#"local d = GetUnitData("target")
for k, v in pairs(d) do
if type(v) ~= "table" and v ~= GetUnitField("target", k) then return false end
end
return true"#
)
.unwrap(),
"GetUnitField must answer what GetUnitData's table holds, name by name"
);
// A name the block does not carry is nil from both — an unstreamed column and a name
// upstream has that this client does not read the same way, which is the honest answer to
// "I cannot tell you".
for name in ["npcFlags", "status", "zoneId", "notAColumn"] {
assert!(
s.eval::<Option<mlua::Value>>(&format!(
r#"return GetUnitField("target", "{name}")"#
))
.unwrap()
.is_none_or(|v| v.is_nil()),
"{name}"
);
assert!(
s.eval::<Option<mlua::Value>>(&format!(r#"return GetUnitData("target").{name}"#))
.unwrap()
.is_none_or(|v| v.is_nil()),
"{name} through the table"
);
}
// The two aura tables are always present and empty for a unit carrying none — the
// distinction DoiteAuras raised on, which it indexes straight into.
assert_eq!(
s.eval::<(i64, i64)>(
r#"local d = GetUnitData("target")
local n, m = 0, 0
for _ in pairs(d.aura) do n = n + 1 end
for _ in pairs(d.auraApplications) do m = m + 1 end
return n, m"#
)
.unwrap(),
(0, 0),
"empty tables, not nil"
);
// With auras, both tables are keyed by 1-based slot and the stack count is the wire's
// own `stacks - 1`, the same encoding `GetUnitField` reports.
s.set_auras(
"target",
Some(vec![crate::script::AuraState {
slot: 32,
spell_id: 589,
count: 3,
..Default::default()
}]),
);
assert_eq!(
s.eval::<(i64, i64)>(
r#"local d = GetUnitData("target")
return d.aura[33], d.auraApplications[33]"#
)
.unwrap(),
(589, 2),
"slot 32 is Lua slot 33, and three stacks ride the wire as two"
);
assert_eq!(
s.eval::<(i64, i64)>(
r#"return GetUnitField("target", "aura")[33],
GetUnitField("target", "auraApplications")[33]"#
)
.unwrap(),
(589, 2),
"and the per-name verb answers the same two numbers"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `GetSpellPower`, `GetRaidTargets` and `LearnTalentRank` — one delegation, one board read
/// and one action that has to count.
#[test]
fn the_spell_power_alias_the_mark_board_and_the_talent_rank() {
let mut s = UiScript::new().unwrap();
// `GetSpellPower` IS `GetSpellBonusDamage`, which is the point: asserted as equality
// across all seven schools and the off-range values, so the two cannot drift.
s.set_player_combat_stats(Some(crate::script::UnitCombatStats {
// [0] physical, then holy, fire, nature, frost, shadow, arcane.
damage_bonus_pos: [12, 0, 90, 0, 45, 90, 90],
damage_bonus_neg: [0, 0, -10, 0, 0, 0, 0],
..Default::default()
}));
for school in [0i64, 1, 2, 3, 4, 5, 6, 7, 8, -1] {
assert_eq!(
s.eval::<i64>(&format!("return GetSpellPower({school})"))
.unwrap(),
s.eval::<i64>(&format!("return GetSpellBonusDamage({school})"))
.unwrap(),
"school {school}: the two names must be one number"
);
}
assert_eq!(
s.eval::<i64>("return GetSpellPower(3)").unwrap(),
80,
"fire: the pos and neg fields summed, as `UnitDamage` sums them for physical"
);
assert_eq!(
s.eval::<i64>("return GetSpellPower(8)").unwrap(),
0,
"off-range is 0, not a raise — pfUI's `or 0` never has to catch one"
);
// `GetRaidTargets`: the board, with an empty mark **absent** rather than a zero GUID.
assert_eq!(
s.eval::<i64>("local n = 0 for _ in pairs(GetRaidTargets()) do n = n + 1 end return n")
.unwrap(),
0,
"no marks is an empty table, never nil"
);
let mut party = crate::script::PartyState::default();
party.raid_targets[7] = 0xdead; // skull
party.raid_targets[0] = 0xbeef; // star
s.set_party(party);
assert_eq!(
s.eval::<(String, String)>("local t = GetRaidTargets() return t[8], t[1]")
.unwrap(),
(
crate::script::guid_token(0xdead),
crate::script::guid_token(0xbeef)
),
"skull is mark 8 and star is mark 1, in the GUID spelling every compat verb writes"
);
assert!(
s.eval::<Option<String>>("return GetRaidTargets()[4]")
.unwrap()
.is_none(),
"an unmarked slot is absent — a zero GUID would be a string that passes `if t[4]`"
);
// And the board really is the board: a mark on a unit no token names is still in it,
// which is what `GetRaidTargetIndex` cannot do.
assert!(
s.eval::<Option<i64>>(r#"return GetRaidTargetIndex("target")"#)
.unwrap()
.is_none(),
"nothing is targeted, so the per-unit verb has nothing to say about 0xdead — which \
is exactly why the board verb exists"
);
// `LearnTalentRank`: one press per missing rank, clamped to the maximum, nothing at all
// for a rank already held.
s.set_talents(crate::script::TalentUiState {
tabs: vec![crate::script::TalentTabView::default()],
talents: vec![vec![
crate::script::TalentView {
rank: 2,
max_rank: 5,
..Default::default()
},
crate::script::TalentView {
rank: 3,
max_rank: 3,
..Default::default()
},
]],
points: (10, 0),
});
s.eval::<()>("LearnTalentRank(1, 1, 4)").unwrap();
assert_eq!(
s.take_talent_learns(),
vec![(1, 1), (1, 1)],
"rank 2 to rank 4 is two presses, because `LearnTalent` learns the NEXT rank"
);
s.eval::<()>("LearnTalentRank(1, 1, 99)").unwrap();
assert_eq!(
s.take_talent_learns().len(),
3,
"clamped to max_rank 5: three presses from rank 2, not ninety-seven"
);
for call in [
"LearnTalentRank(1, 1, 2)", // already at rank 2
"LearnTalentRank(1, 2, 3)", // already maxed
"LearnTalentRank(1, 2, 1)", // asking for less than it has
"LearnTalentRank(9, 1, 5)", // no such page
"LearnTalentRank(1, 9, 5)", // no such talent
"LearnTalentRank(0, 0, 5)", // 1-based on both axes
"LearnTalentRank(1, 1)", // no rank
"LearnTalentRank()",
] {
s.eval::<()>(call).unwrap();
assert!(
s.take_talent_learns().is_empty(),
"{call} must queue nothing"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The custom-file family, through a handle that stands in for the app's fenced directory.
///
/// The harness records every `(name, mode)` the verbs ask for, so what is asserted is **what
@@ -3771,7 +4210,7 @@ mod tests {
/// array columns are read the way their one consumer reads them: `for i = 1, 3`.
#[test]
fn the_spell_record_verbs_answer_every_column_the_corpus_asks_for() {
use crate::script::SpellRecValue as V;
use crate::script::RecValue as V;
let mut s = UiScript::new().unwrap();
let row: Vec<(&'static str, V)> = vec![
("name", V::Str("Fireball".into())),
+2 -2
View File
@@ -243,9 +243,9 @@ pub use model::{
RaceInfo,
};
pub use model::{
CustomFileAccess, CustomFileMode, PasswordEncryptProbe, PlayerCacheEntry, SelfCast,
CustomFileAccess, CustomFileMode, PasswordEncryptProbe, PlayerCacheEntry, RecValue, SelfCast,
SelfMovement, ServerClock, SpellModifierProbe, SpellRangeProbe, SpellRecFieldProbe,
SpellRecValue, SpellRecordProbe,
SpellRecordProbe,
};
pub use model::{
FontProbe, ItemIconProbe, SpellDbcExtras, SpellIconProbe, SpellLookup, SpellMechanicProbe,
+8 -5
View File
@@ -113,14 +113,17 @@ pub type CustomFileProbe = Box<dyn Fn(&str) -> bool>;
/// 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.
/// One column of a record as nampower's by-name readers hand it to Lua — `GetSpellRec` /
/// `GetSpellRecField` over `Spell.dbc`, and `GetUnitData` / `GetUnitField` over the unit
/// descriptor. One type for both, because the two families answer the same shapes and a second
/// copy would drift.
///
/// The array variants are a **1-based Lua table of three**, which is not a guess:
/// The array variants are a **1-based Lua table**, which for the spell record is not a guess:
/// `SuperCleveRoidMacros` walks `GetSpellRecField(spellID, "effectMechanic")` as
/// `for i = 1, 3 do local em = effectMechanics[i]` (`Utility.lua:7776-7794`), and does the same
/// with `effectApplyAuraName`.
#[derive(Clone, Debug, PartialEq)]
pub enum SpellRecValue {
pub enum RecValue {
Int(i64),
Num(f64),
Str(String),
@@ -134,12 +137,12 @@ pub enum SpellRecValue {
/// **The name-to-column mapping lives in `benilla-app`,** beside the `SpellDisplay` it reads: this
/// is a mapping from upstream's spelling to that struct's fields, so it belongs where that struct
/// is, and a copy here would be free to drift from it.
pub type SpellRecFieldProbe = Box<dyn Fn(u32, &str) -> Option<SpellRecValue>>;
pub type SpellRecFieldProbe = Box<dyn Fn(u32, &str) -> Option<RecValue>>;
/// The whole row as `(name, value)` pairs in `Spell.dbc` column order, for `GetSpellRec`. Built
/// by walking the same field list through the same per-field function the probe above uses, so
/// the two verbs cannot answer differently for one name.
pub type SpellRecordProbe = Box<dyn Fn(u32) -> Option<Vec<(&'static str, SpellRecValue)>>>;
pub type SpellRecordProbe = Box<dyn Fn(u32) -> Option<Vec<(&'static str, RecValue)>>>;
/// 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
+10
View File
@@ -88,6 +88,14 @@ pub struct PartyState {
/// the player, as the reference's array does, so `UnitInRaid("player")` answers `1`;
/// `GetNumRaidMembers()` is its length.
pub raid: Vec<RaidMemberInfo>,
/// The 8-slot raid-target board by mark index, `0` for an unmarked slot: nampower's
/// `GetRaidTargets()`.
///
/// The **board**, not a per-unit mark. `UnitState::raid_target` answers
/// `GetRaidTargetIndex(unit)` and is fed per unit, which can only report a mark on a unit
/// some token already names — this is the reference's own table
/// (`GroupState::raid_targets`), so a skull on a mob nothing has targeted is in it.
pub raid_targets: [u64; 8],
/// `GetLootMethod`'s method: `"freeforall"`, `"roundrobin"`, `"master"`, `"group"` or
/// `"needbeforegreed"`. Empty, the default, reads as `"freeforall"`, the reference's answer
/// before any group.
@@ -802,6 +810,7 @@ mod tests {
leader_guid: 0xA11CE,
own_guid: 0x5E1F,
raid: Vec::new(),
raid_targets: [0; 8],
loot_method: "group".into(),
master_looter: None,
loot_threshold: 2,
@@ -1471,6 +1480,7 @@ mod tests {
..Default::default()
};
PartyState {
raid_targets: [0; 8],
members: vec![
PartyMemberInfo {
name: "Alice".into(),
@@ -124,6 +124,10 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
"StartItemExport",
"EncryptPassword",
"EncryptedServerLogin",
"GetSpellPower",
"GetRaidTargets",
"LearnTalentRank",
"GetUnitData",
// 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.
+15
View File
@@ -83,6 +83,21 @@ pub struct PlayerRecord {
/// plain data, with no mlua handles or ECS types.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct UnitState {
/// The unit's raw descriptor columns under nampower's own names, for `GetUnitData(unitToken)`
/// and `GetUnitField(unitToken, fieldName)`.
///
/// **Raw, where every other field here is resolved.** The rest of this struct is what a 1.12
/// verb answers — a localized class string, a folded reaction, a filtered aura list — and
/// those are the right answers for the verbs that read them. These are the descriptor's own
/// dwords, which is what an addon asking by `UNIT_FIELD` name wants, and the two are not
/// interchangeable: `unitFlags` is a bitfield no stock verb exposes, and `health` here is the
/// field rather than `UnitHealth`'s own clamping.
///
/// The name-to-index mapping is `benilla-app`'s `ui_script/unit_rec.rs`, beside the
/// `ObjectFields` it reads — the same place and the same reason as the spell record's.
/// Scalars only: the four aura blocks are answered from the filtered per-unit aura list
/// `GetUnitField` has always used, so the two cannot disagree about which auras a unit has.
pub fields: Vec<(&'static str, super::RecValue)>,
/// `UnitExists`, true for an out-of-range party member through the roster fallback. The
/// reference (`0x515fb0`) also requires a held object to be selectable before that fallback
/// (`0x60be60`: `UNIT_FIELD_FLAGS & 0x02000000` clear, or created by us); that is not built.
+30 -12
View File
@@ -845,15 +845,34 @@ Two also worth recording:
it, so that postcondition already holds at all times. Defining it is the honest answer: the
capability is present, and a missing global makes `HasFeature` report it absent when it is not.
**Still out, now with the field lists that make each a decision rather than a shrug:**
**The four measured-out names — built too, on grom's "62/62".** Each had a reason for being out;
each reason turned out to be answerable, and where it was not the deviation is stated.
| Name | Why not, and what it would take |
| Name | What it took, and what it cost |
|---|---|
| `GetUnitData(unitToken, [copy])` / `GetUnitField`'s full surface | The names are read: 80 of them at `0x92620` — `charm summon charmedBy summonedBy createdBy persuaded channelObject health power1..5 maxHealth maxPower1..5 level factionTemplate bytes0 auraState baseAttackTime … aura auraFlags auraLevels auraApplications powerCostModifier powerCostMultiplier`, plus five computed extras (`guid status zoneId posX posY`). They are the **raw descriptor**, which lives app-side in `ObjectStore` and not in the VM's resolved `UnitState`, so the full surface is a live per-unit feed. `GetUnitField` already answers the only two names anything reads (`aura`, `auraApplications`) and `GetUnitData` has **zero** corpus call sites, so the feed would be built for nothing. The partial alternative is worse: a caller reading `bytes0` and getting nil while `health` works cannot tell which names are real |
| `GetRaidTargets()` | The reference keeps a raid-target **board** (`0xb712a8`); this client feeds the mark per unit (`UnitState::raid_target`), which `GetRaidTargetIndex` answers from. A scan over currently-named tokens would silently omit a marked unit no token names — plausible and wrong, which is worse than absent. Zero corpus call sites |
| `DisenchantAll(itemIdOrName \| quality, [includeSoulbound])` | **Queue machinery, not a getter.** It picks the next matching bag item, casts Disenchant, waits for the resolution, repeats — a server-paced sequencer with aborts on failure and on a bag change, which is the same thing `QueueSpellByName` is and which this client deliberately does not have. pfUI binds `/disenchantall` to it (`modules/nampower.lua:140`), so the *user-visible* cost is one slash command. Say the word and the sequencer is its own piece of work; it is not a binding |
| `GetSpellPower(school)` | The player's spell damage bonus by school, which needs the `PLAYER_FIELD_MOD_DAMAGE_DONE_POS` band fed per school. Nothing in the corpus calls it — the "7 call sites" an earlier sweep counted are BetterCharacterStats' own method of the same name |
| `StartItemExport` | Writes a file of its own; it belongs with the seven withheld, not with the getters |
| `GetSpellPower(school)` | **delegated to `GetSpellBonusDamage`**, which this client already answered. They are one number under two mods' names — `SuperCleveRoidMacros` says so itself ("Reads the same client field nampower's `GetSpellPower` does") — and delegating is the only way to guarantee they cannot drift. The earlier "needs a descriptor band fed" was wrong: it was already fed |
| `GetRaidTargets()` | the 8-slot **board**, now pushed beside the party roster. The earlier reason — "a scan over named tokens would silently omit a marked unit" — was right about the scan and wrong about the alternative: `GroupState::raid_targets` is the reference's own table and copying it costs eight words a push. A mark with nothing on it is **absent**, because a zero GUID is a string that passes `if t[4]` |
| `LearnTalentRank(page, index, rank)` | one `LearnTalent` press per missing rank, which is what a player clicking the button does. The earlier reason — the burst cannot be checked before it is sent — is true and is not a reason to withhold it: the check that matters is the server's and the client has never had another. What *can* be checked locally is, so a rank already held queues nothing and one past the maximum is clamped rather than sending presses the server will refuse |
| `GetUnitData(unitToken, [copy])` | the whole descriptor, from a new raw block on `UnitState` (`ui_script/unit_rec.rs`). The earlier reason — "a decision about which fields" — is now a table of seventy columns under the DLL's own names, and `GetUnitField`'s scalar half reads the same block, so the two cannot answer differently for one name |
**The indices for `GetUnitData` are derived, and the derivation is checked.** Only thirteen
`FIELD_UNIT_*` constants existed in this tree; the rest come from 1.12's `UnitFields` block sizes.
Those thirteen are spread from index 22 to 147, and every one lands exactly where the layout puts
it — thirteen independent confirmations, run as a test rather than asserted in a comment, alongside
one that no two columns overlap and one that nothing falls inside the aura blocks' 47..=124.
Three decisions at that mapping:
- **An unstreamed column is absent, not zero.** `at()` is `get_raw`'s strictness rather than
`get_u32`'s absent-is-zero convenience: `health = 0` for a unit whose health has not arrived is a
number a caller divides by, and nil is what their own guard already tests.
- **`aura` and `auraApplications` come from the filtered per-unit aura list**, not the raw slots —
the same place `GetUnitField` has always answered them from. The two verbs must not disagree
about which auras a unit has, and the filter drops exactly the ones a tracker does not want.
- **`status` and `zoneId` are left out** of nampower's seven computed columns. Nothing in the
string pool or the corpus says what `status` is a status *of*, and a zone is only knowable for
the player. The other five (`guid`, `posX`, `posY`, `power`, `maxPower`) are filled by the
caller, which knows them.
### The whole registered list, accounted for
@@ -871,13 +890,12 @@ comm -23 /tmp/np /tmp/ours
| Bucket | Count | Names |
|---|---|---|
| **Built** | 54 | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByName`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `FindPlayerItemSlot`, `GetBagItem`, `GetBagItems`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetQuestDialogQuestId`, `GetQuestLogQuestIds`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdCooldown`, `GetSpellIdForName`, `GetSpellModifiers`, `GetSpellNameAndRankForId`, `GetSpellRangeData`, `GetSpellRec`, `GetSpellRecField`, `ImportFile`, `ReadCustomFile`, `StartItemExport`, `WriteCustomFile`, `GetSpellSlotTypeIdForName`, `GetTrinketCooldown`, `GetTrinkets`, `GetUnitField`, `GetUnitGUID`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `SetLocalRaidTargetIndex`, `SetMouseoverUnit`, `UseItemIdOrName`, `UseTrinket` |
| **Built** | 58 | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByName`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `FindPlayerItemSlot`, `GetBagItem`, `GetBagItems`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetQuestDialogQuestId`, `GetQuestLogQuestIds`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdCooldown`, `GetSpellIdForName`, `GetSpellModifiers`, `GetSpellNameAndRankForId`, `GetRaidTargets`, `GetSpellPower`, `GetSpellRangeData`, `GetSpellRec`, `GetSpellRecField`, `GetUnitData`, `ImportFile`, `ReadCustomFile`, `StartItemExport`, `WriteCustomFile`, `GetSpellSlotTypeIdForName`, `GetTrinketCooldown`, `GetTrinkets`, `GetUnitField`, `GetUnitGUID`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `LearnTalentRank`, `SetLocalRaidTargetIndex`, `SetMouseoverUnit`, `UseItemIdOrName`, `UseTrinket` |
| **Not ours to add** | 4 | `QueueSpellByName`, `QueueScript`, `ChannelStopCastingNextTick`, `LearnTalentRank` |
| **Measured out, with the reason at each** | 4 | `DisenchantAll` (queue machinery — see the table above), `GetUnitData`, `GetRaidTargets`, `GetSpellPower` |
| **The queue, and the one verb that needs it** | 4 | `QueueSpellByName`, `QueueScript`, `ChannelStopCastingNextTick`, `DisenchantAll` |
54 + 4 + 4 = 62, and the `comm -23` above is the check: run it and the eight it prints are exactly
the last two rows. Nothing is in two buckets and nothing is in none.
58 + 4 = 62, and the `comm -23` above is the check: run it and the four it prints are exactly the
last row — `DisenchantAll` and the queue trio.
The two that were at the head of that last bucket are now built — see "The cast feed" above for
why they came first and what each decision cost.