Lua getters answer the reference's miss shapes; tooltip setters' flag (#1030)
Lua bindings answer the reference's shapes on misses, bad indices and tooltips: - Indexed getters push their full miss tuple (`GetCraftInfo`, `GetPetActionInfo`, the trainer requirements and others). Indices go through `ftol` and an unsigned compare, so a negative misses instead of raising. Verified slot readers keep their own raises (`IsInventoryItemLocked`, `0x4c8520`). - The eight GameTooltip setters return the builder's refresh flag, gated as `0x53267c` gates it, so a hovered cooldown tooltip refreshes. `LOCKED` follows the item object (`0x52bdb3`). - Quality goes through the InventoryType gate (-1), and a pending template shows the question-mark icon. - `GetLootSlotInfo` returns four values. `SetSpell`, `CreateMacro`, `JoinChannelByName`, `GetQuestReward` and the shapeshift and trade-skill filters raise or answer as the reference does. - The shape gate probes each getter's miss legs through an argument ladder. Fixes #145 Fixes #993 Fixes #994 Fixes #995 Fixes #996 Fixes #997 Fixes #998 Fixes #1016 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
781bf36ab3
commit
bb82dd1ac8
@@ -1584,6 +1584,7 @@ fn seed_cooldown_filmstrip(
|
||||
texture: texture.clone(),
|
||||
count: 1,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 6948,
|
||||
link: Some("|cffffffff|Hitem:6948|h[Hearthstone]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -1636,6 +1637,7 @@ fn seed_bag_window(
|
||||
texture: icon(disp),
|
||||
count,
|
||||
quality: Some(quality),
|
||||
inventory_type: 0,
|
||||
item_id: id,
|
||||
link: Some(format!("|cffffffff|Hitem:{id}|h[{name}]|h|r")),
|
||||
locked: false,
|
||||
@@ -1722,6 +1724,7 @@ fn seed_equipped_bags(
|
||||
texture: icon(disp),
|
||||
count,
|
||||
quality: Some(quality),
|
||||
inventory_type: 0,
|
||||
item_id: 0,
|
||||
link: Some(format!("|cffffffff|Hitem:0|h[{name}]|h|r")),
|
||||
locked: false,
|
||||
|
||||
@@ -24,7 +24,7 @@ use super::{
|
||||
|
||||
/// An item action's icon when its template has not answered or its display id has no
|
||||
/// `ItemDisplayInfo` row: the reference's literal `0x847fe4`, from the resolver's `0x5d8927`.
|
||||
pub(super) const MISSING_ITEM_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionMark";
|
||||
pub(crate) const MISSING_ITEM_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionMark";
|
||||
|
||||
/// The feed's memory of what it last pushed, for per-slot change events.
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -23,6 +23,7 @@ mod drain_tests;
|
||||
pub(crate) mod drop_item;
|
||||
mod errors;
|
||||
mod feed;
|
||||
pub(crate) use feed::MISSING_ITEM_ICON;
|
||||
#[cfg(test)]
|
||||
mod feed_tests;
|
||||
mod net;
|
||||
|
||||
@@ -1296,6 +1296,7 @@ mod bind_confirm_tests {
|
||||
texture: Some("Interface\\Icons\\INV_Axe_01".into()),
|
||||
count: 1,
|
||||
quality: Some(quality),
|
||||
inventory_type: 0,
|
||||
item_id: FLURRY_AXE,
|
||||
link: Some("|cffa335ee|Hitem:871:0:0:0|h[Flurry Axe]|h|r".into()),
|
||||
locked: false,
|
||||
|
||||
@@ -399,10 +399,7 @@ pub(super) fn feed_item_stats(
|
||||
commands: Res<NetCommands>,
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
// The caster the `$`-tokens read: its spell modifiers and its skills.
|
||||
(spell_mods, self_q): (
|
||||
Res<crate::spell::SpellModifiers>,
|
||||
Query<&ObjectStore, With<SelfPlayer>>,
|
||||
),
|
||||
(spell_mods, self_q, locks): CasterReads,
|
||||
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
|
||||
// The `$z` token: the bind point's area (`SMSG_BINDPOINTUPDATE`), named through `AreaTable`.
|
||||
home_bind: Option<Res<crate::net::HomeBind>>,
|
||||
@@ -479,23 +476,24 @@ pub(super) fn feed_item_stats(
|
||||
} else {
|
||||
mod_sensitive.remove(&id);
|
||||
}
|
||||
Some((
|
||||
id,
|
||||
template_view(
|
||||
&t,
|
||||
spell_res,
|
||||
Some(&spell_mods),
|
||||
&skill,
|
||||
skill_catalog,
|
||||
home_area,
|
||||
&|| gender,
|
||||
factions.as_deref().map(|f| f.catalog()),
|
||||
sub_classes.as_deref().map(|s| &s.0),
|
||||
classes.as_deref().map(|c| &c.0),
|
||||
icons.as_deref(),
|
||||
&get,
|
||||
),
|
||||
))
|
||||
let mut view = template_view(
|
||||
&t,
|
||||
spell_res,
|
||||
Some(&spell_mods),
|
||||
&skill,
|
||||
skill_catalog,
|
||||
home_area,
|
||||
&|| gender,
|
||||
factions.as_deref().map(|f| f.catalog()),
|
||||
sub_classes.as_deref().map(|s| &s.0),
|
||||
classes.as_deref().map(|c| &c.0),
|
||||
icons.as_deref(),
|
||||
&get,
|
||||
);
|
||||
// A lock id with no `Lock.dbc` row is no lock to the builder (`0x52bd6b`..
|
||||
// `0x52bda9`); with the catalog unloaded the id stands.
|
||||
view.lock_id = known_lock_id(view.lock_id, locks.as_deref().map(|l| &l.0));
|
||||
Some((id, view))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -504,6 +502,24 @@ pub(super) fn feed_item_stats(
|
||||
}
|
||||
}
|
||||
|
||||
/// What the `$`-tokens read of the caster, and the `Lock.dbc` the LOCKED line needs a row from
|
||||
/// (`0x52bd6b`).
|
||||
type CasterReads<'w, 's> = (
|
||||
Res<'w, crate::spell::SpellModifiers>,
|
||||
Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
|
||||
Option<Res<'w, crate::go_templates::Locks>>,
|
||||
);
|
||||
|
||||
/// The template's lock id when `Lock.dbc` holds its row, else 0: the builder's LOCKED line starts
|
||||
/// from that row (`0x52bd57`..`0x52bd76`), and a missing one is no lock. With the catalog not
|
||||
/// loaded the id stands.
|
||||
fn known_lock_id(lock_id: u32, locks: Option<&benilla_formats::LockCatalog>) -> u32 {
|
||||
match locks {
|
||||
Some(l) if l.slots(lock_id).is_none() => 0,
|
||||
_ => lock_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// The player state the tooltip's red requirement lines check: level, class, race, skills,
|
||||
/// proficiencies (`SMSG_SET_PROFICIENCY`) and reputation ranks (the DBC base plus the
|
||||
/// `SMSG_INITIALIZE_FACTIONS` standing), pushed on change.
|
||||
@@ -611,7 +627,8 @@ pub(super) fn feed_player_req(
|
||||
}
|
||||
|
||||
/// One bag slot from its item guid: instance, template, icon and use-spell cooldown. `None` is an
|
||||
/// empty slot; an occupied one not yet resolved is `Some` with empty fields.
|
||||
/// empty slot or one whose item object has not streamed; an occupied one whose template is not yet
|
||||
/// resolved is `Some` with empty fields.
|
||||
fn resolve_slot(
|
||||
guid: u64,
|
||||
objects: &Objects,
|
||||
@@ -637,48 +654,47 @@ fn resolve_slot(
|
||||
let enchant_ms: [Option<u64>; crate::items::ENCHANT_SLOTS] =
|
||||
std::array::from_fn(|s| countdowns.and_then(|c| c.enchant_remaining_display_ms(s as u32)));
|
||||
let duration_ms = countdowns.and_then(|c| c.lifetime_remaining_display_ms());
|
||||
let (entry, count, durability, readable, creator, flags, already_bound, roll, enchant_lines) =
|
||||
match objects.object(guid) {
|
||||
Some(fields) => (
|
||||
fields.object_entry().unwrap_or(0),
|
||||
fields.item_stack_count().unwrap_or(1),
|
||||
// Max 0 is indestructible: no line.
|
||||
fields
|
||||
.item_durability()
|
||||
.zip(fields.item_max_durability())
|
||||
.filter(|&(_, max)| max > 0),
|
||||
// Letter text on the instance, which the hover magnifier keys off; a template
|
||||
// `PageText` book does not set it.
|
||||
fields.item_text_id().is_some_and(|id| id != 0),
|
||||
// The creator's name for "<Made by %s>" and "Written by %s", `None` until the
|
||||
// name query answers.
|
||||
fields
|
||||
.item_creator()
|
||||
.filter(|&g| g != 0)
|
||||
.and_then(|g| names.resolve(g, commands).map(str::to_string)),
|
||||
// The tooltip's unlocked (0x4) and wrapped (0x8) bits.
|
||||
fields.item_flags().unwrap_or(0),
|
||||
// `0x5da2c0`: soulbound, or carrying a binding enchant, off the raw descriptor.
|
||||
crate::items::already_bound(fields, rolls.enchants),
|
||||
// The roll behind the name's suffix; its enchants are already in slots 2..6.
|
||||
fields.item_random_properties_id(),
|
||||
// All seven enchant slots with charges and countdowns: our own items stream whole,
|
||||
// where others' carry two slots.
|
||||
crate::items::enchant_lines(
|
||||
(0..7).map(|s| {
|
||||
(
|
||||
s,
|
||||
fields.item_enchant(s).unwrap_or(0),
|
||||
fields.item_enchant_charges(s),
|
||||
enchant_ms[usize::from(s)],
|
||||
)
|
||||
}),
|
||||
rolls.enchants,
|
||||
),
|
||||
),
|
||||
// A guid whose create has not landed: occupied, unresolved.
|
||||
None => return Some(ContainerSlot::default()),
|
||||
};
|
||||
// A guid whose create has not landed: the reference finds no item object (`0x6228a0` returns
|
||||
// NULL), so `GetContainerItemInfo` pushes nothing (`0x4f96dc`, `0x4f96e0`, `0x4f9810`) and
|
||||
// the slot reads empty.
|
||||
let fields = objects.object(guid)?;
|
||||
let (entry, count, durability, readable, creator, flags, already_bound, roll, enchant_lines) = (
|
||||
fields.object_entry().unwrap_or(0),
|
||||
fields.item_stack_count().unwrap_or(1),
|
||||
// Max 0 is indestructible: no line.
|
||||
fields
|
||||
.item_durability()
|
||||
.zip(fields.item_max_durability())
|
||||
.filter(|&(_, max)| max > 0),
|
||||
// Letter text on the instance, which the hover magnifier keys off; a template
|
||||
// `PageText` book does not set it.
|
||||
fields.item_text_id().is_some_and(|id| id != 0),
|
||||
// The creator's name for "<Made by %s>" and "Written by %s", `None` until the
|
||||
// name query answers.
|
||||
fields
|
||||
.item_creator()
|
||||
.filter(|&g| g != 0)
|
||||
.and_then(|g| names.resolve(g, commands).map(str::to_string)),
|
||||
// The tooltip's unlocked (0x4) and wrapped (0x8) bits.
|
||||
fields.item_flags().unwrap_or(0),
|
||||
// `0x5da2c0`: soulbound, or carrying a binding enchant, off the raw descriptor.
|
||||
crate::items::already_bound(fields, rolls.enchants),
|
||||
// The roll behind the name's suffix; its enchants are already in slots 2..6.
|
||||
fields.item_random_properties_id(),
|
||||
// All seven enchant slots with charges and countdowns: our own items stream whole,
|
||||
// where others' carry two slots.
|
||||
crate::items::enchant_lines(
|
||||
(0..7).map(|s| {
|
||||
(
|
||||
s,
|
||||
fields.item_enchant(s).unwrap_or(0),
|
||||
fields.item_enchant_charges(s),
|
||||
enchant_ms[usize::from(s)],
|
||||
)
|
||||
}),
|
||||
rolls.enchants,
|
||||
),
|
||||
);
|
||||
if entry == 0 {
|
||||
return Some(ContainerSlot::default());
|
||||
}
|
||||
@@ -717,6 +733,7 @@ fn resolve_slot(
|
||||
count,
|
||||
durability,
|
||||
quality: Some(t.quality),
|
||||
inventory_type: t.inventory_type,
|
||||
item_id: entry,
|
||||
// The link carries the roll in `randomPropertyId` and in the suffixed name (`0x5d8b00`);
|
||||
// the tooltip reads its name back off it.
|
||||
@@ -1408,10 +1425,82 @@ fn diff_and_push(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
apply_container_source, charges_count, ContainerSlot, FeedMemory, SlotGuids,
|
||||
apply_container_source, charges_count, resolve_slot, ContainerSlot, FeedMemory, SlotGuids,
|
||||
BANK_CONTAINER, BANK_SLOTS,
|
||||
};
|
||||
|
||||
/// A lock id with no `Lock.dbc` row is no lock to the item builder (`0x52bd6b`..`0x52bda9`).
|
||||
#[test]
|
||||
fn a_lock_id_without_a_row_is_no_lock() {
|
||||
use benilla_formats::{LockCatalog, LockSlot, LOCK_KEY_ITEM};
|
||||
let mut slots = [LockSlot::default(); benilla_formats::MAX_LOCK_SLOTS];
|
||||
slots[0].key_type = LOCK_KEY_ITEM;
|
||||
let catalog = LockCatalog::from_rows([(7, slots)]);
|
||||
assert_eq!(super::known_lock_id(7, Some(&catalog)), 7);
|
||||
assert_eq!(super::known_lock_id(8, Some(&catalog)), 0);
|
||||
assert_eq!(
|
||||
super::known_lock_id(8, None),
|
||||
8,
|
||||
"no catalog, the id stands"
|
||||
);
|
||||
assert_eq!(super::known_lock_id(0, Some(&catalog)), 0);
|
||||
}
|
||||
|
||||
/// A bag slot carries the template's quality and InventoryType, which `GetContainerItemInfo`
|
||||
/// gates its fourth value on (`0x4f9794`); an unanswered template leaves them empty.
|
||||
#[test]
|
||||
fn a_resolved_slot_carries_the_templates_quality_and_inventory_type() {
|
||||
use crate::items::{test_template, Items, RollCatalogs};
|
||||
use crate::names::NameCache;
|
||||
use crate::net::NetCommands;
|
||||
use crate::ui_items::TestObjects;
|
||||
use benilla_protocol::ObjectFields;
|
||||
|
||||
const ENTRY: u16 = 3; // OBJECT_FIELD_ENTRY
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let mut objs = TestObjects::new();
|
||||
for (guid, entry) in [(0xA1_u64, 1234_u32), (0xA2, 1235), (0xA3, 1236)] {
|
||||
objs.spawn(guid, ObjectFields::from_pairs(&[(ENTRY, entry)]));
|
||||
}
|
||||
let mut items = Items::default();
|
||||
let mut sword = test_template("Sword");
|
||||
sword.quality = 4;
|
||||
sword.inventory_type = 13;
|
||||
items.insert_template(1234, Some(sword));
|
||||
items.insert_template(1235, Some(test_template("Healing Potion")));
|
||||
|
||||
let names = NameCache::default();
|
||||
let cooldowns = crate::spell::Cooldowns::default();
|
||||
let mut petitions = crate::ui_petition::PetitionState::default();
|
||||
let mut slot = |guid: u64| {
|
||||
resolve_slot(
|
||||
guid,
|
||||
&objs.get(),
|
||||
&items,
|
||||
None,
|
||||
RollCatalogs::NONE,
|
||||
&commands,
|
||||
&cooldowns,
|
||||
None,
|
||||
&names,
|
||||
&mut petitions,
|
||||
std::time::Instant::now(),
|
||||
0.0,
|
||||
false,
|
||||
)
|
||||
};
|
||||
// A guid whose item object has not streamed reads as no item at all (`0x6228a0` finds
|
||||
// none), not as an occupied blank.
|
||||
assert!(slot(0xA9).is_none());
|
||||
let sword = slot(0xA1).expect("an occupied slot");
|
||||
assert_eq!((sword.quality, sword.inventory_type), (Some(4), 13));
|
||||
let potion = slot(0xA2).expect("an occupied slot");
|
||||
assert_eq!((potion.quality, potion.inventory_type), (Some(1), 0));
|
||||
let pending = slot(0xA3).expect("an occupied slot");
|
||||
assert_eq!((pending.quality, pending.inventory_type), (None, 0));
|
||||
}
|
||||
|
||||
/// No bags and an empty vault.
|
||||
const NO_BAGS: SlotGuids = SlotGuids {
|
||||
bags: [0; 10],
|
||||
|
||||
@@ -320,6 +320,109 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `SetAction` answers 1 when the spell tooltip it built holds a line that changes while shown
|
||||
/// (the chance line, or the remaining cooldown of a spell whose recovery is a minute or more:
|
||||
/// `0x52f68c`, `0x52f581`), else nil, and the stock `ActionButton_SetTooltip` arms `updateTooltip`
|
||||
/// from it (`ActionButton.lua:377-381`); `ActionButton_OnUpdate` then rebuilds the hovered
|
||||
/// tooltip. Run through the stock bar.
|
||||
#[test]
|
||||
fn a_spell_tooltip_with_a_live_line_arms_the_stock_refresh() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
use benilla_ui::script::{ActionState, SpellTooltipView};
|
||||
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_action_bar(&s);
|
||||
s.set_action(
|
||||
1,
|
||||
Some(ActionSlot {
|
||||
texture: Some("Interface\\Icons\\Ability_Warrior_ShieldWall".into()),
|
||||
kind: 0x00,
|
||||
action: 871,
|
||||
count: 0,
|
||||
consumable: false,
|
||||
}),
|
||||
);
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||||
s.tick(10.0); // a nonzero GetTime epoch
|
||||
let view = |cast: &str, recovery_ms: u32, chance: Option<&str>| SpellTooltipView {
|
||||
name: "Shield Wall".into(),
|
||||
cast_time: Some(cast.into()),
|
||||
recovery_ms,
|
||||
chance: chance.map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
// The value the stock button saved from the setter's answer.
|
||||
let armed = |s: &mut UiScript| -> Option<f64> {
|
||||
s.eval::<Option<f64>>("return ActionButton1.updateTooltip")
|
||||
.unwrap()
|
||||
};
|
||||
let line2 = |s: &mut UiScript| -> String {
|
||||
s.eval::<String>("return GameTooltipTextLeft2:GetText() or ''")
|
||||
.unwrap()
|
||||
};
|
||||
let running = |duration_ms: u32| ActionState {
|
||||
usable: true,
|
||||
// Started at 9.4 s on the GetTime clock, so it is running at 10 s.
|
||||
cooldown: Some((9_400, duration_ms, true)),
|
||||
..Default::default()
|
||||
};
|
||||
let leave_and_enter = |s: &mut UiScript| {
|
||||
super::test_ui::unhover(s);
|
||||
s.tick(0.0);
|
||||
super::test_ui::hover(s, "ActionButton1");
|
||||
};
|
||||
|
||||
// No cooldown, no chance line: the setter answers nil, and nothing is armed.
|
||||
s.set_spell_tooltip(871, view("Instant", 1_800_000, None));
|
||||
super::test_ui::hover(&mut s, "ActionButton1");
|
||||
assert!(s.eval::<bool>("return GameTooltip:IsVisible()").unwrap());
|
||||
assert_eq!(armed(&mut s), None);
|
||||
|
||||
// A 30 minute recovery running: 1, so the button arms the stock refresh.
|
||||
s.set_action_state(1, Some(running(1_800_000)));
|
||||
leave_and_enter(&mut s);
|
||||
assert_eq!(
|
||||
armed(&mut s),
|
||||
s.eval::<Option<f64>>("return TOOLTIP_UPDATE_TIME").unwrap()
|
||||
);
|
||||
assert!(armed(&mut s).is_some_and(|t| t > 0.0));
|
||||
assert_eq!(line2(&mut s), "Instant");
|
||||
// The refresh rebuilds the hovered tooltip from the current view.
|
||||
s.set_spell_tooltip(871, view("Next melee", 1_800_000, None));
|
||||
s.tick(0.5);
|
||||
assert_eq!(
|
||||
line2(&mut s),
|
||||
"Next melee",
|
||||
"ActionButton_OnUpdate rebuilt it"
|
||||
);
|
||||
|
||||
// The same cooldown on a spell whose recovery is under a minute: nil.
|
||||
s.set_spell_tooltip(871, view("Instant", 30_000, None));
|
||||
s.set_action_state(1, Some(running(30_000)));
|
||||
leave_and_enter(&mut s);
|
||||
assert_eq!(armed(&mut s), None);
|
||||
|
||||
// A chance line alone does not: `SetAction` reads the builder's value only with a cooldown
|
||||
// running (`0x532597`; the other leg pushes nil, `0x532690`), so a passive answers nil.
|
||||
s.set_action_state(
|
||||
1,
|
||||
Some(ActionState {
|
||||
usable: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.set_spell_tooltip(871, view("Instant", 0, Some("5.00% chance to parry")));
|
||||
leave_and_enter(&mut s);
|
||||
assert_eq!(armed(&mut s), None);
|
||||
// With a cooldown running it does, whatever the spell's recovery.
|
||||
s.set_action_state(1, Some(running(30_000)));
|
||||
leave_and_enter(&mut s);
|
||||
assert!(armed(&mut s).is_some_and(|t| t > 0.0));
|
||||
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `CooldownFrame_SetTimer` hides the pane unless `start > 0 and duration > 0 and enable > 0`
|
||||
/// (Cooldown.lua:3): a running cooldown whose start predates a restarted `GetTime` never draws.
|
||||
#[test]
|
||||
@@ -1061,6 +1164,7 @@ fn shipped_bag_frame_drives_end_to_end() {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: None,
|
||||
locked: false,
|
||||
|
||||
@@ -393,6 +393,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
|
||||
texture: Some("Interface\\Icons\\INV_ThrowingKnife_02".into()),
|
||||
count: 200,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 2947,
|
||||
link: Some("|cffffffff|Hitem:2947|h[Small Throwing Knife]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -470,6 +471,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
|
||||
texture: Some("Interface\\Icons\\INV_Sword_04".into()),
|
||||
count: 1,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 25,
|
||||
link: Some("|cffffffff|Hitem:25|h[Worn Shortsword]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -577,6 +579,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Pelt_Wolf_01".into()),
|
||||
count: 4,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 2318,
|
||||
link: Some("|cffffffff|Hitem:2318|h[Light Leather]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -599,6 +602,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Key_03".into()),
|
||||
count: 1,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 9999,
|
||||
link: Some("|cffffffff|Hitem:9999|h[Shadowforge Key]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -786,6 +790,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(3),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -872,6 +877,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()),
|
||||
count: 1,
|
||||
quality: Some(2),
|
||||
inventory_type: 0,
|
||||
item_id: 200,
|
||||
link: Some("|cffffffff|Hitem:200|h[Shiny Gem]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -1008,6 +1014,7 @@ pub(super) fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
|
||||
locked: false,
|
||||
|
||||
@@ -86,6 +86,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
|
||||
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
|
||||
count: 1,
|
||||
quality: Some(3),
|
||||
inventory_type: 0,
|
||||
item_id: 2000,
|
||||
link: Some("|cff0070dd|Hitem:2000:0:0:0|h[Another Helm]|h|r".into()),
|
||||
locked: false,
|
||||
|
||||
@@ -360,6 +360,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: None,
|
||||
locked: false,
|
||||
|
||||
@@ -22,6 +22,7 @@ fn one_item_backpack(item_id: u32, name: &str, quality: u32) -> ContainerState {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(quality),
|
||||
inventory_type: 0,
|
||||
item_id,
|
||||
link: Some(format!("|cffffffff|Hitem:{item_id}|h[{name}]|h|r")),
|
||||
locked: false,
|
||||
|
||||
@@ -20,6 +20,7 @@ fn one_item_backpack() -> ContainerState {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 1,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -260,6 +261,7 @@ fn escape_closes_an_open_stack_split_frame() {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
|
||||
locked: false,
|
||||
|
||||
@@ -702,6 +702,7 @@ fn backpack() -> ContainerState {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 1,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
|
||||
locked: false,
|
||||
|
||||
@@ -2,15 +2,44 @@
|
||||
//! registers, against what this client answers.
|
||||
//!
|
||||
//! A 1.12 predicate answers `1` or `nil`, never a boolean (`IsPetAttackActive` aside), so kinds
|
||||
//! 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. 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.
|
||||
//! are gated with arity. The table says per row what to trust: `arity_conf = exact` or `set` (a
|
||||
//! call is right when its count is a member, so `GetContainerItemInfo`'s `0|5` is measured),
|
||||
//! kinds only where `kinds_conf = agree`, never `argc_conf`. Every global is probed except those
|
||||
//! that end the session, each on every rung of [`ARG_LADDER`] (an indexed getter raises with none
|
||||
//! and answers its miss tuple for `0`, `99` or `-1`); a rung that raises is skipped. 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. The boolean gate also 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.
|
||||
const NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[];
|
||||
const NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[
|
||||
(
|
||||
"ClearCursor",
|
||||
1,
|
||||
"Deviation: the handler ends `mov eax,1` with nothing pushed (0x4895bc), handing back the \
|
||||
stack top; copying a stack-top leak would copy a bug",
|
||||
),
|
||||
(
|
||||
"TaxiNodeSetCurrent",
|
||||
1,
|
||||
"Deviation: the same stack-top return (0x4dcd7c)",
|
||||
),
|
||||
];
|
||||
|
||||
/// The query verbs the negative-index rule reads: a verb that acts on its index (`PickupAction`)
|
||||
/// raises on a negative in this client, and the reference's verbs fall out of range quietly too,
|
||||
/// but those are a separate sweep.
|
||||
const QUERY_PREFIXES: &[&str] = &["Get", "Is", "Has", "Can", "Unit", "Num"];
|
||||
|
||||
/// Getters whose negative index raises in the reference too, each with the bytes that say so: a
|
||||
/// slot reader with a whitelist raises outside it, so `-1` raises where `99` answers. Verified
|
||||
/// entries only.
|
||||
const NEGATIVE_RAISES: &[(&str, &str)] = &[(
|
||||
"IsInventoryItemLocked",
|
||||
"its slot reader raises \"Invalid inventory slot\" outside {0, 1-23, 40-69, 82-113} (0x4c8520, \
|
||||
0x4c8e7c): 99 is the keyring's slot 98 and answers, -1 raises",
|
||||
)];
|
||||
|
||||
/// The kind gate's list, same rules: a name, the wrong kinds it answers and the reason. An entry
|
||||
/// whose binding comes into agreement fails the gate.
|
||||
@@ -29,7 +58,9 @@ const GLUE_TABLE: &str = "0x8373b8";
|
||||
struct Row {
|
||||
name: String,
|
||||
table_va: String,
|
||||
arity: usize,
|
||||
/// The return counts the reference can push: one for an `exact` row, the set for a `set` row
|
||||
/// (`0|5`: a miss and a hit that differ). A call is right when its count is a member.
|
||||
arities: Vec<usize>,
|
||||
/// The `kinds` column verbatim, `|`-separated alternatives; empty unless trustworthy.
|
||||
kinds: String,
|
||||
}
|
||||
@@ -38,7 +69,7 @@ fn rows() -> Vec<Row> {
|
||||
rows_of_kind("global")
|
||||
}
|
||||
|
||||
/// The `arity_conf = exact` rows of one `table_kind`: `global` is the registrar surface,
|
||||
/// The `arity_conf = exact` or `set` rows of one `table_kind`: `global` is the registrar surface,
|
||||
/// `baselib` the 36-entry Lua base array looped into `_G` at `0x811e28`.
|
||||
fn rows_of_kind(kind: &str) -> Vec<Row> {
|
||||
let tsv = concat!(
|
||||
@@ -51,7 +82,11 @@ fn rows_of_kind(kind: &str) -> Vec<Row> {
|
||||
.filter_map(|l| {
|
||||
let f: Vec<&str> = l.split('\t').collect();
|
||||
// name fn pair_va table_va table_kind argc argc_conf arity arity_conf kinds …
|
||||
if f.len() < 9 || f[4] != kind || f[8] != "exact" {
|
||||
if f.len() < 9 || f[4] != kind || !(f[8] == "exact" || f[8] == "set") {
|
||||
return None;
|
||||
}
|
||||
let arities: Vec<usize> = f[7].split('|').filter_map(|n| n.parse().ok()).collect();
|
||||
if arities.is_empty() || (f[8] == "exact" && arities.len() != 1) {
|
||||
return None;
|
||||
}
|
||||
// A `delegated-push` row's callee pushes on its behalf, unseen by the push trace, so
|
||||
@@ -60,7 +95,7 @@ fn rows_of_kind(kind: &str) -> Vec<Row> {
|
||||
Some(Row {
|
||||
name: f[0].to_string(),
|
||||
table_va: f[3].to_string(),
|
||||
arity: f[7].parse().ok()?,
|
||||
arities,
|
||||
// Kinds are advisory unless `kinds_conf` is `agree`, so such a row carries none.
|
||||
kinds: if f.len() > 10 && f[10] == "agree" && !delegated {
|
||||
f[9].to_string()
|
||||
@@ -103,10 +138,7 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
|
||||
|
||||
for (name, rs) in &by_name {
|
||||
// Query verbs only: the gate calls what it measures.
|
||||
if !["Get", "Is", "Has", "Can", "Unit", "Num"]
|
||||
.iter()
|
||||
.any(|p| name.starts_with(p))
|
||||
{
|
||||
if NOT_PROBED.contains(name) {
|
||||
continue;
|
||||
}
|
||||
let candidates: Vec<&&Row> = if rs.len() == 1 {
|
||||
@@ -118,47 +150,70 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
|
||||
let Some(first) = candidates.first() else {
|
||||
continue;
|
||||
};
|
||||
if candidates.iter().any(|r| r.arity != first.arity) {
|
||||
if candidates.iter().any(|r| r.arities != first.arities) {
|
||||
continue;
|
||||
}
|
||||
let want = first.arity;
|
||||
let want = &first.arities;
|
||||
|
||||
// Counted with 5.0's `arg.n` inside the `pcall`: `select` is not a 1.12 global
|
||||
// (`lua50::install` removes it).
|
||||
let call = if name.starts_with("Unit") {
|
||||
format!(r#"{name}("player")"#)
|
||||
} else {
|
||||
format!("{name}()")
|
||||
};
|
||||
let probe = format!(
|
||||
"if type({name}) ~= 'function' then return -1 end \
|
||||
local ok, n = pcall(function() return (function(...) return arg.n end)({call}) end) \
|
||||
if not ok then return -1 end return n"
|
||||
);
|
||||
let got: i64 = match s.eval(&probe) {
|
||||
Ok(n) => n,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if got < 0 {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
if got as usize != want {
|
||||
if let Some((_, known, _)) = NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
|
||||
assert_eq!(
|
||||
*known, want,
|
||||
"{name} is on the not-yet-asserted list at a stale arity — the table now says \
|
||||
{want}"
|
||||
);
|
||||
// Every rung of the argument ladder that returns is measured: an indexed getter raises
|
||||
// with none and answers its miss tuple for `0` or `99`, a hit leg is not seeded here. A
|
||||
// binding that raises on every rung is skipped. Counted with 5.0's `arg.n` inside the
|
||||
// `pcall`: `select` is not a 1.12 global (`lua50::install` removes it).
|
||||
let mut measured = false;
|
||||
// Which rungs returned: an index that falls out of range answers the miss tuple whatever
|
||||
// its sign, so a getter that answers a positive rung and raises its negative twin reads
|
||||
// its index in a type that refuses a negative (#1016).
|
||||
let mut answered: Vec<&str> = Vec::new();
|
||||
for args in ARG_LADDER {
|
||||
let probe = format!(
|
||||
"if type({name}) ~= 'function' then return -1 end \
|
||||
local ok, n = pcall(function() return (function(...) return arg.n end)({name}({args})) end) \
|
||||
if not ok then return -1 end return n"
|
||||
);
|
||||
let got: i64 = match s.eval(&probe) {
|
||||
Ok(n) => n,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if got < 0 {
|
||||
continue;
|
||||
}
|
||||
mismatches.push(format!("{name}: answers {got}, reference states {want}"));
|
||||
answered.push(args);
|
||||
measured = true;
|
||||
if !want.contains(&(got as usize)) {
|
||||
if let Some((_, known, _)) = NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
|
||||
assert!(
|
||||
want.contains(known),
|
||||
"{name} is on the not-yet-asserted list at a stale arity — the table now \
|
||||
says {want:?}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
mismatches.push(format!(
|
||||
"{name}({args}): answers {got}, reference states {want:?}"
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if measured {
|
||||
checked += 1;
|
||||
}
|
||||
// Only the first argument: a second-argument index (`GetContainerItemInfo(bag, slot)`) or
|
||||
// a spell slot (`GetSpellName`, which raises "Invalid spell slot" for one out of range)
|
||||
// has its own law per binding.
|
||||
if QUERY_PREFIXES.iter().any(|p| name.starts_with(p))
|
||||
&& answered.contains(&"99")
|
||||
&& !answered.contains(&"-1")
|
||||
&& !NEGATIVE_RAISES.iter().any(|(n, _)| n == name)
|
||||
{
|
||||
mismatches.push(format!(
|
||||
"{name}(-1) raises while {name}(99) answers: a negative index must fall out of range"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// A floor, not a target: raise it when coverage rises, never lower it to fit a change.
|
||||
assert!(
|
||||
checked >= 240,
|
||||
checked >= 900,
|
||||
"the gate measured only {checked} bindings — it has stopped covering anything"
|
||||
);
|
||||
// The list may only shrink: an entry that now answers the reference's arity must come off.
|
||||
@@ -167,12 +222,20 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
|
||||
.map(|(n, ..)| *n)
|
||||
.filter(|n| {
|
||||
by_name.get(n).is_some_and(|rs| {
|
||||
let want = rs[0].arity;
|
||||
s.eval::<i64>(&format!(
|
||||
"local ok, n = pcall(function() return (function(...) return arg.n end)({n}()) end) \
|
||||
if not ok then return -1 end return n"
|
||||
))
|
||||
.is_ok_and(|got| got >= 0 && got as usize == want)
|
||||
let want = &rs[0].arities;
|
||||
// Answers the reference's arity on every rung that returns, and on one at least.
|
||||
let seen: Vec<i64> = ARG_LADDER
|
||||
.iter()
|
||||
.filter_map(|args| {
|
||||
s.eval::<i64>(&format!(
|
||||
"local ok, n = pcall(function() return (function(...) return arg.n end)({n}({args})) end) \
|
||||
if not ok then return -1 end return n"
|
||||
))
|
||||
.ok()
|
||||
.filter(|got| *got >= 0)
|
||||
})
|
||||
.collect();
|
||||
!seen.is_empty() && seen.iter().all(|got| want.contains(&(*got as usize)))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -264,14 +327,17 @@ fn the_base_library_answers_the_reference_s_return_arity_and_kinds() {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
if got as usize != r.arity {
|
||||
if !r.arities.contains(&(got as usize)) {
|
||||
if let Some((_, known, _)) = BASELIB_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
|
||||
assert_eq!(*known, r.arity, "{name} is listed at a stale arity");
|
||||
assert!(
|
||||
r.arities.contains(known),
|
||||
"{name} is listed at a stale arity"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
mismatches.push(format!(
|
||||
"{name}: answers {got}, reference states {}",
|
||||
r.arity
|
||||
"{name}: answers {got}, reference states {:?}",
|
||||
r.arities
|
||||
));
|
||||
continue;
|
||||
}
|
||||
@@ -282,7 +348,7 @@ fn the_base_library_answers_the_reference_s_return_arity_and_kinds() {
|
||||
let Ok(kinds) = s.eval::<String>(&format!(
|
||||
"local t = {{ {name}({args}) }} local out = '' \
|
||||
for i = 1, {} do out = out .. (i > 1 and ',' or '') .. type(t[i]) end return out",
|
||||
r.arity
|
||||
r.arities[0]
|
||||
)) else {
|
||||
continue;
|
||||
};
|
||||
@@ -456,6 +522,65 @@ fn every_widget_method_answers_the_reference_s_return_arity() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The eight `GameTooltip` setters that answer the builder's return value (`0x5325bb`, `0x532eb4`
|
||||
/// and kin): one value, `1` or nil, never a boolean and never nothing. `Set*` is outside the
|
||||
/// query-verb prefixes the widget gates probe, so they are named here and called with arguments
|
||||
/// that reach their empty and miss legs on a bare VM.
|
||||
#[test]
|
||||
fn the_tooltip_flag_setters_answer_one_value_that_is_a_number_or_nil() {
|
||||
const SETTERS: &[&str] = &[
|
||||
"SetAction",
|
||||
"SetSpell",
|
||||
"SetShapeshift",
|
||||
"SetPetAction",
|
||||
"SetInboxItem",
|
||||
"SetAuctionItem",
|
||||
"SetAuctionSellItem",
|
||||
"SetSendMailItem",
|
||||
];
|
||||
const TIP_TABLE: &str = "0x854198";
|
||||
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 s = benilla_ui::script::UiScript::new().expect("VM");
|
||||
s.run(KIND_HELPER).expect("kind helper");
|
||||
s.run("PGTipSetter = CreateFrame('GameTooltip', 'PGTipSetter', UIParent)")
|
||||
.unwrap();
|
||||
// `Set*(slot)`, `SetSpell(slot, bookType)`, `SetAuctionItem(type, index)`, and the bare ones.
|
||||
const ARGS: &[&str] = &["", "1", "1, 'spell'", "'list', 1", "0", "99", "-1"];
|
||||
for name in SETTERS {
|
||||
let row = text
|
||||
.lines()
|
||||
.map(|l| l.split('\t').collect::<Vec<_>>())
|
||||
.find(|f| f.len() > 9 && f[4] == "widget" && f[3] == TIP_TABLE && f[0] == *name)
|
||||
.unwrap_or_else(|| panic!("{name} has no GameTooltip row"));
|
||||
assert_eq!(
|
||||
(row[7], row[8], row[9]),
|
||||
("1", "exact", "(nil) | (number)"),
|
||||
"{name}: the table row changed under the gate"
|
||||
);
|
||||
let mut measured = 0;
|
||||
for args in ARGS {
|
||||
let probe = format!(
|
||||
"local ok, s = pcall(function() return PGKinds(PGTipSetter:{name}({args})) end) \
|
||||
if not ok then return '?' end return s"
|
||||
);
|
||||
let got = s.eval::<String>(&probe).unwrap();
|
||||
if got == "?" {
|
||||
continue;
|
||||
}
|
||||
measured += 1;
|
||||
assert!(
|
||||
got == "nil" || got == "number",
|
||||
"{name}({args}) answers ({got}), the reference pushes one nil or number"
|
||||
);
|
||||
}
|
||||
assert!(measured > 0, "{name} raised on every rung");
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a `kinds` cell into alternatives of per-slot kinds: `(nil) | (number)` is two of one
|
||||
/// slot, and `()` yields none, so the kind gates skip a verb that answers nothing.
|
||||
fn kind_alternatives(cell: &str) -> Vec<Vec<String>> {
|
||||
@@ -516,10 +641,7 @@ fn every_query_binding_answers_the_reference_s_return_kinds() {
|
||||
let mut checked = 0usize;
|
||||
let mut mismatches: Vec<String> = Vec::new();
|
||||
for (name, rs) in &by_name {
|
||||
if !["Get", "Is", "Has", "Can", "Unit", "Num"]
|
||||
.iter()
|
||||
.any(|p| name.starts_with(p))
|
||||
{
|
||||
if NOT_PROBED.contains(name) {
|
||||
continue;
|
||||
}
|
||||
let candidates: Vec<&&Row> = if rs.len() == 1 {
|
||||
@@ -538,59 +660,69 @@ fn every_query_binding_answers_the_reference_s_return_kinds() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let call = if name.starts_with("Unit") {
|
||||
format!(r#"{name}("player")"#)
|
||||
} else {
|
||||
format!("{name}()")
|
||||
};
|
||||
let probe = format!(
|
||||
"if type({name}) ~= 'function' then return '?' end \
|
||||
local ok, s = pcall(function() return PGKinds({call}) end) \
|
||||
if not ok then return '?' end return s"
|
||||
);
|
||||
let Ok(got) = s.eval::<String>(&probe) else {
|
||||
continue;
|
||||
};
|
||||
if got == "?" {
|
||||
continue;
|
||||
}
|
||||
let got: Vec<&str> = got.split(',').filter(|k| !k.is_empty()).collect();
|
||||
// An arity fault is the arity gate's to report.
|
||||
if !want.iter().any(|alt| alt.len() == got.len()) {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
let agrees = want.iter().any(|alt| {
|
||||
alt.len() == got.len()
|
||||
&& alt
|
||||
.iter()
|
||||
.zip(&got)
|
||||
.all(|(w, g)| slot_accepts(w.as_str(), g))
|
||||
});
|
||||
let observed = got.join(",");
|
||||
if !agrees {
|
||||
if let Some((_, known, _)) = KINDS_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
|
||||
assert_eq!(
|
||||
*known, observed,
|
||||
"{name}'s not-yet-asserted row is stale: it now answers ({observed})"
|
||||
);
|
||||
// Every rung of the argument ladder that returns is compared, so an indexed getter's miss
|
||||
// leg is measured with the rest (see the arity gate).
|
||||
let mut measured = false;
|
||||
let mut disagreed = false;
|
||||
for args in ARG_LADDER {
|
||||
let probe = format!(
|
||||
"if type({name}) ~= 'function' then return '?' end \
|
||||
local ok, s = pcall(function() return PGKinds({name}({args})) end) \
|
||||
if not ok then return '?' end return s"
|
||||
);
|
||||
let Ok(got) = s.eval::<String>(&probe) else {
|
||||
continue;
|
||||
};
|
||||
if got == "?" {
|
||||
continue;
|
||||
}
|
||||
mismatches.push(format!(
|
||||
"{name}: answers ({observed}), reference states {}",
|
||||
first.kinds
|
||||
));
|
||||
} else {
|
||||
assert!(
|
||||
!KINDS_NOT_YET_ASSERTED.iter().any(|(n, ..)| n == name),
|
||||
"{name} now agrees with the reference and must come off KINDS_NOT_YET_ASSERTED"
|
||||
);
|
||||
let got: Vec<&str> = got.split(',').filter(|k| !k.is_empty()).collect();
|
||||
// An arity fault is the arity gate's to report.
|
||||
if !want.iter().any(|alt| alt.len() == got.len()) {
|
||||
continue;
|
||||
}
|
||||
measured = true;
|
||||
let agrees = want.iter().any(|alt| {
|
||||
alt.len() == got.len()
|
||||
&& alt
|
||||
.iter()
|
||||
.zip(&got)
|
||||
.all(|(w, g)| slot_accepts(w.as_str(), g))
|
||||
});
|
||||
let observed = got.join(",");
|
||||
if !agrees {
|
||||
disagreed = true;
|
||||
if let Some((_, known, _)) = KINDS_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name)
|
||||
{
|
||||
assert_eq!(
|
||||
*known, observed,
|
||||
"{name}'s not-yet-asserted row is stale: it now answers ({observed})"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
mismatches.push(format!(
|
||||
"{name}({args}): answers ({observed}), reference states {}",
|
||||
first.kinds
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if measured {
|
||||
checked += 1;
|
||||
// A listed entry on which no rung disagrees has come into agreement; a rung that
|
||||
// disagrees and was listed `continue`s without pushing, which is not agreement.
|
||||
if !disagreed {
|
||||
assert!(
|
||||
!KINDS_NOT_YET_ASSERTED.iter().any(|(n, ..)| n == name),
|
||||
"{name} now agrees with the reference and must come off KINDS_NOT_YET_ASSERTED"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A floor, not a target.
|
||||
assert!(
|
||||
checked >= 150,
|
||||
checked >= 460,
|
||||
"the kind gate measured only {checked} bindings — it has stopped covering anything"
|
||||
);
|
||||
assert!(
|
||||
@@ -714,6 +846,11 @@ fn every_widget_method_answers_the_reference_s_return_kinds() {
|
||||
const ARG_LADDER: &[&str] = &[
|
||||
"",
|
||||
"\"player\"",
|
||||
"0",
|
||||
"99",
|
||||
// A negative index truncates, decrements and compares unsigned, so it falls out of range
|
||||
// (`0x4f6ed8`) where a getter that reads it as `usize` would raise.
|
||||
"-1",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
|
||||
@@ -119,6 +119,7 @@ fn seed_items(s: &mut UiScript) {
|
||||
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
|
||||
count: 1,
|
||||
quality: Some(3),
|
||||
inventory_type: 0,
|
||||
item_id: 2000,
|
||||
link: Some("|cff0070dd|Hitem:2000:0:0:0|h[Another Helm]|h|r".into()),
|
||||
locked: false,
|
||||
|
||||
@@ -362,6 +362,7 @@ pub(super) fn build_view(
|
||||
range,
|
||||
cast_time,
|
||||
cooldown,
|
||||
recovery_ms,
|
||||
requires_item,
|
||||
item_met,
|
||||
requires_form,
|
||||
|
||||
@@ -333,6 +333,7 @@ fn fireball_view_on_real_data() {
|
||||
v.cooldown, None,
|
||||
"Fireball has no recovery in either column"
|
||||
);
|
||||
assert_eq!(v.recovery_ms, 0);
|
||||
assert_eq!(v.requires_form, None);
|
||||
assert!(
|
||||
v.description.starts_with("Hurls a fiery ball that causes"),
|
||||
@@ -359,6 +360,7 @@ fn fireball_view_on_real_data() {
|
||||
assert_eq!(v.range.as_deref(), Some("8-25 yd range"));
|
||||
assert_eq!(v.cast_time.as_deref(), Some("Instant"));
|
||||
assert_eq!(v.cooldown.as_deref(), Some("15 sec cooldown"));
|
||||
assert_eq!(v.recovery_ms, 15_000, "the same column, in ms");
|
||||
assert_eq!(v.requires_form.as_deref(), Some("Requires Battle Stance"));
|
||||
assert!(!v.form_met, "form 0 (unshifted) does not satisfy the mask");
|
||||
assert_eq!(
|
||||
@@ -435,12 +437,14 @@ fn improved_fire_blast_shortens_the_cooldown_cell() {
|
||||
let objects = objs.get();
|
||||
let base = spell_tooltip_view(2136, &spells, &mut t.ctx(&objects, 0, None)).unwrap();
|
||||
assert_eq!(base.cooldown.as_deref(), Some("8 sec cooldown"));
|
||||
assert_eq!(base.recovery_ms, 8_000);
|
||||
|
||||
t.spell_mods.set_class_family(fire_blast.spell_family);
|
||||
t.spell_mods
|
||||
.set(true, bit, crate::spell::OP_COOLDOWN, -1_500);
|
||||
let improved = spell_tooltip_view(2136, &spells, &mut t.ctx(&objects, 0, None)).unwrap();
|
||||
assert_eq!(improved.cooldown.as_deref(), Some("6.5 sec cooldown"));
|
||||
assert_eq!(improved.recovery_ms, 6_500, "the modified column, in ms");
|
||||
}
|
||||
|
||||
/// The cast cell reads `GetCastTime(1)` (`52eb4b`): op 10 applies and nothing clamps, so a
|
||||
|
||||
@@ -25,6 +25,9 @@ struct TradeOffer {
|
||||
gold: u32,
|
||||
/// The spell on this side's enchant slot, 0 for none; stored, not shown yet.
|
||||
enchant_spell_id: u32,
|
||||
/// Our side only: each placed item's `ITEM_FIELD_FLAGS`, read off its object when it was
|
||||
/// placed. The slot's tooltip passes that live object to the builder (`0x5342ed`).
|
||||
object_flags: [u32; TRADE_SLOT_COUNT],
|
||||
}
|
||||
|
||||
/// The trade session [`net`] fills and [`feed_trade`] reads, cleared on a close and on disconnect.
|
||||
@@ -128,12 +131,13 @@ impl TradeSession {
|
||||
|
||||
/// Place our item in trade slot `id_1based` client-side: vmangos sends a placement to the
|
||||
/// partner only (`TradeData.cpp:81`), never back to the placer.
|
||||
pub(crate) fn place_own_item(&mut self, id_1based: u32, item: TradeItem) {
|
||||
pub(crate) fn place_own_item(&mut self, id_1based: u32, item: TradeItem, flags: u32) {
|
||||
if let Some(slot) = id_1based
|
||||
.checked_sub(1)
|
||||
.and_then(|i| self.our.slots.get_mut(i as usize))
|
||||
{
|
||||
*slot = Some(item);
|
||||
self.our.object_flags[id_1based as usize - 1] = flags;
|
||||
self.our_offer_changed();
|
||||
}
|
||||
}
|
||||
@@ -450,9 +454,16 @@ fn resolve_slot(
|
||||
let template = items.template(item.entry, 0, commands);
|
||||
let name = template.map(|t| t.name.clone());
|
||||
let quality = template.map(|t| t.quality);
|
||||
let texture = icons
|
||||
.and_then(|i| i.catalog.get(item.display_id))
|
||||
.and_then(|d| d.icon.clone());
|
||||
let inventory_type = template.map_or(0, |t| t.inventory_type);
|
||||
// The icon comes from the template's display, not the wire's (`0x5d88b0([rec+0x18])`,
|
||||
// `0x4c01f0`; `0x5d8af0` on our side, `0x4c0529`); a cached template whose display has no
|
||||
// icon falls to the question mark, and an unanswered one leaves it to the getter.
|
||||
let texture = template.map(|t| {
|
||||
icons
|
||||
.and_then(|i| i.catalog.get(t.display_info_id))
|
||||
.and_then(|d| d.icon.clone())
|
||||
.unwrap_or_else(|| crate::ui_action::MISSING_ITEM_ICON.to_string())
|
||||
});
|
||||
// Nil until the template answers: the link embeds the name and the quality colour.
|
||||
let link = match (name.as_deref(), quality) {
|
||||
(Some(n), Some(q)) => Some(crate::ui_items::item_link(item.entry, n, q)),
|
||||
@@ -464,6 +475,9 @@ fn resolve_slot(
|
||||
texture,
|
||||
count: item.count.max(1),
|
||||
quality,
|
||||
inventory_type,
|
||||
lock_id: item.lock_id,
|
||||
object_flags: None,
|
||||
enchantment: None,
|
||||
link,
|
||||
}
|
||||
@@ -478,42 +492,53 @@ fn own_item_at(
|
||||
objects: &Objects,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
) -> Option<TradeItem> {
|
||||
) -> Option<(TradeItem, u32)> {
|
||||
let (guid, count) = crate::ui_items::slot_guid_count(store, bag, slot, objects);
|
||||
if guid == 0 {
|
||||
return None;
|
||||
}
|
||||
let entry = objects.object(guid).and_then(|f| f.object_entry())?;
|
||||
let fields = objects.object(guid)?;
|
||||
let entry = fields.object_entry()?;
|
||||
let flags = fields.item_flags().unwrap_or(0);
|
||||
let display_id = items
|
||||
.template(entry, guid, commands)
|
||||
.map(|t| t.display_info_id)
|
||||
.unwrap_or(0);
|
||||
Some(TradeItem {
|
||||
entry,
|
||||
display_id,
|
||||
count,
|
||||
wrapped: false,
|
||||
gift_creator: 0,
|
||||
perm_enchant: 0,
|
||||
creator: 0,
|
||||
charges: 0,
|
||||
suffix_factor: 0,
|
||||
random_prop_id: 0,
|
||||
lock_id: 0,
|
||||
max_durability: 0,
|
||||
durability: 0,
|
||||
})
|
||||
Some((
|
||||
TradeItem {
|
||||
entry,
|
||||
display_id,
|
||||
count,
|
||||
wrapped: false,
|
||||
gift_creator: 0,
|
||||
perm_enchant: 0,
|
||||
creator: 0,
|
||||
charges: 0,
|
||||
suffix_factor: 0,
|
||||
random_prop_id: 0,
|
||||
lock_id: 0,
|
||||
max_durability: 0,
|
||||
durability: 0,
|
||||
},
|
||||
flags,
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_side(
|
||||
offer: &TradeOffer,
|
||||
ours: bool,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> TradeSideState {
|
||||
let mut slots: [Option<TradeSlotItem>; TRADE_SLOT_COUNT] = Default::default();
|
||||
for (i, s) in offer.slots.iter().enumerate() {
|
||||
slots[i] = s.map(|it| resolve_slot(&it, items, icons, commands));
|
||||
slots[i] = s.map(|it| {
|
||||
let mut row = resolve_slot(&it, items, icons, commands);
|
||||
// Our items are live objects; the partner's are blocks (`0x5342ed`, `0x53456b`).
|
||||
row.object_flags = ours.then_some(offer.object_flags[i]);
|
||||
row
|
||||
});
|
||||
}
|
||||
TradeSideState {
|
||||
slots,
|
||||
@@ -531,8 +556,8 @@ fn snapshot(
|
||||
return None;
|
||||
}
|
||||
Some(TradeState {
|
||||
player: resolve_side(&trade.our, items, icons, commands),
|
||||
target: resolve_side(&trade.their, items, icons, commands),
|
||||
player: resolve_side(&trade.our, true, items, icons, commands),
|
||||
target: resolve_side(&trade.their, false, items, icons, commands),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -729,8 +754,9 @@ fn drain_trade(
|
||||
};
|
||||
if let Some(trade_slot) = id.checked_sub(1).and_then(|n| u8::try_from(n).ok()) {
|
||||
// vmangos echoes a placement to the partner only, so our column is filled here.
|
||||
if let Some(item) = own_item_at(bag, slot, store, &objects, &items, &commands) {
|
||||
trade.place_own_item(id, item);
|
||||
if let Some((item, flags)) = own_item_at(bag, slot, store, &objects, &items, &commands)
|
||||
{
|
||||
trade.place_own_item(id, item, flags);
|
||||
}
|
||||
info!(target: "trade", "set item: slot {id} <- bag {bag}/{slot} (wire {wire_bag}/{wire_slot}); sending CMSG_SET_TRADE_ITEM");
|
||||
let _ = commands.0.send(ClientCommand::SetTradeItem {
|
||||
@@ -829,6 +855,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The row carries the template's quality and InventoryType, which `GetTradePlayerItemInfo`
|
||||
/// gates its fourth value on; an unanswered template leaves both empty.
|
||||
#[test]
|
||||
fn a_trade_row_carries_the_templates_quality_and_inventory_type() {
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let mut items = Items::default();
|
||||
let mut sword = crate::items::test_template("Sword");
|
||||
sword.quality = 4;
|
||||
sword.inventory_type = 13;
|
||||
items.insert_template(1234, Some(sword));
|
||||
items.insert_template(1235, Some(crate::items::test_template("Linen Cloth")));
|
||||
|
||||
let row = resolve_slot(&wire_item(1234), &items, None, &commands);
|
||||
assert_eq!((row.quality, row.inventory_type), (Some(4), 13));
|
||||
// The icon is the template's display's, the question mark when that row is missing, and
|
||||
// left to the getter while the template is unanswered.
|
||||
assert_eq!(
|
||||
row.texture.as_deref(),
|
||||
Some("Interface\\Icons\\INV_Misc_QuestionMark")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_slot(&wire_item(1236), &items, None, &commands).texture,
|
||||
None
|
||||
);
|
||||
let row = resolve_slot(&wire_item(1235), &items, None, &commands);
|
||||
assert_eq!((row.quality, row.inventory_type), (Some(1), 0));
|
||||
let row = resolve_slot(&wire_item(1236), &items, None, &commands);
|
||||
assert_eq!((row.quality, row.inventory_type), (None, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_close_state_machine() {
|
||||
let mut s = TradeSession::default();
|
||||
@@ -950,7 +1007,7 @@ mod tests {
|
||||
s.partner_accepted();
|
||||
assert_eq!((s.our_accept, s.their_accept), (true, true));
|
||||
|
||||
s.place_own_item(2, wire_item(1234));
|
||||
s.place_own_item(2, wire_item(1234), 0);
|
||||
assert!(s.our.slots[1].is_some(), "our slot 2 shows the placed item");
|
||||
assert_eq!(
|
||||
(s.our_accept, s.their_accept),
|
||||
|
||||
@@ -384,7 +384,7 @@ mod tests {
|
||||
{
|
||||
let (mut errors, commands, _rx) = sink();
|
||||
let mut trade = open_trade();
|
||||
trade.place_own_item(2, item());
|
||||
trade.place_own_item(2, item(), 0);
|
||||
trade.set_own_gold(700);
|
||||
trade_status(
|
||||
TradeStatus::OnlyConjured { slot },
|
||||
|
||||
@@ -40,6 +40,9 @@ fn item(item_id: u32, name: &str, count: u32, quality: u32) -> TradeSlotItem {
|
||||
texture: Some("Interface\\Icons\\INV_Fabric_Linen_01".to_string()),
|
||||
count,
|
||||
quality: Some(quality),
|
||||
inventory_type: 0,
|
||||
lock_id: 0,
|
||||
object_flags: None,
|
||||
enchantment: None,
|
||||
link: Some(format!("|cffffffff|Hitem:{item_id}:0:0:0|h[{name}]|h|r")),
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::binding_abi::{bool_or_default, flag};
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::Idx32;
|
||||
|
||||
/// The kind byte, bits 24-31 of the packed slot word (vmangos `Player.h:133`). Must match
|
||||
/// `benilla_protocol`'s `ACTION_KIND_*`; this crate has no protocol dependency.
|
||||
@@ -143,7 +144,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"HasAction",
|
||||
lua.create_function(|lua, action: u32| {
|
||||
lua.create_function(|lua, Idx32(action): Idx32| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(flag(model.actions.contains_key(&action)))
|
||||
})?,
|
||||
@@ -193,7 +194,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetActionTexture",
|
||||
lua.create_function(|lua, action: u32| {
|
||||
lua.create_function(|lua, Idx32(action): Idx32| {
|
||||
let tex = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model.actions.get(&action).and_then(|s| s.texture.clone())
|
||||
@@ -259,7 +260,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetActionCount",
|
||||
lua.create_function(|lua, action: u32| {
|
||||
lua.create_function(|lua, Idx32(action): Idx32| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model
|
||||
.actions
|
||||
@@ -300,7 +301,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"IsUsableAction",
|
||||
lua.create_function(|lua, action: u32| {
|
||||
lua.create_function(|lua, Idx32(action): Idx32| {
|
||||
Ok((
|
||||
state_flag(lua, action, |s| s.usable),
|
||||
state_flag(lua, action, |s| s.not_enough_mana),
|
||||
@@ -310,7 +311,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"IsActionInRange",
|
||||
lua.create_function(|lua, action: u32| {
|
||||
lua.create_function(|lua, Idx32(action): Idx32| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(
|
||||
match model
|
||||
@@ -332,20 +333,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
g.set(
|
||||
"IsCurrentAction",
|
||||
lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.current)))?,
|
||||
lua.create_function(|lua, Idx32(action): Idx32| {
|
||||
Ok(state_flag(lua, action, |s| s.current))
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"IsAutoRepeatAction",
|
||||
lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.auto_repeat)))?,
|
||||
lua.create_function(|lua, Idx32(action): Idx32| {
|
||||
Ok(state_flag(lua, action, |s| s.auto_repeat))
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"IsAttackAction",
|
||||
lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.is_attack)))?,
|
||||
lua.create_function(|lua, Idx32(action): Idx32| {
|
||||
Ok(state_flag(lua, action, |s| s.is_attack))
|
||||
})?,
|
||||
)?;
|
||||
// From the slot, not the state map: `0x4e5250` depends only on the item template.
|
||||
g.set(
|
||||
"IsConsumableAction",
|
||||
lua.create_function(|lua, action: u32| {
|
||||
lua.create_function(|lua, Idx32(action): Idx32| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
match model.actions.get(&action) {
|
||||
Some(s) if s.consumable => Ok(Value::Integer(1)),
|
||||
@@ -355,7 +362,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
g.set(
|
||||
"IsEquippedAction",
|
||||
lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.equipped)))?,
|
||||
lua.create_function(|lua, Idx32(action): Idx32| {
|
||||
Ok(state_flag(lua, action, |s| s.equipped))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetActionCooldown(action) → start, duration, enable, in `GetTime` seconds. An absent or
|
||||
@@ -363,7 +372,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// replay the sweep and its finish flash.
|
||||
g.set(
|
||||
"GetActionCooldown",
|
||||
lua.create_function(|lua, action: u32| {
|
||||
lua.create_function(|lua, Idx32(action): Idx32| {
|
||||
let now = crate::script::clock::now(lua);
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(
|
||||
|
||||
@@ -10,6 +10,7 @@ use mlua::{Lua, MultiValue, Value};
|
||||
use super::binding_abi::flag;
|
||||
use super::cursor::{self, CursorPayload};
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::{Idx, Idx32};
|
||||
|
||||
/// The three list types, in the order the app pushes them.
|
||||
pub const LIST: usize = 0;
|
||||
@@ -320,7 +321,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// GetAuctionItemInfo(type, index) → twelve values; `highBidder` is a name, `1` or nil.
|
||||
g.set(
|
||||
"GetAuctionItemInfo",
|
||||
lua.create_function(|lua, (kind, index): (String, usize)| {
|
||||
lua.create_function(|lua, (kind, index): (String, Value)| {
|
||||
let index = crate::script::binding_abi::index_arg(
|
||||
lua,
|
||||
index,
|
||||
"Usage: GetAuctionItemInfo(\"type\", index)",
|
||||
)?;
|
||||
let row = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
row_at(&model, &kind, index)
|
||||
@@ -382,7 +388,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// live countdown: it moves only when a new list lands.
|
||||
g.set(
|
||||
"GetAuctionItemTimeLeft",
|
||||
lua.create_function(|lua, (kind, index): (String, usize)| {
|
||||
lua.create_function(|lua, (kind, Idx(index)): (String, Idx)| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(row_at(&model, &kind, index).map_or(0, |r| i64::from(r.time_left)))
|
||||
})?,
|
||||
@@ -390,7 +396,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetAuctionItemLink",
|
||||
lua.create_function(|lua, (kind, index): (String, usize)| {
|
||||
lua.create_function(|lua, (kind, index): (String, Value)| {
|
||||
let index = crate::script::binding_abi::index_arg(
|
||||
lua,
|
||||
index,
|
||||
"Usage: GetAuctionItemLink(\"type\", index)",
|
||||
)?;
|
||||
let link = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
row_at(&model, &kind, index).and_then(|r| r.link)
|
||||
@@ -429,7 +440,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// SetSelectedAuctionItem(type, index): remembers the auction at that row; out of range clears.
|
||||
g.set(
|
||||
"SetSelectedAuctionItem",
|
||||
lua.create_function(|lua, (kind, index): (String, usize)| {
|
||||
lua.create_function(|lua, (kind, Idx(index)): (String, Idx)| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let Some(i) = list_index(&kind) else {
|
||||
return Ok(());
|
||||
@@ -627,17 +638,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// GetOwnerAuctionItems([page]) / GetBidderAuctionItems([page]): unthrottled, page 0 by default.
|
||||
g.set(
|
||||
"GetOwnerAuctionItems",
|
||||
lua.create_function(|lua, page: Option<u32>| {
|
||||
lua.create_function(|lua, page: Option<Value>| {
|
||||
// A page that is not a number is page 0 (`0x4cecaa`, `0x4cecfa`): the query never
|
||||
// raises, and `trunc(n)` is the signed page (`0x40a2b0`).
|
||||
let page = crate::script::binding_abi::coerced_number(lua, page);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.auction_owner_query = Some(page.unwrap_or(0));
|
||||
model.auction_owner_query = Some(crate::script::binding_abi::ftol(page) as u32);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"GetBidderAuctionItems",
|
||||
lua.create_function(|lua, page: Option<u32>| {
|
||||
lua.create_function(|lua, page: Option<Value>| {
|
||||
// A page that is not a number is page 0 (`0x4cecaa`, `0x4cecfa`): the query never
|
||||
// raises, and `trunc(n)` is the signed page (`0x40a2b0`).
|
||||
let page = crate::script::binding_abi::coerced_number(lua, page);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.auction_bidder_query = Some(page.unwrap_or(0));
|
||||
model.auction_bidder_query = Some(crate::script::binding_abi::ftol(page) as u32);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -676,7 +693,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// CancelAuction(index), always an "owner" row.
|
||||
g.set(
|
||||
"CancelAuction",
|
||||
lua.create_function(|lua, index: u32| {
|
||||
lua.create_function(|lua, Idx32(index): Idx32| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.auction_cancels.push(index);
|
||||
Ok(())
|
||||
@@ -808,6 +825,29 @@ fn click_auction_sell_item(model: &mut Model) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The owner and bidder queries (`0x4cec80`, `0x4cecd0`) read `trunc(n)` as the page and take
|
||||
/// anything else for page 0; they never raise.
|
||||
#[test]
|
||||
fn owner_and_bidder_queries_default_to_page_zero_and_never_raise() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
for (call, page) in [
|
||||
("()", 0),
|
||||
("(2)", 2),
|
||||
("(2.9)", 2),
|
||||
("('x')", 0),
|
||||
("({})", 0),
|
||||
("(nil)", 0),
|
||||
("(-1)", u32::MAX),
|
||||
] {
|
||||
s.run(&format!(
|
||||
"GetOwnerAuctionItems{call} GetBidderAuctionItems{call}"
|
||||
))
|
||||
.unwrap_or_else(|e| panic!("{call} raised: {e}"));
|
||||
assert_eq!(s.take_auction_owner_query(), Some(page), "owner {call}");
|
||||
assert_eq!(s.take_auction_bidder_query(), Some(page), "bidder {call}");
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use crate::script::UiScript;
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ pub(crate) fn auras_of<'m>(model: &'m Model, token: &str) -> mlua::Result<Option
|
||||
}
|
||||
|
||||
/// The `index`-th (1-based) buff (`helpful`) or debuff of `token`, in pushed order; out of range
|
||||
/// answers nothing, the loop terminator.
|
||||
/// answers one nil on every miss leg (`0x5195fe`, `0x5197d4`, `0x519a51`), the loop terminator.
|
||||
fn nth_aura(
|
||||
lua: &Lua,
|
||||
token: &Option<String>,
|
||||
@@ -128,7 +128,7 @@ fn nth_aura(
|
||||
};
|
||||
match hit {
|
||||
Some(a) => returns(lua, &a, !helpful),
|
||||
None => Ok(MultiValue::new()),
|
||||
None => Ok(MultiValue::from_vec(vec![Value::Nil])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,11 +319,17 @@ mod tests {
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitBuff("target", 1) == nil"#)
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.arity(r#"UnitBuff("player", 0)"#).unwrap(),
|
||||
0,
|
||||
"a miss answers nothing"
|
||||
);
|
||||
// One nil on every miss leg (`0x5195fe`, `0x5197d4`, `0x519a51`): an index out of range,
|
||||
// zero or negative, and a unit with no list.
|
||||
for call in [
|
||||
r#"UnitBuff("player", 0)"#,
|
||||
r#"UnitBuff("player", 9)"#,
|
||||
r#"UnitDebuff("player", 9)"#,
|
||||
r#"UnitBuff("target", 1)"#,
|
||||
r#"UnitDebuff("target", 1)"#,
|
||||
] {
|
||||
assert_eq!(s.arity(call).unwrap(), 1, "{call} answers one nil");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -67,7 +67,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// GetBankSlotCost(numSlots): the argument is unread; the pushed state names the next slot.
|
||||
g.set(
|
||||
"GetBankSlotCost",
|
||||
lua.create_function(|lua, _n: Option<u32>| {
|
||||
// Reads no argument at all (`0x4f8630`): any call shape answers.
|
||||
lua.create_function(|lua, _args: mlua::MultiValue| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(i64::from(model.bank.as_ref().map_or(0, |b| b.next_cost)))
|
||||
})?,
|
||||
@@ -138,6 +139,14 @@ mod tests {
|
||||
.eval::<bool>("local n, full = GetNumBankSlots()\nreturn n == 2 and full == nil")
|
||||
.unwrap());
|
||||
assert_eq!(s.eval::<i64>("return GetBankSlotCost(2)").unwrap(), 100_000);
|
||||
// The argument is never read (`0x4f8630`): none, a string or a table answer all the same.
|
||||
for call in [
|
||||
"GetBankSlotCost()",
|
||||
"GetBankSlotCost('x')",
|
||||
"GetBankSlotCost({})",
|
||||
] {
|
||||
assert_eq!(s.eval::<i64>(&format!("return {call}")).unwrap(), 100_000);
|
||||
}
|
||||
|
||||
// Six purchased: full = 1, and 999999999 is the DBC's sentinel row.
|
||||
s.set_bank(Some(BankState {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use mlua::Lua;
|
||||
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::Idx32;
|
||||
|
||||
/// One answer to a pending-equip question.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -58,7 +59,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// standing dialog (`exclusive = 1`), and that cancel releases the superseded item's locks.
|
||||
g.set(
|
||||
"CancelPendingEquip",
|
||||
lua.create_function(|lua, index: u32| {
|
||||
lua.create_function(|lua, Idx32(index): Idx32| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.pending_equip_answers.push(PendingEquipAnswer {
|
||||
index,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! `xor eax,eax; ret` that skips `0x6f4940` (`GetDefaultLanguage`'s failure edges);
|
||||
//! 3. raise.
|
||||
|
||||
use mlua::{Lua, Value};
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
/// `_ftol` (`0x40a2b0`) as the bindings consume it: the x87 rounding control set to chop, a
|
||||
/// `fistp` to a qword, and its low dword. What the qword cannot hold (an infinity, NaN, ±2^63 and
|
||||
@@ -39,6 +39,43 @@ pub(crate) fn number_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Resu
|
||||
}
|
||||
}
|
||||
|
||||
/// An index argument read as the reference reads one: a number or numeric string (`0x6f34d0`)
|
||||
/// truncated by `_ftol` ([`ftol`]) and then decremented and compared unsigned, so a zero or
|
||||
/// negative index is a huge one and falls out of range where a Rust integer parameter would raise
|
||||
/// in the conversion (`GetCraftInfo` `0x4f6ed8`). The inner value is the 32-bit pattern, the
|
||||
/// negative a value no table holds. A non-number raises, as the reference's usage error does.
|
||||
pub(crate) struct Idx(pub usize);
|
||||
|
||||
/// [`Idx`] as a function, for a binding whose reference raises its own usage text for a
|
||||
/// non-number (`usage`); the value is the same 32-bit pattern, widened.
|
||||
pub(crate) fn index_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Result<usize> {
|
||||
number_arg(lua, v, usage).map(|n| n as u32 as usize)
|
||||
}
|
||||
|
||||
/// [`Idx`] for the parameters the bindings keep as `u32`.
|
||||
pub(crate) struct Idx32(pub u32);
|
||||
|
||||
fn index_bits(lua: &Lua, v: Value) -> mlua::Result<u32> {
|
||||
match lua.coerce_number(v)? {
|
||||
Some(n) => Ok(ftol(n) as u32),
|
||||
None => Err(mlua::Error::RuntimeError(
|
||||
"bad argument: number expected".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
impl mlua::FromLua for Idx {
|
||||
fn from_lua(v: Value, lua: &Lua) -> mlua::Result<Self> {
|
||||
index_bits(lua, v).map(|n| Idx(n as usize))
|
||||
}
|
||||
}
|
||||
|
||||
impl mlua::FromLua for Idx32 {
|
||||
fn from_lua(v: Value, lua: &Lua) -> mlua::Result<Self> {
|
||||
index_bits(lua, v).map(Idx32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shape D: an `is-number`-gated flag whose failure keeps its zero instead of raising. The value
|
||||
/// must be strictly `> 0.0` (`0x53303f`), so 0, a negative and NaN are false and `0.5` or `"1"`
|
||||
/// true; `SetInventoryItem`'s `nameOnly` is one.
|
||||
@@ -317,6 +354,18 @@ pub(crate) fn flag(b: bool) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// A tuple the reference pushes by fixed slots, as an indexed getter's miss does: `None` is a nil
|
||||
/// (`0x6f37f0`), `Some(n)` the number `n` (`0x6f3810`). The count is the handler's `mov eax,N`, so
|
||||
/// a caller counting the returns (`arg.n`) reads N, never one nil.
|
||||
pub(crate) fn tuple(slots: &[Option<i64>]) -> MultiValue {
|
||||
MultiValue::from_vec(
|
||||
slots
|
||||
.iter()
|
||||
.map(|s| s.map_or(Value::Nil, Value::Integer))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -295,20 +295,29 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// JoinChannelByName(name [, password [, frameId]]) (`0x49ff00` → `0x49eb70`): a DBC row
|
||||
// answers `(ChannelID, resolvedName)` and a custom channel `(0, nil)`, both sending; a name
|
||||
// with a space, or a row whose zone text is empty, answers nil and sends nothing. A `frameId`
|
||||
// that truncates to 1..=10 registers the channel in that window's list, deduplicated by name
|
||||
// JoinChannelByName(name [, password [, frameId]]) (`0x49ff00` → `0x49eb70`): `name` is a
|
||||
// string or number (anything else raises `Usage: JoinChannelByName("name")`, `0x49ffe7`), the
|
||||
// password a string or number, else empty. A DBC row answers `(ChannelID, resolvedName)` and a
|
||||
// custom channel `(ChannelID, nil)`, two values either way (`0x49ffcc`, `0x49ffdd`), both
|
||||
// sending; a name with a space (`0x49ff5e`), a row whose zone text is empty, or a failed join
|
||||
// (`0x49ffaa`) pushes nothing and sends nothing. A `frameId` (a number or numeric string) that
|
||||
// truncates to 1..=10 registers the channel in that window's list, deduplicated by name
|
||||
// (`0x49ec24`), which is what brings a `/leave`-stripped channel's lines back to the window.
|
||||
g.set(
|
||||
"JoinChannelByName",
|
||||
lua.create_function(
|
||||
|lua, (name, password, frame): (Option<String>, Option<String>, Value)| {
|
||||
let Some(name) = non_empty(name) else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
};
|
||||
if name.contains(' ') {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
|lua, (name, password, frame): (Option<Value>, Option<Value>, Option<Value>)| {
|
||||
let name = super::binding_abi::string_arg(
|
||||
lua,
|
||||
name.unwrap_or(Value::Nil),
|
||||
r#"Usage: JoinChannelByName("name")"#,
|
||||
)?;
|
||||
let password = password
|
||||
.as_ref()
|
||||
.and_then(|v| super::binding_abi::optional_string(lua, v))
|
||||
.unwrap_or_default();
|
||||
if name.is_empty() || name.contains(' ') {
|
||||
return Ok(MultiValue::new());
|
||||
}
|
||||
let row = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
@@ -320,9 +329,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
};
|
||||
let (id, resolved, listed_as) = match row {
|
||||
None => (0, None, name.clone()),
|
||||
Some(ZoneChannelRow { resolved: None, .. }) => {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]))
|
||||
}
|
||||
Some(ZoneChannelRow { resolved: None, .. }) => return Ok(MultiValue::new()),
|
||||
Some(ZoneChannelRow {
|
||||
id,
|
||||
resolved,
|
||||
@@ -332,11 +339,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
};
|
||||
// `0x49ff6b edi = -1` / `0x49ff8a dec edi`; `0x49ec27 cmp esi,0xa; jae` skips.
|
||||
let window = match frame {
|
||||
Value::Integer(n) => Some(n as f64),
|
||||
Value::Number(n) => Some(n),
|
||||
_ => None,
|
||||
Some(v) => lua.coerce_number(v)?,
|
||||
None => None,
|
||||
}
|
||||
.map(|n| n.trunc() as i64 - 1)
|
||||
.map(|n| super::binding_abi::ftol(n).wrapping_sub(1))
|
||||
.and_then(|i| usize::try_from(i).ok())
|
||||
.filter(|i| *i < 10);
|
||||
if let Some(i) = window {
|
||||
@@ -348,7 +354,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
lua,
|
||||
ChannelCommand::Join {
|
||||
name: resolved.clone().unwrap_or_else(|| name.clone()),
|
||||
password: password.unwrap_or_default(),
|
||||
password,
|
||||
},
|
||||
);
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
|
||||
@@ -17,6 +17,7 @@ use mlua::{Lua, Value};
|
||||
|
||||
use super::binding_abi::flag;
|
||||
use super::{binding_abi, Model};
|
||||
use crate::script::binding_abi::Idx;
|
||||
|
||||
/// The `SkillLine.dbc` id behind weapon subclass `0..=20` (vmangos `Item.cpp:700-707`), `None`
|
||||
/// for the obsolete, exotic and misc rows; with no weapon the skill is [`SKILL_UNARMED`]
|
||||
@@ -406,7 +407,7 @@ pub(super) const KEYRING_INV_SLOTS: std::ops::RangeInclusive<usize> = 82..=113;
|
||||
|
||||
/// The keyring's container id (`KEYRING_CONTAINER`, `MainMenuBarBagButtons.lua:1`); must match
|
||||
/// `ui_items::KEYRING_CONTAINER` in the app, which feeds the keys as that container.
|
||||
const KEYRING_CONTAINER: i64 = -2;
|
||||
pub(super) const KEYRING_CONTAINER: i64 = -2;
|
||||
|
||||
impl Model {
|
||||
/// The item `token` exposes at live-API id `slot`, the one routing the `GetInventoryItem*`
|
||||
@@ -501,6 +502,9 @@ impl InvSlotView {
|
||||
locked: slot.locked,
|
||||
creator: slot.creator.clone(),
|
||||
enchants: slot.enchants.clone(),
|
||||
// The item's own flags (UNLOCKED, WRAPPED) and expiry, which the tooltip builder reads.
|
||||
flags: slot.flags,
|
||||
duration_ms: slot.duration_ms,
|
||||
// Slot 20 (`Bag0Slot`) among `equip_slots` marks a container (`INVTYPE_BAG`). The bank
|
||||
// vault is past the `0x16` short-circuit, so only `Some` versus `None` matters.
|
||||
contents_count: slot.equip_slots.contains(&20).then_some(0),
|
||||
@@ -859,7 +863,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// 4 broken.
|
||||
g.set(
|
||||
"GetInventoryAlertStatus",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
Ok(if (1..=12).contains(&index) {
|
||||
i64::from(model.inventory_alerts[index - 1])
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
//! (0 the backpack, 1-4 the worn bags, -1 the bank's own slots, 5-10 the bank bags, -2 the
|
||||
//! keyring); slots are 1-based.
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use super::binding_abi::flag;
|
||||
use super::binding_abi::{flag, number_arg};
|
||||
use super::cursor::{self, CursorItem, CursorPayload};
|
||||
use super::Model;
|
||||
|
||||
@@ -28,8 +28,11 @@ pub struct ContainerSlot {
|
||||
/// Icon texture path (`Interface\Icons\…`); `None` while the template answer is in flight.
|
||||
pub texture: Option<String>,
|
||||
pub count: u32,
|
||||
/// Item quality 0..6; `None` while unresolved (the API reports `nil`).
|
||||
/// Item quality 0..6; `None` while unresolved. `GetContainerItemInfo` reports it only when
|
||||
/// `inventory_type` is nonzero (`0x4f9794`), else -1.
|
||||
pub quality: Option<u32>,
|
||||
/// The template's InventoryType; 0 while unresolved and for an item that cannot be equipped.
|
||||
pub inventory_type: u32,
|
||||
/// The item's template entry; 0 while unresolved.
|
||||
pub item_id: u32,
|
||||
/// An `|Hitem:…|h[Name]|h` link once the name is known.
|
||||
@@ -430,6 +433,21 @@ fn queue_move(
|
||||
});
|
||||
}
|
||||
|
||||
/// The `(bag, slot)` the three container getters resolve (`0x4f9820`): both are numbers (else the
|
||||
/// binding's usage error), the slot is `trunc(n) - 1` and fails the resolver when negative
|
||||
/// (`0x4f990d`), so a zero or negative slot is no slot at all. `None` is that failure. The slot
|
||||
/// is returned 1-based, as the snapshot keys it.
|
||||
fn container_key(
|
||||
lua: &Lua,
|
||||
bag: Value,
|
||||
slot: Value,
|
||||
usage: &'static str,
|
||||
) -> mlua::Result<Option<(i64, u32)>> {
|
||||
let bag = number_arg(lua, bag, usage)?;
|
||||
let slot = number_arg(lua, slot, usage)?;
|
||||
Ok((slot >= 1).then_some((i64::from(bag), slot as u32)))
|
||||
}
|
||||
|
||||
/// Register the container verbs as bare globals, as `reference/1.12-globals.tsv` lists them;
|
||||
/// `C_Container` is a later client's namespace, and an addon that finds it assumes that client.
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
@@ -520,10 +538,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// re-feed cannot replay the finish flash.
|
||||
lua.globals().set(
|
||||
"GetContainerItemCooldown",
|
||||
lua.create_function(|lua, (bag, slot): (i64, u32)| {
|
||||
lua.create_function(|lua, (bag, slot): (Value, Value)| {
|
||||
let now = crate::script::clock::now(lua);
|
||||
let key = container_key(
|
||||
lua,
|
||||
bag,
|
||||
slot,
|
||||
"Usage: GetContainerItemCooldown(index, slot)",
|
||||
)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(match model.container_cooldowns.get(&(bag, slot)) {
|
||||
// No resolved slot or no item in it pushes three zeros, the enable included
|
||||
// (`0x4f9ae9`); an item with no running cooldown reads (0, 0, 1).
|
||||
let held = key.is_some_and(|k| {
|
||||
model
|
||||
.containers
|
||||
.get(&k.0)
|
||||
.is_some_and(|c| c.slots.contains_key(&k.1))
|
||||
});
|
||||
Ok(match key.and_then(|k| model.container_cooldowns.get(&k)) {
|
||||
_ if !held => (0.0, 0.0, 0),
|
||||
Some(&(start, duration, enabled)) if start + duration > now || !enabled => {
|
||||
(start, duration, i32::from(enabled))
|
||||
}
|
||||
@@ -534,28 +567,42 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
lua.globals().set(
|
||||
"GetContainerItemLink",
|
||||
lua.create_function(|lua, (bag, slot): (i64, u32)| {
|
||||
lua.create_function(|lua, (bag, slot): (Value, Value)| {
|
||||
let key = container_key(lua, bag, slot, "Usage: GetContainerItemLink(index, slot)")?;
|
||||
let link = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model
|
||||
.containers
|
||||
.get(&bag)
|
||||
.and_then(|c| c.slots.get(&slot))
|
||||
.and_then(|s| s.link.clone())
|
||||
key.and_then(|(bag, slot)| {
|
||||
model
|
||||
.containers
|
||||
.get(&bag)
|
||||
.and_then(|c| c.slots.get(&slot))
|
||||
.and_then(|s| s.link.clone())
|
||||
})
|
||||
};
|
||||
// Nothing at all on every miss: the resolver failing, no item object or no template
|
||||
// name (`0x4f996b`, `0x4f9979`, `0x4f99a5`).
|
||||
match link {
|
||||
Some(l) => Ok(Value::String(lua.create_string(&l)?)),
|
||||
None => Ok(Value::Nil),
|
||||
Some(l) => Ok(MultiValue::from_vec(vec![Value::String(
|
||||
lua.create_string(&l)?,
|
||||
)])),
|
||||
None => Ok(MultiValue::new()),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetContainerItemInfo(bag, slot) (`0x4f9670`) → texture, itemCount, locked, quality,
|
||||
// readable, the five `ContainerFrame.lua:241` reads; no values for an empty or unknown slot.
|
||||
// The texture is the question mark while the template is unknown.
|
||||
// `locked` and `readable` are 1 or nil (`0x4f975d`/`0x4f976b`, `0x4f97e6`/`0x4f97d3`).
|
||||
lua.globals().set(
|
||||
"GetContainerItemInfo",
|
||||
lua.create_function(|lua, (bag, slot): (i64, u32)| {
|
||||
lua.create_function(|lua, (bag, slot): (Value, Value)| {
|
||||
let Some((bag, slot)) =
|
||||
container_key(lua, bag, slot, "Usage: GetContainerItemInfo(index, slot)")?
|
||||
else {
|
||||
// The resolver failed (`0x4f990d`): nothing is pushed (`0x4f9810`).
|
||||
return Ok(mlua::MultiValue::new());
|
||||
};
|
||||
let (info, held_here) = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let held = matches!(
|
||||
@@ -581,18 +628,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
return Ok(mlua::MultiValue::new());
|
||||
};
|
||||
Ok(mlua::MultiValue::from_vec(vec![
|
||||
match &s.texture {
|
||||
Some(p) => Value::String(lua.create_string(p.as_str())?),
|
||||
None => Value::Nil,
|
||||
},
|
||||
// A string on every path: the question mark while the template is unknown
|
||||
// (`0x5d8af0`).
|
||||
Value::String(
|
||||
lua.create_string(
|
||||
s.texture
|
||||
.as_deref()
|
||||
.unwrap_or(super::item_stats::MISSING_ITEM_ICON),
|
||||
)?,
|
||||
),
|
||||
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.
|
||||
flag(s.locked || held_here),
|
||||
match s.quality {
|
||||
Some(q) => Value::Integer(i64::from(q)),
|
||||
None => Value::Nil,
|
||||
},
|
||||
// A number on every path: -1 unless the template is cached and equippable
|
||||
// (`0x4f9794`).
|
||||
Value::Integer(super::item_stats::gated_quality(
|
||||
s.quality,
|
||||
s.inventory_type,
|
||||
)),
|
||||
flag(s.readable),
|
||||
]))
|
||||
})?,
|
||||
@@ -733,6 +787,7 @@ mod tests {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -841,7 +896,8 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(icon, "Interface\\Icons\\INV_Misc_Food_16");
|
||||
assert_eq!((count, quality), (5, 1));
|
||||
// Food has InventoryType 0, so its quality is the -1 sentinel (`0x4f9794`).
|
||||
assert_eq!((count, quality), (5, -1));
|
||||
assert_eq!(
|
||||
s.eval::<i64>(
|
||||
"local _, _, id = string.find(GetContainerItemLink(0, 1), 'item:(%d+)') \
|
||||
@@ -858,11 +914,12 @@ mod tests {
|
||||
.eval::<bool>("local _, _, _, _, readable = GetContainerItemInfo(0, 4) return readable")
|
||||
.unwrap());
|
||||
|
||||
// An in-flight slot: texture and quality nil, itemCount real.
|
||||
// An in-flight slot: the question mark, the -1 quality sentinel, itemCount real.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local texture, itemCount, locked, quality = GetContainerItemInfo(0, 3)\n\
|
||||
return texture == nil and quality == nil and itemCount ~= nil",
|
||||
return texture == 'Interface\\\\Icons\\\\INV_Misc_QuestionMark' \
|
||||
and quality == -1 and itemCount ~= nil",
|
||||
)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
@@ -873,6 +930,98 @@ mod tests {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The fourth value is the template's quality only when the template is cached and its
|
||||
/// InventoryType is nonzero (`0x4f9794`, `0x4f979b`), else the number -1 (`0x4f97a6`): a potion,
|
||||
/// a reagent or conjured food answers -1 with its template in hand, as does an item whose
|
||||
/// template has not arrived. Never nil.
|
||||
#[test]
|
||||
fn container_item_info_quality_is_gated_on_the_inventory_type() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let slot = |quality: Option<u32>, inventory_type: u32| ContainerSlot {
|
||||
count: 1,
|
||||
quality,
|
||||
inventory_type,
|
||||
item_id: 25,
|
||||
..Default::default()
|
||||
};
|
||||
let mut slots = std::collections::HashMap::new();
|
||||
// An equippable epic, two InventoryType 0 items of quality 1 and 3, and an item whose
|
||||
// template is still in flight.
|
||||
slots.insert(1, slot(Some(4), 13));
|
||||
slots.insert(2, slot(Some(1), 0));
|
||||
slots.insert(3, slot(Some(3), 0));
|
||||
slots.insert(4, slot(None, 0));
|
||||
s.set_container(
|
||||
0,
|
||||
Some(ContainerState {
|
||||
slots,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
for (index, want) in [
|
||||
(1, "number:4"),
|
||||
(2, "number:-1"),
|
||||
(3, "number:-1"),
|
||||
(4, "number:-1"),
|
||||
] {
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!(
|
||||
"local c = function(...) return arg.n .. ':' .. type(arg[4]) .. ':' .. \
|
||||
tostring(arg[4]) end return c(GetContainerItemInfo(0, {index}))"
|
||||
))
|
||||
.unwrap(),
|
||||
format!("5:{want}"),
|
||||
"slot {index}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The texture is a string on every path: while the template is unknown, `0x5d8af0` falls back
|
||||
/// to `Interface\Icons\INV_Misc_QuestionMark` (`0x5d88b0`, `0x847fe4`), not nil.
|
||||
#[test]
|
||||
fn container_item_info_texture_falls_back_to_the_question_mark() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut slots = std::collections::HashMap::new();
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
count: 1,
|
||||
item_id: 25,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
slots.insert(
|
||||
2,
|
||||
ContainerSlot {
|
||||
count: 1,
|
||||
item_id: 26,
|
||||
texture: Some("Interface\\Icons\\INV_Sword_01".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
0,
|
||||
Some(ContainerState {
|
||||
slots,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
for (index, want) in [
|
||||
(1, "string:Interface\\Icons\\INV_Misc_QuestionMark"),
|
||||
(2, "string:Interface\\Icons\\INV_Sword_01"),
|
||||
] {
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!(
|
||||
"local c = function(...) return type(arg[1]) .. ':' .. tostring(arg[1]) end \
|
||||
return c(GetContainerItemInfo(0, {index}))"
|
||||
))
|
||||
.unwrap(),
|
||||
want,
|
||||
"slot {index}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `GetContainerItemInfo` (`0x4f9670`) pushes `locked` and `readable` as the number 1
|
||||
/// (`0x4f975d`, `0x4f97e6`) or nil (`0x4f976b`, `0x4f97d3`), five values in all.
|
||||
#[test]
|
||||
@@ -1001,6 +1150,7 @@ mod tests {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()),
|
||||
count: 1,
|
||||
quality: Some(2),
|
||||
inventory_type: 0,
|
||||
item_id: 200, // item B, a different id
|
||||
link: Some("|Hitem:200|h[Shiny Gem]|h".into()),
|
||||
locked: false,
|
||||
|
||||
@@ -12,6 +12,7 @@ use mlua::{Lua, MultiValue, Value, Variadic};
|
||||
use super::binding_abi::{self, number_arg};
|
||||
use super::item_stats::item_link;
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::Idx;
|
||||
|
||||
/// One reagent a recipe consumes (`GetCraftReagentInfo`); name and icon are `None` until the item
|
||||
/// template answer lands.
|
||||
@@ -272,14 +273,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// GetCraftInfo(index) → craftName, craftSubSpellName, craftType, numAvailable, isExpanded,
|
||||
// trainingPointCost, requiredLevel (`Blizzard_CraftUI.lua:168`); one nil past the end.
|
||||
// trainingPointCost, requiredLevel (`Blizzard_CraftUI.lua:168`); a miss pushes the seven too.
|
||||
// `isExpanded` is always nil; Beast Training's cost and level answer 0, not built.
|
||||
g.set(
|
||||
"GetCraftInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(r) = recipe(&model, index) else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
// `0x4f70a4`: nil, nil, nil, 0, nil, 0, 0.
|
||||
return Ok(binding_abi::tuple(&[
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(0),
|
||||
None,
|
||||
Some(0),
|
||||
Some(0),
|
||||
]));
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::String(lua.create_string(&r.name)?),
|
||||
@@ -296,7 +306,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// GetCraftIcon(index) → the icon path, nil while in flight or past the end.
|
||||
g.set(
|
||||
"GetCraftIcon",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
opt_str(lua, recipe(&model, index).and_then(|r| r.icon.as_ref()))
|
||||
})?,
|
||||
@@ -306,7 +316,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// painting (`Blizzard_CraftUI.lua:312`).
|
||||
g.set(
|
||||
"GetCraftDescription",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
opt_str(
|
||||
lua,
|
||||
@@ -318,21 +328,22 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// GetCraftNumReagents(index) → the reagent count, 0 past the end or when closed.
|
||||
g.set(
|
||||
"GetCraftNumReagents",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(recipe(&model, index).map_or(0, |r| r.reagents.len()) as i64)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetCraftReagentInfo(index, reagentIndex) → name, icon, need, have; one nil past the end.
|
||||
// GetCraftReagentInfo(index, reagentIndex) → name, icon, need, have; a miss is four nils
|
||||
// (`0x4f759b`).
|
||||
g.set(
|
||||
"GetCraftReagentInfo",
|
||||
lua.create_function(|lua, (index, reagent_index): (usize, usize)| {
|
||||
lua.create_function(|lua, (Idx(index), Idx(reagent_index)): (Idx, Idx)| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(reagent) = recipe(&model, index)
|
||||
.and_then(|r| reagent_index.checked_sub(1).and_then(|n| r.reagents.get(n)))
|
||||
else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
return Ok(binding_abi::tuple(&[None; 4]));
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
opt_str(lua, reagent.name.as_ref())?,
|
||||
|
||||
@@ -474,6 +474,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok(flag(matches!(model.cursor, Some(CursorPayload::Spell(_)))))
|
||||
})?,
|
||||
)?;
|
||||
// Deviation: returns nothing. The handler ends `mov eax,1` with nothing pushed (`0x4895bc`), so
|
||||
// Lua 5.0 hands back the stack top, the last argument or the function itself: an accident of
|
||||
// the handler, not an API, and copying a stack-top leak would copy a bug.
|
||||
g.set(
|
||||
"ClearCursor",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -526,6 +529,7 @@ mod tests {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(3),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -650,6 +654,24 @@ mod tests {
|
||||
assert_eq!(ask(&mut s, "CursorHasSpell"), NO);
|
||||
}
|
||||
|
||||
/// Deviation pin: the reference hands back its stack top (`0x4895bc`); `ClearCursor` returns
|
||||
/// nothing, with an empty cursor and a held payload alike.
|
||||
#[test]
|
||||
fn clear_cursor_returns_no_values() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let count = "return (function(...) return arg.n end)(ClearCursor())";
|
||||
assert_eq!(s.eval::<i64>(count).unwrap(), 0);
|
||||
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
|
||||
passive: false,
|
||||
book_slot: 1,
|
||||
book_type: "spell".into(),
|
||||
spell_id: 1,
|
||||
texture: None,
|
||||
}));
|
||||
assert_eq!(s.eval::<i64>(count).unwrap(), 0);
|
||||
assert!(s.cursor_payload().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_cursor_widens_to_any_arm() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The paper-doll cursor verbs: an equipped slot is an item source under
|
||||
//! [`super::EQUIPMENT_BAG`], so the shared cursor code handles its cancel, clear and world drop.
|
||||
|
||||
use mlua::Lua;
|
||||
use mlua::{Lua, Value};
|
||||
|
||||
use crate::script::binding_abi::flag;
|
||||
use crate::script::container::ContainerMove;
|
||||
@@ -237,9 +237,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
g.set(
|
||||
"IsInventoryItemLocked",
|
||||
lua.create_function(|lua, id: u32| {
|
||||
lua.create_function(|lua, slot: Value| {
|
||||
// The slot reader raises for any id outside its whitelist, a negative included
|
||||
// (`0x4c8520`, `0x4c8e7c`), and the ammo leg (Lua 0) answers nil (`0x4c8e98`).
|
||||
let slot0 = crate::script::inventory_verbs::inventory_slot_arg(
|
||||
lua,
|
||||
slot,
|
||||
"Invalid inventory slot in IsInventoryItemLocked",
|
||||
)?;
|
||||
if slot0 < 0 {
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(flag(is_inventory_item_locked(&model, id)))
|
||||
Ok(flag(is_inventory_item_locked(&model, slot0 as u32 + 1)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -252,6 +262,35 @@ mod tests {
|
||||
use crate::script::cursor::{CursorAction, CursorPayload, CursorSpell, EQUIPMENT_BAG};
|
||||
use crate::script::{ContainerMove, ContainerSlot, ContainerState, InvSlotView, UiScript};
|
||||
|
||||
/// `IsInventoryItemLocked`'s slot reader (`0x4c8520`) raises for any id outside {0, 1-23,
|
||||
/// 40-69, 82-113}, a negative included (`0x4c8e7c`); the ammo leg (0) answers nil; 99 is the
|
||||
/// keyring's slot 98 and answers.
|
||||
#[test]
|
||||
fn is_inventory_item_locked_raises_outside_the_readers_whitelist() {
|
||||
let s = UiScript::new().unwrap();
|
||||
for id in ["-1", "24", "39", "70", "81", "114", "'x'", "nil"] {
|
||||
let err = s
|
||||
.eval::<String>(&format!(
|
||||
"local ok, e = pcall(IsInventoryItemLocked, {id}) return ok and 'ok' or tostring(e)"
|
||||
))
|
||||
.unwrap();
|
||||
assert!(
|
||||
err.contains("Invalid inventory slot in IsInventoryItemLocked"),
|
||||
"{id}: {err}"
|
||||
);
|
||||
}
|
||||
for id in ["0", "1", "23", "40", "69", "82", "99", "113", "1.9"] {
|
||||
assert_eq!(
|
||||
s.eval::<i64>(&format!(
|
||||
"return (function(...) return arg.n end)(IsInventoryItemLocked({id}))"
|
||||
))
|
||||
.unwrap(),
|
||||
1,
|
||||
"{id} answers one value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Head (1), a ring in finger slot 11 that also fits 12, and Tabard (19).
|
||||
fn doll_slots() -> crate::script::InventorySlots {
|
||||
let mut slots: crate::script::InventorySlots = Default::default();
|
||||
@@ -325,6 +364,7 @@ mod tests {
|
||||
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
|
||||
count: 1,
|
||||
quality: Some(3),
|
||||
inventory_type: 0,
|
||||
item_id: 2000,
|
||||
link: Some("|cff0070dd|Hitem:2000:0:0:0|h[Another Helm]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -506,6 +546,7 @@ mod tests {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
|
||||
count: 1,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 4496,
|
||||
link: Some("|cffffffff|Hitem:4496:0:0:0|h[Small Brown Pouch]|h|r".into()),
|
||||
locked: false,
|
||||
|
||||
@@ -13,7 +13,7 @@ use super::Model;
|
||||
/// `0x4c8520`, the registrar's shared slot reader: `is-number`, `tonumber`, the truncating cast,
|
||||
/// then `dec` into the 0-based slot and the whitelist; a failure of either raises `message`. The
|
||||
/// 0-based slot, `-1` for Lua 0, the ammo leg.
|
||||
fn inventory_slot_arg(lua: &Lua, v: Value, message: &'static str) -> mlua::Result<i32> {
|
||||
pub(super) fn inventory_slot_arg(lua: &Lua, v: Value, message: &'static str) -> mlua::Result<i32> {
|
||||
let slot0 = number_arg(lua, v, message)?.wrapping_sub(1);
|
||||
if !inventory_slot_reader_accepts(slot0) {
|
||||
return Err(mlua::Error::RuntimeError(message.into()));
|
||||
|
||||
@@ -115,6 +115,25 @@ pub struct PlayerReqState {
|
||||
pub honor_rank: u8,
|
||||
}
|
||||
|
||||
/// The icon an item-texture getter answers while the item's template is unknown or its display
|
||||
/// has no icon: `0x5d8af0` looks the template up, finds none and passes display id 0 to `0x5d88b0`,
|
||||
/// which falls back to `INV_Misc_QuestionMark` (`0x847fe4`, `0x5d8927`) under the icon directory.
|
||||
/// `GetContainerItemInfo` (`0x4f9708`) and `GetTradePlayerItemInfo` (`0x4c0529`) push it, so their
|
||||
/// texture is a string on every path.
|
||||
pub(super) const MISSING_ITEM_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionMark";
|
||||
|
||||
/// The quality an item-info getter answers: the template's quality only when the template is
|
||||
/// cached and its InventoryType is nonzero, else -1 (`GetContainerItemInfo` `0x4f9794`..`0x4f97a6`,
|
||||
/// `GetTradePlayerItemInfo` `0x4c059c`..`0x4c05af`, `GetTradeTargetItemInfo` `0x4c0241`). A potion,
|
||||
/// a reagent or a trade good (InventoryType 0) answers -1 with its template in hand, and the
|
||||
/// number is never nil. `inventory_type` is 0 while the template is unresolved.
|
||||
pub(super) fn gated_quality(quality: Option<u32>, inventory_type: u32) -> i64 {
|
||||
match quality {
|
||||
Some(q) if inventory_type != 0 => i64::from(q),
|
||||
_ => -1,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`item_usable`] by item entry: entry 0 and a template not yet answered are usable, as the
|
||||
/// merchant getter skips an uncached record (`0x4fb298`).
|
||||
pub(super) fn item_usable_by_id(model: &super::Model, item_id: u32) -> bool {
|
||||
|
||||
@@ -12,6 +12,7 @@ use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use super::binding_abi::flag;
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::{Idx, Idx32};
|
||||
|
||||
/// `CLootButton`'s own Lua method table (`0x847ce4`): one entry, per the registrar's `mov edx,1`.
|
||||
pub(super) const REG_LOOTBUTTON_METHODS: &str = "__benilla_lootbutton_methods";
|
||||
@@ -48,7 +49,8 @@ pub struct LootRow {
|
||||
pub quality: Option<u32>,
|
||||
/// The synthesized coin pile (`LootSlotIsCoin` true, `LootSlotIsItem` false).
|
||||
pub is_coin: bool,
|
||||
/// The tooltip store's key, 0 for the coin row: `GetLootSlotInfo`'s fifth return, not 1.12's.
|
||||
/// The tooltip store's key, 0 for the coin row: `SetLootItem` reads it. `GetLootSlotInfo`
|
||||
/// returns four values and no id (`0x4c2d07`).
|
||||
pub item_id: u32,
|
||||
/// `GetLootSlotLink`'s answer; `None` for the coin row and while the template is in flight.
|
||||
pub link: Option<String>,
|
||||
@@ -154,7 +156,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// template lands, and the app holds `LOOT_OPENED` back likewise.
|
||||
g.set(
|
||||
"GetLootSlotInfo",
|
||||
lua.create_function(|lua, slot: usize| {
|
||||
lua.create_function(|lua, Idx(slot): Idx| {
|
||||
// `Some(Some(row))` live, `Some(None)` cleared, `None` no such slot.
|
||||
let slot_state = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
@@ -175,8 +177,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
} else {
|
||||
NO_SLOT_QUALITY
|
||||
}),
|
||||
// The trailing item id: none here.
|
||||
Value::Integer(0),
|
||||
]));
|
||||
};
|
||||
let texture =
|
||||
@@ -189,8 +189,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
item,
|
||||
Value::Integer(i64::from(row.quantity)),
|
||||
quality,
|
||||
// The item id, a fifth return that is not a 1.12 value.
|
||||
Value::Integer(i64::from(row.item_id)),
|
||||
]))
|
||||
})?,
|
||||
)?;
|
||||
@@ -200,7 +198,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// `ChatFrameEditBox:Insert` (`:152`), both of which take a nil.
|
||||
g.set(
|
||||
"GetLootSlotLink",
|
||||
lua.create_function(|lua, slot: usize| {
|
||||
lua.create_function(|lua, Idx(slot): Idx| {
|
||||
let link = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model
|
||||
@@ -305,7 +303,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// as nobody (`LootFrame.lua:168-232`).
|
||||
g.set(
|
||||
"GetMasterLootCandidate",
|
||||
lua.create_function(|lua, index: u32| {
|
||||
lua.create_function(|lua, Idx32(index): Idx32| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let name = index.checked_sub(1).and_then(|i| {
|
||||
model
|
||||
@@ -509,6 +507,33 @@ mod tests {
|
||||
assert!(s.eval::<bool>("return LootSlotIsItem(2)").unwrap());
|
||||
}
|
||||
|
||||
/// `GetLootSlotInfo` pushes four values on every leg (`0x4c2d07 mov eax,4`): texture, name,
|
||||
/// quantity, quality, with no item id.
|
||||
#[test]
|
||||
fn loot_slot_info_returns_four_values_on_every_leg() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let count = |s: &UiScript, slot: u32| -> i64 {
|
||||
s.eval::<i64>(&format!(
|
||||
"return (function(...) return arg.n end)(GetLootSlotInfo({slot}))"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
// No window.
|
||||
assert_eq!(count(&s, 1), 4);
|
||||
let mut state = loot();
|
||||
state.rows[0].as_mut().unwrap().item_id = 2589;
|
||||
state.rows[1].as_mut().unwrap().item_id = 2589;
|
||||
s.set_loot(Some(state.clone()));
|
||||
// A coin row, an item row with an id, an in-flight row, past the end.
|
||||
for slot in [1, 2, 3, 9] {
|
||||
assert_eq!(count(&s, slot), 4, "slot {slot}");
|
||||
}
|
||||
// A looted slot.
|
||||
state.rows[0] = None;
|
||||
s.set_loot(Some(state));
|
||||
assert_eq!(count(&s, 1), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_loot_candidates_read_1_based_and_nil_past_the_end() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -8,6 +8,7 @@ use mlua::Lua;
|
||||
|
||||
use super::binding_abi::flag;
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::Idx32;
|
||||
|
||||
/// `rollType` 0, the one vote the bind-on-pickup gate never intercepts.
|
||||
const PASS: u8 = 0;
|
||||
@@ -81,7 +82,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// template does there.
|
||||
g.set(
|
||||
"GetLootRollItemInfo",
|
||||
lua.create_function(|lua, roll_id: u32| {
|
||||
lua.create_function(|lua, Idx32(roll_id): Idx32| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(r) = find(&model, roll_id) else {
|
||||
return lua.pack_multi((
|
||||
@@ -106,7 +107,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// `nil` for an unknown id or while the item template is in flight.
|
||||
g.set(
|
||||
"GetLootRollItemLink",
|
||||
lua.create_function(|lua, roll_id: u32| {
|
||||
lua.create_function(|lua, Idx32(roll_id): Idx32| {
|
||||
let link = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
find(&model, roll_id).and_then(|r| r.link.clone())
|
||||
@@ -122,7 +123,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// (`LootFrame.lua:290-293`).
|
||||
g.set(
|
||||
"GetLootRollTimeLeft",
|
||||
lua.create_function(|lua, roll_id: u32| {
|
||||
lua.create_function(|lua, Idx32(roll_id): Idx32| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(find(&model, roll_id).map_or(0, |r| r.time_left_ms))
|
||||
})?,
|
||||
|
||||
@@ -11,6 +11,7 @@ use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::cursor::{raise_cursor_update, CursorMacro, CursorPayload};
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::Idx;
|
||||
|
||||
/// Macros per tab (`Blizzard_MacroUI.lua:1`), and so the character tab's base.
|
||||
pub const MAX_MACROS: usize = 18;
|
||||
@@ -169,9 +170,9 @@ impl super::UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// `CreateMacro(name, iconIndex, body, local, perCharacter)` (usage string `0x44cb74`): the new
|
||||
/// 1-based index, or `None` on the two failures the client logs (`0x44cbb4`, `0x44cbdc`), an empty
|
||||
/// name and a full tab.
|
||||
/// `CreateMacro(name, iconIndex, body, local, perCharacter)` (usage string `0x84cb74`): the new
|
||||
/// 1-based index, or the error the client raises for the two failures (`0x84cbb4`, `0x84cbdc`), an
|
||||
/// empty name and a full tab.
|
||||
fn create_macro(
|
||||
model: &mut Model,
|
||||
name: &str,
|
||||
@@ -179,18 +180,19 @@ fn create_macro(
|
||||
body: &str,
|
||||
local_only: bool,
|
||||
per_character: bool,
|
||||
) -> Option<usize> {
|
||||
let name = clamp_chars(name.trim(), MAX_MACRO_NAME);
|
||||
) -> mlua::Result<usize> {
|
||||
// Tested on the first byte (`*name == 0`): a blank name is a name.
|
||||
if name.is_empty() {
|
||||
model.record_warning("CreateMacro() failed, no name specified");
|
||||
return None;
|
||||
return Err(mlua::Error::RuntimeError(
|
||||
"CreateMacro() failed, no name specified".into(),
|
||||
));
|
||||
}
|
||||
let name = clamp_chars(name, MAX_MACRO_NAME);
|
||||
let list = model.macros.list_mut(per_character);
|
||||
if list.len() >= MAX_MACROS {
|
||||
model.record_warning(format!(
|
||||
return Err(mlua::Error::RuntimeError(format!(
|
||||
"CreateMacro() failed, already have {MAX_MACROS} macros"
|
||||
));
|
||||
return None;
|
||||
)));
|
||||
}
|
||||
list.push(MacroView {
|
||||
name,
|
||||
@@ -201,7 +203,7 @@ fn create_macro(
|
||||
let pos = list.len() - 1;
|
||||
model.macros_dirty = true;
|
||||
model.macros_generation += 1;
|
||||
Some(MacroIndex { per_character, pos }.lua_index())
|
||||
Ok(MacroIndex { per_character, pos }.lua_index())
|
||||
}
|
||||
|
||||
/// `EditMacro(index, name, icon, body, local)`: an omitted name, icon or body leaves its field
|
||||
@@ -303,10 +305,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetMacroInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(m) = model.macros.get(index) else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
// `0x4f1829`: four nils.
|
||||
return Ok(super::binding_abi::tuple(&[None; 4]));
|
||||
};
|
||||
let texture = match &m.texture {
|
||||
Some(t) => Value::String(lua.create_string(t)?),
|
||||
@@ -343,7 +346,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetMacroIconInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let path = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
index
|
||||
@@ -358,22 +361,41 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// CreateMacro stores `iconIndex`, an index into the chooser list, as its path.
|
||||
// CreateMacro(name, iconIndex, body, local, perCharacter) (`0x4f15b0`): the name is a string
|
||||
// or number and the icon a number or numeric string, else the usage text (`0x84cb74`) raises.
|
||||
// `iconIndex` is an index into the chooser list. An empty name raises (`0x84cbb4`, tested on
|
||||
// the first byte, so a blank one is a name), and so does a full tab (`0x84cbdc`); a made macro
|
||||
// answers exactly its new 1-based index (`0x4f16ce`). Not modelled: the script-context gate
|
||||
// `0x494a50(7)` ahead of the body, which has no taint to read here.
|
||||
g.set(
|
||||
"CreateMacro",
|
||||
lua.create_function(|lua, args: MultiValue| {
|
||||
let a: Vec<Value> = args.into_iter().collect();
|
||||
let name = opt_string(a.first()).unwrap_or_default();
|
||||
let usage = || {
|
||||
mlua::Error::RuntimeError(
|
||||
"Usage: CreateMacro(name, iconIndex, body, local, perCharacter)".into(),
|
||||
)
|
||||
};
|
||||
let name = super::binding_abi::string_arg(
|
||||
lua,
|
||||
a.first().cloned().unwrap_or(Value::Nil),
|
||||
"Usage: CreateMacro(name, iconIndex, body, local, perCharacter)",
|
||||
)?;
|
||||
let icon = lua
|
||||
.coerce_number(a.get(1).cloned().unwrap_or(Value::Nil))?
|
||||
.ok_or_else(usage)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let texture = icon_path(&model, a.get(1));
|
||||
let body = opt_string(a.get(2)).unwrap_or_default();
|
||||
// `trunc(n) - 1` against the chooser list, unsigned (`0x4f1632`, `0x4f163c`).
|
||||
let texture = usize::try_from(super::binding_abi::ftol(icon).wrapping_sub(1))
|
||||
.ok()
|
||||
.and_then(|i| model.macro_icons.get(i).cloned());
|
||||
let body = a
|
||||
.get(2)
|
||||
.and_then(|v| super::binding_abi::optional_string(lua, v))
|
||||
.unwrap_or_default();
|
||||
let (local_only, per_character) = (flag(a.get(3)), flag(a.get(4)));
|
||||
Ok(
|
||||
match create_macro(&mut model, &name, texture, &body, local_only, per_character) {
|
||||
Some(i) => Value::Integer(i as i64),
|
||||
None => Value::Nil,
|
||||
},
|
||||
)
|
||||
create_macro(&mut model, &name, texture, &body, local_only, per_character)
|
||||
.map(|i| Value::Integer(i as i64))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -545,12 +567,36 @@ mod tests {
|
||||
#[test]
|
||||
fn create_refuses_a_blank_name_and_a_full_tab_and_clamps_the_caps() {
|
||||
let s = seeded();
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return CreateMacro("", 1, "x") == nil"#)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return CreateMacro(" ", 1, "x") == nil"#)
|
||||
.unwrap());
|
||||
let raised = |s: &UiScript, call: &str| s.run(call).expect_err(call).to_string();
|
||||
assert!(raised(&s, r#"CreateMacro("", 1, "x")"#).contains("failed, no name specified"));
|
||||
// Tested on the first byte, not trimmed: a blank name is a name (`0x4f1610`).
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return CreateMacro(" ", 1, "x")"#)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
s.run("DeleteMacro(1)").unwrap();
|
||||
// A non-string name, a non-numeric icon and a missing icon raise the usage text.
|
||||
for call in [
|
||||
"CreateMacro()",
|
||||
"CreateMacro({}, 1)",
|
||||
"CreateMacro('a')",
|
||||
"CreateMacro('a', 'x')",
|
||||
"CreateMacro('a', nil, 'b')",
|
||||
] {
|
||||
assert!(
|
||||
raised(&s, call)
|
||||
.contains("Usage: CreateMacro(name, iconIndex, body, local, perCharacter)"),
|
||||
"{call}"
|
||||
);
|
||||
}
|
||||
// A number is a name, a numeric string an icon, and exactly one value is pushed.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return (function(...) return arg.n end)(CreateMacro(12, '1', 'b'))")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
s.run("DeleteMacro(1)").unwrap();
|
||||
|
||||
for i in 1..=MAX_MACROS {
|
||||
let made = s
|
||||
@@ -559,8 +605,8 @@ mod tests {
|
||||
assert_eq!(made as usize, i);
|
||||
}
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return CreateMacro("one too many", 1, "") == nil"#)
|
||||
.unwrap(),
|
||||
raised(&s, r#"CreateMacro("one too many", 1, "")"#)
|
||||
.contains("CreateMacro() failed, already have 18 macros"),
|
||||
"the 19th account macro is refused"
|
||||
);
|
||||
// The character tab is a separate 18.
|
||||
|
||||
@@ -7,6 +7,7 @@ use mlua::{Lua, MultiValue, Value};
|
||||
use super::binding_abi::flag;
|
||||
use super::cursor::{self, CursorPayload};
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::Idx;
|
||||
|
||||
/// One inbox row, resolved by the app from a wire `MailListEntry`; its 1-based index is its
|
||||
/// position in [`MailState::inbox`].
|
||||
@@ -284,7 +285,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// (`0x4aeff0`..`0x4af05e`): four nils, 0 for money, COD amount and days left, six nils.
|
||||
g.set(
|
||||
"GetInboxHeaderInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let row = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
row_at(&model, index)
|
||||
@@ -334,7 +335,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// (`MailFrame.lua:366`), the pair vmangos requires (`MailHandler.cpp:867`).
|
||||
g.set(
|
||||
"GetInboxText",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let row = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let row = row_at(&model, index);
|
||||
@@ -370,7 +371,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// being three nils and four zeros (`0x4af559`).
|
||||
g.set(
|
||||
"GetInboxInvoiceInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let row = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
row_at(&model, index)
|
||||
@@ -405,7 +406,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// the shared item-usable gate, true while the template is in flight.
|
||||
g.set(
|
||||
"GetInboxItem",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let (row, usable) = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let row = row_at(&model, index);
|
||||
|
||||
@@ -13,6 +13,7 @@ use super::binding_abi::{flag, number_arg};
|
||||
use super::container::UiCursorMode;
|
||||
use super::cursor::CursorPayload;
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::Idx;
|
||||
|
||||
/// One vendor row, resolved by the app; its 1-based index is its place in [`MerchantState::items`].
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -218,7 +219,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetMerchantItemInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let (item, usable) = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let item = model
|
||||
@@ -238,10 +239,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Some(n) => Value::String(lua.create_string(n)?),
|
||||
None => Value::Nil,
|
||||
};
|
||||
let texture = match &it.texture {
|
||||
Some(t) => Value::String(lua.create_string(t)?),
|
||||
None => Value::Nil,
|
||||
};
|
||||
// A string on every path: the wire display id has no icon row, or no row, so
|
||||
// `0x5d88b0` answers the question mark (`0x4fb214`, `0x5d8927`).
|
||||
let texture = Value::String(
|
||||
lua.create_string(
|
||||
it.texture
|
||||
.as_deref()
|
||||
.unwrap_or(super::item_stats::MISSING_ITEM_ICON),
|
||||
)?,
|
||||
);
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
name,
|
||||
texture,
|
||||
@@ -257,7 +263,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// arms accept a nil (`MerchantFrame.lua:303`, `:306`).
|
||||
g.set(
|
||||
"GetMerchantItemLink",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let link = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model
|
||||
@@ -362,7 +368,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// same `0x5ea930` gate over the sold item (`0x4fb4f7`): one its seller cannot use reads red.
|
||||
g.set(
|
||||
"GetBuybackItemInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let (item, usable) = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let item = model
|
||||
@@ -378,14 +384,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let Some(it) = item else {
|
||||
return Ok(MultiValue::from_vec(invalid_tuple()));
|
||||
};
|
||||
let name = match &it.name {
|
||||
Some(n) => Value::String(lua.create_string(n)?),
|
||||
None => Value::Nil,
|
||||
};
|
||||
let texture = match &it.texture {
|
||||
Some(t) => Value::String(lua.create_string(t)?),
|
||||
None => Value::Nil,
|
||||
};
|
||||
// A template the cache lacks takes the miss (`0x4fb416`, `0x4fb515`); a cached one
|
||||
// reaches the icon through its display (`0x4fb45e`, `0x5d8af0`), falling to the
|
||||
// question mark when that row is missing or bare.
|
||||
if it.name.is_none() {
|
||||
return Ok(MultiValue::from_vec(invalid_tuple()));
|
||||
}
|
||||
let name = Value::String(lua.create_string(it.name.as_deref().unwrap_or_default())?);
|
||||
let texture = Value::String(
|
||||
lua.create_string(
|
||||
it.texture
|
||||
.as_deref()
|
||||
.unwrap_or(super::item_stats::MISSING_ITEM_ICON),
|
||||
)?,
|
||||
);
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
name,
|
||||
texture,
|
||||
@@ -590,11 +602,13 @@ mod tests {
|
||||
assert_eq!((price, quantity, num), (25, 5, -1));
|
||||
assert_eq!(usable, 1);
|
||||
|
||||
// In flight: name and texture nil, and usable (the null-record skip).
|
||||
// In flight: name nil, the texture the question mark (the wire display has no icon row,
|
||||
// `0x4fb214`), and usable (the null-record skip).
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local n, t, p, q, a, u = GetMerchantItemInfo(2)\n\
|
||||
return n == nil and t == nil and p == 1500 and u == 1",
|
||||
return n == nil and t == 'Interface\\\\Icons\\\\INV_Misc_QuestionMark' \
|
||||
and p == 1500 and u == 1",
|
||||
)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
@@ -746,6 +760,56 @@ mod tests {
|
||||
assert!(!s.take_merchant_close(), "drained");
|
||||
}
|
||||
|
||||
/// The question-mark fallback and the buyback miss: a vendor row whose wire display has no
|
||||
/// icon (or no row) answers the question mark (`0x5d88b0` at `0x4fb214`), a buyback row with a
|
||||
/// cached template and no icon does too (`0x5d8af0` at `0x4fb45e`), and one whose template is
|
||||
/// uncached takes the miss tuple nil, nil, 0, 1, 0, 1 (`0x4fb416`, `0x4fb515`).
|
||||
#[test]
|
||||
fn merchant_and_buyback_icons_fall_back_and_an_uncached_buyback_misses() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let row = |name: Option<&str>| MerchantItem {
|
||||
name: name.map(str::to_string),
|
||||
texture: None,
|
||||
price: 10,
|
||||
quantity: 1,
|
||||
num_available: -1,
|
||||
item_id: 25,
|
||||
..Default::default()
|
||||
};
|
||||
s.set_merchant(Some(MerchantState {
|
||||
items: vec![row(Some("Sword")), row(None)],
|
||||
buyback: vec![
|
||||
MerchantItem {
|
||||
num_available: 0,
|
||||
..row(Some("Sold Sword"))
|
||||
},
|
||||
row(None),
|
||||
],
|
||||
..Default::default()
|
||||
}));
|
||||
let shape = |call: &str| -> String {
|
||||
s.eval(&format!(
|
||||
"local c = function(...) local o = {{}} for i = 1, arg.n do o[i] = tostring(arg[i]) end \
|
||||
return table.concat(o, ',') end return c({call})"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let q = "Interface\\Icons\\INV_Misc_QuestionMark";
|
||||
assert_eq!(
|
||||
shape("GetMerchantItemInfo(1)"),
|
||||
format!("Sword,{q},10,1,-1,1")
|
||||
);
|
||||
assert_eq!(
|
||||
shape("GetMerchantItemInfo(2)"),
|
||||
format!("nil,{q},10,1,-1,1")
|
||||
);
|
||||
assert_eq!(
|
||||
shape("GetBuybackItemInfo(1)"),
|
||||
format!("Sold Sword,{q},10,1,0,1")
|
||||
);
|
||||
assert_eq!(shape("GetBuybackItemInfo(2)"), "nil,nil,0,1,0,1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buyback_reads_and_intents() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -648,7 +648,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// GetNumSavedInstances() / GetSavedInstanceInfo(index), 1-based over the pushed list; a miss
|
||||
// returns nothing.
|
||||
// pushes nil, 0, nil (`0x4a19e9`).
|
||||
g.set(
|
||||
"GetNumSavedInstances",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -673,7 +673,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Value::Integer(i64::from(r.instance)),
|
||||
Value::Integer(i64::from(r.reset)),
|
||||
]),
|
||||
None => MultiValue::new(),
|
||||
None => super::binding_abi::tuple(&[None, Some(0), None]),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::Idx32;
|
||||
|
||||
// The slot-word values the one-shot orders synthesize, restated from
|
||||
// `benilla_protocol::messages::pet` because this crate does not depend on the protocol.
|
||||
@@ -285,13 +286,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// An out-of-range index answers a single nil, which the Lua reads as an empty slot.
|
||||
// An out-of-range index answers seven nils (`0x4bdf3e`), which the Lua reads as an empty slot.
|
||||
g.set(
|
||||
"GetPetActionInfo",
|
||||
lua.create_function(move |lua, i: u32| {
|
||||
lua.create_function(move |lua, Idx32(i): Idx32| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(slot) = slot_at(&model, i) else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
return Ok(super::binding_abi::tuple(&[None; 7]));
|
||||
};
|
||||
let v = &slot.view;
|
||||
let text = |s: &Option<String>| match s {
|
||||
@@ -313,7 +314,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// An elapsed or absent cooldown answers (0, 0, 1), so a re-feed never replays the sweep.
|
||||
g.set(
|
||||
"GetPetActionCooldown",
|
||||
lua.create_function(|lua, i: u32| {
|
||||
lua.create_function(|lua, Idx32(i): Idx32| {
|
||||
let now = crate::script::clock::now(lua);
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(match slot_at(&model, i).and_then(|s| s.cooldown) {
|
||||
@@ -328,7 +329,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// The one pet binding that answers a real boolean (`0x6f39f0`), `false` even out of range.
|
||||
g.set(
|
||||
"IsPetAttackActive",
|
||||
lua.create_function(move |lua, i: u32| {
|
||||
lua.create_function(move |lua, Idx32(i): Idx32| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(slot_at(&model, i).is_some_and(|s| s.view.attack_active))
|
||||
})?,
|
||||
|
||||
@@ -7,6 +7,7 @@ use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::binding_abi::flag;
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::Idx;
|
||||
|
||||
/// Which questgiver panel a [`QuestState`] is for, set from the wire packet.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -246,7 +247,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
fn install_title(lua: &Lua, name: &str, active: bool) -> mlua::Result<()> {
|
||||
lua.globals().set(
|
||||
name,
|
||||
lua.create_function(move |lua, i: usize| {
|
||||
lua.create_function(move |lua, Idx(i): Idx| {
|
||||
let title = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model.quest.as_ref().and_then(|q| {
|
||||
@@ -329,7 +330,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// GetQuestItemLink(type, index), for a row's ctrl and shift clicks (`QuestFrame.lua:118`).
|
||||
g.set(
|
||||
"GetQuestItemLink",
|
||||
lua.create_function(|lua, (kind, index): (String, usize)| {
|
||||
lua.create_function(|lua, (kind, index): (String, Value)| {
|
||||
// `index - 1` is compared unsigned against 6 (`0x501924`), so a zero or negative index
|
||||
// is no item; every miss pushes one nil (`0x50216a`).
|
||||
let index = super::binding_abi::index_arg(
|
||||
lua,
|
||||
index,
|
||||
"Usage: GetQuestItemLink(\"type\", index)",
|
||||
)?;
|
||||
let link = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model.quest.as_ref().and_then(|q| {
|
||||
@@ -447,11 +455,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// `RewChoiceItemId` with it.
|
||||
g.set(
|
||||
"GetQuestReward",
|
||||
lua.create_function(|lua, choice: Option<u32>| {
|
||||
lua.create_function(|lua, choice: Option<Value>| {
|
||||
// `is-number` failing leaves the choice at -1 (`0x501d89`) and raises nothing; a
|
||||
// number is `trunc(n) - 1` (`0x501da8`), read as signed.
|
||||
let choice = match choice {
|
||||
Some(v) => lua
|
||||
.coerce_number(v)?
|
||||
.map_or(-1, |n| super::binding_abi::ftol(n).wrapping_sub(1)),
|
||||
None => -1,
|
||||
};
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.quest_actions.push(super::QuestAction::Reward(
|
||||
choice.unwrap_or(0).saturating_sub(1),
|
||||
));
|
||||
// Off the reward panel the reference does nothing and answers success (`0x5015b0`:
|
||||
// `[0xbe0818] != 3`).
|
||||
let Some(q) = model
|
||||
.quest
|
||||
.as_ref()
|
||||
.filter(|q| q.panel == QuestPanel::Reward)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
// With choices on offer an index outside `[0, n)` fails (`0x501608`..`0x50160e`) and
|
||||
// the binding raises; without any, the wire gets `max(choice, 0)` (`0x501627`).
|
||||
let n = q.choices.len() as i32;
|
||||
if n > 0 && !(0..n).contains(&choice) {
|
||||
return Err(mlua::Error::RuntimeError(
|
||||
"Invalid reward choice in GetQuestReward([choice])".into(),
|
||||
));
|
||||
}
|
||||
model
|
||||
.quest_actions
|
||||
.push(super::QuestAction::Reward(choice.max(0) as u32));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -700,21 +733,89 @@ mod tests {
|
||||
assert_eq!(s.take_quest_actions(), vec![QuestAction::Accept]);
|
||||
|
||||
s.run("CompleteQuest()").unwrap(); // progress -> reward
|
||||
let mut offer = detail();
|
||||
offer.panel = QuestPanel::Reward;
|
||||
s.set_quest(Some(offer)); // the app pushes the reward panel
|
||||
s.run("GetQuestReward(2)").unwrap(); // row 2 (1-based) → wire choice 1 (0-based)
|
||||
s.run("GetQuestReward()").unwrap(); // no-choice quest -> 0
|
||||
s.run("DeclineQuest()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_quest_actions(),
|
||||
vec![
|
||||
QuestAction::Continue,
|
||||
QuestAction::Reward(1),
|
||||
QuestAction::Reward(0),
|
||||
QuestAction::Decline,
|
||||
]
|
||||
);
|
||||
assert!(s.take_quest_actions().is_empty(), "drained");
|
||||
}
|
||||
|
||||
/// `GetQuestReward` (`0x501d80` → `0x5015b0`): with choices on offer an index outside `[0, n)`
|
||||
/// raises "Invalid reward choice" (`0x84eda4`), the default for no argument being -1; with
|
||||
/// none the wire gets `max(index, 0)`; off the reward panel it does nothing.
|
||||
#[test]
|
||||
fn get_quest_reward_validates_the_choice_against_the_offer() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_quest(Some(detail()));
|
||||
let n_choices = s.eval::<i64>("return GetNumQuestChoices()").unwrap();
|
||||
assert!(n_choices >= 2);
|
||||
// Not on the reward panel yet: nothing happens, and nothing raises.
|
||||
s.run("GetQuestReward(99)").unwrap();
|
||||
assert!(s.take_quest_actions().is_empty());
|
||||
s.run("CompleteQuest()").unwrap();
|
||||
s.take_quest_actions();
|
||||
let mut offer = detail();
|
||||
offer.panel = QuestPanel::Reward;
|
||||
s.set_quest(Some(offer));
|
||||
for call in [
|
||||
"GetQuestReward()",
|
||||
"GetQuestReward(0)",
|
||||
"GetQuestReward(-3)",
|
||||
&format!("GetQuestReward({})", n_choices + 1),
|
||||
"GetQuestReward('x')",
|
||||
] {
|
||||
let err = s.run(call).unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("Invalid reward choice in GetQuestReward([choice])"),
|
||||
"{call}: {err}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
s.take_quest_actions().is_empty(),
|
||||
"a failed pick sends nothing"
|
||||
);
|
||||
s.run(&format!("GetQuestReward({n_choices})")).unwrap();
|
||||
s.run("GetQuestReward(1.9)").unwrap();
|
||||
assert_eq!(
|
||||
s.take_quest_actions(),
|
||||
vec![
|
||||
QuestAction::Reward(n_choices as u32 - 1),
|
||||
QuestAction::Reward(0)
|
||||
]
|
||||
);
|
||||
|
||||
// No choices on offer: any index goes, as `max(index, 0)`.
|
||||
let mut bare = detail();
|
||||
bare.choices.clear();
|
||||
bare.panel = QuestPanel::Reward;
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_quest(Some(bare));
|
||||
for call in [
|
||||
"GetQuestReward()",
|
||||
"GetQuestReward(-3)",
|
||||
"GetQuestReward(4)",
|
||||
] {
|
||||
s.run(call).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
s.take_quest_actions(),
|
||||
vec![
|
||||
QuestAction::Reward(0),
|
||||
QuestAction::Reward(0),
|
||||
QuestAction::Reward(3)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_the_quest_empties_it() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -15,6 +15,7 @@ use mlua::{Lua, MultiValue, Value};
|
||||
use super::binding_abi::{flag, number_arg};
|
||||
use super::quest::QuestItemView;
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::{Idx, Idx32};
|
||||
|
||||
/// One quest-log row; its 1-based position in [`QuestLogState::entries`] is the index every verb
|
||||
/// takes.
|
||||
@@ -291,9 +292,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// (`QuestLogFrame.lua:616`).
|
||||
g.set(
|
||||
"GetNumQuestLeaderBoards",
|
||||
lua.create_function(|lua, i: Option<u32>| {
|
||||
lua.create_function(|lua, i: Option<Idx32>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let index = i.unwrap_or(model.quest_log_selection) as usize;
|
||||
let index = i.map_or(model.quest_log_selection, |i| i.0) as usize;
|
||||
Ok(index
|
||||
.checked_sub(1)
|
||||
.and_then(|n| model.quest_log.entries.get(n))
|
||||
@@ -308,10 +309,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// takes the tail at `0x4e07ac`, which pushes three nils.
|
||||
g.set(
|
||||
"GetQuestLogLeaderBoard",
|
||||
lua.create_function(|lua, (i, quest): (usize, Option<u32>)| {
|
||||
lua.create_function(|lua, (Idx(i), quest): (Idx, Option<Idx32>)| {
|
||||
let line = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let index = quest.unwrap_or(model.quest_log_selection) as usize;
|
||||
let index = quest.map_or(model.quest_log_selection, |q| q.0) as usize;
|
||||
index
|
||||
.checked_sub(1)
|
||||
.and_then(|n| model.quest_log.entries.get(n))
|
||||
@@ -554,7 +555,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// (`QuestTimerFrame.lua:43`, `QuestTimerFrame.xml:20`).
|
||||
g.set(
|
||||
"GetQuestIndexForTimer",
|
||||
lua.create_function(|lua, t: usize| {
|
||||
lua.create_function(|lua, Idx(t): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let now = model.server_unix_time;
|
||||
let index = t.checked_sub(1).and_then(|n| {
|
||||
@@ -703,7 +704,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
g.set(
|
||||
"IsQuestWatched",
|
||||
lua.create_function(|lua, i: u32| {
|
||||
lua.create_function(|lua, Idx32(i): Idx32| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(flag(
|
||||
watch_id_at(&model, i).is_some_and(|id| model.quest_log_watched.contains(&id)),
|
||||
@@ -735,7 +736,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// The watched quest's current 1-based row, nil when it is not a visible row.
|
||||
g.set(
|
||||
"GetQuestIndexForWatch",
|
||||
lua.create_function(|lua, w: usize| {
|
||||
lua.create_function(|lua, Idx(w): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let index = w
|
||||
.checked_sub(1)
|
||||
|
||||
@@ -14,6 +14,7 @@ use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::binding_abi;
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::Idx;
|
||||
|
||||
/// The synthetic header key for factions the player moved to the inactive bucket.
|
||||
const KEY_INACTIVE: i64 = -1;
|
||||
@@ -270,7 +271,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// isCollapsed, isWatched; eleven wide on every path, a miss included.
|
||||
g.set(
|
||||
"GetFactionInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(row) = index
|
||||
.checked_sub(1)
|
||||
@@ -403,7 +404,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// the pane repaints on; the folds survive, as only a server rebuild resets them.
|
||||
g.set(
|
||||
"IsFactionInactive",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(binding_abi::flag(
|
||||
entry_at(&model, index).is_some_and(|e| e.inactive),
|
||||
|
||||
@@ -67,6 +67,17 @@ fn form_at(model: &Model, i: u32) -> Option<&StoredShapeshiftForm> {
|
||||
.and_then(|n| model.shapeshift_forms.get(n))
|
||||
}
|
||||
|
||||
/// The index of the two form getters: `lua_tonumber` (`0x6f3620`) compared with 0.0 (`0x4b45d8`,
|
||||
/// `0x4b49b4`), so a zero, an absent or a non-number raises the binding's usage error (`0x846a6c`,
|
||||
/// `0x846ab4`) before the table is read; the truncated value is then compared unsigned.
|
||||
fn form_index(lua: &Lua, index: Option<Value>, usage: &'static str) -> mlua::Result<u32> {
|
||||
let n = crate::script::binding_abi::coerced_number(lua, index);
|
||||
if n == 0.0 {
|
||||
return Err(mlua::Error::RuntimeError(usage.into()));
|
||||
}
|
||||
Ok(crate::script::binding_abi::ftol(n) as u32)
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
@@ -80,10 +91,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetShapeshiftFormInfo",
|
||||
lua.create_function(|lua, i: u32| {
|
||||
lua.create_function(|lua, index: Option<Value>| {
|
||||
let i = form_index(lua, index, "Usage: GetShapeshiftFormInfo(index)")?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(form) = form_at(&model, i) else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
// `0x4b47d9`: four nils.
|
||||
return Ok(super::binding_abi::tuple(&[None; 4]));
|
||||
};
|
||||
let texture = match &form.view.texture {
|
||||
Some(t) => Value::String(lua.create_string(t)?),
|
||||
@@ -103,7 +116,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// replays the sweep.
|
||||
g.set(
|
||||
"GetShapeshiftFormCooldown",
|
||||
lua.create_function(|lua, i: u32| {
|
||||
lua.create_function(|lua, index: Option<Value>| {
|
||||
let i = form_index(lua, index, "Usage: GetShapeshiftFormCooldown(index)")?;
|
||||
let now = crate::script::clock::now(lua);
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(match form_at(&model, i).and_then(|f| f.cooldown) {
|
||||
|
||||
@@ -18,6 +18,7 @@ use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::binding_abi;
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::Idx;
|
||||
|
||||
/// One known skill line off the player's `PLAYER_SKILL_INFO` block, resolved by the app.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -280,7 +281,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// stock `SkillFrame.lua:211`'s early return.
|
||||
g.set(
|
||||
"GetSkillLineInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(n) = index.checked_sub(1) else {
|
||||
return out_of_range_tuple(lua);
|
||||
|
||||
@@ -169,7 +169,11 @@ pub(super) fn is_pet_book(book_type: &str) -> bool {
|
||||
/// The reference's shared spell-slot marshaller (`0x4b3ec0`): arg 1 a number, arg 2 a number or
|
||||
/// string (`0x4b3ee8`), and the index `trunc(arg1 - 1)` in `[0, 0x400)`, else the binding raises
|
||||
/// `Invalid spell slot in <Verb>`. Answers the 1-based id and the normalised book type.
|
||||
fn spell_slot_args(id: Value, book_type: Value, verb: &str) -> mlua::Result<(u32, String)> {
|
||||
pub(super) fn spell_slot_args(
|
||||
id: Value,
|
||||
book_type: Value,
|
||||
verb: &str,
|
||||
) -> mlua::Result<(u32, String)> {
|
||||
let invalid = || mlua::Error::runtime(format!("Invalid spell slot in {verb}"));
|
||||
// `lua_isnumber` + `lua_tonumber` (`0x6f34d0`/`0x6f3620`): a numeric string is a number.
|
||||
let index = match id {
|
||||
@@ -187,8 +191,10 @@ fn spell_slot_args(id: Value, book_type: Value, verb: &str) -> mlua::Result<(u32
|
||||
Value::Integer(_) | Value::Number(_) => String::new(),
|
||||
_ => return Err(invalid()),
|
||||
};
|
||||
let index = (index - 1.0).trunc();
|
||||
if !(0.0..1024.0).contains(&index) {
|
||||
// `fsub 1.0` then `_ftol` (`0x4b3efd`, `0x532dd9`): the cast wraps to its low dword, so
|
||||
// 4294967297 is slot 1, and the signed result must lie in `[0, 0x400)`.
|
||||
let index = super::binding_abi::ftol(index - 1.0);
|
||||
if !(0..1024).contains(&index) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let book = if is_pet_book(&book) {
|
||||
|
||||
@@ -22,6 +22,7 @@ use mlua::{Lua, MultiValue, Table, Value};
|
||||
use super::binding_abi::flag;
|
||||
use super::tooltip_spell::{spell_view_of, TalentLines};
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::Idx;
|
||||
|
||||
/// One talent page, a `TalentTab.dbc` row: `GetTalentTabInfo`'s source.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -126,13 +127,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `texture` is nil: the stock frame never reads it. Out of range answers a single nil.
|
||||
// `texture` is nil: the stock frame never reads it. Out of range answers nil, nil, 0, nil
|
||||
// (`0x4f3132`).
|
||||
g.set(
|
||||
"GetTalentTabInfo",
|
||||
lua.create_function(|lua, i: usize| {
|
||||
lua.create_function(|lua, Idx(i): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(tab) = i.checked_sub(1).and_then(|n| model.talents.tabs.get(n)) else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
return Ok(super::binding_abi::tuple(&[None, None, Some(0), None]));
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::String(lua.create_string(&tab.name)?),
|
||||
@@ -145,7 +147,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetNumTalents",
|
||||
lua.create_function(|lua, tab: usize| {
|
||||
lua.create_function(|lua, Idx(tab): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let n = tab
|
||||
.checked_sub(1)
|
||||
@@ -157,10 +159,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetTalentInfo",
|
||||
lua.create_function(|lua, (tab, i): (usize, usize)| {
|
||||
lua.create_function(|lua, (Idx(tab), Idx(i)): (Idx, Idx)| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(t) = talent_at(&model, tab, i) else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
// `0x4f3454`: nil, nil, 1, 1, 0, 0, nil, nil.
|
||||
return Ok(super::binding_abi::tuple(&[
|
||||
None,
|
||||
None,
|
||||
Some(1),
|
||||
Some(1),
|
||||
Some(0),
|
||||
Some(0),
|
||||
None,
|
||||
None,
|
||||
]));
|
||||
};
|
||||
let texture = match &t.texture {
|
||||
Some(tex) => Value::String(lua.create_string(tex)?),
|
||||
|
||||
@@ -135,6 +135,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// The reference computes the hovered node's route here; the app precomputes every route.
|
||||
// Deviation: returns nothing. The handler ends `mov eax,1` with nothing pushed (`0x4dcd7c`), so
|
||||
// Lua 5.0 hands back the stack top, the node argument: an accident of the handler, not an API,
|
||||
// and copying a stack-top leak would copy a bug.
|
||||
g.set(
|
||||
"TaxiNodeSetCurrent",
|
||||
lua.create_function(|_, _: i64| Ok(()))?,
|
||||
@@ -221,3 +224,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// Deviation pin: the reference hands back its stack top (`0x4dcd7c`); `TaxiNodeSetCurrent`
|
||||
/// returns nothing, whatever the node.
|
||||
#[test]
|
||||
fn taxi_node_set_current_returns_no_values() {
|
||||
let s = UiScript::new().unwrap();
|
||||
for node in ["1", "0", "99"] {
|
||||
assert_eq!(
|
||||
s.eval::<i64>(&format!(
|
||||
"return (function(...) return arg.n end)(TaxiNodeSetCurrent({node}))"
|
||||
))
|
||||
.unwrap(),
|
||||
0,
|
||||
"node {node}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,3 +248,43 @@ fn a_manual_guild_recruitment_verb_resets_the_latch() {
|
||||
);
|
||||
assert!(!s.take_guild_recruitment_change());
|
||||
}
|
||||
|
||||
/// `JoinChannelByName` (`0x49ff00`): a name that is neither string nor number raises the usage
|
||||
/// text (`0x49ffe7`); a spaced name pushes nothing (`0x49ff5e`); a join pushes two values (the
|
||||
/// channel id and the resolved name or nil, `0x49ffcc`, `0x49ffdd`) and sends; a `frameId` may be
|
||||
/// a numeric string.
|
||||
#[test]
|
||||
fn join_channel_by_name_raises_pushes_two_values_or_nothing() {
|
||||
let mut s = script();
|
||||
let count = |s: &crate::script::UiScript, call: &str| -> i64 {
|
||||
s.eval(&format!("return (function(...) return arg.n end)({call})"))
|
||||
.unwrap()
|
||||
};
|
||||
for call in [
|
||||
"JoinChannelByName()",
|
||||
"JoinChannelByName(nil)",
|
||||
"JoinChannelByName({})",
|
||||
"JoinChannelByName(true)",
|
||||
] {
|
||||
let err = s.run(call).expect_err(call).to_string();
|
||||
assert!(
|
||||
err.contains(r#"Usage: JoinChannelByName("name")"#),
|
||||
"{call}: {err}"
|
||||
);
|
||||
}
|
||||
// A spaced name, and an empty one: nothing at all, and nothing sent.
|
||||
assert_eq!(count(&s, "JoinChannelByName('two words')"), 0);
|
||||
assert_eq!(count(&s, "JoinChannelByName('')"), 0);
|
||||
assert!(s.take_channel_commands().is_empty());
|
||||
// A custom channel: two values, the second nil; a number is stringified.
|
||||
assert_eq!(count(&s, "JoinChannelByName('Guildies', 'pw', '2')"), 2);
|
||||
assert_eq!(count(&s, "JoinChannelByName(7)"), 2);
|
||||
let sent = s.take_channel_commands();
|
||||
assert_eq!(sent.len(), 2);
|
||||
// The numeric-string frame id registered the channel in window 2's list.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"for _, n in ipairs({ GetChatWindowChannels(2) }) do if n == 'Guildies' then return true end end return false"
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! An indexed getter asked for a row it does not have still pushes its full tuple, as the handler's
|
||||
//! miss path does: a `0x6f37f0` nil or a `0x6f3810` number per slot, and the same count as a hit.
|
||||
//! Each answer is read as `tostring` of every value joined by commas, counted with 5.0's `arg.n`,
|
||||
//! so one string pins the count, the types and the values (`nil` or the number).
|
||||
|
||||
use super::common::script;
|
||||
|
||||
const SHOW: &str = "function PGShow(...) local o = {} \
|
||||
for i = 1, arg.n do local v = arg[i] if v == nil then o[i] = 'nil' else o[i] = tostring(v) end end \
|
||||
return table.concat(o, ',') end";
|
||||
|
||||
/// `(call, the tuple the reference pushes on a miss)`; the model is empty, so every call misses.
|
||||
const MISSES: &[(&str, &str)] = &[
|
||||
// The resolver fails (`0x4f990d`) or finds no item: nothing at all, and three zeros for the
|
||||
// cooldown (`0x4f9810`, `0x4f99a5`, `0x4f9ae9`).
|
||||
("GetContainerItemInfo(0, 1)", ""),
|
||||
("GetContainerItemLink(0, 1)", ""),
|
||||
("GetContainerItemCooldown(0, 1)", "0,0,0"),
|
||||
// `index - 1` compared unsigned (`0x4ceed9`, `0x4cf375`): the twelve-value miss (`0x4cf1ec`),
|
||||
// and nothing for a link (`0x4cf45b`).
|
||||
(
|
||||
"GetAuctionItemInfo('list', 1)",
|
||||
"nil,nil,1,-1,nil,0,0,0,0,0,nil,nil",
|
||||
),
|
||||
("GetAuctionItemLink('list', 1)", ""),
|
||||
// `index - 1` against 6, unsigned (`0x501924`); every miss is one nil (`0x50216a`).
|
||||
("GetQuestItemLink('reward', 1)", "nil"),
|
||||
("GetCraftInfo(1)", "nil,nil,nil,0,nil,0,0"),
|
||||
("GetCraftInfo(0)", "nil,nil,nil,0,nil,0,0"),
|
||||
("GetCraftReagentInfo(1, 1)", "nil,nil,nil,nil"),
|
||||
("GetMacroInfo(1)", "nil,nil,nil,nil"),
|
||||
("GetMapLandmarkInfo(1)", "nil,nil,0,0,0"),
|
||||
("GetMapOverlayInfo(1)", "nil,0,0,0,0,0,0"),
|
||||
("GetPetActionInfo(1)", "nil,nil,nil,nil,nil,nil,nil"),
|
||||
("GetSavedInstanceInfo(1)", "nil,0,nil"),
|
||||
("GetShapeshiftFormInfo(1)", "nil,nil,nil,nil"),
|
||||
("GetTalentInfo(1, 1)", "nil,nil,1,1,0,0,nil,nil"),
|
||||
("GetTalentTabInfo(1)", "nil,nil,0,nil"),
|
||||
("GetTradePlayerItemInfo(1)", "nil,nil,0,0,nil"),
|
||||
("GetTradeTargetItemInfo(1)", "nil,nil,0,0,nil,nil"),
|
||||
("GetTradeSkillInfo(1)", "nil,nil,0,nil"),
|
||||
("GetTradeSkillReagentInfo(1, 1)", "nil,nil,nil,nil"),
|
||||
("GetTrainerServiceInfo(1)", "nil,nil,nil,1"),
|
||||
("GetTrainerServiceInfo(0)", "nil,nil,nil,1"),
|
||||
("GetTrainerServiceSkillReq(1)", "nil,0,1"),
|
||||
("GetTrainerServiceAbilityReq(1, 1)", "nil,1"),
|
||||
("GetTrainerServiceStepReq(1)", "nil,1"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn an_indexed_getter_with_no_row_pushes_the_references_full_tuple() {
|
||||
let s = script();
|
||||
s.run(SHOW).unwrap();
|
||||
// A negative index truncates, decrements and compares unsigned (`0x4f6ed8`), so it falls out of
|
||||
// range and answers the same miss tuple, as does a fractional one; no getter raises (#1016).
|
||||
// `GetShapeshiftFormInfo` raises for a zero alone (`0x4b45d8`).
|
||||
let calls: Vec<(String, &str)> = MISSES
|
||||
.iter()
|
||||
.flat_map(|(call, want)| {
|
||||
["(-1", "(-7.5", "(1e10"]
|
||||
.map(|first| (call.replacen("(1", first, 1), *want))
|
||||
.into_iter()
|
||||
.chain([(call.replacen(", 1)", ", -1)", 1), *want)])
|
||||
.chain([(call.to_string(), *want)])
|
||||
})
|
||||
.collect();
|
||||
let wrong: Vec<String> = calls
|
||||
.iter()
|
||||
.filter_map(|(call, want)| {
|
||||
let got = s
|
||||
.eval::<String>(&format!("return PGShow({call})"))
|
||||
.unwrap_or_else(|e| format!("raises: {e}"));
|
||||
(got != *want).then(|| format!("{call}: pushes ({got}), the reference ({want})"))
|
||||
})
|
||||
.collect();
|
||||
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
|
||||
// Zero, an absent index and a non-number raise the usage error where the reference does
|
||||
// (`0x4b45d8`, `0x4b49b4`); a negative or a fractional one falls out of range quietly.
|
||||
for name in ["GetShapeshiftFormInfo", "GetShapeshiftFormCooldown"] {
|
||||
for arg in ["0", "", "nil", "'x'"] {
|
||||
assert!(
|
||||
s.eval::<bool>(&format!(
|
||||
"local ok, err = pcall({name}{}{arg}) \
|
||||
return not ok and string.find(tostring(err), 'Usage: {name}(index)', 1, true) ~= nil",
|
||||
if arg.is_empty() { "" } else { ", " }
|
||||
))
|
||||
.unwrap(),
|
||||
"{name}({arg}) raises its usage"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval::<i64>(&format!(
|
||||
"return (function(...) return arg.n end)({name}(-3))"
|
||||
))
|
||||
.unwrap(),
|
||||
if name.ends_with("Info") { 4 } else { 3 },
|
||||
"{name}(-3) is a miss"
|
||||
);
|
||||
// 0.5 is not zero (`fcomp` against 0.0), so it reaches the table and truncates to a miss.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(&format!(
|
||||
"return (function(...) return arg.n end)({name}(0.5))"
|
||||
))
|
||||
.unwrap(),
|
||||
if name.ends_with("Info") { 4 } else { 3 },
|
||||
);
|
||||
}
|
||||
// A non-number raises the binding's own usage text (`0x84d860`, `0x84d88c`, `0x84d8b8`).
|
||||
for name in [
|
||||
"GetContainerItemInfo",
|
||||
"GetContainerItemLink",
|
||||
"GetContainerItemCooldown",
|
||||
] {
|
||||
for call in [
|
||||
format!("{name}('x', 1)"),
|
||||
format!("{name}(0)"),
|
||||
format!("{name}(0, nil)"),
|
||||
] {
|
||||
assert!(
|
||||
s.eval::<bool>(&format!(
|
||||
"local ok, err = pcall(function() return {call} end) \
|
||||
return not ok and string.find(tostring(err), 'Usage: {name}(index, slot)', 1, true) ~= nil"
|
||||
))
|
||||
.unwrap(),
|
||||
"{call} raises its usage"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ mod layout_structure;
|
||||
mod line_spacing;
|
||||
mod measure;
|
||||
mod minimap;
|
||||
mod miss_tuples;
|
||||
mod model_clock;
|
||||
mod modelframe;
|
||||
mod movable;
|
||||
@@ -73,6 +74,7 @@ mod texcoord_font;
|
||||
mod texture_art;
|
||||
mod tooltip;
|
||||
mod tooltip_item;
|
||||
mod tooltip_setter_flags;
|
||||
mod tooltip_spell;
|
||||
mod tooltip_unit;
|
||||
mod toplevel;
|
||||
|
||||
@@ -72,6 +72,7 @@ fn merchant_compare_renders_the_compare_shape_and_no_hover_seats_a_plate() {
|
||||
texture: None,
|
||||
count: 1,
|
||||
quality: Some(2),
|
||||
inventory_type: 0,
|
||||
item_id: 7002,
|
||||
link: Some("|cff1eff00|Hitem:7002:0:0:0|h[New Loop]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -759,6 +760,131 @@ fn item_bindings_return_the_pushed_repair_cost() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The LOCKED line is drawn for an item object lacking UNLOCKED `0x4` (`0x52bdb3`..`0x52bdc4`), or
|
||||
/// for a block with a lock id of its own (`0x52bdd2`), never for a template source; every lock
|
||||
/// path sets the builder's return value (`0x52bfa6`), so the bag setter answers 1 for a locked
|
||||
/// item with no cooldown and no expiry.
|
||||
#[test]
|
||||
fn locked_is_drawn_for_a_locked_object_and_sets_the_builder_flag() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
let slot = |flags| ContainerSlot {
|
||||
item_id: 8300,
|
||||
count: 1,
|
||||
quality: Some(1),
|
||||
link: Some("|cffffffff|Hitem:8300:0:0:0|h[Strongbox]|h|r".into()),
|
||||
flags,
|
||||
..Default::default()
|
||||
};
|
||||
s.set_container(
|
||||
0,
|
||||
Some(ContainerState {
|
||||
name: Some("Backpack".into()),
|
||||
num_slots: 24,
|
||||
slots: HashMap::from([(1, slot(0)), (2, slot(0x4))]),
|
||||
}),
|
||||
);
|
||||
s.set_item_template(
|
||||
8300,
|
||||
ItemTemplateView {
|
||||
name: "Strongbox".into(),
|
||||
quality: 1,
|
||||
lock_id: 7,
|
||||
..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")
|
||||
local function locked()
|
||||
for i = 1, tt:NumLines() do
|
||||
if getglobal("TTTextLeft" .. i):GetText() == "[LOCKED]" then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
local cd = tt:SetBagItem(0, 1)
|
||||
assert(locked() and cd == 1, "a locked object draws LOCKED and sets the flag")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
cd = tt:SetBagItem(0, 2)
|
||||
assert(not locked() and cd == nil, "UNLOCKED clears both: " .. tostring(cd))
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
SetItemByIdForTest(tt, 8300)
|
||||
assert(not locked(), "a template source has no object and draws none")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.take_errors().is_empty(), "{:?}", s.take_errors());
|
||||
}
|
||||
|
||||
/// A bank vault item seen through `SetInventoryItem` keeps its flags and expiry: a picked lockbox
|
||||
/// (UNLOCKED) shows no LOCKED, a locked one does, and a timed item keeps its duration line and
|
||||
/// the builder flag.
|
||||
#[test]
|
||||
fn bank_vault_items_keep_their_flags_and_expiry() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run("ITEM_DURATION_HOURS = '[DURATION %d h]' LOCKED = '[LOCKED]'")
|
||||
.unwrap();
|
||||
let slot = |flags, duration_ms| ContainerSlot {
|
||||
item_id: 8300,
|
||||
count: 1,
|
||||
quality: Some(1),
|
||||
flags,
|
||||
duration_ms,
|
||||
..Default::default()
|
||||
};
|
||||
s.set_container(
|
||||
-1,
|
||||
Some(ContainerState {
|
||||
name: Some("Bank".into()),
|
||||
num_slots: 24,
|
||||
slots: HashMap::from([
|
||||
(1, slot(0, None)),
|
||||
(2, slot(0x4, None)),
|
||||
(3, slot(0x4, Some(3_600_000))),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
s.set_item_template(
|
||||
8300,
|
||||
ItemTemplateView {
|
||||
name: "Strongbox".into(),
|
||||
quality: 1,
|
||||
lock_id: 7,
|
||||
..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")
|
||||
local function text(needle)
|
||||
for i = 1, tt:NumLines() do
|
||||
if getglobal("TTTextLeft" .. i):GetText() == needle then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function look(id)
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
local _, cd = tt:SetInventoryItem("player", id)
|
||||
return cd
|
||||
end
|
||||
local cd = look(40)
|
||||
assert(text("[LOCKED]") and cd == 1, "locked vault item")
|
||||
cd = look(41)
|
||||
assert(not text("[LOCKED]") and cd == nil, "picked: " .. tostring(cd))
|
||||
cd = look(42)
|
||||
assert(not text("[LOCKED]") and text("[DURATION 1 h]") and cd == 1, "timed: " .. tostring(cd))
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.take_errors().is_empty(), "{:?}", s.take_errors());
|
||||
}
|
||||
|
||||
/// `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.
|
||||
|
||||
@@ -111,7 +111,7 @@ fn red_lines_track_player_state() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The gated families (`0x52b650`): SIGNABLE green, UNIQUE before STARTS_QUEST, LOCKED red, six
|
||||
/// The gated families (`0x52b650`): SIGNABLE green, UNIQUE before STARTS_QUEST, six
|
||||
/// equal resistances as one ALL line (Holy never prints alone), a known taught spell's red
|
||||
/// "Already known", the gold description, and no openable line: the openable, readable and creator
|
||||
/// tail needs an item object (`0x52e1c7`, `0x52e2e0`), and a template has none.
|
||||
@@ -159,7 +159,7 @@ fn verified_families_signable_locked_resists_known() {
|
||||
"[ITEM_SIGNABLE]",
|
||||
"[ITEM_UNIQUE]",
|
||||
"[ITEM_STARTS_QUEST]",
|
||||
"[LOCKED]",
|
||||
// No LOCKED: a template source has no item object and no block (`0x52bdb3`..`0x52bdd8`).
|
||||
"[RESIST_ALL +5]",
|
||||
"[ITEM_SPELL_KNOWN]",
|
||||
"\"Sign here.\"",
|
||||
@@ -168,7 +168,6 @@ fn verified_families_signable_locked_resists_known() {
|
||||
);
|
||||
let color = |needle: &str| lines.iter().find(|(t, _)| t == needle).unwrap().1;
|
||||
let red = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0];
|
||||
assert_eq!(color("[LOCKED]"), red, "LOCKED is red");
|
||||
assert_eq!(
|
||||
color("[ITEM_SPELL_KNOWN]"),
|
||||
red,
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
//! The `Set*` bindings that answer the tooltip builder's return value as 1 or nil: `SetAction`,
|
||||
//! `SetSpell`, `SetShapeshift`, `SetPetAction` (`0x5325a9`, `0x532e9b`, `0x5328ae`) and the mail
|
||||
//! and auction item setters (`0x5356c7`, `0x53549c`). The stock buttons arm `updateTooltip` from
|
||||
//! it (`ActionButton.lua:377-381`, `SpellBookFrame.lua:239-243`). Each answer is read as the
|
||||
//! count, type and value of everything the call returned.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
|
||||
/// `count:type:value` of the first return, with `arg.n` counting them.
|
||||
const SHOW: &str =
|
||||
"function PGShow(...) return arg.n .. ':' .. type(arg[1]) .. ':' .. tostring(arg[1]) end";
|
||||
|
||||
const RUNNING_30_MIN: Option<(i64, u32, bool)> = Some((9_400, 1_800_000, true));
|
||||
|
||||
fn view(recovery_ms: u32, chance: Option<&str>) -> SpellTooltipView {
|
||||
SpellTooltipView {
|
||||
name: "Shield Wall".into(),
|
||||
cast_time: Some("Instant".into()),
|
||||
recovery_ms,
|
||||
chance: chance.map(str::to_string),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn show(s: &UiScript, call: &str) -> String {
|
||||
s.eval::<String>(&format!(
|
||||
"TT:SetOwner(FL, 'ANCHOR_RIGHT') return PGShow({call})"
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn fixture() -> UiScript {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(SHOW).unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
CreateFrame("Button", "FL"):SetPoint("CENTER", 0, 0)
|
||||
FL:SetWidth(10) FL:SetHeight(10)
|
||||
CreateFrame("GameTooltip", "TT")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A nonzero GetTime epoch, so the cooldowns above are running at 10 s.
|
||||
s.tick(10.0);
|
||||
s
|
||||
}
|
||||
|
||||
fn slot(spell_id: u32, cooldown: Option<(i64, u32, bool)>) -> SpellSlotView {
|
||||
SpellSlotView {
|
||||
spell_id,
|
||||
name: "Shield Wall".into(),
|
||||
cooldown,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A spell's tooltip holds a changing line when its recovery is a minute or more and the cooldown
|
||||
/// runs (the remaining line, `0x52f581`), or when it carries the chance line (`0x52f68c`); the
|
||||
/// book setter answers that as 1, else nil, always one value.
|
||||
#[test]
|
||||
fn set_spell_answers_one_while_the_tooltip_holds_a_changing_line() {
|
||||
let mut s = fixture();
|
||||
s.set_spell_tooltip(871, view(1_800_000, None));
|
||||
s.set_spell_tooltip(872, view(30_000, None));
|
||||
s.set_spell_tooltip(873, view(0, Some("5.00% chance to parry")));
|
||||
s.set_spellbook(SpellBookState {
|
||||
tabs: Vec::new(),
|
||||
slots: vec![
|
||||
slot(871, RUNNING_30_MIN),
|
||||
// No cooldown, a shorter recovery, a passive's chance line, a cooldown on hold
|
||||
// (enable 0), and one that elapsed at 3 s.
|
||||
slot(871, None),
|
||||
slot(872, Some((9_400, 30_000, true))),
|
||||
slot(873, None),
|
||||
slot(871, Some((9_400, 1_800_000, false))),
|
||||
slot(871, Some((1_000, 2_000, true))),
|
||||
],
|
||||
});
|
||||
for (index, want) in [
|
||||
(1, "1:number:1"),
|
||||
(2, "1:nil:nil"),
|
||||
(3, "1:nil:nil"),
|
||||
(4, "1:number:1"),
|
||||
(5, "1:nil:nil"),
|
||||
(6, "1:nil:nil"),
|
||||
// Past the book, and a non-string book type, miss with one nil.
|
||||
(99, "1:nil:nil"),
|
||||
] {
|
||||
assert_eq!(
|
||||
show(&s, &format!("TT:SetSpell({index}, 'spell')")),
|
||||
want,
|
||||
"slot {index}"
|
||||
);
|
||||
}
|
||||
// A number book type is stringified, the player's book (`0x6f3510`); anything else raises.
|
||||
assert_eq!(show(&s, "TT:SetSpell(1, 5)"), "1:number:1");
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local ok, err = pcall(TT.SetSpell, TT, 1, nil) \
|
||||
return not ok and string.find(tostring(err), 'Invalid spell slot in SetSpell', 1, true) ~= nil"
|
||||
)
|
||||
.unwrap());
|
||||
assert!(s.take_errors().is_empty(), "{:?}", s.take_errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_shapeshift_and_set_pet_action_answer_the_same_flag() {
|
||||
let mut s = fixture();
|
||||
s.set_spell_tooltip(871, view(1_800_000, None));
|
||||
s.set_pet_spell_tooltip(871, view(1_800_000, None));
|
||||
// A chance line is a changing line too, but these setters read the builder's value only with
|
||||
// a cooldown running (`0x532a89`, `0x5328aa`).
|
||||
s.set_spell_tooltip(873, view(0, Some("5.00% chance to parry")));
|
||||
s.set_pet_spell_tooltip(873, view(0, Some("5.00% chance to parry")));
|
||||
s.set_shapeshift_forms(vec![
|
||||
ShapeshiftFormView {
|
||||
spell_id: 871,
|
||||
name: "Battle Stance".into(),
|
||||
cooldown: RUNNING_30_MIN,
|
||||
..Default::default()
|
||||
},
|
||||
ShapeshiftFormView {
|
||||
spell_id: 871,
|
||||
name: "Defensive Stance".into(),
|
||||
..Default::default()
|
||||
},
|
||||
ShapeshiftFormView {
|
||||
spell_id: 873,
|
||||
name: "Parry".into(),
|
||||
..Default::default()
|
||||
},
|
||||
ShapeshiftFormView {
|
||||
spell_id: 873,
|
||||
name: "Parry".into(),
|
||||
cooldown: RUNNING_30_MIN,
|
||||
..Default::default()
|
||||
},
|
||||
]);
|
||||
let pet = |cooldown| PetActionView {
|
||||
name: Some("Shield Wall".into()),
|
||||
spell_id: Some(871),
|
||||
cooldown,
|
||||
..Default::default()
|
||||
};
|
||||
let chance_pet = |cooldown| PetActionView {
|
||||
spell_id: Some(873),
|
||||
..pet(cooldown)
|
||||
};
|
||||
s.set_pet_actions(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
vec![
|
||||
pet(RUNNING_30_MIN),
|
||||
pet(None),
|
||||
chance_pet(None),
|
||||
chance_pet(RUNNING_30_MIN),
|
||||
],
|
||||
);
|
||||
for (call, want) in [
|
||||
("TT:SetShapeshift(1)", "1:number:1"),
|
||||
("TT:SetShapeshift(2)", "1:nil:nil"),
|
||||
("TT:SetShapeshift(3)", "1:nil:nil"),
|
||||
("TT:SetShapeshift(4)", "1:number:1"),
|
||||
("TT:SetShapeshift(9)", "1:nil:nil"),
|
||||
("TT:SetPetAction(1)", "1:number:1"),
|
||||
("TT:SetPetAction(2)", "1:nil:nil"),
|
||||
("TT:SetPetAction(3)", "1:nil:nil"),
|
||||
("TT:SetPetAction(4)", "1:number:1"),
|
||||
("TT:SetPetAction(9)", "1:nil:nil"),
|
||||
] {
|
||||
assert_eq!(show(&s, call), want, "{call}");
|
||||
}
|
||||
assert!(s.take_errors().is_empty(), "{:?}", s.take_errors());
|
||||
}
|
||||
|
||||
/// `SetAction` answers the spell builder's flag for a spell slot, the item builder's for an item
|
||||
/// (a running cooldown reaches its remaining line, `0x52e140`), and nil for a macro, an empty slot
|
||||
/// and a kind it does not build.
|
||||
#[test]
|
||||
fn set_action_answers_the_builders_flag_by_kind() {
|
||||
let mut s = fixture();
|
||||
s.set_spell_tooltip(871, view(1_800_000, None));
|
||||
s.set_item_template(
|
||||
6948,
|
||||
ItemTemplateView {
|
||||
name: "Hearthstone".into(),
|
||||
quality: 1,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.set_macros(MacroState {
|
||||
account: vec![MacroView {
|
||||
name: "spawn".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
character: Vec::new(),
|
||||
});
|
||||
let put = |s: &mut UiScript, id: u32, kind: u8, action: u32, cooldown| {
|
||||
s.set_action(
|
||||
id,
|
||||
Some(ActionSlot {
|
||||
kind,
|
||||
action,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.set_action_state(
|
||||
id,
|
||||
Some(ActionState {
|
||||
usable: true,
|
||||
cooldown,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
};
|
||||
put(&mut s, 1, 0x00, 871, RUNNING_30_MIN);
|
||||
put(&mut s, 2, 0x00, 871, None);
|
||||
// The player owns one Hearthstone (the action word resolves by entry, `0x532490`).
|
||||
s.set_container(
|
||||
0,
|
||||
Some(ContainerState {
|
||||
name: Some("Backpack".into()),
|
||||
num_slots: 16,
|
||||
slots: std::collections::HashMap::from([(
|
||||
1,
|
||||
ContainerSlot {
|
||||
item_id: 6948,
|
||||
count: 1,
|
||||
..Default::default()
|
||||
},
|
||||
)]),
|
||||
}),
|
||||
);
|
||||
put(&mut s, 3, 0x80, 6948, RUNNING_30_MIN);
|
||||
put(&mut s, 4, 0x80, 6948, None);
|
||||
put(&mut s, 5, 0x40, 1, RUNNING_30_MIN);
|
||||
put(&mut s, 6, 0xC0, 1, RUNNING_30_MIN);
|
||||
// Attack, Parry, Block and Dodge carry the chance line: nil without a running cooldown.
|
||||
s.set_spell_tooltip(873, view(0, Some("5.00% chance to parry")));
|
||||
put(&mut s, 8, 0x00, 873, None);
|
||||
put(&mut s, 9, 0x00, 873, RUNNING_30_MIN);
|
||||
for (id, want) in [
|
||||
(1, "1:number:1"),
|
||||
(2, "1:nil:nil"),
|
||||
(3, "1:number:1"),
|
||||
(4, "1:nil:nil"),
|
||||
(5, "1:nil:nil"),
|
||||
(6, "1:nil:nil"),
|
||||
(7, "1:nil:nil"),
|
||||
(8, "1:nil:nil"),
|
||||
(9, "1:number:1"),
|
||||
] {
|
||||
assert_eq!(
|
||||
show(&s, &format!("TT:SetAction({id})")),
|
||||
want,
|
||||
"action {id}"
|
||||
);
|
||||
}
|
||||
assert!(s.take_errors().is_empty(), "{:?}", s.take_errors());
|
||||
}
|
||||
|
||||
/// The four mail and auction item setters answer one value: nil with no item, nil for a hover that
|
||||
/// writes no changing line. Their builder reaches the changing lines (the item's expiry and a
|
||||
/// temporary enchant's countdown) only through an item object or an instance block carrying
|
||||
/// them, which these rows do not.
|
||||
#[test]
|
||||
fn the_mail_and_auction_item_setters_answer_one_value() {
|
||||
let mut s = fixture();
|
||||
s.set_item_template(
|
||||
2589,
|
||||
ItemTemplateView {
|
||||
name: "Linen Cloth".into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let call = |s: &UiScript, c: &str| show(s, c);
|
||||
// Nothing attached or listed: nil, one value.
|
||||
for c in [
|
||||
"TT:SetInboxItem(1)",
|
||||
"TT:SetAuctionItem('list', 1)",
|
||||
"TT:SetAuctionSellItem()",
|
||||
"TT:SetSendMailItem()",
|
||||
] {
|
||||
assert_eq!(call(&s, c), "1:nil:nil", "{c} empty");
|
||||
}
|
||||
s.set_mail(Some(MailState {
|
||||
inbox: vec![MailInboxRow {
|
||||
item_id: 2589,
|
||||
item_name: Some("Linen Cloth".into()),
|
||||
..Default::default()
|
||||
}],
|
||||
}));
|
||||
let mut auction = AuctionState::default();
|
||||
auction.lists[0].rows.push(AuctionItemRow {
|
||||
item_id: 2589,
|
||||
name: Some("Linen Cloth".into()),
|
||||
..Default::default()
|
||||
});
|
||||
s.set_auction(Some(auction));
|
||||
{
|
||||
let mut model = s.model_mut();
|
||||
let item = CursorItem {
|
||||
bag: 0,
|
||||
slot: 1,
|
||||
item_id: 2589,
|
||||
texture: None,
|
||||
link: None,
|
||||
count: None,
|
||||
quality: Some(1),
|
||||
equip_slots: Vec::new(),
|
||||
bar_placeable: false,
|
||||
};
|
||||
model.mail_send_item = Some(item.clone());
|
||||
model.auction_sell_item = Some(item);
|
||||
}
|
||||
for c in [
|
||||
"TT:SetInboxItem(1)",
|
||||
"TT:SetAuctionItem('list', 1)",
|
||||
"TT:SetAuctionSellItem()",
|
||||
"TT:SetSendMailItem()",
|
||||
] {
|
||||
assert_eq!(call(&s, c), "1:nil:nil", "{c} filled");
|
||||
assert!(s.eval::<bool>("return TT:IsShown()").unwrap(), "{c} drew");
|
||||
}
|
||||
assert!(s.take_errors().is_empty(), "{:?}", s.take_errors());
|
||||
}
|
||||
|
||||
/// The sell and send-mail slots hold a live item object, so its expiry, temporary enchants and
|
||||
/// LOCKED state reach the flag; the trade target's block names a lock of its own.
|
||||
#[test]
|
||||
fn live_objects_and_blocks_reach_the_flag() {
|
||||
let mut s = fixture();
|
||||
s.run(r#"ITEM_DURATION_HOURS = "[DURATION %d h]" LOCKED = "[LOCKED]""#)
|
||||
.unwrap();
|
||||
s.set_item_template(
|
||||
8300,
|
||||
ItemTemplateView {
|
||||
name: "Strongbox".into(),
|
||||
lock_id: 7,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.set_item_template(
|
||||
8301,
|
||||
ItemTemplateView {
|
||||
name: "Plain".into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let held = |item_id, flags, duration_ms| ContainerSlot {
|
||||
item_id,
|
||||
count: 1,
|
||||
flags,
|
||||
duration_ms,
|
||||
..Default::default()
|
||||
};
|
||||
s.set_container(
|
||||
0,
|
||||
Some(ContainerState {
|
||||
name: Some("Backpack".into()),
|
||||
num_slots: 16,
|
||||
slots: std::collections::HashMap::from([
|
||||
(1, held(8300, 0, None)),
|
||||
(2, held(8300, 0x4, None)),
|
||||
(3, held(8301, 0, Some(3_600_000))),
|
||||
(4, held(8301, 0, None)),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
let cursor = |slot| CursorItem {
|
||||
bag: 0,
|
||||
slot,
|
||||
item_id: if slot <= 2 { 8300 } else { 8301 },
|
||||
texture: None,
|
||||
link: None,
|
||||
count: None,
|
||||
quality: Some(1),
|
||||
equip_slots: Vec::new(),
|
||||
bar_placeable: false,
|
||||
};
|
||||
for (slot, want) in [
|
||||
(1, "1:number:1"),
|
||||
(2, "1:nil:nil"),
|
||||
(3, "1:number:1"),
|
||||
(4, "1:nil:nil"),
|
||||
] {
|
||||
{
|
||||
let mut model = s.model_mut();
|
||||
model.mail_send_item = Some(cursor(slot));
|
||||
model.auction_sell_item = Some(cursor(slot));
|
||||
}
|
||||
assert_eq!(show(&s, "TT:SetSendMailItem()"), want, "mail slot {slot}");
|
||||
assert_eq!(
|
||||
show(&s, "TT:SetAuctionSellItem()"),
|
||||
want,
|
||||
"sell slot {slot}"
|
||||
);
|
||||
}
|
||||
|
||||
// The trade target's block carries the partner's lock id: LOCKED with no object, flag set.
|
||||
// The action bar's item word resolves to the player's own item by entry (`0x532490`): the
|
||||
// locked strongbox in the bag sets the flag with no cooldown, a plain one does not.
|
||||
for (id, item) in [(1, 8300), (2, 8301)] {
|
||||
s.set_action(
|
||||
id,
|
||||
Some(ActionSlot {
|
||||
kind: 0x80,
|
||||
action: item,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
}
|
||||
assert_eq!(show(&s, "TT:SetAction(1)"), "1:number:1");
|
||||
assert_eq!(show(&s, "TT:SetAction(2)"), "1:number:1", "slot 3 expires");
|
||||
{
|
||||
let mut model = s.model_mut();
|
||||
model.containers.get_mut(&0).unwrap().slots.remove(&3);
|
||||
}
|
||||
assert_eq!(show(&s, "TT:SetAction(2)"), "1:nil:nil");
|
||||
|
||||
let mut st = TradeState::default();
|
||||
for (i, lock_id) in [(0, 9), (1, 0)] {
|
||||
st.target.slots[i] = Some(TradeSlotItem {
|
||||
item_id: 8301,
|
||||
name: Some("Plain".into()),
|
||||
count: 1,
|
||||
lock_id,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
// The player's slots are live item objects: the flags decide LOCKED (`0x5342ed`), and the
|
||||
// block's lock id is zeroed (`0x534341`).
|
||||
for (i, flags) in [(0, Some(0)), (1, Some(0x4)), (2, None)] {
|
||||
st.player.slots[i] = Some(TradeSlotItem {
|
||||
item_id: 8300,
|
||||
name: Some("Strongbox".into()),
|
||||
count: 1,
|
||||
object_flags: flags,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
s.set_trade(Some(st));
|
||||
let locked = |s: &UiScript, call: &str| {
|
||||
s.eval::<bool>(&format!(
|
||||
"TT:SetOwner(FL, 'ANCHOR_RIGHT') {call} \
|
||||
for i = 1, TT:NumLines() do \
|
||||
if getglobal('TTTextLeft' .. i):GetText() == '[LOCKED]' then return true end \
|
||||
end return false"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
assert!(locked(&s, "TT:SetTradeTargetItem(1)"));
|
||||
assert!(!locked(&s, "TT:SetTradeTargetItem(2)"));
|
||||
assert!(
|
||||
locked(&s, "TT:SetTradePlayerItem(1)"),
|
||||
"a locked object in the player's slot"
|
||||
);
|
||||
assert!(!locked(&s, "TT:SetTradePlayerItem(2)"), "UNLOCKED");
|
||||
assert!(
|
||||
!locked(&s, "TT:SetTradePlayerItem(3)"),
|
||||
"no object, no block lock"
|
||||
);
|
||||
}
|
||||
|
||||
/// The action bar's item word finds the player's item as `0x622420` walks with mask `0x47`: the
|
||||
/// worn items, then each equipped bag and its contents, then the backpack, then the keyring; the
|
||||
/// bank is out of the mask; a broken item or one with flag `0x10` is skipped; a miss pushes nil
|
||||
/// whatever the builder returned (`0x5325bc`). A timed copy answers 1, a plain one nil, so the
|
||||
/// answer says which copy was found.
|
||||
#[test]
|
||||
fn the_action_bar_item_word_walks_the_references_order() {
|
||||
let mut s = fixture();
|
||||
s.run("ITEM_DURATION_HOURS = '[DURATION %d h]'").unwrap();
|
||||
s.set_item_template(
|
||||
9000,
|
||||
ItemTemplateView {
|
||||
name: "Charm".into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let timed = |flags, durability| ContainerSlot {
|
||||
item_id: 9000,
|
||||
count: 1,
|
||||
flags,
|
||||
durability,
|
||||
duration_ms: Some(3_600_000),
|
||||
..Default::default()
|
||||
};
|
||||
let plain = ContainerSlot {
|
||||
item_id: 9000,
|
||||
count: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let bag = |slots: Vec<(u32, ContainerSlot)>| {
|
||||
Some(ContainerState {
|
||||
name: None,
|
||||
num_slots: 16,
|
||||
slots: slots.into_iter().collect(),
|
||||
})
|
||||
};
|
||||
let ask = |s: &mut UiScript| {
|
||||
s.set_action(
|
||||
1,
|
||||
Some(ActionSlot {
|
||||
kind: 0x80,
|
||||
action: 9000,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
show(s, "TT:SetAction(1)")
|
||||
};
|
||||
// Not carried at all.
|
||||
assert_eq!(ask(&mut s), "1:nil:nil");
|
||||
// Only in the bank (container -1): outside the mask, so a miss.
|
||||
s.set_container(-1, bag(vec![(1, timed(0, None))]));
|
||||
assert_eq!(ask(&mut s), "1:nil:nil", "the bank is not walked");
|
||||
// A broken timed copy and a flag-0x10 timed copy in the backpack are skipped; the plain
|
||||
// copy behind them is found, and says nil.
|
||||
s.set_container(
|
||||
0,
|
||||
bag(vec![
|
||||
(1, timed(0, Some((0, 100)))),
|
||||
(2, timed(0x10, None)),
|
||||
(3, plain.clone()),
|
||||
]),
|
||||
);
|
||||
assert_eq!(
|
||||
ask(&mut s),
|
||||
"1:nil:nil",
|
||||
"skipped copies, then the plain one"
|
||||
);
|
||||
// A wrapped broken copy is not skipped (`0x6224f7`).
|
||||
s.set_container(0, bag(vec![(1, timed(0x8, Some((0, 100))))]));
|
||||
assert_eq!(ask(&mut s), "1:number:1");
|
||||
// An equipped bag's contents come before the backpack's.
|
||||
s.set_container(0, bag(vec![(1, plain.clone())]));
|
||||
s.set_container(1, bag(vec![(1, timed(0, None))]));
|
||||
assert_eq!(ask(&mut s), "1:number:1", "bag 1 before the backpack");
|
||||
// The worn item comes before the bags.
|
||||
let mut inv: InventorySlots = Default::default();
|
||||
inv[5] = Some(InvSlotView {
|
||||
item_id: 9000,
|
||||
..Default::default()
|
||||
});
|
||||
s.set_inventory_slots(inv);
|
||||
assert_eq!(
|
||||
ask(&mut s),
|
||||
"1:nil:nil",
|
||||
"the worn copy is plain and found first"
|
||||
);
|
||||
// The keyring comes last.
|
||||
let mut inv: InventorySlots = Default::default();
|
||||
inv[5] = None;
|
||||
s.set_inventory_slots(inv);
|
||||
s.set_container(1, None);
|
||||
s.set_container(0, None);
|
||||
s.set_container(-2, bag(vec![(1, timed(0, None))]));
|
||||
assert_eq!(ask(&mut s), "1:number:1", "the keyring is walked");
|
||||
}
|
||||
@@ -109,10 +109,29 @@ fn a_pet_book_hover_reads_the_pets_book_not_the_players() {
|
||||
assert(TTTextLeft1:GetText() == "Firebolt", "case-insensitive pet")
|
||||
tt:SetSpell(1, "book")
|
||||
assert(TTTextLeft1:GetText() == "Fireball", "any other string is the player's book")
|
||||
-- A non-string third argument is not "the player's book": the reference bails outright
|
||||
-- (0x532dc0's isstring test), leaving whatever was shown.
|
||||
-- A number is stringified (0x6f3510 accepts it) and so is the player's book.
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetSpell(1, 7)
|
||||
assert(TTTextLeft1:GetText() == "Fireball", "a non-string arg shows nothing new")
|
||||
assert(TTTextLeft1:GetText() == "Fireball", "a number book type is the player's book")
|
||||
-- A nil, table or boolean book type, a non-number index, and an index outside 1..1024
|
||||
-- (trunc(n - 1) outside [0, 0x400)) raise "Invalid spell slot in SetSpell" (0x855290).
|
||||
local function raises(...)
|
||||
local ok, err = pcall(tt.SetSpell, tt, unpack(arg))
|
||||
return not ok and string.find(tostring(err), "Invalid spell slot in SetSpell", 1, true)
|
||||
end
|
||||
assert(raises(1, nil), "nil book type")
|
||||
assert(raises(1, {}), "table book type")
|
||||
assert(raises(1, true), "boolean book type")
|
||||
assert(raises("x", "spell"), "non-number index")
|
||||
assert(raises(0, "spell"), "index 0")
|
||||
assert(raises(1025, "spell"), "index 1025")
|
||||
assert(raises(-3, "spell"), "negative index")
|
||||
assert(not raises(1024, "spell"), "index 1024 is in range and misses quietly")
|
||||
-- `_ftol` wraps to the low dword (0x532dd9): 4294967297 - 1 is 2^32, which is slot 1.
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetSpell(4294967297, "spell")
|
||||
assert(TTTextLeft1:GetText() == "Fireball", "a wrapped index reads slot 1")
|
||||
assert(raises(4294967297 + 1024, "spell"), "2^32 + 1024 wraps to slot 1025, past the table")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -18,7 +18,7 @@ use super::tooltip::{append_line, clear, clear_silent, show_or_hide_empty};
|
||||
use super::{ItemTemplateView, Model};
|
||||
|
||||
mod names;
|
||||
mod render;
|
||||
pub(in crate::script) mod render;
|
||||
|
||||
use names::{quality_color, GRAY, WHITE};
|
||||
use render::{render_view, BuilderFlags};
|
||||
@@ -141,6 +141,96 @@ fn hyperlink_item_fields(link: &str) -> Option<(u32, u32, u32)> {
|
||||
(item_id != 0).then_some((item_id, enchant_id, random_property_id))
|
||||
}
|
||||
|
||||
/// A bag slot's item as the builder's live item object: the name off the link (the reference
|
||||
/// builds both from `0x5d8b00`), its fields and the open gate's p6.
|
||||
fn slot_instance(s: &crate::script::ContainerSlot, openable_source: bool) -> render::ItemInstance {
|
||||
render::ItemInstance {
|
||||
name: s.link.as_deref().and_then(link_name).map(str::to_string),
|
||||
durability: s.durability,
|
||||
creator: s.creator.clone(),
|
||||
has_text: s.readable,
|
||||
flags: s.flags,
|
||||
already_bound: s.already_bound,
|
||||
petition: s.petition.clone(),
|
||||
enchants: s.enchants.clone(),
|
||||
openable_source,
|
||||
duration_ms: s.duration_ms,
|
||||
object: true,
|
||||
block_lock_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's own item of entry `item_id` as the builder's live item object, found as the
|
||||
/// action bar's item word finds it (`0x532490` → `0x622270` → the walk `0x622420`, mask `0x47`
|
||||
/// because `0x5dd44d` sets `[player+0x1d48]`): the worn items (positions 0..18), then each
|
||||
/// equipped bag (19..22) followed by its contents, then the backpack (23..38), then the keyring
|
||||
/// (81..112). The bank is out of the mask. A broken item (durability 0 with a maximum, unless
|
||||
/// wrapped, `0x6224f7`..`0x62250e`) or one carrying flag `0x10` (`0x622510`) is skipped. The
|
||||
/// reference also skips an object whose `[+0xe4]` bit 0 is set (`0x614ea0`); benilla holds no
|
||||
/// such fact. `None` when nothing matches, where the setter pushes nil (`0x5325bc`).
|
||||
pub(super) fn owned_instance(model: &Model, item_id: u32) -> Option<render::ItemInstance> {
|
||||
const BROKEN: u32 = 0x10;
|
||||
const WRAPPED: u32 = 0x8;
|
||||
let usable = |flags: u32, durability: Option<(u32, u32)>| {
|
||||
let broken =
|
||||
flags & WRAPPED == 0 && durability.is_some_and(|(cur, max)| max > 0 && cur == 0);
|
||||
!broken && flags & BROKEN == 0
|
||||
};
|
||||
let worn = |s: &crate::script::InvSlotView| {
|
||||
(s.item_id == item_id && usable(s.flags, s.durability)).then(|| render::ItemInstance {
|
||||
name: s.name.clone(),
|
||||
durability: s.durability,
|
||||
creator: s.creator.clone(),
|
||||
flags: s.flags,
|
||||
already_bound: s.already_bound,
|
||||
enchants: s.enchants.clone(),
|
||||
duration_ms: s.duration_ms,
|
||||
object: true,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let held = |bag: i64| -> Option<render::ItemInstance> {
|
||||
let c = model.containers.get(&bag)?;
|
||||
let mut slots: Vec<_> = c.slots.iter().collect();
|
||||
slots.sort_by_key(|(n, _)| **n);
|
||||
slots
|
||||
.into_iter()
|
||||
.map(|(_, s)| s)
|
||||
.find(|s| s.item_id == item_id && usable(s.flags, s.durability))
|
||||
.map(|s| slot_instance(s, false))
|
||||
};
|
||||
// Equipment, then each equipped bag item and straight after it that bag's contents.
|
||||
for s in model.inventory_slots.iter().take(20).skip(1).flatten() {
|
||||
if let Some(i) = worn(s) {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
for bag in 1..=4_i64 {
|
||||
if let Some(i) = model
|
||||
.inventory_slots
|
||||
.get(19 + bag as usize)
|
||||
.and_then(Option::as_ref)
|
||||
.and_then(worn)
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
if let Some(i) = held(bag) {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
held(0).or_else(|| held(super::char_stats::KEYRING_CONTAINER))
|
||||
}
|
||||
|
||||
/// The item at `(bag, slot)` in the cursor's numbering, as a live object with no block, for the
|
||||
/// sell and send-mail slots (`0x5357c3`, `0x5353a0`).
|
||||
fn held_instance(model: &Model, bag: i64, slot: u32) -> Option<render::ItemInstance> {
|
||||
model
|
||||
.containers
|
||||
.get(&bag)
|
||||
.and_then(|c| c.slots.get(&slot))
|
||||
.map(|s| slot_instance(s, true))
|
||||
}
|
||||
|
||||
/// The id-keyed render; on a miss, the ask and a name-only line, as `SetBagItem`'s miss path. The
|
||||
/// engine's own item hovers reach it from Rust (the action bar's, `SetTrainerService`,
|
||||
/// `SetCraftSpell`): 1.12's `Set*Item` bindings all name a container, so no Lua method takes an id.
|
||||
@@ -151,12 +241,23 @@ pub(super) fn render_by_id(
|
||||
fb_name: Option<String>,
|
||||
fb_q: Option<u32>,
|
||||
) -> mlua::Result<()> {
|
||||
render_by_id_flagged(lua, this, item_id, fb_name, fb_q, None).map(|_| ())
|
||||
}
|
||||
|
||||
/// [`render_by_id`] answering the item builder's return value (see [`render::render_view_flagged`]);
|
||||
/// a template still in flight answers false. `inst` is the live object the source holds, if any.
|
||||
pub(super) fn render_by_id_flagged(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
item_id: u32,
|
||||
fb_name: Option<String>,
|
||||
fb_q: Option<u32>,
|
||||
inst: Option<&render::ItemInstance>,
|
||||
) -> mlua::Result<bool> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
clear(lua, h);
|
||||
match view_of(lua, item_id) {
|
||||
Some(v) => {
|
||||
render_view(lua, this, &v, BuilderFlags::default(), None)?;
|
||||
}
|
||||
let live = match view_of(lua, item_id) {
|
||||
Some(v) => render::render_view_flagged(lua, this, &v, BuilderFlags::default(), inst)?,
|
||||
None => {
|
||||
if let Some(name) = fb_name {
|
||||
append_line(
|
||||
@@ -167,10 +268,11 @@ pub(super) fn render_by_id(
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
show_or_hide_empty(lua, h);
|
||||
Ok(())
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
fn set_quest_item_view(
|
||||
@@ -358,6 +460,10 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// nothing openable is equippable.
|
||||
openable_source: false,
|
||||
duration_ms: s.duration_ms,
|
||||
// Only the player's own doll holds item objects; an
|
||||
// inspected unit's item is a block (`0x532ee0`).
|
||||
object: unit.eq_ignore_ascii_case("player"),
|
||||
block_lock_id: 0,
|
||||
},
|
||||
)
|
||||
});
|
||||
@@ -384,8 +490,8 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
name_only,
|
||||
};
|
||||
clear(lua, h);
|
||||
match view_of(lua, item_id) {
|
||||
Some(v) => render_view(lua, &this, &v, flags, Some(&inst))?,
|
||||
let live = match view_of(lua, item_id) {
|
||||
Some(v) => render::render_view_flagged(lua, &this, &v, flags, Some(&inst))?,
|
||||
None => {
|
||||
// Template in flight: the header and the slot's own name, as
|
||||
// `SetBagItem`'s miss path.
|
||||
@@ -402,15 +508,17 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
};
|
||||
append_line(lua, &this, (name, color), None, false)?;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
show_or_hide_empty(lua, h);
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::Integer(1),
|
||||
// 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.
|
||||
flag(inst.duration_ms.is_some()),
|
||||
// hasCooldown is the builder's `[ebp-0x38]`, set by four lines: LOCKED, the
|
||||
// temporary-enchant countdown, the item's duration (`0x52ce0d`) and
|
||||
// ITEM_COOLDOWN_TIME (not built). A template still in flight draws only the
|
||||
// name, but the slot's own expiry still counts.
|
||||
flag(live || inst.duration_ms.is_some()),
|
||||
Value::Integer(i64::from(repair_cost)),
|
||||
]))
|
||||
},
|
||||
@@ -447,21 +555,9 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
has_cd,
|
||||
s.link.clone(),
|
||||
s.quality.unwrap_or(1),
|
||||
render::ItemInstance {
|
||||
// The link's name: the reference builds both from `0x5d8b00`.
|
||||
name: s.link.as_deref().and_then(link_name).map(str::to_string),
|
||||
durability: s.durability,
|
||||
creator: s.creator.clone(),
|
||||
has_text: s.readable,
|
||||
flags: s.flags,
|
||||
already_bound: s.already_bound,
|
||||
petition: s.petition.clone(),
|
||||
enchants: s.enchants.clone(),
|
||||
// p6 = 0 iff no cooldown runs; mid-cooldown the reference shows
|
||||
// ITEM_COOLDOWN_TIME instead, which is not built.
|
||||
openable_source: !has_cd,
|
||||
duration_ms: s.duration_ms,
|
||||
},
|
||||
// p6 = 0 iff no cooldown runs; mid-cooldown the reference shows
|
||||
// ITEM_COOLDOWN_TIME instead, which is not built.
|
||||
slot_instance(s, !has_cd),
|
||||
cost.unwrap_or(0),
|
||||
)
|
||||
}
|
||||
@@ -469,9 +565,15 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
}
|
||||
};
|
||||
clear(lua, h);
|
||||
match view_of(lua, item_id) {
|
||||
let live = match view_of(lua, item_id) {
|
||||
Some(v) => {
|
||||
render_view(lua, &this, &v, BuilderFlags::default(), Some(&inst))?;
|
||||
let live = render::render_view_flagged(
|
||||
lua,
|
||||
&this,
|
||||
&v,
|
||||
BuilderFlags::default(),
|
||||
Some(&inst),
|
||||
)?;
|
||||
let (merchant_open, repairing) = {
|
||||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
(model.merchant.is_some(), model.repair_mode)
|
||||
@@ -483,6 +585,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
append_line(lua, &this, (t, WHITE), None, false)?;
|
||||
}
|
||||
}
|
||||
live
|
||||
}
|
||||
None => {
|
||||
// Deviation: while the template is in flight the reference leaves the tooltip
|
||||
@@ -497,12 +600,14 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
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.
|
||||
flag(has_cd || inst.duration_ms.is_some()),
|
||||
// The builder's `[ebp-0x38]`: LOCKED, a countdown, the item's duration line, or a
|
||||
// running cooldown (ITEM_COOLDOWN_TIME, not drawn here).
|
||||
flag(has_cd || live || inst.duration_ms.is_some()),
|
||||
Value::Integer(i64::from(repair_cost)),
|
||||
]))
|
||||
})?,
|
||||
@@ -532,6 +637,12 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
clear(lua, h);
|
||||
let inst = render::ItemInstance {
|
||||
name: item.name.clone(),
|
||||
// The target side's block carries the partner's lock id (`0x534563`); the
|
||||
// player's is zero (`0x534341`), but its slot is a live item object, whose
|
||||
// flags the LOCKED gate reads (`0x5342ed`, `0x5343da`).
|
||||
block_lock_id: if player_side { 0 } else { item.lock_id },
|
||||
object: item.object_flags.is_some(),
|
||||
flags: item.object_flags.unwrap_or(0),
|
||||
..Default::default()
|
||||
};
|
||||
match view_of(lua, item.item_id) {
|
||||
@@ -835,7 +946,8 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
}
|
||||
|
||||
// GameTooltip:SetInboxItem(index): the mail item hover (`MailFrame.lua:218`,
|
||||
// `MailFrame.lua:470`). A row with no item does nothing; stock calls it only when there is one.
|
||||
// `MailFrame.lua:470`). A row with no item draws nothing and answers nil; stock calls it only
|
||||
// when there is one. The answer is the builder's return value, 1 or nil (`0x5356bb`).
|
||||
m.set(
|
||||
"SetInboxItem",
|
||||
lua.create_function(|lua, (this, index): (Table, usize)| {
|
||||
@@ -843,7 +955,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
let (item_id, roll, name) = {
|
||||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let Some(mail) = &model.mail else {
|
||||
return Ok(());
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
match mail.inbox.get(index.saturating_sub(1)) {
|
||||
Some(r) => (r.item_id, r.item_random_property_id, r.item_name.clone()),
|
||||
@@ -851,7 +963,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
}
|
||||
};
|
||||
if item_id == 0 {
|
||||
return Ok(());
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
clear(lua, h);
|
||||
// A block source (`0x5355fa`, p6=1) carrying the attachment's roll: no placeholder.
|
||||
@@ -860,17 +972,25 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
enchants: roll_enchants(lua, roll),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(v) = view_of(lua, item_id) {
|
||||
render_view(lua, &this, &v, BuilderFlags::default(), Some(&inst))?;
|
||||
}
|
||||
let live = match view_of(lua, item_id) {
|
||||
Some(v) => render::render_view_flagged(
|
||||
lua,
|
||||
&this,
|
||||
&v,
|
||||
BuilderFlags::default(),
|
||||
Some(&inst),
|
||||
)?,
|
||||
None => false,
|
||||
};
|
||||
show_or_hide_empty(lua, h);
|
||||
Ok(())
|
||||
Ok(flag(live))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GameTooltip:SetAuctionItem(type, index): an auction row's hover, `type` "list", "bidder" or
|
||||
// "owner". No creator, gift or open line: the reference zeroes the guid arguments
|
||||
// (`0x535a6d`, `0x535a73`), so the item-object gate those lines hang off fails.
|
||||
// (`0x535a6d`, `0x535a73`), so the item-object gate those lines hang off fails. It answers the
|
||||
// builder's return value, 1 or nil; a miss answers nil.
|
||||
m.set(
|
||||
"SetAuctionItem",
|
||||
lua.create_function(|lua, (this, kind, index): (Table, String, usize)| {
|
||||
@@ -878,10 +998,10 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
let (item_id, roll, name) = {
|
||||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let Some(auction) = &model.auction else {
|
||||
return Ok(());
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let Some(list) = super::auction::list_index_of(&kind) else {
|
||||
return Ok(());
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
match auction.lists[list].rows.get(index.saturating_sub(1)) {
|
||||
Some(r) => (r.item_id, r.random_property_id, r.name.clone()),
|
||||
@@ -889,7 +1009,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
}
|
||||
};
|
||||
if item_id == 0 {
|
||||
return Ok(());
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
clear(lua, h);
|
||||
// A block source (`0x5359d9`, p6=1) carrying the listing's roll: no placeholder.
|
||||
@@ -898,64 +1018,89 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
enchants: roll_enchants(lua, roll),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(v) = view_of(lua, item_id) {
|
||||
render_view(lua, &this, &v, BuilderFlags::default(), Some(&inst))?;
|
||||
}
|
||||
let live = match view_of(lua, item_id) {
|
||||
Some(v) => render::render_view_flagged(
|
||||
lua,
|
||||
&this,
|
||||
&v,
|
||||
BuilderFlags::default(),
|
||||
Some(&inst),
|
||||
)?,
|
||||
None => false,
|
||||
};
|
||||
show_or_hide_empty(lua, h);
|
||||
Ok(())
|
||||
Ok(flag(live))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GameTooltip:SetAuctionSellItem(): the sell slot's hover (`Blizzard_AuctionUI.xml:1791`); an
|
||||
// empty slot does nothing. The reference resolves the live sell-slot object (`[0xb72608]`,
|
||||
// `[0xb7260c]`, `0x5357b4`), so its creator line can show; this renders the template alone.
|
||||
// empty slot does nothing and answers nil. The reference resolves the live sell-slot object
|
||||
// (`[0xb72608]`, `[0xb7260c]`, `0x5357b4`), so its creator line can show; this renders the
|
||||
// template alone. It answers the builder's return value, 1 or nil (`0x5357da`).
|
||||
m.set(
|
||||
"SetAuctionSellItem",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let item_id = {
|
||||
// The slot's item is a live object (`0x5357b4`, `0x5353a0`), so its LOCKED gate,
|
||||
// expiry and temporary enchants follow the bag slot it came from.
|
||||
let (item_id, inst) = {
|
||||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model
|
||||
.auction_sell_item
|
||||
.as_ref()
|
||||
.map(|it| it.item_id)
|
||||
.unwrap_or(0)
|
||||
match model.auction_sell_item.as_ref() {
|
||||
Some(it) => (it.item_id, held_instance(&model, it.bag, it.slot)),
|
||||
None => (0, None),
|
||||
}
|
||||
};
|
||||
if item_id == 0 {
|
||||
return Ok(());
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
clear(lua, h);
|
||||
if let Some(v) = view_of(lua, item_id) {
|
||||
render_view(lua, &this, &v, BuilderFlags::default(), None)?;
|
||||
}
|
||||
let live = match view_of(lua, item_id) {
|
||||
Some(v) => render::render_view_flagged(
|
||||
lua,
|
||||
&this,
|
||||
&v,
|
||||
BuilderFlags::default(),
|
||||
inst.as_ref(),
|
||||
)?,
|
||||
None => false,
|
||||
};
|
||||
show_or_hide_empty(lua, h);
|
||||
Ok(())
|
||||
Ok(flag(live))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GameTooltip:SetSendMailItem(): the attached item's hover (`MailFrame.xml:952`); an empty
|
||||
// slot does nothing.
|
||||
// slot does nothing and answers nil. It answers the builder's return value, 1 or nil
|
||||
// (`0x53548a`).
|
||||
m.set(
|
||||
"SetSendMailItem",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let item_id = {
|
||||
// The slot's item is a live object (`0x5357b4`, `0x5353a0`), so its LOCKED gate,
|
||||
// expiry and temporary enchants follow the bag slot it came from.
|
||||
let (item_id, inst) = {
|
||||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model
|
||||
.mail_send_item
|
||||
.as_ref()
|
||||
.map(|it| it.item_id)
|
||||
.unwrap_or(0)
|
||||
match model.mail_send_item.as_ref() {
|
||||
Some(it) => (it.item_id, held_instance(&model, it.bag, it.slot)),
|
||||
None => (0, None),
|
||||
}
|
||||
};
|
||||
if item_id == 0 {
|
||||
return Ok(());
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
clear(lua, h);
|
||||
if let Some(v) = view_of(lua, item_id) {
|
||||
render_view(lua, &this, &v, BuilderFlags::default(), None)?;
|
||||
}
|
||||
let live = match view_of(lua, item_id) {
|
||||
Some(v) => render::render_view_flagged(
|
||||
lua,
|
||||
&this,
|
||||
&v,
|
||||
BuilderFlags::default(),
|
||||
inst.as_ref(),
|
||||
)?,
|
||||
None => false,
|
||||
};
|
||||
show_or_hide_empty(lua, h);
|
||||
Ok(())
|
||||
Ok(flag(live))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::names::*;
|
||||
/// What a source knows beyond the template: an item object's fields, or a block source's name and
|
||||
/// roll. A template source passes `None`.
|
||||
#[derive(Default)]
|
||||
pub(super) struct ItemInstance {
|
||||
pub(in crate::script) struct ItemInstance {
|
||||
/// The name with its random suffix joined, which the reference composes in the builder
|
||||
/// (`0x52b7bf`, `0x5d8b00` through `ITEM_SUFFIX_TEMPLATE`); `None` takes the template's.
|
||||
pub name: Option<String>,
|
||||
@@ -46,6 +46,14 @@ pub(super) struct ItemInstance {
|
||||
/// A timed item's remaining lifetime in ms, from `SMSG_ITEM_TIME_UPDATE`, not
|
||||
/// `ITEM_FIELD_DURATION`: the client displays from the packet (`Objects/Item.cpp:1093`).
|
||||
pub duration_ms: Option<u64>,
|
||||
/// The source holds a live item object (the guid arguments of `0x52b650`): the LOCKED gate
|
||||
/// reads its UNLOCKED flag (`0x52bdb3`..`0x52bdc4`). A block source, a template source and an
|
||||
/// inspected unit's item have none.
|
||||
pub object: bool,
|
||||
/// The block's lock id (`[this+0x430]`, set by the trade target's setter, `0x534563`), 0 for
|
||||
/// none or no block. With no lock row from the template it names one (`0x52bd78`..`0x52bda9`),
|
||||
/// and a nonzero one draws LOCKED even without an object (`0x52bdd2`).
|
||||
pub block_lock_id: u32,
|
||||
}
|
||||
|
||||
/// The set block's blank gold line, the reference's literal `0x854b2c`: a space and a newline. An
|
||||
@@ -67,16 +75,23 @@ pub(super) struct BuilderFlags {
|
||||
pub name_only: bool,
|
||||
}
|
||||
|
||||
/// Render one item into the tooltip in `0x52b650`'s order. Not built: ITEM_COOLDOWN_TIME,
|
||||
/// ITEM_WRAPPED_BY and the `LOCKED_WITH_*` line under LOCKED.
|
||||
pub(super) fn render_view(
|
||||
/// Render one item into the tooltip in `0x52b650`'s order, and answer the builder's return value
|
||||
/// (`[ebp-0x38]`): whether a line that changes while the tooltip stays up was written, the
|
||||
/// LOCKED line, the temporary enchant's countdown (`0x52ca82`) or the item's own expiry
|
||||
/// (`0x52ce0d`). The
|
||||
/// setters push it as 1 or nil; the stock buttons arm `updateTooltip` from it. The LOCKED line
|
||||
/// sets it on every lock path (`0x52bfa6`, after `0x52be58`, whether or not a `LOCKED_WITH_*`
|
||||
/// sub-line follows), as ITEM_COOLDOWN_TIME does (`0x52e140`). Not built: ITEM_COOLDOWN_TIME,
|
||||
/// ITEM_WRAPPED_BY and the `LOCKED_WITH_*` sub-line.
|
||||
pub(super) fn render_view_flagged(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
v: &ItemTemplateView,
|
||||
flags: BuilderFlags,
|
||||
// `None`: a template source, with no item object and no instance block.
|
||||
inst: Option<&ItemInstance>,
|
||||
) -> mlua::Result<()> {
|
||||
) -> mlua::Result<bool> {
|
||||
let mut live = false;
|
||||
let (req, known_spell, taught_known, set_view, equipped) = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let knows = |id: u32| model.spellbook.slots.iter().any(|s| s.spell_id == id);
|
||||
@@ -179,9 +194,17 @@ pub(super) fn render_view(
|
||||
if v.start_quest != 0 {
|
||||
keyed("ITEM_STARTS_QUEST", &[], WHITE, false)?;
|
||||
}
|
||||
// LOCKED, red, until the instance carries UNLOCKED `0x4`.
|
||||
if v.lock_id != 0 && inst.is_none_or(|i| i.flags & 0x4 == 0) {
|
||||
// LOCKED, red (`0x52bdde`..`0x52be58`), for a lock row the template or the block names
|
||||
// (`0x52bd57`..`0x52bda9`) and a source that passes the gate (`0x52bdb3`..`0x52bdd8`): an
|
||||
// item object lacking UNLOCKED `0x4`, or a block with a lock id of its own. A template
|
||||
// source has neither, so it draws no LOCKED. Every lock path then sets the builder's
|
||||
// return value (`0x52bfa6`).
|
||||
let block_lock = inst.map_or(0, |i| i.block_lock_id);
|
||||
let has_row = v.lock_id != 0 || block_lock != 0;
|
||||
let passes = block_lock != 0 || inst.is_some_and(|i| i.object && i.flags & 0x4 == 0);
|
||||
if has_row && passes {
|
||||
keyed("LOCKED", &[], RED, false)?;
|
||||
live = true;
|
||||
}
|
||||
}
|
||||
// The slot | type line, or for a bag the CONTAINER_SLOTS line in its seat. The type cell is
|
||||
@@ -410,7 +433,10 @@ pub(super) fn render_view(
|
||||
// A missing template skips the slot, not falling back to the bare name: the
|
||||
// reference formats an empty string, which `AddLine` drops.
|
||||
Some(ms) => match enchant_time_left(&e.name, ms, &get) {
|
||||
Some(t) => t,
|
||||
Some(t) => {
|
||||
live = true;
|
||||
t
|
||||
}
|
||||
None => continue,
|
||||
},
|
||||
None => e.name.clone(),
|
||||
@@ -450,6 +476,7 @@ pub(super) fn render_view(
|
||||
let ms = u32::try_from(ms).unwrap_or(u32::MAX);
|
||||
if let Some(line) = duration_text(ms, "ITEM_DURATION", true, &get) {
|
||||
add((line, WHITE))?;
|
||||
live = true;
|
||||
}
|
||||
}
|
||||
if v.allowable_class > 0
|
||||
@@ -605,7 +632,7 @@ pub(super) fn render_view(
|
||||
// p4's early return (`0x52e147..0x52e14c`): compact falls through to `0x52e14e`, lays out and
|
||||
// returns, so nothing from `0x52e170` on runs: flavor text, creator, open or read line, money.
|
||||
if flags.name_only {
|
||||
return Ok(());
|
||||
return Ok(live);
|
||||
}
|
||||
if !v.description.is_empty() {
|
||||
addw((format!("\"{}\"", v.description), GOLD))?;
|
||||
@@ -613,7 +640,7 @@ pub(super) fn render_view(
|
||||
// The reference gates the rest on an item object (`0x52e1c7`, `0x52e2e0`). Here only a template
|
||||
// source (`None`) stops, so a link, loot, mail or auction hover, which has an instance but no
|
||||
// object, still reaches READABLE.
|
||||
let Some(inst) = inst else { return Ok(()) };
|
||||
let Some(inst) = inst else { return Ok(live) };
|
||||
// The creator line (`0x52e1b1..0x52e2db`), white `0xc0cf60`: ITEM_WRITTEN_BY for a letter, else
|
||||
// ITEM_CREATED_BY, green by its own `|cff00ff00`. A wrapped instance (`0x8`, gate `0x52b7b0`)
|
||||
// takes ITEM_WRAPPED_BY off `ITEM_FIELD_GIFTCREATOR` in the reference; that is not built, so a
|
||||
@@ -640,7 +667,18 @@ pub(super) fn render_view(
|
||||
} else if v.page_text != 0 || inst.has_text {
|
||||
keyed("ITEM_READABLE", &[], GREEN, false)?;
|
||||
}
|
||||
Ok(())
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// [`render_view_flagged`] for a caller that does not answer the flag.
|
||||
pub(super) fn render_view(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
v: &ItemTemplateView,
|
||||
flags: BuilderFlags,
|
||||
inst: Option<&ItemInstance>,
|
||||
) -> mlua::Result<()> {
|
||||
render_view_flagged(lua, this, v, flags, inst).map(|_| ())
|
||||
}
|
||||
|
||||
/// A temporary enchant's name with its countdown (`0x52fa50`): `ITEM_ENCHANT_TIME_LEFT_<unit>`, its
|
||||
@@ -723,6 +761,50 @@ mod tests {
|
||||
assert_eq!(t(0).as_deref(), Some("<Rockbiter :: 0 S>"));
|
||||
}
|
||||
|
||||
/// The builder's return value is true when it wrote a line that changes while shown: the item's
|
||||
/// expiry (`0x52ce0d`) or a temporary enchant's countdown (`0x52ca82`), and not for a
|
||||
/// permanent enchant or a source with neither.
|
||||
#[test]
|
||||
fn the_builder_flag_is_true_for_an_expiry_or_an_enchant_countdown() {
|
||||
let s = crate::script::UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
ITEM_DURATION_HOURS = "[DURATION %d h]"
|
||||
ITEM_ENCHANT_TIME_LEFT_MIN = "<%s :: %d M>"
|
||||
CreateFrame("GameTooltip", "TT")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let tt: Table = s.lua().globals().get("TT").unwrap();
|
||||
let v = ItemTemplateView {
|
||||
name: "Sword".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let flagged = |inst: Option<&ItemInstance>| {
|
||||
render_view_flagged(s.lua(), &tt, &v, BuilderFlags::default(), inst).unwrap()
|
||||
};
|
||||
let enchant = |slot, remaining_ms| crate::script::EnchantView {
|
||||
slot,
|
||||
name: "Rockbiter".into(),
|
||||
remaining_ms,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!flagged(None));
|
||||
assert!(!flagged(Some(&ItemInstance::default())));
|
||||
assert!(flagged(Some(&ItemInstance {
|
||||
duration_ms: Some(3_600_000),
|
||||
..Default::default()
|
||||
})));
|
||||
assert!(flagged(Some(&ItemInstance {
|
||||
enchants: vec![enchant(1, Some(300_000))],
|
||||
..Default::default()
|
||||
})));
|
||||
assert!(!flagged(Some(&ItemInstance {
|
||||
enchants: vec![enchant(0, None)],
|
||||
..Default::default()
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_countdown_family_draws_nothing() {
|
||||
assert_eq!(enchant_time_left("Rockbiter", 60_000, &|_| None), None);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use super::binding_abi::flag;
|
||||
use super::object::{frame_handle_of, frame_wrapper};
|
||||
use super::tooltip::{append_line, clear, clear_silent, tip_mut};
|
||||
use super::{CraftTooltip, Model, TrainerTooltip};
|
||||
@@ -45,6 +46,9 @@ pub struct SpellTooltipView {
|
||||
/// The cooldown cell (`SPELL_RECAST_TIME_SEC 0x854ed4`): `max(RecoveryTime,
|
||||
/// CategoryRecoveryTime)`, as Charge's 15 s is in the category column.
|
||||
pub cooldown: Option<String>,
|
||||
/// The same larger recovery column in milliseconds (the builder's `[ebp-0x38]`, `0x52eb0d`):
|
||||
/// the live "Cooldown remaining" line and the setters' return flag need it at 60 000 or more.
|
||||
pub recovery_ms: u32,
|
||||
/// "Requires Wands" (`SPELL_EQUIPPED_ITEM 0x854e94`), from `EquippedItemClass` and
|
||||
/// `EquippedItemSubClassMask`.
|
||||
pub requires_item: Option<String>,
|
||||
@@ -236,6 +240,7 @@ fn answer_wait(lua: &Lua, h: FrameHandle, spell_id: u32, alt_caster: bool) -> ml
|
||||
wait.opts,
|
||||
wait.remaining,
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// The view store and its asks for the unit a render is built against: the pet's when the
|
||||
@@ -309,8 +314,39 @@ pub(super) struct SpellRenderOpts {
|
||||
/// lines drop (`0x52ed43`, `0x52f393`). `SetPetAction` sets it (`0x532888`), `SetSpell` on the
|
||||
/// pet book (`0x532e23`) and `SetTrainerService` for a `LEARN_PET_SPELL` service (`0x533a7f`).
|
||||
pub(super) alt_caster: bool,
|
||||
/// `param4`, the live cooldown left in ms (`start + duration - now`), 0 for none: the setter
|
||||
/// takes it from the cooldown query (`0x6e2ea0`) and passes it only while that cooldown runs
|
||||
/// (enabled, with a start and a duration), as `0x532e55`..`0x532e81` does.
|
||||
pub(super) remaining_ms: u32,
|
||||
}
|
||||
|
||||
/// The live cooldown left in ms for the builder's `param4`, from a `(start, duration, enabled)`
|
||||
/// triple in `GetTime` seconds: nonzero only while the cooldown runs, enabled with a start and a
|
||||
/// duration (`0x532e5a`..`0x532e81`, `0x5328aa`), else 0. An item action's block takes the same
|
||||
/// number (`0x5324d5`).
|
||||
pub(super) fn live_remaining_ms(cooldown: Option<(f64, f64, bool)>, now: f64) -> u32 {
|
||||
let Some((start, duration, true)) = cooldown else {
|
||||
return 0;
|
||||
};
|
||||
if start <= 0.0 || duration <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
((start + duration - now) * 1000.0)
|
||||
.round()
|
||||
.clamp(0.0, f64::from(u32::MAX)) as u32
|
||||
}
|
||||
|
||||
/// The cooldown-remaining leg of the spell builder's return value: the live remaining line is
|
||||
/// reached with `param4` nonzero and a recovery column of a minute or more (`0x52f537`..`0x52f581`).
|
||||
/// The line itself is not built; only its flag is answered.
|
||||
fn cooldown_countdown(v: &SpellTooltipView, opts: SpellRenderOpts) -> bool {
|
||||
!opts.aura && opts.remaining_ms > 0 && v.recovery_ms >= 60_000
|
||||
}
|
||||
|
||||
/// Renders the spell and answers the builder's return value (`[ebp-0x28]`): whether the tooltip
|
||||
/// holds a line that changes while it is shown, the chance line (`0x52f68c`) or the remaining
|
||||
/// cooldown (`0x52f581`). The setters push it as 1 or nil, and the stock buttons arm
|
||||
/// `updateTooltip` from it.
|
||||
fn render_spell(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
@@ -318,12 +354,14 @@ fn render_spell(
|
||||
opts: SpellRenderOpts,
|
||||
remaining: Option<String>,
|
||||
talent: Option<&TalentLines>,
|
||||
) -> mlua::Result<()> {
|
||||
) -> mlua::Result<bool> {
|
||||
let SpellRenderOpts {
|
||||
aura,
|
||||
show_rank,
|
||||
alt_caster,
|
||||
..
|
||||
} = opts;
|
||||
let mut live = false;
|
||||
// The spell builder writes the name white (`0x530270`), the aura builder gold (`0x530380`).
|
||||
let name_color = if aura { GOLD } else { WHITE };
|
||||
let right = if aura {
|
||||
@@ -383,6 +421,7 @@ fn render_spell(
|
||||
// The chance line, white and unwrapped, between the reagents and the description.
|
||||
if let Some(chance) = &v.chance {
|
||||
append_line(lua, this, (chance.clone(), WHITE), None, false)?;
|
||||
live = true;
|
||||
}
|
||||
}
|
||||
let desc = if aura && !v.aura_description.is_empty() {
|
||||
@@ -424,7 +463,7 @@ fn render_spell(
|
||||
if let Some(rem) = remaining {
|
||||
append_line(lua, this, (rem, GOLD), None, false)?;
|
||||
}
|
||||
Ok(())
|
||||
Ok(live || (!talent_builder && cooldown_countdown(v, opts)))
|
||||
}
|
||||
|
||||
/// `GameTooltip:SetTalent`'s render: the talent's spell plus its talent lines; a missing next-rank
|
||||
@@ -441,14 +480,16 @@ pub(super) fn set_spell_with_talent(
|
||||
super::talent::ask_next_rank(lua, talent.next_spell);
|
||||
}
|
||||
match spell_view_of(lua, spell_id, false) {
|
||||
Some(v) => render_spell(
|
||||
lua,
|
||||
this,
|
||||
&v,
|
||||
SpellRenderOpts::default(),
|
||||
None,
|
||||
Some(&talent),
|
||||
)?,
|
||||
Some(v) => {
|
||||
render_spell(
|
||||
lua,
|
||||
this,
|
||||
&v,
|
||||
SpellRenderOpts::default(),
|
||||
None,
|
||||
Some(&talent),
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
// No view yet: the rank line alone until the next hover.
|
||||
if let Some(rank) = &talent.rank_line {
|
||||
@@ -485,6 +526,20 @@ pub(super) fn set_spell_by_id(
|
||||
opts: SpellRenderOpts,
|
||||
remaining: Option<String>,
|
||||
) -> mlua::Result<()> {
|
||||
set_spell_flagged(lua, this, spell_id, fallback_name, opts, remaining).map(|_| ())
|
||||
}
|
||||
|
||||
/// [`set_spell_by_id`] answering the builder's return value (see [`render_spell`]). A view still
|
||||
/// in flight answers false: the wait paints the tooltip when the app answers, and the setter has
|
||||
/// returned by then.
|
||||
pub(super) fn set_spell_flagged(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
spell_id: u32,
|
||||
fallback_name: Option<String>,
|
||||
opts: SpellRenderOpts,
|
||||
remaining: Option<String>,
|
||||
) -> mlua::Result<bool> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
clear(lua, h);
|
||||
fill_spell(lua, this, h, spell_id, fallback_name, opts, remaining)
|
||||
@@ -499,8 +554,8 @@ fn fill_spell(
|
||||
fallback_name: Option<String>,
|
||||
opts: SpellRenderOpts,
|
||||
remaining: Option<String>,
|
||||
) -> mlua::Result<()> {
|
||||
match spell_view_of(lua, spell_id, opts.alt_caster) {
|
||||
) -> mlua::Result<bool> {
|
||||
let live = match spell_view_of(lua, spell_id, opts.alt_caster) {
|
||||
Some(v) => render_spell(lua, this, &v, opts, remaining, None)?,
|
||||
None => {
|
||||
if let Some(name) = fallback_name.clone() {
|
||||
@@ -521,10 +576,11 @@ fn fill_spell(
|
||||
},
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
super::tooltip::show_or_hide_empty(lua, h);
|
||||
Ok(())
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// Register the spell and aura content methods on the GameTooltip method table.
|
||||
@@ -535,48 +591,82 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// bank it passes to `0x6e2ea0` (`0x532e50`).
|
||||
m.set(
|
||||
"SetSpell",
|
||||
lua.create_function(|lua, (this, book_id, book_type): (Table, u32, Value)| {
|
||||
// A non-string `bookType` bails (`0x532dc0`) rather than meaning the player's book.
|
||||
let Some(book_type) = book_type.as_string().and_then(|s| s.to_str().ok()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let (spell_id, name) = {
|
||||
lua.create_function(|lua, (this, book_id, book_type): (Table, Value, Value)| {
|
||||
// The shared slot marshaller's gates (`0x532db3`, `0x532dc0`, `0x532dd9`..`0x532df4`):
|
||||
// a number index and a number or string `bookType` (a number is stringified and so is
|
||||
// the player's book), the index `trunc(n - 1)` in `[0, 0x400)`, else the binding
|
||||
// raises "Invalid spell slot in SetSpell" (`0x532ec8`, string `0x855290`).
|
||||
let (book_id, book_type) =
|
||||
super::spellbook::spell_slot_args(book_id, book_type, "SetSpell")?;
|
||||
let now = crate::script::clock::now(lua);
|
||||
let (spell_id, name, remaining_ms) = {
|
||||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
match super::spellbook::book_slot(&model, book_id, &book_type) {
|
||||
Some(s) => (s.spell_id, Some(s.name.clone())),
|
||||
None => return Ok(()),
|
||||
Some(s) => {
|
||||
let cooldown = s.cooldown.map(|(start_ms, duration_ms, enabled)| {
|
||||
(
|
||||
start_ms as f64 / 1000.0,
|
||||
f64::from(duration_ms) / 1000.0,
|
||||
enabled,
|
||||
)
|
||||
});
|
||||
(
|
||||
s.spell_id,
|
||||
Some(s.name.clone()),
|
||||
live_remaining_ms(cooldown, now),
|
||||
)
|
||||
}
|
||||
None => return Ok(Value::Nil),
|
||||
}
|
||||
};
|
||||
// The pet book's flag is also the builder's unit selector (`0x532e70`).
|
||||
let opts = SpellRenderOpts {
|
||||
alt_caster: super::spellbook::is_pet_book(&book_type),
|
||||
remaining_ms,
|
||||
..Default::default()
|
||||
};
|
||||
set_spell_by_id(lua, &this, spell_id, name, opts, None)
|
||||
Ok(flag(set_spell_flagged(
|
||||
lua, &this, spell_id, name, opts, None,
|
||||
)?))
|
||||
})?,
|
||||
)?;
|
||||
// GameTooltip:SetShapeshift(index): the stance-bar hover, the form's spell tooltip.
|
||||
// GameTooltip:SetShapeshift(index): the stance-bar hover, the form's spell tooltip. It answers
|
||||
// the builder's return value, 1 or nil, with a cooldown running (`0x532a89`).
|
||||
m.set(
|
||||
"SetShapeshift",
|
||||
lua.create_function(|lua, (this, index): (Table, usize)| {
|
||||
let (spell_id, name) = {
|
||||
let now = crate::script::clock::now(lua);
|
||||
let (spell_id, name, remaining_ms) = {
|
||||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
match model.shapeshift_forms.get(index.saturating_sub(1)) {
|
||||
Some(f) => (f.view.spell_id, Some(f.view.name.clone())),
|
||||
None => return Ok(()),
|
||||
Some(f) => (
|
||||
f.view.spell_id,
|
||||
Some(f.view.name.clone()),
|
||||
live_remaining_ms(f.cooldown, now),
|
||||
),
|
||||
None => return Ok(Value::Nil),
|
||||
}
|
||||
};
|
||||
set_spell_by_id(lua, &this, spell_id, name, SpellRenderOpts::default(), None)
|
||||
let opts = SpellRenderOpts {
|
||||
remaining_ms,
|
||||
..Default::default()
|
||||
};
|
||||
// The builder's value is read only on the running-cooldown leg (`0x532a89`); the
|
||||
// other leg builds with a zero remainder and pushes nil (`0x532abe`, `0x532ac3`).
|
||||
let live = set_spell_flagged(lua, &this, spell_id, name, opts, None)?;
|
||||
Ok(flag(remaining_ms > 0 && live))
|
||||
})?,
|
||||
)?;
|
||||
// GameTooltip:SetPetAction(index): the pet-bar hover for a spell slot, built against the pet
|
||||
// (the unit selector, pushed at `0x532888` and `0x5328d0`); the stock
|
||||
// `PetActionButton_OnEnter` builds a token's tooltip itself (`PetActionBarFrame.lua:290`). A
|
||||
// slot with no spell is a no-op.
|
||||
// slot with no spell is a no-op answering nil; a spell slot answers the builder's return value,
|
||||
// 1 or nil, with a cooldown running (`0x5328ca`).
|
||||
m.set(
|
||||
"SetPetAction",
|
||||
lua.create_function(|lua, (this, index): (Table, usize)| {
|
||||
let (spell_id, name) = {
|
||||
let now = crate::script::clock::now(lua);
|
||||
let (spell_id, name, remaining_ms) = {
|
||||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
match model
|
||||
.pet_bar
|
||||
@@ -585,17 +675,21 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
.filter(|s| !s.view.is_token)
|
||||
{
|
||||
Some(s) => match s.view.spell_id {
|
||||
Some(id) => (id, s.view.name.clone()),
|
||||
None => return Ok(()),
|
||||
Some(id) => (id, s.view.name.clone(), live_remaining_ms(s.cooldown, now)),
|
||||
None => return Ok(Value::Nil),
|
||||
},
|
||||
None => return Ok(()),
|
||||
None => return Ok(Value::Nil),
|
||||
}
|
||||
};
|
||||
let opts = SpellRenderOpts {
|
||||
alt_caster: true,
|
||||
remaining_ms,
|
||||
..Default::default()
|
||||
};
|
||||
set_spell_by_id(lua, &this, spell_id, name, opts, None)
|
||||
// As `SetShapeshift`: the builder's value counts only with a cooldown running
|
||||
// (`0x5328aa`; the other leg builds and pushes nil, `0x5328dc`, `0x5328e1`).
|
||||
let live = set_spell_flagged(lua, &this, spell_id, name, opts, None)?;
|
||||
Ok(flag(remaining_ms > 0 && live))
|
||||
})?,
|
||||
)?;
|
||||
// GameTooltip:SetPlayerBuff(buffIndex): the aura variant plus the time-remaining line only
|
||||
@@ -767,28 +861,65 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// GameTooltip:SetAction(slot): the action-bar hover by payload kind (`0x5322a0`): a spell
|
||||
// (0x00) to the spell builder `0x52e610`, an item (0x80) to the item builder `0x52b650`, a
|
||||
// macro (0x40) to `0x52b040`, which fetches it (`0x4f0f40`) and renders only its name, white
|
||||
// (`0x5303b0` in colour `0xc0cf60`), or hides with none; 1.12 has no `#showtooltip`.
|
||||
// (`0x5303b0` in colour `0xc0cf60`), or hides with none; 1.12 has no `#showtooltip`. It
|
||||
// answers the spell or item builder's return value as 1 or nil (`0x5325a9`), the spell's only
|
||||
// with a cooldown running, nil for the macro leg, an empty slot and any other kind;
|
||||
// `ActionButton_SetTooltip` arms `updateTooltip` from it.
|
||||
m.set(
|
||||
"SetAction",
|
||||
lua.create_function(|lua, (this, slot): (Table, u32)| {
|
||||
let action = {
|
||||
let now = crate::script::clock::now(lua);
|
||||
let (action, remaining_ms, owned) = {
|
||||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.actions.get(&slot).cloned()
|
||||
let action = model.actions.get(&slot).cloned();
|
||||
// An item word resolves to the player's own item by entry (`0x532490`).
|
||||
let owned = action
|
||||
.as_ref()
|
||||
.filter(|a| a.kind == 0x80)
|
||||
.and_then(|a| super::tooltip_item::owned_instance(&model, a.action));
|
||||
(
|
||||
action,
|
||||
live_remaining_ms(model.action_states.get(&slot).and_then(|s| s.cooldown), now),
|
||||
owned,
|
||||
)
|
||||
};
|
||||
let Some(a) = action else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let Some(a) = action else { return Ok(()) };
|
||||
match a.kind {
|
||||
0x00 => set_spell_by_id(
|
||||
lua,
|
||||
&this,
|
||||
a.action,
|
||||
None,
|
||||
SpellRenderOpts {
|
||||
show_rank: true,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
),
|
||||
0x80 => super::tooltip_item::render_by_id(lua, &this, a.action, None, None),
|
||||
// The builder's value counts only with a cooldown running (`0x532597`); the other
|
||||
// leg builds with a zero remainder and pushes nil (`0x53267c`, `0x532690`), so
|
||||
// Attack, Parry, Block and Dodge answer nil here, whatever their chance line.
|
||||
0x00 => {
|
||||
let live = set_spell_flagged(
|
||||
lua,
|
||||
&this,
|
||||
a.action,
|
||||
None,
|
||||
SpellRenderOpts {
|
||||
show_rank: true,
|
||||
remaining_ms,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
)?;
|
||||
Ok(flag(remaining_ms > 0 && live))
|
||||
}
|
||||
// The item builder's block takes the same live cooldown (`0x5324d5`), whose
|
||||
// ITEM_COOLDOWN_TIME line (not built) sets the return value (`0x52e140`).
|
||||
// The tooltip is built either way, but a miss in the player's own items pushes
|
||||
// nil whatever the builder returned (`0x5325bc`, `0x532604`, `0x532404`).
|
||||
0x80 => {
|
||||
let live = super::tooltip_item::render_by_id_flagged(
|
||||
lua,
|
||||
&this,
|
||||
a.action,
|
||||
None,
|
||||
None,
|
||||
owned.as_ref(),
|
||||
)?;
|
||||
Ok(flag(owned.is_some() && (live || remaining_ms > 0)))
|
||||
}
|
||||
0x40 => {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
clear(lua, h);
|
||||
@@ -802,9 +933,10 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
append_line(lua, &this, (name, WHITE), None, false)?;
|
||||
}
|
||||
super::tooltip::show_or_hide_empty(lua, h);
|
||||
Ok(())
|
||||
// The macro leg's builder return is dropped (`0x5326be`).
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
_ => Ok(()),
|
||||
_ => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -8,6 +8,7 @@ use mlua::{Lua, MultiValue, Value};
|
||||
use super::binding_abi::flag;
|
||||
use super::cursor::{self, CursorPayload};
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::Idx;
|
||||
|
||||
/// Slots per side, six traded and the non-traded enchant slot: vmangos `TRADE_SLOT_COUNT`
|
||||
/// (`TradeData.h`), kept equal to the protocol crate's, which this crate does not depend on.
|
||||
@@ -22,9 +23,20 @@ pub struct TradeSlotItem {
|
||||
pub name: Option<String>,
|
||||
pub texture: Option<String>,
|
||||
pub count: u32,
|
||||
/// `None` while the template is in flight; only the target side's info returns it
|
||||
/// (`TradeFrame.lua:84`).
|
||||
/// `None` while the template is in flight. `GetTradePlayerItemInfo` and
|
||||
/// `GetTradeTargetItemInfo` report it only when `inventory_type` is nonzero (`0x4c059c`,
|
||||
/// `0x4c0241`), else -1.
|
||||
pub quality: Option<u32>,
|
||||
/// The template's InventoryType; 0 while the template is in flight and for an item that cannot
|
||||
/// be equipped.
|
||||
pub inventory_type: u32,
|
||||
/// The wire's `lock_id`: the trade target's tooltip draws LOCKED off it (`0x534563`,
|
||||
/// `0x52bdd2`). 0 for none; the player's side ignores it.
|
||||
pub lock_id: u32,
|
||||
/// The player's own side only: the placed item's `ITEM_FIELD_FLAGS`. Its presence means the
|
||||
/// slot holds a live item object, which the tooltip builder's LOCKED gate reads (`0x5342ed`);
|
||||
/// the partner's side has none.
|
||||
pub object_flags: Option<u32>,
|
||||
/// The name of the enchant being applied to slot 7, which stock shows in green
|
||||
/// (`TradeFrame.lua:62`); the app always sends `None`.
|
||||
pub enchantment: Option<String>,
|
||||
@@ -164,20 +176,25 @@ fn click_trade_button(model: &mut Model, id: u32) {
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// GetTradePlayerItemInfo(id): the five values `TradeFrame.lua:55` reads.
|
||||
// GetTradePlayerItemInfo(id): name, texture, numItems, quality, enchantment; an empty slot is
|
||||
// nil, nil, 0, 0, nil (`0x4c0613`), and a filled one's texture is a string. The fourth is the quality, -1 unless the template is cached
|
||||
// and equippable (`0x4c059c`), never a usable flag; `TradeFrame.lua:55` names it `isUsable` and
|
||||
// never reads it.
|
||||
g.set(
|
||||
"GetTradePlayerItemInfo",
|
||||
lua.create_function(|lua, id: usize| {
|
||||
let (slot, usable) = {
|
||||
lua.create_function(|lua, Idx(id): Idx| {
|
||||
let slot = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let slot = slot_at(&model, id, |t| &t.player);
|
||||
let usable = slot
|
||||
.as_ref()
|
||||
.is_none_or(|s| super::item_stats::item_usable_by_id(&model, s.item_id));
|
||||
(slot, usable)
|
||||
slot_at(&model, id, |t| &t.player)
|
||||
};
|
||||
let Some(s) = slot else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
return Ok(super::binding_abi::tuple(&[
|
||||
None,
|
||||
None,
|
||||
Some(0),
|
||||
Some(0),
|
||||
None,
|
||||
]));
|
||||
};
|
||||
let opt_str = |v: &Option<String>| -> mlua::Result<Value> {
|
||||
Ok(match v {
|
||||
@@ -187,9 +204,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
opt_str(&s.name)?,
|
||||
opt_str(&s.texture)?,
|
||||
// The question mark while the template is unknown (`0x4c0529`).
|
||||
Value::String(
|
||||
lua.create_string(
|
||||
s.texture
|
||||
.as_deref()
|
||||
.unwrap_or(super::item_stats::MISSING_ITEM_ICON),
|
||||
)?,
|
||||
),
|
||||
Value::Integer(i64::from(s.count.max(1))),
|
||||
flag(usable),
|
||||
Value::Integer(super::item_stats::gated_quality(
|
||||
s.quality,
|
||||
s.inventory_type,
|
||||
)),
|
||||
opt_str(&s.enchantment)?,
|
||||
]))
|
||||
})?,
|
||||
@@ -202,7 +229,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
] {
|
||||
g.set(
|
||||
name,
|
||||
lua.create_function(move |lua, id: usize| {
|
||||
lua.create_function(move |lua, Idx(id): Idx| {
|
||||
let link = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let slot = if side {
|
||||
@@ -220,10 +247,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
}
|
||||
|
||||
// GetTradeTargetItemInfo(id): the six values `TradeFrame.lua:84` reads.
|
||||
// GetTradeTargetItemInfo(id): the six values `TradeFrame.lua:84` reads; an empty slot is
|
||||
// nil, nil, 0, 0, nil, nil (`0x4c02f6`). The fourth is the quality through the same gate as
|
||||
// the player's (`0x4c0241`).
|
||||
g.set(
|
||||
"GetTradeTargetItemInfo",
|
||||
lua.create_function(|lua, id: usize| {
|
||||
lua.create_function(|lua, Idx(id): Idx| {
|
||||
let (slot, usable) = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let slot = slot_at(&model, id, |t| &t.target);
|
||||
@@ -232,8 +261,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
.is_none_or(|s| super::item_stats::item_usable_by_id(&model, s.item_id));
|
||||
(slot, usable)
|
||||
};
|
||||
let Some(s) = slot else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
// A template the cache lacks takes the miss too (`0x4c019b`, `0x4c02f6`).
|
||||
let Some(s) = slot.filter(|s| s.name.is_some()) else {
|
||||
return Ok(super::binding_abi::tuple(&[
|
||||
None,
|
||||
None,
|
||||
Some(0),
|
||||
Some(0),
|
||||
None,
|
||||
None,
|
||||
]));
|
||||
};
|
||||
let opt_str = |v: &Option<String>| -> mlua::Result<Value> {
|
||||
Ok(match v {
|
||||
@@ -241,15 +278,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
None => Value::Nil,
|
||||
})
|
||||
};
|
||||
let quality = match s.quality {
|
||||
Some(q) => Value::Integer(i64::from(q)),
|
||||
None => Value::Nil,
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
opt_str(&s.name)?,
|
||||
opt_str(&s.texture)?,
|
||||
Value::String(
|
||||
lua.create_string(
|
||||
s.texture
|
||||
.as_deref()
|
||||
.unwrap_or(super::item_stats::MISSING_ITEM_ICON),
|
||||
)?,
|
||||
),
|
||||
Value::Integer(i64::from(s.count.max(1))),
|
||||
quality,
|
||||
Value::Integer(super::item_stats::gated_quality(
|
||||
s.quality,
|
||||
s.inventory_type,
|
||||
)),
|
||||
flag(usable),
|
||||
opt_str(&s.enchantment)?,
|
||||
]))
|
||||
@@ -385,6 +427,9 @@ mod tests {
|
||||
texture: Some("Interface\\Icons\\INV_Fabric_Linen_01".into()),
|
||||
count,
|
||||
quality: Some(quality),
|
||||
inventory_type: 0,
|
||||
lock_id: 0,
|
||||
object_flags: None,
|
||||
enchantment: None,
|
||||
link: Some(format!(
|
||||
"|cffffffff|Hitem:{item_id}:0:0:0|h[Linen Cloth]|h|r"
|
||||
@@ -426,6 +471,9 @@ mod tests {
|
||||
texture: None,
|
||||
count: 1,
|
||||
quality: None,
|
||||
inventory_type: 0,
|
||||
lock_id: 0,
|
||||
object_flags: None,
|
||||
enchantment: None,
|
||||
link: None,
|
||||
});
|
||||
@@ -464,27 +512,29 @@ mod tests {
|
||||
|
||||
s.set_trade(Some(state()));
|
||||
|
||||
let (name, tex, count, usable): (String, String, i64, i64) = s
|
||||
// Linen cloth has InventoryType 0, so the fourth value is the -1 sentinel.
|
||||
let (name, tex, count, quality): (String, String, i64, i64) = s
|
||||
.eval(
|
||||
"local n,t,c,u,e = GetTradePlayerItemInfo(1)\n\
|
||||
return n,t,c,u",
|
||||
"local n,t,c,q,e = GetTradePlayerItemInfo(1)\n\
|
||||
return n,t,c,q",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((name.as_str(), count, usable), ("Linen Cloth", 5, 1));
|
||||
assert_eq!((name.as_str(), count, quality), ("Linen Cloth", 5, -1));
|
||||
assert_eq!(tex, "Interface\\Icons\\INV_Fabric_Linen_01");
|
||||
assert!(s
|
||||
.eval::<bool>("local n,t,c,u,e = GetTradePlayerItemInfo(1)\nreturn e == nil")
|
||||
.eval::<bool>("local n,t,c,q,e = GetTradePlayerItemInfo(1)\nreturn e == nil")
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return GetTradePlayerItemInfo(2) == nil")
|
||||
.unwrap());
|
||||
|
||||
// The target side adds quality as the fourth return.
|
||||
// The target side adds the enchant and the usable flag; the fourth is the quality, -1 for
|
||||
// linen cloth (InventoryType 0).
|
||||
let (name, _tex, count, quality, usable): (String, String, i64, i64, i64) =
|
||||
s.eval("return GetTradeTargetItemInfo(1)").unwrap();
|
||||
assert_eq!(
|
||||
(name.as_str(), count, quality, usable),
|
||||
("Linen Cloth", 1, 2, 1)
|
||||
("Linen Cloth", 1, -1, 1)
|
||||
);
|
||||
// The enchant slot (7) is filled on the target side.
|
||||
assert!(s
|
||||
@@ -492,6 +542,95 @@ mod tests {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The fourth value is the quality, through the item-info gate (`0x4c059c`..`0x4c05c1`): the
|
||||
/// template's quality for a cached, equippable item, else the number -1; five values on every
|
||||
/// slot, the fourth never nil and never a usable flag.
|
||||
#[test]
|
||||
fn player_item_quality_is_gated_on_the_inventory_type() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut st = state();
|
||||
let mut weapon = item(1234, 1, 4);
|
||||
weapon.inventory_type = 13;
|
||||
st.player.slots[1] = Some(weapon);
|
||||
// Equippable, but its template is still in flight.
|
||||
let mut pending = item(1235, 1, 0);
|
||||
pending.quality = None;
|
||||
pending.inventory_type = 0;
|
||||
st.player.slots[2] = Some(pending);
|
||||
s.set_trade(Some(st));
|
||||
for (slot, want) in [(1, "5:number:-1"), (2, "5:number:4"), (3, "5:number:-1")] {
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!(
|
||||
"local c = function(...) return arg.n .. ':' .. type(arg[4]) .. ':' .. \
|
||||
tostring(arg[4]) end return c(GetTradePlayerItemInfo({slot}))"
|
||||
))
|
||||
.unwrap(),
|
||||
want,
|
||||
"slot {slot}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The target side's quality goes through the same gate, and an empty slot pushes the six-value
|
||||
/// miss tuple (`0x4c02f6`): nil, nil, 0, 0, nil, nil.
|
||||
#[test]
|
||||
fn target_item_info_gates_its_quality_and_pushes_six_values_on_a_miss() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut st = state();
|
||||
let mut weapon = item(1234, 1, 3);
|
||||
weapon.inventory_type = 13;
|
||||
st.target.slots[1] = Some(weapon);
|
||||
s.set_trade(Some(st));
|
||||
let shape = |s: &UiScript, slot: u32| -> String {
|
||||
s.eval(&format!(
|
||||
"local c = function(...) local o = {{}} for i = 1, arg.n do o[i] = tostring(arg[i]) end \
|
||||
return table.concat(o, ',') end return c(GetTradeTargetItemInfo({slot}))"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
shape(&s, 2),
|
||||
"Linen Cloth,Interface\\Icons\\INV_Fabric_Linen_01,1,3,1,nil"
|
||||
);
|
||||
assert_eq!(shape(&s, 3), "nil,nil,0,0,nil,nil");
|
||||
// A row whose template is still in flight takes the miss too (`0x4c019b`, `0x4c02f6`).
|
||||
let mut st = state();
|
||||
st.target.slots[3] = Some(TradeSlotItem {
|
||||
item_id: 4306,
|
||||
count: 1,
|
||||
..Default::default()
|
||||
});
|
||||
s.set_trade(Some(st));
|
||||
assert_eq!(shape(&s, 4), "nil,nil,0,0,nil,nil");
|
||||
assert_eq!(shape(&s, 0), "nil,nil,0,0,nil,nil");
|
||||
assert_eq!(shape(&s, 9), "nil,nil,0,0,nil,nil");
|
||||
}
|
||||
|
||||
/// The texture is a string for a filled slot: `0x5d8af0` falls back to the question mark while
|
||||
/// the template is unknown.
|
||||
#[test]
|
||||
fn player_item_texture_falls_back_to_the_question_mark() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut st = state();
|
||||
let mut pending = item(1235, 1, 0);
|
||||
pending.texture = None;
|
||||
st.player.slots[1] = Some(pending);
|
||||
s.set_trade(Some(st));
|
||||
for (slot, want) in [
|
||||
(1, "Interface\\Icons\\INV_Fabric_Linen_01"),
|
||||
(2, "Interface\\Icons\\INV_Misc_QuestionMark"),
|
||||
] {
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!(
|
||||
"local n, t = GetTradePlayerItemInfo({slot}) return type(t) .. ':' .. t"
|
||||
))
|
||||
.unwrap(),
|
||||
format!("string:{want}"),
|
||||
"slot {slot}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_reads_the_pushed_state() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -10,6 +10,14 @@ use super::view::{
|
||||
build_groups, first_recipe_index, inv_slot_token, num_rows, present_inv_slots, recipe_at, rows,
|
||||
select, selected_visible_index, set_collapsed, Row,
|
||||
};
|
||||
use crate::script::binding_abi::Idx;
|
||||
|
||||
/// The index of the two filter getters: `is-number` (raising the usage error), then `_ftol` minus
|
||||
/// one as a signed number; negative means the whole list.
|
||||
fn filter_index(lua: &Lua, v: Option<Value>, usage: &'static str) -> mlua::Result<i32> {
|
||||
let v = v.unwrap_or(Value::Nil);
|
||||
Ok(number_arg(lua, v, usage)?.wrapping_sub(1))
|
||||
}
|
||||
|
||||
/// An optional string as a Lua value (`nil` when absent).
|
||||
fn opt_str(lua: &Lua, s: Option<&String>) -> mlua::Result<Value> {
|
||||
@@ -112,16 +120,17 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// GetTradeSkillInfo(index): (name, "header", 0, isExpanded) for a header, (name, difficulty,
|
||||
// numAvailable, nil) for a recipe, one nil out of range.
|
||||
// numAvailable, nil) for a recipe, nil, nil, 0, nil out of range (`0x4fd8d0`).
|
||||
g.set(
|
||||
"GetTradeSkillInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let miss = || binding_abi::tuple(&[None, None, Some(0), None]);
|
||||
let Some(n) = index.checked_sub(1) else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
return Ok(miss());
|
||||
};
|
||||
let Some(row) = rows(&model).get(n).cloned() else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
return Ok(miss());
|
||||
};
|
||||
match row {
|
||||
Row::Header { key, name } => {
|
||||
@@ -195,7 +204,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetTradeSkillIcon",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
opt_str(lua, recipe_at(&model, index).and_then(|r| r.icon.as_ref()))
|
||||
})?,
|
||||
@@ -203,7 +212,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetTradeSkillNumMade",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let (min_made, max_made) =
|
||||
recipe_at(&model, index).map_or((0, 0), |r| (r.min_made, r.max_made));
|
||||
@@ -218,7 +227,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// (Blizzard_TradeSkillUI.lua:215).
|
||||
g.set(
|
||||
"GetTradeSkillCooldown",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(
|
||||
match recipe_at(&model, index).and_then(|r| r.cooldown_secs) {
|
||||
@@ -231,22 +240,22 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetTradeSkillNumReagents",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(recipe_at(&model, index).map_or(0, |r| r.reagents.len()) as i64)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTradeSkillReagentInfo(index, reagentIndex): one nil on a miss; name and icon are nil
|
||||
// until the item template arrives.
|
||||
// GetTradeSkillReagentInfo(index, reagentIndex): four nils on a miss (`0x4ff68b`); name and
|
||||
// icon are nil until the item template arrives.
|
||||
g.set(
|
||||
"GetTradeSkillReagentInfo",
|
||||
lua.create_function(|lua, (index, reagent_index): (usize, usize)| {
|
||||
lua.create_function(|lua, (Idx(index), Idx(reagent_index)): (Idx, Idx)| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(reagent) = recipe_at(&model, index)
|
||||
.and_then(|r| reagent_index.checked_sub(1).and_then(|n| r.reagents.get(n)))
|
||||
else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
return Ok(binding_abi::tuple(&[None; 4]));
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
opt_str(lua, reagent.name.as_ref())?,
|
||||
@@ -337,24 +346,28 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// subclass flag (`+0xc`, mask `0x84dd60`) is kept here as hidden group keys.
|
||||
g.set(
|
||||
"GetTradeSkillSubClassFilter",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, index: Option<Value>| {
|
||||
// `is-number`, then `trunc(n) - 1` as a signed index (`0x4ffdcd`): anything at or below
|
||||
// 0 asks about the whole list, one past the end raises (`0x4ffe0e`, `0x84e738`).
|
||||
let n = filter_index(lua, index, "Usage: GetTradeSkillSubClassFilter(index)")?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(t) = model.trade_skill.as_ref() else {
|
||||
return Ok(binding_abi::flag(false));
|
||||
let groups = model
|
||||
.trade_skill
|
||||
.as_ref()
|
||||
.map(|t| build_groups(&t.recipes))
|
||||
.unwrap_or_default();
|
||||
let shown = |g: &super::view::TradeSkillGroup| {
|
||||
!model.trade_skill_subclass_hidden.contains(&g.key)
|
||||
};
|
||||
let groups = build_groups(&t.recipes);
|
||||
Ok(match index.checked_sub(1) {
|
||||
None => binding_abi::flag(
|
||||
!groups
|
||||
.iter()
|
||||
.any(|g| model.trade_skill_subclass_hidden.contains(&g.key)),
|
||||
),
|
||||
Some(n) => binding_abi::flag(
|
||||
groups
|
||||
.get(n)
|
||||
.is_some_and(|g| !model.trade_skill_subclass_hidden.contains(&g.key)),
|
||||
),
|
||||
})
|
||||
if n < 0 {
|
||||
return Ok(binding_abi::flag(groups.iter().all(shown)));
|
||||
}
|
||||
match groups.get(n as usize) {
|
||||
Some(g) => Ok(binding_abi::flag(shown(g))),
|
||||
None => Err(mlua::Error::RuntimeError(
|
||||
"Bad sub class in GetTradeSkillSubClassFilter".into(),
|
||||
)),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
@@ -413,23 +426,27 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// (`0x4ffe60`).
|
||||
g.set(
|
||||
"GetTradeSkillInvSlotFilter",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, index: Option<Value>| {
|
||||
// The same shape over the present inventory-type bits (`0x500018`): at or below 0
|
||||
// asks about all of them, past the last present one raises (`0x50006e`, `0x84e838`).
|
||||
let n = filter_index(lua, index, "Usage: GetTradeSkillInvSlotFilter(index)")?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
if model.trade_skill.is_none() {
|
||||
return Ok(binding_abi::flag(false));
|
||||
let bits = if model.trade_skill.is_some() {
|
||||
present_inv_slots(&model)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let shown = |b: &u32| model.trade_skill_invslot_mask & (1 << b) != 0;
|
||||
// Everything shown: (present & mask) == present (`0x4fffd0`).
|
||||
if n < 0 {
|
||||
return Ok(binding_abi::flag(bits.iter().all(shown)));
|
||||
}
|
||||
match bits.get(n as usize) {
|
||||
Some(b) => Ok(binding_abi::flag(shown(b))),
|
||||
None => Err(mlua::Error::RuntimeError(
|
||||
"Bad inv type in GetTradeSkillInvSlotFilter".into(),
|
||||
)),
|
||||
}
|
||||
let bits = present_inv_slots(&model);
|
||||
Ok(match index.checked_sub(1) {
|
||||
// Everything shown: (present & mask) == present (`0x4fffd0`).
|
||||
None => binding_abi::flag(
|
||||
bits.iter()
|
||||
.all(|&b| model.trade_skill_invslot_mask & (1 << b) != 0),
|
||||
),
|
||||
Some(n) => binding_abi::flag(
|
||||
bits.get(n)
|
||||
.is_some_and(|&b| model.trade_skill_invslot_mask & (1 << b) != 0),
|
||||
),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
|
||||
@@ -633,6 +633,75 @@ fn subclass_filter_exclusive_narrows_list_but_not_vocabulary() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Both filter getters read `trunc(n) - 1` as a signed index: anything at or below 0 asks about
|
||||
/// the whole list (`0x4ffdcd`, `0x500018`), one past the end raises (`0x4ffe0e`, `0x50006e`), and a
|
||||
/// non-number raises the usage error.
|
||||
#[test]
|
||||
fn filter_getters_treat_a_non_positive_index_as_all_and_raise_past_the_end() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trade_skill(Some(state()));
|
||||
s.run("SetTradeSkillSubClassFilter(2, 1, 1)").unwrap();
|
||||
for idx in ["0", "-1", "-7.5", "0.5"] {
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>(&format!("return GetTradeSkillSubClassFilter({idx})"))
|
||||
.unwrap(),
|
||||
None,
|
||||
"a filtered list is not all shown at {idx}"
|
||||
);
|
||||
}
|
||||
s.run("SetTradeSkillSubClassFilter(0, 1, 1)").unwrap();
|
||||
for idx in ["0", "-1", "-7.5"] {
|
||||
assert_eq!(
|
||||
s.eval::<i64>(&format!("return GetTradeSkillSubClassFilter({idx})"))
|
||||
.unwrap(),
|
||||
1,
|
||||
"all shown at {idx}"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(&format!("return GetTradeSkillInvSlotFilter({idx})"))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
let raises = |s: &UiScript, call: &str, text: &str| {
|
||||
let err = s
|
||||
.eval::<String>(&format!(
|
||||
"local ok, e = pcall(function() return {call} end) return tostring(e)"
|
||||
))
|
||||
.unwrap();
|
||||
assert!(err.contains(text), "{call}: {err}");
|
||||
};
|
||||
// The state has four groups and two present inventory types.
|
||||
raises(
|
||||
&s,
|
||||
"GetTradeSkillSubClassFilter(5)",
|
||||
"Bad sub class in GetTradeSkillSubClassFilter",
|
||||
);
|
||||
raises(
|
||||
&s,
|
||||
"GetTradeSkillInvSlotFilter(3)",
|
||||
"Bad inv type in GetTradeSkillInvSlotFilter",
|
||||
);
|
||||
raises(
|
||||
&s,
|
||||
"GetTradeSkillSubClassFilter('x')",
|
||||
"Usage: GetTradeSkillSubClassFilter(index)",
|
||||
);
|
||||
raises(
|
||||
&s,
|
||||
"GetTradeSkillInvSlotFilter()",
|
||||
"Usage: GetTradeSkillInvSlotFilter(index)",
|
||||
);
|
||||
// With no skill window open the list is empty: all is shown, and index 1 is past the end.
|
||||
s.set_trade_skill(None);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetTradeSkillSubClassFilter(0)")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
raises(&s, "GetTradeSkillSubClassFilter(1)", "Bad sub class");
|
||||
}
|
||||
|
||||
/// Stand-in slot strings named after their tokens (`0x84dd70`): `SECONDARYHANDSLOT`,
|
||||
/// `INVTYPE_SHIELD` and `INVTYPE_WEAPONOFFHAND` all read "Off Hand" in enUS, so only the token
|
||||
/// shows which one a bit reaches.
|
||||
|
||||
@@ -8,6 +8,7 @@ use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::binding_abi;
|
||||
use super::Model;
|
||||
use crate::script::binding_abi::Idx;
|
||||
|
||||
/// A service's state (the wire's `TrainerSpellState`): green learnable, red gated, gray known.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -437,16 +438,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerServiceInfo(index) → name, subText, serviceType, isExpanded; one nil past the end.
|
||||
// GetTrainerServiceInfo(index) → name, subText, serviceType, isExpanded. Past the end the three
|
||||
// strings are nil and the fourth is 1 (`0x4d8e42`: `0x4d8c30` answers 0 for a row that is no
|
||||
// header, which pushes 1); a service row answers 1 there too, a header 1 when it is expanded.
|
||||
g.set(
|
||||
"GetTrainerServiceInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let miss = || binding_abi::tuple(&[None, None, None, Some(1)]);
|
||||
let Some(n) = index.checked_sub(1) else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
return Ok(miss());
|
||||
};
|
||||
let Some(row) = rows(&model).0.get(n).copied() else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
return Ok(miss());
|
||||
};
|
||||
let t = model
|
||||
.trainer
|
||||
@@ -458,7 +462,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let expanded = !model.trainer_collapsed.contains(&g.key);
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::String(lua.create_string(&g.name)?),
|
||||
Value::Nil,
|
||||
// The header's subText is the empty string (`0x4d8b50` returns `0x882748`
|
||||
// for a header row, pushed at `0x4d8e13`), not nil.
|
||||
Value::String(lua.create_string("")?),
|
||||
Value::String(lua.create_string("header")?),
|
||||
binding_abi::flag(expanded),
|
||||
]))
|
||||
@@ -469,7 +475,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
opt_str(lua, s.name.as_ref())?,
|
||||
opt_str(lua, s.subtext.as_ref())?,
|
||||
Value::String(lua.create_string(s.category.era_str())?),
|
||||
Value::Nil,
|
||||
Value::Integer(1),
|
||||
]))
|
||||
}
|
||||
}
|
||||
@@ -480,7 +486,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// (`0x4d9160`) is untraced; the stock `CONFIRM_PROFESSION` dialog shows it as the profession.
|
||||
g.set(
|
||||
"GetTrainerServiceSkillLine",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(n) = index.checked_sub(1) else {
|
||||
return Ok(Value::Nil);
|
||||
@@ -500,7 +506,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetTrainerServiceIcon",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
opt_str(lua, service(&model, index).and_then(|s| s.texture.as_ref()))
|
||||
})?,
|
||||
@@ -508,7 +514,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetTrainerServiceDescription",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
opt_str(
|
||||
lua,
|
||||
@@ -521,7 +527,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// is always 0, the profession cost 1 for a primary profession's first rank.
|
||||
g.set(
|
||||
"GetTrainerServiceCost",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let (money, prof) = service(&model, index)
|
||||
.map(|s| (s.cost, u32::from(s.prof_first_rank)))
|
||||
@@ -536,7 +542,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetTrainerServiceLevelReq",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(service(&model, index).map_or(0, |s| s.level_req) as i64)
|
||||
})?,
|
||||
@@ -544,10 +550,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetTrainerServiceSkillReq",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
// No skill requirement, or no such row: nil, 0, 1 (`0x4d95bc`).
|
||||
let Some(req) = service(&model, index).and_then(|s| s.skill_req.as_ref()) else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
return Ok(binding_abi::tuple(&[None, Some(0), Some(1)]));
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::String(lua.create_string(&req.name)?),
|
||||
@@ -559,7 +566,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetTrainerServiceNumAbilityReq",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(service(&model, index).map_or(0, |s| s.ability_reqs.len()) as i64)
|
||||
})?,
|
||||
@@ -567,12 +574,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"GetTrainerServiceAbilityReq",
|
||||
lua.create_function(|lua, (index, i): (usize, usize)| {
|
||||
lua.create_function(|lua, (Idx(index), Idx(i)): (Idx, Idx)| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(req) = service(&model, index)
|
||||
.and_then(|s| i.checked_sub(1).and_then(|n| s.ability_reqs.get(n)))
|
||||
else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
// `0x4d98b5`: nil, 1.
|
||||
return Ok(binding_abi::tuple(&[None, Some(1)]));
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::String(lua.create_string(&req.name)?),
|
||||
@@ -581,15 +589,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerServiceStepReq(index) → step, met: always nil, as the wire carries no step data.
|
||||
// GetTrainerServiceStepReq(index) → step, met: nil, 1, as the wire carries no step data. That
|
||||
// is the reference's tuple for a row with no step spell and for a miss (`0x4d9afc`).
|
||||
g.set(
|
||||
"GetTrainerServiceStepReq",
|
||||
lua.create_function(|_, _index: usize| Ok(Value::Nil))?,
|
||||
lua.create_function(|_, Idx(_index): Idx| Ok(binding_abi::tuple(&[None, Some(1)])))?,
|
||||
)?;
|
||||
|
||||
g.set(
|
||||
"IsTrainerServiceTradeSkill",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(binding_abi::flag(
|
||||
service(&model, index).is_some_and(|s| s.is_trade_skill),
|
||||
@@ -601,7 +610,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// tradeskill is a learn spell; isPetLearnSpell is not built and always answers nil.
|
||||
g.set(
|
||||
"IsTrainerServiceLearnSpell",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
lua.create_function(|lua, Idx(index): Idx| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let learn = service(&model, index).is_some_and(|s| !s.is_trade_skill);
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
|
||||
@@ -90,7 +90,8 @@ fn tree_interleaves_headers_and_ordered_services() {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((hn.as_str(), ht.as_str()), ("Arms", "header"));
|
||||
assert_eq!((hsub, hexp), (None, Some(1)));
|
||||
// The header's subText is the empty string (`0x4d8b50`), not nil.
|
||||
assert_eq!((hsub.as_deref(), hexp), (Some(""), Some(1)));
|
||||
|
||||
let info = |s: &mut UiScript, i: i64| {
|
||||
s.eval::<(String, String)>(&format!(
|
||||
@@ -107,6 +108,27 @@ fn tree_interleaves_headers_and_ordered_services() {
|
||||
assert_eq!(info(&mut s, 5), ("Bloodrage".into(), "unavailable".into()));
|
||||
}
|
||||
|
||||
/// The fourth value of a service row is 1 (`0x4d8e42`: the header test `0x4d8c30` answers 0 for a
|
||||
/// row that is no header, which pushes 1), not nil; a collapsed header's is nil.
|
||||
#[test]
|
||||
fn a_service_row_answers_one_for_its_fourth_value() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trainer(Some(trainer()));
|
||||
let fourth = |s: &mut UiScript, row: u32| -> (i64, Option<i64>) {
|
||||
s.eval(&format!(
|
||||
"local n = function(...) return arg.n, arg[4] end return n(GetTrainerServiceInfo({row}))"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
// Rows: Arms header, Heroic Strike, Cleave, Fury header, Bloodrage.
|
||||
assert_eq!(fourth(&mut s, 1), (4, Some(1)));
|
||||
for service in [2, 3, 5] {
|
||||
assert_eq!(fourth(&mut s, service), (4, Some(1)), "row {service}");
|
||||
}
|
||||
s.run("CollapseTrainerSkillLine(1)").unwrap();
|
||||
assert_eq!(fourth(&mut s, 1), (4, None), "a collapsed header");
|
||||
}
|
||||
|
||||
/// `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.
|
||||
|
||||
@@ -870,7 +870,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
.and_then(|i| model.worldmap.landmarks.get(i).cloned())
|
||||
};
|
||||
let Some(l) = landmark else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
// `0x4a88b3`: nil, nil, 0, 0, 0.
|
||||
return Ok(super::binding_abi::tuple(&[
|
||||
None,
|
||||
None,
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(0),
|
||||
]));
|
||||
};
|
||||
let description = match l.description.is_empty() {
|
||||
true => Value::Nil,
|
||||
@@ -908,7 +915,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
.and_then(|i| revealed_overlays(&model.worldmap).get(i).cloned().cloned())
|
||||
};
|
||||
let Some(o) = overlay else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
// `0x4a8b53`: nil, then six 0.
|
||||
return Ok(super::binding_abi::tuple(&[
|
||||
None,
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(0),
|
||||
]));
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::String(lua.create_string(&o.texture)?),
|
||||
|
||||
Reference in New Issue
Block a user