the client starts remembering: zoom, camera, filters, a Combat page

- minimap zoom and the camera's distance and pitch persist across sessions
- the trainer's point-filter is a saved variable now and survives a relog, resetting per packet like the reference
- the options window gains its Combat page, over rows that can write the UI's own saved globals
- the step-up is a rise, not a jump forward — and the descent cone's cap belongs to the body, on every leg

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-08 17:13:23 +07:00
co-authored by Claude Fable 5
parent 1415f14b8a
commit 8a502fe1d6
25 changed files with 2254 additions and 120 deletions
+22
View File
@@ -93,6 +93,28 @@ impl super::UiScript {
}
}
/// An **engine-side** write: a value the engine owns moved, and the CVar table mirrors it. The
/// minimap's zoom is the case that needs it — the client's `set_zoom` (`0x6da8e0`) writes the live
/// index *and* `CVar::Set`s `minimapZoom`/`minimapInsideZoom` in the same breath, which is exactly
/// why the level survives a restart. So this queues the change like a Lua `SetCVar` would (the host
/// must hear it and dirty the config file), unlike [`super::UiScript::set_cvar_host`], whose whole
/// point is *not* to echo the value it just loaded.
///
/// A name the host never registered is a **silent** no-op here, not a warning: engine writes are
/// code, not UI content, so a miss means this build's host doesn't back the var (a bare test VM) —
/// and the registered set is welded to the code truths by `crate::cvars`'s own test.
pub(super) fn set_from_engine(model: &mut Model, name: &str, value: String) {
let Some(slot) = model.cvars.get_mut(&name.to_ascii_lowercase()) else {
return;
};
if slot.value == value {
return;
}
slot.value = value.clone();
let registered = slot.name.clone();
model.cvar_changes.push((registered, value));
}
/// Push the warn-once for an unknown CVar name into the model's warning stream.
fn warn_unknown(model: &mut Model, name: &str) {
let key = name.to_ascii_lowercase();
+26 -1
View File
@@ -8,6 +8,14 @@
//! (decision 0203). The model attrs (`minimapPlayerModel=`/`minimapArrowModel=`) are not
//! modeled yet — deferred with phase 3's blips.
//!
//! **The level persists** (decision 1131). The client keeps two halves: the live indices
//! (`0x86f698` outdoor / `0x86f69c` indoor — our [`MinimapState`]) and the two CVar objects
//! `minimapZoom`/`minimapInsideZoom` (both registered default `"3"`), which are what `Config.wtf`
//! actually stores. `set_zoom` writes *both*; the minimap reset path (`0x6d9008`–`0x6d901f`)
//! re-seeds the live index from the CVar's parsed int. benilla runs the same two halves at the same
//! seam: `SetZoom` below writes the index and the CVar, and the app seeds the widget from the CVar
//! table once, when the in-game UI materializes ([`super::UiScript::set_minimap_zoom`]).
//!
//! The methods live in their own registry table, consulted by the frame `__index` dispatcher only
//! for Minimap frames — the same duck-typing posture as StatusBar's.
@@ -57,7 +65,24 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// inside flag selects, so the +/- buttons zoom the map you're actually looking at and
// each mode keeps its own level.
let clamped = (zoom.max(0.0) as u8).min(MINIMAP_ZOOM_LEVELS - 1);
with_minimap(lua, &this, |m| m.set_active_zoom(clamped))
let inside = with_minimap(lua, &this, |m| {
m.set_active_zoom(clamped);
m.inside
})?;
// …and it persists the level in the same breath: `set_zoom` writes the live index AND
// `CVar::Set`s the matching CVar (`minimapInsideZoom` indoors, `minimapZoom` out), which
// is the whole reason a zoom level survives a restart. The borrow above is released
// before this one — both reach the same `Model` app_data.
super::cvars::set_from_engine(
&mut lua.app_data_mut::<Model>().expect("model app_data"),
if inside {
"minimapInsideZoom"
} else {
"minimapZoom"
},
clamped.to_string(),
);
Ok(())
})?,
)?;
m.set(
+21
View File
@@ -79,6 +79,7 @@ mod pvp;
mod quest;
mod quest_log;
mod region;
mod saved;
mod scrollframe;
mod session;
mod shapeshift;
@@ -278,6 +279,7 @@ impl UiScript {
death::install(&lua)?;
aura::install(&lua)?;
cvars::install(&lua)?;
saved::install(&lua)?;
keybind::install(&lua)?;
sound::install(&lua)?;
pointer::install(&lua)?;
@@ -375,6 +377,25 @@ impl UiScript {
}
}
/// Seed every Minimap widget's two zoom indices from the persisted levels — the client's
/// minimap reset path copying each CVar object's parsed int into its live index
/// (`[0x86f698] ← [[0xb4b410]+0x28]`, `[0x86f69c] ← [[0xb4d90c]+0x28]`; wow-re
/// `wmo-interior-minimap.md`, VERIFIED). Called **once**, when the in-game UI materializes and
/// the widget exists — not per frame: from then on the widget's index is the live truth and
/// `Minimap:SetZoom` keeps the CVar following it, so a repeated push would fight the +/- buttons.
/// Both indices clamp into `[0, MINIMAP_ZOOM_LEVELS)` exactly like `set_zoom`, so a hand-edited
/// `config.toml` cannot seed an out-of-range level (decision 1131).
pub fn set_minimap_zoom(&mut self, zoom: u8, inside_zoom: u8) {
let top = crate::widget::MINIMAP_ZOOM_LEVELS - 1;
let (zoom, inside_zoom) = (zoom.min(top), inside_zoom.min(top));
for (_, frame) in self.model_mut().arena.iter_frames_mut() {
if let KindState::Minimap(m) = &mut frame.kind_state {
m.zoom = zoom;
m.inside_zoom = inside_zoom;
}
}
}
/// Load and run a Lua chunk (text-only; the sandbox rejects bytecode). Errors propagate to the
/// caller (a *load-time* error is the caller's to see; *handler* errors during events go to
/// [`UiScript::errors`]).
+5
View File
@@ -215,6 +215,10 @@ pub(crate) struct Model {
/// Unknown CVar names already warned about (warn-once, the era-atlas-miss posture).
pub(crate) cvars_warned: HashSet<String>,
/// The globals `RegisterForSave` declared, in registration order — the saved-variables set the
/// host writes out at logout/exit and re-executes at load (decision 1128, [`super::saved`]).
pub(crate) saved_names: Vec<String>,
/// The key-binding table (decision 0997, [`super::keybind`]) — the chord→command store the
/// Key Bindings window edits, plus its stored account/character sets. The CVar table's twin:
/// host-registered commands, Lua reads/writes synchronously, the app re-derives dispatch when
@@ -868,6 +872,7 @@ impl Model {
cvars: HashMap::new(),
cvar_changes: Vec::new(),
cvars_warned: HashSet::new(),
saved_names: Vec::new(),
keybinds: super::keybind::KeybindState::default(),
actions: HashMap::new(),
action_states: HashMap::new(),
+317
View File
@@ -0,0 +1,317 @@
//! **Saved variables** — the Lua-level settings that survive a restart (decision 1128).
//!
//! The reference client has *two* mechanisms for this, byte-verified in wow-re
//! (`system/ui/scratch/savedvariables-protocol.md`):
//!
//! 1. **`RegisterForSave("NAME")`** — the Lua API FrameXML uses for its **38** globals
//! (`LOCK_ACTIONBAR`, `CHAT_LOCKED`, the eleven `COMBAT_TEXT_*`, …), written to the flat
//! `WTF/Account/<ACC>/SavedVariables.lua` and read back by a hand-rolled line parser
//! (`0x4913b0`, strings and numbers only) before `VARIABLES_LOADED` fires.
//! 2. **`## SavedVariables` in a `.toc`** — one file per addon, *executed as a Lua chunk*
//! (`AddOn_Load 0x51f240` step 4, before `ADDON_LOADED` at step 6). Exactly one stock addon
//! uses it: `Blizzard_TrainerUI`, for the trainer window's three filter globals.
//!
//! benilla's ported UI is **one FrameXML tree, not addons** ([`crate::script`]'s manifest), so the
//! two collapse into one here: the *declaration* API is (1) — the one our XML can call — and the
//! *load* is (2)'s, a real chunk, because we already have a Lua VM and executing beats a line
//! parser that cannot carry a table. One file, install-scoped (the `benilla/` folder is already
//! per-install, so it IS the reference's account scope — decision 0954). The host side — path,
//! load seam, write triggers — is the app's `ui_saved` module's.
//!
//! **Deliberate divergences from the reference's serializer**, all recorded in 1128: LF and no
//! leading blank line (its `"\r\n"`-per-variable prefix means every file it writes *starts* with a
//! blank line); Rust's shortest round-tripping float formatting instead of `%.16g` (which loses a
//! double needing 17 digits); table entries emitted in a **sorted** order instead of raw `pairs`
//! order, so a settings file diffs cleanly; `\r` escaped rather than written raw (raw CR inside a
//! quoted string is what the reference emits and it does not re-parse); and a cycle is dropped
//! rather than cut by writing a lightuserdata sentinel *into the caller's table*. What is faithful
//! is the grammar — `NAME = value`, `nil` written out, bracketed keys, TAB indent, trailing comma —
//! so a file is readable by, and swappable with, the reference's own.
use std::collections::HashSet;
use std::ffi::c_void;
use std::fmt::Write as _;
use mlua::{Lua, Table, Value};
use super::Model;
/// How deep a table may nest before the serializer gives up. The reference recurses uncapped (only
/// its indent saturates at `0x80`); a settings file is not a place where 32 levels is anything but
/// a bug, and a bound is what keeps a hostile or accidental structure from eating the stack.
const MAX_DEPTH: usize = 32;
impl super::UiScript {
/// The registered names, in registration order — what the host writes out (the reference's
/// own emission order, and stable across runs because the load order is).
pub fn saved_variable_names(&self) -> Vec<String> {
self.model_mut().saved_names.clone()
}
/// Serialize every registered global into the settings file's body, `NAME = value` per line.
///
/// A name whose value cannot round-trip through a Lua chunk (a function, a widget reference,
/// any userdata — and `inf`/`NaN`, which are not Lua literals) is **skipped with a warning**
/// rather than written as something that would fail to load; the reference loses the same set,
/// silently. `nil` IS written (`NAME = nil`), which is how the reference records a toggle that
/// has never been touched.
pub fn saved_variables_text(&self) -> String {
let names = self.saved_variable_names();
let mut out = String::new();
let mut unwritable = Vec::new();
for name in &names {
let value: Value = match self.lua().globals().get(name.as_str()) {
Ok(v) => v,
Err(e) => {
unwritable.push(format!("{name} ({e})"));
continue;
}
};
let mut seen = HashSet::new();
match serialize(&value, 1, &mut seen) {
Some(text) => {
let _ = writeln!(out, "{name} = {text}");
}
None => unwritable.push(name.clone()),
}
}
if !unwritable.is_empty() {
self.model_mut().warnings.push(format!(
"saved variables: not serializable, skipped: {}",
unwritable.join(", ")
));
}
out
}
}
/// One Lua value as the text of a Lua expression, or `None` when it cannot be one.
///
/// `depth` is the indent level of the *contents* of a table at this position (so a top-level value
/// starts at 1); `seen` carries the table identities on the current path — a repeat is a cycle and
/// drops that entry. Note it is the *path*, not every table ever visited: a shared subtable is
/// legitimately written twice (as it must be, since the file has no way to express aliasing).
fn serialize(v: &Value, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<String> {
match v {
Value::Nil => Some("nil".to_string()),
Value::Boolean(b) => Some(b.to_string()),
Value::Integer(i) => Some(i.to_string()),
Value::Number(n) => number(*n),
Value::String(s) => Some(quote(&s.to_string_lossy())),
Value::Table(t) => table(t, depth, seen),
// Functions, threads, userdata (every widget reference is one — a frame's Lua value is a
// table whose `[0]` is a lightuserdata handle, RF-0023) cannot be written as a literal.
_ => None,
}
}
/// A Lua number literal. Integral values print without a fractional part (`1`, not `1.0` — the
/// reference's `%.16g` does the same, and our XML compares these against integers); everything
/// else takes Rust's shortest form that round-trips exactly. `inf`/`NaN` have no literal at all.
fn number(n: f64) -> Option<String> {
if !n.is_finite() {
return None;
}
if n.fract() == 0.0 && n.abs() < 9_007_199_254_740_992.0 {
return Some(format!("{}", n as i64));
}
Some(format!("{n}"))
}
/// A quoted Lua string. The reference escapes exactly four characters (`\000`, `\n`, `\"`, `\\`)
/// and writes CR and every high byte raw; we add `\r` (raw CR in a quoted string is not something
/// its own loader could read back) and keep high bytes raw, so localized text stays legible.
fn quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\0' => out.push_str("\\000"),
c => out.push(c),
}
}
out.push('"');
out
}
/// A table constructor: `{\n<tabs>[key] = value,\n<tabs-1>}`. Keys are always bracketed (the
/// reference's shape, and it sidesteps every reserved-word and non-identifier question); entries
/// are **sorted** — integer keys ascending, then strings alphabetically — so the file is stable
/// across runs instead of following Lua's hash order.
fn table(t: &Table, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<String> {
if depth > MAX_DEPTH || !seen.insert(t.to_pointer()) {
return None;
}
let mut ints: Vec<(i64, String)> = Vec::new();
let mut strs: Vec<(String, String)> = Vec::new();
for pair in t.pairs::<Value, Value>() {
let Ok((k, v)) = pair else { continue };
let Some(value) = serialize(&v, depth + 1, seen) else {
continue; // an unserializable entry drops, like the reference's
};
match k {
Value::Integer(i) => ints.push((i, value)),
// A float key that is integral is the same slot as the integer in Lua 5.1.
Value::Number(n) if n.fract() == 0.0 => ints.push((n as i64, value)),
Value::String(s) => strs.push((s.to_string_lossy(), value)),
_ => continue, // a table/bool/function key cannot be written as a literal
}
}
seen.remove(&t.to_pointer());
ints.sort_by_key(|(k, _)| *k);
strs.sort_by(|a, b| a.0.cmp(&b.0));
let indent = "\t".repeat(depth);
let close = "\t".repeat(depth.saturating_sub(1));
let mut out = String::from("{\n");
for (k, v) in ints {
let _ = writeln!(out, "{indent}[{k}] = {v},");
}
for (k, v) in strs {
let _ = writeln!(out, "{indent}[{}] = {v},", quote(&k));
}
out.push_str(&close);
out.push('}');
Some(out)
}
/// Register the `RegisterForSave` global.
///
/// Divergence, disclosed: the reference's binding is **taint-gated to Blizzard code**
/// (`0x4884e0`), because a third-party addon writing into the shared account file would be a
/// security surface. benilla has no taint model yet and loads no third-party addons, so the gate
/// has nothing to gate; when addon loading lands, this is one of the bindings that needs it (and
/// an addon's own `## SavedVariables` file is the mechanism it should use instead — the `.toc`
/// directive [`crate::toc::Toc::list`] already parses).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"RegisterForSave",
lua.create_function(|lua, name: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if !model.saved_names.contains(&name) {
model.saved_names.push(name);
}
Ok(())
})?,
)
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// The round trip that is the whole point: what the writer emits, the loader's own chunk
/// restores — through a fresh VM, exactly as a restart does.
#[test]
fn the_declared_globals_round_trip_through_a_fresh_vm() {
let mut s = UiScript::new().unwrap();
s.run(
r#"
TRAINER_FILTER_AVAILABLE = 1
TRAINER_FILTER_UNAVAILABLE = 0
NEVER_TOUCHED = nil
CHAT_LABEL = "say \"hi\"\nnow"
OPACITY = 0.5
SPELLBOOK_PAGENUMBERS = { [2] = 3, [1] = 1, ["odd key"] = { [1] = true } }
RegisterForSave("TRAINER_FILTER_AVAILABLE")
RegisterForSave("TRAINER_FILTER_UNAVAILABLE")
RegisterForSave("NEVER_TOUCHED")
RegisterForSave("CHAT_LABEL")
RegisterForSave("OPACITY")
RegisterForSave("SPELLBOOK_PAGENUMBERS")
RegisterForSave("TRAINER_FILTER_AVAILABLE")
"#,
)
.unwrap();
assert_eq!(
s.saved_variable_names(),
vec![
"TRAINER_FILTER_AVAILABLE",
"TRAINER_FILTER_UNAVAILABLE",
"NEVER_TOUCHED",
"CHAT_LABEL",
"OPACITY",
"SPELLBOOK_PAGENUMBERS",
],
"registration order, and a re-register is not a second entry"
);
let text = s.saved_variables_text();
// The grammar: one statement per line, `nil` written out, integral numbers bare, keys
// bracketed and SORTED (integers ascending, then strings), tab-indented, trailing comma.
assert_eq!(
text,
"TRAINER_FILTER_AVAILABLE = 1\n\
TRAINER_FILTER_UNAVAILABLE = 0\n\
NEVER_TOUCHED = nil\n\
CHAT_LABEL = \"say \\\"hi\\\"\\nnow\"\n\
OPACITY = 0.5\n\
SPELLBOOK_PAGENUMBERS = {\n\
\t[1] = 1,\n\
\t[2] = 3,\n\
\t[\"odd key\"] = {\n\
\t\t[1] = true,\n\
\t},\n\
}\n",
"got:\n{text}"
);
assert!(s.take_warnings().is_empty(), "nothing was unserializable");
// A fresh VM (the restart), the file's chunk, and every value is back.
let fresh = UiScript::new().unwrap();
fresh.run(&text).unwrap();
assert_eq!(
fresh
.eval::<i64>("return TRAINER_FILTER_AVAILABLE + TRAINER_FILTER_UNAVAILABLE")
.unwrap(),
1
);
assert!(fresh.eval::<bool>("return NEVER_TOUCHED == nil").unwrap());
assert_eq!(
fresh.eval::<String>("return CHAT_LABEL").unwrap(),
"say \"hi\"\nnow"
);
assert_eq!(fresh.eval::<f64>("return OPACITY").unwrap(), 0.5);
assert_eq!(
fresh
.eval::<i64>("return SPELLBOOK_PAGENUMBERS[2]")
.unwrap(),
3
);
assert!(fresh
.eval::<bool>("return SPELLBOOK_PAGENUMBERS['odd key'][1]")
.unwrap());
}
/// A value with no Lua literal is skipped and named, never written as something that would
/// break the next load — the one place we improve on the reference, which loses these silently.
#[test]
fn unserializable_values_are_skipped_with_one_warning() {
let mut s = UiScript::new().unwrap();
s.run(
r#"
A_FUNCTION = function() end
NOT_A_NUMBER = 1/0
KEPT = 7
CYCLE = {}
CYCLE.self = CYCLE
RegisterForSave("A_FUNCTION")
RegisterForSave("NOT_A_NUMBER")
RegisterForSave("KEPT")
RegisterForSave("CYCLE")
"#,
)
.unwrap();
let text = s.saved_variables_text();
// The cycle's own entry drops; the table itself still writes (empty here).
assert_eq!(text, "KEPT = 7\nCYCLE = {\n}\n", "got:\n{text}");
let warns = s.take_warnings();
assert_eq!(warns.len(), 1, "one line, not one per name: {warns:?}");
assert!(warns[0].contains("A_FUNCTION") && warns[0].contains("NOT_A_NUMBER"));
// And what it wrote is loadable.
UiScript::new().unwrap().run(&text).unwrap();
}
}
@@ -126,3 +126,66 @@ fn minimap_indoor_and_outdoor_zoom_indices_are_independent() {
"the outdoor index survived an indoor zoom"
);
}
/// **The level persists** (decision 1131). `SetZoom` writes the live index *and* the matching CVar
/// — `minimapInsideZoom` while inside a WMO, `minimapZoom` outside — the client's own `set_zoom` →
/// `CVar::Set` pair, which is the whole reason a zoom survives a restart. The host push in the
/// other direction (the seed) does *not* echo back as a change.
#[test]
fn setzoom_persists_the_level_through_the_cvar_it_belongs_to() {
let mut s = script();
s.register_cvars([("minimapZoom", "3"), ("minimapInsideZoom", "3")]);
s.run(r#"m = CreateFrame("Minimap", "TestMinimap")"#)
.unwrap();
// The seed is a HOST write: it moves both live indices and queues nothing (the host is the
// one that just read them off disk — an echo would re-dirty the file it loaded).
s.set_minimap_zoom(1, 4);
assert_eq!(s.eval::<u8>("return m:GetZoom()").unwrap(), 1);
assert!(s.take_cvar_changes().is_empty(), "the seed must not echo");
// Outdoors, a zoom writes the outdoor CVar and only that one.
s.run("m:SetZoom(5)").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("minimapZoom".to_string(), "5".to_string())]
);
assert_eq!(s.cvar("minimapInsideZoom").as_deref(), Some("3"));
// Indoors it writes the indoor one — the two levels persist separately, like the indices.
s.set_minimap_inside(true);
s.run("m:SetZoom(0)").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("minimapInsideZoom".to_string(), "0".to_string())]
);
assert_eq!(s.cvar("minimapZoom").as_deref(), Some("5"));
// A no-op zoom queues nothing: quiet frames stay quiet, and the config stays clean.
s.run("m:SetZoom(0)").unwrap();
assert!(s.take_cvar_changes().is_empty());
// The seed clamps like `set_zoom` does, so a hand-edited config.toml cannot seed out of range.
s.set_minimap_zoom(99, 99);
s.set_minimap_inside(false);
assert_eq!(
s.eval::<u8>("return m:GetZoom()").unwrap(),
MINIMAP_ZOOM_LEVELS - 1
);
}
/// A VM whose host registered nothing (a bare test harness, a glue-only run) still zooms — the
/// engine-side CVar write is a silent no-op there, not a warning: engine writes are code, not UI
/// content, so a miss means this build's host does not back the var.
#[test]
fn zooming_without_a_registered_cvar_table_is_silent() {
let mut s = script();
s.run(r#"m = CreateFrame("Minimap", "TestMinimap") m:SetZoom(4)"#)
.unwrap();
assert_eq!(s.eval::<u8>("return m:GetZoom()").unwrap(), 4);
assert!(s.take_cvar_changes().is_empty());
assert!(
s.take_warnings().is_empty(),
"no warning for an engine write"
);
}
@@ -279,6 +279,31 @@ impl super::UiScript {
}
}
/// **Reset the state filter and the collapse set — one `SMSG_TRAINER_LIST` arriving.**
///
/// Byte-verified (wow-re `system/ui/scratch/trainer-service-suppression.md`, decision 1128): the
/// list builder writes the filter mask itself on every packet — `0x4d75d9 mov ds:0xb73a1c,3`
/// (available|unavailable, "already known" OFF), or `5` (available|used) when `trainerType == 1`
/// — alongside `ds:0xb73a20 = ds:0xb73a24 = 0xffffffff`, which is "no group collapsed". So the
/// player's filter choice does NOT live in the engine across trainer visits in the reference: it
/// lives in the saved variable `TRAINER_FILTER_*`, and the window's show handler pushes it back
/// over this reset (decision 1128; `TrainerFrame.xml`'s `BenillaTrainerFrame_ApplyFilter`).
///
/// This is the **packet** edge, not the content edge — [`Self::set_trainer`] runs on every
/// snapshot change (an item template landing, a name resolving), and the reference's repaint path
/// `0x4d7d40` does not touch either mask. Reset there and a filter would evaporate mid-window.
pub fn reset_trainer_filter(&mut self, trainer_type: u32) {
let mut model = self.model_mut();
// Mask 5 at a mount/"talent" trainer: available + already-known, which is what makes a
// known mount visible under its "My Talents" header at all (decision 1124's group -1).
model.trainer_filter = if trainer_type == TRAINER_TYPE_MOUNT {
[true, false, true]
} else {
[true, true, false]
};
model.trainer_collapsed.clear();
}
/// Drain the **spell ids** `BuyTrainerService` queued since the last call (the engine resolves each
/// clicked filtered row to its service's spell id, so the app sends `CMSG_TRAINER_BUY_SPELL`
/// without needing to know the filter/index mapping).