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:
co-authored by
Claude Opus 5.5
parent
48afcbce0a
commit
33c2c5aa70
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:#}"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(©, 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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user