From 720dcf8188338b4c563726b400ab73d510e2dcdb Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:01:32 +0700 Subject: [PATCH] Lua bindings return 1/nil and the reference's value counts (#1000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lua bindings now return what 1.12.1 pushes, in type and count: - Flags are 1 or nil, never a Lua boolean. This covers `SetInventoryItem`/`SetBagItem` hasCooldown, `GetQuestLogLeaderBoard` finished, `GetContainerItemInfo` locked/readable, `AtTop`/`AtBottom`, `IsOwned` and `SetAlphaGradient`. - Eleven verbs whose handlers end `xor eax,eax` return nothing. That covers `PickupAction`, `PlaceAction`, `PickupSpell`, `PickupInventoryItem`, `EquipCursorItem`, `SplitContainerItem`, `PickupMacro`, `DeleteMacro`, `ResetChatColors` and others. - The miss tuples: `GetInboxHeaderInfo` gives 13 values (`0x4aeff0`), `GetInboxText` four nils, and `GetQuestLogLeaderBoard` three. - `GetTrainerServiceDescription` is nil with no text (`0x4d9d44`). `GetZonePVPInfo`'s faction is `""`, not nil (`0x48d633`). The boolean gates now probe every global and widget method. Fixes #442 Fixes #574 Fixes #577 Fixes #578 Fixes #822 Fixes #823 Fixes #872 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/area.rs | 28 +++++- .../src/ui_script/character_tests.rs | 16 +++- .../benilla-app/src/ui_script/shape_gate.rs | 75 ++++++++++------ .../src/ui_script/trainer_tests.rs | 2 +- .../src/ui_script/zone_text_tests.rs | 4 +- crates/benilla-app/src/ui_trainer/law.rs | 18 ++-- crates/benilla-app/src/ui_trainer/tests.rs | 69 +++++++++++---- crates/benilla-ui/src/script/chat_types.rs | 5 +- crates/benilla-ui/src/script/container.rs | 48 ++++++++--- crates/benilla-ui/src/script/cursor.rs | 37 +++++--- crates/benilla-ui/src/script/cursor/bar.rs | 33 +++---- crates/benilla-ui/src/script/cursor/doll.rs | 48 ++++++----- crates/benilla-ui/src/script/cursor/pet.rs | 23 ++--- crates/benilla-ui/src/script/macros.rs | 17 ++-- crates/benilla-ui/src/script/mail.rs | 58 ++++++++++++- .../src/script/messageframe/scrolling.rs | 14 ++- crates/benilla-ui/src/script/quest_log.rs | 40 +++++++-- crates/benilla-ui/src/script/region/paint.rs | 7 +- crates/benilla-ui/src/script/spellbook.rs | 24 ++---- .../benilla-ui/src/script/tests/host_state.rs | 17 ++++ crates/benilla-ui/src/script/tests/mod.rs | 1 + .../src/script/tests/one_or_nil_methods.rs | 86 +++++++++++++++++++ .../src/script/tests/tooltip_item/bindings.rs | 76 ++++++++++++++++ crates/benilla-ui/src/script/tooltip/verbs.rs | 7 +- .../benilla-ui/src/script/tooltip_item/mod.rs | 9 +- crates/benilla-ui/src/script/trainer/mod.rs | 11 +-- crates/benilla-ui/src/script/trainer/tests.rs | 27 +++++- crates/benilla-ui/src/script/zone_text.rs | 4 +- 28 files changed, 614 insertions(+), 190 deletions(-) create mode 100644 crates/benilla-ui/src/script/tests/one_or_nil_methods.rs diff --git a/crates/benilla-app/src/area.rs b/crates/benilla-app/src/area.rs index 9250f3b7..7da05226 100644 --- a/crates/benilla-app/src/area.rs +++ b/crates/benilla-app/src/area.rs @@ -219,10 +219,7 @@ fn feed_zone_events( subzone: signal.subzone_text.clone(), minimap: minimap_text.clone(), pvp_type: pvp.map(|(ty, _)| ty.to_string()), - pvp_faction: pvp - .map(|(_, faction)| faction) - .filter(|f| !f.is_empty()) - .map(str::to_string), + pvp_faction: pvp_faction_text(pvp), is_arena, }); @@ -245,6 +242,14 @@ fn feed_zone_events( cache.wmo_group = wmo_group; } +/// `GetZonePVPInfo`'s second value: with a type answered, the faction name is always a string, +/// starting as the empty string (`0x48d633`, `0x882748`) and pushed by `lua_pushstring` +/// (`0x48d68f`) whether or not a FactionGroup row supplied one; only the bail (`0x48d6b5`) pushes +/// nil. +fn pvp_faction_text(pvp: Option<(&str, &str)>) -> Option { + pvp.map(|(_, faction)| faction.to_string()) +} + /// `GetZonePVPInfo` (`0x48d540`): `(pvpType, isArena)`. isArena is the leaf's Flags `0x80`, read /// before any bail. The type is nil with no zone row, no faction template, or the display gate /// closed: it opens on a PvP realm ([`RealmPvp`]) or in a zone whose Flags carry `0x10`, the six @@ -433,6 +438,21 @@ mod tests { assert_eq!(pvp(true, &orgrimmar), Some("hostile")); } + /// An answered type always carries a faction string, `""` in an ownerless zone such as + /// Stranglethorn Vale (`0x48d633`); no type, no string (`0x48d6b5`). + #[test] + fn an_answered_type_carries_an_empty_faction_name_not_nil() { + assert_eq!( + pvp_faction_text(Some(("contested", ""))), + Some(String::new()) + ); + assert_eq!( + pvp_faction_text(Some(("friendly", "Alliance"))), + Some("Alliance".to_string()) + ); + assert_eq!(pvp_faction_text(None), None); + } + /// isArena is the leaf's `0x80`, read before any bail, so a closed gate keeps it. #[test] fn is_arena_survives_a_closed_gate() { diff --git a/crates/benilla-app/src/ui_script/character_tests.rs b/crates/benilla-app/src/ui_script/character_tests.rs index a4189c76..89e98d84 100644 --- a/crates/benilla-app/src/ui_script/character_tests.rs +++ b/crates/benilla-app/src/ui_script/character_tests.rs @@ -509,8 +509,20 @@ fn cursor_update_highlights_fitting_doll_slots_while_holding_a_bag_item() { assert!(s.errors().is_empty(), "{:?}", s.errors()); // The predicate the arm branches on, pinned beside the effect. - assert!(s.eval::("return CursorCanGoInSlot(1)").unwrap()); - assert!(!s.eval::("return CursorCanGoInSlot(2)").unwrap()); + // `CursorCanGoInSlot` (`0x4c8ff0`) pushes the number 1 (`0x4c9063`) or nil (`0x4c909e`) and + // returns one value (`arg.n` counts the returns). + let ask = + "local function c(...) return arg.n .. ':' .. type(arg[1]) .. ':' .. tostring(arg[1]) end"; + assert_eq!( + s.eval::(&format!("{ask} return c(CursorCanGoInSlot(1))")) + .unwrap(), + "1:number:1" + ); + assert_eq!( + s.eval::(&format!("{ask} return c(CursorCanGoInSlot(2))")) + .unwrap(), + "1:nil:nil" + ); assert!( ring_lit(&mut s, "CharacterHeadSlot"), diff --git a/crates/benilla-app/src/ui_script/shape_gate.rs b/crates/benilla-app/src/ui_script/shape_gate.rs index f575c4e4..dfb172ff 100644 --- a/crates/benilla-app/src/ui_script/shape_gate.rs +++ b/crates/benilla-app/src/ui_script/shape_gate.rs @@ -724,13 +724,10 @@ const ARG_LADDER: &[&str] = &[ "\"player\", \"player\"", ]; -/// The kinds a predicate row carries in the table: the `(nil) | (number)` of a flag, which the -/// gate probes whatever the binding's name or arity confidence. -const FLAG_KINDS: &str = "(nil) | (number)"; - /// The global bindings the boolean gate probes: every `global` row of the table, whatever its -/// `arity_conf`, that is named like a query (`Get`, `Is`, `Has`, `Can`, `Unit`, `Num`) or whose -/// kinds are [`FLAG_KINDS`], whatever its name (`LootSlotIsItem`, `Resurrect*`, `Check*Dist`). +/// name or `arity_conf`. 1.12 has no boolean-returning global but `IsPetAttackActive`, so a +/// `Pickup*` or `Place*` verb that hands back whether it moved something fails here as readily as +/// a predicate that answers `true` (`LootSlotIsItem`, `Resurrect*`, `Check*Dist`). fn predicate_names() -> Vec { let tsv = concat!( env!("CARGO_MANIFEST_DIR"), @@ -742,13 +739,8 @@ fn predicate_names() -> Vec { .filter(|l| !l.starts_with('#') && !l.starts_with("name\t")) .filter_map(|l| { let f: Vec<&str> = l.split('\t').collect(); - if f.len() < 10 || f[4] != "global" { - return None; - } - let query = ["Get", "Is", "Has", "Can", "Unit", "Num"] - .iter() - .any(|p| f[0].starts_with(p)); - (query || f[9] == FLAG_KINDS).then(|| f[0].to_string()) + (f.len() >= 10 && f[4] == "global" && !NOT_PROBED.contains(&f[0])) + .then(|| f[0].to_string()) }) .collect(); names.sort_unstable(); @@ -756,13 +748,24 @@ fn predicate_names() -> Vec { names } +/// Globals the boolean gate leaves alone: they end the session or the VM, not answer a value. +const NOT_PROBED: &[&str] = &[ + "Quit", + "ForceQuit", + "Logout", + "CancelLogout", + "ReloadUI", + "RestartGx", +]; + /// The state the predicates that answer only against state read from: a seated player, two /// talents (an ordinary one met and learnable, an exceptional one neither), two loot rolls (a -/// resolved bind-on-pickup one and one still in flight), a respec question in range. Each carries -/// both legs of its flags. +/// resolved bind-on-pickup one and one still in flight), a respec question in range, a quest log +/// with one finished line. Each carries both legs of its flags. fn seed_boolean_probe_state(s: &mut benilla_ui::script::UiScript) { use benilla_ui::script::{ - LootRollEntry, LootRollsState, TalentPrereqView, TalentTabView, TalentUiState, TalentView, + LootRollEntry, LootRollsState, QuestLogEntryView, QuestLogObjectiveView, QuestLogState, + TalentPrereqView, TalentTabView, TalentUiState, TalentView, }; s.set_unit( "player", @@ -830,6 +833,22 @@ fn seed_boolean_probe_state(s: &mut benilla_ui::script::UiScript) { ], }); s.set_talent_master_pending(true); + s.set_quest_log(QuestLogState { + num_quests: 1, + entries: vec![QuestLogEntryView { + quest_id: 1, + title: "Probe".into(), + objectives: vec![QuestLogObjectiveView { + text: "Probes slain: 1/1".into(), + kind: "monster".into(), + finished: true, + cur: 1, + req: 1, + }], + ..Default::default() + }], + ..Default::default() + }); } /// 1.12 has no boolean query: of the table's trustworthy rows only `IsPetAttackActive` and Lua's @@ -920,8 +939,17 @@ fn no_widget_method_answers_a_lua_boolean() { } } - // A widget predicate takes a script or object-type name more often than a number. - const WIDGET_ARGS: &[&str] = &["", "\"OnClick\"", "\"Frame\"", "1", "1, 1"]; + // A widget predicate takes a script or object-type name more often than a number, and + // `IsOwned` a frame; `PGFrame` is the probe frame made above. + const WIDGET_ARGS: &[&str] = &[ + "", + "\"OnClick\"", + "\"Frame\"", + "1", + "1, 1", + "PGFrame", + "1, 5", + ]; let mut checked = 0usize; let mut booleans: Vec = Vec::new(); @@ -934,13 +962,8 @@ fn no_widget_method_answers_a_lua_boolean() { continue; } let (name, table) = (f[0], f[3]); - if !["Get", "Is", "Has", "Can", "Num"] - .iter() - .any(|p| name.starts_with(p)) - { - continue; - } - // No widget method answers a boolean in the reference, so none is exempt here. + // No widget method answers a boolean in the reference, so none is exempt here, whatever + // its name: `AtBottom`, `SetAlphaGradient` and `IsOwned` push 1 or nil. let Some(obj) = made.get(table) else { continue }; for args in WIDGET_ARGS { let probe = format!( @@ -957,8 +980,8 @@ fn no_widget_method_answers_a_lua_boolean() { checked += 1; if got.split(',').any(|k| k == "boolean") { booleans.push(format!("{obj}:{name}({args}) answers ({got})")); + break; } - break; } } diff --git a/crates/benilla-app/src/ui_script/trainer_tests.rs b/crates/benilla-app/src/ui_script/trainer_tests.rs index b1c0e4e0..e73a40ee 100644 --- a/crates/benilla-app/src/ui_script/trainer_tests.rs +++ b/crates/benilla-app/src/ui_script/trainer_tests.rs @@ -132,7 +132,7 @@ fn service( name: Some(name.into()), subtext: None, texture: Some("Interface\\Icons\\INV_Sword_04".into()), - description: String::new(), + description: None, cost, prof_first_rank: false, category, diff --git a/crates/benilla-app/src/ui_script/zone_text_tests.rs b/crates/benilla-app/src/ui_script/zone_text_tests.rs index 01339506..07bbc193 100644 --- a/crates/benilla-app/src/ui_script/zone_text_tests.rs +++ b/crates/benilla-app/src/ui_script/zone_text_tests.rs @@ -17,7 +17,7 @@ fn text_of(s: &UiScript, fontstring: &str) -> String { } /// The zone caches the app pushes on an area change: the minimap line is the subzone, else the -/// zone; an empty PvP word or faction is nil. +/// zone; an empty PvP word is nil, and an answered one carries its faction name, `""` included. fn area(zone: &str, sub: &str, pvp: &str, faction: &str) -> ZoneTexts { let some = |t: &str| (!t.is_empty()).then(|| t.to_string()); ZoneTexts { @@ -26,7 +26,7 @@ fn area(zone: &str, sub: &str, pvp: &str, faction: &str) -> ZoneTexts { subzone: sub.into(), minimap: if sub.is_empty() { zone } else { sub }.into(), pvp_type: some(pvp), - pvp_faction: some(faction), + pvp_faction: (!pvp.is_empty()).then(|| faction.to_string()), is_arena: false, } } diff --git a/crates/benilla-app/src/ui_trainer/law.rs b/crates/benilla-app/src/ui_trainer/law.rs index 250a844c..1db8584b 100644 --- a/crates/benilla-app/src/ui_trainer/law.rs +++ b/crates/benilla-app/src/ui_trainer/law.rs @@ -111,7 +111,9 @@ pub(super) struct ServiceText<'a> { /// never expanded). A used row at a mount trainer skips its own text (`0x4d9c29`), and every /// failure falls back to the wire text again (`0x4d9d3b`, probing slot 0 alone for the caster). /// The expansion itself is the spell tooltip's description call (`0x52f717`): level override 0, -/// the Description column, and the modifiers applied. +/// the Description column, and the modifiers applied. `None` is the nil the binding pushes +/// (`0x4d9d90`): the wire spell has no record (`0x4d9bd7`), or no arm found text and the wire +/// spell's own is null or empty (`0x4d9d44`, `0x4d9d49`). pub(super) fn service_description( wire: &TrainerSpell, trainer_type: u32, @@ -119,10 +121,8 @@ pub(super) fn service_description( items: &Items, commands: &NetCommands, text: &ServiceText, -) -> String { - let Some(d) = spells.get(wire.spell) else { - return String::new(); - }; +) -> Option { + let d = spells.get(wire.spell)?; let learn = (0..3).find(|&i| { matches!( d.effects[i], @@ -139,13 +139,13 @@ pub(super) fn service_description( let wire_text = d.description.as_deref().filter(|t| !t.is_empty()); if !(trainer_type == TRAINER_TYPE_MOUNT && wire.state == trainer_spell_state::GRAY) { if let Some(t) = wire_text { - return substitute(t, d, learn.map_or(text.player, caster)); + return Some(substitute(t, d, learn.map_or(text.player, caster))); } } if let Some(slot) = learn { if let Some(taught) = spells.get(d.effect_trigger_spell[slot]) { if let Some(t) = taught.description.as_deref().filter(|t| !t.is_empty()) { - return substitute(t, taught, caster(slot)); + return Some(substitute(t, taught, caster(slot))); } // The recipe's product carries the only text a `CREATE_ITEM` spell has. if taught.attributes & SPELL_ATTR_IS_TRADESKILL != 0 @@ -156,12 +156,12 @@ pub(super) fn service_description( .map(|item| item.description.clone()) .filter(|t| !t.is_empty()) { - return t; + return Some(t); } } } } - wire_text.map_or_else(String::new, |t| substitute(t, d, caster(0))) + wire_text.map(|t| substitute(t, d, caster(0))) } /// The list builder `0x4d7560`'s group key (`0x4d7786`). Type 2 resolves no skill line, so no row diff --git a/crates/benilla-app/src/ui_trainer/tests.rs b/crates/benilla-app/src/ui_trainer/tests.rs index a86ed923..51e5bc9c 100644 --- a/crates/benilla-app/src/ui_trainer/tests.rs +++ b/crates/benilla-app/src/ui_trainer/tests.rs @@ -528,11 +528,40 @@ fn service_description_expands_the_wire_spells_own_text() { &deps.commands, text, ), - "Increases melee damage by 11." + Some("Increases melee damage by 11.".into()) ); }); } +/// No text to show is `None`, the nil `0x4d9d90` pushes: the wire spell has no record +/// (`0x4d9bd7`), or no arm found text and its own is empty (`0x4d9d44`, `0x4d9d49`). +#[test] +fn service_description_is_none_without_a_record_or_text() { + let empty = SpellDisplay { + description: Some(String::new()), + ..Default::default() + }; + let bare = SpellDisplay::default(); + let spells = SpellCatalog::from_displays(HashMap::from([(1, empty), (2, bare)])); + let deps = Deps::new(); + with_text(&spells, 0, 0, None, |text| { + for spell in [1, 2, 3] { + assert_eq!( + service_description( + &wire(spell, trainer_spell_state::GREEN, 10, 1, 0), + 0, + &spells, + &deps.items, + &deps.commands, + text, + ), + None, + "spell {spell}" + ); + } + }); +} + /// A learn wrapper has no text of its own, so the taught spell's Description is expanded /// (`0x4d9c6f`); the description is the taught record's, not the wrapper's. #[test] @@ -562,7 +591,7 @@ fn service_description_hops_to_the_taught_spell() { &deps.commands, text, ), - "Causes 10 threat." + Some("Causes 10 threat.".into()) ); }); } @@ -597,7 +626,7 @@ fn service_description_takes_the_recipes_product_text_verbatim() { &deps.commands, text, ), - "Keeps $s1 for later.", + Some("Keeps $s1 for later.".into()), "the product's text is the item's own, tokens and all" ); }); @@ -633,11 +662,11 @@ fn service_description_skips_the_wire_text_for_a_used_mount_row() { }; assert_eq!( description(0, trainer_spell_state::GREEN), - "Summons a mount." + Some("Summons a mount.".into()) ); assert_eq!( description(TRAINER_TYPE_MOUNT, trainer_spell_state::GRAY), - "Riding skill." + Some("Riding skill.".into()) ); } @@ -682,7 +711,7 @@ fn service_description_expands_against_the_pet_for_a_learn_pet_row() { &deps.commands, text, ), - "Causes 11 threat.", + Some("Causes 11 threat.".into()), "the pet's level drives the taught spell's points" ); assert_eq!( @@ -694,7 +723,7 @@ fn service_description_expands_against_the_pet_for_a_learn_pet_row() { &deps.commands, text, ), - "Causes 10 threat.", + Some("Causes 10 threat.".into()), "the same taught spell under LEARN_SPELL reads the player's" ); }); @@ -724,7 +753,7 @@ fn service_description_expands_z_to_the_bind_area() { &deps.commands, text, ), - want + Some(want.to_string()) ); }); } @@ -748,12 +777,14 @@ fn service_description_on_real_data_expands_the_taught_ability() { ); assert!( svc.description + .as_deref() + .unwrap_or_default() .starts_with("A strong attack that increases melee damage by "), "the taught 78's description, not the wrapper's empty one: {:?}", svc.description ); assert!( - !svc.description.contains('$'), + !svc.description.as_deref().unwrap_or_default().contains('$'), "every token resolved: {:?}", svc.description ); @@ -783,9 +814,11 @@ fn service_description_on_real_data_reads_astral_recalls_bind_area() { ) }); assert_eq!( - svc.description, - "Yanks the caster through the twisting nether back to Razor Hill. Speak to an \ - Innkeeper in a different place to change your home location." + svc.description.as_deref(), + Some( + "Yanks the caster through the twisting nether back to Razor Hill. Speak to an \ + Innkeeper in a different place to change your home location." + ) ); } @@ -811,9 +844,11 @@ fn service_description_on_real_data_reads_the_profession_learn_row() { &BTreeSet::new(), ); assert_eq!( - svc.description, - "Allows a Blacksmith to make basic weapons and armor up to a maximum potential skill of \ - 75. Requires stone and metal found with the mining skill." + svc.description.as_deref(), + Some( + "Allows a Blacksmith to make basic weapons and armor up to a maximum potential skill \ + of 75. Requires stone and metal found with the mining skill." + ) ); } @@ -856,7 +891,7 @@ fn service_description_on_real_data_reaches_for_the_recipes_product() { let deps = Deps::new(); assert_eq!( describe(&deps), - "", + None, "nothing to show until the product's template lands" ); assert_eq!( @@ -873,7 +908,7 @@ fn service_description_on_real_data_reaches_for_the_recipes_product() { deps.items.insert_template(6338, Some(product)); deps }; - assert_eq!(describe(&landed), "Needed by Enchanters."); + assert_eq!(describe(&landed), Some("Needed by Enchanters.".into())); } #[test] diff --git a/crates/benilla-ui/src/script/chat_types.rs b/crates/benilla-ui/src/script/chat_types.rs index 2f0d0e6c..1b1bd748 100644 --- a/crates/benilla-ui/src/script/chat_types.rs +++ b/crates/benilla-ui/src/script/chat_types.rs @@ -2,7 +2,7 @@ //! `ResetChatColors`: the reference's runtime table (`0xb4e518`), which the `COLORS` block of //! `chat-cache.txt` overwrites in place at load; the app owns that file. -use mlua::{Lua, MultiValue, Value}; +use mlua::{Lua, MultiValue}; use super::Model; @@ -247,7 +247,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let mut model = lua.app_data_mut::().expect("model app_data"); reset(&mut model.chat_colors); model.chat_colors_changed = true; - Ok(Value::Nil) + // `0x4a09e0` ends `xor eax,eax; ret`: no value. + Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index f77bdc0f..2b602b3e 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -5,6 +5,7 @@ use mlua::{Lua, Table, Value}; +use super::binding_abi::flag; use super::cursor::{self, CursorItem, CursorPayload}; use super::Model; @@ -538,6 +539,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // GetContainerItemInfo(bag, slot) (`0x4f9670`) → texture, itemCount, locked, quality, // readable, the five `ContainerFrame.lua:241` reads; no values for an empty or unknown slot. + // `locked` and `readable` are 1 or nil (`0x4f975d`/`0x4f976b`, `0x4f97e6`/`0x4f97d3`). lua.globals().set( "GetContainerItemInfo", lua.create_function(|lua, (bag, slot): (i64, u32)| { @@ -573,12 +575,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { Value::Integer(i64::from(s.count)), // A held item's source slot reads locked, derived from the cursor so the app's // re-push cannot wipe it. - Value::Boolean(s.locked || held_here), + flag(s.locked || held_here), match s.quality { Some(q) => Value::Integer(i64::from(q)), None => Value::Nil, }, - Value::Boolean(s.readable), + flag(s.readable), ])) })?, )?; @@ -702,6 +704,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { #[cfg(test)] mod tests { use super::{ContainerMove, ContainerSlot, ContainerState, PendingWrap, UiCursorMode}; + use crate::script::cursor::split_container_item; use crate::script::{MerchantItem, MerchantState, UiScript}; fn backpack() -> ContainerState { @@ -857,6 +860,35 @@ mod tests { .unwrap()); } + /// `GetContainerItemInfo` (`0x4f9670`) pushes `locked` and `readable` as the number 1 + /// (`0x4f975d`, `0x4f97e6`) or nil (`0x4f976b`, `0x4f97d3`), five values in all. + #[test] + fn container_item_info_flags_are_one_or_nil() { + let mut s = UiScript::new().unwrap(); + s.set_container(0, Some(backpack())); + // `arg.n` counts the returns (`select` is 5.1's, not a 1.12 global). + let shape = |s: &mut UiScript, slot: u32| { + s.eval::(&format!( + "local function c(...) return arg.n .. ':' .. type(arg[3]) .. ':' .. tostring(arg[3]) \ + .. ':' .. type(arg[5]) .. ':' .. tostring(arg[5]) end \ + return c(GetContainerItemInfo(0, {slot}))" + )) + .unwrap() + }; + assert_eq!(shape(&mut s, 1), "5:nil:nil:nil:nil", "a plain item"); + assert_eq!( + shape(&mut s, 4), + "5:nil:nil:number:1", + "the readable letter" + ); + s.run("PickupContainerItem(0, 1)").unwrap(); + assert_eq!( + shape(&mut s, 1), + "5:number:1:nil:nil", + "the held item reads locked" + ); + } + #[test] fn use_container_item_queues_intents() { let mut s = UiScript::new().unwrap(); @@ -1061,9 +1093,7 @@ mod tests { ); s.set_container(0, Some(state)); - assert!(s - .eval::("return SplitContainerItem(0, 1, 3)") - .unwrap()); + assert!(split_container_item(&mut s.model_mut(), 0, 1, 3)); let held = s.cursor_item().expect("the split carry"); assert_eq!( (held.bag, held.slot, held.item_id, held.count), @@ -1107,15 +1137,11 @@ mod tests { fn split_of_the_whole_stack_is_a_plain_pickup() { let mut s = UiScript::new().unwrap(); s.set_container(0, Some(backpack())); // slot 1: a 5-stack - assert!(s - .eval::("return SplitContainerItem(0, 1, 5)") - .unwrap()); + assert!(split_container_item(&mut s.model_mut(), 0, 1, 5)); assert_eq!(s.cursor_item().unwrap().count, None); // A split of more than the stack is a plain pickup too. s.run("ClearCursor()").unwrap(); - assert!(s - .eval::("return SplitContainerItem(0, 1, 99)") - .unwrap()); + assert!(split_container_item(&mut s.model_mut(), 0, 1, 99)); assert_eq!(s.cursor_item().unwrap().count, None); } diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index e18515b2..22fcbc4f 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -484,7 +484,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "SplitContainerItem", lua.create_function(|lua, (bag, slot, count): (i64, u32, i64)| { let mut model = lua.app_data_mut::().expect("model app_data"); - Ok(split_container_item(&mut model, bag, slot, count)) + split_container_item(&mut model, bag, slot, count); + Ok(()) })?, )?; g.set( @@ -507,7 +508,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { #[cfg(test)] mod tests { - use super::{CursorAction, CursorItem, CursorPayload, CursorSpell}; + use super::{split_container_item, CursorAction, CursorItem, CursorPayload, CursorSpell}; use crate::script::UiScript; /// A backpack holding a five-stack in slot 1. @@ -595,12 +596,24 @@ mod tests { ); } + /// `CursorHasItem` (`0x4895d0`) and `CursorHasSpell` (`0x489600`) push the number 1 + /// (`0x4895ea`, `0x489609`) or nil (`0x4895df`, `0x48961b`) and return one value. #[test] fn the_has_checks_answer_per_arm() { let mut s = UiScript::new().unwrap(); + // `arg.n` counts the returns (`select` is 5.1's, not a 1.12 global). + let ask = |s: &mut UiScript, f: &str| { + s.eval::(&format!( + "local function c(...) return arg.n .. ':' .. type(arg[1]) .. ':' .. tostring(arg[1]) end \ + return c({f}())" + )) + .unwrap() + }; + const YES: &str = "1:number:1"; + const NO: &str = "1:nil:nil"; - assert!(!s.eval::("return CursorHasItem()").unwrap()); - assert!(!s.eval::("return CursorHasSpell()").unwrap()); + assert_eq!(ask(&mut s, "CursorHasItem"), NO); + assert_eq!(ask(&mut s, "CursorHasSpell"), NO); s.set_cursor_for_test(CursorPayload::Item(CursorItem { bar_placeable: true, @@ -613,8 +626,8 @@ mod tests { quality: Some(3), equip_slots: Vec::new(), })); - assert!(s.eval::("return CursorHasItem()").unwrap()); - assert!(!s.eval::("return CursorHasSpell()").unwrap()); + assert_eq!(ask(&mut s, "CursorHasItem"), YES); + assert_eq!(ask(&mut s, "CursorHasSpell"), NO); s.set_cursor_for_test(CursorPayload::Spell(CursorSpell { passive: false, @@ -623,8 +636,8 @@ mod tests { spell_id: 133, texture: None, })); - assert!(!s.eval::("return CursorHasItem()").unwrap()); - assert!(s.eval::("return CursorHasSpell()").unwrap()); + assert_eq!(ask(&mut s, "CursorHasItem"), NO); + assert_eq!(ask(&mut s, "CursorHasSpell"), YES); s.set_cursor_for_test(CursorPayload::Action(CursorAction { src_slot: 12, @@ -632,8 +645,8 @@ mod tests { action: 133, texture: None, })); - assert!(!s.eval::("return CursorHasItem()").unwrap()); - assert!(!s.eval::("return CursorHasSpell()").unwrap()); + assert_eq!(ask(&mut s, "CursorHasItem"), NO); + assert_eq!(ask(&mut s, "CursorHasSpell"), NO); } #[test] @@ -749,9 +762,7 @@ mod tests { fn split_of_the_whole_stack_degrades_to_a_plain_pickup() { let mut s = UiScript::new().unwrap(); s.set_container(0, Some(one_item_backpack())); // slot 1: a 5-stack - assert!(s - .eval::("return SplitContainerItem(0, 1, 5)") - .unwrap()); + assert!(split_container_item(&mut s.model_mut(), 0, 1, 5)); let held = s.cursor_item().expect("picked up"); assert_eq!(held.count, None, "count>=stack ⇒ whole-stack pickup"); } diff --git a/crates/benilla-ui/src/script/cursor/bar.rs b/crates/benilla-ui/src/script/cursor/bar.rs index e2b37ebc..48210a5a 100644 --- a/crates/benilla-ui/src/script/cursor/bar.rs +++ b/crates/benilla-ui/src/script/cursor/bar.rs @@ -110,14 +110,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "PickupAction", lua.create_function(|lua, id: u32| { let mut model = lua.app_data_mut::().expect("model app_data"); - Ok(pickup_action(&mut model, id)) + pickup_action(&mut model, id); + Ok(()) })?, )?; g.set( "PlaceAction", lua.create_function(|lua, id: u32| { let mut model = lua.app_data_mut::().expect("model app_data"); - Ok(place_action(&mut model, id)) + place_action(&mut model, id); + Ok(()) })?, )?; @@ -126,6 +128,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { #[cfg(test)] mod tests { + use super::{pickup_action, place_action}; use crate::script::cursor::{CursorAction, CursorItem, CursorPayload, CursorSpell}; use crate::script::{ActionSlot, UiScript}; @@ -144,7 +147,7 @@ mod tests { let mut s = UiScript::new().unwrap(); s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 133))); - assert!(s.eval::("return PickupAction(1)").unwrap()); + assert!(pickup_action(&mut s.model_mut(), 1)); assert!( !s.eval::("return HasAction(1)").unwrap(), "removed from the engine's optimistic mirror" @@ -160,7 +163,7 @@ mod tests { #[test] fn pickup_action_on_an_empty_slot_is_a_no_op() { let mut s = UiScript::new().unwrap(); - assert!(!s.eval::("return PickupAction(5)").unwrap()); + assert!(!pickup_action(&mut s.model_mut(), 5)); assert!(s.cursor_payload().is_none()); assert!(s.take_action_sets().is_empty()); } @@ -175,7 +178,7 @@ mod tests { texture: Some("Interface\\Icons\\Spell_A".into()), })); - assert!(s.eval::("return PlaceAction(7)").unwrap()); + assert!(place_action(&mut s.model_mut(), 7)); assert!(s.cursor_payload().is_none(), "empty destination clears"); assert!(s.eval::("return HasAction(7)").unwrap()); assert_eq!( @@ -192,10 +195,10 @@ mod tests { s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111))); s.set_action(2, Some(action_slot("Interface\\Icons\\Spell_B", 0x00, 222))); - assert!(s.eval::("return PickupAction(1)").unwrap()); + assert!(pickup_action(&mut s.model_mut(), 1)); assert_eq!(s.take_action_sets(), vec![(1, 0)]); - assert!(s.eval::("return PlaceAction(2)").unwrap()); + assert!(place_action(&mut s.model_mut(), 2)); assert_eq!( s.eval::("return GetActionTexture(2)").unwrap(), "Interface\\Icons\\Spell_A" @@ -227,7 +230,7 @@ mod tests { equip_slots: Vec::new(), })); - assert!(s.eval::("return PlaceAction(4)").unwrap()); + assert!(place_action(&mut s.model_mut(), 4)); assert!(s.cursor_payload().is_none()); // 0x80<<24 | 117. assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000 | 117)]); @@ -248,7 +251,7 @@ mod tests { texture: Some("Interface\\Icons\\Spell_A".into()), })); - assert!(s.eval::("return PlaceAction(9)").unwrap()); + assert!(place_action(&mut s.model_mut(), 9)); assert_eq!(s.take_action_sets(), vec![(9, 133)]); // 0x00<<24 | 133 } @@ -256,7 +259,7 @@ mod tests { fn place_action_with_an_empty_cursor_is_a_no_op() { let mut s = UiScript::new().unwrap(); s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111))); - assert!(!s.eval::("return PlaceAction(1)").unwrap()); + assert!(!place_action(&mut s.model_mut(), 1)); assert!(s.eval::("return HasAction(1)").unwrap(), "untouched"); assert!(s.take_action_sets().is_empty()); } @@ -270,7 +273,7 @@ mod tests { action: 111, texture: Some("Interface\\Icons\\Spell_A".into()), })); - assert!(s.eval::("return PickupAction(5)").unwrap()); + assert!(pickup_action(&mut s.model_mut(), 5)); assert!(s.cursor_payload().is_none(), "placed onto the empty slot 5"); assert!(s.eval::("return HasAction(5)").unwrap()); assert_eq!(s.take_action_sets(), vec![(5, 111)]); @@ -326,7 +329,7 @@ mod tests { equip_slots: Vec::new(), })); - assert!(!s.eval::("return PlaceAction(4)").unwrap()); + assert!(!place_action(&mut s.model_mut(), 4)); assert!(!s.eval::("return HasAction(4)").unwrap(), "no store"); assert!(s.take_action_sets().is_empty(), "no packet"); assert!( @@ -351,7 +354,7 @@ mod tests { passive: true, })); - assert!(!s.eval::("return PlaceAction(4)").unwrap()); + assert!(!place_action(&mut s.model_mut(), 4)); assert!(!s.eval::("return HasAction(4)").unwrap()); assert!(s.take_action_sets().is_empty()); assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_)))); @@ -377,7 +380,7 @@ mod tests { passive: false, })); - assert!(s.eval::("return PlaceAction(4)").unwrap()); + assert!(place_action(&mut s.model_mut(), 4)); assert!(s.eval::("return HasAction(4)").unwrap()); assert!(s.take_ui_errors().is_empty()); } @@ -397,7 +400,7 @@ mod tests { equip_slots: vec![20, 21, 22, 23], })); - assert!(s.eval::("return PlaceAction(4)").unwrap()); + assert!(place_action(&mut s.model_mut(), 4)); assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000u32 | 4496)]); } } diff --git a/crates/benilla-ui/src/script/cursor/doll.rs b/crates/benilla-ui/src/script/cursor/doll.rs index f842d95f..a73bf403 100644 --- a/crates/benilla-ui/src/script/cursor/doll.rs +++ b/crates/benilla-ui/src/script/cursor/doll.rs @@ -196,14 +196,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "PickupInventoryItem", lua.create_function(|lua, id: u32| { let mut model = lua.app_data_mut::().expect("model app_data"); - Ok(pickup_inventory_item(&mut model, id)) + pickup_inventory_item(&mut model, id); + Ok(()) })?, )?; g.set( "EquipCursorItem", lua.create_function(|lua, id: u32| { let mut model = lua.app_data_mut::().expect("model app_data"); - Ok(equip_cursor_item(&mut model, id)) + equip_cursor_item(&mut model, id); + Ok(()) })?, )?; g.set( @@ -217,7 +219,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "AutoEquipCursorItem", lua.create_function(|lua, ()| { let mut model = lua.app_data_mut::().expect("model app_data"); - Ok(auto_equip_cursor_item(&mut model)) + auto_equip_cursor_item(&mut model); + Ok(()) })?, )?; g.set( @@ -241,6 +244,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { #[cfg(test)] mod tests { + use super::{auto_equip_cursor_item, equip_cursor_item, pickup_inventory_item}; use crate::script::cursor::{CursorAction, CursorPayload, CursorSpell, EQUIPMENT_BAG}; use crate::script::{ContainerMove, ContainerSlot, ContainerState, InvSlotView, UiScript}; @@ -341,7 +345,7 @@ mod tests { s.set_inventory_slots(doll_slots()); assert!(!s.eval::("return IsInventoryItemLocked(1)").unwrap()); - assert!(s.eval::("return PickupInventoryItem(1)").unwrap()); + assert!(pickup_inventory_item(&mut s.model_mut(), 1)); let held = s.cursor_item().expect("picked up"); assert_eq!( (held.bag, held.slot, held.item_id), @@ -356,8 +360,8 @@ mod tests { fn pickup_same_doll_slot_cancels_no_move() { let mut s = UiScript::new().unwrap(); s.set_inventory_slots(doll_slots()); - assert!(s.eval::("return PickupInventoryItem(1)").unwrap()); - assert!(s.eval::("return PickupInventoryItem(1)").unwrap()); + assert!(pickup_inventory_item(&mut s.model_mut(), 1)); + assert!(pickup_inventory_item(&mut s.model_mut(), 1)); assert!(s.cursor_item().is_none()); assert!(s.take_container_moves().is_empty()); assert!(!s.eval::("return IsInventoryItemLocked(1)").unwrap()); @@ -372,7 +376,7 @@ mod tests { s.run("PickupContainerItem(0, 1)").unwrap(); assert_eq!(s.cursor_item().unwrap().equip_slots, vec![1]); - assert!(s.eval::("return PickupInventoryItem(1)").unwrap()); + assert!(pickup_inventory_item(&mut s.model_mut(), 1)); assert!(s.cursor_item().is_none(), "a plain clear, no hop"); assert_eq!( s.take_container_moves(), @@ -394,7 +398,7 @@ mod tests { s.set_container(0, Some(one_fitting_bag_item())); s.run("PickupContainerItem(0, 1)").unwrap(); - assert!(!s.eval::("return PickupInventoryItem(2)").unwrap()); + assert!(!pickup_inventory_item(&mut s.model_mut(), 2)); let held = s.cursor_item().expect("kept — doesn't fit slot 2"); assert_eq!(held.item_id, 2000); assert!(s.take_container_moves().is_empty()); @@ -405,10 +409,10 @@ mod tests { let mut s = UiScript::new().unwrap(); s.set_inventory_slots(doll_slots()); - assert!(s.eval::("return PickupInventoryItem(11)").unwrap()); + assert!(pickup_inventory_item(&mut s.model_mut(), 11)); assert_eq!(s.cursor_item().unwrap().equip_slots, vec![11, 12]); - assert!(s.eval::("return PickupInventoryItem(12)").unwrap()); + assert!(pickup_inventory_item(&mut s.model_mut(), 12)); assert!(s.cursor_item().is_none()); assert_eq!( s.take_container_moves(), @@ -434,7 +438,7 @@ mod tests { let held = s.cursor_item().expect("a split carry"); assert_eq!(held.count, Some(2)); - assert!(!s.eval::("return PickupInventoryItem(1)").unwrap()); + assert!(!pickup_inventory_item(&mut s.model_mut(), 1)); let held = s.cursor_item().expect("kept — can't equip a partial stack"); assert_eq!(held.count, Some(2)); assert!(s.take_container_moves().is_empty()); @@ -473,8 +477,8 @@ mod tests { s.set_inventory_slots(slots); // Ammo (0) is refused; Bag0Slot (20) picks up the equipped bag. - assert!(!s.eval::("return PickupInventoryItem(0)").unwrap()); - assert!(s.eval::("return PickupInventoryItem(20)").unwrap()); + assert!(!pickup_inventory_item(&mut s.model_mut(), 0)); + assert!(pickup_inventory_item(&mut s.model_mut(), 20)); let held = s.cursor_item().expect("bag picked up from the bar"); assert_eq!( (held.bag, held.slot, held.item_id), @@ -520,7 +524,7 @@ mod tests { s.run("PickupContainerItem(0, 3)").unwrap(); assert!(s.eval::("return CursorCanGoInSlot(21)").unwrap()); - assert!(s.eval::("return PickupInventoryItem(21)").unwrap()); + assert!(pickup_inventory_item(&mut s.model_mut(), 21)); assert!(s.cursor_item().is_none(), "a plain clear on place"); assert_eq!( s.take_container_moves(), @@ -570,7 +574,7 @@ mod tests { s.set_container(0, Some(one_fitting_bag_item())); s.run("PickupContainerItem(0, 1)").unwrap(); - assert!(s.eval::("return AutoEquipCursorItem()").unwrap()); + assert!(auto_equip_cursor_item(&mut s.model_mut())); assert!(s.cursor_item().is_none()); assert_eq!(s.take_container_autoequips(), vec![(0, 1)]); } @@ -581,7 +585,7 @@ mod tests { s.set_inventory_slots(doll_slots()); s.run("PickupInventoryItem(1)").unwrap(); - assert!(!s.eval::("return AutoEquipCursorItem()").unwrap()); + assert!(!auto_equip_cursor_item(&mut s.model_mut())); assert!(s.cursor_item().is_some(), "kept"); assert!(s.take_container_autoequips().is_empty()); s.run("ClearCursor()").unwrap(); @@ -590,7 +594,7 @@ mod tests { state.slots.get_mut(&1).unwrap().count = 5; s.set_container(0, Some(state)); s.run("SplitContainerItem(0, 1, 2)").unwrap(); - assert!(!s.eval::("return AutoEquipCursorItem()").unwrap()); + assert!(!auto_equip_cursor_item(&mut s.model_mut())); assert!(s.cursor_item().is_some(), "kept"); assert!(s.take_container_autoequips().is_empty()); } @@ -633,8 +637,8 @@ mod tests { })); s.run("ShowRepairCursor()").unwrap(); - assert!(!s.eval::("return PickupInventoryItem(1)").unwrap()); - assert!(!s.eval::("return PickupInventoryItem(1)").unwrap()); + assert!(!pickup_inventory_item(&mut s.model_mut(), 1)); + assert!(!pickup_inventory_item(&mut s.model_mut(), 1)); s.run("UseInventoryItem(1)").unwrap(); assert!(s.cursor_item().is_none()); assert_eq!(s.take_inventory_repairs(), vec![1, 1, 1]); @@ -723,7 +727,7 @@ mod tests { s.set_container(0, Some(one_fitting_bag_item())); s.run("PickupContainerItem(0, 1)").unwrap(); - assert!(s.eval::("return EquipCursorItem(1)").unwrap()); + assert!(equip_cursor_item(&mut s.model_mut(), 1)); assert!(s.cursor_item().is_none()); assert_eq!( s.take_container_moves(), @@ -746,7 +750,7 @@ mod tests { s.set_container(0, Some(one_fitting_bag_item())); // Unarmed, both seams do their ordinary gesture and queue nothing. - assert!(s.eval::("return PickupInventoryItem(1)").unwrap()); + assert!(pickup_inventory_item(&mut s.model_mut(), 1)); assert!(s.cursor_item().is_some()); assert!(s.take_item_picks().is_empty()); @@ -761,7 +765,7 @@ mod tests { // Armed with an empty cursor: both seams bind and leave the cursor empty. s.run("ClearCursor()").unwrap(); assert!(s.cursor_item().is_none()); - assert!(!s.eval::("return PickupInventoryItem(1)").unwrap()); + assert!(!pickup_inventory_item(&mut s.model_mut(), 1)); s.run("PickupContainerItem(0, 1)").unwrap(); assert_eq!( s.take_item_picks(), diff --git a/crates/benilla-ui/src/script/cursor/pet.rs b/crates/benilla-ui/src/script/cursor/pet.rs index 51c42565..658cd99f 100644 --- a/crates/benilla-ui/src/script/cursor/pet.rs +++ b/crates/benilla-ui/src/script/cursor/pet.rs @@ -221,13 +221,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { lua.globals().set( "PickupPetAction", lua.create_function(|lua, slot: u32| { - let took = { + { let mut model = lua.app_data_mut::().expect("model app_data"); - pickup_pet_action(&mut model, slot) - }; + pickup_pet_action(&mut model, slot); + } // Both the pickup and a drop's displaced occupant can put a token on the cursor. resolve_token_icon(lua); - Ok(took) + Ok(()) })?, )?; Ok(()) @@ -236,6 +236,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { #[cfg(test)] mod tests { use super::*; + use crate::script::cursor::bar::place_action; use crate::script::{PetActionView, UiScript}; /// A hunter's bar words. `FILLER` is vmangos's unused slot, `ACT_DISABLED` with spell 0: type @@ -340,7 +341,7 @@ mod tests { #[test] fn picking_up_a_spell_blanks_its_slot_and_sends() { let mut s = bar_script(); - assert!(s.eval::("return PickupPetAction(4)").unwrap()); + assert!(pickup_pet_action(&mut s.model_mut(), 4)); assert!( matches!(s.cursor_payload(), Some(crate::script::CursorPayload::PetAction(c)) if c.src_slot == 4), "{:?}", @@ -388,7 +389,7 @@ mod tests { let mut s = UiScript::new().unwrap(); s.set_pet_actions(true, false, true, hunter_bar()); - assert!(s.eval::("return PickupPetAction(4)").unwrap()); + assert!(pickup_pet_action(&mut s.model_mut(), 4)); assert!( matches!(s.cursor_payload(), Some(crate::script::CursorPayload::PetAction(c)) if c.src_slot == 4 && c.packed == CLAW), @@ -440,7 +441,7 @@ mod tests { #[test] fn picking_up_a_token_leaves_the_slot_alone() { let mut s = bar_script(); - assert!(s.eval::("return PickupPetAction(1)").unwrap()); + assert!(pickup_pet_action(&mut s.model_mut(), 1)); assert!( s.take_pet_set_actions().is_empty(), "nothing was written, so nothing is sent" @@ -506,8 +507,8 @@ mod tests { #[test] fn slot_eleven_is_refused_rather_than_written_past_the_array() { let mut s = bar_script(); - assert!(!s.eval::("return PickupPetAction(11)").unwrap()); - assert!(!s.eval::("return PickupPetAction(0)").unwrap()); + assert!(!pickup_pet_action(&mut s.model_mut(), 11)); + assert!(!pickup_pet_action(&mut s.model_mut(), 0)); assert!(s.take_pet_set_actions().is_empty()); assert!(s.cursor_payload().is_none()); } @@ -517,7 +518,7 @@ mod tests { fn a_possessed_bar_takes_no_drag_at_all() { let mut s = UiScript::new().unwrap(); s.set_pet_actions(true, true, false, hunter_bar()); - assert!(!s.eval::("return PickupPetAction(4)").unwrap()); + assert!(!pickup_pet_action(&mut s.model_mut(), 4)); assert!(s.take_pet_set_actions().is_empty()); } @@ -558,7 +559,7 @@ mod tests { "the action bar's grid stays down — nothing can land there" ); // PlaceAction refuses it outright and leaves it held. - assert!(!s.eval::("return PlaceAction(1)").unwrap()); + assert!(!place_action(&mut s.model_mut(), 1)); assert!(matches!( s.cursor_payload(), Some(crate::script::CursorPayload::PetAction(_)) diff --git a/crates/benilla-ui/src/script/macros.rs b/crates/benilla-ui/src/script/macros.rs index b7984b3e..4d8fd354 100644 --- a/crates/benilla-ui/src/script/macros.rs +++ b/crates/benilla-ui/src/script/macros.rs @@ -406,7 +406,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "DeleteMacro", lua.create_function(|lua, index: usize| { let mut model = lua.app_data_mut::().expect("model app_data"); - Ok(delete_macro(&mut model, index)) + delete_macro(&mut model, index); + Ok(()) })?, )?; @@ -414,7 +415,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "PickupMacro", lua.create_function(|lua, index: usize| { let mut model = lua.app_data_mut::().expect("model app_data"); - Ok(pickup_macro(&mut model, index)) + pickup_macro(&mut model, index); + Ok(()) })?, )?; @@ -500,7 +502,7 @@ mod tests { ); // The character macro keeps its own base after the delete. - assert!(s.eval::("return DeleteMacro(1)").unwrap()); + assert!(delete_macro(&mut s.model_mut(), 1)); assert_eq!( s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c") .unwrap(), @@ -620,11 +622,8 @@ mod tests { s.run(r#"CreateMacro("Ambush", 1, "/cast Ambush")"#) .unwrap(); - assert!( - !s.eval::("return PickupMacro(5)").unwrap(), - "empty slot" - ); - assert!(s.eval::("return PickupMacro(1)").unwrap()); + assert!(!pickup_macro(&mut s.model_mut(), 5), "empty slot"); + assert!(pickup_macro(&mut s.model_mut(), 1)); assert_eq!( s.cursor_payload(), Some(CursorPayload::Macro(CursorMacro { @@ -633,7 +632,7 @@ mod tests { })) ); assert!( - !s.eval::("return PickupMacro(1)").unwrap(), + !pickup_macro(&mut s.model_mut(), 1), "a macro button is a source, never a drop target" ); } diff --git a/crates/benilla-ui/src/script/mail.rs b/crates/benilla-ui/src/script/mail.rs index eccf7a43..c7d15e5e 100644 --- a/crates/benilla-ui/src/script/mail.rs +++ b/crates/benilla-ui/src/script/mail.rs @@ -287,7 +287,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // GetInboxHeaderInfo(index): the 13 values `MailFrame.lua:105` reads; nil out of range. + // GetInboxHeaderInfo(index): the 13 values `MailFrame.lua:105` reads. Out of range (or index + // 0, which `OpenMail_Update` asks after a letter closes) the reference still returns 13 + // (`0x4aeff0`..`0x4af05e`): four nils, 0 for money, COD amount and days left, six nils. g.set( "GetInboxHeaderInfo", lua.create_function(|lua, index: usize| { @@ -296,7 +298,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { row_at(&model, index) }; let Some(r) = row else { - return Ok(MultiValue::from_vec(vec![Value::Nil])); + let mut miss = vec![Value::Nil; 4]; + miss.extend(vec![Value::Integer(0); 3]); + miss.extend(vec![Value::Nil; 6]); + return Ok(MultiValue::from_vec(miss)); }; let opt_str = |s: &Option| -> mlua::Result { Ok(match s { @@ -350,8 +355,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { } row }; + // A miss pushes four nils (`0x4af311`..`0x4af334`). let Some(r) = row else { - return Ok(MultiValue::from_vec(vec![Value::Nil])); + return Ok(MultiValue::from_vec(vec![Value::Nil; 4])); }; Ok(MultiValue::from_vec(vec![ // An invoice's body is nil (`0x4af1cf`): its text is the auction house's @@ -1040,6 +1046,52 @@ mod tests { .unwrap()); } + /// A row that does not exist still answers the full tuple: `GetInboxHeaderInfo` 13 values, + /// four nils, `0` money, COD and days left, six nils (`0x4aeff0`..`0x4af05e`), so a closed + /// letter's `GetInboxHeaderInfo(0)` can be compared with `CODAmount > 0` (`MailFrame.lua:399`); + /// `GetInboxText` four nils (`0x4af311`..`0x4af334`). + #[test] + fn a_missing_inbox_row_answers_the_references_miss_tuples() { + let mut s = UiScript::new().unwrap(); + s.set_mail(Some(state())); + for index in [0, 3, 99] { + assert_eq!( + s.arity(&format!("GetInboxHeaderInfo({index})")).unwrap(), + 13, + "header {index}" + ); + assert_eq!( + s.arity(&format!("GetInboxText({index})")).unwrap(), + 4, + "text {index}" + ); + assert_eq!( + s.eval::(&format!( + "local t = {{GetInboxHeaderInfo({index})}} \ + local k = '' \ + for i = 1, 13 do k = k .. type(t[i]) .. ',' end \ + local _, _, _, _, m, c, d = GetInboxHeaderInfo({index}) \ + return k .. m .. ',' .. c .. ',' .. d .. ',' .. tostring(c > 0)" + )) + .unwrap(), + "nil,nil,nil,nil,number,number,number,nil,nil,nil,nil,nil,nil,0,0,0,false", + "header {index}" + ); + assert!( + s.eval::(&format!( + "local a, b, c, d = GetInboxText({index}) \ + return a == nil and b == nil and c == nil and d == nil" + )) + .unwrap(), + "text {index}" + ); + } + assert!( + s.take_mail_opens().is_empty(), + "a miss opens nothing for the app to fetch" + ); + } + #[test] fn get_inbox_text_returns_body_and_queues_open() { let mut s = UiScript::new().unwrap(); diff --git a/crates/benilla-ui/src/script/messageframe/scrolling.rs b/crates/benilla-ui/src/script/messageframe/scrolling.rs index 2b58b443..09f55821 100644 --- a/crates/benilla-ui/src/script/messageframe/scrolling.rs +++ b/crates/benilla-ui/src/script/messageframe/scrolling.rs @@ -154,13 +154,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { scroll(lua, &this, ScrollingMessageState::page_down) })?, )?; + // AtTop (`0x793130`) pushes 1 (`0x7931c3`) or nil (`0x7931d6`); AtBottom (`0x7931f0`) pushes 1 + // (`0x793283`) or nil (`0x793296`). m.set( "AtTop", - lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.at_top()))?, + lua.create_function(|lua, this: Table| { + with_smf(lua, &this, |smf| { + crate::script::binding_abi::flag(smf.at_top()) + }) + })?, )?; m.set( "AtBottom", - lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.at_bottom()))?, + lua.create_function(|lua, this: Table| { + with_smf(lua, &this, |smf| { + crate::script::binding_abi::flag(smf.at_bottom()) + }) + })?, )?; // SetMaxLines wipes the ring (`0x7938a0`). diff --git a/crates/benilla-ui/src/script/quest_log.rs b/crates/benilla-ui/src/script/quest_log.rs index 24b7b3e3..dd4b301f 100644 --- a/crates/benilla-ui/src/script/quest_log.rs +++ b/crates/benilla-ui/src/script/quest_log.rs @@ -302,7 +302,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // `text, type, finished` of line `i`, for any row as above (`QuestLogFrame.lua:635`). + // `text, type, finished` of line `i`, for any row as above (`QuestLogFrame.lua:635`). Three + // values on every path (`mov eax,3` at `0x4e02ba`, `0x4e079b`, `0x4e07c8`): `finished` is the + // number 1 (`0x4e02ae`, `0x4e078f`) or nil (`0x4e07bc`), and a row or line that does not exist + // takes the tail at `0x4e07ac`, which pushes three nils. g.set( "GetQuestLogLeaderBoard", lua.create_function(|lua, (i, quest): (usize, Option)| { @@ -316,12 +319,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { .cloned() }; let Some(l) = line else { - return Ok(MultiValue::from_vec(vec![Value::Nil])); + return Ok(MultiValue::from_vec(vec![ + Value::Nil, + Value::Nil, + Value::Nil, + ])); }; Ok(MultiValue::from_vec(vec![ Value::String(lua.create_string(&l.text)?), Value::String(lua.create_string(&l.kind)?), - Value::Boolean(l.finished), + flag(l.finished), ])) })?, )?; @@ -1042,15 +1049,38 @@ mod tests { assert!(s .eval::( "local t, k, f = GetQuestLogLeaderBoard(1, 2)\n\ - return t == 'Kobold Worker slain: 10/10' and k == 'monster' and f == true" + return t == 'Kobold Worker slain: 10/10' and k == 'monster' and f == 1" ) .unwrap()); assert!(s .eval::( "local t, k, f = GetQuestLogLeaderBoard(1)\n\ - return t == 'Kobold Vermin slain: 3/10' and k == 'monster' and f == false" + return t == 'Kobold Vermin slain: 3/10' and k == 'monster' and f == nil" ) .unwrap()); + // Three values on every path; a row or line with nothing answers three nils (`0x4e07ac`). + for call in [ + "GetQuestLogLeaderBoard(1)", + "GetQuestLogLeaderBoard(1, 2)", + "GetQuestLogLeaderBoard(2)", + "GetQuestLogLeaderBoard(1, 9)", + "GetQuestLogLeaderBoard(0)", + ] { + assert_eq!(s.arity(call).unwrap(), 3, "{call}"); + } + for call in [ + "GetQuestLogLeaderBoard(2)", + "GetQuestLogLeaderBoard(1, 9)", + "GetQuestLogLeaderBoard(0)", + ] { + assert!( + s.eval::(&format!( + "local a, b, c = {call} return a == nil and b == nil and c == nil" + )) + .unwrap(), + "{call}" + ); + } assert_eq!( s.eval::("return GetQuestLogRewardMoney()").unwrap(), 40 diff --git a/crates/benilla-ui/src/script/region/paint.rs b/crates/benilla-ui/src/script/region/paint.rs index 18d9ef5f..d960d8d1 100644 --- a/crates/benilla-ui/src/script/region/paint.rs +++ b/crates/benilla-ui/src/script/region/paint.rs @@ -276,8 +276,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { })?, )?; - // SetAlphaGradient(start, length), a FontString's write-on reveal: answers whether `start` is - // still inside the text in chars, and stock `QuestFrame.lua:558` advances until it is not. + // SetAlphaGradient(start, length) (`0x79cc80`), a FontString's write-on reveal: answers 1 + // (`0x79cd58`) or nil (`0x79cd6d`) for whether `start` is still inside the text in chars, and + // stock `QuestFrame.lua:558` advances until it is not. m.set( "SetAlphaGradient", lua.create_function(|lua, (this, start, length): (Table, f32, f32)| { @@ -286,7 +287,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { let data = model.region_data.entry(rh).or_default(); data.alpha_gradient = Some((start, length)); let chars = data.text.as_deref().map_or(0, |t| t.chars().count()); - Ok(start < chars as f32) + Ok(crate::script::binding_abi::flag(start < chars as f32)) })?, )?; diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index 8ce11886..a868d3f1 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -582,7 +582,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { lua.create_function(|lua, (id, book_type): (Value, Value)| { let (id, book_type) = spell_slot_args(id, book_type, "PickupSpell")?; let mut model = lua.app_data_mut::().expect("model app_data"); - Ok(pickup_spell(&mut model, id, &book_type)) + pickup_spell(&mut model, id, &book_type); + Ok(()) })?, )?; @@ -655,7 +656,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { #[cfg(test)] mod tests { - use super::{PetBookState, SpellBookState, SpellSlotView, SpellTabView}; + use super::{pickup_spell, PetBookState, SpellBookState, SpellSlotView, SpellTabView}; use crate::script::cursor::{CursorAction, CursorPayload}; use crate::script::UiScript; @@ -912,8 +913,7 @@ mod tests { let mut s = UiScript::new().unwrap(); s.set_spellbook(book()); - s.run(r#"picked = PickupSpell(1, BOOKTYPE_SPELL)"#).unwrap(); - assert!(s.eval::("return picked").unwrap()); + s.run(r#"PickupSpell(1, BOOKTYPE_SPELL)"#).unwrap(); assert!(s.cursor_payload().is_some()); assert!( matches!( @@ -967,9 +967,7 @@ mod tests { texture: None, })); - assert!(!s - .eval::(r#"return PickupSpell(1, BOOKTYPE_SPELL)"#) - .unwrap()); + assert!(!pickup_spell(&mut s.model_mut(), 1, "spell")); // The original (action) payload survives untouched. assert!( matches!( @@ -1093,9 +1091,7 @@ mod tests { assert!(s.take_spell_casts().is_empty(), "pet cast is a no-op"); assert!(s.take_pet_spell_casts().is_empty()); - assert!(!s - .eval::(r#"return PickupSpell(1, BOOKTYPE_PET)"#) - .unwrap()); + assert!(!pickup_spell(&mut s.model_mut(), 1, "pet")); assert!(s.cursor_payload().is_none(), "pet pickup is a no-op"); } @@ -1283,9 +1279,7 @@ mod tests { s.set_spellbook(book()); s.set_pet_book(pet_book()); - assert!(s - .eval::(r#"return PickupSpell(1, BOOKTYPE_PET)"#) - .unwrap()); + assert!(pickup_spell(&mut s.model_mut(), 1, "pet")); let Some(CursorPayload::PetAction(p)) = s.cursor_payload() else { panic!( "expected a pet action payload, got {:?}", @@ -1297,9 +1291,7 @@ mod tests { // The player book still produces a spell payload. s.run("ClearCursor()").unwrap(); - assert!(s - .eval::(r#"return PickupSpell(1, BOOKTYPE_SPELL)"#) - .unwrap()); + assert!(pickup_spell(&mut s.model_mut(), 1, "spell")); assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_)))); } diff --git a/crates/benilla-ui/src/script/tests/host_state.rs b/crates/benilla-ui/src/script/tests/host_state.rs index 34ee8aa2..0fe7851e 100644 --- a/crates/benilla-ui/src/script/tests/host_state.rs +++ b/crates/benilla-ui/src/script/tests/host_state.rs @@ -203,6 +203,23 @@ fn the_getters_answer_the_pushed_state_in_their_1_12_shapes() { s.eval::<(f64, f64)>("return GetGameTime()").unwrap(), (21.0, 7.0) ); + + // An ownerless zone: the type is answered and the faction name is the empty string + // (`0x48d633`, pushed at `0x48d68f`), not nil. + s.set_zone_texts(crate::script::ZoneTexts { + pvp_type: Some("contested".into()), + pvp_faction: Some(String::new()), + ..Default::default() + }); + assert_eq!(s.arity("GetZonePVPInfo()").unwrap(), 3); + assert_eq!( + s.eval::( + "local t, f, a = GetZonePVPInfo() \ + return t .. '|' .. type(f) .. ':' .. f .. '|' .. tostring(a)" + ) + .unwrap(), + "contested|string:|nil" + ); } /// The reference's handler slot starts empty (`[0x8722cc]` = -1 at `0x7039e0`), so diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 4c3fb1ac..939c7f4d 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -52,6 +52,7 @@ mod name_targets; mod nameplate; mod numeric_shape_gate; mod object_model; +mod one_or_nil_methods; mod reference_surface; mod region_map; mod regions; diff --git a/crates/benilla-ui/src/script/tests/one_or_nil_methods.rs b/crates/benilla-ui/src/script/tests/one_or_nil_methods.rs new file mode 100644 index 00000000..8df871b1 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/one_or_nil_methods.rs @@ -0,0 +1,86 @@ +//! Widget methods that push the number 1 or nil, never a Lua boolean, and the cursor, action and +//! chat-colour verbs that push nothing at all. Counted through the implicit vararg table's `arg.n` (`select` +//! is 5.1's, not a 1.12 global). + +use super::common::script; + +/// `ScrollingMessageFrame:AtTop` (`0x793130`) and `:AtBottom` (`0x7931f0`) test one flag and push +/// 1 (`0x7931c3`, `0x793283`) or nil (`0x7931d6`, `0x793296`), one value each. +#[test] +fn scrolling_message_frame_at_top_and_bottom_answer_one_or_nil() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + local function shape(...) return arg.n .. ':' .. type(arg[1]) .. ':' .. tostring(arg[1]) end + local f = CreateFrame("ScrollingMessageFrame", "SMF") + f:SetMaxLines(128) + for i = 1, 40 do f:AddMessage("line " .. i) end + assert(shape(f:AtBottom()) == "1:number:1", shape(f:AtBottom())) + assert(shape(f:AtTop()) == "1:nil:nil", shape(f:AtTop())) + f:ScrollToTop() + assert(shape(f:AtTop()) == "1:number:1", shape(f:AtTop())) + assert(shape(f:AtBottom()) == "1:nil:nil", shape(f:AtBottom())) + "#, + ) + .unwrap(); +} + +/// `GameTooltip:IsOwned` (`0x530fe0`) pushes 1 (`0x5310ad`) or nil (`0x5310c0`), one value. +#[test] +fn tooltip_is_owned_answers_one_or_nil() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + local function shape(...) return arg.n .. ':' .. type(arg[1]) .. ':' .. tostring(arg[1]) end + local a = CreateFrame("Button", "A"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) + local b = CreateFrame("Button", "B"); b:SetPoint("CENTER", 50, 0); b:SetWidth(10); b:SetHeight(10) + local tt = CreateFrame("GameTooltip", "TT") + tt:SetOwner(a, "ANCHOR_RIGHT") + assert(shape(tt:IsOwned(a)) == "1:number:1", shape(tt:IsOwned(a))) + assert(shape(tt:IsOwned(b)) == "1:nil:nil", shape(tt:IsOwned(b))) + "#, + ) + .unwrap(); +} + +/// `FontString:SetAlphaGradient` (`0x79cc80`) pushes 1 (`0x79cd58`) while `start` is inside the +/// text and nil (`0x79cd6d`) once it is past, one value. +#[test] +fn set_alpha_gradient_answers_one_or_nil() { + let s = script(); + s.run( + r#" + local function shape(...) return arg.n .. ':' .. type(arg[1]) .. ':' .. tostring(arg[1]) end + local f = CreateFrame("Frame", "F") + local fs = f:CreateFontString("FS") + fs:SetText("hello") + assert(shape(fs:SetAlphaGradient(0, 5)) == "1:number:1", shape(fs:SetAlphaGradient(0, 5))) + assert(shape(fs:SetAlphaGradient(5, 5)) == "1:nil:nil", shape(fs:SetAlphaGradient(5, 5))) + "#, + ) + .unwrap(); +} + +/// The cursor, action and chat-colour verbs push no value: `xor eax,eax; ret` ends each handler. A bare +/// `return F()` hands back zero values, not a boolean for whether anything moved. +#[test] +fn the_pickup_and_place_verbs_return_nothing() { + let s = script(); + for call in [ + "PickupInventoryItem(1)", + "EquipCursorItem(1)", + "AutoEquipCursorItem()", + "SplitContainerItem(0, 1, 1)", + "PickupAction(1)", + "PlaceAction(1)", + "PickupPetAction(1)", + "PickupSpell(1, BOOKTYPE_SPELL)", + "PickupMacro(1)", + "DeleteMacro(1)", + "ResetChatColors()", + ] { + assert_eq!(s.arity(call).unwrap(), 0, "{call}"); + } +} diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs index 6ecc5498..d41210cd 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs @@ -758,3 +758,79 @@ fn item_bindings_return_the_pushed_repair_cost() { .unwrap(); assert!(s.take_errors().is_empty()); } + +/// `SetInventoryItem` (`0x532ee0`) and `SetBagItem` (`0x534620`) push `hasCooldown` as the number 1 +/// (`0x5332d3`, `0x534958`) or nil (`0x5332da`, `0x534966`), never a Lua boolean, and the counts +/// stay 3 and 2. +#[test] +fn item_bindings_push_has_cooldown_as_one_or_nil() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + let mut inv: InventorySlots = Default::default(); + inv[16] = Some(InvSlotView { + item_id: 8200, + name: Some("Timed Blade".into()), + quality: 2, + duration_ms: Some(60_000), + ..Default::default() + }); + inv[17] = Some(InvSlotView { + item_id: 8200, + name: Some("Timed Blade".into()), + quality: 2, + ..Default::default() + }); + s.set_inventory_slots(inv); + let slot = |cooldown, duration_ms| ContainerSlot { + item_id: 8200, + count: 1, + quality: Some(2), + link: Some("|cff1eff00|Hitem:8200:0:0:0|h[Timed Blade]|h|r".into()), + cooldown, + duration_ms, + ..Default::default() + }; + s.set_container( + 0, + Some(ContainerState { + name: Some("Backpack".into()), + num_slots: 24, + slots: HashMap::from([ + (1, slot(Some((1000, 30_000, true)), None)), + (2, slot(None, None)), + (3, slot(None, Some(60_000))), + ]), + }), + ); + s.set_item_template( + 8200, + ItemTemplateView { + name: "Timed Blade".into(), + quality: 2, + ..Default::default() + }, + ); + s.run( + r#" + local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0) + a:SetWidth(10); a:SetHeight(10) + local tt = CreateFrame("GameTooltip", "TT") + tt:SetOwner(a, "ANCHOR_RIGHT") + -- `arg.n` counts the returns (`select` is 5.1's, not a 1.12 global). + local function r(...) return arg.n, arg[2] end + local function b(...) return arg.n, arg[1] end + local n, cd = r(tt:SetInventoryItem("player", 16)) + assert(n == 3 and cd == 1 and type(cd) == "number", "timed item: " .. tostring(cd)) + n, cd = r(tt:SetInventoryItem("player", 17)) + assert(n == 3 and cd == nil, "plain item: " .. tostring(cd)) + n, cd = b(tt:SetBagItem(0, 1)) + assert(n == 2 and cd == 1 and type(cd) == "number", "running cooldown: " .. tostring(cd)) + n, cd = b(tt:SetBagItem(0, 2)) + assert(n == 2 and cd == nil, "cold item is nil, not false: " .. tostring(cd)) + n, cd = b(tt:SetBagItem(0, 3)) + assert(n == 2 and cd == 1, "timed item: " .. tostring(cd)) + "#, + ) + .unwrap(); + assert!(s.take_errors().is_empty()); +} diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index 4b488965..777849ed 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -87,14 +87,15 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { Ok(anchor.name()) })?, )?; - // IsOwned(frame): the hover loop's OnUpdate gate (`ContainerFrame.lua:657`); the owner drops - // on hide, so a stale OnUpdate never brings a hidden tooltip back. + // IsOwned(frame) (`0x530fe0`): 1 (`0x5310ad`) or nil (`0x5310c0`), the hover loop's OnUpdate + // gate (`ContainerFrame.lua:657`); the owner drops on hide, so a stale OnUpdate never brings a + // hidden tooltip back. m.set( "IsOwned", lua.create_function(|lua, (this, frame): (Table, Table)| { let owner = with_tip(lua, &this, |t| t.owner)?; let fh = frame_handle_of(lua, &frame)?; - Ok(owner == Some(fh)) + Ok(crate::script::binding_abi::flag(owner == Some(fh))) })?, )?; diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index 60de4faf..47185f88 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -12,6 +12,7 @@ use mlua::{Lua, MultiValue, Table, Value}; +use super::binding_abi::flag; use super::object::frame_handle_of; use super::tooltip::{append_line, clear, clear_silent, show_or_hide_empty}; use super::{ItemTemplateView, Model}; @@ -409,11 +410,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { // hasCooldown is the builder's `[ebp-0x38]`, set by four lines: // LOCKED_WITH_ITEM, the temporary-enchant countdown, the item's duration // (`0x52ce0d`) and ITEM_COOLDOWN_TIME. Only the duration is answered here. - if inst.duration_ms.is_some() { - Value::Boolean(true) - } else { - Value::Nil - }, + flag(inst.duration_ms.is_some()), Value::Integer(i64::from(repair_cost)), ])) }, @@ -505,7 +502,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { show_or_hide_empty(lua, h); Ok(MultiValue::from_vec(vec![ // The builder's `[ebp-0x38]`: a running cooldown or the item's duration line. - Value::Boolean(has_cd || inst.duration_ms.is_some()), + flag(has_cd || inst.duration_ms.is_some()), Value::Integer(i64::from(repair_cost)), ])) })?, diff --git a/crates/benilla-ui/src/script/trainer/mod.rs b/crates/benilla-ui/src/script/trainer/mod.rs index f8117bf1..6f51e4bd 100644 --- a/crates/benilla-ui/src/script/trainer/mod.rs +++ b/crates/benilla-ui/src/script/trainer/mod.rs @@ -111,7 +111,8 @@ pub struct TrainerService { /// The wire spell's rank text, such as "Rank 2". pub subtext: Option, pub texture: Option, - pub description: String, + /// The expanded text; `None` answers nil (`0x4d9d90`). + pub description: Option, /// In copper, already reputation-discounted by the server. pub cost: u32, /// A primary profession's first rank: the stock window's `cpCost2 > 0`, which asks to confirm. @@ -510,10 +511,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "GetTrainerServiceDescription", lua.create_function(|lua, index: usize| { let model = lua.app_data_ref::().expect("model app_data"); - let text = service(&model, index) - .map(|s| s.description.clone()) - .unwrap_or_default(); - lua.create_string(&text) + opt_str( + lua, + service(&model, index).and_then(|s| s.description.as_ref()), + ) })?, )?; diff --git a/crates/benilla-ui/src/script/trainer/tests.rs b/crates/benilla-ui/src/script/trainer/tests.rs index def3be58..8f03aeae 100644 --- a/crates/benilla-ui/src/script/trainer/tests.rs +++ b/crates/benilla-ui/src/script/trainer/tests.rs @@ -19,7 +19,7 @@ fn svc( name: Some(name.into()), subtext: Some("Rank 1".into()), texture: Some(format!("Interface\\Icons\\Spell_{spell_id}")), - description: format!("Teaches {name}."), + description: Some(format!("Teaches {name}.")), cost: 100, prof_first_rank: false, category: cat, @@ -107,6 +107,31 @@ fn tree_interleaves_headers_and_ordered_services() { assert_eq!(info(&mut s, 5), ("Bloodrage".into(), "unavailable".into())); } +/// `GetTrainerServiceDescription` (`0x4d9b40`) pushes one value, nil (`0x4d9d90`) where there is +/// no text to show: no row at the index (`0x4d9ba8` to `0x4d9d93`), a service with no spell record +/// (`0x4d9bd7`), or a service whose spell has none (`0x4d9d44`, `0x4d9d49`). A header row has no service either. +#[test] +fn service_description_is_nil_without_text() { + let mut s = UiScript::new().unwrap(); + let mut t = trainer(); + t.services[1].description = None; + s.set_trainer(Some(t)); + + // `arg.n` counts the returns (`select` is 5.1's, not a 1.12 global). + let shape = |s: &mut UiScript, i: i64| { + s.eval::(&format!( + "local function c(...) return arg.n .. ':' .. type(arg[1]) end \ + return c(GetTrainerServiceDescription({i}))" + )) + .unwrap() + }; + assert_eq!(shape(&mut s, 2), "1:string"); + assert_eq!(shape(&mut s, 3), "1:nil", "a service with no text"); + assert_eq!(shape(&mut s, 4), "1:nil", "a header row"); + assert_eq!(shape(&mut s, 99), "1:nil", "past the last row"); + assert_eq!(shape(&mut s, 0), "1:nil", "before the first"); +} + #[test] fn service_getters_read_the_row_at_a_visible_index() { let mut s = UiScript::new().unwrap(); diff --git a/crates/benilla-ui/src/script/zone_text.rs b/crates/benilla-ui/src/script/zone_text.rs index dd014da9..9c98e28b 100644 --- a/crates/benilla-ui/src/script/zone_text.rs +++ b/crates/benilla-ui/src/script/zone_text.rs @@ -21,8 +21,8 @@ pub struct ZoneTexts { /// `GetZonePVPInfo`'s type, `"friendly"`, `"hostile"` or `"contested"`; `None` when the /// player, the zone or its faction template is missing, or on a PvE realm outside a capital. pub pvp_type: Option, - /// `GetZonePVPInfo`'s faction, `FactionGroup.dbc`'s name for the zone's mask bit; `None` for - /// none. + /// `GetZonePVPInfo`'s faction, `FactionGroup.dbc`'s name for the zone's mask bit, `""` when no + /// row names one (`0x48d633`); `None` only where `pvp_type` is `None` (`0x48d6b5`). pub pvp_faction: Option, /// `GetZonePVPInfo`'s isArena: the leaf area's flag `0x80`, the free-for-all pit. pub is_arena: bool,