From 9be2802f390f356cfa8b040bc0d656cf633dccc2 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:02:34 +0700 Subject: [PATCH] Flag predicates answer 1 or nil, and the gate now sees them all (#904) Fourteen bindings answered Lua `true`/`false` where 1.12.1 pushes the number 1 or nil: the loot slot, resurrect and distance predicates, the talent flags (`isExceptional` read 0, which is true, for every ordinary talent), the cursor predicates, and `GetLootRollItemInfo`'s bind flag, whose count also echoed the stack size where the reference answers 1. `GetRepairAllCost` returned two values with no vendor open, and `GetLootRollItemInfo` a sixth value. The reference pushes 1 or nil for each and returns exactly the values its catalogue row records. All of them now answer through `binding_abi::flag`, and `GetRepairAllCost` answers the one value 0 unless a repairing merchant is open. The boolean gate, which missed these, now probes every row whose kinds are nil or number whatever its name or arity confidence, over seeded talent and roll state. The widened gate fails on the parent for all fourteen. Fixes #159 Fixes #900 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/benilla-app/src/ui_loot_roll.rs | 3 - .../src/ui_script/group_loot_tests.rs | 3 - .../benilla-app/src/ui_script/shape_gate.rs | 174 ++++++++++++++---- crates/benilla-ui/src/script/binder.rs | 6 +- crates/benilla-ui/src/script/cursor.rs | 5 +- crates/benilla-ui/src/script/cursor/doll.rs | 2 +- crates/benilla-ui/src/script/death.rs | 12 +- crates/benilla-ui/src/script/loot.rs | 18 +- crates/benilla-ui/src/script/loot_roll.rs | 52 +++--- crates/benilla-ui/src/script/merchant.rs | 38 ++-- crates/benilla-ui/src/script/talent.rs | 11 +- crates/benilla-ui/src/script/tests/talent.rs | 38 +++- 12 files changed, 261 insertions(+), 101 deletions(-) diff --git a/crates/benilla-app/src/ui_loot_roll.rs b/crates/benilla-app/src/ui_loot_roll.rs index 00f826a6..70f6608f 100644 --- a/crates/benilla-app/src/ui_loot_roll.rs +++ b/crates/benilla-app/src/ui_loot_roll.rs @@ -445,9 +445,6 @@ fn snapshot( roll_id: r.roll_id, name: t.map(|t| catalogs.name(&t.name, r.random_property_id)), texture, - // The reference's `GetLootRollItemInfo` returns a literal 1 (`0x4c3160`); the - // packet carries no count. - quantity: 1, quality: t.map(|t| t.quality), bind_on_pickup: t.is_some_and(|t| t.bonding == BIND_WHEN_PICKED_UP), time_left_ms: r.remaining_ms, diff --git a/crates/benilla-app/src/ui_script/group_loot_tests.rs b/crates/benilla-app/src/ui_script/group_loot_tests.rs index eb41c100..49f6614e 100644 --- a/crates/benilla-app/src/ui_script/group_loot_tests.rs +++ b/crates/benilla-app/src/ui_script/group_loot_tests.rs @@ -99,7 +99,6 @@ fn rolls() -> LootRollsState { roll_id: 7, name: Some("Staff of Jordan".into()), texture: Some("Interface\\Icons\\INV_Staff_12".into()), - quantity: 1, quality: Some(4), // Epic -> purple bind_on_pickup: true, time_left_ms: 42_000, @@ -111,7 +110,6 @@ fn rolls() -> LootRollsState { roll_id: 8, name: Some("Worn Shortsword".into()), texture: Some("Interface\\Icons\\INV_Sword_04".into()), - quantity: 1, quality: Some(1), // Common -> white bind_on_pickup: false, time_left_ms: 60_000, @@ -124,7 +122,6 @@ fn rolls() -> LootRollsState { roll_id: 9, name: None, texture: None, - quantity: 1, quality: None, bind_on_pickup: false, time_left_ms: 55_000, diff --git a/crates/benilla-app/src/ui_script/shape_gate.rs b/crates/benilla-app/src/ui_script/shape_gate.rs index a8b4f1fc..f575c4e4 100644 --- a/crates/benilla-app/src/ui_script/shape_gate.rs +++ b/crates/benilla-app/src/ui_script/shape_gate.rs @@ -5,7 +5,8 @@ //! are gated with arity. The table says per row what to trust: `arity_conf = exact`, kinds only //! where `kinds_conf = agree`, never `argc_conf`. Only query verbs are probed, a call that raises //! is skipped, and a name registered from several tables (`GetBuildInfo` is 5 in the glue table -//! `0x8373b8`, 3 in-game at `0x83de68`) is read at its non-glue row. +//! `0x8373b8`, 3 in-game at `0x83de68`) is read at its non-glue row. The boolean gate alone reads every `global` row, whatever its +//! `arity_conf`, and adds each row whose kinds are `(nil) | (number)` to the query verbs. /// Globals known to answer the wrong arity: a name, the reference's arity and the reason. The /// list may only shrink. @@ -706,21 +707,63 @@ fn every_widget_method_answers_the_reference_s_return_kinds() { ); } -/// Argument tuples the boolean gate tries in order until a call returns. A plausible wrong -/// argument can change which kinds come back, which is why the kind gate passes only a unit -/// token, but it never turns `1` into `true`. -const ARG_LADDER: &[&str] = &["", "\"player\"", "1", "1, 1", "\"player\", 1"]; +/// Argument tuples the boolean gate tries, every one that returns being measured: a plausible +/// wrong argument can change which kinds come back, which is why the kind gate passes only a unit +/// token, but it never turns `1` into `true`. The ids and indices reach the rows +/// [`seed_boolean_probe_state`] pushes, each leg of a predicate on its own tuple. +const ARG_LADDER: &[&str] = &[ + "", + "\"player\"", + "1", + "2", + "3", + "1, 1", + "1, 2", + "2, 1", + "\"player\", 1", + "\"player\", \"player\"", +]; -/// 1.12 has no boolean query: of the table's trustworthy rows only `IsPetAttackActive` and Lua's -/// own `rawequal` answer a `boolean`, and every other predicate pushes `1` or `nil`, which -/// `x == 1`, `tostring(x)` or a saved variable tell apart from `true`. -#[test] -fn no_query_binding_answers_a_lua_boolean() { - // The reference's own two; the list may only shrink. - const REFERENCE_BOOLEANS: &[&str] = &["IsPetAttackActive", "rawequal"]; +/// 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)"; - let all = rows(); - let mut s = benilla_ui::script::UiScript::new().expect("VM"); +/// 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`). +fn predicate_names() -> Vec { + let tsv = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../reference/1.12-shapes.tsv" + ); + let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv"); + let mut names: Vec = text + .lines() + .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()) + }) + .collect(); + names.sort_unstable(); + names.dedup(); + names +} + +/// 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. +fn seed_boolean_probe_state(s: &mut benilla_ui::script::UiScript) { + use benilla_ui::script::{ + LootRollEntry, LootRollsState, TalentPrereqView, TalentTabView, TalentUiState, TalentView, + }; s.set_unit( "player", Some(benilla_ui::script::UnitState { @@ -730,24 +773,85 @@ fn no_query_binding_answers_a_lua_boolean() { ..Default::default() }), ); + s.set_talents(TalentUiState { + tabs: vec![TalentTabView { + name: "Probe".into(), + background: "Interface\\TalentFrame\\Probe-".into(), + points_spent: 0, + }], + talents: vec![vec![ + TalentView { + name: "Ordinary".into(), + tier: 1, + column: 1, + max_rank: 5, + meets_prereq: true, + ..Default::default() + }, + TalentView { + name: "Exceptional".into(), + tier: 2, + column: 1, + max_rank: 1, + exceptional: true, + prereqs: vec![ + TalentPrereqView { + tier: 1, + column: 1, + learnable: true, + }, + TalentPrereqView { + tier: 1, + column: 2, + learnable: false, + }, + ], + ..Default::default() + }, + ]], + points: (1, 0), + }); + s.set_loot_rolls(LootRollsState { + rolls: vec![ + LootRollEntry { + roll_id: 1, + name: Some("Bound".into()), + texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()), + quality: Some(4), + bind_on_pickup: true, + item_id: 1, + ..Default::default() + }, + LootRollEntry { + roll_id: 2, + item_id: 2, + ..Default::default() + }, + ], + }); + s.set_talent_master_pending(true); +} + +/// 1.12 has no boolean query: of the table's trustworthy rows only `IsPetAttackActive` and Lua's +/// own `rawequal` answer a `boolean`, and every other predicate pushes `1` or `nil`, which +/// `x == 1`, `tostring(x)` or a saved variable tell apart from `true`. +#[test] +fn no_query_binding_answers_a_lua_boolean() { + // The reference's own two; the list may only shrink. + const REFERENCE_BOOLEANS: &[&str] = &["IsPetAttackActive", "rawequal"]; + + let mut s = benilla_ui::script::UiScript::new().expect("VM"); + seed_boolean_probe_state(&mut s); s.run(KIND_HELPER).expect("kind helper"); - let mut names: Vec<&str> = all - .iter() - .map(|r| r.name.as_str()) - .filter(|n| { - ["Get", "Is", "Has", "Can", "Unit", "Num"] - .iter() - .any(|p| n.starts_with(p)) - }) - .filter(|n| !REFERENCE_BOOLEANS.contains(n)) + let names: Vec = predicate_names() + .into_iter() + .filter(|n| !REFERENCE_BOOLEANS.contains(&n.as_str())) .collect(); - names.sort_unstable(); - names.dedup(); - let mut checked = 0usize; + let mut measured = std::collections::BTreeSet::new(); let mut booleans: Vec = Vec::new(); - for name in names { + for name in &names { for args in ARG_LADDER { let probe = format!( "if type({name}) ~= 'function' then return '?' end \ @@ -760,17 +864,23 @@ fn no_query_binding_answers_a_lua_boolean() { if got == "?" { continue; } - checked += 1; - if got.split(',').any(|k| k == "boolean") { + measured.insert(name.as_str()); + // One line per binding: the first rung that answers a boolean. + if got.split(',').any(|k| k == "boolean") + && !booleans + .last() + .is_some_and(|l| l.starts_with(&format!("{name}("))) + { booleans.push(format!("{name}({args}) answers ({got})")); } - break; } } assert!( - checked >= 300, - "the boolean gate measured only {checked} bindings — it has stopped covering anything" + // A floor, not a target: raise it when coverage rises, never lower it to fit a change. + measured.len() >= 500, + "the boolean gate measured only {} bindings — it has stopped covering anything", + measured.len() ); assert!( booleans.is_empty(), diff --git a/crates/benilla-ui/src/script/binder.rs b/crates/benilla-ui/src/script/binder.rs index 2487e217..1b588af2 100644 --- a/crates/benilla-ui/src/script/binder.rs +++ b/crates/benilla-ui/src/script/binder.rs @@ -4,6 +4,7 @@ use mlua::Lua; +use super::binding_abi::flag; use super::Model; impl super::UiScript { @@ -36,13 +37,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // `CheckBinderDist()`, polled by the dialog's OnUpdate. This answers a boolean; the - // reference (`0x48d210`) answers 1 or nil. + // `CheckBinderDist()`, polled by the dialog's OnUpdate: 1 or nil (`0x48d210`). g.set( "CheckBinderDist", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(model.binder_pending) + Ok(flag(model.binder_pending)) })?, )?; diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index e11b6194..c055ac24 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -5,6 +5,7 @@ use mlua::Lua; +use super::binding_abi::flag; use super::{Model, ScriptValue}; mod bag_verbs; @@ -460,14 +461,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "CursorHasItem", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(matches!(model.cursor, Some(CursorPayload::Item(_)))) + Ok(flag(matches!(model.cursor, Some(CursorPayload::Item(_))))) })?, )?; g.set( "CursorHasSpell", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(matches!(model.cursor, Some(CursorPayload::Spell(_)))) + Ok(flag(matches!(model.cursor, Some(CursorPayload::Spell(_))))) })?, )?; g.set( diff --git a/crates/benilla-ui/src/script/cursor/doll.rs b/crates/benilla-ui/src/script/cursor/doll.rs index a95e54cb..f842d95f 100644 --- a/crates/benilla-ui/src/script/cursor/doll.rs +++ b/crates/benilla-ui/src/script/cursor/doll.rs @@ -210,7 +210,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "CursorCanGoInSlot", lua.create_function(|lua, id: u32| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(cursor_can_go_in_slot(&model, id)) + Ok(flag(cursor_can_go_in_slot(&model, id))) })?, )?; g.set( diff --git a/crates/benilla-ui/src/script/death.rs b/crates/benilla-ui/src/script/death.rs index 5330a4d0..e50d0d78 100644 --- a/crates/benilla-ui/src/script/death.rs +++ b/crates/benilla-ui/src/script/death.rs @@ -1,11 +1,11 @@ //! The release, reclaim, resurrect and self-res verbs and the getters the `DEATH`, //! `RECOVER_CORPSE`, `RESURRECT` and `XP_LOSS` dialogs call, over a snapshot the app pushes. -//! The three predicates answer Lua booleans where the reference answers `1` or nil -//! (`ResurrectHasSickness` `0x48aa00`, `ResurrectHasTimer` `0x48aa30`, `CheckSpiritHealerDist` -//! `0x48d120`). +//! The three predicates answer `1` or nil, as the reference does (`ResurrectHasSickness` +//! `0x48aa00`, `ResurrectHasTimer` `0x48aa30`, `CheckSpiritHealerDist` `0x48d120`). use mlua::{Lua, Value}; +use super::binding_abi::flag; use super::Model; /// The death snapshot the app pushes each frame. @@ -106,21 +106,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "ResurrectHasSickness", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(model.death.resurrect_sickness) + Ok(flag(model.death.resurrect_sickness)) })?, )?; g.set( "ResurrectHasTimer", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(model.death.resurrect_has_timer) + Ok(flag(model.death.resurrect_has_timer)) })?, )?; g.set( "CheckSpiritHealerDist", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(model.death.spirit_healer_in_range) + Ok(flag(model.death.spirit_healer_in_range)) })?, )?; diff --git a/crates/benilla-ui/src/script/loot.rs b/crates/benilla-ui/src/script/loot.rs index 31cad993..82086792 100644 --- a/crates/benilla-ui/src/script/loot.rs +++ b/crates/benilla-ui/src/script/loot.rs @@ -219,10 +219,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "LootSlotIsItem", lua.create_function(|lua, slot: usize| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(slot - .checked_sub(1) - .and_then(|n| model.loot.as_ref()?.rows.get(n)?.as_ref()) - .is_some_and(|r| !r.is_coin)) + Ok(flag( + slot.checked_sub(1) + .and_then(|n| model.loot.as_ref()?.rows.get(n)?.as_ref()) + .is_some_and(|r| !r.is_coin), + )) })?, )?; @@ -230,10 +231,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "LootSlotIsCoin", lua.create_function(|lua, slot: usize| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(slot - .checked_sub(1) - .and_then(|n| model.loot.as_ref()?.rows.get(n)?.as_ref()) - .is_some_and(|r| r.is_coin)) + Ok(flag( + slot.checked_sub(1) + .and_then(|n| model.loot.as_ref()?.rows.get(n)?.as_ref()) + .is_some_and(|r| r.is_coin), + )) })?, )?; diff --git a/crates/benilla-ui/src/script/loot_roll.rs b/crates/benilla-ui/src/script/loot_roll.rs index 06429478..d13de001 100644 --- a/crates/benilla-ui/src/script/loot_roll.rs +++ b/crates/benilla-ui/src/script/loot_roll.rs @@ -6,6 +6,7 @@ use mlua::Lua; +use super::binding_abi::flag; use super::Model; /// `rollType` 0, the one vote the bind-on-pickup gate never intercepts. @@ -26,17 +27,13 @@ pub struct LootRollEntry { pub name: Option, /// Icon texture path (`Interface\Icons\…`). `None` only if the display catalog had no icon. pub texture: Option, - /// The stack size, answered as `GetLootRollItemInfo`'s `count`; the reference answers a literal - /// `1.0` there, a group roll being one stack (`0x4c3160`). - pub quantity: u32, /// Item quality 0..6; `None` while the template is in flight, answered as `CACHE_MISS_QUALITY`. pub quality: Option, /// Bind on pickup, which swaps in the gold backdrop; `false` while the template is in flight. pub bind_on_pickup: bool, /// Milliseconds left, from `SMSG_LOOT_START_ROLL`'s countdown; saturates at 0. pub time_left_ms: u32, - /// The item id, the key of the hover's item lookup; `GetLootRollItemInfo` also returns it as a - /// sixth value, which 1.12 does not. + /// The item id, the key of the hover's item lookup. pub item_id: u32, /// The link `GetLootRollItemLink` answers for the icon's ctrl/shift clicks /// (`LootFrame.xml:353-361`); `None` until the item template lands, since it embeds the name. @@ -78,8 +75,10 @@ fn find(model: &Model, roll_id: u32) -> Option<&LootRollEntry> { pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); - // `0x4c3050`, read at `LootFrame.lua:261`. An unknown id takes the reference's miss tail - // `nil, nil, 1.0, 1.0, nil` (`0x4c31a3`), as a roll with no item template does there. + // `0x4c3050`, read at `LootFrame.lua:261`. `count` is the literal 1.0 (`0x4c3160`) and + // `bindOnPickup` 1.0 when `[template+0x194] == 1` else nil (`0x4c317e`). An unknown id takes + // the reference's miss tail `nil, nil, 1.0, 1.0, nil` (`0x4c31a3`), as a roll with no item + // template does there. g.set( "GetLootRollItemInfo", lua.create_function(|lua, roll_id: u32| { @@ -91,16 +90,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { 1, CACHE_MISS_QUALITY, mlua::Value::Nil, - mlua::Value::Nil, )); }; let vals = ( r.texture.clone(), r.name.clone(), - r.quantity, + 1, r.quality.map_or(CACHE_MISS_QUALITY, i64::from), - r.bind_on_pickup, - r.item_id, + flag(r.bind_on_pickup), ); lua.pack_multi(vals) })?, @@ -182,7 +179,6 @@ mod tests { roll_id: 7, name: Some("Staff of Jordan".into()), texture: Some("Interface\\Icons\\INV_Staff_12".into()), - quantity: 1, quality: Some(4), bind_on_pickup: true, time_left_ms: 42_000, @@ -195,7 +191,6 @@ mod tests { roll_id: 8, name: None, texture: None, - quantity: 2, quality: None, bind_on_pickup: false, time_left_ms: 60_000, @@ -218,26 +213,35 @@ mod tests { s.set_loot_rolls(rolls()); let (texture, name, count, quality, bop) = s - .eval::<(String, String, i64, i64, bool)>("return GetLootRollItemInfo(7)") + .eval::<(String, String, i64, i64, i64)>("return GetLootRollItemInfo(7)") .unwrap(); assert_eq!(texture, "Interface\\Icons\\INV_Staff_12"); assert_eq!(name, "Staff of Jordan"); - assert_eq!((count, quality, bop), (1, 4, true)); + assert_eq!((count, quality, bop), (1, 4, 1)); assert_eq!( s.eval::("return GetLootRollTimeLeft(7)").unwrap(), 42_000 ); - assert_eq!( - s.eval::("local _, _, _, _, _, id = GetLootRollItemInfo(7)\nreturn id") - .unwrap(), - 17182 - ); + // Five values, a hit and the miss tail alike (`0x4c31a3`). + for id in [7, 99] { + let vals = s + .eval::(&format!("return GetLootRollItemInfo({id})")) + .unwrap(); + assert_eq!(vals.len(), 5, "roll {id}"); + } - // In flight: the quality is the miss tail's 1, never nil. + // In flight: the quality is the miss tail's 1, never nil; the count is the literal 1 + // whatever the stack, and a roll that does not bind answers nil, not false. assert!(s .eval::( "local t, n, c, q, b = GetLootRollItemInfo(8)\n\ - return t == nil and n == nil and q == 1 and c == 2 and b == false", + return t == nil and n == nil and q == 1 and c == 1 and b == nil", + ) + .unwrap()); + assert!(s + .eval::( + "local _, _, _, _, b7 = GetLootRollItemInfo(7)\n\ + return type(b7) == 'number' and b7 == 1", ) .unwrap()); @@ -259,8 +263,8 @@ mod tests { assert_eq!((count, quality), (1, 1), "the miss tail is 1, 1"); assert_eq!( s.arity("GetLootRollItemInfo(99)").unwrap(), - 6, - "five reference returns plus benilla's trailing item id" + 5, + "the reference's five returns (`0x4c31a3`)" ); assert!(s .eval::("return GetLootRollItemLink(99) == nil") diff --git a/crates/benilla-ui/src/script/merchant.rs b/crates/benilla-ui/src/script/merchant.rs index c1878992..257f3dd2 100644 --- a/crates/benilla-ui/src/script/merchant.rs +++ b/crates/benilla-ui/src/script/merchant.rs @@ -9,7 +9,7 @@ use mlua::{Lua, MultiValue, Value}; -use super::binding_abi::number_arg; +use super::binding_abi::{flag, number_arg}; use super::container::UiCursorMode; use super::cursor::CursorPayload; use super::Model; @@ -414,21 +414,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; // GetRepairAllCost() → cost, canRepair: whether there is damage to pay for, which enables the - // repair-all button (`MerchantFrame.lua:38`); 0 away from a vendor that repairs (`0x4fbd60`). + // repair-all button (`MerchantFrame.lua:38`). `canRepair` is 1 or nil (`0x4fbfa8`-`0x4fbfce`). + // With no open merchant that repairs (`0x4fadb0`) it answers the one value 0 (`0x4fbd76`). g.set( "GetRepairAllCost", lua.create_function(|lua, ()| { - let cost = { - let model = lua.app_data_ref::().expect("model app_data"); - if model.merchant.as_ref().is_some_and(|m| m.can_repair) { - model.repair_all_cost - } else { - 0 - } - }; + let model = lua.app_data_ref::().expect("model app_data"); + if !model.merchant.as_ref().is_some_and(|m| m.can_repair) { + return Ok(MultiValue::from_vec(vec![Value::Integer(0)])); + } + let cost = model.repair_all_cost; Ok(MultiValue::from_vec(vec![ Value::Integer(i64::from(cost)), - Value::Boolean(cost > 0), + flag(cost > 0), ])) })?, )?; @@ -783,11 +781,21 @@ mod tests { s.eval::("return GetRepairAllCost() == 0").unwrap(), "no vendor" ); + assert_eq!( + s.arity("GetRepairAllCost()").unwrap(), + 1, + "no vendor: `0x4fbd76`" + ); s.set_merchant(Some(stock())); assert!( s.eval::("return GetRepairAllCost() == 0").unwrap(), "a vendor that does not repair" ); + assert_eq!( + s.arity("GetRepairAllCost()").unwrap(), + 1, + "one value, 0, when `0x4fadb0` fails (`0x4fbd76`)" + ); } /// `RepairAllItems` returns before anything else unless the open merchant repairs @@ -814,8 +822,14 @@ mod tests { s.set_repair_all_cost(1234); assert!(s.eval::("return CanMerchantRepair() == 1").unwrap()); assert!(s - .eval::("local c, can = GetRepairAllCost()\nreturn c == 1234 and can == true",) + .eval::("local c, can = GetRepairAllCost()\nreturn c == 1234 and can == 1",) .unwrap()); + // No damage: `canRepair` is nil, not false (`0x4fbfce`). + s.set_repair_all_cost(0); + assert!(s + .eval::("local c, can = GetRepairAllCost()\nreturn c == 0 and can == nil",) + .unwrap()); + assert_eq!(s.arity("GetRepairAllCost()").unwrap(), 2); s.run("RepairAllItems()").unwrap(); assert!(s.take_repair_all()); diff --git a/crates/benilla-ui/src/script/talent.rs b/crates/benilla-ui/src/script/talent.rs index ba344327..71908440 100644 --- a/crates/benilla-ui/src/script/talent.rs +++ b/crates/benilla-ui/src/script/talent.rs @@ -19,6 +19,7 @@ use mlua::{Lua, MultiValue, Table, Value}; +use super::binding_abi::flag; use super::tooltip_spell::{spell_view_of, TalentLines}; use super::Model; @@ -172,8 +173,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { Value::Integer(i64::from(t.column)), Value::Integer(i64::from(t.rank)), Value::Integer(i64::from(t.max_rank)), - Value::Integer(i64::from(t.exceptional)), - Value::Boolean(t.meets_prereq), + flag(t.exceptional), + flag(t.meets_prereq), ])) })?, )?; @@ -188,7 +189,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { for p in &t.prereqs { out.push(Value::Integer(i64::from(p.tier))); out.push(Value::Integer(i64::from(p.column))); - out.push(Value::Boolean(p.learnable)); + out.push(flag(p.learnable)); } } Ok(MultiValue::from_vec(out)) @@ -217,14 +218,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // CheckTalentMasterDist(): polled by that dialog's OnUpdate, false hides it. The reference + // CheckTalentMasterDist(): polled by that dialog's OnUpdate, nil hides it. The reference // re-runs its interact-range test on the latched trainer (`0x5df980`, `d² <= [0xc4c28c]`); // no packet either way. g.set( "CheckTalentMasterDist", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(model.talent_master_pending) + Ok(flag(model.talent_master_pending)) })?, )?; diff --git a/crates/benilla-ui/src/script/tests/talent.rs b/crates/benilla-ui/src/script/tests/talent.rs index 7920b405..56111db8 100644 --- a/crates/benilla-ui/src/script/tests/talent.rs +++ b/crates/benilla-ui/src/script/tests/talent.rs @@ -75,13 +75,13 @@ fn bindings_read_the_pushed_snapshot() { local n, icon, tier, col, rank, max, exc, meets = GetTalentInfo(1, 1) assert(n == "Improved Fireball" and tier == 1 and col == 1) - assert(rank == 3 and max == 5 and exc == 0 and meets == true) + assert(rank == 3 and max == 5 and exc == nil and meets == 1) assert(icon == "Interface\\Icons\\Spell_Fire_FlameBolt") assert(GetTalentInfo(1, 9) == nil) -- The prereq triplets, flat (the reference walks arg[5], arg[6], arg[7], ...). local pt, pc, pl = GetTalentPrereqs(1, 2) - assert(pt == 1 and pc == 1 and pl == false) + assert(pt == 1 and pc == 1 and pl == nil) assert(GetTalentPrereqs(1, 1) == nil, "no prereqs is empty") local cp1, cp2 = UnitCharacterPoints("player") @@ -92,6 +92,40 @@ fn bindings_read_the_pushed_snapshot() { assert!(s.take_errors().is_empty()); } +/// `isExceptional` is `1.0` when `[rec+0x4c] & 1` and nil otherwise (`0x4f33e8`): never `0`, which +/// Lua reads as true, and `meetsPrereq` and each prereq's `isLearnable` are 1 or nil too +/// (`0x4f3403`-`0x4f3443`, `0x4f3676`, `0x4f3644`). `GetTalentPrereqs` answers three values per edge. +#[test] +fn talent_flags_are_one_or_nil() { + let mut s = script(); + let mut state = one_tab_state(); + state.talents[0][0].exceptional = true; + state.talents[0][1].meets_prereq = false; + state.talents[0][1].prereqs.push(TalentPrereqView { + tier: 1, + column: 2, + learnable: true, + }); + s.set_talents(state); + s.run( + r#" + local _, _, _, _, _, _, exc, meets = GetTalentInfo(1, 1) + assert(exc == 1 and type(exc) == "number" and meets == 1) + local _, _, _, _, _, _, exc2, meets2 = GetTalentInfo(1, 2) + assert(exc2 == nil, "an ordinary talent is not exceptional") + assert(meets2 == nil, "an unmet prerequisite is nil, not false") + + local t1, c1, l1, t2, c2, l2 = GetTalentPrereqs(1, 2) + assert(t1 == 1 and c1 == 1 and l1 == nil) + assert(t2 == 1 and c2 == 2 and l2 == 1 and type(l2) == "number") + "#, + ) + .unwrap(); + assert_eq!(s.arity("GetTalentInfo(1, 2)").unwrap(), 8); + assert_eq!(s.arity("GetTalentPrereqs(1, 2)").unwrap(), 6); + assert!(s.take_errors().is_empty()); +} + #[test] fn learn_talent_queues_for_the_app_drain() { let mut s = script();