Files
benilla-octo/crates/benilla-ui/src/script/gossip.rs
T
SamandClaude Fable 5.1 ed92f71af8 the gossip window stops flashing, and the chat plate fades in again
- the gossip window stops flashing on a first-visit sub-menu: a guard's "Where is …" option answers `CMSG_GOSSIP_SELECT_OPTION` with a new `SMSG_GOSSIP_MESSAGE` on the same NPC whose text id is a first visit, and 1508's hold made the snapshot `None` while the greeting query was in flight, which the feed read as `Some → None` — `GOSSIP_CLOSED` (`HideUIPanel`) at the select, `GOSSIP_SHOW` (`ShowUIPanel`) when the text landed, the window gone for a server round trip, once per text id because the record cache serves every later visit; the reference's `0x4e2010` on a cache miss sets its select latch (`[0xbbb670] = 1`) and returns with no greeting write and no event, `0x4e1fa0` closes only on a zero guid, and `SelectGossipOption` (`0x4e2320`) is refused while the latch holds — so `GossipState::text_pending` names that latch, `feed_gossip` returns before touching the VM while it holds, both drains refuse on it, the NPC name query is asked ahead of the gate so a first visit's name rides beside its text query, and the landing repaints in place (1994); and the ready check is read off the bytes: `0x4ba40e` stores the constant 0 for any guid-matched answer, the wire byte only gates the `RAID_MEMBER_NOT_READY` print ("%s is not ready") on a 0, `ConfirmReadyCheck` sends one byte, 1 or 0, only while the deadline is armed and unexpired, `DoReadyCheck` sends an empty body, the summary lists only members who never answered, and the early close fires iff no member is both pending AND online (`[entry+0x18]` bit 0) — an offline member who never answered no longer holds the check open and is still listed as AFK when it closes; the `SMSG_GROUP_LIST` leg's close predicate (`0x4ba5f0`) keeps 1989's reading, with no online term (1997)
- the chat menu's rows hug their left edge: a `UIMenuButtonTemplate` row has no `<ButtonText>` — `UIMenu_AddButton` labels it with `SetText` — and the only thing placing the label is `<NormalFont inherits="GameFontNormal" justifyH="LEFT"/>`; the reference's lazy creator (`SetText 0x778dc0`) hands the fresh string to the adopter `0x778d20`, which anchors an unanchored label by the BUTTON's normal embedded font's justify word (`[button+0x390]`: LEFT→LEFT / RIGHT→RIGHT / else CENTER) and links it to that font, where ours anchored by the string's own ctor-default word (CENTER) and the loader wrote the justify onto a force-created label after the anchor was already decided — so every row sat centred in its fixed 104-wide slot and "Macro" ran into "/macro" (1.12's `UIMenu` does not auto-size; `UIMenu_AutoSize` is a later client's); `ButtonState` now carries each state font's own justify, one adopter (`button::adopt_label`) is the tail of both `SetText`'s lazy creation and `SetFontString`, the loader never touches the label, and paint follows the current instance's justify behind the severance mask like every other axis (1996); and the chat plate's hover fade survives a quick exit and re-entry: the stock `FCF_OnUpdate` queues the tabs' fade-out with `FCF_ChatTabFadeFinished` as its finished callback, which nils `chatFrame.oldAlpha` unconditionally 0.15 s later — re-enter inside `CHAT_FRAME_FADE_TIME` and it lands under a live hover, the plate arm's gate never passes again, no later leave clears `hover`, the hover-start re-read never runs, and only `FCF_SetWindowAlpha` (the tab menu's opacity slider) reseats it, which was every clue in the report; the flick from the text to a scroll button and back is the gesture, so an existing window looked broken from a session's first hover; the repair is the smallest guard at the one line — the finished callback leaves `oldAlpha` alone while `hover` is set — installed from Rust after the manifest loads (`manifest::install_chat_plate_guard`), never written into the stock file, and named as a deviation from the reference's own Lua (1998)
- a layer drains its quads, then its text, then its model scenes: `ZKey`'s one font-string bit becomes a two-bit batch rank (Quad, Text, Callback) — the client's layer batch (ctor `0x772e80`) carries its quads at `+0x10`, its `CGxStringBatch` at `+0x18` and a `RENDERCALLBACKNODE` list at `+0x1c`, `0x76fb00` drains them in that order, and `0x76d160` registers a `Model` frame's scene for the ARTWORK batch only — so a `ModelPane` quad takes `ZKey::callback(Artwork)`, after every texture AND every font string of its (strata, level, layer) whatever their link stamps and before anything in the next layer, closing the residual 1992 named on the world map (the arrow is linked first under `WorldMapFrame`, the area label and the overlays later); two scenes in one bucket keep registration order, and the link stamp gives up one bit (19 → 18), which the arena's renumbering at the cap makes unobservable (1995); and the two full-screen vignettes are dead code in 5875: the string `PlayerStatus_OnUpdate` occurs zero times in `WoW.exe`, the per-frame pump runs a frame's OnUpdate by registry reference (`0x70518c lua_rawgeti`, `0x705199 lua_pcall`), the whole `lua_pcall` census has exactly two by-name calls from C, and the only `UIFrameFlash` sites for `LowHealthFrame` and `OutOfControlFrame` in the chain are inside that uncalled function, while the `PLAYER_CONTROL_LOST` arm only sets `UIParent.isOutOfControl` — so 1988's "losing control flashes the vignette" is withdrawn, the two `.blp`s ship and are never loaded, nothing in code changes, and the manifest's note on the two frames says why no driver exists (1999)
- a tiled UI sprite wraps only the axis whose mapping runs past the texture: at four forms `ShapeshiftBar_Update` maps the stance shelf's middle strip `SetTexCoord(0, numForms-2, 0, 1)` — tiling along u, spanning exactly `[0, 1]` in v — and the repeat-sampled image it was served from wrapped BOTH axes, so bilinear at the strip's top pixel row reached `v < 0` and wrapped to the texture's last row, opaque grey on the 32×32 `ShapeshiftBarMiddle.blp` whose rows 0–7 are transparent: a one-device-px hairline along the strip's top edge over the world, reading 0.81–0.84× its neighbours' luma against 1.00 over the clamp-sampled caps (predicted 75 over a world of 100, measured 74–77); 1608's `uv_clamp_window` skipped an axis spanning the whole texture because "the sampler's own clamp already is this", true for the plain sprite and false for the tiled one; `tiling_axes` now decides the wrap per axis with the same tolerance, `WorldAssets::sprite_texture_wrapped` bakes `Repeat` where asked and `ClampToEdge` elsewhere, cached by (path, u, v), and the backdrop pieces and the chat bubble's edge keep both axes on; `WOW_DEMO_BOTTOM_BARS=0` leaves the action-bar demo's bottom bars down, the only state the shelf draws in (2000); the duplicate-placement census rides the probe line (`orphan_parts=`, `tiles=`) and the inspector card (parts alive, parts drawn this frame, each glow card's live world scale): a placed doodad or WMO part is spawned by exactly one placement, so a part alive outside every placement's entity list outlived a respawn, with the retained pass's fader exiles named as the one population legitimately outside the registry

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:31:38 +07:00

432 lines
19 KiB
Rust

//! The gossip bindings (decision 0081 phase 3) — the Era-shaped NPC-dialog surface, the same
//! two-way seam as [`super::container`]: the app pushes a **gossip menu snapshot**
//! ([`UiScript::set_gossip`] — the greeting + option rows already resolved from the wire), and the
//! Lua `SelectGossipOption`/`CloseGossip` calls queue outbound **intents** the app drains
//! ([`UiScript::take_gossip_selects`] / [`UiScript::take_gossip_close`]). The engine holds no NPC
//! knowledge — an option is "a label, an icon type, and whether it's coded".
//!
//! ## The Era API shape
//!
//! 1.12's `GetGossipOptions()` returns a flat vararg of `(text, type)` pairs — the FrameXML's
//! `GossipFrameUpdate` walks it two-at-a-time, `SetText`ing each `GossipTitleButton` and mapping the
//! `type` string to an `Interface\GossipFrame\<Type>GossipIcon` texture. Benilla keeps that exact
//! shape: `GetGossipOptions()` returns `label1, type1, label2, type2, …`, where `type` is the
//! lowercase Era icon name (`"gossip"`/`"vendor"`/`"taxi"`/`"trainer"`/…) the app derived from the
//! wire `GOSSIP_ICON` byte. The one addition is `IsGossipOptionCoded(i)` — a benilla-local
//! predicate the XML uses to grey password-gated options (decision 0081 v1: coded options are
//! parsed and greyed, never selected; the real client pops a password box, out of scope here).
//! `GetGossipText()` returns the greeting body (`SMSG_NPC_TEXT_UPDATE`), `nil` when no menu is
//! open. There is no "menu open, text pending" state: while a greeting is in flight the app
//! pushes nothing and fires nothing — the VM keeps its last menu (or none), exactly as the
//! reference's frame keeps its last paint (its handler returns on a cache miss without an event,
//! and its greeting write and `GOSSIP_SHOW` are adjacent and unconditional on one success path —
//! wow-re `gossip-npctext-law.md` §1/§4; B292, benilla decisions 1508/1994), which is why
//! [`GossipMenu::greeting`] is a plain `String`.
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// One gossip menu option, resolved by the app from the wire `GossipOption` (decision 0081). Plain
/// data — 1-based order in the menu is its position in [`GossipMenu::options`].
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GossipOptionView {
/// The option label (`SetText` on its row button).
pub label: String,
/// The Era `GetGossipOptions()` second value — the lowercase icon *type* the app mapped from
/// the wire `GOSSIP_ICON` byte (`"gossip"`/`"vendor"`/`"taxi"`/`"trainer"`/…). The XML resolves
/// it to a `Interface\GossipFrame\<Type>GossipIcon` texture.
pub icon_type: String,
/// A password-gated (`coded`) option — greyed and unselectable in v1 (decision 0081).
pub coded: bool,
}
/// One quest row riding a gossip menu (`SMSG_GOSSIP_MESSAGE`'s quest-option block). A gossip NPC
/// that also gives quests lists them above the gossip options; a click sends
/// `CMSG_QUESTGIVER_QUERY_QUEST` (decision 0088). `active` splits the row into the "current quests"
/// vs "available quests" headers (the app derives it from the wire dialog-status icon); the app maps
/// the clicked 1-based row back to its quest id.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GossipQuestRow {
pub title: String,
/// The quest's level — `SMSG_GOSSIP_MESSAGE`'s own third field per quest row.
///
/// The app parsed it and dropped it until 1751's twenty-first window, because the invented
/// `GetGossipQuestInfo` had nowhere to put it. The reference's two verbs return the rows as
/// `(title, level)` PAIRS, and stock `GossipFrame.lua` strides its walk by 2 over them — so
/// the level is load-bearing for the stride even though 1.12's own FrameXML never reads it.
pub level: u32,
pub active: bool,
}
/// One open gossip menu: the greeting body, the quest rows, and the option rows. Pushed whole by
/// the app; `None` means no menu is open.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GossipMenu {
/// The NPC greeting, always resolved: a menu is only pushed once its `SMSG_NPC_TEXT_UPDATE`
/// answered — an open gossip frame with a blank page is not a reachable state (module doc).
pub greeting: String,
/// Quest rows the NPC offers/has active, riding the same packet (decision 0088).
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 (and clear the flag). vanilla's
/// client-side close sends no packet — the app just clears its local menu state.
pub fn take_gossip_close(&mut self) -> bool {
std::mem::take(&mut self.model_mut().gossip_close)
}
/// Drain the 1-based quest-row positions queued by `SelectGossipQuest` since the last call. The
/// app maps each to the row's quest id + the open NPC and sends `CMSG_QUESTGIVER_QUERY_QUEST`.
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();
// → the greeting body, or nil while there is no menu (an open menu always has one — the
// app-side hold, module doc).
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),
}
})?,
)?;
// → flat (label, type) pairs, the Era `GetGossipOptions()` vararg shape.
g.set(
"GetGossipOptions",
lua.create_function(|lua, ()| {
// Collect owned data under one short borrow, then 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))
})?,
)?;
// IsGossipOptionCoded(i) — benilla-local: the XML greys a coded (password) option (decision
// 0081). `i` is 1-based; an out-of-range index is `false`.
g.set(
"IsGossipOptionCoded",
lua.create_function(|lua, i: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model
.gossip
.as_ref()
.and_then(|m| i.checked_sub(1).and_then(|n| m.options.get(n)))
.is_some_and(|o| o.coded))
})?,
)?;
// SelectGossipOption(i [, ...]) — queue the 1-based option position; the app maps it to the
// wire option index + guid. Extra Era args (the code / a confirm flag) are ignored: v1 never
// sends a code (decision 0081).
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(())
})?,
)?;
// CloseGossip() — client-side close (no packet, vanilla): flag it so the app clears its menu.
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, and why there are two ══════════════════════════════════════════
//
// 1.12 splits the gossip packet's quest rows by their WIRE ICON — `{3,4}` are "active", every
// other value "available" (decision 0758) — and publishes each list through its own vararg
// verb. The reference runs that same test lazily behind these two bindings
// (`0x4e2430`/`0x4e2580`); we run it at parse time and keep the answer on the row.
//
// **Each row is a PAIR: `(title, level)`.** Stock `GossipFrameAvailableQuestsUpdate` /
// `…ActiveQuestsUpdate` walk `for i = 1, arg.n, 2` and read only `arg[i]`, so the level is
// never displayed in 1.12 — but it is what makes the stride land, and an addon reading the
// second slot gets the number the server sent rather than a nil.
//
// These four REPLACE `GetNumGossipQuests` / `GetGossipQuestInfo` / `SelectGossipQuest`, which
// were benilla's own single-list shape and appear nowhere in `reference/1.12-globals.tsv`.
// 1189's rule: a superset is not free — an addon that feature-detects on a name the client
// does not have takes a branch we cannot honour.
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))?,
)?;
// The two selects. Each index is 1-based **within its own list**, which is what
// `GossipTitleButton_OnClick` passes: the reference re-numbers the rows per list as it lays
// them out (`titleIndex`), so a menu with two available and one active quest hands the active
// row a 1, not a 3.
//
// The queue the app drains is one list, so the index is mapped back to the WHOLE row order
// here — the app's own `take_gossip_quest_selects` contract is unchanged, and the two verbs
// stay a pure re-expression of it rather than a second drain to wire up.
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(),
coded: false,
},
GossipOptionView {
label: "I would like to sign the petition.".into(),
icon_type: "gossip".into(),
coded: true,
},
],
}
}
#[test]
fn gossip_snapshot_reads_and_selects_queue() {
let mut s = UiScript::new().unwrap();
// No menu: text nil, no options.
assert!(s.eval::<bool>("return GetGossipText() == nil").unwrap());
assert_eq!(
s.eval::<i64>("return select('#', GetGossipOptions())")
.unwrap(),
0
);
s.set_gossip(Some(menu()));
assert_eq!(
s.eval::<String>("return GetGossipText()").unwrap(),
"Greetings, traveler."
);
// Flat (label, type) pairs: 2 options → 4 return values.
let (n, l1, t1, l2, t2) = s
.eval::<(i64, String, String, String, String)>(
"local a,b,c,d = GetGossipOptions()\n\
return select('#', GetGossipOptions()), a, b, c, d",
)
.unwrap();
assert_eq!(n, 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");
// The coded (petition) option is flagged for greying; the vendor one isn't.
assert!(!s.eval::<bool>("return IsGossipOptionCoded(1)").unwrap());
assert!(s.eval::<bool>("return IsGossipOptionCoded(2)").unwrap());
assert!(!s.eval::<bool>("return IsGossipOptionCoded(9)").unwrap()); // out of range
// Selecting queues the 1-based position; closing flags the intent.
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");
}
/// **The two quest lists the reference publishes, and the two selects that index INTO them.**
///
/// This replaces a test of `GetNumGossipQuests`/`GetGossipQuestInfo`/`SelectGossipQuest` —
/// benilla's own single-list shape, and three names that appear nowhere in
/// `reference/1.12-globals.tsv`. 1189's rule is why they had to go rather than sit beside the
/// real four: an addon that feature-detects on a name this client does not have takes a branch
/// we cannot honour.
///
/// The row shape is a `(title, level)` PAIR because stock `GossipFrameAvailableQuestsUpdate`
/// walks `for i = 1, arg.n, 2` and reads `arg[i]`. 1.12 never displays the level; it is the
/// stride that needs it, and an addon reading the second slot should get the server's number.
#[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 → both lists are empty, and that is `arg.n == 0`, not a nil.
assert_eq!(
s.eval::<i64>("return select('#', GetGossipAvailableQuests())")
.unwrap(),
0
);
assert_eq!(
s.eval::<i64>("return select('#', 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));
// Two available, in menu order, each a pair.
assert_eq!(
s.eval::<(String, i64, String, i64)>("return GetGossipAvailableQuests()")
.unwrap(),
(
"A Threat Within".to_string(),
7,
"Kobold Camp Cleanup".to_string(),
9
)
);
// One active, and the active row does NOT appear in the available list even though it
// comes first in the menu — the split is the wire icon, not the position.
assert_eq!(
s.eval::<(String, i64)>("return GetGossipActiveQuests()")
.unwrap(),
("Report to Goldshire".to_string(), 5)
);
// **Each select is 1-based within its OWN list**, which is what
// `GossipTitleButton_OnClick` passes (the reference re-numbers per list as it lays the
// rows out). Available #2 is "Kobold Camp Cleanup", the THIRD row of the menu — and the
// queue the app drains is still whole-menu positions, so it must come back as 3.
s.run("SelectGossipAvailableQuest(2)").unwrap();
assert_eq!(s.take_gossip_quest_selects(), vec![3]);
// …and the single active row is #1 in its list, the FIRST of the menu.
s.run("SelectGossipActiveQuest(1)").unwrap();
assert_eq!(s.take_gossip_quest_selects(), vec![1]);
// Out of range queues nothing rather than a wrong row.
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.eval::<i64>("return select('#', GetGossipOptions())")
.unwrap(),
0
);
}
}