Files
benilla-octo/crates/benilla-ui/src/script/mail.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

1221 lines
46 KiB
Rust

//! The mail bindings. The app pushes the open mailbox's inbox, already resolved from the wire
//! (`UiScript::set_mail`), and the Lua verbs queue intents the app drains and sends. Indices are
//! 1-based, as `MailFrame.lua` uses them.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::cursor::{self, CursorPayload};
use super::Model;
/// One inbox row, resolved by the app from a wire `MailListEntry`; its 1-based index is its
/// position in [`MailState::inbox`].
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MailInboxRow {
/// `packageIcon`, the enclosed item's icon; `None` with no item or while its template loads.
pub package_icon: Option<String>,
pub stationery_icon: Option<String>,
/// `sender`, from the name cache; `None` while in flight or for a non-player sender.
pub sender: Option<String>,
pub subject: String,
pub money: u32,
pub cod: u32,
pub days_left: f32,
/// The enclosed stack's count, answered as `hasItem`; 0 (nil) is no item.
pub item_count: u32,
/// `checked & READ(0x1)`.
pub was_read: bool,
/// `checked & RETURNED(0x2)`.
pub was_returned: bool,
/// `checked & COPIED(0x4)`: the letter was copied to an item (`textCreated`).
pub text_created: bool,
/// `sender_guid.is_some() && !was_returned`, the Reply button's gate (`MailFrame.lua:281`).
pub can_reply: bool,
/// `stationery == 61`: a GM mail (vmangos `MAIL_STATIONERY_GM`, `Mail.h:85`).
pub is_gm: bool,
/// The letter body; `None` until fetched, while `GetInboxText` answers `""`.
pub body: Option<String>,
pub stationery_texture: String,
/// `isInvoice`: only an auction won (result 1) or sold (2), not an outbid or expiry notice
/// (`0x4af2eb`).
pub is_invoice: bool,
/// `GetInboxInvoiceInfo`'s answer; `None`, the reference's miss, also while the body or the
/// counterparty's name is in flight.
pub invoice: Option<MailInvoice>,
/// `item_text_id != 0`: the mail has a letter body to fetch.
pub has_body: bool,
/// The enclosed item's template entry, 0 for none; the key `SetInboxItem`'s tooltip reads.
pub item_id: u32,
/// The enclosed item's random-suffix id (the wire `randomPropId`), 0 for none, which the
/// reference's `SetInboxItem` hands the tooltip (`+0x424`); `item_name` carries its suffix.
pub item_random_property_id: u32,
/// The enclosed item's name; `None` while its template is in flight.
pub item_name: Option<String>,
pub item_texture: Option<String>,
pub item_quality: Option<u32>,
/// `InboxItemCanDelete`: `!can_reply`, as a player's unreturned mail is returned instead
/// (`MailFrame.lua:417`, `:450`).
pub can_delete: bool,
}
/// One auction invoice, `GetInboxInvoiceInfo`'s answer, parsed by the app from the mail's subject
/// and body text as the reference `sscanf`s them (`0x4af360`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MailInvoice {
/// `"seller"` (your auction sold) or `"buyer"` (you won), literal tokens, not global strings.
pub seller: bool,
pub item_name: String,
/// The counterparty: who bought it (seller invoice) or who sold it (buyer invoice).
pub player_name: String,
/// The winning bid; the window reads `bid == buyout` as a buyout (`MailFrame.lua:307`).
pub bid: u32,
pub buyout: u32,
/// Seller invoices only; 0 on a buyer's, whose body carries three fields, not five.
pub deposit: u32,
/// The auction house's cut, seller invoices only.
pub consignment: u32,
}
/// The open mailbox's inbox, pushed whole by the app; `set_mail(None)` means no mailbox is open.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MailState {
pub inbox: Vec<MailInboxRow>,
}
/// One usable stationery in the send tab's picker; the app builds the list from `Stationery.dbc`
/// and the bags, sorted by price.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StationeryView {
/// The `Stationery.dbc` row id, which `SelectStationery` stores and `CMSG_SEND_MAIL` carries.
pub id: u32,
pub name: String,
/// The item's icon as a full `Interface\Icons\` path, which `MailFrame.lua:671` sets as is.
pub icon: String,
/// The item's BuyPrice in copper; `None` (nil) when the player carries one.
pub cost: Option<u32>,
/// The bare texture basename that `GetSelectedStationeryTexture` answers.
pub texture: String,
}
/// A drained `SendMail` intent, which the app turns into `CMSG_SEND_MAIL`.
#[derive(Clone, Debug, PartialEq)]
pub struct MailSendRequest {
pub target: String,
pub subject: String,
pub body: String,
/// The selected `Stationery.dbc` id; never 0, as a send with none aborts (`0x4ae8dd`).
pub stationery: u32,
pub money: u32,
pub cod: u32,
/// 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)>,
}
impl super::UiScript {
/// Push (or clear, with `None`) the open mailbox's inbox snapshot.
pub fn set_mail(&mut self, state: Option<MailState>) {
self.model_mut().mail = state;
}
/// Take the `CheckInbox` flag; the app answers it with `CMSG_GET_MAIL_LIST`.
pub fn take_mail_check_inbox(&mut self) -> bool {
std::mem::take(&mut self.model_mut().mail_check_inbox)
}
/// Drain the 1-based indices `GetInboxText` opened; the app marks each read, fetches its body.
pub fn take_mail_opens(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().mail_opens)
}
/// Drain the 1-based `TakeInboxItem` row picks.
pub fn take_mail_take_items(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().mail_take_items)
}
/// Drain the 1-based `TakeInboxMoney` row picks.
pub fn take_mail_take_money(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().mail_take_money)
}
/// Drain the 1-based `DeleteInboxItem` row picks.
pub fn take_mail_deletes(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().mail_deletes)
}
/// Drain the 1-based `ReturnInboxItem` row picks.
pub fn take_mail_returns(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().mail_returns)
}
/// Drain the 1-based `TakeInboxTextItem` row picks, each a `CMSG_MAIL_CREATE_TEXT_ITEM`.
pub fn take_mail_take_texts(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().mail_take_texts)
}
/// Take the `CloseMail` flag; 1.12 has no close opcode, so the app only ends its mail session.
pub fn take_mail_close(&mut self) -> bool {
std::mem::take(&mut self.model_mut().mail_close)
}
/// Drain the `SendMail` intent with the current money, COD and attachment. The attachment
/// stays until [`Self::reset_compose_tab`], so a failed send keeps it.
pub fn take_mail_send(&mut self) -> Option<MailSendRequest> {
let mut model = self.model_mut();
let (target, subject, body) = model.mail_send.take()?;
let item = model.mail_send_item.as_ref().map(|it| (it.bag, it.slot));
Some(MailSendRequest {
target,
subject,
body,
stationery: model.mail_stationery,
money: model.mail_send_money,
cod: model.mail_send_cod,
item,
})
}
/// 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.
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;
}
// Fired now, not queued: the order against the caller's `MAIL_SHOW`/`MAIL_FAILED` matters.
// 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
}
/// `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) {
let mut model = self.model_mut();
if model.mail_send_item.take().is_some() {
model
.pending_events
.push(("MAIL_SEND_INFO_UPDATE".to_string(), Vec::new()));
}
}
/// Push the usable stationery list, in the picker's order.
pub fn set_mail_stationeries(&mut self, list: Vec<StationeryView>) {
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 `HasNewMail()`'s answer, independent of any open mailbox. The app sets it before each
/// `UPDATE_PENDING_MAIL`, on which the minimap icon re-reads it (`Minimap.xml:278-289`).
pub fn set_has_new_mail(&mut self, has: bool) {
self.model_mut().has_new_mail = has;
}
}
/// A clone of the inbox row at a 1-based index.
fn row_at(model: &Model, index: usize) -> Option<MailInboxRow> {
model
.mail
.as_ref()
.and_then(|m| index.checked_sub(1).and_then(|n| m.inbox.get(n)))
.cloned()
}
/// Register the mail globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"GetInboxNumItems",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.mail.as_ref().map_or(0, |m| m.inbox.len()) as i64)
})?,
)?;
// GetInboxHeaderInfo(index): the 13 values `MailFrame.lua:105` reads; out of range, money,
// CODAmount and daysLeft are 0 and the rest nil, never a single nil — the reference answers
// every path with the full tuple, as its siblings do (`0x4af559`, `0x4af5d0`), and a stale
// open letter that reads past the end would otherwise raise `MailFrame.lua:399`'s
// `CODAmount > 0` on a nil.
g.set(
"GetInboxHeaderInfo",
lua.create_function(|lua, index: usize| {
let row = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
row_at(&model, index)
};
let Some(r) = row else {
return Ok(MultiValue::from_vec(vec![
Value::Nil, // packageIcon
Value::Nil, // stationeryIcon
Value::Nil, // sender
Value::Nil, // subject
Value::Integer(0), // money
Value::Integer(0), // CODAmount
Value::Number(0.0), // daysLeft
Value::Nil, // hasItem
Value::Nil, // wasRead
Value::Nil, // wasReturned
Value::Nil, // textCreated
Value::Nil, // canReply
Value::Nil, // isGM
]));
};
let opt_str = |s: &Option<String>| -> mlua::Result<Value> {
Ok(match s {
Some(v) => Value::String(lua.create_string(v)?),
None => Value::Nil,
})
};
// packageIcon only for a non-GM mail with an item (`MailFrame.lua:108`).
let package_icon = if r.item_id != 0 && !r.is_gm {
opt_str(&r.package_icon)?
} else {
Value::Nil
};
Ok(MultiValue::from_vec(vec![
package_icon,
opt_str(&r.stationery_icon)?,
opt_str(&r.sender)?,
Value::String(lua.create_string(&r.subject)?),
Value::Integer(i64::from(r.money)),
Value::Integer(i64::from(r.cod)),
Value::Number(f64::from(r.days_left)),
if r.item_count > 0 {
Value::Integer(i64::from(r.item_count))
} else {
Value::Nil
},
flag(r.was_read),
flag(r.was_returned),
flag(r.text_created),
flag(r.can_reply),
flag(r.is_gm),
]))
})?,
)?;
// GetInboxText(index): body, stationeryTexture, isTakeable, isInvoice (`MailFrame.lua:292`);
// reading a mail queues its open. isTakeable with `not textCreated` gates the copy button
// (`MailFrame.lua:366`), the pair vmangos requires (`MailHandler.cpp:867`).
g.set(
"GetInboxText",
lua.create_function(|lua, index: usize| {
let row = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let row = row_at(&model, index);
if row.is_some() {
// Queued once per drain.
let i = index as u32;
if !model.mail_opens.contains(&i) {
model.mail_opens.push(i);
}
}
row
};
let Some(r) = row else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
Ok(MultiValue::from_vec(vec![
// An invoice's body is nil (`0x4af1cf`): its text is the auction house's
// bookkeeping, which only `GetInboxInvoiceInfo` parses.
if r.is_invoice {
Value::Nil
} else {
Value::String(lua.create_string(r.body.as_deref().unwrap_or(""))?)
},
Value::String(lua.create_string(&r.stationery_texture)?),
flag(r.has_body),
flag(r.is_invoice),
]))
})?,
)?;
// GetInboxInvoiceInfo(index) (`MailFrame.lua:302`): always seven values (`0x4af5a1`), a miss
// being three nils and four zeros (`0x4af559`).
g.set(
"GetInboxInvoiceInfo",
lua.create_function(|lua, index: usize| {
let row = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
row_at(&model, index)
};
let miss = || {
MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Nil,
Value::Integer(0),
Value::Integer(0),
Value::Integer(0),
Value::Integer(0),
])
};
let Some(inv) = row.and_then(|r| r.invoice) else {
return Ok(miss());
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(if inv.seller { "seller" } else { "buyer" })?),
Value::String(lua.create_string(&inv.item_name)?),
Value::String(lua.create_string(&inv.player_name)?),
Value::Integer(i64::from(inv.bid)),
Value::Integer(i64::from(inv.buyout)),
Value::Integer(i64::from(inv.deposit)),
Value::Integer(i64::from(inv.consignment)),
]))
})?,
)?;
// GetInboxItem(index): name, texture, count, quality, canUse (`MailFrame.lua:379`); canUse is
// the shared item-usable gate, true while the template is in flight.
g.set(
"GetInboxItem",
lua.create_function(|lua, index: usize| {
let (row, usable) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let row = row_at(&model, index);
let usable = row
.as_ref()
.is_none_or(|r| super::item_stats::item_usable_by_id(&model, r.item_id));
(row, usable)
};
// Five values on every path; with no item, `nil, nil, 0, 0, nil` (`0x4af5d0`).
let Some(r) = row.filter(|r| r.item_id != 0) else {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Integer(0),
Value::Integer(0),
Value::Nil,
]));
};
let name = match &r.item_name {
Some(n) => Value::String(lua.create_string(n)?),
None => Value::Nil,
};
let texture = match &r.item_texture {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
};
// A number on every path, as in `GetSendMailItem`.
let quality = Value::Integer(i64::from(r.item_quality.unwrap_or(0)));
Ok(MultiValue::from_vec(vec![
name,
texture,
Value::Integer(i64::from(r.item_count.max(1))),
quality,
flag(usable),
]))
})?,
)?;
g.set(
"InboxItemCanDelete",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(row_at(&model, index).is_some_and(|r| r.can_delete)))
})?,
)?;
g.set(
"HasNewMail",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(model.has_new_mail))
})?,
)?;
g.set(
"CheckInbox",
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.mail_check_inbox = true;
Ok(())
})?,
)?;
for (name, field) in [
("TakeInboxItem", 0u8),
("TakeInboxMoney", 1),
("DeleteInboxItem", 2),
("ReturnInboxItem", 3),
("TakeInboxTextItem", 4),
] {
g.set(
name,
lua.create_function(move |lua, index: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
match field {
0 => model.mail_take_items.push(index),
1 => model.mail_take_money.push(index),
2 => model.mail_deletes.push(index),
3 => model.mail_returns.push(index),
_ => model.mail_take_texts.push(index),
}
Ok(())
})?,
)?;
}
g.set(
"CloseMail",
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.mail_close = true;
Ok(())
})?,
)?;
g.set(
"SendMail",
lua.create_function(|lua, (target, subject, body): (String, String, String)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// `SendMail` (`0x4ae800`) aborts silently, no packet and no return value, on money
// with COD or on COD with no attached item.
if model.mail_send_money != 0 && model.mail_send_cod != 0 {
return Ok(());
}
if model.mail_send_cod != 0 && model.mail_send_item.is_none() {
return Ok(());
}
// The same silent abort with no stationery selected (`0x4ae8dd`).
if model.mail_stationery == 0 {
return Ok(());
}
model.mail_send = Some((target, subject, body));
Ok(())
})?,
)?;
// SetSendMailMoney(copper) (`0x4ae0f0`): a non-number raises; more than the purse shows
// ERR_NOT_ENOUGH_MONEY and answers nil; else it stores, fires SEND_MAIL_MONEY_CHANGED and
// answers 1, which `StaticPopup.lua:257` branches on.
g.set(
"SetSendMailMoney",
lua.create_function(|lua, copper: Value| {
let n = crate::script::binding_abi::number_arg(
lua,
copper,
"Usage: SetSendMailMoney(amount)",
)? as u32;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if u64::from(n) > model.money {
model.ui_errors.push("ERR_NOT_ENOUGH_MONEY");
return Ok(Value::Nil);
}
model.mail_send_money = n;
model
.pending_events
.push(("SEND_MAIL_MONEY_CHANGED".to_string(), Vec::new()));
Ok(Value::Integer(1))
})?,
)?;
// GetSendMailMoney() and GetSendMailCOD() (`0x4ae150`, `0x4ae1c0`): the stored amounts.
g.set(
"GetSendMailMoney",
lua.create_function(|lua, ()| {
Ok(i64::from(
lua.app_data_ref::<Model>()
.expect("model app_data")
.mail_send_money,
))
})?,
)?;
g.set(
"GetSendMailCOD",
lua.create_function(|lua, ()| {
Ok(i64::from(
lua.app_data_ref::<Model>()
.expect("model app_data")
.mail_send_cod,
))
})?,
)?;
// SetSendMailCOD(copper) (`0x4ae180`): a non-number raises; with no attached item nothing
// happens; there is no purse or sign check and no return value.
g.set(
"SetSendMailCOD",
lua.create_function(|lua, copper: Value| {
let n = crate::script::binding_abi::number_arg(
lua,
copper,
"Usage: SetSendMailCOD(amount)",
)? as u32;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.mail_send_item.is_none() {
return Ok(());
}
model.mail_send_cod = n;
model
.pending_events
.push(("SEND_MAIL_COD_CHANGED".to_string(), Vec::new()));
Ok(())
})?,
)?;
// GetSendMailItem(): name, texture, stackCount, quality (`MailFrame.lua:511`). The reference
// answers `nil, nil, 0, 0` both with nothing attached and before the template loads
// (`0x4ae590`). It answers quality -1 for an item whose InventoryType is 0 (`0x4ae6bf`); this
// answers the cached quality, or 0.
g.set(
"GetSendMailItem",
lua.create_function(|lua, ()| {
// A whole-stack pickup records no count, so the size comes from the source slot.
let item = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.mail_send_item.clone().map(|it| {
let count = cursor::held_count(&model, &it);
(it, count)
})
};
let Some((it, count)) = item else {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Integer(0),
Value::Integer(0),
]));
};
let name = cursor::item_link_name(it.link.as_deref());
let name = if name.is_empty() {
Value::Nil
} else {
Value::String(lua.create_string(&name)?)
};
let texture = match &it.texture {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
};
// A number on every path, as in the reference.
let quality = Value::Integer(i64::from(it.quality.unwrap_or(0)));
Ok(MultiValue::from_vec(vec![
name,
texture,
Value::Integer(i64::from(count)),
quality,
]))
})?,
)?;
// GetSendMailPrice(): 30 copper, vmangos's fee (`MailHandler.cpp:247`). The reference's is 30
// (`0x4ae756`) plus an uncarried stationery's price, the package's and the enclosed money
// (`0x4ae7a0`-`0x4ae7d2`); those are not added here.
g.set(
"GetSendMailPrice",
lua.create_function(|_, ()| Ok(Value::Integer(30)))?,
)?;
g.set(
"ClickSendMailItemButton",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
click_send_mail_item(&mut model);
Ok(())
})?,
)?;
// ── The stationery family ──
// The reference rebuilds the list (`0x4ad970`) in `GetNumStationeries` (`0x4ae1f0`), on world
// entry and on the last item-query answer; the app recomputes it every frame.
g.set(
"GetNumStationeries",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.mail_stationeries.len() as i64)
})?,
)?;
// `GetStationeryInfo(index)` (`0x4ae230`): a non-number raises its Usage; three values
// always, three nils out of range.
g.set(
"GetStationeryInfo",
lua.create_function(|lua, index: Value| {
let index = crate::script::binding_abi::number_arg(
lua,
index,
"Usage: GetStationeryInfo(index)",
)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let row = usize::try_from(index)
.ok()
.and_then(|i| i.checked_sub(1))
.and_then(|i| model.mail_stationeries.get(i));
let Some(row) = row else {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Nil,
]));
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&row.name)?),
Value::String(lua.create_string(&row.icon)?),
row.cost
.map_or(Value::Nil, |c| Value::Integer(i64::from(c))),
]))
})?,
)?;
// `SelectStationery(index)` (`0x4ae380`): out of range is not an error, it stores 0.
g.set(
"SelectStationery",
lua.create_function(|lua, index: Value| {
let index = crate::script::binding_abi::number_arg(
lua,
index,
"Usage: SelectStationery(index)",
)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.mail_stationery = usize::try_from(index)
.ok()
.and_then(|i| i.checked_sub(1))
.and_then(|i| model.mail_stationeries.get(i))
.map_or(0, |row| row.id);
Ok(())
})?,
)?;
// `GetSelectedStationeryTexture()` (`0x4ae3f0`): nil for no selection or an unknown id.
g.set(
"GetSelectedStationeryTexture",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let id = model.mail_stationery;
let tex = (id != 0)
.then(|| model.mail_stationeries.iter().find(|r| r.id == id))
.flatten();
Ok(match tex {
Some(row) => Value::String(lua.create_string(&row.texture)?),
None => Value::Nil,
})
})?,
)?;
Ok(())
}
/// `ClickSendMailItemButton`: the send slot as a cursor drop target, firing `CURSOR_UPDATE` and
/// `ITEM_LOCK_CHANGED` like a bag slot.
fn click_send_mail_item(model: &mut Model) {
match model.cursor.take() {
// A held bag item attaches; it stays in its bag slot until the send.
Some(CursorPayload::Item(item)) => {
let (bag, slot) = (item.bag, item.slot);
// An earlier attachment is overwritten, as in the reference; its slot has no lock.
model.mail_send_item = Some(item);
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
// `MAIL_SEND_INFO_UPDATE` (`0x4ae0de`): the stock tab re-reads the item and postage.
model
.pending_events
.push(("MAIL_SEND_INFO_UPDATE".to_string(), Vec::new()));
}
None => {
if let Some(item) = model.mail_send_item.take() {
let (bag, slot) = (item.bag, item.slot);
model.cursor = Some(CursorPayload::Item(item));
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
model
.pending_events
.push(("MAIL_SEND_INFO_UPDATE".to_string(), Vec::new()));
}
}
Some(other) => {
model.cursor = Some(other);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
/// A send needs a stationery: select the default, as the stock `SendMailFrame_Reset` does.
fn select_default_stationery(s: &mut UiScript) {
s.set_mail_stationeries(vec![super::StationeryView {
id: 41,
name: "Default Stationery".into(),
icon: "Interface\\Icons\\INV_Letter_15".into(),
cost: Some(0),
texture: "STATIONERYTEST".into(),
}]);
s.run("SelectStationery(1)").unwrap();
}
fn row(item_id: u32) -> MailInboxRow {
MailInboxRow {
package_icon: (item_id != 0).then(|| "Interface\\Icons\\INV_Misc_Bag_08".to_string()),
stationery_icon: Some("Interface\\Icons\\INV_Letter_15".to_string()),
sender: Some("Thrall".into()),
subject: "Warchief's orders".into(),
money: 5000,
cod: 0,
days_left: 29.5,
item_count: if item_id != 0 { 3 } else { 0 },
was_read: false,
was_returned: false,
text_created: false,
can_reply: true,
is_gm: false,
body: Some("Lok'tar.".into()),
stationery_texture: "STATIONERYTEST".into(),
is_invoice: false,
invoice: None,
has_body: true,
item_id,
item_name: (item_id != 0).then(|| "Linen Cloth".to_string()),
item_texture: (item_id != 0)
.then(|| "Interface\\Icons\\INV_Fabric_Linen_01".to_string()),
item_quality: (item_id != 0).then_some(1),
can_delete: false, // a player's unreturned mail is returned, not deleted
item_random_property_id: 0,
}
}
fn state() -> MailState {
MailState {
inbox: vec![row(2589), row(0)],
}
}
#[test]
fn the_invoice_answers_seven_values_or_a_three_nil_four_zero_miss() {
let mut s = UiScript::new().unwrap();
let mut st = state();
st.inbox[0].is_invoice = true;
st.inbox[0].invoice = Some(MailInvoice {
seller: true,
item_name: "Linen Cloth".into(),
player_name: "Twowarrior".into(),
bid: 10_000,
buyout: 10_000,
deposit: 25,
consignment: 500,
});
s.set_mail(Some(st));
assert_eq!(s.arity("GetInboxInvoiceInfo(1)").unwrap(), 7);
let vals: Vec<String> = (1..=7)
.map(|i| {
let discards = "_, ".repeat(i - 1);
s.eval::<String>(&format!(
"local {discards}v = GetInboxInvoiceInfo(1) return tostring(v)"
))
.unwrap()
})
.collect();
assert_eq!(
vals,
[
"seller",
"Linen Cloth",
"Twowarrior",
"10000",
"10000",
"25",
"500"
],
"a seller invoice, in the reference's own order"
);
// Row 2 has no invoice, and index 99 is off the end.
for idx in [2, 99] {
assert_eq!(
s.arity(&format!("GetInboxInvoiceInfo({idx})")).unwrap(),
7,
"the miss is still seven values"
);
let tail: Vec<String> = (1..=7)
.map(|i| {
let discards = "_, ".repeat(i - 1);
s.eval::<String>(&format!(
"local {discards}v = GetInboxInvoiceInfo({idx}) return tostring(v)"
))
.unwrap()
})
.collect();
assert_eq!(tail, ["nil", "nil", "nil", "0", "0", "0", "0"]);
}
}
#[test]
fn an_invoice_has_no_letter_body() {
let mut s = UiScript::new().unwrap();
let mut st = state();
st.inbox[0].is_invoice = true;
st.inbox[0].body = Some("6C:10000:10000:25:500".into());
s.set_mail(Some(st));
assert_eq!(
s.arity("GetInboxText(1)").unwrap(),
4,
"four values, invoice or not"
);
assert_eq!(
s.eval::<String>("return tostring((GetInboxText(1)))")
.unwrap(),
"nil",
"the bookkeeping is never handed back as a letter body"
);
assert_eq!(
s.eval::<String>("return tostring((GetInboxText(2)))")
.unwrap(),
"Lok'tar."
);
}
#[test]
fn a_buyer_invoice_is_the_literal_token_buyer() {
let mut s = UiScript::new().unwrap();
let mut st = state();
st.inbox[0].is_invoice = true;
st.inbox[0].invoice = Some(MailInvoice {
seller: false,
item_name: "Small Blue Pouch".into(),
player_name: "Onewarrior".into(),
bid: 9_000,
buyout: 10_000,
deposit: 0,
consignment: 0,
});
s.set_mail(Some(st));
assert_eq!(
s.eval::<String>("return (GetInboxInvoiceInfo(1))").unwrap(),
"buyer"
);
}
#[test]
fn inbox_header_reads_the_reference_tuple() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetInboxNumItems()").unwrap(), 0);
// Out of range is still the full 13-tuple: the numeric fields are 0, so a stale open
// letter reading past the end never feeds `MailFrame.lua:399`'s `CODAmount > 0` a nil.
assert!(s
.eval::<bool>(
"local a,b,c,d,e,f,g,h,i,j,k,l,m = GetInboxHeaderInfo(1)\n\
return a == nil and b == nil and c == nil and d == nil \
and e == 0 and f == 0 and g == 0 \
and h == nil and i == nil and j == nil and k == nil and l == nil and m == nil",
)
.unwrap());
// The first value is still nil, as the reference's miss leads with nils.
assert!(s
.eval::<bool>("return GetInboxHeaderInfo(9) == nil")
.unwrap());
s.set_mail(Some(state()));
assert_eq!(s.eval::<i64>("return GetInboxNumItems()").unwrap(), 2);
let (pkg, sta, sender, subject, money, cod, days): (
String,
String,
String,
String,
i64,
i64,
f64,
) = s
.eval("local a,b,c,d,e,f,g = GetInboxHeaderInfo(1)\nreturn a,b,c,d,e,f,g")
.unwrap();
assert_eq!(pkg, "Interface\\Icons\\INV_Misc_Bag_08");
assert_eq!(sta, "Interface\\Icons\\INV_Letter_15");
assert_eq!(
(sender.as_str(), subject.as_str()),
("Thrall", "Warchief's orders")
);
assert_eq!((money, cod), (5000, 0));
assert!((days - 29.5).abs() < 1e-3);
assert!(s
.eval::<bool>(
"local a,b,c,d,e,f,g, has, read, ret, tc, reply, gm = GetInboxHeaderInfo(1)\n\
return has == 3 and read == nil and ret == nil and tc == nil and reply == 1 and gm == nil",
)
.unwrap());
assert!(s
.eval::<bool>(
"local pkg, sta, s, subj, m, c, d, has = GetInboxHeaderInfo(2)\n\
return pkg == nil and has == nil",
)
.unwrap());
}
#[test]
fn get_inbox_text_returns_body_and_queues_open() {
let mut s = UiScript::new().unwrap();
s.set_mail(Some(state()));
let (body, tex, takeable, invoice): (String, String, Value, Value) =
s.eval("return GetInboxText(1)").unwrap();
assert_eq!(body, "Lok'tar.");
assert_eq!(tex, "STATIONERYTEST");
assert_eq!(takeable, Value::Integer(1)); // has a body to fetch
assert!(matches!(invoice, Value::Nil));
// The open is queued once per drain.
s.run("GetInboxText(1)").unwrap();
assert_eq!(s.take_mail_opens(), vec![1]);
assert!(s.take_mail_opens().is_empty(), "drained");
}
#[test]
fn get_inbox_item_and_can_delete() {
let mut s = UiScript::new().unwrap();
s.set_mail(Some(state()));
let (name, _tex, count, quality, canuse): (String, String, i64, i64, i64) =
s.eval("return GetInboxItem(1)").unwrap();
assert_eq!(
(name.as_str(), count, quality, canuse),
("Linen Cloth", 3, 1, 1)
);
assert!(s.eval::<bool>("return GetInboxItem(2) == nil").unwrap());
// A player's unreturned mail is returnable, not deletable.
assert!(s
.eval::<bool>("return InboxItemCanDelete(1) == nil")
.unwrap());
}
#[test]
fn intents_queue_and_drain() {
let mut s = UiScript::new().unwrap();
s.set_mail(Some(state()));
s.run("CheckInbox()").unwrap();
assert!(s.take_mail_check_inbox());
assert!(!s.take_mail_check_inbox(), "drained");
s.run("TakeInboxItem(1) TakeInboxMoney(1) DeleteInboxItem(2) ReturnInboxItem(1) TakeInboxTextItem(1)")
.unwrap();
assert_eq!(s.take_mail_take_items(), vec![1]);
assert_eq!(s.take_mail_take_money(), vec![1]);
assert_eq!(s.take_mail_deletes(), vec![2]);
assert_eq!(s.take_mail_returns(), vec![1]);
assert_eq!(s.take_mail_take_texts(), vec![1]);
assert!(s.take_mail_take_texts().is_empty(), "drained");
s.run("CloseMail()").unwrap();
assert!(s.take_mail_close());
}
#[test]
fn send_folds_money_cod_and_returns_true_from_setmoney() {
let mut s = UiScript::new().unwrap();
s.set_money(5_000);
assert!(s.take_mail_send().is_none());
assert!(s
.eval::<bool>("return SetSendMailMoney(9999) == nil")
.unwrap());
assert_eq!(s.take_ui_errors(), vec!["ERR_NOT_ENOUGH_MONEY"]);
assert!(s
.eval::<bool>("return SetSendMailMoney(1234) == 1")
.unwrap());
// COD with no attached item: no store, no event.
s.run("SetSendMailCOD(50)").unwrap();
assert_eq!(s.eval::<i64>("return GetSendMailCOD()").unwrap(), 0);
select_default_stationery(&mut s);
s.run("SendMail('Jaina', 'Hi', 'body text')").unwrap();
let req = s.take_mail_send().expect("a send was queued");
assert_eq!(req.target, "Jaina");
assert_eq!(req.subject, "Hi");
assert_eq!(req.body, "body text");
assert_eq!((req.money, req.cod), (1234, 0));
assert_eq!(req.item, None);
assert!(s.take_mail_send().is_none(), "drained");
assert_eq!(s.eval::<i64>("return GetSendMailPrice()").unwrap(), 30);
assert!(
s.run("SetSendMailMoney(nil)").is_err(),
"a non-number raises"
);
assert!(s.run("SetSendMailCOD({})").is_err());
}
#[test]
fn attach_from_cursor_and_get_send_item() {
use crate::script::cursor::{CursorItem, CursorPayload};
let mut s = UiScript::new().unwrap();
assert!(s
.eval::<bool>("local n = GetSendMailItem()\nreturn n == nil")
.unwrap());
s.model_mut().cursor = Some(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 5,
item_id: 2589,
texture: Some("Interface\\Icons\\INV_Fabric_Linen_01".into()),
link: Some("|cff...|Hitem:2589|h[Linen Cloth]|h|r".into()),
count: Some(7),
quality: Some(1),
equip_slots: Vec::new(),
}));
s.run("ClickSendMailItemButton()").unwrap();
assert!(s.eval::<bool>("return not CursorHasItem()").unwrap());
let (name, _tex, count, quality): (String, String, i64, i64) =
s.eval("return GetSendMailItem()").unwrap();
assert_eq!((name.as_str(), count, quality), ("Linen Cloth", 7, 1));
select_default_stationery(&mut s);
s.run("SendMail('Alt', 'stuff', '')").unwrap();
assert_eq!(s.take_mail_send().unwrap().item, Some((0, 5)));
// Clicking with an empty cursor detaches.
s.run("ClickSendMailItemButton()").unwrap();
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
assert!(s
.eval::<bool>("local n = GetSendMailItem()\nreturn n == nil")
.unwrap());
}
#[test]
fn clear_send_item_resets_the_form() {
use crate::script::cursor::{CursorItem, CursorPayload};
let mut s = UiScript::new().unwrap();
s.model_mut().cursor = Some(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 1,
texture: None,
link: None,
count: None,
quality: None,
equip_slots: Vec::new(),
}));
s.run("ClickSendMailItemButton()").unwrap();
s.run("SetSendMailMoney(99) SetSendMailCOD(5)").unwrap();
s.reset_compose_tab();
select_default_stationery(&mut s);
s.run("SendMail('x','y','z')").unwrap();
let req = s.take_mail_send().unwrap();
assert_eq!((req.money, req.cod, req.item), (0, 0, None));
}
#[test]
fn clearing_the_mail_empties_it() {
let mut s = UiScript::new().unwrap();
s.set_mail(Some(state()));
s.set_mail(None);
assert_eq!(s.eval::<i64>("return GetInboxNumItems()").unwrap(), 0);
assert!(s.eval::<bool>("return HasNewMail() == nil").unwrap());
}
#[test]
fn has_new_mail_reads_the_pushed_flag() {
let mut s = UiScript::new().unwrap();
assert!(s.eval::<bool>("return HasNewMail() == nil").unwrap());
s.set_has_new_mail(true);
assert!(s.eval::<bool>("return HasNewMail() == 1").unwrap());
s.set_has_new_mail(false);
assert!(s.eval::<bool>("return HasNewMail() == nil").unwrap());
}
}
#[cfg(test)]
mod stationery_tests {
use super::StationeryView;
use crate::script::UiScript;
fn seated() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_mail_stationeries(vec![
StationeryView {
id: 41,
name: "Default Stationery".into(),
icon: "Interface\\Icons\\INV_Letter_15".into(),
cost: Some(0),
texture: "STATIONERYTEST".into(),
},
StationeryView {
id: 64,
name: "Valentine Stationery".into(),
icon: "Interface\\Icons\\INV_ValentinesCard02".into(),
cost: None,
texture: "STATIONERY_VAL".into(),
},
]);
s
}
#[test]
fn the_stationery_family_answers_like_the_client() {
let s = seated();
assert_eq!(s.eval::<i64>("return GetNumStationeries()").unwrap(), 2);
assert_eq!(
s.eval::<(String, String, i64)>("return GetStationeryInfo(1)")
.unwrap(),
(
"Default Stationery".into(),
"Interface\\Icons\\INV_Letter_15".into(),
0
)
);
assert!(
s.eval::<bool>("local n, t, c = GetStationeryInfo(\"2\") return n == \"Valentine Stationery\" and c == nil")
.unwrap(),
"a carried paper costs nil; a numeric string is an index"
);
assert!(
s.eval::<bool>(
"local n, t, c = GetStationeryInfo(3) return n == nil and t == nil and c == nil"
)
.unwrap(),
"past the end: three nils"
);
for bad in [
"GetStationeryInfo()",
"GetStationeryInfo(\"x\")",
"SelectStationery(nil)",
] {
let err = s.run(bad).expect_err(bad).to_string();
assert!(err.contains("Usage: "), "{bad}: {err}");
}
assert!(s
.eval::<bool>("return GetSelectedStationeryTexture() == nil")
.unwrap());
s.run("SelectStationery(2)").unwrap();
assert_eq!(
s.eval::<String>("return GetSelectedStationeryTexture()")
.unwrap(),
"STATIONERY_VAL"
);
s.run("SelectStationery(9)").unwrap();
assert!(
s.eval::<bool>("return GetSelectedStationeryTexture() == nil")
.unwrap(),
"out of range is a deselect, not an error"
);
}
#[test]
fn a_send_needs_a_stationery_and_carries_its_id() {
let mut s = seated();
s.run("SendMail(\"Bob\", \"hi\", \"body\")").unwrap();
assert!(
s.take_mail_send().is_none(),
"no selection: silently nothing"
);
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.run("SendMail(\"Bob\", \"hi\", \"body\")").unwrap();
assert!(s.take_mail_send().is_none());
}
}