The fourteen missing 1.12.1 functions are defined, the keyring hover runs the stock path, and the stock CloseAllBags keeps a backpack that was open (#581)

The fourteen 1.12.1 functions #336 lists are defined, each derived from its reference address, and benilla's layer drops the two places it stood in for the stock bag code.

- **Keyring:** `KeyRingButtonIDToInvSlotID` (`0x4c8150`) exists, and the keyring slots answer as inventory slots 82-113. The stock `ContainerFrame.lua:616-619` hover path now runs unchanged, and the layer's keyring `OnEnter` wrapper is deleted.
- **Bags:** the layer no longer replaces `CloseAllBags`. The stock one (`ContainerFrame.lua:702-707`) closes the backpack only when it was not open before the vendor or mailbox opened it (#541). The deviation that opens every bag stays, and its comment now names its only callers.
- **Inventory:** `ShowInventorySellCursor`, `SetInventoryPortaitTexture` and `UpdateInventoryAlertStatus` exist.
- **Character and pet:** `GetDamageBonusStat` (ChrClasses field 2), `GetPetTimeRemaining` (armed from `SMSG_PET_SPELLS`) and `GetActionAutocast` exist.
- **Mail:** `ClearSendMail`, `GetNumPackages`, `GetPackageInfo` and `SelectPackage` exist, over a new `Package.dbc` loader. A selected package rides in the mail send. The compose reset now also clears the stationery, the package and the attachment, as `0x4acdc0` does.
- **Tabard:** `GetTabardInfo` opens the tabard designer through the registrar (`0x5e00e0`).
- **World map:** `GetWorldLocMapPosition` projects a map position onto the displayed map (`0x4a7360`).
- **Loot:** `SetLootPortrait` binds a unit loot source's portrait (`0x4c2b40`).

Verified:
- A test per function for its shapes, raises and effects, failing on main.
- A keyring hover test through the stock path, failing on main.
- A backpack-over-vendor test, failing on main.
- `scripts/gates.sh` and `scripts/smoke.sh` are green.
- A live A/B run: hovering a key asked for `player:82` and showed the key's tooltip. After a vendor, opening the Help panel left the backpack open and closed bag 1; on main the backpack closed.

Built on macOS; no platform seam touched.

Fixes #336
Fixes #541

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-29 03:19:56 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 48afcbce0a
commit 33c2c5aa70
42 changed files with 1718 additions and 107 deletions
@@ -1,56 +1,18 @@
<!--
Adapters over the stock ContainerFrame.xml and .lua; loaded after them, so these win.
Deviation: our keyring is a container (id -2), not inventory slots as in the reference, so its
tooltip comes from `SetBagItem`; the stock fork (ContainerFrame.lua:615-620) reads the slots.
A full keyring prints an empty error line as the reference does: `PutKeyInKeyRing` reads
`NO_EMPTY_KEYRING_SLOTS`, which 1.12 does not ship.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- The stock body, captured before it is shadowed; nil when no client data was sourced.
local refItemButtonOnEnter = ContainerFrameItemButton_OnEnter
-- The stock name, redefined: the stock item buttons call it, and our keyring slot needs
-- the bag-slot tooltip.
function ContainerFrameItemButton_OnEnter(button)
if not button then
button = this
end
if not button then return end
-- The stock bag-slot path, not its inventory-slot keyring detour.
if button:GetParent():GetID() == KEYRING_CONTAINER then
if (button:GetRight() or 0) >= (GetScreenWidth() / 2) then
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
else
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
end
GameTooltip:SetBagItem(KEYRING_CONTAINER, button:GetID())
ResetCursor()
return
end
if not refItemButtonOnEnter then
message("benilla: Interface\\FrameXML\\ContainerFrame.lua was not sourced from the "
.. "game data, so bag-slot tooltips cannot work. Check the client data path.")
return
end
refItemButtonOnEnter(button)
end
-- ══ The three bag verbs benilla answers differently ═══════════════════════════════════
-- Deviation: `OpenBackpack` opens every equipped bag, not only the backpack, so the vendor,
-- bank, mail and trade windows that call it bring up every bag to sell or deposit from.
-- The was-open memory is keyed by bag id: `ContainerFrame1..12` are recycled across bags.
-- ══ The two bag verbs benilla answers differently ═════════════════════════════════════
-- Deviation: `OpenBackpack` opens every equipped bag, not only the backpack, so the vendor
-- and mailbox windows that call it (MerchantFrame.lua:35, MailFrame.lua:39) bring up every
-- bag to sell or mail from. The was-open memory is keyed by bag id: `ContainerFrame1..12`
-- are recycled across bags. The stock `CloseAllBags` (ContainerFrame.lua:702-707) runs
-- unchanged: it calls this `CloseBackpack`, then closes bags 1 and up.
BENILLA_BAG_WAS_OPEN = {}
-- Ignores the was-open memory and covers the bank bags, as ContainerFrame.lua:702-707 does.
function BenillaCloseAllBags()
for bagId = 0, (NUM_CONTAINER_FRAMES or -1) do
CloseBag(bagId)
end
end
-- The reference's `backpackWasOpen`, one entry per bag: CloseBackpack leaves those open.
function BenillaOpenBackpack()
for bagId = 0, (NUM_BAG_FRAMES or -1) do
@@ -75,9 +37,8 @@
end
-- The stock names, redefined after the stock ContainerFrame.lua has loaded, so its callers
-- (the vendor, bank, mail and trade windows) reach these.
-- (the vendor and mailbox windows, the vendor's close and `CloseAllBags`) reach these.
OpenBackpack = BenillaOpenBackpack
CloseBackpack = BenillaCloseBackpack
CloseAllBags = BenillaCloseAllBags
]]></Script>
</Ui>
+1 -1
View File
@@ -13,7 +13,7 @@ KeyBindingsPage.xml
OptionsFrame.xml
# The ESC menu's era look on the stock `GameMenuFrame`, whose Options rung opens ours.
GameMenuAdapters.xml
# Wraps the stock bag verbs; the keyring is a container (id -2).
# `OpenBackpack` and `CloseBackpack` over every equipped bag, for the vendor and the mailbox.
ContainerFrameAdapters.xml
# The pet page's autocast widgets in the pet action button's proportions.
SpellBookAdapters.xml
+4
View File
@@ -888,6 +888,10 @@ fn setup_entities(
Ok(catalog) => commands.insert_resource(crate::ui_mail::Stationery(catalog)),
Err(e) => warn!("stationery catalog unavailable, mail uses the default backdrop: {e:#}"),
}
match benilla_formats::load_packages(&mut chain) {
Ok(rows) => commands.insert_resource(crate::ui_mail::Packages(rows)),
Err(e) => warn!("mail packages unavailable, GetNumPackages answers 0: {e:#}"),
}
match benilla_formats::load_page_text_material_catalog(&mut chain) {
Ok(catalog) => commands.insert_resource(crate::ui_item_text::PageMaterials(catalog)),
Err(e) => warn!("page text materials unavailable, books read on parchment: {e:#}"),
+2
View File
@@ -1402,6 +1402,8 @@ pub(crate) enum ClientCommand {
body: String,
/// The `Stationery.dbc` id, the sixth field.
stationery: u32,
/// The `Package.dbc` id, the seventh field, 0 without an item.
package: u32,
item_guid: u64,
money: u32,
cod: u32,
+4 -3
View File
@@ -1232,14 +1232,15 @@ fn writer_loop(
subject,
body,
stationery,
package,
item_guid,
money,
cod,
} => w.send_mail(
mailbox, &receiver, &subject, &body,
// The chosen stationery and package 0, as the reference sends; vmangos
// stores `MAIL_STATIONERY_DEFAULT` (41) regardless.
stationery, 0, item_guid, money, cod,
// vmangos stores `MAIL_STATIONERY_DEFAULT` (41) regardless of the chosen
// stationery, and reads the package id (`Packets/Mail.cpp:10`) unused.
stationery, package, item_guid, money, cod,
),
ClientCommand::MailTakeMoney { mailbox, mail_id } => {
w.mail_take_money(mailbox, mail_id)
+20 -3
View File
@@ -64,8 +64,9 @@ use light::{model_pane_light, studio_light, BoothLight};
pub(crate) mod test_bake;
/// The round portrait slots, each with its own layer and camera. `"npc"` is an interaction
/// window's NPC ([`crate::ui_session::InteractNpc`]); `"targettarget"` resolves only while drawn.
const SLOTS: [&str; 9] = [
/// window's NPC ([`crate::ui_session::InteractNpc`]); `"targettarget"` resolves only while drawn,
/// and so does the loot source `SetLootPortrait` binds ([`benilla_ui::script::LOOT_PORTRAIT_UNIT`]).
const SLOTS: [&str; 10] = [
"player",
"target",
"targettarget",
@@ -75,6 +76,7 @@ const SLOTS: [&str; 9] = [
"party2",
"party3",
"party4",
benilla_ui::script::LOOT_PORTRAIT_UNIT,
];
/// The character window's full-body pane: the dressed player, sampled square.
const PAPERDOLL_SLOT: &str = "paperdoll";
@@ -658,8 +660,10 @@ pub(crate) struct PartyBooths<'w, 's> {
palettes: ResMut<'w, benilla_world::rig_palette::RigPalettes>,
pet_bar: Res<'w, crate::ui_pet::PetBar>,
names: Res<'w, crate::names::NameCache>,
/// What the UI drew last frame, read by `"targettarget"` alone.
/// What the UI drew last frame, read by `"targettarget"` and the loot source.
panes: Res<'w, BoothPanes>,
/// The open loot, whose source `SetLootPortrait` bakes (`0x4c2bc5`, `[0xb71b48]`).
loot: Option<Res<'w, crate::ui_loot::LootState>>,
/// The guid-keyed bake cache, and a fresh target plus the camera's [`RenderTarget`] for the
/// handover.
bakes: ResMut<'w, PortraitBakes>,
@@ -1326,6 +1330,19 @@ fn sync_portraits(
entity
}
"npc" => interact_npc.0,
// Gated on the UI drawing it, as `"targettarget"`: no stock file binds it.
benilla_ui::script::LOOT_PORTRAIT_UNIT => party
.panes
.0
.contains_key(token)
.then(|| {
party
.loot
.as_ref()
.and_then(|l| l.source())
.and_then(|g| party.index.0.get(&g).copied())
})
.flatten(),
// Out of range, the stand-in's race and sex come from the name cache.
tok => {
let member = tok
+84 -1
View File
@@ -523,6 +523,8 @@ fn snapshot(
rows,
fishing: loot.fishing,
master_candidates: who.names_for(loot),
// The feed's, which holds the object manager.
source_unit: false,
})
}
@@ -637,6 +639,30 @@ fn drain_receives(
}
/// Emits the queued pushes, then pushes the loot snapshot and fires the loot events on a change.
/// The object manager as `SetLootPortrait` asks it (`0x468460`, typemask 8): whether the loot
/// source is a unit object we hold.
#[derive(bevy::ecs::system::SystemParam)]
struct LootSourceObjects<'w, 's> {
index: Option<Res<'w, crate::net::GuidIndex>>,
stores: Query<'w, 's, &'static crate::net::ObjectStore>,
}
impl LootSourceObjects<'_, '_> {
fn is_unit(&self, guid: u64) -> bool {
use benilla_protocol::messages::ObjectType;
self.index
.as_ref()
.and_then(|index| index.0.get(&guid))
.and_then(|&e| self.stores.get(e).ok())
.is_some_and(|s| {
matches!(
s.0.object_type(),
Some(ObjectType::Unit | ObjectType::Player)
)
})
}
}
fn feed_loot(
script: Option<NonSendMut<UiScript>>,
mut loot: ResMut<LootState>,
@@ -653,6 +679,7 @@ fn feed_loot(
enchants: Option<Res<crate::items::Enchants>>,
group: Res<GroupState>,
names: Res<NameCache>,
objects: LootSourceObjects,
) {
let Some(mut script) = script else {
return;
@@ -676,7 +703,10 @@ fn feed_loot(
group: &group,
names: &names,
};
let fresh = snapshot(&loot, &items, icons.as_deref(), &commands, rolls, who);
let fresh = snapshot(&loot, &items, icons.as_deref(), &commands, rolls, who).map(|mut snap| {
snap.source_unit = loot.source().is_some_and(|g| objects.is_unit(g));
snap
});
if fresh == *last {
return;
}
@@ -1137,6 +1167,59 @@ mod tests {
(app, rx)
}
/// `SetLootPortrait` answers 1 only while the loot source is a unit object we hold
/// (`0x4c2bd1`-`0x4c2be9`): a creature's corpse, never a chest or a source out of view.
#[test]
fn the_loot_portrait_follows_a_unit_source_only() {
use benilla_protocol::ObjectFields;
for (unit, expected) in [(true, true), (false, false)] {
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_message::<crate::sound::LootPickupSound>()
.init_resource::<LootState>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<GroupState>()
.init_resource::<NameCache>()
.init_resource::<Items>()
.init_resource::<ButtonInput<KeyCode>>()
.init_resource::<LootConfig>()
.init_resource::<crate::net::GuidIndex>()
.insert_resource(NetCommands(tx))
.add_systems(bevy::prelude::Update, feed_loot);
// OBJECT_FIELD_TYPE (2): TYPEMASK_OBJECT | TYPEMASK_UNIT for a creature, | 0x20 for a
// game object.
let kind = if unit { 0x9 } else { 0x21 };
let e = app
.world_mut()
.spawn(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
2, kind,
)])))
.id();
app.world_mut()
.resource_mut::<crate::net::GuidIndex>()
.0
.insert(0x42, e);
app.world_mut().resource_mut::<LootState>().open(
0x42,
loot_type::CORPSE,
5,
Vec::new(),
);
let script = UiScript::new().unwrap();
script
.run(r#"CreateFrame("Frame", "LootHost"):CreateTexture("LootTex")"#)
.unwrap();
app.insert_non_send_resource(script);
app.update();
let answered = app
.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<bool>("return SetLootPortrait(LootTex) == 1")
.unwrap();
assert_eq!(answered, expected, "unit source: {unit}");
}
}
/// `0x4c2ac0` fires `LOOT_OPENED` once, at the last answer; here a negative one opens it too.
#[test]
fn the_window_waits_for_every_item_template() {
+22 -1
View File
@@ -172,7 +172,7 @@ fn rebind_macro_spells(
mut bound: ResMut<MacroBoundSpells>,
mut last_generation: Local<crate::ui_script::VmMemo<Option<u64>>>,
) {
let (Some(script), Some(table)) = (script, table) else {
let (Some(mut script), Some(table)) = (script, table) else {
return;
};
// Per VM: a fresh VM restarts its generation at 0, which a bare memo would gate off for good.
@@ -191,6 +191,27 @@ fn rebind_macro_spells(
};
fresh.insert(index, run::bound_spell(&table, &m.body, &book));
}
// The engine's copy, for `GetActionAutocast`, re-pushed on every recompute so a fresh VM has
// it. The book flag stays the player's: `run::bound_spell` resolves the player's book only.
script.set_macro_bindings(
fresh
.iter()
.map(|(&index, b)| {
let spell = match *b {
BoundSpell::None => 0,
BoundSpell::Unresolved => -1,
BoundSpell::Spell(id) => id as i32,
};
(
index,
benilla_ui::script::MacroBinding {
spell,
pet_book: false,
},
)
})
.collect(),
);
if fresh != bound.0 {
let spells = fresh
.values()
+49 -4
View File
@@ -51,6 +51,21 @@ const STATIONERY_ICON_GM: &str = "Interface\\Icons\\Mail_GMIcon";
#[derive(Resource)]
pub(crate) struct Stationery(pub(crate) benilla_formats::StationeryCatalog);
/// `Package.dbc`'s rows, in file order; without them `GetNumPackages()` answers 0.
#[derive(Resource)]
pub(crate) struct Packages(pub(crate) Vec<benilla_formats::PackageRow>);
/// A package row as the engine answers it: the icon under `StringLookups.dbc` row 3's folder
/// (`0x4ae4ab`, the `"%s%s"` of folder and `\`, then the basename appended).
fn package_view(row: &benilla_formats::PackageRow) -> benilla_ui::script::PackageView {
benilla_ui::script::PackageView {
id: row.id,
name: row.name.clone(),
icon: format!("Interface\\Icons\\{}", row.icon),
cost: row.cost,
}
}
/// A `SEND` result queued for [`feed_mail`]. `MAIL_FAILED` fires on every one, success included:
/// the reference uses it as "the send resolved" (`0x4ad15f`), and the stock handler only
/// re-enables the button.
@@ -422,6 +437,9 @@ struct MailFeedExtras<'w, 's> {
enchants: Option<Res<'w, crate::items::Enchants>>,
sink: crate::ui_action::MessageSink<'w>,
stationeries: Local<'s, crate::ui_script::VmMemo<Vec<StationeryView>>>,
packages: Option<Res<'w, Packages>>,
/// The package rows went to this VM.
packages_pushed: Local<'s, crate::ui_script::VmMemo<bool>>,
}
fn feed_mail(
@@ -451,7 +469,16 @@ fn feed_mail(
enchants,
mut sink,
stationeries: mut last_stationeries,
packages,
mut packages_pushed,
} = extras;
if let Some(packages) = packages.as_deref() {
let pushed = packages_pushed.get(&script);
if !*pushed {
*pushed = true;
script.set_mail_packages(packages.0.iter().map(package_view).collect());
}
}
let rolls = crate::items::RollCatalogs {
props: props.as_deref(),
enchants: enchants.as_deref(),
@@ -538,14 +565,14 @@ fn feed_mail(
script.set_mail_stationeries(usable);
}
if opened {
// The selection clears on open and close (`0x4ace07`); `MAIL_SHOW`'s reset picks row 1.
script.clear_stationery();
// The open core (`0x4acd10`) resets the compose tab before `MAIL_SHOW`; the reset's
// The open core (`0x4acd10`) resets the compose tab before `MAIL_SHOW`, the stationery
// selection with it (`0x4ace07`), which `MAIL_SHOW`'s reset re-picks as row 1; the reset's
// `MAIL_SEND_SUCCESS`, page-turn sound and all, is the reference's too.
script.reset_compose_tab();
script.fire_event("MAIL_SHOW", vec![]);
} else if closed {
script.clear_stationery();
// The close core's silent reset (`0x4acd50` → `0x4acdc0(0)`).
script.reset_compose_tab_silently();
script.fire_event("MAIL_CLOSED", vec![]);
// The close core's tail (`0x4acdad`, `0x4acdb1`): after a read, or an arrival while open,
// re-ask the server, stamping "no mail". `CloseMail` and the range close both land here.
@@ -699,6 +726,8 @@ fn drain_mail(
item_guid: item_guid.unwrap_or(0),
money: req.money,
cod: req.cod,
// The package id rides only with an item (`0x4ae8f3`).
package: if item_guid.is_some() { req.package } else { 0 },
});
}
}
@@ -713,6 +742,22 @@ mod tests {
use super::*;
use benilla_protocol::messages::MailAttachment;
/// `GetPackageInfo`'s icon is the basename under `Interface\Icons` (`0x4ae4c6`, `0x4ae4eb`).
#[test]
fn a_package_row_answers_its_icon_under_the_icon_folder() {
let view = package_view(&benilla_formats::PackageRow {
id: 2,
icon: "INV_BOX_04".into(),
cost: 10,
name: "Test Package".into(),
});
assert_eq!(view.icon, "Interface\\Icons\\INV_BOX_04");
assert_eq!(
(view.id, view.cost, view.name.as_str()),
(2, 10, "Test Package")
);
}
/// The result table at `0x4ad17c` (indexed at `0x4ad1a0`), asserted as the message ids its
/// arms push.
#[test]
+5
View File
@@ -202,6 +202,11 @@ pub(super) fn feed_pet_bar(
// but its buttons work, so possession stays out of `usable`.
let pickup_allowed = pet_flags.unwrap_or(0) & UNIT_FLAG_POSSESSED == 0;
let pet_attacking = bar.attacking;
// `GetPetTimeRemaining`'s expiry on the VM clock, signed both ways around the anchor sample.
script.set_pet_expiry(bar.expires.map(|t| match t.checked_duration_since(anchor) {
Some(ahead) => ui_now + ahead.as_secs_f64(),
None => ui_now - anchor.duration_since(t).as_secs_f64(),
}));
let fresh: Vec<PetActionView> = if has_bar {
bar.spells
+5
View File
@@ -3,6 +3,8 @@
//! data (vmangos `CharmInfo::InitPetActionBar`), not a layout: a possessed or charmed unit fills
//! the same ten words differently, so nothing here assumes them.
use std::time::Instant;
use bevy::prelude::*;
use benilla_protocol::messages::PetSpells;
@@ -47,6 +49,9 @@ pub(crate) struct PetBar {
/// `0x4bc960`), and `OnClick`'s `SetChecked(0)` needs that repaint to relight a press on the
/// current mode, so the count is in the feed's dedup key. Wrapping: only a change is read.
pub(crate) bar_signals: u32,
/// `[0xb714a8]`: the charm or possess expiry, the packet's duration past its arrival; `None`
/// when the duration is 0, as for a hunter's or warlock's own pet.
pub(crate) expires: Option<Instant>,
}
impl PetBar {
+25
View File
@@ -154,6 +154,9 @@ fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut PetBar) {
.join(" · ")
);
let now = Instant::now();
// `SetPet` (`0x4bc7e0`) re-arms the expiry on every packet, and a 0 duration clears it.
bar.expires = (spells.duration_ms != 0)
.then(|| now + std::time::Duration::from_millis(u64::from(spells.duration_ms)));
bar.cooldowns = crate::spell::Cooldowns::default();
for cd in &spells.cooldowns {
let display = catalog.and_then(|c| c.catalog.get(cd.spell_id));
@@ -282,6 +285,28 @@ mod tests {
assert!(!bar.has_bar());
}
/// `SetPet` (`0x4bc7e0`): each packet re-arms the expiry from its own duration, 0 clears it,
/// and the teardown clears it too.
#[test]
fn each_pet_packet_rearms_or_clears_the_expiry() {
let mut bar = PetBar::default();
let mut charm = a_bar(0x2A);
charm.duration_ms = 60_000;
let before = Instant::now();
pet_spells(charm.clone(), None, &mut bar);
let left = bar.expires.expect("armed").duration_since(before);
assert!(
left >= std::time::Duration::from_secs(60) && left < std::time::Duration::from_secs(61)
);
pet_spells(a_bar(0x2A), None, &mut bar);
assert_eq!(bar.expires, None, "a 0 duration clears it");
pet_spells(charm, None, &mut bar);
assert!(bar.expires.is_some());
pet_spells(PetSpells::default(), None, &mut bar);
assert_eq!(bar.expires, None, "the teardown clears it");
}
#[test]
fn pet_mode_only_writes_its_own_bar() {
let mut bar = PetBar::default();
@@ -173,6 +173,8 @@ pub(super) fn drain_petition(
self_guid: Res<SelfGuid>,
selection: Res<crate::target::Selection>,
commands: Res<NetCommands>,
tabard: Res<crate::ui_tabard::TabardOpen>,
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
) {
let Some(mut script) = script else {
return;
@@ -212,6 +214,19 @@ pub(super) fn drain_petition(
}
// Sends nothing.
PetitionRequest::CloseRegistrar => registrar.close(),
PetitionRequest::TabardInfo => {
let npc = registrar.npc().unwrap_or(0);
if let Some(store) = self_q.iter().next() {
tabard_vendor_activate(
npc,
tabard.npc().unwrap_or(0),
store,
&commands,
&mut errors,
);
}
registrar.close();
}
PetitionRequest::ClosePetition => {
petition.decline_on_close(me, &commands);
petition.close();
@@ -263,3 +278,77 @@ pub(super) fn drain_petition(
}
}
}
/// `0x5e00e0`, the designer's opener, for `GetTabardInfo` with the registrar's guid `npc`
/// (`0x4f5338`): nothing while the designer is open on that guid (`0x5e00e6`-`0x5e00fc`, so two
/// zero guids send nothing), the refusal while the player's `UNIT_FIELD_DISPLAYID` is not its
/// `UNIT_FIELD_NATIVEDISPLAYID` (`0x5e0102`-`0x5e0116`, error `0x130`), else the packet.
fn tabard_vendor_activate(
npc: u64,
designer: u64,
player: &ObjectStore,
commands: &NetCommands,
errors: &mut crate::ui_action::UiErrorKeys,
) {
if designer == npc {
return;
}
if player.0.unit_displayid() != player.0.unit_native_displayid() {
errors.0.push(crate::ui_action::UiError::key(
"ERR_EMBLEMERROR_NOTABARDGEOSET",
));
return;
}
let _ = commands.0.send(ClientCommand::TabardVendorActivate { npc });
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_action::UiErrorKeys;
use benilla_protocol::ObjectFields;
/// The player's display ids, `UNIT_FIELD_DISPLAYID` (131) and `UNIT_FIELD_NATIVEDISPLAYID`.
fn player(display: u32, native: u32) -> ObjectStore {
ObjectStore(ObjectFields::from_pairs(&[(131, display), (132, native)]))
}
fn run(npc: u64, designer: u64, me: &ObjectStore) -> (Vec<ClientCommand>, Vec<&'static str>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut errors = UiErrorKeys::default();
tabard_vendor_activate(npc, designer, me, &NetCommands(tx), &mut errors);
(
rx.try_iter().collect(),
errors.0.iter().map(|e| e.key).collect(),
)
}
#[test]
fn get_tabard_info_opens_the_designer_for_the_registrar() {
let (sent, errors) = run(0x42, 0, &player(49, 49));
assert!(
matches!(
sent.as_slice(),
[ClientCommand::TabardVendorActivate { npc: 0x42 }]
),
"{sent:?}"
);
assert!(errors.is_empty());
}
#[test]
fn get_tabard_info_sends_nothing_to_a_designer_already_open_on_that_npc() {
let (sent, errors) = run(0x42, 0x42, &player(49, 49));
assert!(sent.is_empty() && errors.is_empty());
// With no registrar and no designer both guids are 0: equal, so nothing.
let (sent, _) = run(0, 0, &player(49, 49));
assert!(sent.is_empty());
}
#[test]
fn get_tabard_info_refuses_a_shapeshifted_player() {
let (sent, errors) = run(0x42, 0, &player(2281, 49));
assert!(sent.is_empty());
assert_eq!(errors, ["ERR_EMBLEMERROR_NOTABARDGEOSET"]);
}
}
@@ -1647,6 +1647,52 @@ fn the_keyring_button_opens_a_keyring_window() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A hovered key's tooltip comes through the stock keyring arm of `ContainerFrameItemButton_OnEnter`
/// (`ContainerFrame.lua:616-619`): `SetInventoryItem("player", KeyRingButtonIDToInvSlotID(id))`,
/// the key's inventory slot 82 and up, answered from the keyring container.
#[test]
fn a_hovered_key_shows_its_tooltip_through_its_inventory_slot() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
keyring_surface(&s);
s.set_money(0);
s.set_has_key(true);
seat_player_at_level(&mut s, 44);
s.set_container(-2, Some(keyring(8, true)));
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
s.run("KeyRingButton:Click()").unwrap();
assert!(open(&s, -2), "fixture: the keyring is open");
// Which slot the tooltip is asked for: the frame's own field shadows the method table.
s.run(
r#"local method = GameTooltip.SetInventoryItem
GameTooltip.SetInventoryItem = function(self, unit, slot)
ASKED = unit .. ":" .. slot
return method(self, unit, slot)
end"#,
)
.unwrap();
s.resolve();
let key = bag_slot_button(&s, -2, 1);
hover(&mut s, &key);
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
assert_eq!(
s.eval::<Option<String>>("return ASKED").unwrap().as_deref(),
Some("player:82"),
"the stock arm asks for key 1's inventory slot"
);
assert!(s.eval::<bool>("return GameTooltip:IsVisible()").unwrap());
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"The Scarlet Key"
);
unhover(&mut s);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Dropping a held key on the button files it in the first free keyring slot (stock
/// `PutKeyInKeyRing`); a full keyring refuses the drop.
#[test]
@@ -65,6 +65,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_keeps_the_held_item() {
// Open the bag and the loot window; drain their open sounds.
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.set_loot(Some(LootState {
source_unit: false,
fishing: false,
master_candidates: Vec::new(),
rows: vec![Some(LootRow {
@@ -237,6 +237,7 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
s.set_container(0, Some(backpack()));
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.set_loot(Some(LootState {
source_unit: false,
fishing: false,
master_candidates: Vec::new(),
rows: vec![Some(LootRow {
@@ -42,6 +42,7 @@ fn text_color(quads: &[ExtractedQuad], t: &str) -> Option<[f32; 4]> {
fn coin_and_two_items() -> LootState {
LootState {
source_unit: false,
fishing: false,
master_candidates: Vec::new(),
rows: vec![
@@ -279,6 +280,7 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
assert!(s.take_sounds().is_empty(), "loot close is silent (C-side)");
s.set_loot(Some(LootState {
source_unit: false,
fishing: false,
master_candidates: Vec::new(),
rows: vec![],
@@ -373,6 +375,7 @@ fn shipped_loot_frame_pages_five_items() {
})
.collect();
s.set_loot(Some(LootState {
source_unit: false,
fishing: false,
master_candidates: Vec::new(),
rows: rows.clone(),
@@ -413,6 +416,7 @@ fn shipped_loot_frame_pages_five_items() {
cleared[1] = None;
cleared[2] = None;
s.set_loot(Some(LootState {
source_unit: false,
fishing: false,
master_candidates: Vec::new(),
rows: cleared,
@@ -577,6 +581,7 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
}
s.set_loot(Some(LootState {
source_unit: false,
fishing: false,
master_candidates: Vec::new(),
rows: vec![
@@ -710,6 +715,7 @@ fn shipped_loot_frame_hands_a_master_row_to_a_candidate() {
})
};
s.set_loot(Some(LootState {
source_unit: false,
fishing: false,
master_candidates: vec![Some("Thrall".into()), Some("Cairne".into())],
rows: vec![row("Wool Cloth", 2), row("Thunderfury", 4)],
@@ -850,6 +856,7 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
candidates[1] = Some("Cairne".to_string());
candidates[10] = Some("Sylvanas".to_string());
s.set_loot(Some(LootState {
source_unit: false,
fishing: false,
master_candidates: candidates,
rows: vec![Some(LootRow {
@@ -1031,6 +1038,7 @@ fn loot_row_awaiting_its_template_opens_clean() {
// One row in flight: the wire gives the icon (by display id) and the count, nothing else.
s.set_loot(Some(LootState {
source_unit: false,
fishing: false,
master_candidates: Vec::new(),
rows: vec![Some(LootRow {
@@ -1091,6 +1099,7 @@ fn loot_row_awaiting_its_template_opens_clean() {
// (`0x4c2ac0`) fires `LOOT_OPENED` as the pending count falls to zero.
s.fire_event("LOOT_CLOSED", vec![]);
s.set_loot(Some(LootState {
source_unit: false,
fishing: false,
master_candidates: Vec::new(),
rows: vec![Some(LootRow {
@@ -424,6 +424,66 @@ fn vendor_leaves_an_already_open_backpack_alone() {
);
}
/// A centre panel opened over a vendor keeps a backpack the player had open before it: the stock
/// `ShowUIPanel` calls `CloseAllBags` (`UIParent.lua:699-702`), whose `CloseBackpack()` honours
/// the was-open memory (`ContainerFrame.lua:702-707`, `:210-218`), and then closes bags 1 and up,
/// the ones the vendor opened included.
#[test]
fn a_centre_panel_over_a_vendor_keeps_the_backpack_the_player_had_open() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in crate::ui_script::test_ui::production_order(&[
BAG_UI,
&[
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
"ScrollTemplates.xml", // our scroll kits
],
]) {
load_xml(&s, file);
}
s.set_money(0);
equip_bag(&mut s, 0, "Backpack", 16);
equip_bag(&mut s, 1, "Small Pouch", 6);
equip_bag(&mut s, 4, "Small Pouch", 6);
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(
bag_open(&s, 0),
"fixture: the player's own backpack is up first"
);
s.set_merchant(Some(MerchantState::default()));
s.fire_event("MERCHANT_SHOW", vec![]);
assert!(
bag_open(&s, 1) && bag_open(&s, 4),
"fixture: the vendor opens the equipped bags"
);
// A centre-area panel, as the Help micro button's `HelpFrame` is (`UIParent.lua:35`).
s.run(
r#"local f = CreateFrame("Frame", "CentrePanelProbe", UIParent)
f:Hide()
UIPanelWindows["CentrePanelProbe"] = { area = "center", pushable = 0 }
ShowUIPanel(CentrePanelProbe)"#,
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return CentrePanelProbe:IsShown() == 1")
.unwrap(),
"fixture: the centre panel opened"
);
assert!(
bag_open(&s, 0),
"the backpack the player had open before the vendor stays open"
);
assert!(
!bag_open(&s, 1) && !bag_open(&s, 4),
"bags 1-4 close, the vendor's included"
);
}
/// Deviation: the vendor opens every equipped bag, not only the backpack as the stock
/// `OpenBackpack` does, so every bag is up to sell from. `ContainerFrameAdapters.xml` replaces the
/// verb after the stock file loads; with the stock body live, bag 2 would not open.
@@ -1181,14 +1181,6 @@ mod tests {
// Open gaps, each with the window and the click that reach it. The assertion below
// refuses an entry whose gap has closed.
const KNOWN: &[(&str, &str, &str)] = &[
(
"ContainerFrame.xml",
"KeyRingButtonIDToInvSlotID",
"an engine binding (`1.12-globals.tsv`). `ContainerFrame.lua:617` hovers a KEYRING \
slot with it, so the raise needs the keyring open and a key hovered. Ours drives \
keyring tooltips through `ContainerFrameAdapters.xml`'s wrapper, which is \
why nothing has hit it — the wrapper answers first for our own rows.",
),
(
"SkillFrame.xml",
"BuySkillTier",
@@ -1216,13 +1208,6 @@ mod tests {
step/rank cost is non-zero — which no 1.12 server sends. Unreachable until the \
skill-point wire exists; not built.",
),
(
"DurabilityFrame.xml",
"UpdateInventoryAlertStatus",
"an engine binding. `DurabilityFrame.lua:81` calls it from the armor guy's own \
update; our `inventory_alerts` snapshot is recomputed on every inventory push \
instead, so the recompute exists and only the Lua verb that forces one does not.",
),
(
"Blizzard_GMSurveyUI.xml",
"GMSurveyAnswerSubmit",
+5
View File
@@ -823,6 +823,11 @@ pub(crate) fn snapshot(
// byte against `ChrClasses.dbc` field 16; without the player test a class-2 NPC answers 1.
has_relic_slot: matches!(store.0.object_type(), Some(ObjectType::Player))
&& class_id.is_some_and(|c| classes.is_some_and(|t| t.has_relic_slot(u32::from(c)))),
// `GetDamageBonusStat` (`0x48b520`): the class byte against `ChrClasses.dbc` field 2,
// read for the active player only (TYPEMASK_PLAYER, `0x48b538`).
damage_bonus_stat: matches!(store.0.object_type(), Some(ObjectType::Player))
.then(|| class_id.and_then(|c| classes.and_then(|t| t.damage_bonus_stat(u32::from(c)))))
.flatten(),
// Gender byte 0 male, 1 female, on `UnitSex`'s scale: 2 male, 3 female, 0 unknown (nil).
sex: match store.0.unit_gender() {
Some(0) => 2,
+47 -1
View File
@@ -29,7 +29,7 @@ use benilla_world::world_map::CurrentMap;
/// The app's copy of the pushed catalog's projection data (rects, world-sheet constants), in the
/// engine's order: the indices must agree.
#[derive(Resource)]
#[derive(Resource, Clone)]
pub(crate) struct WorldMapUiData {
continents: Vec<ContinentEntry>,
/// The instance maps no continent owns, `0x4a5d00`'s third array in `WorldMapArea.dbc` file
@@ -37,6 +37,7 @@ pub(crate) struct WorldMapUiData {
direct: Vec<DirectAreaEntry>,
}
#[derive(Clone)]
pub(crate) struct ContinentEntry {
map_id: u32,
proj: Option<WorldProj>,
@@ -44,6 +45,7 @@ pub(crate) struct ContinentEntry {
zones: Vec<ZoneEntry>,
}
#[derive(Clone)]
struct ZoneEntry {
area_id: u32,
rect: ZoneRect,
@@ -51,6 +53,7 @@ struct ZoneEntry {
/// One instance map, selected directly rather than through a continent and zone; 1.12 ships
/// three, the battlegrounds.
#[derive(Clone)]
struct DirectAreaEntry {
/// The `WorldMapArea` row id, which the third selection cell `[0x845074]` holds (`0x4a6717`);
/// never a list index.
@@ -527,6 +530,14 @@ pub(crate) fn project_on_displayed(
}
}
/// Hand the engine `GetWorldLocMapPosition`'s projection (`0x4a7360`) over a copy of the catalog.
fn install_world_loc_projector(script: &mut UiScript, data: &WorldMapUiData) {
let copy = data.clone();
script.set_world_loc_projector(Box::new(move |selection, map, x, y| {
project_on_displayed(&copy, selection, map, x, y)
}));
}
/// [`feed_world_map`]'s memos under one [`crate::ui_script::VmMemo`], so a new VM resets them all.
#[derive(Default)]
struct FeedMemos {
@@ -534,6 +545,8 @@ struct FeedMemos {
landmarks: Option<LandmarkKey>,
/// The world-enter sync has run: the reference's `old == 0` gate.
map_synced: bool,
/// `GetWorldLocMapPosition`'s projection is installed over the current catalog.
projector: bool,
}
fn feed_world_map(
@@ -593,6 +606,16 @@ fn feed_world_map(
}
}
// `GetWorldLocMapPosition` projects synchronously with the engine's own selection, so the
// engine holds the projection over its own copy of the catalog.
{
let memo = memo.get(&script);
if !memo.projector || data.is_changed() {
memo.projector = true;
install_world_loc_projector(&mut script, &data);
}
}
let selection = script.world_map_selection();
let project =
|pos_map: u32, px: f32, py: f32| project_on_displayed(&data, selection, pos_map, px, py);
@@ -1164,6 +1187,29 @@ mod player_zone_tests {
);
}
/// The engine's `GetWorldLocMapPosition` answers what the feed's own projection answers, on
/// the engine's selection.
#[test]
fn world_loc_map_position_projects_on_the_displayed_map() {
let mut script = UiScript::new().unwrap();
install_world_loc_projector(&mut script, &data());
script.sync_world_map_to_player_zone(0, 0, Some(443));
assert_eq!(script.world_map_selection(), (0, 0, Some(443)));
assert_eq!(
script
.eval::<(f64, f64)>("return GetWorldLocMapPosition(489, 0, 0)")
.unwrap(),
(0.5, 0.5)
);
assert_eq!(
script
.eval::<(f64, f64)>("return GetWorldLocMapPosition(0, 0, 0)")
.unwrap(),
(0.0, 0.0),
"a position on another map is off this one"
);
}
#[test]
fn a_battleground_takes_the_orphan_leg_and_projects_on_its_own_rect() {
let d = data();
+37
View File
@@ -1,6 +1,10 @@
//! `ChrClasses.dbc`, the per-class table, narrowed to the three columns the reference reads off
//! its class-indexed record table (`0xc0def4`, max id `0xc0def8`, loader `0x542360`).
//!
//! Field 2, the damage bonus stat: `GetDamageBonusStat` (`0x48b520`) reads it at `rec + 8` for the
//! active player's class byte and answers it plus one, a 1-based `UnitStat` index. Strength (0)
//! for every row but the Hunter's and the Rogue's Agility (1).
//!
//! Field 4, the pet name token: the second return of `HasPetSpells` (`0x4b4410`), `"PET"` on every
//! row but the Warlock's `"DEMON"`. FrameXML resolves it with `getglobal("PET_TYPE_"..token)`
//! (`SpellBookFrame.lua:173`), so the token is a key, never display text, and is not localized.
@@ -30,6 +34,9 @@ const CHR_CLASSES: &str = "DBFilesClient\\ChrClasses.dbc";
/// never read one column short.
const CHR_CLASSES_FIELDS: usize = 17;
/// Field 2, read at `rec + 8` by `GetDamageBonusStat` (`0x48b569`).
const DAMAGE_BONUS_STAT_FIELD: usize = 0x8 / 4;
/// Field 4, read at `rec + 0x10` by `HasPetSpells` (`0x4b447c`).
const PET_NAME_TOKEN_FIELD: usize = 0x10 / 4;
@@ -45,6 +52,7 @@ pub const PET_NAME_TOKEN_FALLBACK: &str = "PET";
#[derive(Debug, Clone)]
struct ChrClass {
damage_bonus_stat: u32,
pet_name_token: Option<String>,
has_relic_slot: bool,
spell_family: u32,
@@ -64,6 +72,12 @@ impl ChrClasses {
.unwrap_or(PET_NAME_TOKEN_FALLBACK)
}
/// The class's damage bonus stat, 0-based (Strength 0, Agility 1); `None` for a class with
/// no row, where `GetDamageBonusStat` answers 0 (`0x48b58a`).
pub fn damage_bonus_stat(&self, class: u32) -> Option<u32> {
self.0.get(&class).map(|c| c.damage_bonus_stat)
}
/// Whether the class's INVSLOT 17 is a relic slot, `UnitHasRelicSlot` whole. A class with no
/// row answers false, as the reference's bound check against `0xc0def8` falls to its nil leg.
pub fn has_relic_slot(&self, class: u32) -> bool {
@@ -110,6 +124,7 @@ pub fn load_chr_classes(chain: &mut Chain) -> Result<ChrClasses> {
by_id.insert(
id,
ChrClass {
damage_bonus_stat: u32_at(r, DAMAGE_BONUS_STAT_FIELD).unwrap_or(0),
pet_name_token: str_at(&rs, r, PET_NAME_TOKEN_FIELD),
has_relic_slot: u32_at(r, RELIC_SLOT_FIELD).is_some_and(|v| v != 0),
spell_family: u32_at(r, SPELL_FAMILY_FIELD).unwrap_or(0),
@@ -165,6 +180,28 @@ mod tests {
assert!(!t.has_relic_slot(0));
}
/// Hunters and Rogues scale their damage off Agility, everyone else off Strength.
#[test]
fn hunters_and_rogues_take_their_damage_bonus_from_agility() {
let Some(mut chain) = chain() else { return };
let t = load_chr_classes(&mut chain).expect("load ChrClasses.dbc");
for (class, who) in [(3, "Hunter"), (4, "Rogue")] {
assert_eq!(t.damage_bonus_stat(class), Some(1), "{who}");
}
for (class, who) in [
(1, "Warrior"),
(2, "Paladin"),
(5, "Priest"),
(7, "Shaman"),
(8, "Mage"),
(9, "Warlock"),
(11, "Druid"),
] {
assert_eq!(t.damage_bonus_stat(class), Some(0), "{who}");
}
assert_eq!(t.damage_bonus_stat(6), None);
}
/// The whole column pins field 15: nine distinct values matching vmangos `SpellFamilyNames`
/// are a shape no neighbour has (field 14 is the class's file name, field 16 the relic flag).
#[test]
+2
View File
@@ -96,6 +96,8 @@ mod stable_slot_prices;
pub use stable_slot_prices::{load_stable_slot_prices, StableSlotPrices};
mod page_text_material;
pub use page_text_material::{load_page_text_material_catalog, PageTextMaterialCatalog};
mod packages;
pub use packages::{load_packages, PackageRow};
mod stationery;
pub use stationery::{
load_stationery_catalog, StationeryCatalog, StationeryRow, STATIONERY_DEFAULT,
+76
View File
@@ -0,0 +1,76 @@
//! `Package.dbc`: the send tab's gift packages, 12 columns of `0x30` bytes (the loader's checks,
//! `0x54b4ae` and `0x54b4e6`). The reference keeps the rows in file order (`[0xc0da28]`, count
//! `[0xc0da2c]`) and indexes them by position: `GetPackageInfo` reads the icon at `+4`, the cost at
//! `+8` and the localized name at `+0xc`, `SelectPackage` stores the id at `+0`. 1.12 ships one
//! row, "Test Package".
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::chain::Chain;
use crate::dbc::{parse, str_at, u32_at};
const PACKAGE: &str = "DBFilesClient\\Package.dbc";
/// The column count the loader requires.
const PACKAGE_FIELDS: usize = 12;
/// One `Package.dbc` row.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PackageRow {
pub id: u32,
/// The icon's basename, which the reference prefixes with `Interface\Icons\`.
pub icon: String,
/// Copper, read as a signed dword (`0x4ae50d` `fild`).
pub cost: i32,
/// The enUS name.
pub name: String,
}
/// Load `Package.dbc`'s rows, in file order.
pub fn load_packages(chain: &mut Chain) -> Result<Vec<PackageRow>> {
let bytes = chain.read_file(PACKAGE).context("reading Package.dbc")?;
let mut schema = Schema::new("Package");
for i in 0..PACKAGE_FIELDS {
let ty = if i == 1 || i == 3 {
FieldType::String
} else {
FieldType::UInt32
};
schema.add_field(SchemaField::new(format!("F{i}"), ty));
}
let set = parse(&bytes, schema, "Package.dbc")?;
Ok(set
.records()
.iter()
.filter_map(|r| {
Some(PackageRow {
id: u32_at(r, 0)?,
icon: str_at(&set, r, 1).unwrap_or_default(),
cost: u32_at(r, 2)? as i32,
name: str_at(&set, r, 3).unwrap_or_default(),
})
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_one_shipped_package_is_the_test_package() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let rows = load_packages(&mut chain).expect("load Package.dbc");
assert_eq!(
rows,
[PackageRow {
id: 2,
icon: "INV_BOX_04".into(),
cost: 10,
name: "Test Package".into(),
}]
);
}
}
+121
View File
@@ -160,6 +160,48 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetActionAutocast(slot) (`0x4e6f90`) → autoCastAllowed, autoCastEnabled: 1 or nil each,
// always two values. Only a pet spell has them: the slot resolver (`0x4e5a50`) writes its
// book flag 0 for a spell or an item and a macro's `[rec+0x568]`, and only a set flag looks the
// spell up in the pet's words (`0x4bd160`, low 16 bits), reading bits 31 and 30. An empty,
// out-of-range or missing-macro slot leaves the flag unwritten, and the stale non-zero local
// looks up spell 0, which no pet word carries. A non-number raises.
g.set(
"GetActionAutocast",
lua.create_function(|lua, slot: Value| {
let slot = super::binding_abi::number_arg(lua, slot, "Usage: GetActionAutocast(slot)")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
// `(spell, pet_book)` as the resolver leaves them.
let resolved = u32::try_from(slot)
.ok()
.filter(|s| (1..=120).contains(s))
.and_then(|s| model.actions.get(&s))
.and_then(|a| match a.kind & 0xf0 {
ACTION_KIND_MACRO => model.macros.get(a.action as usize).map(|_| {
let b = model
.macro_bindings
.get(&a.action)
.copied()
.unwrap_or_default();
(b.spell as u32, b.pet_book)
}),
_ => Some((a.action, false)),
})
.unwrap_or((0, true));
let autocast = match resolved {
(spell, true) => model
.pet_book
.slots
.iter()
.find(|s| s.spell_id & 0xffff == spell)
.and_then(|s| s.autocast),
(_, false) => None,
};
let (allowed, enabled) = autocast.unwrap_or((false, false));
Ok((flag(allowed), flag(enabled)))
})?,
)?;
g.set(
"GetActionTexture",
lua.create_function(|lua, action: u32| {
@@ -353,6 +395,85 @@ mod tests {
use super::ActionSlot;
use crate::script::UiScript;
/// `GetActionAutocast` answers a pet spell's two bits only for a macro bound out of the pet's
/// book (`0x4e6fcb`), never for a spell slot of the same id, and always two values.
#[test]
fn action_autocast_reads_a_pet_book_macro_only() {
use crate::script::{MacroBinding, MacroState, MacroView, PetBookState, SpellSlotView};
let mut s = UiScript::new().unwrap();
s.set_pet_book(PetBookState {
token: Some("PET".into()),
slots: vec![SpellSlotView {
spell_id: 3009, // Claw
autocast: Some((true, false)),
..Default::default()
}],
});
s.set_macros(MacroState {
account: vec![
MacroView {
name: "claw".into(),
body: "/cast Claw".into(),
..Default::default()
},
MacroView {
name: "fb".into(),
body: "/cast Fireball".into(),
..Default::default()
},
],
character: Vec::new(),
});
s.set_macro_bindings(std::collections::HashMap::from([
(
1,
MacroBinding {
spell: 3009,
pet_book: true,
},
),
(
2,
MacroBinding {
spell: 133,
pet_book: false,
},
),
]));
let slot = |kind, action| ActionSlot {
kind,
action,
..Default::default()
};
s.set_action(1, Some(slot(super::ACTION_KIND_MACRO, 1)));
s.set_action(2, Some(slot(super::ACTION_KIND_MACRO, 2)));
s.set_action(3, Some(slot(super::ACTION_KIND_SPELL, 3009)));
s.set_action(4, Some(slot(super::ACTION_KIND_MACRO, 9)));
let ask = |s: &UiScript, n: u32| {
s.eval::<(i64, Option<i64>, Option<i64>)>(&format!(
"local f = function(...) return arg.n, arg[1], arg[2] end \
return f(GetActionAutocast({n}))"
))
.unwrap()
};
assert_eq!(ask(&s, 1), (2, Some(1), None), "the pet-book macro");
for n in [2, 3, 4, 5, 0, 121] {
assert_eq!(ask(&s, n), (2, None, None), "slot {n}");
}
s.set_pet_book(PetBookState {
token: Some("PET".into()),
slots: vec![SpellSlotView {
spell_id: 3009,
autocast: Some((true, true)),
..Default::default()
}],
});
assert_eq!(ask(&s, 1), (2, Some(1), Some(1)), "autocasting");
let err = s.run("GetActionAutocast()").unwrap_err().to_string();
assert!(err.contains("Usage: GetActionAutocast(slot)"), "{err}");
}
fn uses(s: &mut UiScript) -> Vec<(u32, bool)> {
s.take_action_uses()
.into_iter()
+35 -11
View File
@@ -312,15 +312,8 @@ impl super::UiScript {
pub fn set_inventory_slots(&mut self, slots: InventorySlots) {
{
let mut model = self.model_mut();
let alerts: [u8; 12] = std::array::from_fn(|i| {
if i == 11 {
ammo_alert_status(&slots[ALERT_SLOTS[i]])
} else {
alert_status(&slots[ALERT_SLOTS[i]])
}
});
model.inventory_slots = slots;
model.inventory_alerts = alerts;
recompute_inventory_alerts(&mut model);
}
// Every recompute fires it, never diffed against the last (`0x4c7ee0`); the app pushes
// only on a real change.
@@ -348,6 +341,19 @@ impl super::UiScript {
}
}
/// The 12 alert statuses off the doll snapshot (`0x4c7ee0`'s recompute), without the event.
pub(super) fn recompute_inventory_alerts(model: &mut Model) {
let slots = &model.inventory_slots;
let alerts: [u8; 12] = std::array::from_fn(|i| {
if i == 11 {
ammo_alert_status(&slots[ALERT_SLOTS[i]])
} else {
alert_status(&slots[ALERT_SLOTS[i]])
}
});
model.inventory_alerts = alerts;
}
fn with_unit_stats<T>(
lua: &Lua,
token: &Option<String>,
@@ -394,15 +400,29 @@ const BANK_BAG_INV_SLOTS: std::ops::RangeInclusive<usize> = 64..=69;
/// The vault's container id; must match `ui_items::BANK_CONTAINER` in the app.
const BANK_CONTAINER: i64 = -1;
/// The keyring's live-API ids: `KeyRingButtonIDToInvSlotID` adds `0x51` to a 1-based button id
/// (`0x4c818b`), and the slot reader takes 0-based 81..=112 (`0x4c8567`).
pub(super) const KEYRING_INV_SLOTS: std::ops::RangeInclusive<usize> = 82..=113;
/// The keyring's container id (`KEYRING_CONTAINER`, `MainMenuBarBagButtons.lua:1`); must match
/// `ui_items::KEYRING_CONTAINER` in the app, which feeds the keys as that container.
const KEYRING_CONTAINER: i64 = -2;
impl Model {
/// The item `token` exposes at live-API id `slot`, the one routing the `GetInventoryItem*`
/// getters and `GameTooltip:SetInventoryItem` share. Stock paints the bank through this API
/// (`BankFrame.lua:35`), so the bank band is answered from the container snapshot.
/// (`BankFrame.lua:35`) and hovers a key through it (`ContainerFrame.lua:616-619`), so the
/// bank and keyring bands are answered from the container snapshots.
pub(super) fn inv_slot(&self, token: &str, slot: usize) -> Option<InvSlotView> {
if token.eq_ignore_ascii_case("player") {
if let Some(view) = self.bank_inv_slot(slot) {
return Some(view);
}
if KEYRING_INV_SLOTS.contains(&slot) {
let n = (slot - KEYRING_INV_SLOTS.start() + 1) as u32;
let keyring = self.containers.get(&KEYRING_CONTAINER)?;
return keyring.slots.get(&n).map(InvSlotView::from_container_slot);
}
return self.inventory_slots.get(slot)?.clone();
}
self.inspect
@@ -413,12 +433,16 @@ impl Model {
.clone()
}
/// The player's repair cost at live id `slot`, the vault band by its container slot.
/// The player's repair cost at live id `slot`, the vault and keyring bands by their container
/// slots.
pub(super) fn inv_repair_cost(&self, slot: usize) -> u32 {
let costs = &self.repair_costs;
let cost = if BANK_INV_SLOTS.contains(&slot) {
let n = (slot - BANK_INV_SLOTS.start() + 1) as u32;
costs.bags.get(&(BANK_CONTAINER, n))
} else if KEYRING_INV_SLOTS.contains(&slot) {
let n = (slot - KEYRING_INV_SLOTS.start() + 1) as u32;
costs.bags.get(&(KEYRING_CONTAINER, n))
} else {
u32::try_from(slot)
.ok()
@@ -446,7 +470,7 @@ impl Model {
/// The inventory-slot reader's whitelist (`0x4c8520`) on its 0-based slot (`0x4c8546`); outside
/// it the binding raises "Invalid inventory slot in …". The backpack's item slots (23..=38) and
/// buyback (69..=80) are container-API slots, not in it.
fn inventory_slot_reader_accepts(slot0: i32) -> bool {
pub(super) fn inventory_slot_reader_accepts(slot0: i32) -> bool {
slot0 == -1 // Lua 0 the ammo leg
|| (0x00..=0x16).contains(&slot0) // Lua 1..=23 the doll + the four equipped bags
|| (0x27..=0x3e).contains(&slot0) // Lua 40..=63 the bank vault
@@ -0,0 +1,357 @@
//! Four verbs of the reference's inventory registrar (`0x8484d0`) beside the paper-doll getters in
//! [`super::char_stats`]: the keyring's slot map, the doll's sell cursor, the doll portrait setter
//! and the alert recompute.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::number_arg;
use super::char_stats::{inventory_slot_reader_accepts, recompute_inventory_alerts};
use super::container::UiCursorMode;
use super::region::{clear_texture, this_texture};
use super::Model;
/// `0x4c8520`, the registrar's shared slot reader: `is-number`, `tonumber`, the truncating cast,
/// then `dec` into the 0-based slot and the whitelist; a failure of either raises `message`. The
/// 0-based slot, `-1` for Lua 0, the ammo leg.
fn inventory_slot_arg(lua: &Lua, v: Value, message: &'static str) -> mlua::Result<i32> {
let slot0 = number_arg(lua, v, message)?.wrapping_sub(1);
if !inventory_slot_reader_accepts(slot0) {
return Err(mlua::Error::RuntimeError(message.into()));
}
Ok(slot0)
}
/// Register the four globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// KeyRingButtonIDToInvSlotID(buttonID) (`0x4c8150`): the truncated button id plus `0x51`
// (`0x4c818b`), so key 1 is live-API slot 82, with no range check; a non-number raises.
g.set(
"KeyRingButtonIDToInvSlotID",
lua.create_function(|lua, id: Value| {
let id = number_arg(lua, id, "Usage: KeyRingButtonIDToInvSlotID(buttonID)")?;
Ok(i64::from(id.wrapping_add(0x51)))
})?,
)?;
// ShowInventorySellCursor(slot) (`0x4c90b0`): the Buy cursor over a worn item. The armed-spell
// and base-cursor gates run before the argument is read (`0x4c90b7`, `0x4c90c9`), so under
// either a bad slot does not raise; past them a slot outside the reader's whitelist raises,
// and the ammo slot, an empty slot or a locked item leave the cursor as it was. Zero returns.
// The stock doll's call is commented out (`PaperDollFrame.lua:754-756`).
g.set(
"ShowInventorySellCursor",
lua.create_function(|lua, slot: Value| {
{
let model = lua.app_data_ref::<Model>().expect("model app_data");
// `[0xbe2c4c] == 1`: repair mode (`0x4fbce3`) and the targeting arm move the base.
if model.spell_targeting || model.repair_mode {
return Ok(());
}
}
let slot0 = inventory_slot_arg(
lua,
slot,
"Invalid inventory slot in ShowInventorySellCursor",
)?;
if slot0 == -1 {
return Ok(());
}
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let sellable = usize::try_from(slot0 + 1)
.ok()
.and_then(|slot| model.inv_slot("player", slot))
.is_some_and(|v| v.item_id != 0 && !v.locked);
if sellable {
model.ui_cursor = Some(UiCursorMode::Buy);
model.ui_cursor_dirty = true;
}
Ok(())
})?,
)?;
// SetInventoryPortaitTexture(texture, unit, slot) (`0x4c9150`, the reference's spelling):
// the texture is cleared (`0x4c91ed`) before arguments 2 and 3 are read, each of which raises
// on a bad value. The unit is then read from argument 1 (`0x4c9247`), the texture itself,
// which `lua_tostring` answers NULL for, so the token resolves no unit (`0x515940`) and the
// item's icon is never painted: the call only blanks the texture. No stock caller.
g.set(
"SetInventoryPortaitTexture",
lua.create_function(|lua, (texture, unit, slot): (Value, Value, Value)| {
let rh = this_texture(lua, &texture)?;
clear_texture(
&mut lua.app_data_mut::<Model>().expect("model app_data"),
rh,
);
if !matches!(
unit,
Value::String(_) | Value::Number(_) | Value::Integer(_)
) {
return Err(mlua::Error::runtime(
"Usage: SetInventoryPortaitTexture(texure, unit, slot)",
));
}
inventory_slot_arg(
lua,
slot,
"Invalid inventory slot in SetInventoryPortaitTexture",
)?;
Ok(())
})?,
)?;
// UpdateInventoryAlertStatus() (`0x4c9560`): the alert recompute (`0x4c7ee0`), which fires
// UPDATE_INVENTORY_ALERTS unconditionally; zero returns. Stock calls it when the durability
// figure's enchant timer runs out (`DurabilityFrame.lua:81`).
g.set(
"UpdateInventoryAlertStatus",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
recompute_inventory_alerts(&mut model);
model
.pending_events
.push(("UPDATE_INVENTORY_ALERTS".to_string(), Vec::new()));
Ok(MultiValue::new())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::{ContainerSlot, ContainerState, InvSlotView, UiCursorMode, UiScript};
fn keyring_with_key() -> ContainerState {
let mut slots = std::collections::HashMap::new();
slots.insert(
2,
ContainerSlot {
item_id: 5396,
texture: Some("Interface\\Icons\\INV_Misc_Key_03".into()),
count: 1,
link: Some("|cffffffff|Hitem:5396:0:0:0|h[Key to Searing Gorge]|h|r".into()),
..Default::default()
},
);
ContainerState {
name: None,
num_slots: 4,
slots,
}
}
#[test]
fn keyring_button_ids_map_past_the_buyback_band() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return KeyRingButtonIDToInvSlotID(1)")
.unwrap(),
82
);
assert_eq!(
s.eval::<i64>("return KeyRingButtonIDToInvSlotID(32)")
.unwrap(),
113
);
// No range check, a truncating cast and a numeric string, as `0x4c8150` reads them.
assert_eq!(
s.eval::<i64>("return KeyRingButtonIDToInvSlotID(0)")
.unwrap(),
81
);
assert_eq!(
s.eval::<i64>("return KeyRingButtonIDToInvSlotID(-2.9)")
.unwrap(),
79
);
assert_eq!(
s.eval::<i64>("return KeyRingButtonIDToInvSlotID('3')")
.unwrap(),
84
);
let err = s
.run("KeyRingButtonIDToInvSlotID()")
.unwrap_err()
.to_string();
assert!(
err.contains("Usage: KeyRingButtonIDToInvSlotID(buttonID)"),
"{err}"
);
}
/// The keyring band answers the inventory API from the keyring container, so the stock hover
/// (`ContainerFrame.lua:617`) reads a key through `GetInventoryItem*` and the tooltip.
#[test]
fn a_key_reads_through_its_inventory_slot() {
let mut s = UiScript::new().unwrap();
s.set_container(-2, Some(keyring_with_key()));
assert_eq!(
s.eval::<Option<String>>(
"return GetInventoryItemTexture('player', KeyRingButtonIDToInvSlotID(2))"
)
.unwrap()
.as_deref(),
Some("Interface\\Icons\\INV_Misc_Key_03")
);
assert_eq!(
s.eval::<Option<String>>("return GetInventoryItemTexture('player', 82)")
.unwrap(),
None,
"key slot 1 is empty"
);
assert_eq!(
s.eval::<i64>("return GetInventoryItemCount('player', 83)")
.unwrap(),
1
);
}
#[test]
fn inventory_sell_cursor_buys_over_a_worn_item_only() {
let mut s = UiScript::new().unwrap();
let mut slots: crate::script::InventorySlots = Default::default();
slots[16] = Some(InvSlotView {
item_id: 2361,
..Default::default()
});
slots[17] = Some(InvSlotView {
item_id: 2362,
locked: true,
..Default::default()
});
s.set_inventory_slots(slots);
for quiet in ["0", "1", "17"] {
s.run(&format!("ShowInventorySellCursor({quiet})")).unwrap();
assert_eq!(s.ui_cursor(), None, "slot {quiet}: ammo, empty or locked");
}
s.run("ShowInventorySellCursor(16)").unwrap();
assert_eq!(s.ui_cursor(), Some(UiCursorMode::Buy));
assert_eq!(
s.eval::<i64>(
"local n = function(...) return arg.n end return n(ShowInventorySellCursor(16))"
)
.unwrap(),
0
);
for bad in ["24", "39", "70", "114", "-1", "'x'"] {
let err = s
.run(&format!("ShowInventorySellCursor({bad})"))
.unwrap_err()
.to_string();
assert!(
err.contains("Invalid inventory slot in ShowInventorySellCursor"),
"{bad}: {err}"
);
}
}
/// The two cursor gates come before the argument read (`0x4c90b7`, `0x4c90c9`).
#[test]
fn inventory_sell_cursor_under_repair_mode_is_silent() {
let mut s = UiScript::new().unwrap();
s.set_merchant(Some(crate::script::MerchantState {
can_repair: true,
..Default::default()
}));
s.run("ShowRepairCursor()").unwrap();
s.run("ShowInventorySellCursor(999)").unwrap();
assert_eq!(s.ui_cursor(), None);
}
#[test]
fn inventory_portrait_setter_only_blanks_the_texture() {
let s = UiScript::new().unwrap();
s.run(
r#"local f = CreateFrame("Frame", "PortHost", UIParent)
PortTex = f:CreateTexture("PortTex")
PortTex:SetTexture("Interface\\Icons\\INV_Misc_Bag_08")
PortText = f:CreateFontString("PortText")"#,
)
.unwrap();
s.run("SetInventoryPortaitTexture(PortTex, 'player', 1)")
.unwrap();
assert_eq!(
s.eval::<Option<String>>("return PortTex:GetTexture()")
.unwrap(),
None
);
assert_eq!(
s.eval::<i64>("local n = function(...) return arg.n end return n(SetInventoryPortaitTexture(PortTex, 'player', 1))")
.unwrap(),
0
);
// The clear precedes the argument checks (`0x4c91ed`).
s.run(r#"PortTex:SetTexture("Interface\\Icons\\INV_Misc_Bag_08")"#)
.unwrap();
let err = s
.run("SetInventoryPortaitTexture(PortTex, nil, 1)")
.unwrap_err()
.to_string();
assert!(
err.contains("Usage: SetInventoryPortaitTexture(texure, unit, slot)"),
"{err}"
);
assert_eq!(
s.eval::<Option<String>>("return PortTex:GetTexture()")
.unwrap(),
None
);
let err = s
.run("SetInventoryPortaitTexture(PortTex, 'player', 30)")
.unwrap_err()
.to_string();
assert!(
err.contains("Invalid inventory slot in SetInventoryPortaitTexture"),
"{err}"
);
for (call, message) in [
(
"SetInventoryPortaitTexture(1, 'player', 1)",
"non-table object",
),
(
"SetInventoryPortaitTexture({}, 'player', 1)",
"non-framescript object",
),
(
"SetInventoryPortaitTexture(PortText, 'player', 1)",
"Wrong object type for member function",
),
(
"SetInventoryPortaitTexture(PortHost, 'player', 1)",
"Wrong object type for member function",
),
] {
let err = s.run(call).unwrap_err().to_string();
assert!(err.contains(message), "{call}: {err}");
}
}
#[test]
fn update_inventory_alert_status_fires_the_recompute_event() {
let mut s = UiScript::new().unwrap();
s.run(
r#"ALERTS = 0
local f = CreateFrame("Frame")
f:RegisterEvent("UPDATE_INVENTORY_ALERTS")
f:SetScript("OnEvent", function() ALERTS = ALERTS + 1 end)"#,
)
.unwrap();
assert_eq!(
s.eval::<i64>(
"local n = function(...) return arg.n end return n(UpdateInventoryAlertStatus())"
)
.unwrap(),
0
);
s.run("UpdateInventoryAlertStatus()").unwrap();
s.tick(0.0);
assert_eq!(s.eval::<i64>("return ALERTS").unwrap(), 2);
}
}
+83
View File
@@ -72,8 +72,16 @@ pub struct LootState {
/// dropdown labels "Group N" (`LootFrame.lua:197-213`). A `None` (an empty slot, or a name not
/// yet resolved) answers nil.
pub master_candidates: Vec<Option<String>>,
/// The loot source (`[0xb71b48]`) resolves as a unit object (`0x468460`, typemask 8): a
/// corpse, not a chest, a bobber or an item. `SetLootPortrait` bakes it under the token
/// [`LOOT_PORTRAIT_UNIT`].
pub source_unit: bool,
}
/// The portrait key `SetLootPortrait` binds a texture to, which the app bakes from the loot
/// source. Upper case, so no `SetPortraitTexture` token, which is lowercased, can name it.
pub const LOOT_PORTRAIT_UNIT: &str = "LOOT";
impl super::UiScript {
/// Push (or clear, with `None`) the open loot.
pub fn set_loot(&mut self, state: Option<LootState>) {
@@ -108,6 +116,28 @@ impl super::UiScript {
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// SetLootPortrait(texture) (`0x4c2b40`): the loot source's 3D portrait, the unit-frame bake
// (`0x524f60`), and the number 1; a source that is no unit object, or no loot, clears the
// texture (`0x4c2c0d`) and answers nil. A non-Texture raises the member prologue's errors. No
// stock caller: `LootFrame.lua` paints its own portrait art.
g.set(
"SetLootPortrait",
lua.create_function(|lua, texture: Value| {
let rh = super::region::this_texture(lua, &texture)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if !model.loot.as_ref().is_some_and(|l| l.source_unit) {
super::region::clear_texture(&mut model, rh);
return Ok(Value::Nil);
}
let data = model.region_data.entry(rh).or_default();
data.portrait_unit = Some(LOOT_PORTRAIT_UNIT.to_string());
data.texture = None;
data.fill = None;
data.circular = true;
Ok(Value::Integer(1))
})?,
)?;
// GetNumLootItems(): cleared slots included, so it holds still while a window is open.
g.set(
"GetNumLootItems",
@@ -306,8 +336,61 @@ mod tests {
use super::{LootRow, LootState};
use crate::script::UiScript;
/// The region data behind a named texture: its portrait key and file.
fn portrait_of(s: &UiScript, name: &str) -> (Option<String>, Option<String>) {
let model = s.model_ref();
let id = model.region_names[name];
let rh = *model.id_to_region.get(&id).expect("a region");
let data = &model.region_data[&rh];
(data.portrait_unit.clone(), data.texture.clone())
}
/// `SetLootPortrait` (`0x4c2b40`): a unit source binds the loot portrait and answers 1;
/// anything else clears the texture and answers nil.
#[test]
fn loot_portrait_binds_a_unit_source_and_clears_otherwise() {
let mut s = UiScript::new().unwrap();
s.run(
r#"local f = CreateFrame("Frame", "LootHost", UIParent)
f:CreateTexture("LootTex")
LootTex:SetTexture("Interface\TargetingFrame\TargetDead")"#,
)
.unwrap();
assert!(s
.eval::<bool>("return SetLootPortrait(LootTex) == nil")
.unwrap());
assert_eq!(portrait_of(&s, "LootTex"), (None, None), "no loot: cleared");
s.run(r#"LootTex:SetTexture("Interface\TargetingFrame\TargetDead")"#)
.unwrap();
s.set_loot(Some(LootState {
source_unit: true,
..loot()
}));
assert_eq!(s.eval::<i64>("return SetLootPortrait(LootTex)").unwrap(), 1);
assert_eq!(
portrait_of(&s, "LootTex"),
(Some(super::LOOT_PORTRAIT_UNIT.to_string()), None)
);
s.set_loot(Some(loot()));
assert!(s
.eval::<bool>("return SetLootPortrait(LootTex) == nil")
.unwrap());
assert_eq!(portrait_of(&s, "LootTex"), (None, None), "a chest: cleared");
let err = s.run("SetLootPortrait(LootHost)").unwrap_err().to_string();
assert!(
err.contains("Wrong object type for member function"),
"{err}"
);
let err = s.run("SetLootPortrait()").unwrap_err().to_string();
assert!(err.contains("non-table object"), "{err}");
}
fn loot() -> LootState {
LootState {
source_unit: false,
master_candidates: Vec::new(),
rows: vec![
Some(LootRow {
+15
View File
@@ -36,6 +36,16 @@ pub struct MacroView {
pub local_only: bool,
}
/// A macro record's cached cast, the reference's `([rec+0x564], [rec+0x568])` pair (`0x4efe00`):
/// the bound spell and the book it resolved from, which the app derives.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct MacroBinding {
/// The bound spell id; 0 for none, -1 for a `/cast` that did not resolve.
pub spell: i32,
/// The spell came from the pet's book (`0x4b3b51` writes 1), not the player's.
pub pet_book: bool,
}
/// The macro table: the account and character lists.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MacroState {
@@ -135,6 +145,11 @@ impl super::UiScript {
self.model_mut().macros.clone()
}
/// Push every macro's cached cast by 1-based index, replacing the last push.
pub fn set_macro_bindings(&mut self, bindings: std::collections::HashMap<u32, MacroBinding>) {
self.model_mut().macro_bindings = bindings;
}
/// Whether a script changed the table since the last call; the app then saves it and fires
/// `UPDATE_MACROS` (`0x452460`).
pub fn take_macros_dirty(&mut self) -> bool {
+231 -15
View File
@@ -97,6 +97,20 @@ pub struct StationeryView {
pub texture: String,
}
/// One `Package.dbc` row, as `GetPackageInfo` answers it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PackageView {
/// The row id, which `SelectPackage` stores and `CMSG_SEND_MAIL` carries.
pub id: u32,
/// The localized name (field 3 on).
pub name: String,
/// The icon as a full path: `StringLookups.dbc` row 3's folder, `\` and field 1
/// (`0x4ae4ab`-`0x4ae4eb`).
pub icon: String,
/// Field 2, in copper.
pub cost: i32,
}
/// A drained `SendMail` intent, which the app turns into `CMSG_SEND_MAIL`.
#[derive(Clone, Debug, PartialEq)]
pub struct MailSendRequest {
@@ -110,6 +124,27 @@ pub struct MailSendRequest {
/// The attached item's `(bag, 1-based slot)`; the app resolves its guid when the send fires,
/// as the reference re-reads the slot then.
pub item: Option<(i64, u32)>,
/// The selected `Package.dbc` id, sent only with an attached item (`0x4ae8f3`).
pub package: u32,
}
/// `0x4acdc0`'s clears: the attachment, whose lock it lifts (`0x495420`, firing
/// `ITEM_LOCK_CHANGED`), then money, COD, stationery and package. Returns the lock event to fire.
fn clear_compose(model: &mut Model) -> Option<(String, Vec<super::ScriptValue>)> {
let lock = model.mail_send_item.take().map(|item| {
(
"ITEM_LOCK_CHANGED".to_string(),
vec![
super::ScriptValue::Int(item.bag),
super::ScriptValue::Int(i64::from(item.slot)),
],
)
});
model.mail_send_money = 0;
model.mail_send_cod = 0;
model.mail_stationery = 0;
model.mail_package = 0;
lock
}
impl super::UiScript {
@@ -172,27 +207,37 @@ impl super::UiScript {
money: model.mail_send_money,
cod: model.mail_send_cod,
item,
package: model.mail_package,
})
}
/// The reference's compose-tab reset (`0x4acdc0`): clear the attachment, money and COD, then
/// fire `SEND_MAIL_MONEY_CHANGED`, `SEND_MAIL_COD_CHANGED` and `MAIL_SEND_SUCCESS` in that
/// order. The events must follow the clear, as `SendMailFrame_Reset` re-reads
/// `GetSendMailItem`. `MAIL_SEND_SUCCESS` means the form is clean: opening a mailbox fires it.
/// The reference's compose-tab reset (`0x4acdc0(1)`), run on mailbox open and a sent mail:
/// clear the attachment (its lock lifted), money, COD, stationery and package, then fire
/// `SEND_MAIL_MONEY_CHANGED`, `SEND_MAIL_COD_CHANGED` and `MAIL_SEND_SUCCESS` in that order.
/// The events must follow the clear, as `SendMailFrame_Reset` re-reads `GetSendMailItem` and
/// selects stationery row 1. `MAIL_SEND_SUCCESS` means the form is clean: opening a mailbox
/// fires it.
pub fn reset_compose_tab(&mut self) {
{
let mut model = self.model_mut();
model.mail_send_item = None;
model.mail_send_money = 0;
model.mail_send_cod = 0;
}
let lock = clear_compose(&mut self.model_mut());
// Fired now, not queued: the order against the caller's `MAIL_SHOW`/`MAIL_FAILED` matters.
if let Some((event, args)) = lock {
self.fire_event(&event, args);
}
// One literal call each, as the event census (`ui_script::reference_ui`) reads them.
self.fire_event("SEND_MAIL_MONEY_CHANGED", Vec::new()); // 0x4ace14
self.fire_event("SEND_MAIL_COD_CHANGED", Vec::new()); // 0x4ace1e
self.fire_event("MAIL_SEND_SUCCESS", Vec::new()); // 0x4ace28
}
/// The close core's silent reset (`0x4acdc0(0)`, from `0x4acd50`): the same clears, and only
/// the attachment's `ITEM_LOCK_CHANGED`, which `0x495420` fires on either leg.
pub fn reset_compose_tab_silently(&mut self) {
let lock = clear_compose(&mut self.model_mut());
if let Some((event, args)) = lock {
self.fire_event(&event, args);
}
}
/// `SendMail`'s abort when the attached item left its slot (`ERR_ITEM_NOT_FOUND`, no packet,
/// `MAIL_SEND_INFO_UPDATE` at `0x4ae98d`): drop the attachment, keep the money and COD.
pub fn drop_send_mail_item(&mut self) {
@@ -209,10 +254,9 @@ impl super::UiScript {
self.model_mut().mail_stationeries = list;
}
/// Clear the stationery selection, as the reference does on mailbox open and close
/// (`0x4ace07`).
pub fn clear_stationery(&mut self) {
self.model_mut().mail_stationery = 0;
/// Push `Package.dbc`'s rows, in file order.
pub fn set_mail_packages(&mut self, packages: Vec<PackageView>) {
self.model_mut().mail_packages = packages;
}
/// Push `HasNewMail()`'s answer, independent of any open mailbox. The app sets it before each
@@ -689,6 +733,72 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// ClearSendMail() (`0x4adee0`): the compose reset with its events (`0x4acdc0(1)`), queued in
// its order; zero returns.
g.set(
"ClearSendMail",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let lock = clear_compose(&mut model);
model.pending_events.extend(lock);
for event in [
"SEND_MAIL_MONEY_CHANGED",
"SEND_MAIL_COD_CHANGED",
"MAIL_SEND_SUCCESS",
] {
model.pending_events.push((event.to_string(), Vec::new()));
}
Ok(MultiValue::new())
})?,
)?;
// GetNumPackages() (`0x4ae430`): `Package.dbc`'s raw row count, unfiltered.
g.set(
"GetNumPackages",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.mail_packages.len() as i64)
})?,
)?;
// GetPackageInfo(index) (`0x4ae450`) → name, icon, cost for a 1-based row; out of range it
// pushes nil, nil, 0 (`0x4ae525`), and a non-number raises.
g.set(
"GetPackageInfo",
lua.create_function(|lua, index: Value| {
let index =
crate::script::binding_abi::number_arg(lua, index, "Usage: GetPackageInfo(index)")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let row = usize::try_from(index.wrapping_sub(1))
.ok()
.and_then(|i| model.mail_packages.get(i));
Ok(match row {
Some(row) => MultiValue::from_vec(vec![
Value::String(lua.create_string(&row.name)?),
Value::String(lua.create_string(&row.icon)?),
Value::Integer(i64::from(row.cost)),
]),
None => MultiValue::from_vec(vec![Value::Nil, Value::Nil, Value::Integer(0)]),
})
})?,
)?;
// SelectPackage(index) (`0x4ae550`): stores the 1-based row's id, or 0 out of range
// (`0x4adc90`); a non-number raises. Zero returns.
g.set(
"SelectPackage",
lua.create_function(|lua, index: Value| {
let index =
crate::script::binding_abi::number_arg(lua, index, "Usage: SelectPackage(index)")?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.mail_package = usize::try_from(index.wrapping_sub(1))
.ok()
.and_then(|i| model.mail_packages.get(i))
.map_or(0, |row| row.id);
Ok(MultiValue::new())
})?,
)?;
Ok(())
}
@@ -1077,6 +1187,112 @@ mod tests {
assert_eq!((req.money, req.cod, req.item), (0, 0, None));
}
/// `ClearSendMail()` (`0x4adee0`) is the compose reset with its events, the attachment's lock
/// first, then the three in `0x4acdc0`'s order; stationery and package clear with the rest.
#[test]
fn clear_send_mail_resets_the_form_and_fires_its_events() {
use crate::script::cursor::{CursorItem, CursorPayload};
let mut s = UiScript::new().unwrap();
select_default_stationery(&mut s);
s.set_mail_packages(vec![PackageView {
id: 2,
name: "Test Package".into(),
icon: "Interface\\Icons\\INV_BOX_04".into(),
cost: 10,
}]);
s.run("SelectPackage(1)").unwrap();
s.model_mut().cursor = Some(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 3,
item_id: 1,
texture: None,
link: None,
count: None,
quality: None,
equip_slots: Vec::new(),
}));
s.run("ClickSendMailItemButton() SetSendMailMoney(99) SetSendMailCOD(5)")
.unwrap();
s.tick(0.0);
s.run(
r#"FIRED = {}
local f = CreateFrame("Frame")
for _, e in { "ITEM_LOCK_CHANGED", "SEND_MAIL_MONEY_CHANGED",
"SEND_MAIL_COD_CHANGED", "MAIL_SEND_SUCCESS" } do
f:RegisterEvent(e)
end
f:SetScript("OnEvent", function() table.insert(FIRED, event) end)"#,
)
.unwrap();
assert_eq!(
s.eval::<i64>("local n = function(...) return arg.n end return n(ClearSendMail())")
.unwrap(),
0
);
s.tick(0.0);
assert_eq!(
s.eval::<Vec<String>>("return FIRED").unwrap(),
[
"ITEM_LOCK_CHANGED",
"SEND_MAIL_MONEY_CHANGED",
"SEND_MAIL_COD_CHANGED",
"MAIL_SEND_SUCCESS"
]
);
assert!(s
.eval::<bool>("return GetSendMailItem() == nil and GetSendMailMoney() == 0")
.unwrap());
s.run("SendMail('x', 'y', 'z')").unwrap();
assert!(s.take_mail_send().is_none(), "the stationery cleared too");
select_default_stationery(&mut s);
s.run("SendMail('x', 'y', 'z')").unwrap();
assert_eq!(s.take_mail_send().map(|r| r.package), Some(0));
}
/// `Package.dbc`'s one shipped row, through the three package verbs (`0x4ae430`, `0x4ae450`,
/// `0x4ae550`).
#[test]
fn packages_list_describe_and_select_by_row() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetNumPackages()").unwrap(), 0);
s.set_mail_packages(vec![PackageView {
id: 2,
name: "Test Package".into(),
icon: "Interface\\Icons\\INV_BOX_04".into(),
cost: 10,
}]);
assert_eq!(s.eval::<i64>("return GetNumPackages()").unwrap(), 1);
let (name, icon, cost): (String, String, i64) = s.eval("return GetPackageInfo(1)").unwrap();
assert_eq!(
(name.as_str(), icon.as_str(), cost),
("Test Package", "Interface\\Icons\\INV_BOX_04", 10)
);
for miss in ["0", "2", "-1"] {
assert!(
s.eval::<bool>(&format!(
"local a, b, c = GetPackageInfo({miss}) return a == nil and b == nil and c == 0"
))
.unwrap(),
"{miss}"
);
}
for call in ["GetPackageInfo()", "SelectPackage('x')"] {
let err = s.run(call).unwrap_err().to_string();
assert!(err.contains("Usage: "), "{call}: {err}");
}
select_default_stationery(&mut s);
s.run("SelectPackage(1) SendMail('x', 'y', 'z')").unwrap();
assert_eq!(s.take_mail_send().map(|r| r.package), Some(2));
s.run("SelectPackage(5) SendMail('x', 'y', 'z')").unwrap();
assert_eq!(
s.take_mail_send().map(|r| r.package),
Some(0),
"out of range stores 0"
);
}
#[test]
fn clearing_the_mail_empties_it() {
let mut s = UiScript::new().unwrap();
@@ -1184,7 +1400,7 @@ mod stationery_tests {
s.run("SelectStationery(1) SendMail(\"Bob\", \"hi\", \"body\")")
.unwrap();
assert_eq!(s.take_mail_send().map(|r| r.stationery), Some(41));
s.clear_stationery();
s.reset_compose_tab_silently();
s.run("SendMail(\"Bob\", \"hi\", \"body\")").unwrap();
assert!(s.take_mail_send().is_none());
}
+8 -4
View File
@@ -74,6 +74,7 @@ mod surface;
pub use surface::widget_method_census;
mod input_verbs;
mod inspect;
mod inventory_verbs;
mod item_stats;
mod item_text;
pub mod keybind;
@@ -213,15 +214,17 @@ pub use inspect::{InspectView, UnitReach};
pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState};
pub use item_text::ItemTextState;
pub use layout_cache::{FrameLayout, LayoutPoint};
pub use loot::{LootRow, LootState};
pub use loot::{LootRow, LootState, LOOT_PORTRAIT_UNIT};
pub use loot_roll::{LootRollEntry, LootRollsState};
pub use macros::{MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NAME};
pub use mail::{MailInboxRow, MailInvoice, MailSendRequest, MailState, StationeryView};
pub use macros::{MacroBinding, MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NAME};
pub use mail::{
MailInboxRow, MailInvoice, MailSendRequest, MailState, PackageView, StationeryView,
};
pub use measure::TextMeasure;
pub use merchant::{ItemStatsHead, MerchantItem, MerchantState, RepairCosts};
pub(crate) use minimap::apply_model_attrs as apply_minimap_model_attrs;
pub(crate) use model::Model;
pub use model::{FontProbe, TextureProbe, TextureSizeProbe};
pub use model::{FontProbe, TextureProbe, TextureSizeProbe, WorldLocProjector};
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
pub use pet::{PetActionView, PetStats};
pub use pvp::{HonorState, InspectHonorData};
@@ -567,6 +570,7 @@ impl UiScript {
skills::install(&lua)?;
item_stats::install(&lua)?;
char_stats::install(&lua)?;
inventory_verbs::install(&lua)?;
weapon_enchant::install(&lua)?;
loot::install(&lua)?;
loot_roll::install(&lua)?;
+18
View File
@@ -17,6 +17,12 @@ use super::{
/// The host's answer to whether a texture path resolves to a file.
pub type TextureProbe = Box<dyn Fn(&str) -> bool>;
/// The host's world-to-map projection (`0x4a7360`): the displayed map's selection as
/// [`super::UiScript::world_map_selection`] reads it, a map id and a world `(x, y)`, to the map UV,
/// `None` where the reference's outputs stay at their `(0, 0)`.
pub type WorldLocProjector =
Box<dyn Fn((u32, u32, Option<u32>), u32, f32, f32) -> Option<(f32, f32)>>;
/// The host's answer to a texture path's size in texels.
pub type TextureSizeProbe = Box<dyn Fn(&str) -> Option<(u32, u32)>>;
@@ -76,6 +82,8 @@ pub(crate) struct Model {
/// Whether a texture path resolves (patch chain or loose addon file), so the path form of
/// `SetTexture` returns the reference's 1 or nil inline (`0x79bb40`). `None` answers nil.
pub(crate) texture_probe: Option<TextureProbe>,
/// `GetWorldLocMapPosition`'s projection; `None` answers `(0, 0)`.
pub(crate) world_loc_projector: Option<WorldLocProjector>,
/// A path's texel size, which an axis authored as 0 takes, one texel per unit, as the client's
/// `GetWidth` (`0x770720`) and `GetHeight` (`0x770790`) do; `None` leaves it as authored.
pub(crate) texture_size_probe: Option<TextureSizeProbe>,
@@ -431,6 +439,8 @@ pub(crate) struct Model {
pub(crate) macros_dirty: bool,
/// Bumped by every seed and change, for readers that must not drain `macros_dirty`.
pub(crate) macros_generation: u64,
/// Each macro's cached cast by 1-based index, from the app; an absent macro reads unbound.
pub(crate) macro_bindings: HashMap<u32, macros::MacroBinding>,
/// The macro icon paths from `SpellIcon.dbc`, behind `GetMacroIconInfo`.
pub(crate) macro_icons: Vec<String>,
/// `ToggleSpellAutocast` ids for `CMSG_PET_SPELL_AUTOCAST` (`0x2F3`), which names a spell.
@@ -709,6 +719,10 @@ pub(crate) struct Model {
pub(crate) mail_stationeries: Vec<mail::StationeryView>,
/// `SelectStationery`'s `Stationery.dbc` id; 0 is none, which silences `SendMail`.
pub(crate) mail_stationery: u32,
/// `Package.dbc`'s rows in file order, `GetPackageInfo`'s list.
pub(crate) mail_packages: Vec<mail::PackageView>,
/// `SelectPackage`'s `Package.dbc` id (`[0xb6efb8]`); 0 is none.
pub(crate) mail_package: u32,
/// `HasNewMail()` (`0x4afea0`), from `MSG_QUERY_NEXT_MAIL_TIME` and `SMSG_RECEIVED_MAIL`.
pub(crate) has_new_mail: bool,
@@ -998,6 +1012,7 @@ impl Model {
addons_chain_reader: None,
measurer: None,
texture_probe: None,
world_loc_projector: None,
texture_size_probe: None,
font_probe: None,
addons_saved_account: None,
@@ -1150,6 +1165,7 @@ impl Model {
macros: macros::MacroState::default(),
macros_dirty: false,
macros_generation: 0,
macro_bindings: HashMap::new(),
macro_icons: Vec::new(),
pet_spell_autocasts: Vec::new(),
casting: false,
@@ -1291,6 +1307,8 @@ impl Model {
mail_send_item: None,
mail_stationeries: Vec::new(),
mail_stationery: 0,
mail_packages: Vec::new(),
mail_package: 0,
has_new_mail: false,
auction: None,
auction_item_classes: Vec::new(),
+64
View File
@@ -120,6 +120,9 @@ pub(crate) struct PetBarState {
pub(crate) can_be_abandoned: bool,
/// `PetCanBeRenamed()`, ANDed with the above for the rename row; set until the first rename.
pub(crate) can_be_renamed: bool,
/// The charm or possess expiry `[0xb714a8]` on the `GetTime` clock, in seconds: the last
/// `SMSG_PET_SPELLS` duration past its arrival, `None` for a packet whose duration is 0.
pub(crate) expiry: Option<f64>,
}
impl super::UiScript {
@@ -160,6 +163,12 @@ impl super::UiScript {
bar.stats = stats;
}
/// Push the pet's expiry on the `GetTime` clock, `None` for none: `SetPet` (`0x4bc7e0`)
/// stores `OsTick() + duration` for each `SMSG_PET_SPELLS` whose duration is not 0, else 0.
pub fn set_pet_expiry(&mut self, expiry: Option<f64>) {
self.model_mut().pet_bar.expiry = expiry;
}
/// Push the menu's two predicates, which move with the pet's `UNIT_FIELD_FLAGS`.
pub fn set_pet_menu(&mut self, can_be_abandoned: bool, can_be_renamed: bool) {
let bar = &mut self.model_mut().pet_bar;
@@ -235,6 +244,28 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let flag = |b: bool| if b { Value::Integer(1) } else { Value::Nil };
// GetPetTimeRemaining() (`0x4be600`): nil with no expiry, else the milliseconds to it as an
// unsigned 32-bit tick difference (`fild qword` over a zero high dword), so one past its
// expiry wraps to about 4.29e9 until the next packet. The stock caller is commented out
// (`PetFrame.lua:91-97`).
g.set(
"GetPetTimeRemaining",
lua.create_function(|lua, ()| {
let expiry = lua
.app_data_ref::<Model>()
.expect("model app_data")
.pet_bar
.expiry;
let Some(expiry) = expiry else {
return Ok(Value::Nil);
};
let now = crate::script::clock::now(lua);
#[allow(clippy::cast_possible_truncation)] // a tick difference, wrapped as the u32 is
let ms = ((expiry - now) * 1000.0).round() as i64 as u32;
Ok(Value::Number(f64::from(ms)))
})?,
)?;
g.set(
"PetHasActionBar",
lua.create_function(move |lua, ()| {
@@ -546,6 +577,39 @@ mod tests {
use super::{PetActionView, PetStats};
use crate::script::UiScript;
/// A charmed or possessed unit's time left in ms, nil for none, and the unsigned wrap once
/// the expiry has passed (`0x4be634`).
#[test]
fn pet_time_remaining_counts_down_to_the_expiry() {
let mut s = UiScript::new().unwrap();
assert!(s
.eval::<Option<f64>>("return GetPetTimeRemaining()")
.unwrap()
.is_none());
s.tick(10.0);
s.set_pet_expiry(Some(12.5));
assert_eq!(
s.eval::<f64>("return GetPetTimeRemaining()").unwrap(),
2500.0
);
s.tick(2.0);
assert_eq!(
s.eval::<f64>("return GetPetTimeRemaining()").unwrap(),
500.0
);
s.tick(1.0);
assert_eq!(
s.eval::<f64>("return GetPetTimeRemaining()").unwrap(),
f64::from(u32::MAX - 499),
"0.5 s past it"
);
s.set_pet_expiry(None);
assert!(s
.eval::<Option<f64>>("return GetPetTimeRemaining()")
.unwrap()
.is_none());
}
/// Attack (a token, attacking), Claw (a spell, autocasting, cooling down), an empty slot.
fn slots() -> Vec<PetActionView> {
vec![
+23 -1
View File
@@ -66,6 +66,11 @@ pub enum PetitionRequest {
TurnIn,
/// `CloseGuildRegistrar()`: sends nothing (`0x4f5010`).
CloseRegistrar,
/// `GetTabardInfo()` (`0x4f5300`), not a getter: with a player, `MSG_TABARDVENDOR_ACTIVATE`
/// for the registrar's guid unless the designer is already open on that guid, or the
/// `ERR_EMBLEMERROR_NOTABARDGEOSET` refusal while the player's display id is not its native
/// one (`0x5e00e0`); then the registrar closes (`0x4f5355` → `0x4f5010`).
TabardInfo,
/// `SignPetition([n])`: a wire byte defaulting to 1, not 0 (`0x4f46d9`); vmangos skips it.
Sign(i8),
/// `OfferPetition()`: the app resolves the current target (`CGGameUI`'s selection pair, not its
@@ -222,10 +227,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// ── The verbs ────────────────────────────────────────────────────────────────────────────
// The four that carry and push nothing; none may touch the snapshot, which the server owns.
// The five that carry and push nothing; none may touch the snapshot, which the server owns.
// `GetTabardInfo`'s stock caller is commented out (`GuildRegistrarFrame.xml:190`).
for (global, request) in [
("TurnInGuildCharter", PetitionRequest::TurnIn),
("CloseGuildRegistrar", PetitionRequest::CloseRegistrar),
("GetTabardInfo", PetitionRequest::TabardInfo),
("OfferPetition", PetitionRequest::Offer),
("ClosePetition", PetitionRequest::ClosePetition),
] {
@@ -529,6 +536,21 @@ mod tests {
}
}
/// `GetTabardInfo()` pushes nothing (`0x4f535f`) and queues the designer's opener.
#[test]
fn get_tabard_info_is_an_intent_with_no_returns() {
let mut s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("local n = function(...) return arg.n end return n(GetTabardInfo())")
.unwrap(),
0
);
assert_eq!(
s.take_petition_requests(),
vec![PetitionRequest::TabardInfo]
);
}
#[test]
fn dropping_close_intents_leaves_the_other_verbs_untouched() {
let mut s = UiScript::new().unwrap();
+33
View File
@@ -26,6 +26,39 @@ pub(super) fn region_handle_of(lua: &Lua, this: &Table) -> mlua::Result<RegionHa
.ok_or_else(|| mlua::Error::runtime("stale or invalid region handle"))
}
/// Argument 1 of a global that takes a Texture as its `this` (`SetLootPortrait` `0x4c2b40`,
/// `SetInventoryPortaitTexture` `0x4c9150`), with the member prologue's three raises: a non-table
/// (`0x847ef8`), a table with no object at `[0]` (`0x847ec0`), and any object but a Texture, the
/// `IsA` test against the Texture class token `[0xcf4cdc]` (`0x847e98`).
pub(super) fn this_texture(lua: &Lua, this: &Value) -> mlua::Result<RegionHandle> {
let Value::Table(t) = this else {
return Err(mlua::Error::runtime(
"Attempt to find 'this' in non-table object (used '.' instead of ':' ?)",
));
};
if decode_id(t).is_err() {
return Err(mlua::Error::runtime(
"Attempt to find 'this' in non-framescript object",
));
}
let wrong = || mlua::Error::runtime("Wrong object type for member function");
let rh = region_handle_of(lua, t).map_err(|_| wrong())?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
if model.arena.region(rh).map(|r| r.kind) != Some(RegionKind::Texture) {
return Err(wrong());
}
Ok(rh)
}
/// `CSimpleTexture::SetTexture(NULL)` (`0x770300` with 0): the texture slot emptied, a file, a
/// solid fill and a baked unit portrait alike; the shader slot, and so the desaturation, stays.
pub(super) fn clear_texture(model: &mut Model, rh: RegionHandle) {
let data = model.region_data.entry(rh).or_default();
data.texture = None;
data.fill = None;
data.portrait_unit = None;
}
/// Apply the font parts an XML element supplies (`font=`, `<FontHeight>`, `outline=`), any of
/// which may be absent. Not the `SetFont` binding: the reference applies XML fonts in `LoadXML`,
/// and its `SetFont` requires a path and a height (`0x87c69c`). Each part is an explicit set, so it
@@ -608,6 +608,7 @@ fn a_looted_roll_shows_its_lines_and_never_the_placeholder() {
},
);
s.set_loot(Some(LootState {
source_unit: false,
master_candidates: Vec::new(),
rows: vec![Some(LootRow {
name: Some("Bloodrazor of the Monkey".into()),
@@ -689,6 +690,7 @@ fn a_looted_item_with_no_roll_shows_neither_line_nor_placeholder() {
},
);
s.set_loot(Some(LootState {
source_unit: false,
master_candidates: Vec::new(),
rows: vec![Some(LootRow {
name: Some("Bloodrazor".into()),
@@ -623,3 +623,49 @@ fn worldmap_overlays_reveal_by_explored_bits() {
s.run("SetMapZoom(2)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumMapOverlays()").unwrap(), 0);
}
/// `GetWorldLocMapPosition` (`0x4a88f0`) hands the displayed selection, the truncated map id and
/// the f32 position to the host's projection, answers `(0, 0)` without one, and raises on any
/// non-number argument.
#[test]
fn world_loc_map_position_projects_through_the_host() {
let mut s = script();
push_catalog(&mut s);
assert_eq!(
s.eval::<(f64, f64)>("return GetWorldLocMapPosition(0, 100, 200)")
.unwrap(),
(0.0, 0.0),
"no projection: the zeroed outputs"
);
let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let log = seen.clone();
s.set_world_loc_projector(Box::new(move |sel, map, x, y| {
log.borrow_mut().push((sel, map, x, y));
(map == 0).then_some((0.25, 0.75))
}));
s.run("SetMapZoom(2, 1)").unwrap();
assert_eq!(
s.eval::<(f64, f64)>("return GetWorldLocMapPosition(0.9, '-8913.2', 554.6)")
.unwrap(),
(0.25, 0.75)
);
assert_eq!(
s.eval::<(f64, f64)>("return GetWorldLocMapPosition(-1, 0, 0)")
.unwrap(),
(0.0, 0.0)
);
let seen = seen.borrow();
assert_eq!(seen[0], ((2, 1, None), 0, -8913.2f32, 554.6f32));
assert_eq!(seen[1].1, u32::MAX, "a negative map id matches nothing");
for call in [
"GetWorldLocMapPosition(0, 1)",
"GetWorldLocMapPosition('x', 1, 1)",
"GetWorldLocMapPosition(0, nil, 1)",
] {
let err = s.run(call).unwrap_err().to_string();
assert!(
err.contains("Usage: GetWorldLocMapPosition(continent, x, y)"),
"{call}: {err}"
);
}
}
@@ -723,6 +723,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetDamageBonusStat() (`0x48b520`): the active player's `ChrClasses.dbc` field 2 plus one,
// the 1-based `UnitStat` index its melee damage scales with; 0 with no player or no class row
// (`0x48b58a`). No stock caller.
g.set(
"GetDamageBonusStat",
lua.create_function(|lua, ()| {
let stat = with_unit(lua, &Some("player".to_string()), None, |u| {
u.damage_bonus_stat
})?;
Ok(stat.map_or(0, |s| i64::from(s) + 1))
})?,
)?;
// UnitSex(unit): 2 male, 3 female, or 1 neuter, which no feed sends. A unit that does not
// resolve answers 2, never nil (`0x517f9f`).
g.set(
+3
View File
@@ -107,6 +107,9 @@ pub struct UnitState {
/// `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,
/// `ChrClasses.dbc` field 2 for a player's class, 0-based (Strength 0, Agility 1), which
/// `GetDamageBonusStat` (`0x48b520`) reads for the active player; `None` with no class row.
pub damage_bonus_stat: Option<u32>,
/// `UnitSex`'s scale, 2 male and 3 female, from `UNIT_FIELD_BYTES_0` byte 2 (0 male, 1
/// female). The unfilled 0 answers 2, the reference's unresolved answer (`0x517f9f`).
pub sex: u8,
@@ -1379,6 +1379,35 @@ fn unit_is_party_leader_ors_two_legs_and_answers_one_when_solo() {
assert!(s.run(r#"UnitIsPartyLeader("notatoken")"#).is_err());
}
/// `GetDamageBonusStat()` (`0x48b520`) is the player's class row's field 2 plus one, 0 with no
/// player or no row; it takes no unit, so another unit's class never answers.
#[test]
fn damage_bonus_stat_is_the_player_class_stat_one_based() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetDamageBonusStat()").unwrap(), 0);
let mut rogue = player();
rogue.damage_bonus_stat = Some(1);
s.set_unit("target", Some(rogue.clone()));
assert_eq!(
s.eval::<i64>("return GetDamageBonusStat()").unwrap(),
0,
"the target is not the player"
);
s.set_unit("player", Some(rogue));
assert_eq!(s.eval::<i64>("return GetDamageBonusStat()").unwrap(), 2);
let mut warrior = player();
warrior.damage_bonus_stat = Some(0);
s.set_unit("player", Some(warrior));
assert_eq!(s.eval::<i64>("return GetDamageBonusStat()").unwrap(), 1);
let mut rowless = player();
rowless.damage_bonus_stat = None;
s.set_unit("player", Some(rowless));
assert_eq!(s.eval::<i64>("return GetDamageBonusStat()").unwrap(), 0);
}
/// Stock calls it unconditionally (`PaperDollFrame.lua:429`, `PaperDollFrame.lua:580`), so the
/// global must exist for every class.
#[test]
+35
View File
@@ -418,6 +418,11 @@ impl super::UiScript {
(wm.selection.0, wm.selection.1, wm.direct_area)
}
/// Install the host's world-to-map projection behind `GetWorldLocMapPosition`.
pub fn set_world_loc_projector(&mut self, projector: super::WorldLocProjector) {
self.model_mut().world_loc_projector = Some(projector);
}
/// Moves the selection from the engine, as the zone updater `0x494780` calls the setter
/// `0x4a67a0`: a fresh login shows the player's map before any Lua runs, not the world sheet,
/// whose `GetPlayerMapPosition` is sheet UV (`0x4a7360`). It takes the resolver's whole
@@ -602,6 +607,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetWorldLocMapPosition(continent, x, y) (`0x4a88f0`): a world position on the displayed
// map, through the projection `GetCorpseMapPosition` uses (`0x4a7360`). The first argument is
// the position's map id, however the usage names it (`0x4a7320` matches it against
// `WorldMapContinent.dbc`'s map ids); x and y narrow to f32. Each argument must be a number,
// else the usage raises; off the displayed map the answer is (0, 0). No stock caller.
g.set(
"GetWorldLocMapPosition",
lua.create_function(|lua, (map, x, y): (Value, Value, Value)| {
const USAGE: &str = "Usage: GetWorldLocMapPosition(continent, x, y)";
let (Some(map), Some(x), Some(y)) = (
lua.coerce_number(map)?,
lua.coerce_number(x)?,
lua.coerce_number(y)?,
) else {
return Err(mlua::Error::RuntimeError(USAGE.into()));
};
// The truncating cast, then the map id compared as an unsigned dword.
let map = map as i64 as i32 as u32;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let wm = &model.worldmap;
let selection = (wm.selection.0, wm.selection.1, wm.direct_area);
let (u, v) = model
.world_loc_projector
.as_ref()
.and_then(|project| project(selection, map, x as f32, y as f32))
.unwrap_or((0.0, 0.0));
Ok((f64::from(u), f64::from(v)))
})?,
)?;
// GetCorpseMapPosition() → map UV, or (0, 0), which WorldMapFrame.lua:445 hides.
g.set(
"GetCorpseMapPosition",
+1 -1
View File
@@ -104,7 +104,7 @@
- **benilla-adt** (lib.rs): combined_alpha
- **benilla-app** (lib.rs): addon_harness area area_poi area_trigger asset_churn aura_visual bindings blob_shadow bowstring camera_shake capture char_create char_select chat_bubble chr_classes cinematic combat_log combat_text console crash creature_anim cursor cvars death debug_panel dev doodad_events entities fishing_line footprints game_plugins game_tip glue glue_strings go_anim go_templates hover_log items loading_screen local_state login minimap name_persist nameplates names net npc_text opaque2d pending_item_ops perf pipe_warm player poi_marker portrait preflight probe_shield query_cache quest_markers raid_marks ranged_flex realm_select realmlist run_mode screen_fade screenshot script_calls shaders shutdown smart_rect sound spell swing_refusal target test_support text_filter text_reshape textinput transport tutorial ui_action ui_auction ui_aura ui_bank ui_battlefield ui_battlefield_positions ui_battlefield_score ui_bind_confirm ui_binder ui_cast ui_char ui_chat ui_craft ui_dialog_verbs ui_dressup ui_duel ui_follow ui_gamma ui_gm_ticket ui_gossip ui_guild ui_hide ui_honor ui_inspect ui_instance ui_item_text ui_items ui_layout ui_logout ui_loot ui_loot_roll ui_macro ui_mail ui_merchant ui_mirror ui_models ui_net ui_party ui_pass ui_pet ui_pet_book ui_pet_doll ui_pet_stats ui_petition ui_quest ui_quest_log ui_quest_share ui_reputation ui_saved ui_script ui_session ui_shapeshift ui_social ui_spellbook ui_stable ui_summon ui_tabard ui_talent ui_talent_wipe ui_taxi ui_text ui_tooltip ui_trade ui_tradeskill ui_trainer ui_unit ui_world_map video vplates weapon_trail world_backdrop world_state world_state_ui
- **benilla-assets** (lib.rs): adt anim_rng blp column_grid coords gpu_blp m2 materials minimap_grid model spatial_cache terrain tex_filter trace wdt wmo world_assets
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen lock lock_type macro_icons maps material minimap_translate models npc_greeting page_text_material particles pet_stats quest_headers quest_info race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen lock lock_type macro_icons maps material minimap_translate models npc_greeting packages page_text_material particles pet_stats quest_headers quest_info race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
- **benilla-m2** (lib.rs): error model skin tests track
- **benilla-mpq** (lib.rs): crypto
- **benilla-protocol** (lib.rs): auth events guid messages wire world