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:
Sam Sung
2026-10-27 05:04:00 +07:00
committed by GitHub
parent b18d08f64f
commit ad0fd89729
45 changed files with 1428 additions and 322 deletions
+1 -1
View File
@@ -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"));
}
+20 -5
View File
@@ -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]);
}
+3
View File
@@ -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(),
+93 -1
View File
@@ -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();
+1 -1
View File
@@ -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`.
+12 -3
View File
@@ -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,
}
}
+4 -9
View File
@@ -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 {
+62 -1
View File
@@ -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]
+14 -4
View File
@@ -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
),
}
}
+89 -14
View File
@@ -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,
+178 -15
View File
@@ -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");
}
+20 -2
View File
@@ -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()
+1
View File
@@ -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),
+11 -6
View File
@@ -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>."
);
}
+14 -4
View File
@@ -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)?;
+65 -5
View File
@@ -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]
+23 -8
View File
@@ -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);
}
}
+55 -10
View File
@@ -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());
+11 -5
View File
@@ -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);
}
}
+13 -7
View File
@@ -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]
+16 -9
View File
@@ -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);
}
+60 -17
View File
@@ -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
+12 -3
View File
@@ -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(),
+124 -69
View File
@@ -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);
}
+30 -6
View File
@@ -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();
+13 -8
View File
@@ -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
+71 -20
View File
@@ -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).
+59 -46
View File
@@ -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]
+13 -5
View File
@@ -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))
})?,
)?;
+9
View File
@@ -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()
}
}
+76 -18
View File
@@ -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
View File
@@ -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))