Files
benilla-octo/crates/benilla-ui/src/script/gossip.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

343 lines
12 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 opens a password box (`UIParent.lua:563`). `IsGossipOptionCoded(i)` is benilla's
//! own, not a 1.12 global, and no stock file calls it.
//!
//! 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,
/// A password-gated option, which the app never selects.
pub coded: bool,
}
/// 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))
})?,
)?;
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 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(),
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();
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");
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
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);
}
}