The Lua VM keeps no surface the 1.12.1 client lacks. - **Removed:** the nine Era-only globals: `UnitAura`, `CancelUnitBuff`, `GetCursorInfo`, `GetInventoryItemID`, `GetPlayerFacing`, `GetTradePartnerName`, `IsGossipOptionCoded`, `UnitIsAFK`, `UnitIsDND`. - **Hooks off Lua:** `GameTooltip:BenillaSetItemById` and `:BenillaGetTooltipOwner` are replaced by Rust-side calls. The uncalled `Benilla*` hooks are deleted. - **Frame strata:** `SetFrameStrata` knows the reference's eight strata and raises its message for any other (`0x774450`). - **`SetDrawLayer`** reads the layer name alone (`0x79a780`). - **`Set<State>Texture`** reads a number as a path (`0x781b23`); there is no colour form. - **`PlaySound`** names a kit and returns nothing (`0x4586d0`). `PlaySoundFile` returns one value (`0x458780`); benilla answers 1, a recorded deviation, since it plays after the call. - **`OnClick`** sets only `arg1` (`0x779540`). - **Quest getters:** `GetQuestItemInfo` and the quest-log reward and choice getters answer the reference's five values and its miss behaviour (`0x501f80`, `0x4e0b00`, `0x4e0cf0`). - **`PickupContainerItem`** returns nothing (`0x4f9b30`). - **Surface gates:** the bare VM's globals and every widget class's methods are held to `reference/1.12-globals.tsv` with one commented allowlist. A new gate on the full production load (core plus layer) allows beyond that only the layer's `Benilla`-prefixed globals. Verified: - A test per removal and per corrected form; a scripted undo of each fails its test. - Each gate fails on a re-added name. - `scripts/gates.sh` and `scripts/smoke.sh` are green. - A live run showed the same warnings and 0 script errors before and after. - In the 219-addon corpus, AckisRecipeList's bundled AceGUI-3.0 and Roid-Macros' Grid extension now behave as they do on 1.12.1. Built on macOS; no platform seam touched. Fixes #171 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
324 lines
11 KiB
Rust
324 lines
11 KiB
Rust
//! The gossip bindings: the app pushes the open menu ([`UiScript::set_gossip`]), and
|
|
//! `SelectGossipOption` and `CloseGossip` queue intents it drains.
|
|
//!
|
|
//! `GetGossipOptions()` returns flat `(text, type)` pairs, `type` the lowercase icon name the app
|
|
//! maps from the wire `GOSSIP_ICON`. A coded option is not built: the app drops its select, where
|
|
//! the reference fires `GOSSIP_ENTER_CODE` and the stock popup (`StaticPopup.lua:1383`) calls
|
|
//! `SelectGossipOption(index, code)`.
|
|
//!
|
|
//! A menu is pushed only once its greeting (`SMSG_NPC_TEXT_UPDATE`) has arrived; until then the
|
|
//! VM keeps its last menu, as the reference's frame keeps its last paint: its handler returns on a
|
|
//! cache miss without an event, and the greeting write and `GOSSIP_SHOW` share one success path
|
|
//! (`0x4e2010`, `0x4e22b0`).
|
|
|
|
use mlua::{Lua, MultiValue, Value};
|
|
|
|
use super::Model;
|
|
|
|
/// One gossip option; its 1-based index is its place in [`GossipMenu::options`].
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct GossipOptionView {
|
|
/// The option's label.
|
|
pub label: String,
|
|
/// The lowercase icon type (`"gossip"`, `"vendor"`, `"taxi"`, …); `GossipFrame.lua:123` draws
|
|
/// `Interface\GossipFrame\<Type>GossipIcon`.
|
|
pub icon_type: String,
|
|
}
|
|
|
|
/// One quest row of `SMSG_GOSSIP_MESSAGE`; a click sends `CMSG_QUESTGIVER_QUERY_QUEST`. `active`,
|
|
/// from the wire icon, puts it under current quests rather than available ones.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct GossipQuestRow {
|
|
pub title: String,
|
|
/// The row's third wire field. 1.12 never shows it, but the verbs return `(title, level)` pairs
|
|
/// and `GossipFrame.lua:66` strides by 2 over them.
|
|
pub level: u32,
|
|
pub active: bool,
|
|
}
|
|
|
|
/// One open gossip menu, pushed whole by the app.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct GossipMenu {
|
|
/// The NPC greeting, always resolved.
|
|
pub greeting: String,
|
|
/// Quest rows from the same packet.
|
|
pub quests: Vec<GossipQuestRow>,
|
|
pub options: Vec<GossipOptionView>,
|
|
}
|
|
|
|
impl super::UiScript {
|
|
/// Push (or clear, with `None`) the open gossip menu.
|
|
pub fn set_gossip(&mut self, menu: Option<GossipMenu>) {
|
|
self.model_mut().gossip = menu;
|
|
}
|
|
|
|
/// Drain the 1-based option positions queued by `SelectGossipOption` since the last call.
|
|
pub fn take_gossip_selects(&mut self) -> Vec<u32> {
|
|
std::mem::take(&mut self.model_mut().gossip_selects)
|
|
}
|
|
|
|
/// Whether `CloseGossip` was called since the last drain; the 1.12 close sends no packet.
|
|
pub fn take_gossip_close(&mut self) -> bool {
|
|
std::mem::take(&mut self.model_mut().gossip_close)
|
|
}
|
|
|
|
/// Drain the queued 1-based whole-menu quest-row positions.
|
|
pub fn take_gossip_quest_selects(&mut self) -> Vec<u32> {
|
|
std::mem::take(&mut self.model_mut().gossip_quest_selects)
|
|
}
|
|
}
|
|
|
|
/// Register the gossip globals.
|
|
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
|
let g = lua.globals();
|
|
|
|
g.set(
|
|
"GetGossipText",
|
|
lua.create_function(|lua, ()| {
|
|
let text = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
model.gossip.as_ref().map(|m| m.greeting.clone())
|
|
};
|
|
match text {
|
|
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
|
|
None => Ok(Value::Nil),
|
|
}
|
|
})?,
|
|
)?;
|
|
|
|
g.set(
|
|
"GetGossipOptions",
|
|
lua.create_function(|lua, ()| {
|
|
// Copy out under one short borrow; build the Lua values with none held.
|
|
let pairs: Vec<(String, String)> = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
model.gossip.as_ref().map_or_else(Vec::new, |m| {
|
|
m.options
|
|
.iter()
|
|
.map(|o| (o.label.clone(), o.icon_type.clone()))
|
|
.collect()
|
|
})
|
|
};
|
|
let mut out: Vec<Value> = Vec::with_capacity(pairs.len() * 2);
|
|
for (label, icon) in pairs {
|
|
out.push(Value::String(lua.create_string(&label)?));
|
|
out.push(Value::String(lua.create_string(&icon)?));
|
|
}
|
|
Ok(MultiValue::from_vec(out))
|
|
})?,
|
|
)?;
|
|
|
|
// SelectGossipOption(i [, ...]): queue the 1-based position. Further arguments, a code among
|
|
// them, are ignored: coded options are not built, so no code is ever sent.
|
|
g.set(
|
|
"SelectGossipOption",
|
|
lua.create_function(|lua, (i, _rest): (u32, mlua::MultiValue)| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.gossip_selects.push(i);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
g.set(
|
|
"CloseGossip",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.gossip_close = true;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// ══ The two quest lists ══
|
|
// The reference splits the quest rows by wire icon, 3 and 4 active and the rest available,
|
|
// behind two vararg verbs (`0x4e2430`, `0x4e2580`); the app splits them at parse time.
|
|
fn quest_rows(lua: &Lua, active: bool) -> mlua::Result<MultiValue> {
|
|
let rows: Vec<(String, u32)> = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
model.gossip.as_ref().map_or_else(Vec::new, |m| {
|
|
m.quests
|
|
.iter()
|
|
.filter(|q| q.active == active)
|
|
.map(|q| (q.title.clone(), q.level))
|
|
.collect()
|
|
})
|
|
};
|
|
let mut out = Vec::with_capacity(rows.len() * 2);
|
|
for (title, level) in rows {
|
|
out.push(Value::String(lua.create_string(&title)?));
|
|
out.push(Value::Integer(i64::from(level)));
|
|
}
|
|
Ok(MultiValue::from_vec(out))
|
|
}
|
|
|
|
g.set(
|
|
"GetGossipAvailableQuests",
|
|
lua.create_function(|lua, ()| quest_rows(lua, false))?,
|
|
)?;
|
|
g.set(
|
|
"GetGossipActiveQuests",
|
|
lua.create_function(|lua, ()| quest_rows(lua, true))?,
|
|
)?;
|
|
|
|
// A select's index is 1-based within its own list, as `GossipFrame.lua:73` numbers the
|
|
// buttons; it maps back to the whole-menu position the app's queue holds.
|
|
fn select_quest(lua: &Lua, active: bool, index: usize) {
|
|
let whole = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
model.gossip.as_ref().and_then(|m| {
|
|
m.quests
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, q)| q.active == active)
|
|
.nth(index.wrapping_sub(1))
|
|
.map(|(n, _)| n as u32 + 1)
|
|
})
|
|
};
|
|
if let Some(n) = whole {
|
|
lua.app_data_mut::<Model>()
|
|
.expect("model app_data")
|
|
.gossip_quest_selects
|
|
.push(n);
|
|
}
|
|
}
|
|
|
|
g.set(
|
|
"SelectGossipAvailableQuest",
|
|
lua.create_function(|lua, i: usize| {
|
|
select_quest(lua, false, i);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"SelectGossipActiveQuest",
|
|
lua.create_function(|lua, i: usize| {
|
|
select_quest(lua, true, i);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{GossipMenu, GossipOptionView};
|
|
use crate::script::UiScript;
|
|
|
|
fn menu() -> GossipMenu {
|
|
GossipMenu {
|
|
greeting: "Greetings, traveler.".into(),
|
|
quests: Vec::new(),
|
|
options: vec![
|
|
GossipOptionView {
|
|
label: "Let me browse your goods.".into(),
|
|
icon_type: "vendor".into(),
|
|
},
|
|
GossipOptionView {
|
|
label: "I would like to sign the petition.".into(),
|
|
icon_type: "gossip".into(),
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gossip_snapshot_reads_and_selects_queue() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert!(s.eval::<bool>("return GetGossipText() == nil").unwrap());
|
|
assert_eq!(s.arity("GetGossipOptions()").unwrap(), 0);
|
|
|
|
s.set_gossip(Some(menu()));
|
|
assert_eq!(
|
|
s.eval::<String>("return GetGossipText()").unwrap(),
|
|
"Greetings, traveler."
|
|
);
|
|
let (l1, t1, l2, t2) = s
|
|
.eval::<(String, String, String, String)>(
|
|
"local a,b,c,d = GetGossipOptions()\n\
|
|
return a, b, c, d",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(s.arity("GetGossipOptions()").unwrap(), 4);
|
|
assert_eq!(
|
|
(l1.as_str(), t1.as_str()),
|
|
("Let me browse your goods.", "vendor")
|
|
);
|
|
assert_eq!(l2, "I would like to sign the petition.");
|
|
assert_eq!(t2, "gossip");
|
|
|
|
s.run("SelectGossipOption(1)").unwrap();
|
|
s.run("SelectGossipOption(2, 'unused-code')").unwrap(); // extra arg ignored
|
|
assert_eq!(s.take_gossip_selects(), vec![1, 2]);
|
|
assert!(s.take_gossip_selects().is_empty(), "drained");
|
|
|
|
assert!(!s.take_gossip_close());
|
|
s.run("CloseGossip()").unwrap();
|
|
assert!(s.take_gossip_close());
|
|
assert!(!s.take_gossip_close(), "drained");
|
|
}
|
|
|
|
#[test]
|
|
fn the_two_gossip_quest_lists_split_by_active_and_select_within_themselves() {
|
|
use super::GossipQuestRow;
|
|
let mut s = UiScript::new().unwrap();
|
|
// No menu: `arg.n == 0`, not a nil.
|
|
assert_eq!(s.arity("GetGossipAvailableQuests()").unwrap(), 0);
|
|
assert_eq!(s.arity("GetGossipActiveQuests()").unwrap(), 0);
|
|
|
|
let mut m = menu();
|
|
m.quests = vec![
|
|
GossipQuestRow {
|
|
title: "Report to Goldshire".into(),
|
|
level: 5,
|
|
active: true,
|
|
},
|
|
GossipQuestRow {
|
|
title: "A Threat Within".into(),
|
|
level: 7,
|
|
active: false,
|
|
},
|
|
GossipQuestRow {
|
|
title: "Kobold Camp Cleanup".into(),
|
|
level: 9,
|
|
active: false,
|
|
},
|
|
];
|
|
s.set_gossip(Some(m));
|
|
|
|
assert_eq!(
|
|
s.eval::<(String, i64, String, i64)>("return GetGossipAvailableQuests()")
|
|
.unwrap(),
|
|
(
|
|
"A Threat Within".to_string(),
|
|
7,
|
|
"Kobold Camp Cleanup".to_string(),
|
|
9
|
|
)
|
|
);
|
|
// The active row comes first in the menu but splits by icon, not position.
|
|
assert_eq!(
|
|
s.eval::<(String, i64)>("return GetGossipActiveQuests()")
|
|
.unwrap(),
|
|
("Report to Goldshire".to_string(), 5)
|
|
);
|
|
|
|
// Available #2 is the menu's third row.
|
|
s.run("SelectGossipAvailableQuest(2)").unwrap();
|
|
assert_eq!(s.take_gossip_quest_selects(), vec![3]);
|
|
s.run("SelectGossipActiveQuest(1)").unwrap();
|
|
assert_eq!(s.take_gossip_quest_selects(), vec![1]);
|
|
s.run("SelectGossipAvailableQuest(9) SelectGossipActiveQuest(0)")
|
|
.unwrap();
|
|
assert!(s.take_gossip_quest_selects().is_empty(), "drained");
|
|
}
|
|
|
|
#[test]
|
|
fn clearing_the_menu_empties_it() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_gossip(Some(menu()));
|
|
s.set_gossip(None);
|
|
assert!(s.eval::<bool>("return GetGossipText() == nil").unwrap());
|
|
assert_eq!(s.arity("GetGossipOptions()").unwrap(), 0);
|
|
}
|
|
}
|