Lua getters answer what their 1.12.1 handlers answer (#2663)
Lua getters that answered what 1.12.1 never does now read what their handlers read: - **Bind area.** GetBindLocation and the `$z` spell token fall back to HOME_INN (`0x507f3a`). - **Craft.** GetCraftName answers the trade-skill slot by craft type (`0x4f601c`). - **Trainer.** The skill line and requirement come from the service's spell (`0x4d9261`, `0x4d94d6`). - **Map.** GetPlayerMapPosition takes any unit token; GetMapInfo answers its sizes. - **Mail.** GetInboxItem gates by quality and misses uncached items (`0x4af721`). - **Pets.** Stabled pets pack in list order, the summoned one by its byte's bit 1 (`0x4cabe3`). UnitCreatureFamily reads any unit's record. - **Gates.** HasFullControl and UseAction gate as the client's do; ItemTextGetItem answers nil with no text open. Each fix has a test. Fixes #2475 Fixes #2497 Fixes #2499 Fixes #2508 Fixes #2509 Fixes #2510 Fixes #2511 Fixes #2513 Fixes #2514 Fixes #2515 Fixes #2516 Fixes #2519 Fixes #2523 Fixes #2524 Fixes #2525 Fixes #2528 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1133,7 +1133,7 @@ fn seat_a_session(script: &mut UiScript) {
|
||||
});
|
||||
script.set_realm_name("Harness");
|
||||
// The bind point: the server sends `SMSG_BINDPOINTUPDATE` at login, before any addon runs.
|
||||
script.set_bind_location("Stormwind City");
|
||||
script.set_bind_location(Some("Stormwind City"));
|
||||
script.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
|
||||
@@ -176,8 +176,9 @@ fn the_gossip_getters_read_the_world_at_the_call() {
|
||||
"Hello,Browse,2,A Threat Within"
|
||||
);
|
||||
assert_eq!(
|
||||
c.eval::<Option<String>>(false, "return GetGossipText()"),
|
||||
None
|
||||
c.eval::<String>(false, "return GetGossipText()"),
|
||||
"",
|
||||
"with no host to ask, the empty buffer, never nil"
|
||||
);
|
||||
// The next message's slots read at once; its text, still out, leaves the last.
|
||||
c.handle(vec![menu(8, "Fly")]);
|
||||
|
||||
@@ -127,7 +127,7 @@ impl FarSight {
|
||||
}
|
||||
|
||||
/// Whether far sight is on (`+0x1c70` bit 0).
|
||||
fn on(&self) -> bool {
|
||||
pub(crate) fn on(&self) -> bool {
|
||||
self.state().on
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ pub(crate) struct UnitLive<'w, 's> {
|
||||
names: Option<Res<'w, crate::names::NameCache>>,
|
||||
commands: Option<Res<'w, crate::net::NetCommands>>,
|
||||
tables: crate::ui_unit::NameTables<'w>,
|
||||
families: Option<Res<'w, crate::ui_pet_stats::PetFamilyTables>>,
|
||||
}
|
||||
|
||||
impl UnitLive<'_, '_> {
|
||||
@@ -174,6 +175,7 @@ impl UnitLive<'_, '_> {
|
||||
names: self.names.as_deref(),
|
||||
commands: self.commands.as_deref(),
|
||||
tables: self.tables.get(),
|
||||
families: self.families.as_deref(),
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
@@ -891,3 +891,60 @@ fn a_teardowns_strike_cancel_reports_before_the_target_change() {
|
||||
run(&mut f, r#"ClearTarget() SEEN = table.concat(ORDER, ",")"#);
|
||||
assert_eq!(seen(&mut f), "SPELLCAST_STOP,PLAYER_TARGET_CHANGED");
|
||||
}
|
||||
|
||||
/// `UnitCreatureFamily("target")` (`0x51a310`) reads the held unit's cached creature record
|
||||
/// (`0x6055e0`: `[[unit+0xb30]+0x1c]`) through `CreatureFamily.dbc` inside its call, though no
|
||||
/// snapshot carries the word; a unit whose record the cache lacks answers nil.
|
||||
#[test]
|
||||
fn a_creature_family_is_read_off_the_held_units_record() {
|
||||
const OBJECT_FIELD_TYPE: u16 = 2;
|
||||
const OBJECT_FIELD_ENTRY: u16 = 3;
|
||||
const BOAR: u32 = 1_126;
|
||||
let mut f = frame(false);
|
||||
// A unit object (`TYPEMASK_OBJECT | TYPEMASK_UNIT`) of the boar's template.
|
||||
spawn_mob(
|
||||
&mut f,
|
||||
&[(OBJECT_FIELD_TYPE, 0x9), (OBJECT_FIELD_ENTRY, BOAR)],
|
||||
);
|
||||
f.app.world_mut().resource_mut::<Selection>().guid = Some(MOB);
|
||||
settle(&mut f);
|
||||
let family =
|
||||
|f: &mut Frame| eval::<Option<String>>(f, r#"return UnitCreatureFamily("target")"#);
|
||||
assert_eq!(family(&mut f), None, "no record cached yet");
|
||||
|
||||
let world = f.app.world_mut();
|
||||
world
|
||||
.resource_mut::<crate::names::NameCache>()
|
||||
.insert_creature(
|
||||
BOAR,
|
||||
Some(crate::names::CreatureRecord {
|
||||
name: "Stonetusk Boar".into(),
|
||||
subname: None,
|
||||
creature_type: 1,
|
||||
pet_family: 5,
|
||||
rank: 0,
|
||||
type_flags: 0,
|
||||
pet_spell_list: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 607,
|
||||
}),
|
||||
);
|
||||
world.insert_resource(crate::ui_pet_stats::PetFamilyTables {
|
||||
families: benilla_formats::CreatureFamilies::from_rows([(
|
||||
5,
|
||||
benilla_formats::CreatureFamily {
|
||||
name: "Boar".into(),
|
||||
icon: "Interface\\Icons\\Ability_Hunter_Pet_Boar".into(),
|
||||
pet_food_mask: 0,
|
||||
min_scale: 1.0,
|
||||
min_scale_level: 0,
|
||||
max_scale: 1.0,
|
||||
max_scale_level: 0,
|
||||
skill_lines: [0, 0],
|
||||
},
|
||||
)]),
|
||||
foods: Default::default(),
|
||||
});
|
||||
assert_eq!(family(&mut f).as_deref(), Some("Boar"));
|
||||
}
|
||||
|
||||
@@ -693,9 +693,10 @@ fn spell_book(
|
||||
actions.spells.clear();
|
||||
actions.profession_order.clear();
|
||||
actions.craft_order.clear();
|
||||
actions.effect_slots = Default::default();
|
||||
for spell_id in spell_ids {
|
||||
actions.spells.insert(spell_id);
|
||||
list_learn(spell_id, actions);
|
||||
list_learn(spell_id, actions, spells);
|
||||
}
|
||||
actions.dirty = true;
|
||||
let now = Instant::now();
|
||||
@@ -731,18 +732,32 @@ fn learned_spell(
|
||||
if actions.spells.insert(spell_id) {
|
||||
actions.dirty = true;
|
||||
}
|
||||
list_learn(spell_id, actions);
|
||||
list_learn(spell_id, actions, spells);
|
||||
register_in_book(spell_id, spells, actions);
|
||||
announce_learn(spell_id, spells, errors);
|
||||
}
|
||||
|
||||
/// `LearnSpell 0x5e9c20`'s list legs: the profession lists append with no duplicate check
|
||||
/// (`0x5e9d72`), the craft lists only a spell they lack (`0x5e9db3`, `0x5e9e0f`).
|
||||
fn list_learn(spell_id: u32, actions: &mut PlayerActions) {
|
||||
/// (`0x5e9d72`), the craft lists only a spell they lack (`0x5e9db3`, `0x5e9e0f`); and a spell with
|
||||
/// no `Attributes & 0x20`, no cast UI and `SPELL_EFFECT_TRADE_SKILL` first takes the player's
|
||||
/// effect slot its misc value names (`0x5e9c8e`, `0x5e9d88`, `0x5e9e1c`-`0x5e9e36`), the one write
|
||||
/// of `CGPlayer_C+0x1d10`; its reader indexes below 4 (`0x5ea360`).
|
||||
fn list_learn(spell_id: u32, actions: &mut PlayerActions, spells: Option<&Spells>) {
|
||||
actions.profession_order.push(spell_id);
|
||||
if !actions.craft_order.contains(&spell_id) {
|
||||
actions.craft_order.push(spell_id);
|
||||
}
|
||||
let slot = spells.and_then(|s| s.catalog.get(spell_id)).and_then(|d| {
|
||||
let opens_window = d.attributes & benilla_formats::SPELL_ATTR_IS_TRADESKILL == 0
|
||||
&& (d.cast_ui as i32) <= 0
|
||||
&& d.effects[0] == benilla_formats::SPELL_EFFECT_TRADE_SKILL;
|
||||
usize::try_from(d.effect_misc_value[0])
|
||||
.ok()
|
||||
.filter(|&misc| opens_window && misc < 4)
|
||||
});
|
||||
if let Some(slot) = slot {
|
||||
actions.effect_slots[slot] = Some(spell_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// `UnlearnSpell 0x5e9fe0`'s list legs: the profession lists' backward scan takes out the last
|
||||
@@ -881,7 +896,7 @@ fn superceded_spell(
|
||||
actions.spells.remove(&old_spell_id);
|
||||
list_unlearn(old_spell_id, actions);
|
||||
actions.spells.insert(new_spell_id);
|
||||
list_learn(new_spell_id, actions);
|
||||
list_learn(new_spell_id, actions, spells);
|
||||
actions.superseded.push((old_spell_id, new_spell_id));
|
||||
actions.dirty = true;
|
||||
// `0x4b2f50` unlists the old rank and registers the new one at the tail; the player's own
|
||||
|
||||
@@ -81,3 +81,56 @@ fn the_known_spells_keep_their_learn_order() {
|
||||
superceded_spell(A, B, &mut actions, Some(&spells), &errors);
|
||||
assert_eq!(actions.profession_order, [C, D, B]);
|
||||
}
|
||||
|
||||
/// `LearnSpell` (`0x5e9c20`) writes `CGPlayer_C+0x1d10[misc]` for a spell with no `Attributes &
|
||||
/// 0x20`, no cast UI, `SPELL_EFFECT_TRADE_SKILL` first and a misc value below 4 (`0x5e9e1c`-
|
||||
/// `0x5e9e36`): the last such learn holds the slot, an unlearn clears nothing, and the book's
|
||||
/// replay empties them.
|
||||
#[test]
|
||||
fn a_trade_skill_opener_learn_fills_its_effect_slot() {
|
||||
let opener = |misc: i32, attributes: u32, cast_ui: u32| benilla_formats::SpellDisplay {
|
||||
attributes,
|
||||
cast_ui,
|
||||
effects: [benilla_formats::SPELL_EFFECT_TRADE_SKILL, 0, 0],
|
||||
effect_misc_value: [misc, 0, 0],
|
||||
..Default::default()
|
||||
};
|
||||
let mut spells = Spells::empty_for_tests();
|
||||
spells.catalog = benilla_formats::SpellCatalog::from_displays(
|
||||
[
|
||||
(10, opener(0, 0, 0)),
|
||||
(11, opener(0, 0, 0)),
|
||||
(12, opener(3, 0, 0)),
|
||||
(13, opener(1, benilla_formats::SPELL_ATTR_IS_TRADESKILL, 0)),
|
||||
(14, opener(2, 0, 1)),
|
||||
(15, opener(4, 0, 0)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
let mut actions = PlayerActions::default();
|
||||
let errors = UiErrorKeys::default();
|
||||
for id in [10, 12, 13, 14, 15, 11] {
|
||||
learned_spell(id, &mut actions, Some(&spells), &errors);
|
||||
}
|
||||
assert_eq!(actions.effect_slots, [Some(11), None, None, Some(12)]);
|
||||
removed_spell(11, &mut actions, Some(&spells), &errors);
|
||||
assert_eq!(
|
||||
actions.effect_slots[0],
|
||||
Some(11),
|
||||
"an unlearn leaves the slot"
|
||||
);
|
||||
spell_book(
|
||||
vec![10],
|
||||
vec![],
|
||||
&mut actions,
|
||||
&mut Cooldowns::default(),
|
||||
(
|
||||
Some(&spells),
|
||||
&crate::items::Items::default(),
|
||||
&crate::spell::SpellModifiers::default(),
|
||||
crate::spell::CategoryPad::for_list(false, None),
|
||||
),
|
||||
);
|
||||
assert_eq!(actions.effect_slots, [Some(10), None, None, None]);
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ pub(crate) struct PlayerActions {
|
||||
/// The order the craft types' lists hold theirs (`+0x1cd0`): a learn appends a spell the list
|
||||
/// lacks (`0x5e9db3`, `0x5e9e0f`), an unlearn takes out its first copy (`0x5ea1b8`).
|
||||
pub craft_order: Vec<u32>,
|
||||
/// `CGPlayer_C+0x1d10`, the spell each effect slot holds (`0x5ea360`): written by a learn
|
||||
/// (`list_learn`), never cleared by an unlearn, emptied with the book.
|
||||
pub effect_slots: [Option<u32>; 4],
|
||||
/// `[0xb700b0]`: the known spells whose `Effect[0]` is `SPELL_EFFECT_OPEN_LOCK`, in learn
|
||||
/// order. The login list enters in wire order (`0x5dea8b`-`0x5deab9`), each learn appends at
|
||||
/// the tail with no duplicate check (`0x4b2665`-`0x4b2689`), and an unlearn removes the last
|
||||
|
||||
@@ -95,6 +95,7 @@ mod tests {
|
||||
spells: spells.iter().copied().collect(),
|
||||
profession_order: spells.to_vec(),
|
||||
craft_order: spells.to_vec(),
|
||||
effect_slots: Default::default(),
|
||||
openers: Vec::new(),
|
||||
stuck_spell: None,
|
||||
superseded: Vec::new(),
|
||||
|
||||
@@ -271,6 +271,13 @@ fn feed_craft(
|
||||
return;
|
||||
};
|
||||
let memo = memo.get(script);
|
||||
// `GetCraftName` reads the player's effect slot of the open type, 0 when closed (`0x4f6c32`).
|
||||
let slot_names = self_store
|
||||
.single()
|
||||
.ok()
|
||||
.and_then(|_| Some(effect_slot_names(&actions, spells.as_deref()?)))
|
||||
.unwrap_or_default();
|
||||
script.set_craft_slot_names(slot_names);
|
||||
// The list only reads the item cache: the build asks nothing (`0x4f60c0`), and a reagent
|
||||
// or a totem is asked by its getter's call, inside it (`0x4f75f2`, `0x4f7a0a`).
|
||||
let fresh = (|| -> Option<CraftState> {
|
||||
@@ -285,6 +292,10 @@ fn feed_craft(
|
||||
.map(|l| l.name.clone())
|
||||
.unwrap_or_else(|| format!("Skill {line}"));
|
||||
let global = |key: &str| benilla_ui::strings::global(script.lua(), key);
|
||||
// The `$z` token: the bind area the items feed pushed for `GetBindLocation`, so the
|
||||
// two agree (`0x507f07`-`0x507f45`, `0x48dae0`).
|
||||
let bind = script.bind_location();
|
||||
let home_area: Option<&str> = bind.as_deref();
|
||||
// The player's level for a spell and its cost (`0x6e3130`, `0x6e31b0`, `0x6e3300`).
|
||||
let skills = crate::spell::PlayerSkills {
|
||||
lines: Some(&skill_lines.catalog),
|
||||
@@ -323,7 +334,7 @@ fn feed_craft(
|
||||
},
|
||||
unmodified_points: false,
|
||||
gender: &|| store.0.unit_gender().unwrap_or(0),
|
||||
home_area: &|| None,
|
||||
home_area: &|| home_area,
|
||||
global: &global,
|
||||
printf: &crate::ui_script::token_printf,
|
||||
};
|
||||
@@ -508,6 +519,16 @@ fn feed_craft(
|
||||
})
|
||||
}
|
||||
|
||||
/// The names of the spells in the player's four effect slots (`CGPlayer_C+0x1d10`, `0x5ea360`),
|
||||
/// which `GetCraftName` reads at the open craft type (`0x4f6c32`); a learn writes them
|
||||
/// ([`PlayerActions::effect_slots`]), an unlearn leaves them.
|
||||
fn effect_slot_names(actions: &PlayerActions, spells: &Spells) -> [Option<String>; 4] {
|
||||
actions.effect_slots.map(|slot| {
|
||||
slot.and_then(|id| spells.catalog.get(id))
|
||||
.map(|d| d.name.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// What [`feed_craft`] remembers from its last frame.
|
||||
#[derive(Default)]
|
||||
struct CraftMemo {
|
||||
@@ -767,6 +788,77 @@ mod tests {
|
||||
assert_eq!(read(39, 40), (0, false), "the bank is outside mask 0");
|
||||
}
|
||||
|
||||
/// A craft description's `$z` reads the bind area like every spell text (`0x507f07`-
|
||||
/// `0x507f45`): the area's name once the bind point resolves, the `HOME_INN` text before.
|
||||
#[test]
|
||||
fn a_craft_description_names_the_bind_area_or_the_inn() {
|
||||
const SPELL: u32 = 7_001;
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
let mut actions = PlayerActions::default();
|
||||
actions.spells.insert(SPELL);
|
||||
actions.profession_order.push(SPELL);
|
||||
actions.craft_order.push(SPELL);
|
||||
app.init_resource::<CraftOpen>()
|
||||
.init_resource::<CraftItemWaits>()
|
||||
.insert_resource(actions)
|
||||
.init_resource::<crate::spell::SpellModifiers>()
|
||||
.init_resource::<crate::net::GuidIndex>()
|
||||
.init_resource::<Items>()
|
||||
.insert_resource(NetCommands(tx))
|
||||
.insert_resource(crate::ui_action::Spells {
|
||||
catalog: benilla_formats::SpellCatalog::from_displays(
|
||||
[(
|
||||
SPELL,
|
||||
SpellDisplay {
|
||||
name: "Enchant".into(),
|
||||
cast_ui: 3,
|
||||
description: Some("Returns you to $z.".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
..crate::ui_action::Spells::empty_for_tests()
|
||||
})
|
||||
.insert_resource(SkillLines {
|
||||
catalog: benilla_formats::SkillLineCatalog::from_abilities([(
|
||||
SPELL,
|
||||
benilla_formats::SlaInfo {
|
||||
skill_id: 333,
|
||||
req_skill_value: 1,
|
||||
forward_spell_id: 0,
|
||||
trivial_low: 1,
|
||||
trivial_high: 2,
|
||||
},
|
||||
)]),
|
||||
});
|
||||
app.world_mut()
|
||||
.spawn((SelfPlayer, ObjectStore(Default::default())));
|
||||
let script = UiScript::new().unwrap();
|
||||
script.run("HOME_INN = 'your inn'").unwrap();
|
||||
app.insert_non_send_resource(script);
|
||||
app.add_systems(Update, feed_craft);
|
||||
{
|
||||
let mut open = app.world_mut().resource_mut::<CraftOpen>();
|
||||
open.line = Some(333);
|
||||
open.craft_type = 3;
|
||||
}
|
||||
let described = |app: &mut App| {
|
||||
app.update();
|
||||
app.world()
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval::<String>("return GetCraftDescription(1)")
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(described(&mut app), "Returns you to your inn.");
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.set_bind_location(Some("Elwynn Forest"));
|
||||
assert_eq!(described(&mut app), "Returns you to Elwynn Forest.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_end_closes_the_craft_window() {
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -559,7 +559,7 @@ pub(super) fn feed_item_stats(
|
||||
.and_then(|id| area_names.as_deref()?.0.resolve(id as i32));
|
||||
// Pushed on change, per VM: a rebuilt VM is fed again the frame it appears.
|
||||
if last_home.as_deref() != home_area {
|
||||
script.set_bind_location(home_area.unwrap_or_default());
|
||||
script.set_bind_location(home_area);
|
||||
*last_home = home_area.map(str::to_string);
|
||||
// Re-substitute every held view, or one pushed before the bind point carries a raw
|
||||
// `$z`.
|
||||
|
||||
@@ -385,8 +385,15 @@ fn resolve_row(
|
||||
let player_mail = entry.message_type == mail_message_type::NORMAL;
|
||||
let can_reply = player_mail && !is_gm;
|
||||
|
||||
let (item_id, item_roll, item_count, item_name, item_texture, item_quality) = match &entry.item
|
||||
{
|
||||
let (
|
||||
item_id,
|
||||
item_roll,
|
||||
item_count,
|
||||
item_name,
|
||||
item_texture,
|
||||
item_quality,
|
||||
item_inventory_type,
|
||||
) = match &entry.item {
|
||||
Some(att) => {
|
||||
// The row's lookup, whose miss asks; then what `GetInboxItem` reads of the record as
|
||||
// it runs, read the same way.
|
||||
@@ -404,9 +411,10 @@ fn resolve_row(
|
||||
live.name(),
|
||||
live.texture(),
|
||||
live.quality(),
|
||||
live.inventory_type(),
|
||||
)
|
||||
}
|
||||
None => (0, 0, 0, None, None, None),
|
||||
None => (0, 0, 0, None, None, None, 0),
|
||||
};
|
||||
// The block's charges and `(current, max)` durability (`0x53566f`, `0x535699`, `0x53568d`).
|
||||
let (item_charges, item_durability) = entry.item.as_ref().map_or((0, (0, 0)), |att| {
|
||||
@@ -542,6 +550,7 @@ fn resolve_row(
|
||||
item_name,
|
||||
item_texture,
|
||||
item_quality,
|
||||
item_inventory_type,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ pub(crate) fn stats_for(
|
||||
tables: Option<&PetStatTables>,
|
||||
family: (Option<String>, Option<String>, Vec<String>),
|
||||
) -> (bool, PetStats) {
|
||||
let (family, icon, food_types) = family;
|
||||
let (_, icon, food_types) = family;
|
||||
let Some(fields) = pet.map(|s| &s.0) else {
|
||||
return (false, PetStats::default());
|
||||
};
|
||||
@@ -124,13 +124,12 @@ pub(crate) fn stats_for(
|
||||
&& self_store.map(|s| ((s.0.unit_bytes_0().unwrap_or(0) >> 8) & 0xff) as u8)
|
||||
== Some(CLASS_HUNTER);
|
||||
if !hunter {
|
||||
// The family word passes the gate and the diet does not (module doc): a mind-controlled
|
||||
// boar under a non-hunter (family 5, food mask 63) gets "Boar" and no diet.
|
||||
// The icon passes the gate and the diet does not (module doc): a mind-controlled boar
|
||||
// under a non-hunter (family 5, food mask 63) gets no diet.
|
||||
return (
|
||||
has_ui,
|
||||
PetStats {
|
||||
family,
|
||||
// The icon passes with the word; whether the reference gates it is untraced.
|
||||
// Whether the reference gates the icon is untraced.
|
||||
icon,
|
||||
..PetStats::default()
|
||||
},
|
||||
@@ -158,7 +157,6 @@ pub(crate) fn stats_for(
|
||||
}),
|
||||
training_points: fields.unit_training_points(),
|
||||
experience: fields.unit_pet_experience(),
|
||||
family,
|
||||
icon,
|
||||
food_types,
|
||||
},
|
||||
@@ -489,7 +487,6 @@ mod tests {
|
||||
(Some("Boar".into()), None, boar_diet.clone()),
|
||||
);
|
||||
assert!(!s.hunter_pet);
|
||||
assert_eq!(s.family.as_deref(), Some("Boar"), "the word is ungated");
|
||||
assert!(
|
||||
s.food_types.is_empty(),
|
||||
"…but the diet shares 0x6116e0 with the stats"
|
||||
@@ -504,7 +501,6 @@ mod tests {
|
||||
Some(&t),
|
||||
(Some("Boar".into()), None, boar_diet.clone()),
|
||||
);
|
||||
assert_eq!(s.family.as_deref(), Some("Boar"));
|
||||
assert_eq!(s.food_types, boar_diet);
|
||||
}
|
||||
|
||||
@@ -516,7 +512,6 @@ mod tests {
|
||||
None,
|
||||
(Some("Imp".into()), None, vec![]),
|
||||
);
|
||||
assert_eq!(s.family, None);
|
||||
assert!(s.food_types.is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ fn session() -> UiScript {
|
||||
// No engine behind it: `RaidFrame`'s `RequestRaidInfo()` at world entry is kept.
|
||||
s.keep_calls();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`).
|
||||
s.seat_player_guid(0x5E1F);
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(UnitState {
|
||||
@@ -519,6 +521,11 @@ fn the_battleground_blips_and_the_flag_follow_the_position_family() {
|
||||
}),
|
||||
1.0,
|
||||
);
|
||||
s.set_unit_guids(&benilla_ui::script::UnitGuids {
|
||||
player: 0x5E1F,
|
||||
party: [0x11, 0x12, 0, 0],
|
||||
..Default::default()
|
||||
});
|
||||
s.set_world_map_feed(
|
||||
Some((1, 1)),
|
||||
Some((0.5, 0.25)),
|
||||
|
||||
@@ -47,6 +47,7 @@ fn pet_unit() -> UnitState {
|
||||
max_health: 1000,
|
||||
level: 58,
|
||||
combat: pet_combat_stats(),
|
||||
family: Some("Boar".into()),
|
||||
..UnitState::default()
|
||||
}
|
||||
}
|
||||
@@ -63,7 +64,6 @@ fn hunter_pet_stats() -> PetStats {
|
||||
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
|
||||
training_points: (170, 130),
|
||||
experience: (4200, 8000),
|
||||
family: Some("Boar".into()),
|
||||
food_types: ["Meat", "Fish", "Cheese", "Bread", "Fungus", "Fruit"]
|
||||
.map(String::from)
|
||||
.to_vec(),
|
||||
@@ -306,13 +306,16 @@ fn the_level_line_names_the_family_and_is_untouched_without_one() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let with_family = |family: Option<String>| {
|
||||
let mut s = load_pet_page();
|
||||
s.set_unit("pet", Some(pet_unit()));
|
||||
s.set_unit(
|
||||
"pet",
|
||||
Some(UnitState {
|
||||
family,
|
||||
..pet_unit()
|
||||
}),
|
||||
);
|
||||
s.set_pet_stats(
|
||||
true,
|
||||
PetStats {
|
||||
family,
|
||||
..PetStats::default() // hunter_pet false: a warlock's minion
|
||||
},
|
||||
PetStats::default(), // hunter_pet false: a warlock's minion
|
||||
);
|
||||
s.fire_event("PET_BAR_UPDATE", vec![]);
|
||||
s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap();
|
||||
|
||||
@@ -58,6 +58,8 @@ fn pet(name: Option<&str>) -> UnitState {
|
||||
max_health: 1000,
|
||||
level: 58,
|
||||
guid: 0xF140_0000_0000_0001,
|
||||
// `UnitCreatureFamily("pet")`, which `PetStable.lua:129` concatenates.
|
||||
family: Some("Boar".into()),
|
||||
..UnitState::default()
|
||||
}
|
||||
}
|
||||
@@ -69,7 +71,6 @@ fn hunter_pet_stats() -> PetStats {
|
||||
icon: Some(r"Interface\Icons\Ability_Hunter_Pet_Boar".into()),
|
||||
hunter_pet: true,
|
||||
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
|
||||
family: Some("Boar".into()),
|
||||
..PetStats::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ fn service(
|
||||
skill_step: false,
|
||||
step_increase: None,
|
||||
line_name: Some(line_name.into()),
|
||||
skill_line: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,9 +37,14 @@ fn harness() -> UiScript {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
// A world position here is already map UV, so a landmark source sits where it says.
|
||||
s.set_world_loc_projector(Box::new(|_, _, x, y| Some((x, y))));
|
||||
// `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`).
|
||||
s.seat_player_guid(PLAYER);
|
||||
s
|
||||
}
|
||||
|
||||
/// The active player's guid, which the position verb resolves `"player"` to.
|
||||
const PLAYER: u64 = 0x5E1F;
|
||||
|
||||
/// The map arrow's file facts, as the app hands them over: `MinimapArrow.m2`, one looping 3.333 s
|
||||
/// Stand keying no bone, header box x in [-0.0127, 0.0135], y in [-0.0118, 0.0145].
|
||||
fn arrow_facts(s: &mut UiScript) {
|
||||
@@ -255,6 +260,11 @@ fn party_blips_sit_at_their_map_positions_and_hide_when_absent() {
|
||||
}
|
||||
|
||||
// Two members on the map and one between them off it: the app pushes None for no position.
|
||||
s.set_unit_guids(&benilla_ui::script::UnitGuids {
|
||||
player: PLAYER,
|
||||
party: [0x11, 0x12, 0x13, 0],
|
||||
..Default::default()
|
||||
});
|
||||
s.set_world_map_feed(
|
||||
None,
|
||||
Some((0.5, 0.5)),
|
||||
@@ -313,6 +323,8 @@ fn the_player_arrow_is_the_stock_model_pane_seated_and_turned_by_the_update() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
// `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`).
|
||||
s.seat_player_guid(PLAYER);
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
@@ -465,6 +477,8 @@ fn the_maps_own_furniture_survives_the_hide_that_showing_it_performs() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
// `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`).
|
||||
s.seat_player_guid(PLAYER);
|
||||
// A player exists before the manifest loads: the UI loads on world entry, and the macro
|
||||
// window's OnLoad formats `UnitName("player")`.
|
||||
s.set_unit(
|
||||
@@ -581,6 +595,8 @@ fn the_player_arrow_draws_over_the_zones_explored_overlays() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
// `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`).
|
||||
s.seat_player_guid(PLAYER);
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
@@ -639,6 +655,8 @@ fn a_click_over_a_blip_still_answers_the_maps_uv() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
// `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`).
|
||||
s.seat_player_guid(PLAYER);
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
@@ -822,6 +840,8 @@ fn a_stale_layout_row_cannot_seat_a_stock_frame() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
// `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`).
|
||||
s.seat_player_guid(PLAYER);
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
@@ -912,6 +932,8 @@ fn the_fullscreen_quads_follow_a_resize() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
// `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`).
|
||||
s.seat_player_guid(PLAYER);
|
||||
// A player exists by the time the manifest loads.
|
||||
s.set_unit(
|
||||
"player",
|
||||
@@ -963,6 +985,8 @@ fn a_reopened_map_shows_the_players_zone_before_its_first_paint() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
// `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`).
|
||||
s.seat_player_guid(PLAYER);
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
|
||||
@@ -23,6 +23,11 @@ struct Stable {
|
||||
/// The whole client, headless, with us streamed (`summon` our summoned pet's guid, or none), a
|
||||
/// stable open on its master with one slot bought and pet 3 in it, and 1g in the purse.
|
||||
fn client(summon: u64) -> Stable {
|
||||
client_in_slot(summon, 1)
|
||||
}
|
||||
|
||||
/// [`client`] with pet 3 in stable slot `slot` (1 or 2, the rebased wire byte).
|
||||
fn client_in_slot(summon: u64, slot: u8) -> Stable {
|
||||
let mut app = crate::game_plugins::schedule_tests::headless_client_unrun();
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
app.insert_resource(NetCommands(tx));
|
||||
@@ -56,7 +61,7 @@ fn client(summon: u64) -> Stable {
|
||||
level: 10,
|
||||
name: "Boar".into(),
|
||||
loyalty: 1,
|
||||
slot: 1,
|
||||
slot,
|
||||
}],
|
||||
);
|
||||
// The boar's template and its family's icon, which the pickup needs (`0x4950c0`).
|
||||
@@ -147,6 +152,62 @@ fn the_stable_verbs_send_inside_their_calls() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The stabled pets pack in list order (`0x4cab10`, the array `0xb720c8`): with only the second
|
||||
/// stable slot occupied, which happens after the first pet is taken out because the server does not
|
||||
/// renumber, the pet is the first box and `UnstablePet(1)` takes it; the second box is empty.
|
||||
#[test]
|
||||
fn a_pet_in_the_second_slot_is_the_first_box() {
|
||||
let mut s = client_in_slot(0, 2);
|
||||
let name = |s: &mut Stable, i: u32| -> Option<String> {
|
||||
with_hosted_vm(s.app.world_mut(), |vm| {
|
||||
vm.expect("a VM")
|
||||
.eval(&format!("local _, n = GetStablePetInfo({i}) return n"))
|
||||
.unwrap()
|
||||
})
|
||||
};
|
||||
assert_eq!(name(&mut s, 1).as_deref(), Some("Boar"));
|
||||
assert_eq!(name(&mut s, 2), None);
|
||||
run(&mut s, "UnstablePet(2) UnstablePet(1)");
|
||||
assert_eq!(
|
||||
sent(&s),
|
||||
lines(&[ClientCommand::UnstablePet {
|
||||
npc: MASTER,
|
||||
pet_number: 3,
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
/// The summoned pet is the row whose raw slot byte has bit 1 clear (`0x4cabe3`), not the one with
|
||||
/// byte 1: a byte of 4 is summoned too, and fills box 0.
|
||||
#[test]
|
||||
fn the_summoned_row_is_the_one_whose_slot_byte_has_bit_one_clear() {
|
||||
use benilla_protocol::messages::StabledPet;
|
||||
let pet = |slot: u8| StabledPet {
|
||||
pet_number: 3,
|
||||
creature_entry: 1_126,
|
||||
level: 10,
|
||||
name: "Boar".into(),
|
||||
loyalty: 1,
|
||||
slot,
|
||||
};
|
||||
// Raw bytes 1, 4 and 5 are summoned; 2 and 3 stabled.
|
||||
assert_eq!(
|
||||
[0, 1, 2, 3, 4].map(|slot| pet(slot).is_summoned()),
|
||||
[true, false, false, true, true]
|
||||
);
|
||||
// A row with raw byte 4 (rebased 3) is the summoned pet: box 0, no stabled row.
|
||||
let mut s = client_in_slot(0, 3);
|
||||
let name = |s: &mut Stable, i: i32| -> Option<String> {
|
||||
with_hosted_vm(s.app.world_mut(), |vm| {
|
||||
vm.expect("a VM")
|
||||
.eval(&format!("local _, n = GetStablePetInfo({i}) return n"))
|
||||
.unwrap()
|
||||
})
|
||||
};
|
||||
assert_eq!(name(&mut s, 0).as_deref(), Some("Boar"));
|
||||
assert_eq!(name(&mut s, 1), None);
|
||||
}
|
||||
|
||||
/// `UnstablePet` bails while the active player holds a charm or a summon (`0x4caf73`-`0x4caf88`),
|
||||
/// read off its own descriptor, whatever the pet bar holds.
|
||||
#[test]
|
||||
|
||||
@@ -199,13 +199,23 @@ fn snapshot(
|
||||
ask: Ask<'_>,
|
||||
) -> Option<StableState> {
|
||||
let mut slots: [Option<StablePetSlot>; NUM_STABLE_SLOTS] = Default::default();
|
||||
// The summoned pet (wire slot byte 1, bit 1 clear) takes row 0; the stabled ones (bit 1 set)
|
||||
// pack behind it in list order, whatever their slot byte says, so with only the second slot
|
||||
// occupied (the server does not renumber) the pet shows in box 1 and `UnstablePet(1)` takes it
|
||||
// (`0x4cab10`-`0x4cac95`, the array `0xb720c8`; `0x4cb285`-`0x4cb298`, `0x4cafa5`-`0x4cafb1`).
|
||||
let mut next = 1;
|
||||
for wire in &open.pets {
|
||||
// Seat rows by their slot, not position: a petless hunter's list has no slot-0 row.
|
||||
match slots.get_mut(usize::from(wire.slot)) {
|
||||
let seat = if wire.is_summoned() {
|
||||
0
|
||||
} else {
|
||||
next += 1;
|
||||
next - 1
|
||||
};
|
||||
match slots.get_mut(seat) {
|
||||
Some(seat) => *seat = Some(resolve_pet(wire, names, families, stats, ask)),
|
||||
None => debug!(
|
||||
"ui_stable: pet {} arrived in slot {} — past the {NUM_STABLE_SLOTS} the window has, dropped",
|
||||
wire.pet_number, wire.slot
|
||||
"ui_stable: pet {} arrived past the {NUM_STABLE_SLOTS} rows the window has, dropped",
|
||||
wire.pet_number
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ use std::collections::BTreeSet;
|
||||
|
||||
use benilla_formats::{
|
||||
LearnEffect, SkillLineCatalog, SpellCatalog, TokenContext, SPELL_EFFECT_LEARN_PET_SPELL,
|
||||
SPELL_EFFECT_SKILL_STEP,
|
||||
SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_SKILL_STEP,
|
||||
};
|
||||
use benilla_protocol::messages::{trainer_spell_state, TrainerSpell};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{
|
||||
TrainerAbilityReq, TrainerService, TrainerServiceCategory, TrainerServiceLive, TrainerSkillReq,
|
||||
TrainerState, TrainerTooltip, UiScript, TRAINER_GROUP_KNOWN,
|
||||
TrainerAbilityReq, TrainerService, TrainerServiceLive, TrainerSkillReq, TrainerState,
|
||||
TrainerTooltip, UiScript, TRAINER_GROUP_KNOWN,
|
||||
};
|
||||
|
||||
use crate::entities::ItemDisplays;
|
||||
@@ -232,6 +232,72 @@ pub(super) struct ReqBooks<'a> {
|
||||
/// The player's race and class, which index the taught spell's line (`0x60c920` →
|
||||
/// `0x6de040`).
|
||||
pub(super) race_class: (u8, u8),
|
||||
/// The player's `PLAYER_SKILL_INFO` slots, `Some` while the player object exists: the skill
|
||||
/// requirement scans them by line (`0x4d94d6`-`0x4d9524`), and the skill-line name needs the
|
||||
/// player (`0x4d91fd`-`0x4d921c`).
|
||||
pub(super) skills: Option<&'a [benilla_protocol::messages::PlayerSkillSlot]>,
|
||||
}
|
||||
|
||||
/// `GetTrainerServiceSkillReq`'s flag (`0x4d94d6`-`0x4d9575`): 1 unless the requirement names a
|
||||
/// line with a row and the player's first slot for it, its value plus the permanent bonus (added
|
||||
/// only to a nonzero value), falls short of the required value; no player, no requirement and an
|
||||
/// unknown line keep the default 1 (`0x4d945a`, `0x4d95bc`).
|
||||
fn skill_req_met(
|
||||
wire: &TrainerSpell,
|
||||
skill_lines: Option<&SkillLineCatalog>,
|
||||
books: &ReqBooks,
|
||||
) -> bool {
|
||||
let Some(slots) = books.skills else {
|
||||
return true;
|
||||
};
|
||||
if wire.req_skill == 0
|
||||
|| wire.req_skill_value == 0
|
||||
|| !skill_lines.is_none_or(|c| c.line(wire.req_skill).is_some())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let total = slots
|
||||
.iter()
|
||||
.find(|s| u32::from(s.skill_id) == wire.req_skill)
|
||||
.map_or(0, |s| match s.value {
|
||||
0 => 0,
|
||||
v => u32::from(v) + u32::from(s.perm_bonus as u16),
|
||||
});
|
||||
total >= wire.req_skill_value
|
||||
}
|
||||
|
||||
/// `GetTrainerServiceSkillLine`'s name (`0x4d91dc`-`0x4d92c5`), needing the player: the line of
|
||||
/// `FindSkillLineAbility(spell, race, class)` on the spell the first learn effect (`0x24` or
|
||||
/// `0x39`) teaches, else on the sent spell, whose miss goes on to the same lookup on the spell
|
||||
/// numbered by the sent record's third `EffectMiscValue`, the dword before the trigger array
|
||||
/// that an index of -1 reads (`0x4d9261`).
|
||||
fn skill_line_name(
|
||||
wire_spell: u32,
|
||||
spells: &SpellCatalog,
|
||||
skill_lines: Option<&SkillLineCatalog>,
|
||||
books: &ReqBooks,
|
||||
) -> Option<String> {
|
||||
books.skills?;
|
||||
let lines = skill_lines?;
|
||||
let (race, class) = books.race_class;
|
||||
let d = spells.get(wire_spell)?;
|
||||
let named = |spell: u32| {
|
||||
spells.get(spell)?;
|
||||
let line = lines.spell_to_line(spell, race, class)?;
|
||||
lines.line(line).map(|l| l.name.clone())
|
||||
};
|
||||
let learn = (0..3).find(|&i| {
|
||||
matches!(
|
||||
d.effects[i],
|
||||
SPELL_EFFECT_LEARN_SPELL | SPELL_EFFECT_LEARN_PET_SPELL
|
||||
)
|
||||
});
|
||||
match learn {
|
||||
Some(i) => named(d.effect_trigger_spell[i]),
|
||||
None => {
|
||||
named(wire_spell).or_else(|| u32::try_from(d.effect_misc_value[2]).ok().and_then(named))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one wire [`TrainerSpell`] into the Lua [`TrainerService`]; `books` colour each
|
||||
@@ -255,17 +321,18 @@ fn resolve_service(
|
||||
let taught = spells.learned_spell(wire.spell).unwrap_or(wire.spell);
|
||||
let display = spells.get(wire.spell);
|
||||
let cat = category(wire.state);
|
||||
// The wire has no per-gate bit, so the skill gate follows the service's category; the 1.12
|
||||
// client compares the player's skill value itself, which is not built here.
|
||||
let skill_met = cat != TrainerServiceCategory::Unavailable;
|
||||
let skill_req = (wire.req_skill != 0).then(|| TrainerSkillReq {
|
||||
name: skill_lines
|
||||
.and_then(|c| c.line(wire.req_skill))
|
||||
.map(|l| l.name.clone())
|
||||
.unwrap_or_else(|| format!("Skill {}", wire.req_skill)),
|
||||
rank: wire.req_skill_value,
|
||||
met: skill_met,
|
||||
});
|
||||
// The flag compares the player's skill value itself, whatever the row's colour (`0x4d9570`).
|
||||
let skill_met = skill_req_met(wire, skill_lines, books);
|
||||
// A zero line or value, a line `SkillLine.dbc` lacks and no player are all the miss
|
||||
// `nil, 0, 1` (`0x4d9487`-`0x4d94ca` → `0x4d95bc`).
|
||||
let skill_req = (wire.req_skill_value != 0 && books.skills.is_some())
|
||||
.then(|| skill_lines?.line(wire.req_skill))
|
||||
.flatten()
|
||||
.map(|l| TrainerSkillReq {
|
||||
name: l.name.clone(),
|
||||
rank: wire.req_skill_value,
|
||||
met: skill_met,
|
||||
});
|
||||
let (is_trade_skill, learn_spell) = service_kind(wire.spell, spells);
|
||||
// The wrapper is never in `SkillLineAbility`, so the skill-line arm keys on the taught spell;
|
||||
// a service it cannot place drops, as in the client's builder, and is logged.
|
||||
@@ -318,6 +385,7 @@ fn resolve_service(
|
||||
tooltip: service_tooltip(wire.spell, spells),
|
||||
skill_step: display.is_some_and(|d| d.effects.contains(&SPELL_EFFECT_SKILL_STEP)),
|
||||
step_increase: step_increase(wire.spell, spells, skill_lines, get),
|
||||
skill_line: skill_line_name(wire.spell, spells, skill_lines, books),
|
||||
line_name: skill_lines
|
||||
.and_then(|c| c.line(group_key))
|
||||
.map(|l| l.name.clone()),
|
||||
@@ -390,6 +458,7 @@ fn live_service(
|
||||
description,
|
||||
description_wait,
|
||||
ability_reqs,
|
||||
skill_met: skill_req_met(wire, skill_lines, books),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,10 +753,16 @@ impl TrainerReads<'_, '_> {
|
||||
.collect(),
|
||||
_ => BTreeSet::new(),
|
||||
});
|
||||
let skill_slots: Option<Vec<_>> = store.map(|s| {
|
||||
(0..PLAYER_SKILL_SLOTS)
|
||||
.filter_map(|i| s.0.player_skill(i))
|
||||
.collect()
|
||||
});
|
||||
let books = ReqBooks {
|
||||
player: &actions.spells,
|
||||
pet: pet_book.as_ref(),
|
||||
race_class: store.map_or((0, 0), |s| s.race_class()),
|
||||
skills: skill_slots.as_deref(),
|
||||
};
|
||||
Some(f(&Resolver {
|
||||
spells: &spells.catalog,
|
||||
|
||||
@@ -8,7 +8,7 @@ use benilla_formats::{
|
||||
SPELL_EFFECT_SKILL_STEP,
|
||||
};
|
||||
use benilla_protocol::messages::trainer_spell_state;
|
||||
use benilla_ui::script::TrainerTooltip;
|
||||
use benilla_ui::script::{TrainerServiceCategory, TrainerTooltip};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// An empty spell catalog, as before `Spell.dbc` loads.
|
||||
@@ -26,6 +26,7 @@ fn probe_strings(key: &str) -> Option<String> {
|
||||
"TRADESKILL_SERVICE_LEARN" => Some("<LEARN>".into()),
|
||||
"KNOWN_TALENTS_HEADER" => Some("<KNOWN>".into()),
|
||||
"INCREASE_POTENTIAL" => Some("Increases potential in %s by %d".into()),
|
||||
"HOME_INN" => Some("<INN>".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -76,6 +77,18 @@ fn resolve_service(
|
||||
spells: &SpellCatalog,
|
||||
skill_lines: Option<&SkillLineCatalog>,
|
||||
known: &BTreeSet<u32>,
|
||||
) -> TrainerService {
|
||||
resolve_service_skilled(wire, trainer_type, spells, skill_lines, known, Some(&[]))
|
||||
}
|
||||
|
||||
/// [`resolve_service`] with the player's skill slots, `None` for no player object.
|
||||
fn resolve_service_skilled(
|
||||
wire: &TrainerSpell,
|
||||
trainer_type: u32,
|
||||
spells: &SpellCatalog,
|
||||
skill_lines: Option<&SkillLineCatalog>,
|
||||
known: &BTreeSet<u32>,
|
||||
skills: Option<&[benilla_protocol::messages::PlayerSkillSlot]>,
|
||||
) -> TrainerService {
|
||||
let deps = Deps::new();
|
||||
with_text(spells, 0, 0, None, |text| {
|
||||
@@ -88,6 +101,7 @@ fn resolve_service(
|
||||
player: known,
|
||||
pet: None,
|
||||
race_class: (1, 1),
|
||||
skills,
|
||||
},
|
||||
None,
|
||||
&deps.items,
|
||||
@@ -124,6 +138,7 @@ fn snap(open: &TrainerOpen, spells: &SpellCatalog) -> Option<TrainerState> {
|
||||
player: &BTreeSet::new(),
|
||||
pet: None,
|
||||
race_class: (1, 1),
|
||||
skills: Some(&[]),
|
||||
},
|
||||
None,
|
||||
&deps.items,
|
||||
@@ -533,17 +548,11 @@ fn resolve_reads_cost_state_and_gates_with_no_catalog() {
|
||||
assert_eq!(svc.cost, 1000);
|
||||
assert_eq!(svc.category, TrainerServiceCategory::Unavailable);
|
||||
assert_eq!(svc.level_req, 20);
|
||||
// The skill gate follows the category; the ability gate reads met, as no service spell
|
||||
// record reaches the known test (`0x4d9842`), and a requirement with no record answers nil
|
||||
// (`0x4d97c8`, `0x4d98d1`). Every slot stays in place (`0x4d97a5`).
|
||||
assert_eq!(
|
||||
svc.skill_req,
|
||||
Some(TrainerSkillReq {
|
||||
name: "Skill 164".to_string(),
|
||||
rank: 100,
|
||||
met: false,
|
||||
})
|
||||
);
|
||||
// A skill line with no `SkillLine.dbc` row is no requirement (`0x4d94c5` → `0x4d95bc`); the
|
||||
// ability gate reads met, as no service spell record reaches the known test (`0x4d9842`), and
|
||||
// a requirement with no record answers nil (`0x4d97c8`, `0x4d98d1`). Every slot stays in place
|
||||
// (`0x4d97a5`).
|
||||
assert_eq!(svc.skill_req, None);
|
||||
assert_eq!(
|
||||
svc.ability_reqs,
|
||||
vec![
|
||||
@@ -878,8 +887,8 @@ fn service_description_expands_against_the_pet_for_a_learn_pet_row() {
|
||||
});
|
||||
}
|
||||
|
||||
/// The `$z` token: the bind point's area name (`AreaTable.dbc`), and raw where no bind point has
|
||||
/// arrived, as the item and tooltip feeds' `$z` behaves.
|
||||
/// The `$z` token: the bind point's area name (`AreaTable.dbc`), and the `HOME_INN` text where no
|
||||
/// bind point has arrived (`0x507f3a`), as the item and tooltip feeds' `$z` behaves.
|
||||
#[test]
|
||||
fn service_description_expands_z_to_the_bind_area() {
|
||||
let d = SpellDisplay {
|
||||
@@ -890,7 +899,7 @@ fn service_description_expands_z_to_the_bind_area() {
|
||||
let deps = Deps::new();
|
||||
for (home, want) in [
|
||||
(Some("Razor Hill"), "Returns you to Razor Hill."),
|
||||
(None, "Returns you to $z."),
|
||||
(None, "Returns you to <INN>."),
|
||||
] {
|
||||
with_text(&spells, 0, 0, home, |text| {
|
||||
assert_eq!(
|
||||
@@ -958,6 +967,7 @@ fn service_description_on_real_data_reads_astral_recalls_bind_area() {
|
||||
player: &BTreeSet::new(),
|
||||
pet: None,
|
||||
race_class: (1, 1),
|
||||
skills: Some(&[]),
|
||||
},
|
||||
None,
|
||||
&deps.items,
|
||||
@@ -1785,6 +1795,7 @@ fn resolve_with_pet(
|
||||
player,
|
||||
pet,
|
||||
race_class: (1, 1),
|
||||
skills: Some(&[]),
|
||||
},
|
||||
None,
|
||||
&deps.items,
|
||||
@@ -2254,3 +2265,155 @@ fn a_pet_rows_pet_is_the_summon_not_the_bar() {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
fn skill_slot(
|
||||
skill_id: u16,
|
||||
value: u16,
|
||||
perm_bonus: i16,
|
||||
) -> benilla_protocol::messages::PlayerSkillSlot {
|
||||
benilla_protocol::messages::PlayerSkillSlot {
|
||||
skill_id,
|
||||
step: 0,
|
||||
value,
|
||||
max: 300,
|
||||
temp_bonus: 0,
|
||||
perm_bonus,
|
||||
}
|
||||
}
|
||||
|
||||
/// `GetTrainerServiceSkillReq`'s flag (`0x4d9570`) compares the player's rank plus the permanent
|
||||
/// bonus, added only to a nonzero rank, with the required value: a row that is red for its level
|
||||
/// still reports the skill met, and an unmet skill shows on a green row. No player, no slot for
|
||||
/// the line, and an unknown line keep the default 1 or fail as the scan ends (`0x4d945a`,
|
||||
/// `0x4d9575`, `0x4d95bc`).
|
||||
#[test]
|
||||
fn skill_req_met_compares_the_players_skill_not_the_rows_colour() {
|
||||
const ALCHEMY: u32 = 171;
|
||||
let spells = empty_catalog();
|
||||
let lines = SkillLineCatalog::from_spell_lines([]).with_line_names([(ALCHEMY, "Alchemy")]);
|
||||
let met = |state: u8, skills: Option<&[benilla_protocol::messages::PlayerSkillSlot]>| {
|
||||
let mut w = wire(2018, state, 100, 10, ALCHEMY);
|
||||
w.req_skill_value = 50;
|
||||
resolve_service_skilled(
|
||||
&w,
|
||||
TRAINER_TYPE_TRADESKILL,
|
||||
&spells,
|
||||
Some(&lines),
|
||||
&BTreeSet::new(),
|
||||
skills,
|
||||
)
|
||||
.skill_req
|
||||
.map(|r| r.met)
|
||||
};
|
||||
let red = trainer_spell_state::RED;
|
||||
let green = trainer_spell_state::GREEN;
|
||||
assert_eq!(
|
||||
met(red, Some(&[skill_slot(171, 50, 0)])),
|
||||
Some(true),
|
||||
"50 of 50, red for level"
|
||||
);
|
||||
assert_eq!(
|
||||
met(green, Some(&[skill_slot(171, 40, 9)])),
|
||||
Some(false),
|
||||
"49 of 50"
|
||||
);
|
||||
assert_eq!(
|
||||
met(green, Some(&[skill_slot(171, 40, 10)])),
|
||||
Some(true),
|
||||
"bonus counts"
|
||||
);
|
||||
assert_eq!(
|
||||
met(green, Some(&[skill_slot(171, 0, 80)])),
|
||||
Some(false),
|
||||
"no bonus on rank 0"
|
||||
);
|
||||
assert_eq!(
|
||||
met(green, Some(&[skill_slot(164, 300, 0)])),
|
||||
Some(false),
|
||||
"no slot for the line"
|
||||
);
|
||||
assert_eq!(met(red, None), None, "no player is the miss `nil, 0, 1`");
|
||||
// A zero value and a line with no row are the miss too (`0x4d94a0`, `0x4d94c5`).
|
||||
let skill_req = |req_skill: u32, value: u32| {
|
||||
let mut w = wire(2018, green, 100, 10, req_skill);
|
||||
w.req_skill_value = value;
|
||||
resolve_service_skilled(
|
||||
&w,
|
||||
TRAINER_TYPE_TRADESKILL,
|
||||
&spells,
|
||||
Some(&lines),
|
||||
&BTreeSet::new(),
|
||||
Some(&[]),
|
||||
)
|
||||
.skill_req
|
||||
};
|
||||
assert_eq!(skill_req(ALCHEMY, 0), None, "a zero value");
|
||||
assert_eq!(skill_req(0, 50), None, "a zero line");
|
||||
assert_eq!(skill_req(999, 50), None, "a line with no row");
|
||||
assert_eq!(
|
||||
skill_req(ALCHEMY, 50).map(|r| r.name),
|
||||
Some("Alchemy".into())
|
||||
);
|
||||
}
|
||||
|
||||
/// `GetTrainerServiceSkillLine` (`0x4d9160`) names the line of the ability row of the spell the
|
||||
/// first learn effect teaches (`0x4d91e6`), else of the sent spell, and falls on to the spell the
|
||||
/// record's third `EffectMiscValue` numbers (`0x4d9261`); nil without a player or a match. It is
|
||||
/// not the group's name, which at a tradeskill trainer is the learn header.
|
||||
#[test]
|
||||
fn skill_line_name_is_the_taught_spells_line_not_the_group() {
|
||||
let learn = |taught: u32| SpellDisplay {
|
||||
name: "Alchemy".into(),
|
||||
effects: [SPELL_EFFECT_LEARN_SPELL, 0, 0],
|
||||
effect_trigger_spell: [taught, 0, 0],
|
||||
..Default::default()
|
||||
};
|
||||
let spells = SpellCatalog::from_displays(HashMap::from([
|
||||
(2018, learn(2259)),
|
||||
(2019, learn(31337)),
|
||||
(2259, taught_ability()),
|
||||
(78, taught_ability()),
|
||||
(
|
||||
5000,
|
||||
SpellDisplay {
|
||||
effect_misc_value: [0, 0, 78],
|
||||
..taught_ability()
|
||||
},
|
||||
),
|
||||
]));
|
||||
let lines = SkillLineCatalog::from_spell_lines([(2259, 171), (78, 26)])
|
||||
.with_line_names([(171, "Alchemy"), (26, "Arms")]);
|
||||
let name = |spell: u32, trainer_type: u32, skills: Option<&[_]>| {
|
||||
resolve_service_skilled(
|
||||
&wire(spell, trainer_spell_state::GREEN, 100, 1, 0),
|
||||
trainer_type,
|
||||
&spells,
|
||||
Some(&lines),
|
||||
&BTreeSet::new(),
|
||||
skills,
|
||||
)
|
||||
.skill_line
|
||||
};
|
||||
let player = Some(&[][..]);
|
||||
assert_eq!(
|
||||
name(2018, TRAINER_TYPE_TRADESKILL, player).as_deref(),
|
||||
Some("Alchemy")
|
||||
);
|
||||
assert_eq!(name(2018, TRAINER_TYPE_TRADESKILL, None), None, "no player");
|
||||
assert_eq!(
|
||||
name(2019, 0, player),
|
||||
None,
|
||||
"a taught spell with no ability row"
|
||||
);
|
||||
assert_eq!(
|
||||
name(78, 0, player).as_deref(),
|
||||
Some("Arms"),
|
||||
"the sent spell's own row"
|
||||
);
|
||||
assert_eq!(
|
||||
name(5000, 0, player).as_deref(),
|
||||
Some("Arms"),
|
||||
"the third misc value's spell"
|
||||
);
|
||||
assert_eq!(name(31337, 0, player), None, "no record");
|
||||
}
|
||||
|
||||
@@ -843,12 +843,15 @@ fn feed_player_control(
|
||||
fn feed_farsight_focus(
|
||||
mut hosted_vm: crate::script_calls::HostedVm,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
far_sight: Option<Res<crate::player::FarSight>>,
|
||||
mut focus: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
) {
|
||||
hosted_vm.run(|script| {
|
||||
let Some(script) = script else {
|
||||
return;
|
||||
};
|
||||
// `HasFullControl` refuses while the player's far-sight bit is on (`0x51a142`).
|
||||
script.set_far_sight_on(far_sight.as_deref().is_some_and(|f| f.on()));
|
||||
let Some(store) = self_q.iter().next() else {
|
||||
return;
|
||||
};
|
||||
@@ -1065,13 +1068,15 @@ pub(crate) fn unit_class_names<'a>(
|
||||
|
||||
/// What names a held unit as the getters read it now ([`benilla_ui::script::UnitIdentityRead`]):
|
||||
/// its name off the name caches as `GetUnitName` (`0x609210`) reads them, asking a miss once while
|
||||
/// it is pending, and its race and class bytes through the tables.
|
||||
/// it is pending, its race and class bytes through the tables, and its creature family off its
|
||||
/// cached record.
|
||||
pub(crate) struct LiveIdentity<'a> {
|
||||
pub(crate) guid: u64,
|
||||
pub(crate) store: &'a ObjectStore,
|
||||
pub(crate) names: Option<&'a NameCache>,
|
||||
pub(crate) commands: Option<&'a NetCommands>,
|
||||
pub(crate) tables: UnitNames<'a>,
|
||||
pub(crate) families: Option<&'a crate::ui_pet_stats::PetFamilyTables>,
|
||||
}
|
||||
|
||||
impl benilla_ui::script::UnitIdentityRead for LiveIdentity<'_> {
|
||||
@@ -1090,6 +1095,10 @@ impl benilla_ui::script::UnitIdentityRead for LiveIdentity<'_> {
|
||||
fn class(&self) -> Option<(String, String)> {
|
||||
unit_class_names(self.store, &self.tables).map(|(n, f)| (n.to_string(), f.to_string()))
|
||||
}
|
||||
/// `UnitCreatureFamily` (`0x51a310`): the word of the record's family id, read only.
|
||||
fn family(&self) -> Option<String> {
|
||||
crate::ui_pet_stats::family_for(Some(self.store), self.names?, self.families).0
|
||||
}
|
||||
}
|
||||
|
||||
/// What a unit's descriptor alone answers ([`UnitObject`]), read off `store` with no table or
|
||||
@@ -3085,7 +3094,7 @@ mod tests {
|
||||
app.init_resource::<crate::player::Player>()
|
||||
.init_resource::<crate::player::OwnControlLost>()
|
||||
.add_systems(Update, (feed_player_control, feed_farsight_focus));
|
||||
let script = UiScript::new().unwrap();
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script
|
||||
.run(
|
||||
r#"
|
||||
@@ -3098,6 +3107,15 @@ mod tests {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The player object `HasFullControl` needs, alive.
|
||||
script.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
health: 100,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
app.insert_non_send_resource(script);
|
||||
let me = app
|
||||
.world_mut()
|
||||
|
||||
@@ -91,6 +91,7 @@ fn row(sender: &str, subject: &str, was_read: bool, item_id: u32, cod: u32) -> M
|
||||
item_name: (item_id != 0).then(|| "Linen Cloth".to_string()),
|
||||
item_texture: (item_id != 0).then(|| "Interface\\Icons\\INV_Fabric_Linen_01".to_string()),
|
||||
item_quality: (item_id != 0).then_some(1),
|
||||
item_inventory_type: 0,
|
||||
item_random_property_id: 0,
|
||||
item_charges: 0,
|
||||
item_durability: (0, 0),
|
||||
|
||||
@@ -50,8 +50,8 @@ pub struct TokenContext<'a> {
|
||||
/// branch on. Asked only where a branch expands.
|
||||
pub gender: &'a dyn Fn() -> u8,
|
||||
/// The `$z` token: the home-bind area's name, from `SMSG_BINDPOINTUPDATE`'s area id through
|
||||
/// `AreaTable.dbc`; `None` leaves the token raw. Asked only where a `$z` expands, so a caller
|
||||
/// can tell which texts read the bind point.
|
||||
/// `AreaTable.dbc`; `None` is the `HOME_INN` text (`507f3a`). Asked only where a `$z` expands,
|
||||
/// so a caller can tell which texts read the bind point.
|
||||
pub home_area: &'a dyn Fn() -> Option<&'a str>,
|
||||
/// `GetText 0x703bf0`: a `GlobalStrings` template by key. `None` leaves the token raw.
|
||||
pub global: &'a dyn Fn(&str) -> Option<String>,
|
||||
@@ -466,9 +466,12 @@ fn token_value(
|
||||
text(v.to_string(), f64::from(v))
|
||||
}
|
||||
'z' => {
|
||||
// Player state, not spell data: the home-bind area name.
|
||||
let name = (ctx.home_area)()?;
|
||||
text(name.to_string(), 0.0)
|
||||
// Player state, not spell data: the home-bind area name; a bind zone out of range or
|
||||
// without an AreaTable row reads `HOME_INN` through GetText (`507f07`-`507f45`).
|
||||
match (ctx.home_area)() {
|
||||
Some(name) => text(name.to_string(), 0.0),
|
||||
None => Some((keyed("HOME_INN", Vec::new()), 0.0)),
|
||||
}
|
||||
}
|
||||
'c' | 'p' => {
|
||||
// `507dde`/`507ded`: the cost or the per-second cost of the token's record, through
|
||||
@@ -741,6 +744,7 @@ mod tests {
|
||||
"SPELL_DURATION_MIN" => "<%.2fmin>",
|
||||
"INT_SPELL_POINTS_SPREAD_TEMPLATE" => "<%d..%d>",
|
||||
"SPELL_POINTS_SPREAD_TEMPLATE" => "<%.1f to %.1f>",
|
||||
"HOME_INN" => "<inn>",
|
||||
_ => return None,
|
||||
}
|
||||
.into(),
|
||||
@@ -1631,10 +1635,11 @@ mod tests {
|
||||
substitute("Returns you to $z.", &d, &c),
|
||||
"Returns you to Goldshire."
|
||||
);
|
||||
// No bind area resolves: the `HOME_INN` text, never the raw token (`507f3a`).
|
||||
let unbound = ctx(&durations, &radii, &lookup);
|
||||
assert_eq!(
|
||||
substitute("Returns you to $z.", &d, &unbound),
|
||||
"Returns you to $z."
|
||||
"Returns you to <inn>."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,11 +20,21 @@ pub struct StabledPet {
|
||||
pub name: String,
|
||||
/// Loyalty level 1..6 (`Pet.h:57-65`), a `PetLoyalty.dbc` row id, not a string.
|
||||
pub loyalty: u32,
|
||||
/// The client's index: 0 is the current pet, 1..=2 the stable slots. The wire byte is one
|
||||
/// higher (`SendStablePet` writes `character_pet.slot + 1`) and is rebased here, once.
|
||||
/// The wire byte less one (`SendStablePet` writes `character_pet.slot + 1`, 1 for the
|
||||
/// current pet), rebased here once. The client does not seat by it: bit 1 of the raw byte
|
||||
/// tells the summoned pet from a stabled one ([`Self::is_summoned`]), and the stabled pets
|
||||
/// take the array in list order (`0x4cabe3`).
|
||||
pub slot: u8,
|
||||
}
|
||||
|
||||
impl StabledPet {
|
||||
/// The row the client files as the summoned pet: bit 1 of the raw slot byte clear
|
||||
/// (`test al,2` at `0x4cabe3`), so a byte of 1, 4 or 5 is summoned and 2 or 3 is stabled.
|
||||
pub fn is_summoned(&self) -> bool {
|
||||
self.slot.wrapping_add(1) & 2 == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_STABLE_RESULT` codes, vmangos `StableResultCode` (`NPCHandler.cpp:40-47`).
|
||||
pub mod stable_result {
|
||||
/// `STABLE_ERR_MONEY`: not enough gold for the next slot; only a slot purchase gets it.
|
||||
@@ -78,8 +88,8 @@ pub fn stable_swap_pet(npc_guid: u64, pet_number: u32) -> Vec<u8> {
|
||||
// No `CMSG_STABLE_REVIVE_PET` (0x0274) verb: vmangos's handler is empty (`NPCHandler.cpp:731`).
|
||||
|
||||
/// Read `MSG_LIST_STABLED_PETS` from the server (`NPCHandler.cpp:522-575`). The slot count is how
|
||||
/// many are bought (0..=2), not occupied. The current pet's row may be missing, so find rows by
|
||||
/// [`StabledPet::slot`], not position.
|
||||
/// many are bought (0..=2), not occupied. The current pet's row may be missing: the rows are the
|
||||
/// summoned pet ([`StabledPet::is_summoned`]) and the stabled ones in list order.
|
||||
pub(super) fn read_list_stabled_pets(r: &mut &[u8]) -> io::Result<(u64, u8, Vec<StabledPet>)> {
|
||||
let npc = read_u64_le(r)?;
|
||||
let num_pets = read_u8(r)?;
|
||||
|
||||
@@ -217,6 +217,22 @@ pub(crate) struct StoredActionState {
|
||||
pub(crate) cooldown: Option<(f64, f64, bool)>,
|
||||
}
|
||||
|
||||
/// Whether `UseAction(slot, 1)` sends the held payload to placement (`0x4e5f14`-`0x4e5f42`): a
|
||||
/// spell (`GetCursorSpell` `0x494e10`, the cell `[0xb4e2f4]`), a mode-1 item (`GetCursorItemGuid`
|
||||
/// `0x494c60`, a nonzero guid), a macro (`[0xb4e2fc]`) or a mode-7 payload with an entry
|
||||
/// (`[0xb4d900] == 7` and `GetCursorAmmo` `0x495170`). Coins, a pet action, a vendor row, a
|
||||
/// stabled pet and ammo fall through to using the slot (`0x4e5f52`).
|
||||
fn places_held(held: &super::cursor::CursorPayload) -> bool {
|
||||
use super::cursor::CursorPayload::{Action, Item, Macro, Spell};
|
||||
match held {
|
||||
Spell(s) => s.spell_id != 0,
|
||||
Item(i) => i.guid != 0,
|
||||
Macro(m) => m.id != 0,
|
||||
Action(a) => a.action != 0,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// One queued `UseAction` press.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ActionUse {
|
||||
@@ -444,10 +460,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UseAction(action [, checkCursor [, onSelf]]): a nonzero `checkCursor` while the cursor holds
|
||||
// a payload places it, as `PlaceAction` does; otherwise the press is queued with `onSelf`, the
|
||||
// self-cast flag. FrameXML passes 1 from a click and 0 from a keybind; the binding's own test
|
||||
// of it is untraced, and place-on-click is the reading that split implies.
|
||||
// UseAction(action [, checkCursor [, onSelf]]) (`0x4e5ee0`): a nonzero `checkCursor` while the
|
||||
// cursor holds a spell, a mode-1 item, a macro or a mode-7 payload places it, as `PlaceAction`
|
||||
// does ([`places_held`]); any other payload, or none, falls through to the press, queued with
|
||||
// `onSelf`, the self-cast flag. FrameXML passes 1 from a click and 0 from a keybind.
|
||||
g.set(
|
||||
"UseAction",
|
||||
lua.create_function(|lua, (action, rest): (Value, MultiValue)| {
|
||||
@@ -463,7 +479,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let on_self = bool_or_default(rest.get(1), false);
|
||||
let may_cast = protected::gate(lua, Tag::CAST);
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
if check_cursor && model.cursor.is_some() {
|
||||
if check_cursor && model.cursor.as_ref().is_some_and(places_held) {
|
||||
drop(model);
|
||||
super::cursor::place_action(lua, action)?;
|
||||
} else {
|
||||
@@ -956,6 +972,50 @@ mod tests {
|
||||
assert_eq!(uses(&mut s), vec![(10, false)]);
|
||||
}
|
||||
|
||||
/// Only a spell, a mode-1 item, a macro or a mode-7 payload goes to placement (`0x4e5f14`-
|
||||
/// `0x4e5f42`); coins, a pet action, a vendor row, a stabled pet and ammo go on to the use
|
||||
/// (`0x4e5f52`) and stay held.
|
||||
#[test]
|
||||
fn use_action_check_cursor_uses_the_slot_under_a_payload_it_does_not_place() {
|
||||
use crate::script::cursor::{
|
||||
CursorAmmo, CursorMerchantItem, CursorMoney, CursorPayload, CursorPetAction,
|
||||
CursorStablePet,
|
||||
};
|
||||
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
let held = [
|
||||
CursorPayload::Money(CursorMoney { copper: 50 }),
|
||||
CursorPayload::PetAction(CursorPetAction {
|
||||
src_slot: 1,
|
||||
packed: 0x0700_0002,
|
||||
passive: false,
|
||||
texture: None,
|
||||
view: Default::default(),
|
||||
}),
|
||||
CursorPayload::Merchant(CursorMerchantItem {
|
||||
item_id: 2589,
|
||||
row: 0,
|
||||
texture: None,
|
||||
}),
|
||||
CursorPayload::StablePet(CursorStablePet {
|
||||
slot: 1,
|
||||
texture: "Interface\\Icons\\Ability_Hunter_Pet_Boar".into(),
|
||||
}),
|
||||
CursorPayload::Ammo(CursorAmmo {
|
||||
item_id: 2512,
|
||||
texture: None,
|
||||
}),
|
||||
];
|
||||
for payload in held {
|
||||
let name = format!("{payload:?}");
|
||||
s.set_cursor_for_test(payload);
|
||||
s.run("UseAction(9, 1)").unwrap();
|
||||
assert_eq!(uses(&mut s), vec![(9, false)], "{name} uses the slot");
|
||||
assert!(s.cursor_payload().is_some(), "{name} stays held");
|
||||
}
|
||||
}
|
||||
|
||||
// ── `GetActionText` (`0x4e7050`) ──
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -208,13 +208,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// `GetBindLocation()`: the hearthstone's bind location name, which the instance-boot dialog
|
||||
// formats into its text (`StaticPopup.lua:1742`). `""` until `SMSG_BINDPOINTUPDATE` lands, as
|
||||
// the reference's empty buffer, never nil, since addons concatenate it.
|
||||
// formats into its text (`StaticPopup.lua:1742`). A bind zone below 0, past the max or without
|
||||
// an AreaTable row, which is every zone before `SMSG_BINDPOINTUPDATE` lands, answers the
|
||||
// `HOME_INN` text (`48db19`-`48db2c`), never nil.
|
||||
g.set(
|
||||
"GetBindLocation",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
lua.create_string(&model.bind_location)
|
||||
let named = lua
|
||||
.app_data_ref::<Model>()
|
||||
.expect("model app_data")
|
||||
.bind_location
|
||||
.clone();
|
||||
match named {
|
||||
Some(name) => lua.create_string(name),
|
||||
None => lua.create_string(super::glue_core::global_text(lua, "HOME_INN")),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -246,12 +254,19 @@ impl super::UiScript {
|
||||
self.take_calls_like(&super::ScriptCall::OpeningCinematic) as u32
|
||||
}
|
||||
|
||||
/// The bind location's name as `GetBindLocation` has it pushed: `None` while the bind zone has
|
||||
/// no AreaTable row, which the `$z` token reads as the `HOME_INN` text.
|
||||
pub fn bind_location(&self) -> Option<String> {
|
||||
self.model_ref().bind_location.clone()
|
||||
}
|
||||
|
||||
/// Push the bind location's name, resolved from `SMSG_BINDPOINTUPDATE`'s AreaTable id through
|
||||
/// the catalog the hearthstone's `$z` uses, so the two agree; empty until the packet lands.
|
||||
pub fn set_bind_location(&mut self, area_name: &str) {
|
||||
/// the catalog the hearthstone's `$z` uses, so the two agree; `None` until the packet lands, or
|
||||
/// for an area with no row, which `GetBindLocation` answers with the `HOME_INN` text.
|
||||
pub fn set_bind_location(&mut self, area_name: Option<&str>) {
|
||||
let mut model = self.model_mut();
|
||||
if model.bind_location != area_name {
|
||||
model.bind_location = area_name.to_string();
|
||||
if model.bind_location.as_deref() != area_name {
|
||||
model.bind_location = area_name.map(str::to_string);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,7 +140,8 @@ pub struct CraftRecipe {
|
||||
/// The open craft window, pushed whole by the app.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CraftState {
|
||||
/// The title `GetCraftName` answers.
|
||||
/// The window's identity for the app's open detection; `GetCraftName` reads the effect slot
|
||||
/// of [`Self::craft_type`] instead ([`UiScript::set_craft_slot_names`]).
|
||||
pub name: String,
|
||||
/// `GetCraftDisplaySkillLine`'s name, with [`Self::rank`] and [`Self::max_rank`] its rank
|
||||
/// and maximum, each with the permanent bonus; `None` answers nil, 0, 0.
|
||||
@@ -385,6 +386,16 @@ impl super::UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the names of the spells in the player's four effect slots (`CGPlayer_C+0x1d10`),
|
||||
/// which a learn writes by the spell's misc value (`0x5e9e1c`-`0x5e9e36`); `GetCraftName`
|
||||
/// answers the one at the open craft type, slot 0 while none is open.
|
||||
pub fn set_craft_slot_names(&mut self, names: [Option<String>; 4]) {
|
||||
let mut model = self.model_mut();
|
||||
if model.craft_slot_names != names {
|
||||
model.craft_slot_names = names;
|
||||
}
|
||||
}
|
||||
|
||||
/// The open's resets (`0x4f601c`-`0x4f602c`): the selection cleared and every group expanded,
|
||||
/// then the build (`0x4f6036`).
|
||||
pub fn craft_opened(&mut self) {
|
||||
@@ -496,17 +507,19 @@ fn opt_str(lua: &Lua, s: Option<&String>) -> mlua::Result<Value> {
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// GetCraftName() → the title, or "UNKNOWN" when closed (the reference's closed answer is
|
||||
// untraced); the stock window sets its title unconditionally (`Blizzard_CraftUI.lua:101`).
|
||||
// GetCraftName() (`0x4f6c10`) → the name of the player's effect-slot spell for the open craft
|
||||
// type (`GetEffectSlotSpell 0x5ea360`, the marker `[0xbdcfb8]`, set at `0x4f601c`), type 0 with
|
||||
// no window open; nil with no player, no such slot or no such spell (`0x4f6c74`).
|
||||
g.set(
|
||||
"GetCraftName",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let name = model
|
||||
.craft
|
||||
.as_ref()
|
||||
.map_or_else(|| "UNKNOWN".to_string(), |c| c.name.clone());
|
||||
Ok(Value::String(lua.create_string(&name)?))
|
||||
let marker = model.open_professions().craft.unwrap_or(0);
|
||||
let name = usize::try_from(marker)
|
||||
.ok()
|
||||
.and_then(|m| model.craft_slot_names.get(m))
|
||||
.and_then(|n| n.as_deref());
|
||||
opt_str(lua, name.map(str::to_string).as_ref())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -1072,6 +1085,7 @@ mod tests {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
s.set_craft(Some(state()));
|
||||
s.set_craft_slot_names([None, None, None, Some("Enchanting".into())]);
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetCraftName()").unwrap(),
|
||||
@@ -1441,14 +1455,45 @@ mod tests {
|
||||
assert!(s.take_craft_dos().is_empty());
|
||||
}
|
||||
|
||||
/// `GetCraftName` reads the effect-slot spell of the open type (`0x4f601c`), type 0 with the
|
||||
/// window closed (`0x4f6c32`-`0x4f6c74`): nil with no such spell, the slot's name once the
|
||||
/// player holds one, and slot 0 again after a `CloseCraft` of an open window.
|
||||
#[test]
|
||||
fn no_snapshot_shapes_unknown_name_and_nil_info() {
|
||||
fn the_name_follows_the_open_marker_and_its_effect_slot() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
assert!(s.eval::<bool>("return GetCraftName() == nil").unwrap());
|
||||
s.set_craft_slot_names([
|
||||
Some("Alchemy".into()),
|
||||
None,
|
||||
None,
|
||||
Some("Enchanting".into()),
|
||||
]);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetCraftName()").unwrap(),
|
||||
"UNKNOWN"
|
||||
"Alchemy"
|
||||
);
|
||||
s.set_craft(Some(state()));
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetCraftName()").unwrap(),
|
||||
"Enchanting",
|
||||
"open at type 3: slot 3"
|
||||
);
|
||||
s.run("CloseCraft()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetCraftName()").unwrap(),
|
||||
"Alchemy",
|
||||
"the marker is 0 again"
|
||||
);
|
||||
s.set_craft_slot_names(Default::default());
|
||||
assert!(s.eval::<bool>("return GetCraftName() == nil").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_snapshot_shapes_nil_name_and_nil_info() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
assert!(s.eval::<bool>("return GetCraftName() == nil").unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return GetCraftDisplaySkillLine() == nil")
|
||||
.unwrap());
|
||||
|
||||
@@ -128,11 +128,13 @@ impl super::UiScript {
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// The static buffer `0xbbb678` (`0x4e28c0`): "" before any gossip (.bss), never nil, and what
|
||||
// the last window drew after it closes.
|
||||
g.set(
|
||||
"GetGossipText",
|
||||
lua.create_function(|lua, ()| match read(lua, |m| m.greeting.clone()) {
|
||||
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
|
||||
None => Ok(Value::Nil),
|
||||
lua.create_function(|lua, ()| {
|
||||
let text = read(lua, |m| m.greeting.clone()).unwrap_or_default();
|
||||
lua.create_string(&text)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -274,7 +276,11 @@ mod tests {
|
||||
fn gossip_snapshot_reads_and_selects_queue() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
assert!(s.eval::<bool>("return GetGossipText() == nil").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetGossipText()").unwrap(),
|
||||
"",
|
||||
"the buffer is empty before any gossip, never nil (`0x4e28c0`)"
|
||||
);
|
||||
assert_eq!(s.arity("GetGossipOptions()").unwrap(), 0);
|
||||
|
||||
s.set_gossip(Some(menu()));
|
||||
@@ -399,7 +405,7 @@ mod tests {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_gossip(Some(menu()));
|
||||
s.set_gossip(None);
|
||||
assert!(s.eval::<bool>("return GetGossipText() == nil").unwrap());
|
||||
assert_eq!(s.eval::<String>("return GetGossipText()").unwrap(), "");
|
||||
assert_eq!(s.arity("GetGossipOptions()").unwrap(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,15 +62,16 @@ pub enum ItemTextCall {
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// `ItemTextGetItem()` (`0x4e38f0`): the open object's name (`0x4e391f`), nil with no object
|
||||
// (`0x4e3919`), `lua_pushstring(NULL)` (`0x4e3928`).
|
||||
g.set(
|
||||
"ItemTextGetItem",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model
|
||||
.item_text
|
||||
.as_ref()
|
||||
.map(|s| s.item.clone())
|
||||
.unwrap_or_default())
|
||||
match model.item_text.as_ref() {
|
||||
Some(s) => Ok(Value::String(lua.create_string(&s.item)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -170,8 +171,9 @@ mod tests {
|
||||
fn getters_mirror_the_pushed_state() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
// No session: empty defaults; the frame reads these only while shown.
|
||||
assert_eq!(s.eval::<String>("return ItemTextGetItem()").unwrap(), "");
|
||||
// No session: no object to name, so nil (`0x4e3919`); the frame reads these only while
|
||||
// shown.
|
||||
assert!(s.eval::<bool>("return ItemTextGetItem() == nil").unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return ItemTextGetCreator() == nil")
|
||||
.unwrap());
|
||||
@@ -193,6 +195,10 @@ mod tests {
|
||||
assert!(s
|
||||
.eval::<bool>("return ItemTextGetMaterial() == nil")
|
||||
.unwrap());
|
||||
|
||||
// The close clears the session: `if ItemTextGetItem() then` is false again.
|
||||
s.set_item_text(None);
|
||||
assert!(s.eval::<bool>("return ItemTextGetItem() == nil").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -162,7 +162,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `nil` for an unknown id or while the item template is in flight.
|
||||
// No values at all for an unknown id or while the item template is in flight: both exits are
|
||||
// `xor eax,eax` (`0x4c3255`, `0x4c3277` → `0x4c322c`).
|
||||
g.set(
|
||||
"GetLootRollItemLink",
|
||||
lua.create_function(|lua, roll_id: Value| {
|
||||
@@ -173,8 +174,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
find(&model, roll_id).and_then(|r| r.link.clone())
|
||||
};
|
||||
match link {
|
||||
Some(link) => Ok(mlua::Value::String(lua.create_string(&link)?)),
|
||||
None => Ok(mlua::Value::Nil),
|
||||
Some(link) => Ok(mlua::MultiValue::from_vec(vec![mlua::Value::String(
|
||||
lua.create_string(&link)?,
|
||||
)])),
|
||||
None => Ok(mlua::MultiValue::new()),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
@@ -302,9 +305,11 @@ mod tests {
|
||||
s.eval::<String>("return GetLootRollItemLink(7)").unwrap(),
|
||||
"|cffa335ee|Hitem:17182:0:0:0|h[Staff of Jordan]|h|r"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return GetLootRollItemLink(8) == nil")
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.arity("GetLootRollItemLink(8)").unwrap(),
|
||||
0,
|
||||
"an uncached item returns no values (`0x4c3277`)"
|
||||
);
|
||||
|
||||
// An unknown id: the reference's miss tail (`0x4c31a3`).
|
||||
assert!(s
|
||||
@@ -319,9 +324,11 @@ mod tests {
|
||||
5,
|
||||
"the reference's five returns (`0x4c31a3`)"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return GetLootRollItemLink(99) == nil")
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.arity("GetLootRollItemLink(99)").unwrap(),
|
||||
0,
|
||||
"an unknown roll returns no values (`0x4c3255`)"
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return GetLootRollTimeLeft(99)").unwrap(), 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,8 @@ pub struct MailInboxRow {
|
||||
pub item_name: Option<String>,
|
||||
pub item_texture: Option<String>,
|
||||
pub item_quality: Option<u32>,
|
||||
/// The record's `InventoryType`, 0 while uncached; the quality shows only for a nonzero one.
|
||||
pub item_inventory_type: u32,
|
||||
}
|
||||
|
||||
/// One auction invoice, `GetInboxInvoiceInfo`'s answer, parsed by the app from the mail's subject
|
||||
@@ -619,7 +621,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// GetInboxItem(index): name, texture, count, quality, canUse (`MailFrame.lua:379`); canUse is
|
||||
// the shared item-usable gate, true while the template is in flight.
|
||||
// the shared item-usable gate. A template the cache lacks takes the miss exit with the five
|
||||
// values `nil, nil, 0, 0, nil` (`0x4af674`-`0x4af679`, `0x4af792`); the count is the row's raw
|
||||
// byte (`0x4af704`) and the quality the record's only for a nonzero inventory type, else -1
|
||||
// (`0x4af721`-`0x4af730`).
|
||||
g.set(
|
||||
"GetInboxItem",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
@@ -633,15 +638,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
.is_none_or(|r| super::item_stats::item_usable_by_id(lua, &model, r.item_id));
|
||||
(row, usable)
|
||||
};
|
||||
// Five values on every path; with no item, `nil, nil, 0, 0, nil` (`0x4af5d0`).
|
||||
let miss = || {
|
||||
MultiValue::from_vec(vec![
|
||||
Value::Nil,
|
||||
Value::Nil,
|
||||
Value::Integer(0),
|
||||
Value::Integer(0),
|
||||
Value::Nil,
|
||||
])
|
||||
};
|
||||
let Some(r) = row.filter(|r| r.item_id != 0) else {
|
||||
return Ok(MultiValue::from_vec(vec![
|
||||
Value::Nil,
|
||||
Value::Nil,
|
||||
Value::Integer(0),
|
||||
Value::Integer(0),
|
||||
Value::Nil,
|
||||
]));
|
||||
return Ok(miss());
|
||||
};
|
||||
// The attachment's record off the item cache as the call runs.
|
||||
let key = ItemKey::Record {
|
||||
@@ -651,10 +658,24 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
let (name, texture, quality) = read_live(lua, Some(key), |item| {
|
||||
(item.name(), item.texture(), item.quality())
|
||||
let (cached, name, texture, quality) = read_live(lua, Some(key), |item| {
|
||||
let quality =
|
||||
super::item_stats::gated_quality(item.quality(), item.inventory_type());
|
||||
(item.cached(), item.name(), item.texture(), quality)
|
||||
})
|
||||
.unwrap_or_else(|| (r.item_name.clone(), r.item_texture.clone(), r.item_quality));
|
||||
.unwrap_or_else(|| {
|
||||
let quality =
|
||||
super::item_stats::gated_quality(r.item_quality, r.item_inventory_type);
|
||||
(
|
||||
r.item_quality.is_some(),
|
||||
r.item_name.clone(),
|
||||
r.item_texture.clone(),
|
||||
quality,
|
||||
)
|
||||
});
|
||||
if !cached {
|
||||
return Ok(miss());
|
||||
}
|
||||
let name = match &name {
|
||||
Some(n) => Value::String(lua.create_string(n)?),
|
||||
None => Value::Nil,
|
||||
@@ -663,13 +684,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Some(t) => Value::String(lua.create_string(t)?),
|
||||
None => Value::Nil,
|
||||
};
|
||||
// A number on every path, as in `GetSendMailItem`.
|
||||
let quality = Value::Integer(i64::from(quality.unwrap_or(0)));
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
name,
|
||||
texture,
|
||||
Value::Integer(i64::from(r.item_count.max(1))),
|
||||
quality,
|
||||
Value::Integer(i64::from(r.item_count)),
|
||||
Value::Integer(quality),
|
||||
flag(usable),
|
||||
]))
|
||||
})?,
|
||||
@@ -1411,6 +1430,7 @@ mod tests {
|
||||
item_texture: (item_id != 0)
|
||||
.then(|| "Interface\\Icons\\INV_Fabric_Linen_01".to_string()),
|
||||
item_quality: (item_id != 0).then_some(1),
|
||||
item_inventory_type: 0,
|
||||
item_random_property_id: 0,
|
||||
item_charges: 0,
|
||||
item_durability: (0, 0),
|
||||
@@ -1704,12 +1724,35 @@ mod tests {
|
||||
fn get_inbox_item_and_can_delete() {
|
||||
let mut s = kept();
|
||||
s.set_mail(Some(state()));
|
||||
// Linen Cloth has no inventory type: the quality is -1 (`0x4af721`-`0x4af730`).
|
||||
let (name, _tex, count, quality, canuse): (String, String, i64, i64, i64) =
|
||||
s.eval("return GetInboxItem(1)").unwrap();
|
||||
assert_eq!(
|
||||
(name.as_str(), count, quality, canuse),
|
||||
("Linen Cloth", 3, 1, 1)
|
||||
("Linen Cloth", 3, -1, 1)
|
||||
);
|
||||
// An equippable record shows its own quality.
|
||||
let mut st = state();
|
||||
st.inbox[0].item_inventory_type = 4;
|
||||
s.set_mail(Some(st));
|
||||
assert_eq!(
|
||||
s.eval::<i64>("local _, _, _, q = GetInboxItem(1) return q")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
// An uncached template takes the miss exit: `nil, nil, 0, 0, nil` (`0x4af674`-`0x4af679`).
|
||||
let mut st = state();
|
||||
st.inbox[0].item_quality = None;
|
||||
st.inbox[0].item_name = None;
|
||||
s.set_mail(Some(st));
|
||||
assert_eq!(s.arity("GetInboxItem(1)").unwrap(), 5);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local n, t, c, q, u = GetInboxItem(1) \
|
||||
return n == nil and t == nil and c == 0 and q == 0 and u == nil"
|
||||
)
|
||||
.unwrap());
|
||||
s.set_mail(Some(state()));
|
||||
assert!(s.eval::<bool>("return GetInboxItem(2) == nil").unwrap());
|
||||
// A player's unreturned mail is returnable, not deletable.
|
||||
assert!(s
|
||||
|
||||
@@ -703,6 +703,9 @@ pub(crate) struct Model {
|
||||
/// `HasFullControl`, the reference's `[0xb4b3e4]`: set by `SMSG_CLIENT_CONTROL_UPDATE` for the
|
||||
/// player, boots 1, and every cast, item and cursor gate refuses at 0.
|
||||
pub(crate) player_control: bool,
|
||||
/// `+0x1c70` bit 0 of the player: far sight is on (`CMSG_FAR_SIGHT` 1 sent), which
|
||||
/// `HasFullControl` refuses (`0x51a142`).
|
||||
pub(crate) far_sight_on: bool,
|
||||
/// The on-use spells that pass the item dispatcher's control gate without control of the
|
||||
/// character (`0x5d8ec0`-`0x5d8f6a`), fed by the app.
|
||||
pub(crate) control_bypass: HashSet<u32>,
|
||||
@@ -861,6 +864,10 @@ pub(crate) struct Model {
|
||||
pub(crate) craft: Option<craft::CraftState>,
|
||||
/// The window is open (`[0xbdcfb8]`), which a close alone zeroes (`0x4f5fe0`).
|
||||
pub(crate) craft_open: bool,
|
||||
/// The names of the spells in the player's four effect slots (`CGPlayer_C+0x1d10`,
|
||||
/// `0x5ea360`), which `GetCraftName` reads at the open marker (`[0xbdcfb8]`, 0 closed); `None`
|
||||
/// is nil.
|
||||
pub(crate) craft_slot_names: [Option<String>; 4],
|
||||
/// The selected spell id (`0xbdcfbc`), 0 for none, which a header or an index past the rows
|
||||
/// stores (`0x4f6050`); the index answers by finding its row (`0x4f6080`).
|
||||
pub(crate) craft_selection: u32,
|
||||
@@ -1175,8 +1182,8 @@ pub(crate) struct Model {
|
||||
/// `"player"` pushes that carry it: corrected, never blanked. A per-login rebuild is unseen, as
|
||||
/// its verbs exist only in game (`0x850438`), not at the glue screen (`0x46abb0`).
|
||||
pub(crate) player_record: super::PlayerRecord,
|
||||
/// `GetBindLocation()`, the bind point's area name: `""` before it lands, never nil.
|
||||
pub(crate) bind_location: String,
|
||||
/// `GetBindLocation()`, the bind point's area name; `None` answers the `HOME_INN` text.
|
||||
pub(crate) bind_location: Option<String>,
|
||||
/// `GMTicketCategory.dbc` `(id, name)` rows in file order; the ids are wire values.
|
||||
pub(crate) gm_ticket_categories: Vec<(u32, String)>,
|
||||
/// `CheckBinderDist()`: the innkeeper's question is live and in range; false hides its dialog.
|
||||
@@ -1578,6 +1585,7 @@ impl Model {
|
||||
shapeshift_forms: Vec::new(),
|
||||
pet_bar: super::pet::PetBarState::default(),
|
||||
player_control: true,
|
||||
far_sight_on: false,
|
||||
control_bypass: HashSet::new(),
|
||||
containers: HashMap::new(),
|
||||
container_cooldowns: HashMap::new(),
|
||||
@@ -1637,6 +1645,7 @@ impl Model {
|
||||
trade_skill_repeat_count: 0,
|
||||
craft: None,
|
||||
craft_open: false,
|
||||
craft_slot_names: Default::default(),
|
||||
craft_selection: 0,
|
||||
craft_rows: Vec::new(),
|
||||
craft_visible: 0,
|
||||
@@ -1717,7 +1726,7 @@ impl Model {
|
||||
screenshot_asks: 0,
|
||||
realm_name: String::new(),
|
||||
player_record: super::PlayerRecord::default(),
|
||||
bind_location: String::new(),
|
||||
bind_location: None,
|
||||
gm_ticket_categories: Vec::new(),
|
||||
binder_pending: false,
|
||||
summon_confirm: super::summon::SummonConfirmUiState::default(),
|
||||
|
||||
@@ -103,9 +103,9 @@ pub(crate) struct StoredPetAction {
|
||||
}
|
||||
|
||||
/// The pet stat block: the four hunter stat bindings and `HasPetUI`'s second return
|
||||
/// (`0x4be670`), plus the family word, icon and diet. The stats share one hunter-pet gate
|
||||
/// (`0x4be670`), plus the family icon and diet. The stats share one hunter-pet gate
|
||||
/// (`0x6116e0`) and fail their own ways: loyalty to nil, the pairs to `(0, 0)`, happiness to
|
||||
/// `(nil, 100.0, 0.0)`. The family word has no gate (`0x51a310`); the diet shares the stats'.
|
||||
/// `(nil, 100.0, 0.0)`. The diet shares the stats' gate.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PetStats {
|
||||
/// `HasPetUI`'s second return; false makes every gated binding answer its failure form.
|
||||
@@ -122,10 +122,6 @@ pub struct PetStats {
|
||||
pub training_points: (u16, u16),
|
||||
/// `GetPetExperience`: `(currXP, nextXP)`.
|
||||
pub experience: (u32, u32),
|
||||
/// `UnitCreatureFamily("pet")`: the localized `CreatureFamily.dbc` word off the cached creature
|
||||
/// record (`0x51a310`). `None` is nil: no record yet, id 0, or an id with no row (10, 13, 14,
|
||||
/// 18 and 22 have none). `PetPaperDollFrame.lua:68-70` guards its level line on it.
|
||||
pub family: Option<String>,
|
||||
/// `GetPetIcon()`: the family row's `CreatureFamily.dbc` icon, `None` on the family word's
|
||||
/// misses. Ungated like the family word; whether the reference gates it (`0x4beb10`) is
|
||||
/// untraced, and no warlock reaches its stock callers (`PetStable.lua:51`, `161-162`).
|
||||
@@ -342,6 +338,11 @@ impl super::UiScript {
|
||||
self.model_mut().player_control = in_control;
|
||||
}
|
||||
|
||||
/// Set the player's far-sight bit (`+0x1c70` bit 0), which `HasFullControl` reads.
|
||||
pub fn set_far_sight_on(&mut self, on: bool) {
|
||||
self.model_mut().far_sight_on = on;
|
||||
}
|
||||
|
||||
/// The drag's bar writes a harness VM kept, already applied here: one `Vec` per
|
||||
/// `CMSG_PET_SET_ACTION` of one or two `(0-based position, packed word)` pairs.
|
||||
pub fn take_pet_set_actions(&mut self) -> Vec<Vec<(u32, u32)>> {
|
||||
@@ -467,23 +468,30 @@ 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.
|
||||
// All three outputs start at 0 (`0x4be006`-`0x4be00c`); only a pet's slot holding an action of
|
||||
// type 1 to 5 (`0x4be017`-`0x4be023`) asks the list (`0x4be03c` → `0x6e2ea0`), which sets the
|
||||
// enable output to 1. A command, a reaction, an empty or absent slot and no pet answer
|
||||
// (0, 0, 0). An elapsed cooldown answers (0, 0, 1), so a re-feed never replays the sweep.
|
||||
g.set(
|
||||
"GetPetActionCooldown",
|
||||
lua.create_function(|lua, i: Value| {
|
||||
// `is-number` else `Usage: GetPetActionCooldown(index)` (`0x8478d8`, `0x4bdfb7`).
|
||||
let i = binding_abi::index_arg32(lua, i, "Usage: GetPetActionCooldown(index)")?;
|
||||
let now = crate::script::clock::now(lua);
|
||||
let spell = slot_now(lua, i).and_then(|v| v.spell_id);
|
||||
let spell = slot_now(lua, i)
|
||||
.filter(|_| flags_now(lua).has_bar)
|
||||
.filter(|v| matches!((v.packed >> 24) & 0x3F, 1..=5))
|
||||
.map(|v| v.packed & 0xFFFF);
|
||||
let Some(spell) = spell else {
|
||||
return Ok((0.0, 0.0, 0));
|
||||
};
|
||||
let snapshot = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
slot_at(&model, i).and_then(|s| s.cooldown)
|
||||
};
|
||||
// A spell slot's query reads the pet's list inside the call (`0x4be03c` →
|
||||
// `0x6e2ea0`), through the lent host; with none, the frame's snapshot answers.
|
||||
let live = spell.and_then(|spell| {
|
||||
super::cooldown_read::read_live(lua, super::CooldownQuery::PetSpell(spell))
|
||||
});
|
||||
// The query reads the pet's list inside the call, through the lent host; with none,
|
||||
// the frame's snapshot answers.
|
||||
let live = super::cooldown_read::read_live(lua, super::CooldownQuery::PetSpell(spell));
|
||||
Ok(match live.unwrap_or(snapshot) {
|
||||
Some((start, duration, enabled)) if start + duration > now || !enabled => (
|
||||
crate::script::clock::held_start(start, enabled, now),
|
||||
@@ -678,16 +686,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// One return on every path, with no class gate (`0x51a310`). Answers the `"pet"` token only:
|
||||
// the reference resolves any unit off its cached creature record (`[[unit+0xb30]+0x1c]`), so
|
||||
// a wild boar's `"target"` answers "Boar" there and nil here. The pet page is the only stock
|
||||
// caller.
|
||||
// `UnitCreatureFamily("unit")` (`0x51a310`): one return on every path, with no class gate. A
|
||||
// missing or non-string argument raises the usage line (`0x51a318`, `0x51a321`); the token goes
|
||||
// through the resolver (`0x51a341` → `0x515940`), which raises for an unknown one, and the
|
||||
// family word is read off the unit (`0x51a34c` → `0x6055e0`); any unit answers the word of its
|
||||
// cached creature record (`[[unit+0xb30]+0x1c]`), so a wild boar's `"target"` is "Boar".
|
||||
g.set(
|
||||
"UnitCreatureFamily",
|
||||
lua.create_function(move |lua, token: Option<String>| {
|
||||
let family = token
|
||||
.filter(|t| t.eq_ignore_ascii_case("pet"))
|
||||
.and_then(|_| stats_now(lua).1.family);
|
||||
lua.create_function(move |lua, token: Value| {
|
||||
let token =
|
||||
binding_abi::string_arg(lua, token, r#"Usage: UnitCreatureFamily("unit")"#)?;
|
||||
super::unit::check_unit_token(&Some(token.clone()))?;
|
||||
let family = super::unit::read_identity(lua, &token, |_, id| id.family()).flatten();
|
||||
Ok(match family.as_deref() {
|
||||
Some(name) => Value::String(lua.create_string(name)?),
|
||||
None => Value::Nil,
|
||||
@@ -867,6 +877,7 @@ mod tests {
|
||||
is_token: true,
|
||||
active: true,
|
||||
attack_active: true,
|
||||
packed: super::order(super::PET_ACT_COMMAND, super::PET_COMMAND_ATTACK),
|
||||
..Default::default()
|
||||
},
|
||||
PetActionView {
|
||||
@@ -876,6 +887,7 @@ mod tests {
|
||||
autocast_allowed: true,
|
||||
autocast_enabled: true,
|
||||
cooldown: Some((9400, 1500, true)),
|
||||
packed: 0xC100_0000 | 1767,
|
||||
..Default::default()
|
||||
},
|
||||
PetActionView::default(),
|
||||
@@ -947,12 +959,16 @@ mod tests {
|
||||
s.tick(10.0); // GetTime == 10
|
||||
s.set_pet_actions(true, true, true, slots());
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i32)>("return GetPetActionCooldown(1)")
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 1),
|
||||
"no cooldown reads cold"
|
||||
);
|
||||
// Only a spell action on a slot with a pet asks the list, which sets the enable output
|
||||
// (`0x4be017`-`0x4be03c`); a command, an empty slot and one past the bar answer zeros.
|
||||
for slot in [1, 3, 11] {
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i32)>(&format!("return GetPetActionCooldown({slot})"))
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 0),
|
||||
"slot {slot} is no spell"
|
||||
);
|
||||
}
|
||||
let (start, duration, enable) = s
|
||||
.eval::<(f64, f64, i32)>("return GetPetActionCooldown(2)")
|
||||
.unwrap();
|
||||
@@ -964,7 +980,16 @@ mod tests {
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i32)>("return GetPetActionCooldown(2)")
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 1)
|
||||
(0.0, 0.0, 1),
|
||||
"a spell slot with no cooldown reads cold"
|
||||
);
|
||||
|
||||
// No pet: the guid test comes first (`0x4bdfde`), so even a spell slot answers zeros.
|
||||
s.set_pet_actions(false, true, true, slots());
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i32)>("return GetPetActionCooldown(2)")
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1241,14 +1266,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `HasFullControl` (`0x51a140`) answers 1 only with a player object, far sight off
|
||||
/// (`+0x1c70` bit 0), health above 0 and the control flag set (`0x51a158`).
|
||||
#[test]
|
||||
fn has_full_control_follows_the_hosts_flag() {
|
||||
fn has_full_control_needs_the_player_alive_in_control_and_off_far_sight() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
let nil = |s: &UiScript| s.eval::<bool>("return HasFullControl() == nil").unwrap();
|
||||
assert!(nil(&s), "no player object");
|
||||
let player = |health: u32| {
|
||||
Some(crate::script::UnitState {
|
||||
exists: true,
|
||||
health,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
s.set_unit("player", player(100));
|
||||
assert_eq!(s.eval::<i64>("return HasFullControl()").unwrap(), 1);
|
||||
s.set_player_control(false);
|
||||
assert!(s.eval::<bool>("return HasFullControl() == nil").unwrap());
|
||||
assert!(nil(&s), "control lost");
|
||||
s.set_player_control(true);
|
||||
s.set_far_sight_on(true);
|
||||
assert!(nil(&s), "far sight or possession");
|
||||
s.set_far_sight_on(false);
|
||||
s.set_unit("player", player(0));
|
||||
assert!(nil(&s), "a corpse");
|
||||
s.set_unit("player", player(1));
|
||||
assert_eq!(s.eval::<i64>("return HasFullControl()").unwrap(), 1);
|
||||
}
|
||||
|
||||
@@ -1277,7 +1320,6 @@ mod tests {
|
||||
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
|
||||
training_points: (170, 130),
|
||||
experience: (4200, 8000),
|
||||
family: Some("Boar".into()),
|
||||
food_types: vec![
|
||||
"Meat".into(),
|
||||
"Fish".into(),
|
||||
@@ -1384,47 +1426,66 @@ mod tests {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// `UnitCreatureFamily("unit")` (`0x51a310`) answers the family word of any unit's cached
|
||||
/// creature record, nil for a unit with none, a missing or non-string argument raises its
|
||||
/// usage line (`0x51a318`, `0x51a321`) and an unknown token the resolver's error
|
||||
/// (`0x51a341`).
|
||||
#[test]
|
||||
fn unit_creature_family_is_nil_on_every_absent_path() {
|
||||
fn unit_creature_family_reads_any_unit_and_raises_on_a_bad_token() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
// No pet pushed.
|
||||
// No unit at all, or one with no family: nil, never "" (a truthy empty string would print
|
||||
// a bare 'Level 58 ' on the pet page).
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitCreatureFamily("pet") == nil"#)
|
||||
.unwrap());
|
||||
let unit = |family: Option<&str>| {
|
||||
Some(crate::script::UnitState {
|
||||
exists: true,
|
||||
family: family.map(str::to_string),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
s.set_unit("pet", unit(None));
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitCreatureFamily("pet") == nil"#)
|
||||
.unwrap());
|
||||
|
||||
// Family 0 and an unanswered creature query both arrive as `None`.
|
||||
s.set_pet_stats(
|
||||
true,
|
||||
PetStats {
|
||||
family: None,
|
||||
..hunter_stats()
|
||||
},
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitCreatureFamily("pet") == nil"#)
|
||||
.unwrap());
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return UnitCreatureFamily("pet") ~= ''"#)
|
||||
.unwrap(),
|
||||
"nil, never an empty string — '' is TRUTHY in Lua, so it would pass the ref's guard \
|
||||
and print a bare 'Level 58 ' with a trailing space"
|
||||
);
|
||||
|
||||
// With a family: `"pet"` answers it, any other token nil.
|
||||
s.set_pet_stats(true, hunter_stats());
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return UnitCreatureFamily("pet")"#)
|
||||
.unwrap(),
|
||||
"Boar"
|
||||
);
|
||||
for token in [r#""target""#, r#""player""#, "nil"] {
|
||||
assert!(
|
||||
s.eval::<bool>(&format!("return UnitCreatureFamily({token}) == nil"))
|
||||
s.set_unit("pet", unit(Some("Imp")));
|
||||
s.set_unit("target", unit(Some("Boar")));
|
||||
for (token, want) in [("pet", "Imp"), ("target", "Boar"), ("TARGET", "Boar")] {
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!(r#"return UnitCreatureFamily("{token}")"#))
|
||||
.unwrap(),
|
||||
"{token} must answer nil"
|
||||
want,
|
||||
"{token}"
|
||||
);
|
||||
}
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitCreatureFamily("player") == nil"#)
|
||||
.unwrap());
|
||||
|
||||
for (call, want) in [
|
||||
(
|
||||
"UnitCreatureFamily()",
|
||||
r#"Usage: UnitCreatureFamily("unit")"#,
|
||||
),
|
||||
(
|
||||
"UnitCreatureFamily(nil)",
|
||||
r#"Usage: UnitCreatureFamily("unit")"#,
|
||||
),
|
||||
(
|
||||
"UnitCreatureFamily({})",
|
||||
r#"Usage: UnitCreatureFamily("unit")"#,
|
||||
),
|
||||
(
|
||||
r#"UnitCreatureFamily("nonsense")"#,
|
||||
"Unknown unit name: nonsense",
|
||||
),
|
||||
] {
|
||||
let err = s.run(call).unwrap_err().to_string();
|
||||
assert!(err.contains(want), "{call}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1458,23 +1519,17 @@ mod tests {
|
||||
|
||||
/// The diet's hunter gate is the app's to apply, so here a non-hunter's diet is just empty.
|
||||
#[test]
|
||||
fn the_family_word_answers_for_a_non_hunter_pet() {
|
||||
fn a_non_hunter_pet_has_no_diet() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.keep_calls();
|
||||
s.set_pet_stats(
|
||||
true,
|
||||
PetStats {
|
||||
hunter_pet: false,
|
||||
family: Some("Imp".into()),
|
||||
food_types: vec![],
|
||||
..PetStats::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return UnitCreatureFamily("pet")"#)
|
||||
.unwrap(),
|
||||
"Imp"
|
||||
);
|
||||
assert!(s.eval::<bool>("return GetPetLoyalty() == nil").unwrap());
|
||||
assert_eq!(s.arity("GetPetFoodTypes()").unwrap(), 0);
|
||||
}
|
||||
|
||||
@@ -373,15 +373,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"GetStablePetInfo",
|
||||
lua.create_function(|lua, i: Value| {
|
||||
// `is-number` else `Usage: GetStablePetInfo(index)` (`0x848f18`, `0x4cb25b`).
|
||||
let i = i64::from(binding_abi::number_arg(
|
||||
lua,
|
||||
i,
|
||||
"Usage: GetStablePetInfo(index)",
|
||||
)?);
|
||||
let i = binding_abi::number_arg(lua, i, "Usage: GetStablePetInfo(index)")?;
|
||||
// The signed test on `i - 1` (`0x4cb285`-`0x4cb286`): 0 or below is the summoned pet's
|
||||
// record, anything else the stabled array's `i - 1` (`0x4cb288`-`0x4cb298`).
|
||||
let row = if i.wrapping_sub(1) < 0 {
|
||||
Some(0)
|
||||
} else {
|
||||
slot_index(i64::from(i))
|
||||
};
|
||||
let pet = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let mut view = view(lua, &model);
|
||||
slot_index(i).and_then(|i| view.state.as_mut()?.slots[i].take())
|
||||
row.and_then(|i| view.state.as_mut()?.slots[i].take())
|
||||
};
|
||||
if let Some(pet) = &pet {
|
||||
ask_creature(lua, pet, false)?;
|
||||
@@ -776,6 +779,27 @@ mod tests {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The signed test on `i - 1` (`0x4cb285`, `js 0x4cb2a0`) sends 0 and below to the summoned
|
||||
/// pet's record; the stabled pets are `i - 1` of the array.
|
||||
#[test]
|
||||
fn an_index_of_zero_or_below_reads_the_summoned_pet() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
open(&mut s);
|
||||
for i in ["0", "-1", "-40"] {
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("local _, n = GetStablePetInfo({i}) return n"))
|
||||
.unwrap(),
|
||||
"Rex",
|
||||
"index {i}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval::<String>("local _, n = GetStablePetInfo(1) return n")
|
||||
.unwrap(),
|
||||
"Bruiser"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_pet_still_answers_five_values() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -176,18 +176,18 @@ fn the_instruction_budget_counts_lua_a_library_function_runs() {
|
||||
assert!(err.contains("instruction budget exhausted"), "{err}");
|
||||
}
|
||||
|
||||
/// The hearth location's name, `""` before the packet; `StaticPopup.lua:1742` formats it.
|
||||
/// The hearth location's name; before the packet, or for a bind zone with no AreaTable row, the
|
||||
/// `HOME_INN` text (`0x48db19`), never nil. `StaticPopup.lua:1742` formats it.
|
||||
#[test]
|
||||
fn get_bind_location_answers_the_pushed_name_and_never_nil() {
|
||||
fn get_bind_location_answers_the_pushed_name_and_the_inn_text_otherwise() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
assert_eq!(s.eval::<String>("return GetBindLocation()").unwrap(), "");
|
||||
s.eval::<()>(r#"HOME_INN = "your inn""#).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(GetBindLocation())").unwrap(),
|
||||
"string",
|
||||
"never nil — a consumer concatenates it"
|
||||
s.eval::<String>("return GetBindLocation()").unwrap(),
|
||||
"your inn"
|
||||
);
|
||||
|
||||
s.set_bind_location("Stormwind City");
|
||||
s.set_bind_location(Some("Stormwind City"));
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetBindLocation()").unwrap(),
|
||||
"Stormwind City"
|
||||
@@ -197,11 +197,16 @@ fn get_bind_location_answers_the_pushed_name_and_never_nil() {
|
||||
.unwrap(),
|
||||
"Bound: Stormwind City"
|
||||
);
|
||||
s.set_bind_location("Ironforge");
|
||||
s.set_bind_location(Some("Ironforge"));
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetBindLocation()").unwrap(),
|
||||
"Ironforge"
|
||||
);
|
||||
s.set_bind_location(None);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetBindLocation()").unwrap(),
|
||||
"your inn"
|
||||
);
|
||||
}
|
||||
|
||||
/// `RequestTimePlayed()` is a call the host sends inside the verb; `TIME_PLAYED_MSG` carries the
|
||||
|
||||
@@ -327,13 +327,47 @@ fn worldmap_navigation_and_map_info() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `GetMapInfo`'s second value is `ftol(H * 1002 / W)` (`0x4a6d50`), the span the
|
||||
/// `WorldMapContinent` tile counts at continent level and the loc rect at zone level; the third
|
||||
/// rounds its low byte up to a power of two (`0x4a7e69`). The world sheet answers 0, 0.
|
||||
#[test]
|
||||
fn get_map_info_answers_the_texture_height_and_its_power_of_two_round() {
|
||||
let mut s = script();
|
||||
push_catalog(&mut s);
|
||||
let info = |s: &UiScript| {
|
||||
s.eval::<(i64, i64)>("local _, h, p = GetMapInfo() return h, p")
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(info(&s), (0, 0), "the world sheet");
|
||||
// Kalimdor's tiles are 26 wide and 44 high: 1695, low byte 159 rounds to 256.
|
||||
s.run("SetMapZoom(1)").unwrap();
|
||||
assert_eq!(info(&s), (1695, 1792));
|
||||
// Eastern Kingdoms: 25 by 47 tiles, 1883, low byte 91 rounds to 128.
|
||||
s.run("SetMapZoom(2)").unwrap();
|
||||
assert_eq!(info(&s), (1883, 1920));
|
||||
// Durotar: a 5000 by 4700 rect, 941, low byte 173 rounds to 256.
|
||||
s.run("SetMapZoom(1, 1)").unwrap();
|
||||
assert_eq!(info(&s), (941, 1024));
|
||||
// The Barrens: 10000 by 11500, 1152, whose low byte 128 is a power of two already.
|
||||
s.run("SetMapZoom(1, 2)").unwrap();
|
||||
assert_eq!(info(&s), (1152, 1152));
|
||||
}
|
||||
|
||||
/// `SetMapToCurrentZone` lands on the app-fed player zone, and the feed surfaces through
|
||||
/// `GetPlayerMapPosition` for the player and `party1..4`. A `raid` token
|
||||
/// answers the off-map sentinel; the reference reads raid positions (`WorldMapFrame.lua:379`).
|
||||
/// `GetPlayerMapPosition` for the player, `party1..4` and the raid rows, by the token's guid
|
||||
/// (`0x4a8658` → `0x515970`): any case, a `target` that names a member, and "Unknown unit name"
|
||||
/// for a token the resolver does not know. A string or number is required (`0x4a861e`).
|
||||
#[test]
|
||||
fn worldmap_current_zone_and_player_feed() {
|
||||
let mut s = script();
|
||||
push_catalog(&mut s);
|
||||
s.set_unit_guids(&crate::script::UnitGuids {
|
||||
player: 1,
|
||||
target: 10,
|
||||
party: [10, 11, 0, 0],
|
||||
raid: vec![20, 1],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
s.set_world_map_feed(
|
||||
Some((1, 2)),
|
||||
@@ -341,7 +375,7 @@ fn worldmap_current_zone_and_player_feed() {
|
||||
1.5,
|
||||
None,
|
||||
vec![Some((0.1, 0.2)), None],
|
||||
Vec::new(),
|
||||
vec![Some((0.3, 0.4)), Some((0.25, 0.75))],
|
||||
);
|
||||
s.run("SetMapToCurrentZone()").unwrap();
|
||||
assert_eq!(
|
||||
@@ -349,24 +383,41 @@ fn worldmap_current_zone_and_player_feed() {
|
||||
.unwrap(),
|
||||
(1, 2)
|
||||
);
|
||||
let (x, y) = s
|
||||
.eval::<(f64, f64)>(r#"return GetPlayerMapPosition("player")"#)
|
||||
.unwrap();
|
||||
assert!((x - 0.25).abs() < 1e-6 && (y - 0.75).abs() < 1e-6);
|
||||
let (px, py) = s
|
||||
.eval::<(f64, f64)>(r#"return GetPlayerMapPosition("party1")"#)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(px - 0.1).abs() < 1e-6 && (py - 0.2).abs() < 1e-6,
|
||||
"a party slot reads its own projection"
|
||||
);
|
||||
for token in ["party2", "party5", "party0", "raid1", "nonsense"] {
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>(&format!(r#"return GetPlayerMapPosition("{token}")"#))
|
||||
.unwrap(),
|
||||
(0.0, 0.0),
|
||||
"{token} answers the off-map sentinel"
|
||||
let near = |token: &str, want: (f64, f64)| {
|
||||
let (x, y) = s
|
||||
.eval::<(f64, f64)>(&format!(r#"return GetPlayerMapPosition("{token}")"#))
|
||||
.unwrap();
|
||||
assert!(
|
||||
(x - want.0).abs() < 1e-6 && (y - want.1).abs() < 1e-6,
|
||||
"{token}: {x},{y}"
|
||||
);
|
||||
};
|
||||
near("player", (0.25, 0.75));
|
||||
near("Player", (0.25, 0.75));
|
||||
near("party1", (0.1, 0.2));
|
||||
near("PARTY1", (0.1, 0.2));
|
||||
near("target", (0.1, 0.2));
|
||||
near("raid1", (0.3, 0.4));
|
||||
near("raid2", (0.25, 0.75));
|
||||
for token in ["party2", "party5", "party0", "raid3", "pet", "mouseover"] {
|
||||
near(token, (0.0, 0.0));
|
||||
}
|
||||
for (call, want) in [
|
||||
(
|
||||
r#"GetPlayerMapPosition("nonsense")"#,
|
||||
"Unknown unit name: nonsense",
|
||||
),
|
||||
(
|
||||
"GetPlayerMapPosition()",
|
||||
r#"Usage: GetPlayerMapPosition("player")"#,
|
||||
),
|
||||
(
|
||||
"GetPlayerMapPosition(nil)",
|
||||
r#"Usage: GetPlayerMapPosition("player")"#,
|
||||
),
|
||||
] {
|
||||
let err = s.run(call).unwrap_err().to_string();
|
||||
assert!(err.contains(want), "{call}: {err}");
|
||||
}
|
||||
|
||||
// No feed → the world sheet (never an error).
|
||||
|
||||
@@ -69,8 +69,8 @@ fn filter_bit_named(name: &str) -> Option<u32> {
|
||||
/// The state mask `"all"` sets (`0x4da2a9`): the three states a filter can name.
|
||||
const TRAINER_FILTER_ALL: u32 = 7;
|
||||
|
||||
/// A service's skill requirement (`GetTrainerServiceSkillReq`). The wire has no per-gate bit, so
|
||||
/// `met` comes from the service's `category`.
|
||||
/// A service's skill requirement (`GetTrainerServiceSkillReq`). `met` is the snapshot's answer to
|
||||
/// the player's skill; the getter reads it again at the call ([`TrainerServiceLive::skill_met`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TrainerSkillReq {
|
||||
pub name: String,
|
||||
@@ -182,6 +182,10 @@ pub struct TrainerService {
|
||||
/// `GetTrainerSkillLines` (`0x4da660`) lists per header; `None` for a key with no row, the
|
||||
/// known-talents group's -1 and a tradeskill trainer's 1 and 2 included.
|
||||
pub line_name: Option<String>,
|
||||
/// `GetTrainerServiceSkillLine`'s name (`0x4d91dc`): the `SkillLine` of the ability row of the
|
||||
/// spell the row teaches, else of the sent spell, which differs from the group's name at a
|
||||
/// tradeskill trainer; `None` answers nil.
|
||||
pub skill_line: Option<String>,
|
||||
}
|
||||
|
||||
/// One header and its services, built by [`UiScript::set_trainer`].
|
||||
@@ -234,6 +238,9 @@ pub struct TrainerServiceLive {
|
||||
pub description: Option<TooltipText>,
|
||||
pub description_wait: Option<ProductWait>,
|
||||
pub ability_reqs: Vec<TrainerAbilityReq>,
|
||||
/// `GetTrainerServiceSkillReq`'s third value as the call finds it (`0x4d9570`): the player's
|
||||
/// skill reaches the required value, 1 whenever nothing asks it.
|
||||
pub skill_met: bool,
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
@@ -682,6 +689,7 @@ fn live(lua: &Lua, model: &Model, index: usize) -> Option<TrainerServiceLive> {
|
||||
description: s.description.clone().map(TooltipText::from),
|
||||
description_wait: s.description_wait,
|
||||
ability_reqs: s.ability_reqs.clone(),
|
||||
skill_met: s.skill_req.as_ref().is_none_or(|r| r.met),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -719,19 +727,18 @@ fn raise_trainer_update(model: &mut Model) {
|
||||
model.raise("TRAINER_UPDATE", Vec::new());
|
||||
}
|
||||
|
||||
fn set_collapsed(model: &mut Model, id: usize, collapse: bool) {
|
||||
if id == 0 && !collapse {
|
||||
model.trainer_collapsed.clear();
|
||||
return;
|
||||
}
|
||||
let targets: Vec<u32> = if id == 0 {
|
||||
model
|
||||
/// The fold verbs' write on the row `n - 1` signed (`0x4da6f4`, `0x4da774`): below 0 every group
|
||||
/// at once (`0x4da6f5`, `0x4da775`), else the row must be a header of the array (`0x4d8be0` answers
|
||||
/// -1 for a service or a row past the total) whose group's bit is set or cleared. `false` is the
|
||||
/// bad skill line, which raises with nothing touched and no `TRAINER_UPDATE` (`0x4d8cd0`).
|
||||
fn set_collapsed(model: &mut Model, n: i32, collapse: bool) -> bool {
|
||||
let targets: Vec<u32> = match usize::try_from(n.wrapping_sub(1)) {
|
||||
Err(_) => model
|
||||
.trainer
|
||||
.as_ref()
|
||||
.map(|t| t.groups.iter().map(|g| g.key).collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
match rows(model).0.get(id - 1) {
|
||||
.unwrap_or_default(),
|
||||
Ok(row) => match rows(model).0.get(row) {
|
||||
Some(Row::Header(gi)) => model
|
||||
.trainer
|
||||
.as_ref()
|
||||
@@ -739,8 +746,8 @@ fn set_collapsed(model: &mut Model, id: usize, collapse: bool) {
|
||||
.map(|g| g.key)
|
||||
.into_iter()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
_ => return false,
|
||||
},
|
||||
};
|
||||
for sl in targets {
|
||||
if collapse {
|
||||
@@ -749,6 +756,21 @@ fn set_collapsed(model: &mut Model, id: usize, collapse: bool) {
|
||||
model.trainer_collapsed.remove(&sl);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A fold verb past its usage check: the write, then `TRAINER_UPDATE`, or the `bad` line raised.
|
||||
fn fold(lua: &Lua, n: i32, collapse: bool, bad: &'static str) -> mlua::Result<()> {
|
||||
let folded = super::tick::verb(lua, |model| {
|
||||
let ok = set_collapsed(model, n, collapse);
|
||||
if ok {
|
||||
raise_trainer_update(model);
|
||||
}
|
||||
ok
|
||||
});
|
||||
folded
|
||||
.then_some(())
|
||||
.ok_or_else(|| mlua::Error::RuntimeError(bad.into()))
|
||||
}
|
||||
|
||||
fn opt_str(lua: &Lua, s: Option<&String>) -> mlua::Result<Value> {
|
||||
@@ -816,8 +838,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerServiceSkillLine(index): the group name, nil for a header. The reference's return
|
||||
// (`0x4d9160`) is untraced; the stock `CONFIRM_PROFESSION` dialog shows it as the profession.
|
||||
// GetTrainerServiceSkillLine(index) (`0x4d9160`): the name of the skill line of the row's
|
||||
// ability (see [`TrainerService::skill_line`]), nil for a header; the stock `CONFIRM_PROFESSION`
|
||||
// dialog shows it as the profession.
|
||||
g.set(
|
||||
"GetTrainerServiceSkillLine",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
@@ -825,19 +848,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let index =
|
||||
binding_abi::index_arg(lua, index, "Usage: GetTrainerServiceSkillLine(index)")?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(n) = index.checked_sub(1) else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let Some(Row::Service(si)) = rows(&model).0.get(n).copied() else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let t = model
|
||||
.trainer
|
||||
.as_ref()
|
||||
.expect("a resolved row ⇒ an open trainer");
|
||||
Ok(Value::String(
|
||||
lua.create_string(&t.services[si].group_name)?,
|
||||
))
|
||||
opt_str(
|
||||
lua,
|
||||
service(&model, index).and_then(|s| s.skill_line.as_ref()),
|
||||
)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -924,10 +938,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let Some(req) = service(&model, index).and_then(|s| s.skill_req.as_ref()) else {
|
||||
return Ok(binding_abi::tuple(&[None, Some(0), Some(1)]));
|
||||
};
|
||||
// The flag compares the player's skill at the call, not the row's colour (`0x4d9570`).
|
||||
let met = live(lua, &model, index).map_or(req.met, |l| l.skill_met);
|
||||
// An empty name answers `nil, 0` and the flag (`0x4d9589`-`0x4d9599`).
|
||||
if req.name.is_empty() {
|
||||
return Ok(binding_abi::tuple(&[None, Some(0), met.then_some(1)]));
|
||||
}
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::String(lua.create_string(&req.name)?),
|
||||
Value::Integer(i64::from(req.rank)),
|
||||
binding_abi::flag(req.met),
|
||||
binding_abi::flag(met),
|
||||
]))
|
||||
})?,
|
||||
)?;
|
||||
@@ -1247,30 +1267,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// Collapse/ExpandTrainerSkillLine(id): fold the group headed by row `id`, or all at 0. `id`
|
||||
// spans the whole row array like the getters' gate (`0x4d89b0`); their own bound is untraced.
|
||||
// Collapse/ExpandTrainerSkillLine(n): fold the group headed by row `n`, or every group for
|
||||
// `n - 1` below 0; a row that is no header raises (`0x4da6c0`, `0x4da740`). `n` spans the
|
||||
// whole row array like the getters' gate (`0x4d89b0`).
|
||||
g.set(
|
||||
"CollapseTrainerSkillLine",
|
||||
lua.create_function(|lua, id: Value| {
|
||||
lua.create_function(|lua, n: Value| {
|
||||
// `is-number` else `Usage: CollapseTrainerSkillLine(index)` (`0x84ad24`, `0x4da6d1`).
|
||||
let id = binding_abi::index_arg(lua, id, "Usage: CollapseTrainerSkillLine(index)")?;
|
||||
super::tick::verb(lua, |model| {
|
||||
set_collapsed(model, id, true);
|
||||
raise_trainer_update(model);
|
||||
});
|
||||
Ok(())
|
||||
let n = binding_abi::number_arg(lua, n, "Usage: CollapseTrainerSkillLine(index)")?;
|
||||
fold(lua, n, true, "Bad skill line in CollapseTrainerSkillLine")
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"ExpandTrainerSkillLine",
|
||||
lua.create_function(|lua, id: Value| {
|
||||
lua.create_function(|lua, n: Value| {
|
||||
// `is-number` else `Usage: ExpandTrainerSkillLine(index)` (`0x84ad78`, `0x4da751`).
|
||||
let id = binding_abi::index_arg(lua, id, "Usage: ExpandTrainerSkillLine(index)")?;
|
||||
super::tick::verb(lua, |model| {
|
||||
set_collapsed(model, id, false);
|
||||
raise_trainer_update(model);
|
||||
});
|
||||
Ok(())
|
||||
let n = binding_abi::number_arg(lua, n, "Usage: ExpandTrainerSkillLine(index)")?;
|
||||
fold(lua, n, false, "Bad skill line in ExpandTrainerSkillLine")
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ fn svc(
|
||||
skill_step: false,
|
||||
step_increase: None,
|
||||
line_name: Some(group_name.into()),
|
||||
skill_line: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +267,44 @@ fn service_getters_read_the_row_at_a_visible_index() {
|
||||
assert!(s
|
||||
.eval::<bool>("return GetTrainerServiceSkillReq(1) == nil")
|
||||
.unwrap());
|
||||
|
||||
// An empty name answers `nil, 0` and the flag (`0x4d9589`).
|
||||
let mut t = trainer();
|
||||
t.services[0].skill_req = Some(TrainerSkillReq {
|
||||
name: String::new(),
|
||||
rank: 100,
|
||||
met: false,
|
||||
});
|
||||
s.set_trainer(Some(t));
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local n, r, m = GetTrainerServiceSkillReq(2) return n == nil and r == 0 and m == nil"
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// `GetTrainerServiceSkillLine` answers the service's own skill line (`0x4d92bb`), never its
|
||||
/// group's name, and nil for a header, a service with none and a row past the list.
|
||||
#[test]
|
||||
fn the_skill_line_is_the_services_own_not_its_groups() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut t = trainer();
|
||||
t.services[0].skill_line = Some("Alchemy".into());
|
||||
s.set_trainer(Some(t));
|
||||
// Row 1 is Arms' header; rows 2 and 3 are its services.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetTrainerServiceSkillLine(2)")
|
||||
.unwrap(),
|
||||
"Alchemy",
|
||||
"not the group's \"Arms\""
|
||||
);
|
||||
for row in [1, 3, 99] {
|
||||
assert!(
|
||||
s.eval::<bool>(&format!("return GetTrainerServiceSkillLine({row}) == nil"))
|
||||
.unwrap(),
|
||||
"row {row}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference's filter hide exempts no header (`0x4d8528`, `0x4d8535`).
|
||||
@@ -866,6 +905,47 @@ fn the_filter_and_collapse_verbs_fire_trainer_update_in_place() {
|
||||
assert_eq!(seen, ["TRAINER_UPDATE 4", "TRAINER_UPDATE 3"]);
|
||||
}
|
||||
|
||||
/// The fold verbs take `n - 1` signed (`0x4da6f4`, `0x4da774`): below 0 they fold every group, a
|
||||
/// row that is no header (`0x4d8be0` answers -1 for a service or a row past the total) raises
|
||||
/// "Bad skill line in ..." and fires no `TRAINER_UPDATE`.
|
||||
#[test]
|
||||
fn the_fold_verbs_take_a_negative_index_for_all_and_raise_on_a_bad_row() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trainer(Some(trainer()));
|
||||
s.run("CollapseTrainerSkillLine(-1)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 2);
|
||||
s.run("ExpandTrainerSkillLine(-5)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
|
||||
|
||||
crate::script::tick::heard::listen(&s, &["TRAINER_UPDATE"], None);
|
||||
for (call, want) in [
|
||||
(
|
||||
"CollapseTrainerSkillLine(2)",
|
||||
"Bad skill line in CollapseTrainerSkillLine",
|
||||
),
|
||||
(
|
||||
"CollapseTrainerSkillLine(6)",
|
||||
"Bad skill line in CollapseTrainerSkillLine",
|
||||
),
|
||||
(
|
||||
"ExpandTrainerSkillLine(3)",
|
||||
"Bad skill line in ExpandTrainerSkillLine",
|
||||
),
|
||||
(
|
||||
"ExpandTrainerSkillLine(99)",
|
||||
"Bad skill line in ExpandTrainerSkillLine",
|
||||
),
|
||||
] {
|
||||
let err = s.run(call).unwrap_err().to_string();
|
||||
assert!(err.contains(want), "{call}: {err}");
|
||||
}
|
||||
assert!(
|
||||
crate::script::tick::heard::take(&s).is_empty(),
|
||||
"a raise fires no update"
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
|
||||
}
|
||||
|
||||
/// `OpenTrainer` (`0x4d8cf0`) asks for the player's own list: it hands the host its call, which
|
||||
/// holds the player, and takes no argument.
|
||||
#[test]
|
||||
|
||||
@@ -465,14 +465,22 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// HasFullControl(): the flag `SMSG_CLIENT_CONTROL_UPDATE` writes for the local player
|
||||
// (`0xb4b3e4`, read at `0x51a158`), which the stock unit menu greys Trade and Duel on
|
||||
// (`UnitPopup.lua:474-477`, `:502-505`).
|
||||
// HasFullControl() (`0x51a140`): 1 only with a player object, far sight off (`+0x1c70` bit 0,
|
||||
// `0x51a142`), `UNIT_FIELD_HEALTH` above 0 (`0x51a151`-`0x51a156`) and the flag
|
||||
// `SMSG_CLIENT_CONTROL_UPDATE` writes (`0xb4b3e4`, `0x51a158`), else nil. The stock unit menu
|
||||
// greys Trade and Duel on it (`UnitPopup.lua:474-477`, `:502-505`).
|
||||
g.set(
|
||||
"HasFullControl",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(flag(model.player_control))
|
||||
let ours = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model.player_control && !model.far_sight_on
|
||||
};
|
||||
let alive = ours
|
||||
&& with_object(lua, &Some("player".to_string()), false, |u, o| {
|
||||
u.exists && o.health() > 0
|
||||
})?;
|
||||
Ok(flag(alive))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -114,6 +114,10 @@ pub struct UnitState {
|
||||
pub class: Option<String>,
|
||||
/// `UnitClass`'s second return, the uppercase token (`"WARRIOR"`).
|
||||
pub class_file: Option<String>,
|
||||
/// `UnitCreatureFamily`'s word (`0x6055e0`, `0x51a372`): the localized `CreatureFamily.dbc` name of the
|
||||
/// family word in the unit's cached creature record (`[[unit+0xb30]+0x1c]`); `None` without a
|
||||
/// record, with family 0, or for a family the table lacks. Players have no record.
|
||||
pub family: Option<String>,
|
||||
/// `UnitHasRelicSlot` (`0x519e50`): INVSLOT 17 is a relic slot, from `ChrClasses.dbc` field
|
||||
/// 16. Players only, true for Paladin, Shaman and Druid.
|
||||
pub has_relic_slot: bool,
|
||||
@@ -755,6 +759,11 @@ impl UnitIdentityRead for LiveOr<'_> {
|
||||
.class()
|
||||
.or_else(|| UnitIdentityRead::class(self.unit))
|
||||
}
|
||||
fn family(&self) -> Option<String> {
|
||||
self.live
|
||||
.family()
|
||||
.or_else(|| UnitIdentityRead::family(self.unit))
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit predicate's body: the number 1 or nil through [`super::binding_abi::flag`], never a Lua
|
||||
|
||||
@@ -112,6 +112,8 @@ pub trait UnitIdentityRead {
|
||||
fn race(&self) -> Option<(String, String)>;
|
||||
/// [`UnitState::class`] and [`UnitState::class_file`].
|
||||
fn class(&self) -> Option<(String, String)>;
|
||||
/// [`UnitState::family`].
|
||||
fn family(&self) -> Option<String>;
|
||||
}
|
||||
|
||||
impl UnitIdentityRead for UnitState {
|
||||
@@ -124,4 +126,7 @@ impl UnitIdentityRead for UnitState {
|
||||
fn class(&self) -> Option<(String, String)> {
|
||||
self.class.clone().zip(self.class_file.clone())
|
||||
}
|
||||
fn family(&self) -> Option<String> {
|
||||
self.family.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,56 @@ fn grid_area(state: &WorldMapState, u: f32, v: f32) -> Option<u16> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `GetMapInfo`'s second value (`0x4a6d50`): `ftol(H * 1002 / W)` of the displayed map's span, the
|
||||
/// `WorldMapContinent` tile counts for a continent, the loc rect for a zone; 0 for a span of zero
|
||||
/// width or height (`0x4a6e2e`-`0x4a6e3e`), the world sheet and an instance map, whose map id no
|
||||
/// `WorldMapContinent` row carries (`0x4a6dc2`-`0x4a6df2`).
|
||||
fn texture_height(wm: &WorldMapState) -> u32 {
|
||||
let (c, z) = wm.selection;
|
||||
let Some(cont) = wm
|
||||
.direct_area
|
||||
.is_none()
|
||||
.then_some(c)
|
||||
.and_then(|c| wm.continents.get(usize::try_from(c).ok()?.checked_sub(1)?))
|
||||
else {
|
||||
return 0;
|
||||
};
|
||||
let (w, h) = match z.checked_sub(1).and_then(|n| cont.zones.get(n as usize)) {
|
||||
Some(zone) => {
|
||||
let (left, right, top, bottom) = zone.loc_rect;
|
||||
(
|
||||
f64::from(left) - f64::from(right),
|
||||
f64::from(top) - f64::from(bottom),
|
||||
)
|
||||
}
|
||||
None => {
|
||||
let Some(sheet) = cont.sheet else {
|
||||
return 0;
|
||||
};
|
||||
let (left, right, top, bottom) = sheet.tiles;
|
||||
let tiles =
|
||||
|lo: u32, hi: u32| f64::from((hi as i32).wrapping_sub(lo as i32).wrapping_add(1));
|
||||
(tiles(left, right), tiles(top, bottom))
|
||||
}
|
||||
};
|
||||
if w == 0.0 || h == 0.0 {
|
||||
return 0;
|
||||
}
|
||||
binding_abi::ftol(h * 1002.0 / w) as u32
|
||||
}
|
||||
|
||||
/// `GetMapInfo`'s third value (`0x4a7e69`-`0x4a7e9a`): the height with its low byte rounded up
|
||||
/// to a power of two, the bytes above kept; a low byte of 0 or a power of two stays.
|
||||
fn pot_height(height: u32) -> u32 {
|
||||
let low = height & 0xff;
|
||||
let rounded = if low.is_power_of_two() || low == 0 {
|
||||
low
|
||||
} else {
|
||||
low.next_power_of_two()
|
||||
};
|
||||
height - low + rounded
|
||||
}
|
||||
|
||||
/// The client's power-of-two round-up (`0x4a833c`).
|
||||
fn next_pow2(n: i64) -> i64 {
|
||||
let mut p = 1i64;
|
||||
@@ -790,8 +840,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetMapInfo() → mapFileName and two texture dimensions (`0x4a7e30`, `mov eax,3`): nil, 0, 0
|
||||
// at world level. The name is the unlocalized art folder (`0x4a6cf0` reads `[row+0xc]`).
|
||||
// GetMapInfo() → mapFileName, textureHeight and its power-of-two round (`0x4a7e30`, `mov
|
||||
// eax,3`): nil, 0, 0 at world level. The name is the unlocalized art folder (`0x4a6cf0` reads
|
||||
// `[row+0xc]`).
|
||||
g.set(
|
||||
"GetMapInfo",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -811,13 +862,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
.unwrap_or_else(|| cont.map_file.clone()),
|
||||
}),
|
||||
};
|
||||
// No caller reads the dimensions, 0 here on every map; the reference computes them
|
||||
// from `WorldMapContinent.dbc` (`0x4a6d50`), which lacks an instance map's mapID.
|
||||
let file = match file {
|
||||
Some(f) => Value::String(lua.create_string(&f)?),
|
||||
None => Value::Nil,
|
||||
};
|
||||
Ok((file, 0i64, 0i64))
|
||||
let height = texture_height(wm);
|
||||
Ok((file, i64::from(height), i64::from(pot_height(height))))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -861,23 +911,31 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetPlayerMapPosition(unit) → map UV, or (0, 0) off the map; `player`, `party1`..`party4`
|
||||
// and `raid1`..`raid40` resolve, anything else is (0, 0).
|
||||
// GetPlayerMapPosition(unit) → map UV, or (0, 0) off the map (`0x4a8610`): a string or number
|
||||
// else the usage line (`0x4a861e`, `0x4a8627`), the token's guid through `GetGUIDFromName`
|
||||
// (`0x4a8658` → `0x515970`: any case, the `target` hops, "Unknown unit name" raised), and the
|
||||
// position by guid only for the player (`0x4a8668`), a party member (`0x4a8675`) or a raid
|
||||
// member (`0x4a8681`); anyone else leaves (0, 0).
|
||||
g.set(
|
||||
"GetPlayerMapPosition",
|
||||
lua.create_function(|lua, unit: String| {
|
||||
lua.create_function(|lua, unit: Value| {
|
||||
let token =
|
||||
binding_abi::string_arg(lua, unit, r#"Usage: GetPlayerMapPosition("player")"#)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let slot = |prefix: &str, list: &[Option<(f32, f32)>]| {
|
||||
unit.strip_prefix(prefix)
|
||||
.and_then(|n| n.parse::<usize>().ok())
|
||||
.filter(|n| *n >= 1)
|
||||
.and_then(|n| list.get(n - 1).copied().flatten())
|
||||
let guid = crate::script::unit::guid_of(lua, &model, &token)?.unwrap_or(0);
|
||||
let (guids, wm) = (&model.unit_guids, &model.worldmap);
|
||||
let at = |members: &[u64], uv: &[Option<(f32, f32)>]| {
|
||||
members
|
||||
.iter()
|
||||
.position(|g| *g == guid)
|
||||
.and_then(|n| uv.get(n).copied().flatten())
|
||||
};
|
||||
let (x, y) = match unit.as_str() {
|
||||
"player" => model.worldmap.player_uv,
|
||||
u if u.starts_with("party") => slot("party", &model.worldmap.party_uv),
|
||||
u if u.starts_with("raid") => slot("raid", &model.worldmap.raid_uv),
|
||||
_ => None,
|
||||
let (x, y) = if guid == 0 {
|
||||
None
|
||||
} else if guid == guids.player {
|
||||
wm.player_uv
|
||||
} else {
|
||||
at(&guids.party, &wm.party_uv).or_else(|| at(&guids.raid, &wm.raid_uv))
|
||||
}
|
||||
.unwrap_or((0.0, 0.0));
|
||||
Ok((f64::from(x), f64::from(y)))
|
||||
|
||||
+10
-10
@@ -279,7 +279,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
|
||||
#### spell
|
||||
|
||||
- An out-of-range byte drops the packet, because the reference's unchecked write overruns the table (`(64, 19)` lands on the class family at `0xcecaac`). ([`src/spell/mods.rs:132`](../crates/benilla-app/src/spell/mods.rs#L132))
|
||||
- The fallback is the packet's target guid, never the nearest-hostile acquire, because that would attack something the player never named. ([`src/spell/net.rs:1306`](../crates/benilla-app/src/spell/net.rs#L1306))
|
||||
- The fallback is the packet's target guid, never the nearest-hostile acquire, because that would attack something the player never named. ([`src/spell/net.rs:1321`](../crates/benilla-app/src/spell/net.rs#L1321))
|
||||
- A range failure leaves targeting armed and working. The reference's report cancels the cast (`0x6e2218` → `0x6e4940`): its line, `SPELLCAST_FAILED` and the tradeskill disarm, which benilla keeps; with the word the bind emptied it also sends `CMSG_CANCEL_CAST` for the unsent spell (`0x6e4970`) and clears the caster, then restores the pre-click word (`0x6e6096`), so every later click fails the caster test (`0x6e5b93`) and the cursor is stranded until a cancel. ([`src/spell/targeting/mod.rs:624`](../crates/benilla-app/src/spell/targeting/mod.rs#L624))
|
||||
|
||||
#### target
|
||||
@@ -412,7 +412,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
|
||||
|
||||
#### ui_pet_stats
|
||||
|
||||
- Fired when the bucket or its two tooltip figures change, not on every change of the happiness field as in the reference, because no stock consumer reads the raw value. ([`src/ui_pet_stats.rs:170`](../crates/benilla-app/src/ui_pet_stats.rs#L170))
|
||||
- Fired when the bucket or its two tooltip figures change, not on every change of the happiness field as in the reference, because no stock consumer reads the raw value. ([`src/ui_pet_stats.rs:168`](../crates/benilla-app/src/ui_pet_stats.rs#L168))
|
||||
|
||||
#### ui_quest
|
||||
|
||||
@@ -588,7 +588,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
|
||||
|
||||
#### spells
|
||||
|
||||
- Every token keys the `$l` plural. The reference's `$a`, `$d`, `$t`, `$e`, `$c`, `$p`, `$f`, `$F` and `$z` arms never write `[0xbe0b84]`, so its `$l` keys on the number before them, and Blizzard's "$s1 … every $t1 $lsecond:seconds;" reads "every 1 seconds" in 1.12.1. ([`src/spells/tokens.rs:687`](../crates/benilla-formats/src/spells/tokens.rs#L687))
|
||||
- Every token keys the `$l` plural. The reference's `$a`, `$d`, `$t`, `$e`, `$c`, `$p`, `$f`, `$F` and `$z` arms never write `[0xbe0b84]`, so its `$l` keys on the number before them, and Blizzard's "$s1 … every $t1 $lsecond:seconds;" reads "every 1 seconds" in 1.12.1. ([`src/spells/tokens.rs:690`](../crates/benilla-formats/src/spells/tokens.rs#L690))
|
||||
|
||||
#### tga
|
||||
|
||||
@@ -708,7 +708,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
|
||||
|
||||
#### script
|
||||
|
||||
- An out-of-range slot answers nil, because the reference indexes `0xbc6980` with no bounds check and reads the memory beside its 120 slots. ([`src/script/action.rs:425`](../crates/benilla-ui/src/script/action.rs#L425))
|
||||
- An out-of-range slot answers nil, because the reference indexes `0xbc6980` with no bounds check and reads the memory beside its 120 slots. ([`src/script/action.rs:441`](../crates/benilla-ui/src/script/action.rs#L441))
|
||||
- The files the handler writes beside the install (`<Name>.pub` with the key, `<Name>.url`; `0x51dce0`, `0x51dde0`) are not written, as the install is read-only; the key is never read either, as there is no signature to verify with it. ([`src/script/addon.rs:207`](../crates/benilla-ui/src/script/addon.rs#L207))
|
||||
- Holding the file, so the shutdown write keeps it. ([`src/script/addon.rs:787`](../crates/benilla-ui/src/script/addon.rs#L787))
|
||||
- The reference drops a send that resolves to PARTY while party slot 0's guid (`0xbc6f48`) is zero (`0x49fa9f`-`0x49faab`); benilla sends it, because `PartyState` is empty until the first `SMSG_GROUP_LIST` and the gate would swallow a real broadcast. vmangos drops a group-less party line anyway (`ChatHandler.cpp:476`). ([`src/script/addon_message.rs:7`](../crates/benilla-ui/src/script/addon_message.rs#L7))
|
||||
@@ -733,8 +733,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
|
||||
- A row the session owns (an env lever) is passed, because the env chose its value for this run and the file never holds it, so a reset would undo the lever and save nothing. ([`src/script/console_exec.rs:204`](../crates/benilla-ui/src/script/console_exec.rs#L204))
|
||||
- The backpack is bounded by its own 16 slots, where `0x4f990f` bounds bag 0 by the player list's count (0x71), a reference bug that lets the backpack index read other containers. ([`src/script/container.rs:327`](../crates/benilla-ui/src/script/container.rs#L327))
|
||||
- A row index the list no longer holds buys nothing and drops, where this path reads through a null row (`0x4f9f35`). ([`src/script/container.rs:825`](../crates/benilla-ui/src/script/container.rs#L825))
|
||||
- A last `spell_id` key breaks the remaining ties (Charge's six ranks all have `spellLevel` 0), because the reference leaves them to its qsort over an array benilla does not reproduce; ascending id is ascending rank in every 1.12 pet-ability family. ([`src/script/craft.rs:201`](../crates/benilla-ui/src/script/craft.rs#L201))
|
||||
- The reference reads past the table unbounded (`0x4f6c90`), so type 5 would answer "CloseCraft"; no shipped opener carries a type past 3, and benilla answers nil rather than read past it. ([`src/script/craft.rs:775`](../crates/benilla-ui/src/script/craft.rs#L775))
|
||||
- A last `spell_id` key breaks the remaining ties (Charge's six ranks all have `spellLevel` 0), because the reference leaves them to its qsort over an array benilla does not reproduce; ascending id is ascending rank in every 1.12 pet-ability family. ([`src/script/craft.rs:202`](../crates/benilla-ui/src/script/craft.rs#L202))
|
||||
- The reference reads past the table unbounded (`0x4f6c90`), so type 5 would answer "CloseCraft"; no shipped opener carries a type past 3, and benilla answers nil rather than read past it. ([`src/script/craft.rs:788`](../crates/benilla-ui/src/script/craft.rs#L788))
|
||||
- On `Terrain` the reference keeps a spell, action, macro or pet action on the left button; we clear them, because otherwise a spell has no left-click way off the cursor. ([`src/script/cursor.rs:739`](../crates/benilla-ui/src/script/cursor.rs#L739))
|
||||
- Every lock lifts here, bag contents included. The reference's walk of a bag's contents indexes the bag by its own player-array index (`0x495528`, `0x495535`), at least 19, past a bag's slots in every case but the biggest bags, so it normally unlocks none of them; a lock it strands lasts until the item's seat or stack moves, which this does not copy. ([`src/script/cursor.rs:948`](../crates/benilla-ui/src/script/cursor.rs#L948))
|
||||
- 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. ([`src/script/cursor.rs:1188`](../crates/benilla-ui/src/script/cursor.rs#L1188))
|
||||
@@ -808,11 +808,11 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
|
||||
- The reference recurses without a bound (`0x52dbce` builds the product whatever the depth), so a product that teaches a recipe for itself would recurse until the stack overflows; benilla stops here, so a cyclic `Spell.dbc` chain cannot crash the client. ([`src/script/tooltip_item/render.rs:199`](../crates/benilla-ui/src/script/tooltip_item/render.rs#L199))
|
||||
- The reference's padding repeats a mana stat once per zero entry; not emulated, since only the internal test ring 6674 has one. ([`src/script/tooltip_item/render.rs:568`](../crates/benilla-ui/src/script/tooltip_item/render.rs#L568))
|
||||
- The unknown-skill "Requires %s" shares it, though its reference red is `0xffff0000`, because a 32/255 difference in G and B does not show. ([`src/script/tooltip_unit.rs:126`](../crates/benilla-ui/src/script/tooltip_unit.rs#L126))
|
||||
- The selection clears to 0, because the header the reference re-selects (`0x4d7b42`, reading 1) has no spell id; the stock window's `> 1` test treats 0 and 1 alike. ([`src/script/trainer/mod.rs:306`](../crates/benilla-ui/src/script/trainer/mod.rs#L306))
|
||||
- A header clears it too, because it has no spell id; the reference stores its `0xffffffff` (`0x4d7b05`) and reads back 1, which the stock window's `> 1` test treats the same as 0. ([`src/script/trainer/mod.rs:1278`](../crates/benilla-ui/src/script/trainer/mod.rs#L1278))
|
||||
- Index 0 is a no-op, because one stray call would spend the purse; the reference buys every visible service for any index at or below 0 (`0x4da244`, `0x4d8a70`). ([`src/script/trainer/mod.rs:1302`](../crates/benilla-ui/src/script/trainer/mod.rs#L1302))
|
||||
- The selection clears to 0, because the header the reference re-selects (`0x4d7b42`, reading 1) has no spell id; the stock window's `> 1` test treats 0 and 1 alike. ([`src/script/trainer/mod.rs:313`](../crates/benilla-ui/src/script/trainer/mod.rs#L313))
|
||||
- A header clears it too, because it has no spell id; the reference stores its `0xffffffff` (`0x4d7b05`) and reads back 1, which the stock window's `> 1` test treats the same as 0. ([`src/script/trainer/mod.rs:1291`](../crates/benilla-ui/src/script/trainer/mod.rs#L1291))
|
||||
- Index 0 is a no-op, because one stray call would spend the purse; the reference buys every visible service for any index at or below 0 (`0x4da244`, `0x4d8a70`). ([`src/script/trainer/mod.rs:1315`](../crates/benilla-ui/src/script/trainer/mod.rs#L1315))
|
||||
- Kept apart from `Self::vertex_color`, which the reference's `Texture:SetAlpha` (`0x79b580`) rewrites, because where stock sets both on one region (`PlayerFrame.lua:25-26`, `TargetFrame.lua:408` with `420-426`) the drawn product differs for about one frame, and a FontString's `SetAlpha` then keeps its font colour. ([`src/script/types.rs:428`](../crates/benilla-ui/src/script/types.rs#L428))
|
||||
- Among whole-name matches ours picks the nearest, because the reference's first-walked pick is order-dependent and reads as a bug. ([`src/script/unit/bindings.rs:1066`](../crates/benilla-ui/src/script/unit/bindings.rs#L1066))
|
||||
- Among whole-name matches ours picks the nearest, because the reference's first-walked pick is order-dependent and reads as a bug. ([`src/script/unit/bindings.rs:1074`](../crates/benilla-ui/src/script/unit/bindings.rs#L1074))
|
||||
- The reference's matcher takes a whole-string hit at once only when it is nearer than the best so far (`0x493c88`-`0x493ca2`); an exact hit that is not nearer competes on prefix length alone (`0x493cf8`-`0x493d09`), so a nearer "Bobby" walked first beats an exact "Bob", and the pick follows the object list's order. ([`src/script/unit/by_name.rs:19`](../crates/benilla-ui/src/script/unit/by_name.rs#L19))
|
||||
- A full tie goes to the lower guid, where the reference keeps the first unit its walk (`0x493aa0`) reaches, an order neither of ours has; a guid keeps the two picks equal. ([`src/script/unit/by_name.rs:61`](../crates/benilla-ui/src/script/unit/by_name.rs#L61))
|
||||
- `0x4e81d0` has no bound and reads past its four-entry table, which is stale memory, not a mechanism; past it, nobody. ([`src/script/unit/resolve.rs:85`](../crates/benilla-ui/src/script/unit/resolve.rs#L85))
|
||||
|
||||
Reference in New Issue
Block a user