diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs index 24d0ea52..c1200fc1 100644 --- a/crates/benilla-app/src/ui_party/feed.rs +++ b/crates/benilla-app/src/ui_party/feed.rs @@ -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, diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index acb7b450..33024606 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -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, diff --git a/crates/benilla-app/src/ui_script/raid_tests.rs b/crates/benilla-app/src/ui_script/raid_tests.rs index a12b97d9..b474b7f9 100644 --- a/crates/benilla-app/src/ui_script/raid_tests.rs +++ b/crates/benilla-app/src/ui_script/raid_tests.rs @@ -62,6 +62,7 @@ fn push_raid(s: &mut UiScript, raid: Vec) { }) .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 diff --git a/crates/benilla-app/src/ui_script/spell_rec.rs b/crates/benilla-app/src/ui_script/spell_rec.rs index afa16405..5aa67bbf 100644 --- a/crates/benilla-app/src/ui_script/spell_rec.rs +++ b/crates/benilla-app/src/ui_script/spell_rec.rs @@ -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>(a: &[T; 3]) -> SpellRecValue { - SpellRecValue::Ints(a.iter().map(|&v| v.into()).collect()) +fn ints>(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) -> Option { - Some(SpellRecValue::Int(v.into())) +fn int(v: impl Into) -> Option { + 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 { +pub(crate) fn field(d: &SpellDisplay, name: &str) -> Option { 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 { "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 { // 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"), diff --git a/crates/benilla-app/src/ui_script/unit_frame_tests.rs b/crates/benilla-app/src/ui_script/unit_frame_tests.rs index 336ec820..6da5c7b6 100644 --- a/crates/benilla-app/src/ui_script/unit_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/unit_frame_tests.rs @@ -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, method: &str| PartyState { + raid_targets: [0; 8], members: vec![PartyMemberInfo { name: "Brisca".into(), guid: 0x7A17, diff --git a/crates/benilla-app/src/ui_script/unit_rec.rs b/crates/benilla-app/src/ui_script/unit_rec.rs new file mode 100644 index 00000000..3eab08e6 --- /dev/null +++ b/crates/benilla-app/src/ui_script/unit_rec.rs @@ -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::>>() + .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::>>() + .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" + ); + } +} diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index f931e9d3..c6ec2b70 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -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, @@ -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, diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index c4af740b..29afbe5e 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -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 { + self.get_raw(index) + } + fn get_raw(&self, index: u16) -> Option { self.contains(index) .then(|| self.values[usize::from(index)]) diff --git a/crates/benilla-ui/src/script/compat/nampower.rs b/crates/benilla-ui/src/script/compat/nampower.rs index 81f7c993..12a3c6de 100644 --- a/crates/benilla-ui/src/script/compat/nampower.rs +++ b/crates/benilla-ui/src/script/compat/nampower.rs @@ -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 { - use crate::script::SpellRecValue as V; +fn spell_rec_value(lua: &Lua, value: crate::script::RecValue) -> mlua::Result { + 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, Option)| { + let (rows, auras) = { + let model = lua + .app_data_ref::() + .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::>() + }) + .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::() + .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::() @@ -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| { + let bonus: mlua::Function = lua.globals().get("GetSpellBonusDamage")?; + bonus.call::(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::() + .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, Option, Option)| { + let (Some(page), Some(index), Some(rank)) = (page, index, rank) else { + return Ok(()); + }; + let mut model = lua + .app_data_mut::() + .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::>(r#"return GetUnitData("target")"#) + .unwrap() + .is_none_or(|v| v.is_nil())); + assert!(s + .eval::>(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::( + 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::>(&format!( + r#"return GetUnitField("target", "{name}")"# + )) + .unwrap() + .is_none_or(|v| v.is_nil()), + "{name}" + ); + assert!( + s.eval::>(&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::(&format!("return GetSpellPower({school})")) + .unwrap(), + s.eval::(&format!("return GetSpellBonusDamage({school})")) + .unwrap(), + "school {school}: the two names must be one number" + ); + } + assert_eq!( + s.eval::("return GetSpellPower(3)").unwrap(), + 80, + "fire: the pos and neg fields summed, as `UnitDamage` sums them for physical" + ); + assert_eq!( + s.eval::("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::("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::>("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::>(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())), diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 9d3a376a..b21e2e8d 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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, diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 5951a83e..f57d2fa5 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -113,14 +113,17 @@ pub type CustomFileProbe = Box bool>; /// the yards themselves have no other path out of this client. pub type SpellRangeProbe = Box 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 Option>; +pub type SpellRecFieldProbe = Box Option>; /// 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 Option>>; +pub type SpellRecordProbe = Box Option>>; /// 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 diff --git a/crates/benilla-ui/src/script/party.rs b/crates/benilla-ui/src/script/party.rs index 7b469be5..2880dbab 100644 --- a/crates/benilla-ui/src/script/party.rs +++ b/crates/benilla-ui/src/script/party.rs @@ -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, + /// 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(), diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 2f52c6ea..5111b65b 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -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. diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 322681a1..caac1227 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -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. diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 2cde8e30..485e4448 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -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.