Trainer filters, groups and NPC windows follow 1.12.1 (#2261)

The trainer window, its filters and the NPC service windows' lifecycle follow 1.12.1.
- `SetTrainerServiceTypeFilter` takes `"all"`, an exclusive third argument and its own errors; `GetTrainerServiceTypeFilter` matches case-blind (`0x4da260`, `0x4da510`).
- A mount or pet trainer always builds its known group first (`0x4d7609`); rows of state 3 and up show under no filter (`0x4da390`).
- An item record a getter waited on re-derives, re-sorts and fires, once per callback (`0x4d9140`, `0x4d7d40`).
- An NPC that loses a service flag closes that window (`0x60b420`).
- No window fires its event when the NPC's name arrives; `QUEST_ITEM_UPDATE` comes only from the item callback (`0x5018c0`).

Tests pin each address. Built on macOS.

Fixes #2241
Fixes #2242
Fixes #2243
Fixes #2244
Fixes #1494
Fixes #203

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-23 01:54:16 +07:00
committed by GitHub
parent 14d1153179
commit 1fc327efa5
16 changed files with 1358 additions and 218 deletions
+37 -25
View File
@@ -15,7 +15,7 @@ use crate::net::{Guid, NetCommands, ObjectStore, SelfPlayer};
use crate::query_cache::Ask;
use crate::ui_quest::row_is_active;
use crate::ui_script::{SweepApp, SweepLeg, UiFeed};
use crate::ui_session::{CoreCloses, LatchApp, LatchedWindow, NpcWindow};
use crate::ui_session::{LatchApp, LatchedWindow, NpcWindow};
/// The open gossip menu, plus the NPC-text records by id, which outlive it. The cache holds whole
/// records: the reference draws the greeting afresh at each open, from the NPC's gender and a roll.
@@ -280,9 +280,9 @@ fn snapshot(state: &GossipState) -> Option<GossipMenu> {
})
}
/// Push the menu into the VM and, with the window open, fire `GOSSIP_SHOW` for each message and
/// on a change; the close is the core's ([`close_core`]). The menu the VM holds is the memory:
/// `SelectGossipOption`'s reset writes both sides, so it reads as no change. The slots wait
/// Push the menu into the VM and, with the window open, fire `GOSSIP_SHOW` once for each message
/// as its greeting is drawn; the close is the core's ([`close_core`]). The menu the VM holds is the
/// memory: `SelectGossipOption`'s reset writes both sides, so it reads as no change. The slots wait
/// outside the VM's turn, since the gossip verbs write them in the host.
fn feed_gossip(
mut vm: crate::script_calls::VmWith<Res<'static, GossipState>>,
@@ -292,28 +292,21 @@ fn feed_gossip(
objects: crate::net::Objects,
unit_names: crate::ui_unit::NameTables,
states: Res<crate::world_state::WorldStates>,
mut last_name: crate::ui_script::WindowMemo<Option<String>>,
mut shown_message: crate::ui_script::WindowMemo<u64>,
mut closes: CoreCloses,
) {
vm.turn(|script, state| {
let Some(script) = script else {
return;
};
let last_name = last_name.get(script);
let shown_message = shown_message.get(script);
if closes.since_last(script, NpcWindow::Gossip) {
*last_name = None;
}
let shown = script.gossip();
// `GOSSIP_SHOW` carries no argument (`SignalEvent` `0x4e22b5`) and the stock window titles
// with `UnitName("npc")` (`GossipFrame.lua:33`), so the name's landing alone re-fires it.
// Asked before the hold, so the name query goes out beside the text query; `UnitName` looks
// it up with a callback (`0x5171b8`), at the show a gossip packet brings.
// The stock window titles with `UnitName("npc")` (`GossipFrame.lua:33`), which looks the
// name up with a callback (`0x5171b8`) at the show a gossip packet brings; its landing
// fires nothing. Asked before the hold, so the name query goes out beside the text query.
let ask = Ask::at(shown.is_none() || state.is_changed(), &commands);
let npc_name = state
.npc
.and_then(|g| names.resolve(&objects, g, ask).map(str::to_string));
if let Some(g) = state.npc {
let _ = names.resolve(&objects, g, ask);
}
// While the text query is out, fire nothing and keep the last snapshot: the reference
// returns at `0x4e2068` with no write and no event, so its frame keeps what it last showed.
// Reading the hold as a close would flash an open window.
@@ -332,18 +325,16 @@ fn feed_gossip(
},
);
}
let name_changed = *last_name != npc_name;
let new_message = std::mem::replace(shown_message, state.generation) != state.generation;
if fresh == shown && !name_changed && !new_message {
return;
if fresh != shown {
script.set_gossip(fresh.clone());
}
script.set_gossip(fresh.clone());
// A message, or a change while open; a close is the core's, and a disconnect's clear
// tells the VM nothing.
if fresh.is_some() && state.npc.is_some() {
// `GOSSIP_SHOW` has one producer, `0x4e2010` (`0x4e22b5`, no argument), run by a message
// (`0x4e27df`) and by its text's arrival (`0x4e22f2`), which the hold above waits on. A
// close is the core's, and a disconnect's clear tells the VM nothing.
if new_message && fresh.is_some() && state.npc.is_some() {
script.fire_event("GOSSIP_SHOW", vec![]);
}
*last_name = npc_name;
})
}
@@ -642,6 +633,27 @@ mod tests {
);
}
/// `GOSSIP_SHOW` fires only in `0x4e2010`, which a message (`0x4e27df`) or its text's arrival
/// (`0x4e22f2`) runs: the NPC's name landing fires nothing, and the next message fires again.
#[test]
fn the_npc_name_landing_fires_no_second_gossip_show() {
const SPEAKER: u64 = 0x10;
let (mut app, _rx) = feed_app(|s| s.run(RECORDER).unwrap());
let menu = |app: &mut App| {
let mut st = state(app);
st.remember_record(50, record("Well met."));
st.open_menu(SPEAKER, 50, option("Goodbye"), Vec::new(), 0);
};
menu(&mut app);
assert_eq!(tick(&mut app).0, ["GOSSIP_SHOW"]);
app.world_mut()
.resource_mut::<NameCache>()
.insert_player(SPEAKER, "Gossiper".into(), None);
assert!(tick(&mut app).0.is_empty(), "a landed name fires nothing");
menu(&mut app);
assert_eq!(tick(&mut app).0, ["GOSSIP_SHOW"], "the next message does");
}
/// The same sequence through stock `GossipFrame.xml`: the window stays up, silent, on the
/// first menu, and the repaint plays no open kit, as `ShowUIPanel` returns early on a visible
/// frame.
+52 -13
View File
@@ -616,7 +616,6 @@ fn feed_merchant(
mut errors: ResMut<MerchantErrors>,
mut last: crate::ui_script::WindowMemo<Option<MerchantState>>,
mut last_money: Local<crate::ui_script::VmMemo<Option<u64>>>,
mut last_name: crate::ui_script::WindowMemo<Option<String>>,
mut closes: CoreCloses,
mut sink: crate::ui_action::MessageSink,
) {
@@ -626,10 +625,8 @@ fn feed_merchant(
};
let last = last.get(script);
let last_money = last_money.get(script);
let last_name = last_name.get(script);
if closes.since_last(script, NpcWindow::Merchant) {
*last = None;
*last_name = None;
}
// Refusals show, and speak, as their message rows say; a silent code has no key.
let refusals: Vec<_> = errors
@@ -671,26 +668,24 @@ fn feed_merchant(
player,
vendor_npc_flags(&open, &units),
);
// `MERCHANT_SHOW` and `MERCHANT_UPDATE` carry no argument (`SignalEvent` `0x4fad92`,
// `0x4facaa`) and the stock window titles with `UnitName("NPC")` (`MerchantFrame.lua:67`),
// so the name's landing alone re-fires `MERCHANT_UPDATE`.
let vendor_name = open
.vendor
.and_then(|g| names.resolve(&objects, g, ask).map(str::to_string));
let name_changed = *last_name != vendor_name;
if fresh == *last && !name_changed {
// The stock window titles with `UnitName("NPC")` (`MerchantFrame.lua:67`), which looks
// the name up with a callback (`0x5171b8`) at its events; its landing fires nothing.
if let Some(g) = open.vendor {
let _ = names.resolve(&objects, g, ask);
}
if fresh == *last {
return;
}
script.set_merchant(fresh.clone());
match (&*last, &fresh) {
(None, Some(_)) => script.fire_event("MERCHANT_SHOW", vec![]),
// A change while open: an item or the name landed, or the stock moved.
// A change while open: an item landed, or the list or stock moved. Neither event
// carries an argument (`SignalEvent` `0x4fad92`, `0x4facaa`).
(Some(_), Some(_)) => script.fire_event("MERCHANT_UPDATE", vec![]),
// The core told the VM; a disconnect's clear tells it nothing.
(Some(_), None) | (None, None) => {}
}
*last = fresh;
*last_name = vendor_name;
})
}
@@ -1638,6 +1633,50 @@ mod tests {
assert_eq!(item_asks(&rx), 0);
}
/// `MERCHANT_SHOW` and `MERCHANT_UPDATE` have their own producers (`0x4fad92`; `0x4facaa`,
/// `0x4faea5`, `0x4faebd`, `0x4fb115`, `0x4fb575`), none of them the vendor's name: its landing
/// fires nothing.
#[test]
fn the_vendors_name_landing_fires_no_merchant_update() {
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<MerchantOpen>()
.init_resource::<MerchantErrors>()
.init_resource::<NameCache>()
.init_resource::<crate::net::Reputations>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.insert_resource(NetCommands(tx))
.add_systems(Update, feed_merchant);
let script = UiScript::new().unwrap();
script
.run(
"SEEN = {} local f = CreateFrame('Frame') f:RegisterEvent('MERCHANT_SHOW') \
f:RegisterEvent('MERCHANT_UPDATE') \
f:SetScript('OnEvent', function() table.insert(SEEN, event) end)",
)
.unwrap();
app.insert_non_send_resource(script);
let tick = |app: &mut App| -> Vec<String> {
app.update();
let s = app.world_mut().non_send_resource_mut::<UiScript>();
let seen = s.eval::<Vec<String>>("return SEEN").unwrap();
s.run("SEEN = {}").unwrap();
seen
};
const VENDOR: u64 = 0x10;
app.world_mut()
.resource_mut::<MerchantOpen>()
.open(VENDOR, Vec::new());
assert_eq!(tick(&mut app), ["MERCHANT_SHOW"]);
app.world_mut()
.resource_mut::<NameCache>()
.insert_player(VENDOR, "Vendor".into(), None);
assert!(tick(&mut app).is_empty(), "a landed name fires nothing");
}
/// Every merchant close runs the core `0x4fadf0`, whose tail clears a vendor row held on the
/// cursor (`0x4fae31`-`0x4fae4a`): `CloseMerchant` here, through the feed's close edge.
#[test]
+189 -29
View File
@@ -73,6 +73,10 @@ pub(crate) struct QuestGiver {
/// with a callback (`0x5017de`).
panels: u64,
panels_asked: u64,
/// The item records `GetQuestItemInfo`'s calls missed, one per call: the item cache's
/// callbacks `0x5018c0`, which outlive the panel and fire `QUEST_ITEM_UPDATE` when their
/// record lands (`0x5018fe`-`0x501903`), whatever the window shows then.
item_waits: Vec<u32>,
}
/// The greeting's pools, the available quests (`0xbdfe60`, its count `0xbe0834`) with each row's
@@ -207,6 +211,24 @@ impl QuestGiver {
self.closing = None;
self.view = None;
self.pools = QuestPools::default();
self.item_waits.clear();
}
/// Spend the waits whose item record answered: how many brought a record, each its own
/// `QUEST_ITEM_UPDATE`; a refusal runs its callback with status 0, which fires nothing
/// (`0x5018d6`-`0x5018e0`).
fn item_answers(&mut self, items: &Items) -> usize {
let mut landed = 0;
self.item_waits.retain(|&entry| {
if !items.template_answered(entry) {
return true;
}
if items.template(entry, 0, Ask::Read).is_some() {
landed += 1;
}
false
});
landed
}
}
@@ -496,8 +518,9 @@ fn panel_event(panel: QuestPanel) -> &'static str {
}
}
/// Push the quest view into the VM and fire its events: a panel's open event on a new panel,
/// `QUEST_ITEM_UPDATE` on a content change. The close is the core's ([`close_core`]).
/// Push the quest view into the VM and fire its events: a panel's event on each publish,
/// `QUEST_ITEM_UPDATE` on each item record a `GetQuestItemInfo` call missed. The close is the
/// core's ([`close_core`]).
pub(crate) fn feed_quest(
mut vm: crate::script_calls::VmWith<ResMut<'static, QuestGiver>>,
objects: crate::net::Objects,
@@ -512,7 +535,6 @@ pub(crate) fn feed_quest(
materials: Option<Res<crate::ui_item_text::PageMaterials>>,
mut sink: MessageSink,
mut last: crate::ui_script::WindowMemo<Option<QuestState>>,
mut last_name: crate::ui_script::WindowMemo<Option<String>>,
mut closes: CoreCloses,
) {
// The window is the host's too, so the feed reads and pushes it as an engine turn; the panel
@@ -522,10 +544,8 @@ pub(crate) fn feed_quest(
return;
};
let last = last.get(script);
let last_name = last_name.get(script);
if closes.since_last(script, NpcWindow::Quest) {
*last = None;
*last_name = None;
}
if giver.take_completed_fanfare() {
script.queue_sound_kit("QUESTCOMPLETED");
@@ -542,11 +562,12 @@ pub(crate) fn feed_quest(
show_messages(script, &mut sink, "ui_quest", lines);
let player = crate::npc_text::player_identity(&self_q, &names, unit_names.get());
// A panel's publish, or a window this VM has not shown, is the stock frame's getter calls.
let ask = Ask::at(
last.is_none() || giver.panels != giver.panels_asked,
&commands,
);
let published = giver.panels != giver.panels_asked;
let ask = Ask::at(last.is_none() || published, &commands);
giver.panels_asked = giver.panels;
let waits = script.take_quest_item_waits();
giver.item_waits.extend(waits);
let item_updates = giver.item_answers(&items);
let fresh = snapshot(
&giver,
&items,
@@ -572,28 +593,28 @@ pub(crate) fn feed_quest(
});
st
});
let npc_name = giver
.npc
.and_then(|g| names.resolve(&objects, g, ask).map(str::to_string));
let name_changed = *last_name != npc_name;
if fresh == *last && !name_changed {
return;
// The frame reads `UnitName("npc")` (`QuestFrame.lua:63`), which looks the name up with a
// callback (`0x5171b8`) at the panel's event; its landing fires nothing.
if let Some(g) = giver.npc {
let _ = names.resolve(&objects, g, ask);
}
script.set_quest(fresh.clone());
// The panel events carry no argument: the frame reads `UnitName("npc")`
// (`QuestFrame.lua:63`). A close is the core's, which told the VM; a disconnect's clear
// tells it nothing.
if let Some(f) = &fresh {
let same_panel = last.as_ref().is_some_and(|l| l.panel == f.panel);
let event = if same_panel {
"QUEST_ITEM_UPDATE"
} else {
panel_event(f.panel)
};
fires.post(event, vec![]);
// Each panel setter fires its event at its end, every publish (`0x500ee5`, `0x50105d`,
// `0x501120`); a window this VM has not shown takes it too. No event carries an argument.
// A close is the core's, which told the VM; a disconnect's clear tells it nothing.
let shows = fresh
.as_ref()
.filter(|_| published || last.is_none())
.map(|f| f.panel);
if fresh != *last {
script.set_quest(fresh.clone());
*last = fresh;
}
if let Some(panel) = shows {
fires.post(panel_event(panel), vec![]);
}
for _ in 0..item_updates {
fires.post("QUEST_ITEM_UPDATE", vec![]);
}
*last = fresh;
*last_name = npc_name;
})
}
@@ -1049,4 +1070,143 @@ mod tests {
Some("Your quest log is full.")
);
}
/// `QUEST_ITEM_UPDATE` fires only in the item-cache callback `0x5018c0` (`0x501903`), and the
/// panel's event once per panel: the giver's name landing fires nothing.
#[test]
fn the_givers_name_landing_fires_no_quest_event() {
const GIVER: u64 = 0x10;
let mut app = crate::game_plugins::schedule_tests::headless_client();
let script = UiScript::new().unwrap();
script
.run(
"SEEN = {} local f = CreateFrame('Frame') \
for _, e in ipairs({'QUEST_GREETING', 'QUEST_ITEM_UPDATE'}) do \
f:RegisterEvent(e) end \
f:SetScript('OnEvent', function() table.insert(SEEN, event) end)",
)
.unwrap();
app.insert_non_send_resource(script);
open_panel(
app.world_mut(),
GIVER,
QuestView::Greeting(benilla_protocol::messages::QuestGiverList {
npc: GIVER,
greeting: "Greetings.".into(),
emote_delay: 0,
emote: 0,
quests: Vec::new(),
}),
);
// Registered once, so its memo lasts from run to run.
let feed = app.world_mut().register_system(feed_quest);
let tick = |app: &mut App| -> Vec<String> {
app.world_mut().run_system(feed).unwrap();
let s = app.world_mut().non_send_resource_mut::<UiScript>();
let seen = s.eval::<Vec<String>>("return SEEN").unwrap();
s.run("SEEN = {}").unwrap();
seen
};
assert_eq!(tick(&mut app), ["QUEST_GREETING"]);
app.world_mut()
.resource_mut::<NameCache>()
.insert_player(GIVER, "Giver".into(), None);
assert!(tick(&mut app).is_empty(), "a landed name fires nothing");
}
/// The feed over a headless client's world, registered once so its memo lasts from run to
/// run, and a VM that records the panel events in `SEEN`.
fn quest_app() -> (App, bevy::ecs::system::SystemId) {
let mut app = crate::game_plugins::schedule_tests::headless_client();
let script = UiScript::new().unwrap();
script
.run(
"SEEN = {} local f = CreateFrame('Frame') \
for _, e in ipairs({'QUEST_GREETING', 'QUEST_DETAIL', 'QUEST_PROGRESS', \
'QUEST_COMPLETE', 'QUEST_ITEM_UPDATE'}) do f:RegisterEvent(e) end \
f:SetScript('OnEvent', function() table.insert(SEEN, event) end)",
)
.unwrap();
app.insert_non_send_resource(script);
let feed = app.world_mut().register_system(feed_quest);
(app, feed)
}
fn quest_tick(app: &mut App, feed: bevy::ecs::system::SystemId) -> Vec<String> {
app.world_mut().run_system(feed).unwrap();
let s = app.world_mut().non_send_resource_mut::<UiScript>();
let seen = s.eval::<Vec<String>>("return SEEN").unwrap();
s.run("SEEN = {}").unwrap();
seen
}
fn offer(quest_id: u32, title: &str, choice: u32) -> QuestView {
QuestView::Detail(benilla_protocol::messages::QuestDetails {
npc: 0x10,
quest_id,
title: title.into(),
details: String::new(),
objectives: String::new(),
auto_finish: 0,
choices: vec![triple(choice)],
rewards: Vec::new(),
money: 0,
reward_spell: 0,
})
}
/// Each panel setter fires its event at its end, every time (`0x500ee5`, `0x50105d`,
/// `0x501120`): a second offer fires `QUEST_DETAIL` again, an identical one too.
/// `QUEST_ITEM_UPDATE` fires only from the callback `GetQuestItemInfo`'s lookup keeps
/// (`0x5017ce`), once per call, when the record lands (`0x5018fe`-`0x501903`); a record no
/// call asked for fires nothing.
#[test]
fn panel_events_fire_per_publish_and_item_updates_per_callback() {
let (mut app, feed) = quest_app();
let publish = |app: &mut App, view| open_panel(app.world_mut(), 0x10, view);
publish(&mut app, offer(100, "A Threat Within", 901));
assert_eq!(quest_tick(&mut app, feed), ["QUEST_DETAIL"]);
publish(&mut app, offer(101, "Kobold Camp Cleanup", 902));
assert_eq!(
quest_tick(&mut app, feed),
["QUEST_DETAIL"],
"a second offer"
);
publish(&mut app, offer(101, "Kobold Camp Cleanup", 902));
assert_eq!(
quest_tick(&mut app, feed),
["QUEST_DETAIL"],
"an identical one"
);
let land = |app: &mut App, entry| {
app.world_mut()
.resource_mut::<Items>()
.insert_template(entry, Some(crate::items::test_template("Sword")));
};
// No call asked for 902: its landing repaints in place and fires nothing.
land(&mut app, 902);
assert!(quest_tick(&mut app, feed).is_empty());
publish(&mut app, offer(102, "Westbrook Garrison", 903));
assert_eq!(quest_tick(&mut app, feed), ["QUEST_DETAIL"]);
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("GetQuestItemInfo('choice', 1) GetQuestItemInfo('choice', 1)")
.unwrap();
assert!(
quest_tick(&mut app, feed).is_empty(),
"the record is still out"
);
land(&mut app, 903);
assert_eq!(
quest_tick(&mut app, feed),
["QUEST_ITEM_UPDATE", "QUEST_ITEM_UPDATE"],
"one per call"
);
assert!(
quest_tick(&mut app, feed).is_empty(),
"the callbacks are spent"
);
}
}
@@ -133,7 +133,9 @@ fn service(
name: Some(name.into()),
subtext: None,
texture: Some("Interface\\Icons\\INV_Sword_04".into()),
icon_wait: None,
description: None,
description_wait: None,
cost,
prof_first_rank: false,
category,
+60 -4
View File
@@ -10,13 +10,16 @@
//! that is not a stance (`0x60e9f0`) and lacks flag `0x8` releases it with the goodbye
//! (`0x5de07f`, `dl = 0`). The release closes the window through `0x493310`; the goodbye is
//! [`super::LatchVocal`], which a dead player does not hear (`0x605f30`).
//!
//! A unit's `UNIT_NPC_FLAGS` edge runs `0x60b420` (from the watcher `0x604a20`), which closes the
//! window a lost service flag served ([`close_lost_services`]).
use std::collections::HashMap;
use bevy::prelude::*;
use benilla_formats::ShapeshiftForm;
use benilla_protocol::field::FIELD_UNIT_BYTES_1;
use benilla_protocol::field::{FIELD_UNIT_BYTES_1, FIELD_UNIT_NPC_FLAGS};
use benilla_protocol::messages::ObjectType;
use super::latch::{release_cell, release_latch};
@@ -39,9 +42,59 @@ pub(crate) fn form_bars_interaction(forms: &HashMap<u32, ShapeshiftForm>, form:
.is_some_and(|row| !row.is_stance() && !row.allows_interaction())
}
/// The death of a unit and the active player's form change release the latch. The edges are read
/// after the apply pass, and the releases run in order at the next sync point, before the `"npc"`
/// feed.
/// `0x60b420(old)`: each service flag the unit lost (`0x60b44e`, set before and clear now) closes
/// the window whose cell holds the unit, in its order (`0x60b45c`-`0x60b552`): gossip `0x1`
/// (`0x4e1fa0`), quest `0x2` (`0x501130` with its report clear), taxi `0x8` (`0x4dc0f0`), trainer
/// `0x10` (`0x4d7440`) and bank `0x100` (`0x4f84d0`), each core entered as it is with a zero guid.
fn close_lost_services(world: &mut World, guid: u64, old: u32, new: u32) {
use crate::target::cursor_mode::npc_flags as f;
let lost = (old ^ new) & !new;
let holds = |world: &World, cell: fn(&World) -> Option<u64>| cell(world) == Some(guid);
if lost & f::GOSSIP != 0
&& holds(world, |w| {
w.get_resource::<crate::ui_gossip::GossipState>()?.npc
})
{
crate::ui_gossip::close_core(world);
}
if lost & f::QUESTGIVER != 0
&& holds(world, |w| {
w.get_resource::<crate::ui_quest::QuestGiver>()?.npc
})
{
crate::ui_quest::close_core(world, false);
}
if lost & f::FLIGHTMASTER != 0
&& holds(world, |w| {
Some(
w.get_resource::<crate::ui_taxi::TaxiState>()?
.open
.as_ref()?
.flightmaster,
)
})
{
crate::ui_taxi::close_core(world);
}
if lost & f::TRAINER != 0
&& holds(world, |w| {
w.get_resource::<crate::ui_trainer::TrainerOpen>()?.trainer
})
{
crate::ui_trainer::close_core(world);
}
if lost & f::BANKER != 0
&& holds(world, |w| {
w.get_resource::<crate::ui_bank::BankOpen>()?.banker
})
{
crate::ui_bank::close_core(world);
}
}
/// The death of a unit and the active player's form change release the latch, and a unit's lost
/// service flags close their windows. The edges are read after the apply pass, and the releases
/// run in order at the next sync point, before the `"npc"` feed.
pub(super) fn release_on_field_edges(
mut edges: MessageReader<FieldChanged>,
me: Query<Entity, With<SelfPlayer>>,
@@ -65,6 +118,9 @@ pub(super) fn release_on_field_edges(
.is_some_and(|s| form_bars_interaction(&s.forms, form_of(e.new)))
{
commands.queue(|world: &mut World| release_latch(world, true));
} else if e.unit_field(FIELD_UNIT_NPC_FLAGS) {
let (guid, old, new) = (e.guid, e.old, e.new);
commands.queue(move |world: &mut World| close_lost_services(world, guid, old, new));
}
}
}
@@ -266,3 +266,64 @@ fn the_death_edge_reads_health_signed() {
app.update();
assert_eq!(latch(&app), None, "a fall to -1");
}
/// The `UNIT_NPC_FLAGS` watcher (`0x604a20` → `0x60b420`) closes the window whose NPC lost the
/// flag for it, testing each bit against its window's cell (`0x60b45c`-`0x60b552`): gossip `0x1`
/// (`0x4e1fa0`), quest `0x2` (`0x501130`), taxi `0x8` (`0x4dc0f0`), trainer `0x10` (`0x4d7440`)
/// and bank `0x100` (`0x4f84d0`). Each core is entered directly while its window holds the
/// latch, so it fires its close twice, quest once. A flag kept or gained, another unit's loss and
/// a merchant's lost vendor bit close nothing.
#[test]
fn an_npc_that_loses_a_service_flag_closes_its_window() {
use crate::target::cursor_mode::npc_flags as f;
use benilla_protocol::field::FIELD_UNIT_NPC_FLAGS;
const KEEP: u32 = f::VENDOR | f::INNKEEPER;
for (window, bit, event, times) in [
(NpcWindow::Gossip, f::GOSSIP, "GOSSIP_CLOSED", 2),
(NpcWindow::Quest, f::QUESTGIVER, "QUEST_FINISHED", 1),
(NpcWindow::Taxi, f::FLIGHTMASTER, "TAXIMAP_CLOSED", 2),
(NpcWindow::Trainer, f::TRAINER, "TRAINER_CLOSED", 2),
(NpcWindow::Bank, f::BANKER, "BANKFRAME_CLOSED", 2),
] {
let (mut app, _) = app();
let npc = app.world_mut().spawn_empty().id();
let other = app.world_mut().spawn_empty().id();
open(&mut app, window, VENDOR);
let flags = |app: &mut App, entity, guid, old, new| {
app.world_mut().write_message(edge(
entity,
guid,
ObjectType::Unit,
FIELD_UNIT_NPC_FLAGS,
old,
new,
));
app.update();
};
flags(&mut app, other, BANKER, bit | KEEP, KEEP);
flags(&mut app, npc, VENDOR, KEEP, bit | KEEP);
flags(&mut app, npc, VENDOR, bit | KEEP, bit);
assert!(seen_in(&app).is_empty(), "{window:?}: {:?}", seen_in(&app));
assert_eq!(latch(&app), Some(VENDOR), "{window:?}");
flags(&mut app, npc, VENDOR, bit | KEEP, KEEP);
assert_eq!(seen_in(&app), vec![event; times], "{window:?}");
assert_eq!(latch(&app), None, "{window:?}");
}
// The watcher has no vendor arm.
let (mut app, _) = app();
let npc = app.world_mut().spawn_empty().id();
open(&mut app, NpcWindow::Merchant, VENDOR);
app.world_mut().write_message(edge(
npc,
VENDOR,
ObjectType::Unit,
FIELD_UNIT_NPC_FLAGS,
u32::MAX,
0,
));
app.update();
assert!(seen_in(&app).is_empty());
assert_eq!(latch(&app), Some(VENDOR));
}
+67 -27
View File
@@ -200,7 +200,6 @@ fn feed_taxi(
self_q: Query<&ObjectStore, With<SelfPlayer>>,
relations: crate::target::RelationWorld,
mut last: crate::ui_script::WindowMemo<Option<TaxiUiState>>,
mut last_name: crate::ui_script::WindowMemo<Option<String>>,
mut closes: CoreCloses,
mut sink: crate::ui_action::MessageSink,
// Absent in a harness with no tutorials.
@@ -211,10 +210,8 @@ fn feed_taxi(
return;
};
let last = last.get(script);
let last_name = last_name.get(script);
if closes.since_last(script, NpcWindow::Taxi) {
*last = None;
*last_name = None;
}
// The activate reply: a refusal shows where its message row says; `OK` closed the map in
@@ -264,7 +261,6 @@ fn feed_taxi(
// ([`open_map`]); only the memo goes, so that open pushes and fires anew.
let Some(open) = state.open.as_ref() else {
*last = None;
*last_name = None;
return;
};
let flightmaster = index
@@ -301,33 +297,30 @@ fn feed_taxi(
nodes,
};
// The flight master's name is tracked so its landing re-fires `TAXIMAP_OPENED`, whose stock
// handler titles the map with `UnitName("npc")` (`TaxiFrame.lua:27`); the reference fires
// it only on open. `UnitName("npc")` looks it up with a callback (`0x5171b8`), at the
// open's event.
let flightmaster_name = names
.resolve_unit(
open.flightmaster,
flightmaster,
Ask::at(last.is_none(), &commands),
)
.map(str::to_string);
if last.as_ref() != Some(&fresh) || *last_name != flightmaster_name {
// The stock handler titles the map with `UnitName("npc")` (`TaxiFrame.lua:27`), which
// looks the name up with a callback (`0x5171b8`) at the open's event; its landing fires
// nothing.
let _ = names.resolve_unit(
open.flightmaster,
flightmaster,
Ask::at(state.opening, &commands),
);
let opens = std::mem::take(&mut state.opening);
if last.as_ref() != Some(&fresh) {
script.set_taxi(Some(fresh.clone()));
// No arguments: the event's one fire site, `0x4dba96`, is a plain
// `FrameScript_SignalEvent` (`0x703e50`). The close is the core's.
*last = Some(fresh);
}
// The event's one fire site, `0x4dba96`, is the map builder's, once per open; no
// arguments, a plain `FrameScript_SignalEvent` (`0x703e50`). The close is the core's.
if opens {
fires.post("TAXIMAP_OPENED", Vec::new());
// The open's Travel tutorial follows its event (`0x4dbaa2`), whatever a handler of the
// event does to the map; a re-fire for the name raises none.
if std::mem::take(&mut state.opening) {
if let Some(tutorials) = tutorials.as_deref_mut() {
tutorials.write(crate::tutorial::TutorialEvent::trigger(
crate::tutorial::id::TRAVEL,
));
}
// event does to the map.
if let Some(tutorials) = tutorials.as_deref_mut() {
tutorials.write(crate::tutorial::TutorialEvent::trigger(
crate::tutorial::id::TRAVEL,
));
}
*last = Some(fresh);
*last_name = flightmaster_name;
}
})
}
@@ -606,6 +599,53 @@ mod tests {
assert_eq!(crate::query_cache::queries(&rx).len(), 1);
}
/// `TAXIMAP_OPENED` fires only in the map builder `0x4db710` (`0x4dba96`), once per open: the
/// flight master's name landing fires nothing.
#[test]
fn the_flight_masters_name_landing_fires_no_second_open() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalogs = TaxiCatalogs::load(&mut chain).expect("taxi DBCs");
const MASTER: u64 = 0x10;
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<TaxiState>()
.init_resource::<TaxiRouteCache>()
.init_resource::<GuidIndex>()
.init_resource::<NameCache>()
.init_resource::<crate::net::Reputations>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.insert_resource(NetCommands(tx))
.insert_resource(catalogs)
.add_systems(Update, feed_taxi);
let script = UiScript::new().unwrap();
script
.run(
"SEEN = {} local f = CreateFrame('Frame') f:RegisterEvent('TAXIMAP_OPENED') \
f:SetScript('OnEvent', function() table.insert(SEEN, event) end)",
)
.unwrap();
app.insert_non_send_resource(script);
let tick = |app: &mut App| -> Vec<String> {
app.update();
let s = app.world_mut().non_send_resource_mut::<UiScript>();
let seen = s.eval::<Vec<String>>("return SEEN").unwrap();
s.run("SEEN = {}").unwrap();
seen
};
app.world_mut().resource_mut::<TaxiState>().open(
MASTER,
2,
TaxiMask([0b1010_1010, 0, 0, 0, 0, 0, 0, 0]),
);
assert_eq!(tick(&mut app), ["TAXIMAP_OPENED"]);
app.world_mut()
.resource_mut::<NameCache>()
.insert_player(MASTER, "Flier".into(), None);
assert!(tick(&mut app).is_empty(), "a landed name fires nothing");
}
/// `SMSG_SHOWTAXINODES` counts the mask's known nodes (`0x5eced7`-`0x5ecf2e`): under two
/// (`0x5ecf39`) it shows `0xb5`, `ERR_TAXINOPATHS`, inside the handler (`0x5ecf44` →
/// `0x496720`), then closes the gossip menu (`0x4e1fa0` with a zero guid, `0x5ecf55`), whose
+64 -15
View File
@@ -21,8 +21,8 @@ use crate::query_cache::Ask;
/// `GetTrainerServiceIcon 0x4d8f50`: at a tradeskill trainer (`[0xb73a08]`), the item the wire
/// spell's first learn effect (`0x4d8ff5`/`0x4d8ffa`) teaches to create (`0x4d906a`); otherwise
/// the wire spell's own icon (`0x4d9008`), never the taught spell's (`0x4d8fd7`). An uncached
/// template reads `None` until its answer re-fires `TRAINER_UPDATE`, as the client's cache
/// callback does (`0x4d9140`).
/// template reads `None`, and the getter's call keeps a callback on it ([`service_icon_waiting`]).
#[cfg(test)]
pub(super) fn service_icon(
wire_spell: u32,
trainer_type: u32,
@@ -30,6 +30,28 @@ pub(super) fn service_icon(
icons: Option<&ItemDisplays>,
items: &Items,
ask: Ask<'_>,
) -> Option<String> {
service_icon_waiting(
wire_spell,
trainer_type,
spells,
icons,
items,
ask,
&mut None,
)
}
/// [`service_icon`], with `wait` set to the product whose record the lookup (`0x4d9096`) missed:
/// the callback `0x4d9140` each getter call keeps on it (`0x4d907e`) runs `0x4d7d40` when it lands.
pub(super) fn service_icon_waiting(
wire_spell: u32,
trainer_type: u32,
spells: &SpellCatalog,
icons: Option<&ItemDisplays>,
items: &Items,
ask: Ask<'_>,
wait: &mut Option<u32>,
) -> Option<String> {
let wire = spells.get(wire_spell)?;
if trainer_type == TRAINER_TYPE_TRADESKILL {
@@ -46,10 +68,11 @@ pub(super) fn service_icon(
.map(|d| d.effect_item_type[0])
.filter(|&e| e != 0)
{
return items
.template(product, 0, ask)
.map(|t| t.display_info_id)
.and_then(|d| item_icon(icons, d));
let Some(template) = items.template(product, 0, ask) else {
*wait = Some(product);
return None;
};
return item_icon(icons, template.display_info_id);
}
}
wire.icon.clone()
@@ -114,6 +137,7 @@ pub(super) struct ServiceText<'a> {
/// the Description column, and the modifiers applied. `None` is the nil the binding pushes
/// (`0x4d9d90`): the wire spell has no record (`0x4d9bd7`), or no arm found text and the wire
/// spell's own is null or empty (`0x4d9d44`, `0x4d9d49`).
#[cfg(test)]
pub(super) fn service_description(
wire: &TrainerSpell,
trainer_type: u32,
@@ -121,6 +145,21 @@ pub(super) fn service_description(
items: &Items,
ask: Ask<'_>,
text: &ServiceText,
) -> Option<String> {
service_description_waiting(wire, trainer_type, spells, items, ask, text, &mut None)
}
/// [`service_description`], with `wait` set to the recipe's product whose record the lookup
/// (`0x4d9d07`) missed: the callback `0x4d9db0` each getter call keeps on it (`0x4d9cef`) runs
/// `0x4d7d40` when it lands. A product id of 0 is never looked up, so it keeps none.
pub(super) fn service_description_waiting(
wire: &TrainerSpell,
trainer_type: u32,
spells: &SpellCatalog,
items: &Items,
ask: Ask<'_>,
text: &ServiceText,
wait: &mut Option<u32>,
) -> Option<String> {
let d = spells.get(wire.spell)?;
let learn = (0..3).find(|&i| {
@@ -151,12 +190,14 @@ pub(super) fn service_description(
if taught.attributes & SPELL_ATTR_IS_TRADESKILL != 0
&& taught.effects[0] == SPELL_EFFECT_CREATE_ITEM
{
if let Some(t) = items
.template(taught.effect_item_type[0], 0, ask)
.map(|item| item.description.clone())
.filter(|t| !t.is_empty())
{
return Some(t);
let product = taught.effect_item_type[0];
match items.template(product, 0, ask) {
Some(item) if !item.description.is_empty() => {
return Some(item.description.clone());
}
Some(_) => {}
None if product != 0 => *wait = Some(product),
None => {}
}
}
}
@@ -231,11 +272,19 @@ pub(super) fn service_kind(wire_spell: u32, spells: &SpellCatalog) -> (bool, Opt
(trade_skill, learn_spell)
}
/// A wire `state` byte's category; anything but GREEN or GRAY, an unexpected value too, is gated.
/// A `state` byte's category. The finalizer shows a row whose `1 << state` is in the filter
/// (`0x4d84ba`), whose bits past 2 nothing sets (`0x4da390`), so states 3 to 31 are hidden, the
/// re-evaluator's 3 (`0x4d7e3e`, `0x4d83a4`) among them; vmangos sends 0 to 2
/// (`NPCHandler.cpp:113`).
///
/// Deviation: benilla hides every state from 3 up. The reference shows bytes 32 and up, because
/// x86 `shl` wraps its count (bits 32-34 alias 0-2) and its group counter `[hdr+4+state*4]`
/// (`0x4d7854`) runs past the header.
pub(super) fn category(state: u8) -> TrainerServiceCategory {
match state {
trainer_spell_state::GRAY => TrainerServiceCategory::Used,
trainer_spell_state::GREEN => TrainerServiceCategory::Available,
_ => TrainerServiceCategory::Unavailable,
trainer_spell_state::RED => TrainerServiceCategory::Unavailable,
trainer_spell_state::GRAY => TrainerServiceCategory::Used,
_ => TrainerServiceCategory::Hidden,
}
}
+141 -43
View File
@@ -10,12 +10,12 @@ use benilla_formats::{
LearnEffect, SkillLineCatalog, SpellCatalog, TokenContext, SPELL_EFFECT_LEARN_PET_SPELL,
SPELL_EFFECT_SKILL_STEP,
};
use benilla_protocol::messages::TrainerSpell;
use benilla_protocol::messages::{trainer_spell_state, TrainerSpell};
use bevy::prelude::*;
use benilla_ui::script::{
CallTail, TrainerAbilityReq, TrainerCall, TrainerService, TrainerServiceCategory,
TrainerSkillReq, TrainerState, TrainerTooltip, UiScript,
TrainerSkillReq, TrainerState, TrainerTooltip, UiScript, TRAINER_GROUP_KNOWN,
};
use crate::entities::ItemDisplays;
@@ -54,10 +54,18 @@ pub(crate) struct TrainerOpen {
/// the reference's builder resets them per list (`0x4d7b42` for the selection); an open window
/// repaints through [`reeval`], never a second list.
pub(crate) fresh_list: bool,
/// One of the reference's twelve re-evaluation triggers fired with the window open; the feed
/// re-derives and fires `TRAINER_UPDATE` even if no state moved, as `0x4d7d40`'s tail does
/// (`0x4d83f4`), and the stock window re-reads `GetMoney()` on it.
pub(crate) re_derive: bool,
/// The passes of `0x4d7d40` owed, one per trigger of the reference's twelve that fired with
/// the window open: each re-derives, sorts and fires `TRAINER_UPDATE` even if no state moved,
/// as its tail does (`0x4d83f4`), and the stock window re-reads `GetMoney()` on it.
pub(crate) re_derive: u32,
/// Per service, whether its record's group key is the known group's -1: written by the list
/// for a row it gets known at a mount trainer (`0x4d77e8`) and by each pass for a row it leaves
/// known (`0x4d82b2`-`0x4d82c1`), and never written back until the next list.
pub(crate) known_key: Vec<bool>,
/// The item records the icon and description getters' calls missed, one per call: the item
/// cache's callbacks `0x4d9140` and `0x4d9db0`, which outlive the window and run `0x4d7d40`
/// when their record lands (`0x4d914a`, `0x4d9dba`), on whatever trainer is open then.
pub(crate) item_waits: Vec<u32>,
}
impl TrainerOpen {
@@ -71,33 +79,64 @@ impl TrainerOpen {
) {
self.trainer = Some(trainer);
self.trainer_type = trainer_type;
self.known_key = services
.iter()
.map(|w| trainer_type == TRAINER_TYPE_MOUNT && w.state == trainer_spell_state::GRAY)
.collect();
self.services = services;
self.greeting = greeting;
// A fresh list carries the server's own states, so nothing is pending against it.
self.fresh_list = true;
self.re_derive = false;
self.re_derive = 0;
}
/// One of the twelve triggers fired: re-derive on the next feed, if a window is open.
/// One of the twelve triggers fired: one more pass on the next feed, if a window is open.
pub(crate) fn trigger_re_derive(&mut self) {
if self.trainer.is_some() {
self.re_derive = true;
self.re_derive += 1;
}
}
/// Whether a pass is owed.
#[cfg(test)]
pub(crate) fn re_derive_owed(&self) -> bool {
self.re_derive > 0
}
/// A client-side close; a re-open re-lists.
pub(crate) fn clear(&mut self) {
self.trainer = None;
self.services.clear();
self.trainer_type = 0;
self.greeting.clear();
self.known_key.clear();
self.fresh_list = false;
self.re_derive = false;
self.re_derive = 0;
}
/// Disconnect: drop the open window.
/// Disconnect: drop the open window, and the item callbacks with the asks they wait on.
pub(crate) fn clear_session(&mut self) {
self.clear();
self.item_waits.clear();
}
/// Spend the waits whose item record answered: each one that brought a record is a trigger of
/// its own pass; a refusal runs its callback with status 0, which does nothing (`0x4d9146`,
/// `0x4d9db6`).
fn item_answers(&mut self, items: &Items) {
let mut landed = 0;
self.item_waits.retain(|&entry| {
if !items.template_answered(entry) {
return true;
}
if items.template(entry, 0, Ask::Read).is_some() {
landed += 1;
}
false
});
for _ in 0..landed {
self.trigger_re_derive();
}
}
}
@@ -243,15 +282,36 @@ fn resolve_service(
wire.spell
);
}
let (mut icon_wait, mut description_wait) = (None, None);
// Over the wire spell, like the name and subtext ([`service_icon`]).
let texture = service_icon_waiting(
wire.spell,
trainer_type,
spells,
icons,
items,
ask,
&mut icon_wait,
);
let description = service_description_waiting(
wire,
trainer_type,
spells,
items,
ask,
text,
&mut description_wait,
);
TrainerService {
spell_id: wire.spell,
name: display.map(|d| d.name.clone()),
// `0x4d8b50` answers the record's NameSubtext, "" for a rankless spell (`0x4d8b84`), and
// NULL without a Spell record (`0x4d8b58`), which `0x6f3890` pushes as nil.
subtext: display.map(|d| d.rank.clone().unwrap_or_default()),
// Over the wire spell, like the name and subtext ([`service_icon`]).
texture: service_icon(wire.spell, trainer_type, spells, icons, items, ask),
description: service_description(wire, trainer_type, spells, items, ask, text),
texture,
icon_wait,
description,
description_wait,
cost: wire.cost,
prof_first_rank: wire.is_primary_prof_first_rank,
category: cat,
@@ -322,8 +382,9 @@ fn snapshot(
services: open
.services
.iter()
.map(|w| {
resolve_service(
.zip(open.known_key.iter().chain(std::iter::repeat(&false)))
.map(|(w, &known_key)| {
let mut service = resolve_service(
w,
open.trainer_type,
spells,
@@ -334,7 +395,14 @@ fn snapshot(
ask,
text,
get,
)
);
// The record keeps the -1 a list or a pass wrote, whatever its state now.
if known_key && service.group_key != TRAINER_GROUP_KNOWN {
service.group_key = TRAINER_GROUP_KNOWN;
service.group_name = get("KNOWN_TALENTS_HEADER").unwrap_or_default();
service.line_name = None;
}
service
})
.collect(),
// The engine builds the tree in `set_trainer` from each service's group key.
@@ -382,18 +450,16 @@ pub(crate) fn feed_trainer(
names: Res<NameCache>,
mut tooltip_subjects: ResMut<TrainerTooltipSubjects>,
mut last: crate::ui_script::WindowMemo<Option<TrainerState>>,
mut last_name: crate::ui_script::WindowMemo<Option<String>>,
mut closes: CoreCloses,
) {
let mut extra_passes = 0;
vm.feed(|script, mut re_eval, fires| {
let Some(script) = script else {
return;
};
let last = last.get(script);
let last_name = last_name.get(script);
if closes.since_last(script, NpcWindow::Trainer) {
*last = None;
*last_name = None;
}
// Refusals go to the log only, as the reference prints them only to its console.
for code in errors.0.drain(..) {
@@ -407,6 +473,9 @@ pub(crate) fn feed_trainer(
let Some(skill_lines) = skill_lines.as_deref() else {
return;
};
// The getters' item callbacks, kept by their calls; a record that answered one is a trigger.
open.item_waits.extend(script.take_trainer_item_waits());
open.item_answers(&items);
// The per-list reset goes in before the snapshot, so `TRAINER_SHOW` finds it; the stock
// window re-applies its saved filter only on its own `ADDON_LOADED`.
let listed = open.fresh_list;
@@ -441,14 +510,19 @@ pub(crate) fn feed_trainer(
open.trigger_re_derive();
}
}
// `0x4d7d40` over every row, then `TRAINER_UPDATE` unconditionally, as its tail fires it.
let forced = open.re_derive && open.trainer.is_some();
// `0x4d7d40` over every row, then `TRAINER_UPDATE` unconditionally, as its tail fires it,
// once per trigger.
let passes = if open.trainer.is_some() {
std::mem::take(&mut open.re_derive)
} else {
0
};
let forced = passes > 0;
// The stock window calls `GetTrainerServiceIcon` and `GetTrainerServiceDescription`, which
// look the product up with a callback (`0x4d9096`, `0x4d9d07`), at `TRAINER_SHOW` and
// `TRAINER_UPDATE`: a list, a re-derive, or a window this VM has not shown.
let ask = Ask::at(last.is_none() || listed || forced, &commands);
if forced {
open.re_derive = false;
let player = reeval::PlayerView {
known: actions.spells.clone(),
level: self_player
@@ -491,6 +565,7 @@ pub(crate) fn feed_trainer(
let TrainerOpen {
services,
trainer_type,
known_key,
..
} = &mut *open;
reeval::re_derive_all(
@@ -500,6 +575,12 @@ pub(crate) fn feed_trainer(
&skill_lines.catalog,
&player,
);
// `0x4d82b2`-`0x4d82c1`: a row the pass leaves known takes the -1 key at type 1.
if *trainer_type == TRAINER_TYPE_MOUNT {
for (key, wire) in known_key.iter_mut().zip(services.iter()) {
*key |= wire.state == trainer_spell_state::GRAY;
}
}
}
// The spell tooltip's description reads (`0x52f717`), and `0x6e3159`'s charm-else-summon
// for a `LEARN_PET_SPELL` row.
@@ -603,39 +684,54 @@ pub(crate) fn feed_trainer(
if *tooltip_subjects != fresh_tooltip_subjects {
*tooltip_subjects = fresh_tooltip_subjects;
}
// `TRAINER_SHOW` and `TRAINER_UPDATE` carry no argument (`SignalEvent` `0x4d7b73`,
// `0x4d83f9`) and the stock window titles with `UnitName("npc")`
// (`Blizzard_TrainerUI.lua:90`), so a name-only change re-fires `TRAINER_UPDATE` to repaint
// it.
let trainer_name = open.trainer.and_then(|g| {
// The stock window titles with `UnitName("npc")` (`Blizzard_TrainerUI.lua:90`), which
// looks the name up with a callback (`0x5171b8`) at its events; its landing fires nothing.
if let Some(g) = open.trainer {
let store = re_eval
.index
.0
.get(&g)
.and_then(|&e| re_eval.stores.get(e).ok());
names.resolve_unit(g, store, ask).map(str::to_string)
});
let name_changed = *last_name != trainer_name;
let _ = names.resolve_unit(g, store, ask);
}
// A list lays the rows out anew, so it pushes even when it matches the last.
if fresh == *last && !name_changed && !forced && !listed {
if fresh == *last && !forced && !listed {
return;
}
// The rows sort only where the reference finalizes them: a list (`0x4d7b3b`) and the
// state pass `0x4d7d40`; a name or icon that lands leaves their order (and their ties).
if listed || forced || last.is_none() {
// state pass `0x4d7d40`, which an item record a getter missed runs too (`0x4d914a`,
// `0x4d9dba`); a name, an icon or a record no getter asked for leaves their order (and
// their ties).
let shows = fresh.is_some() && (listed || last.is_none());
if shows || forced {
script.set_trainer(fresh.clone());
} else {
script.refresh_trainer(fresh.clone());
}
match (&*last, &fresh) {
(None, Some(_)) => fires.post("TRAINER_SHOW", vec![]),
(Some(_), Some(_)) => fires.post("TRAINER_UPDATE", vec![]),
// The core told the VM; a disconnect's clear tells it nothing.
(Some(_), None) | (None, None) => {}
// Each event has its own producers, neither carrying an argument: `TRAINER_SHOW` the list
// builder's (`0x4d7b73`), every list; `TRAINER_UPDATE` the state pass's (`0x4d83f9`). A
// repaint with neither fires nothing; the close is the core's.
if shows {
fires.post("TRAINER_SHOW", vec![]);
extra_passes = passes;
} else if forced && fresh.is_some() {
fires.post("TRAINER_UPDATE", vec![]);
extra_passes = passes - 1;
}
*last = fresh;
*last_name = trainer_name;
})
});
// The passes past the first, each its own sort from the order the last left and its own
// `TRAINER_UPDATE`, whose handlers run before the next pass sorts.
if extra_passes > 0 {
vm.run(|script| {
if let Some(script) = script {
for _ in 0..extra_passes {
script.rerun_trainer_finalize();
script.fire_event("TRAINER_UPDATE", vec![]);
}
}
});
}
}
/// The trainer's cell `[0xb73a00]`; `CloseTrainer` (`0x4d8d50`) is the core with a zero guid.
@@ -745,9 +841,11 @@ fn drain_trainer(mut hosted_vm: crate::script_calls::HostedVm, mut world_command
mod law;
use law::{
category, service_description, service_group, service_icon, service_kind, service_tooltip,
ServiceText,
category, service_description_waiting, service_group, service_icon_waiting, service_kind,
service_tooltip, ServiceText,
};
#[cfg(test)]
use law::{service_description, service_icon};
#[cfg(test)]
mod call_tests;
+4 -2
View File
@@ -1,8 +1,10 @@
//! The trainer's state re-evaluator, the reference's `0x4d7d40`: on any of its twelve triggers it
//! re-derives every service's state from the player's book, skills, level and pet, so the wire
//! state lasts only until the first. A skill line the player lacks entirely reads green where the
//! server sends red (`0x4d8082` → `0x4d8123`), as in the 1.12 client. Not built: at a type-1
//! trainer the reference also severs the group key of a row re-derived to state 2 (`0x4d82c1`).
//! server sends red (`0x4d8082` → `0x4d8123`), as in the 1.12 client. At a type-1 trainer the
//! pass also writes the known group's -1 into the key of every row it leaves in state 2
//! (`0x4d82b2`-`0x4d82c1`) and never writes it back, which the feed keeps
//! ([`super::TrainerOpen::known_key`]).
use std::collections::BTreeSet;
+286 -5
View File
@@ -511,8 +511,9 @@ fn category_maps_the_wire_state_byte() {
category(trainer_spell_state::GRAY),
TrainerServiceCategory::Used
);
// An unexpected value is gated, never learnable.
assert_eq!(category(99), TrainerServiceCategory::Unavailable);
// The re-evaluator's 3 and any other byte name a bit no filter sets (`0x4d84ba`, `0x4da390`).
assert_eq!(category(3), TrainerServiceCategory::Hidden);
assert_eq!(category(10), TrainerServiceCategory::Hidden);
}
#[test]
@@ -1252,17 +1253,17 @@ fn every_list_packet_begins_a_window_session() {
// A pending re-derivation never survives a list: the packet carries the server's own states.
open.fresh_list = false;
open.trigger_re_derive();
assert!(open.re_derive);
assert!(open.re_derive_owed());
open.open(TRAINER, 0, list(), "Greetings".into());
assert!(
!open.re_derive,
!open.re_derive_owed(),
"a fresh list is the server's states — nothing pending against it"
);
// A trigger with no window open does nothing (`0x4d7d46`'s early-out).
open.clear();
open.trigger_re_derive();
assert!(!open.re_derive);
assert!(!open.re_derive_owed());
}
mod re_derive {
@@ -1634,6 +1635,89 @@ mod re_derive {
assert_eq!(list[0].state, trainer_spell_state::GRAY);
assert_eq!(list[1].state, trainer_spell_state::GREEN);
}
/// A type-1 row re-derived to state 3 (`0x4d7e3e`) is listed under no filter: the filter map
/// `0x4da390` names bits 0-2 only, and the finalizer hides a row whose state bit is clear
/// (`0x4d84ba`). Its type string is still `"unavailable"` (`0x4d8ba0`).
#[test]
fn a_hidden_row_shows_under_no_filter() {
let mut app = super::feed_app(catalog(), skill_lines());
app.world_mut()
.resource_mut::<PlayerActions>()
.spells
.insert(RANK2);
app.world_mut().resource_mut::<TrainerOpen>().open(
0x10,
1,
vec![service(RANK1_WRAPPER)],
String::new(),
);
assert_eq!(super::tick(&mut app), ["TRAINER_SHOW"]);
let every_filter = "SetTrainerServiceTypeFilter('available', 1) \
SetTrainerServiceTypeFilter('unavailable', 1) \
SetTrainerServiceTypeFilter('used', 1) SEEN = {} \
return GetNumTrainerServices()";
assert_eq!(
super::lua_count(&mut app, every_filter),
2,
"the server's red row and its header"
);
app.world_mut()
.resource_mut::<TrainerOpen>()
.trigger_re_derive();
assert_eq!(super::tick(&mut app), ["TRAINER_UPDATE"]);
assert_eq!(super::lua_count(&mut app, every_filter), 0);
let kinds = app
.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<Vec<String>>(
"local out = {} local i = 1 while true do local _, _, t = GetTrainerServiceInfo(i) \
if t == nil then break end table.insert(out, t) i = i + 1 end return out",
)
.unwrap();
assert!(kinds.contains(&"unavailable".to_string()), "{kinds:?}");
}
/// The list files a row the server sent known into the known group, key -1 (`0x4d77e8`), and
/// the state pass writes -1 into every row it leaves known (`0x4d82b2`-`0x4d82c1`), never back:
/// a known row re-derived to learnable stays under the known header until the next list.
#[test]
fn a_row_keyed_known_keeps_the_key_until_the_next_list() {
let mut app = super::feed_app(catalog(), skill_lines());
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("KNOWN_TALENTS_HEADER = 'My Talents'")
.unwrap();
let mut known = service(RANK1_WRAPPER);
known.state = trainer_spell_state::GRAY;
let list = |app: &mut App, row: TrainerSpell| {
app.world_mut()
.resource_mut::<TrainerOpen>()
.open(0x10, 1, vec![row], String::new());
};
let header = |app: &mut App| -> String {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<String>(
"SetTrainerServiceTypeFilter('all') SEEN = {} \
return (GetTrainerServiceInfo(1))",
)
.unwrap()
};
list(&mut app, known);
assert_eq!(super::tick(&mut app), ["TRAINER_SHOW"]);
assert_eq!(header(&mut app), "My Talents");
// RANK1 is not in the book: the pass reads it learnable, and the key stays -1.
app.world_mut()
.resource_mut::<TrainerOpen>()
.trigger_re_derive();
assert_eq!(super::tick(&mut app), ["TRAINER_UPDATE"]);
assert_eq!(header(&mut app), "My Talents");
// The next list files it by its state again.
list(&mut app, service(RANK1_WRAPPER));
assert_eq!(super::tick(&mut app), ["TRAINER_SHOW"]);
assert_eq!(header(&mut app), "Skill 1");
}
}
/// A mistyped key yields an empty header, which [`probe_strings`] cannot catch. Skips without
@@ -1955,3 +2039,200 @@ fn the_trainer_rows_sort_only_at_a_list_and_a_re_derive() {
"the re-derive sorts from the order the list left"
);
}
/// The real [`feed_trainer`] over a VM that records `TRAINER_SHOW` and `TRAINER_UPDATE` in
/// `SEEN`, with `spells` and `lines` loaded.
fn feed_app(spells: SpellCatalog, lines: SkillLineCatalog) -> App {
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_message::<crate::net::FieldChanged>()
.insert_resource(NetCommands(tx))
.init_resource::<TrainerOpen>()
.init_resource::<TrainerErrors>()
.init_resource::<TrainerTooltipSubjects>()
.init_resource::<PlayerActions>()
.init_resource::<Items>()
.init_resource::<NameCache>()
.init_resource::<GuidIndex>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::spell::SpellModifiers>()
.insert_resource(Spells {
catalog: spells,
..Spells::empty_for_tests()
})
.insert_resource(SkillLines { catalog: lines })
.add_systems(Update, feed_trainer);
let script = UiScript::new().unwrap();
script
.run(
"SEEN = {} local f = CreateFrame('Frame') f:RegisterEvent('TRAINER_SHOW') \
f:RegisterEvent('TRAINER_UPDATE') \
f:SetScript('OnEvent', function() table.insert(SEEN, event) end)",
)
.unwrap();
app.insert_non_send_resource(script);
app
}
/// One frame, then the trainer events the VM saw.
fn tick(app: &mut App) -> Vec<String> {
app.update();
let s = app.world_mut().non_send_resource_mut::<UiScript>();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let seen = s.eval::<Vec<String>>("return SEEN").unwrap();
s.run("SEEN = {}").unwrap();
seen
}
/// `chunk`'s number.
fn lua_count(app: &mut App, chunk: &str) -> i64 {
let s = app.world_mut().non_send_resource_mut::<UiScript>();
s.eval::<i64>(chunk).unwrap()
}
/// `chunk`'s list of numbers.
fn lua_numbers(app: &mut App, chunk: &str) -> Vec<i64> {
let s = app.world_mut().non_send_resource_mut::<UiScript>();
s.eval::<Vec<i64>>(chunk).unwrap()
}
/// `TRAINER_SHOW` fires from the list builder alone (`0x4d7b73`), so the open trainer's second
/// list fires it again; `TRAINER_UPDATE` from the state pass (`0x4d83f9`) and the mask commits
/// (`0x4d8ca0`-`0x4d8ce0`) alone. The trainer's name landing fires neither.
#[test]
fn the_trainer_events_fire_only_from_their_producers() {
const TRAINER: u64 = 0x10;
let mut app = feed_app(
icon_catalog(),
benilla_formats::SkillLineCatalog::from_spell_lines([]),
);
let list = |app: &mut App| {
app.world_mut().resource_mut::<TrainerOpen>().open(
TRAINER,
TRAINER_TYPE_TRADESKILL,
vec![wire(301, trainer_spell_state::GREEN, 1, 1, 0)],
String::new(),
);
};
list(&mut app);
assert_eq!(tick(&mut app), ["TRAINER_SHOW"]);
app.world_mut()
.resource_mut::<NameCache>()
.insert_player(TRAINER, "Trainer".into(), None);
assert!(tick(&mut app).is_empty(), "a landed name fires nothing");
list(&mut app);
assert_eq!(tick(&mut app), ["TRAINER_SHOW"], "every list shows");
app.world_mut()
.resource_mut::<TrainerOpen>()
.trigger_re_derive();
assert_eq!(tick(&mut app), ["TRAINER_UPDATE"]);
assert!(tick(&mut app).is_empty());
}
/// The rows' order, each by its cost (a header as 0), over the whole array.
fn costs(app: &mut App, rows: usize) -> Vec<i64> {
lua_numbers(
app,
&format!(
"local out = {{}} for i = 1, {rows} do out[i] = (GetTrainerServiceCost(i)) end \
return out"
),
)
}
/// An item record that a getter's lookup missed lands: the callback that lookup registered
/// (`0x4d9140` from `GetTrainerServiceIcon`, `0x4d907e`) runs the state pass `0x4d7d40`
/// (`0x4d914a`), which re-derives, sorts from the order the last pass left and fires
/// `TRAINER_UPDATE`. A record no getter call asked for runs nothing.
#[test]
fn an_item_record_a_getter_missed_runs_the_state_pass() {
const TRAINER: u64 = 0x10;
let open = |app: &mut App| {
// Wrapper 100's product, item 777, is asked for its icon; the rest have no record, so
// every row ties (`0x4d87cd`) and lands where the sort's history puts it.
let mut services = vec![wire(100, trainer_spell_state::GREEN, 5, 1, 0)];
services
.extend((1..=4).map(|cost| wire(300 + cost, trainer_spell_state::GREEN, cost, 1, 0)));
app.world_mut().resource_mut::<TrainerOpen>().open(
TRAINER,
TRAINER_TYPE_TRADESKILL,
services,
String::new(),
);
};
let land = |app: &mut App| {
app.world_mut()
.resource_mut::<Items>()
.insert_template(777, Some(crate::items::test_template("Copper Shortsword")));
};
// No getter asked: the landing is silent and moves nothing.
let mut app = feed_app(
icon_catalog(),
benilla_formats::SkillLineCatalog::from_spell_lines([]),
);
open(&mut app);
assert_eq!(tick(&mut app), ["TRAINER_SHOW"]);
let listed = costs(&mut app, 6);
land(&mut app);
assert!(tick(&mut app).is_empty(), "no callback, no pass");
assert_eq!(costs(&mut app, 6), listed);
// The stock window's detail pane asks the row's icon: the landing runs the pass.
let mut app = feed_app(
icon_catalog(),
benilla_formats::SkillLineCatalog::from_spell_lines([]),
);
open(&mut app);
assert_eq!(tick(&mut app), ["TRAINER_SHOW"]);
assert_eq!(costs(&mut app, 6), listed);
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("for i = 1, 6 do GetTrainerServiceIcon(i) end")
.unwrap();
assert!(tick(&mut app).is_empty(), "the record is still out");
land(&mut app);
assert_eq!(tick(&mut app), ["TRAINER_UPDATE"]);
assert_ne!(
costs(&mut app, 6),
listed,
"the pass sorts from the order the list left"
);
assert!(tick(&mut app).is_empty(), "the callback is spent");
}
/// `0x4d7d40` runs, and fires `TRAINER_UPDATE` (`0x4d83f9`), once per trigger: two triggers in
/// one frame are two passes, and a record two getter calls missed is two callbacks (`0x4d907e`,
/// dedupe flag 0), so two passes when it lands.
#[test]
fn the_state_pass_runs_once_per_trigger_and_per_callback() {
let mut app = feed_app(
icon_catalog(),
benilla_formats::SkillLineCatalog::from_spell_lines([]),
);
app.world_mut().resource_mut::<TrainerOpen>().open(
0x10,
TRAINER_TYPE_TRADESKILL,
vec![wire(100, trainer_spell_state::GREEN, 5, 1, 0)],
String::new(),
);
assert_eq!(tick(&mut app), ["TRAINER_SHOW"]);
{
let mut open = app.world_mut().resource_mut::<TrainerOpen>();
open.trigger_re_derive();
open.trigger_re_derive();
}
assert_eq!(tick(&mut app), ["TRAINER_UPDATE", "TRAINER_UPDATE"]);
assert!(tick(&mut app).is_empty());
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("for i = 1, 2 do GetTrainerServiceIcon(i) end GetTrainerServiceIcon(2)")
.unwrap();
assert!(tick(&mut app).is_empty(), "the record is still out");
app.world_mut()
.resource_mut::<Items>()
.insert_template(777, Some(crate::items::test_template("Copper Shortsword")));
assert_eq!(tick(&mut app), ["TRAINER_UPDATE", "TRAINER_UPDATE"]);
assert!(tick(&mut app).is_empty(), "the callbacks are spent");
}
+9 -3
View File
@@ -829,8 +829,10 @@ pub(crate) struct Model {
pub(crate) trainer: Option<trainer::TrainerState>,
pub(crate) trainer_selection: Option<u32>,
pub(crate) trainer_close: bool,
/// Available, unavailable and used filters, all on by default; they hide services, not headers.
pub(crate) trainer_filter: [bool; 3],
/// The state filter `ds:0xb73a1c`: bit n set shows the services in state n.
pub(crate) trainer_filter: u32,
/// The item records the trainer getters' calls missed, one per call, until the app takes them.
pub(crate) trainer_item_waits: Vec<u32>,
/// Collapsed skill-line ids, kept across a re-push and cleared when the trainer closes.
pub(crate) trainer_collapsed: HashSet<u32>,
/// The skill-line filter `ds:0xb73a20`: bit n set shows header group n, every bit on a list.
@@ -978,6 +980,8 @@ pub(crate) struct Model {
/// The open questgiver panel, and whether `CloseQuest` ran.
pub(crate) quest: Option<quest::QuestState>,
/// The item records `GetQuestItemInfo`'s calls missed, one per call, until the app takes them.
pub(crate) quest_item_waits: Vec<u32>,
pub(crate) quest_close: bool,
/// The levels of the last greeting's two pools (`0xbde690`, `0xbdfe60`), which outlive the
/// panel until the next list fills them.
@@ -1664,7 +1668,8 @@ impl Model {
trainer: None,
trainer_selection: None,
trainer_close: false,
trainer_filter: [true; 3],
trainer_filter: 7,
trainer_item_waits: Vec::new(),
trainer_collapsed: HashSet::new(),
trainer_line_filter: u32::MAX,
trainer_rows: Vec::new(),
@@ -1728,6 +1733,7 @@ impl Model {
trade_place_seq: 0,
trade_offered: Default::default(),
quest: None,
quest_item_waits: Vec::new(),
quest_pool_levels: Default::default(),
quest_close: false,
death: death::DeathUiState::default(),
+18
View File
@@ -163,6 +163,13 @@ pub(super) fn submit(lua: &Lua, call: QuestCall) -> mlua::Result<()> {
}
impl super::UiScript {
/// The item records `GetQuestItemInfo`'s calls missed since the last take, one per call: the
/// callbacks `0x5018c0` the item cache keeps (dedupe flag 0, `0x5017ca`), each of which fires
/// `QUEST_ITEM_UPDATE` when its record lands (`0x5018fe`-`0x501903`).
pub fn take_quest_item_waits(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().quest_item_waits)
}
/// Push (or clear, with `None`) the open questgiver panel snapshot.
///
/// A greeting also refills the two level pools, which stay when the panel closes or another
@@ -399,6 +406,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
};
list.get(row as usize).cloned()
};
// The lookup (`0x5017de`) of a template in flight keeps the callback `0x5018c0` on it,
// one per call; item id 0 is never looked up.
if let Some(it) = item
.as_ref()
.filter(|it| it.name.is_none() && it.item_id != 0)
{
lua.app_data_mut::<Model>()
.expect("model app_data")
.quest_item_waits
.push(it.item_id);
}
let (name, texture, count, quality, usable) = match item {
Some(it) => {
// `isUsable` seeds 1 (`0x50172e`) and the item-usable predicate clears it for
+142 -48
View File
@@ -9,54 +9,64 @@ use mlua::{Lua, MultiValue, Value};
use super::binding_abi;
use super::Model;
/// A service's state (the wire's `TrainerSpellState`): green learnable, red gated, gray known.
/// A service's state byte `[+0x30]` (the wire's `TrainerSpellState`): green learnable, red gated,
/// gray known, and the state the re-evaluator writes at a mount trainer for a rank the player has
/// passed (`0x4d7e3e`, `0x4d83a4`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TrainerServiceCategory {
#[default]
Available,
Unavailable,
Used,
/// State 3: no filter shows it, as `SetTrainerServiceTypeFilter` names bits 0-2 only
/// (`0x4da390`).
Hidden,
}
impl TrainerServiceCategory {
/// `GetTrainerServiceInfo`'s type string, which the stock window colours each row by.
/// `GetTrainerServiceInfo`'s type string, which the stock window colours each row by: every
/// state but 0 and 2 reads `"unavailable"` (`0x4d8ba0`).
fn era_str(self) -> &'static str {
match self {
TrainerServiceCategory::Available => "available",
TrainerServiceCategory::Unavailable => "unavailable",
TrainerServiceCategory::Unavailable | TrainerServiceCategory::Hidden => "unavailable",
TrainerServiceCategory::Used => "used",
}
}
/// The filter-flag slot this category occupies ([`Model::trainer_filter`]).
fn filter_slot(self) -> usize {
match self {
TrainerServiceCategory::Available => 0,
TrainerServiceCategory::Unavailable => 1,
TrainerServiceCategory::Used => 2,
}
}
/// The record's state byte `[+0x30]`, which `0x4d8ba0` maps to the type string; at type 1 it is
/// also a sort key ([`talent_order`]), so it stays apart from the equal filter slot.
/// also a sort key ([`talent_order`]).
fn state_key(self) -> u8 {
match self {
TrainerServiceCategory::Available => 0,
TrainerServiceCategory::Unavailable => 1,
TrainerServiceCategory::Used => 2,
TrainerServiceCategory::Hidden => 3,
}
}
fn from_filter_str(s: &str) -> Option<Self> {
match s {
"available" => Some(TrainerServiceCategory::Available),
"unavailable" => Some(TrainerServiceCategory::Unavailable),
"used" => Some(TrainerServiceCategory::Used),
_ => None,
}
/// The state filter's bit for this state (`0x4d84b0`-`0x4d84ba`: `1 << [+0x30]`).
fn filter_bit(self) -> u32 {
1 << self.state_key()
}
}
/// `0x4da390`: the filter bit a type name names, compared without case (`0x64a4c0`); `None` is
/// its 6, another name.
fn filter_bit_named(name: &str) -> Option<u32> {
[
TrainerServiceCategory::Available,
TrainerServiceCategory::Unavailable,
TrainerServiceCategory::Used,
]
.into_iter()
.find(|c| crate::crt::str_cmp_i(name, c.era_str()) == 0)
.map(TrainerServiceCategory::filter_bit)
}
/// The state mask `"all"` sets (`0x4da2a9`): the three states a filter can name.
const TRAINER_FILTER_ALL: u32 = 7;
/// A service's skill requirement (`GetTrainerServiceSkillReq`). The wire has no per-gate bit, so
/// `met` comes from the service's `category`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@@ -118,8 +128,14 @@ pub struct TrainerService {
/// record and answers nil (`0x4d8b58`).
pub subtext: Option<String>,
pub texture: Option<String>,
/// The item whose record the icon's product lookup (`0x4d9096`) misses: each
/// `GetTrainerServiceIcon` call on the row keeps the callback `0x4d9140` on it.
pub icon_wait: Option<u32>,
/// The expanded text; `None` answers nil (`0x4d9d90`).
pub description: Option<String>,
/// The item whose record the description's product lookup (`0x4d9d07`) misses: each
/// `GetTrainerServiceDescription` call on the row keeps the callback `0x4d9db0` on it.
pub description_wait: Option<u32>,
/// In copper, already reputation-discounted by the server.
pub cost: u32,
/// A primary profession's first rank: the stock window's `cpCost2 > 0`, which asks to confirm.
@@ -192,6 +208,12 @@ impl super::UiScript {
self.push_trainer(state, true);
}
/// One more pass of the state re-evaluator's tail over the rows as they stand: the finalizer
/// `0x4d8410`, which sorts from the order the last pass left (`0x4d83ef`).
pub fn rerun_trainer_finalize(&mut self) {
finalize(&mut self.model_mut());
}
/// Push the open trainer's new text or icons with no finalize: the rows keep their order and
/// their visible count until the reference's own triggers re-sort them.
pub fn refresh_trainer(&mut self, state: Option<TrainerState>) {
@@ -199,6 +221,12 @@ impl super::UiScript {
}
fn push_trainer(&mut self, state: Option<TrainerState>, finalizes: bool) {
// The known group's header text, which `0x4d8b11` reads by name.
let known_header = self
.lua()
.globals()
.get::<String>("KNOWN_TALENTS_HEADER")
.unwrap_or_default();
let mut model = self.model_mut();
match state {
None => {
@@ -210,7 +238,7 @@ impl super::UiScript {
model.trainer_rows_fresh = false;
}
Some(mut s) => {
s.groups = build_groups(&s.services, s.trainer_type);
s.groups = build_groups(&s.services, s.trainer_type, &known_header);
let live: std::collections::HashSet<u32> = s.groups.iter().map(|g| g.key).collect();
model.trainer_collapsed.retain(|sl| live.contains(sl));
let model = &mut *model;
@@ -239,9 +267,9 @@ impl super::UiScript {
let mut model = self.model_mut();
// Mask 5 at a mount trainer shows available and known services, so the known group shows.
model.trainer_filter = if trainer_type == TRAINER_TYPE_MOUNT {
[true, false, true]
5
} else {
[true, true, false]
3
};
model.trainer_collapsed.clear();
// The skill-line filter resets to every group with the list (`0x4d7560`).
@@ -252,6 +280,13 @@ impl super::UiScript {
model.trainer_rows_fresh = true;
}
/// The item records the icon and description getters' calls missed since the last take, one
/// per call: the callbacks `0x4d9140` and `0x4d9db0` the item cache keeps (dedupe flag 0,
/// `0x4d9076`, `0x4d9ce7`), each of which runs `0x4d7d40` when its record lands.
pub fn take_trainer_item_waits(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().trainer_item_waits)
}
/// Whether `CloseTrainer` was called since the last drain; the close sends no packet.
pub fn take_trainer_close(&mut self) -> bool {
std::mem::take(&mut self.model_mut().trainer_close)
@@ -372,9 +407,22 @@ fn talent_order(a: &TrainerService, b: &TrainerService) -> i32 {
/// Group the services by [`TrainerService::group_key`], dropping key 0, each header made where its
/// key is first met in list order (`0x4d78a3`-`0x4d79c3`), then the header array sorted by the
/// CRT `qsort` (`0x73f727`, called at `0x4d7a0e`): by raw key at type 2 (`0x4d7c30`), else
/// `0x4d7b90`. The rows themselves sort in [`finalize`].
fn build_groups(services: &[TrainerService], trainer_type: u32) -> Vec<TrainerGroup> {
/// `0x4d7b90`. A mount trainer's known group, headed `known_header`, is made first and always,
/// member or none (`0x4d7609`-`0x4d7697`). The rows themselves sort in [`finalize`].
fn build_groups(
services: &[TrainerService],
trainer_type: u32,
known_header: &str,
) -> Vec<TrainerGroup> {
let mut groups: Vec<TrainerGroup> = Vec::new();
if trainer_type == TRAINER_TYPE_MOUNT {
groups.push(TrainerGroup {
key: TRAINER_GROUP_KNOWN,
name: known_header.to_string(),
line_name: None,
services: Vec::new(),
});
}
for (i, s) in services.iter().enumerate() {
if s.group_key == 0 {
continue;
@@ -466,7 +514,7 @@ fn finalize(model: &mut Model) {
group_of[si] = gi;
}
}
let passes = |si: usize| model.trainer_filter[t.services[si].category.filter_slot()];
let passes = |si: usize| model.trainer_filter & t.services[si].category.filter_bit() != 0;
let group_shown: Vec<bool> = t
.groups
.iter()
@@ -702,8 +750,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, index: Value| {
// `is-number` else `Usage: GetTrainerServiceIcon(index)` (`0x84a8cc`, `0x4d8f81`).
let index = binding_abi::index_arg(lua, index, "Usage: GetTrainerServiceIcon(index)")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
opt_str(lua, service(&model, index).and_then(|s| s.texture.as_ref()))
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let (texture, wait) = service(&model, index)
.map(|s| (s.texture.clone(), s.icon_wait))
.unwrap_or_default();
// The miss keeps its callback (`0x4d907e`) and pushes nil (`0x4d90a4`).
model.trainer_item_waits.extend(wait);
opt_str(lua, texture.as_ref())
})?,
)?;
@@ -714,11 +767,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `0x4d9b71`).
let index =
binding_abi::index_arg(lua, index, "Usage: GetTrainerServiceDescription(index)")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
opt_str(
lua,
service(&model, index).and_then(|s| s.description.as_ref()),
)
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let (description, wait) = service(&model, index)
.map(|s| (s.description.clone(), s.description_wait))
.unwrap_or_default();
// The miss keeps its callback (`0x4d9cef`).
model.trainer_item_waits.extend(wait);
opt_str(lua, description.as_ref())
})?,
)?;
@@ -889,28 +944,67 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetTrainerServiceTypeFilter("type") (`0x4da510`): 1 when the named state shows, else nil;
// the name through `0x4da390`, without case, and `"all"` is no name here.
g.set(
"GetTrainerServiceTypeFilter",
lua.create_function(|lua, kind: String| {
lua.create_function(|lua, kind: Value| {
// `is-number-or-string` else the usage (`0x84ac78`, `0x4da521`).
let kind = binding_abi::string_arg(
lua,
kind,
r#"Usage: GetTrainerServiceTypeFilter("type")"#,
)?;
let bit = filter_bit_named(&kind).ok_or_else(|| {
mlua::Error::runtime("Bad service type in GetTrainerServiceTypeFilter")
})?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match TrainerServiceCategory::from_filter_str(&kind) {
Some(c) => binding_abi::flag(model.trainer_filter[c.filter_slot()]),
None => Value::Nil,
})
Ok(binding_abi::flag(model.trainer_filter & bit != 0))
})?,
)?;
// SetTrainerServiceTypeFilter("type", on [, exclusive]) (`0x4da260`): `"all"` sets the mask to
// 7 and reads nothing more (`0x4da2a9`); otherwise the named bit, cleared by an `on` that
// truncates to 0 (`0x4da31a`), the whole mask for a third argument that is a number truncating
// to nonzero (`0x4da359`), else added (`0x4da36f`). Every path commits through `0x4d8c90`: the
// finalizer, then `TRAINER_UPDATE`, also when the mask did not move.
g.set(
"SetTrainerServiceTypeFilter",
lua.create_function(|lua, (kind, on): (String, Value)| {
if let Some(c) = TrainerServiceCategory::from_filter_str(&kind) {
// The stock window passes 1 or 0; nil, false and 0 disable.
let enable = !matches!(on, Value::Nil | Value::Integer(0) | Value::Boolean(false));
super::tick::verb(lua, |model| {
model.trainer_filter[c.filter_slot()] = enable;
// Fires even when the bit did not move, as the reference's setter does.
raise_trainer_update(model);
});
}
lua.create_function(|lua, (kind, on, exclusive): (Value, Value, Value)| {
// `is-number-or-string` else the usage (`0x84ab60`, `0x4da273`).
let kind = binding_abi::string_arg(
lua,
kind,
r#"Usage: SetTrainerServiceTypeFilter("type" [, on\off, exclusive])"#,
)?;
let mask = if crate::crt::str_cmp_i(&kind, "all") == 0 {
TRAINER_FILTER_ALL
} else {
let bit = filter_bit_named(&kind).ok_or_else(|| {
mlua::Error::runtime("Bad service type in SetTrainerServiceTypeFilter")
})?;
let on = binding_abi::number_arg(
lua,
on,
"Missing on//off parameter in SetTrainerServiceTypeFilter",
)?;
let current = lua
.app_data_ref::<Model>()
.expect("model app_data")
.trainer_filter;
if on == 0 {
current & !bit
} else if binding_abi::is_number(lua, exclusive)
.is_some_and(|n| binding_abi::ftol(n) != 0)
{
bit
} else {
current | bit
}
};
super::tick::verb(lua, |model| {
model.trainer_filter = mask;
raise_trainer_update(model);
});
Ok(())
})?,
)?;
@@ -19,7 +19,9 @@ fn svc(
name: Some(name.into()),
subtext: Some("Rank 1".into()),
texture: Some(format!("Interface\\Icons\\Spell_{spell_id}")),
icon_wait: None,
description: Some(format!("Teaches {name}.")),
description_wait: None,
cost: 100,
prof_first_rank: false,
category: cat,
@@ -778,6 +780,8 @@ fn mount_trainer_folds_known_services_into_my_talents() {
),
];
let mut s = UiScript::new().unwrap();
// The header reads its text by name (`0x4d8b11`), not off a service.
s.run("KNOWN_TALENTS_HEADER = 'My Talents'").unwrap();
s.set_trainer(Some(TrainerState {
guid: 0,
greeting: "Ready to ride?".into(),
@@ -1218,3 +1222,217 @@ fn ability_reqs_read_their_slots_in_place() {
assert_eq!(req(&mut s, 3), (None, Some(1)));
assert_eq!(req(&mut s, 4), (None, Some(1)));
}
/// The state filter as `(available, unavailable, used)`.
fn type_filter(s: &mut UiScript) -> (bool, bool, bool) {
let on = |s: &mut UiScript, kind: &str| {
s.eval::<Option<i64>>(&format!("return GetTrainerServiceTypeFilter('{kind}')"))
.unwrap()
.is_some()
};
(on(s, "available"), on(s, "unavailable"), on(s, "used"))
}
/// `SetTrainerServiceTypeFilter("type", on [, exclusive])` (`0x4da260`): `"all"` sets the mask to
/// 7 and reads no second argument (`0x4da2a9`); a nonzero number third makes the one bit the whole
/// mask (`0x4da359`-`0x4da364`); `on` and `exclusive` are truncated (`0x40a2b0`), so 0.5 is off;
/// both names compare without case (`0x64a4c0`, `0x4da390`). Every path commits through
/// `0x4d8c90`, which fires `TRAINER_UPDATE`.
#[test]
fn the_type_filter_takes_all_the_exclusive_mode_and_a_truncated_on() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
s.reset_trainer_list_state(0);
s.run(
"n = 0 local f = CreateFrame('Frame') f:RegisterEvent('TRAINER_UPDATE') \
f:SetScript('OnEvent', function() n = n + 1 end)",
)
.unwrap();
assert_eq!(
type_filter(&mut s),
(true, true, false),
"the list's mask 3"
);
s.run("SetTrainerServiceTypeFilter('all')").unwrap();
assert_eq!(type_filter(&mut s), (true, true, true));
assert_eq!(visible(&mut s).len(), 5, "every row shows");
assert_eq!(s.eval::<i64>("return n").unwrap(), 1, "the commit fires");
s.run("SetTrainerServiceTypeFilter('used', 1, 1)").unwrap();
assert_eq!(type_filter(&mut s), (false, false, true), "exclusive");
s.run("SetTrainerServiceTypeFilter('ALL', 0)").unwrap();
assert_eq!(
type_filter(&mut s),
(true, true, true),
"\"all\" without case, its second argument never read"
);
s.run("SetTrainerServiceTypeFilter('Available', 0.5)")
.unwrap();
assert_eq!(
type_filter(&mut s),
(false, true, true),
"0.5 truncates to 0"
);
s.run("SetTrainerServiceTypeFilter('available', '1', 0.5)")
.unwrap();
assert_eq!(
type_filter(&mut s),
(true, true, true),
"a numeric string is a number, and 0.5 exclusive truncates to 0: added"
);
s.run("SetTrainerServiceTypeFilter('unavailable', -0.9)")
.unwrap();
assert_eq!(
type_filter(&mut s),
(true, false, true),
"-0.9 truncates to 0"
);
s.run("SetTrainerServiceTypeFilter('unavailable', 2, true)")
.unwrap();
assert_eq!(
type_filter(&mut s),
(true, true, true),
"a boolean third argument is no number: added"
);
s.run("SetTrainerServiceTypeFilter('used', 1, '1')")
.unwrap();
assert_eq!(type_filter(&mut s), (false, false, true));
assert_eq!(s.eval::<i64>("return n").unwrap(), 8);
}
/// `0x4da260`'s three refusals, in its order and words (`0x84ab60`, `0x84ab2c`, `0x84aaf0`): the
/// name must be a string or a number (`0x6f3510`), then one the mapper `0x4da390` knows, then `on`
/// must be a number (`0x6f34d0`). A refusal commits nothing and fires nothing.
#[test]
fn the_type_filter_raises_the_exes_errors() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
s.reset_trainer_list_state(0);
s.run(
"n = 0 local f = CreateFrame('Frame') f:RegisterEvent('TRAINER_UPDATE') \
f:SetScript('OnEvent', function() n = n + 1 end)",
)
.unwrap();
let raises = |s: &mut UiScript, call: &str, text: &str| {
let err = s.run(call).expect_err(call).to_string();
assert!(err.contains(text), "{call}: {err}");
};
let usage = r#"Usage: SetTrainerServiceTypeFilter("type" [, on\off, exclusive])"#;
let bad = "Bad service type in SetTrainerServiceTypeFilter";
let missing = "Missing on//off parameter in SetTrainerServiceTypeFilter";
raises(&mut s, "SetTrainerServiceTypeFilter()", usage);
raises(&mut s, "SetTrainerServiceTypeFilter(nil, 1)", usage);
raises(&mut s, "SetTrainerServiceTypeFilter({}, 1)", usage);
raises(&mut s, "SetTrainerServiceTypeFilter('bogus', 1)", bad);
raises(&mut s, "SetTrainerServiceTypeFilter('bogus')", bad);
raises(&mut s, "SetTrainerServiceTypeFilter(1, 1)", bad);
raises(&mut s, "SetTrainerServiceTypeFilter('used')", missing);
raises(&mut s, "SetTrainerServiceTypeFilter('used', true)", missing);
raises(&mut s, "SetTrainerServiceTypeFilter('used', 'x')", missing);
assert_eq!(type_filter(&mut s), (true, true, false), "nothing moved");
assert_eq!(s.eval::<i64>("return n").unwrap(), 0, "nothing fired");
}
/// At a mount trainer the list builds the known group first, header 0 with key -1
/// (`0x4d7609`-`0x4d7697`), whether or not a service is known: with no member its header is
/// hidden (`0x4d8447`), but it is a skill line, the first, named by `KNOWN_TALENTS_HEADER`
/// (`0x4d8b11`) and with no `SkillLine.dbc` row.
#[test]
fn a_mount_trainer_always_builds_the_known_group_first() {
let mut s = UiScript::new().unwrap();
s.run("KNOWN_TALENTS_HEADER = 'My Talents'").unwrap();
s.set_trainer(Some(TrainerState {
guid: 0,
greeting: String::new(),
trainer_type: 1,
groups: Vec::new(),
services: vec![
svc(
33,
"Riding",
TrainerServiceCategory::Available,
762,
"Riding",
),
svc(
8394,
"Ram Riding",
TrainerServiceCategory::Available,
152,
"Ram Riding",
),
],
}));
let (n, first, second, third): (i64, Option<String>, Option<String>, Option<String>) = s
.eval(
"local n = function(...) return arg.n, arg[1], arg[2], arg[3] end \
return n(GetTrainerSkillLines())",
)
.unwrap();
assert_eq!(
(n, first, second.as_deref(), third.as_deref()),
(3, None, Some("Ram Riding"), Some("Riding"))
);
assert_eq!(
visible(&mut s)
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>(),
["Ram Riding", "Ram Riding", "Riding", "Riding"],
"the empty known group shows no header"
);
// The hidden header sits in the array's tail, past the visible count.
let (name, kind): (String, String) = s
.eval("local n, _, t = GetTrainerServiceInfo(5) return n, t")
.unwrap();
assert_eq!((name.as_str(), kind.as_str()), ("My Talents", "header"));
// The skill-line indices count it: group 2 is Ram Riding.
s.run("SetTrainerSkillLineFilter(2, 1, 1)").unwrap();
assert_eq!(
visible(&mut s)
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>(),
["Ram Riding", "Ram Riding"]
);
assert!(s
.eval::<Option<i64>>("return GetTrainerSkillLineFilter(3)")
.unwrap()
.is_none());
// A class trainer builds no such group.
s.set_trainer(Some(trainer()));
assert_eq!(
s.eval::<i64>("local n = function(...) return arg.n end return n(GetTrainerSkillLines())")
.unwrap(),
2
);
}
/// `GetTrainerServiceTypeFilter("type")` (`0x4da510`) maps the name through `0x4da390`, without
/// case, and raises its two errors: a non-string first (`0x84ac78`), an unknown name (`0x84ac48`).
#[test]
fn the_type_filter_getter_reads_any_case_and_raises_the_exes_errors() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
s.reset_trainer_list_state(0);
assert_eq!(
s.eval::<(Option<i64>, Option<i64>)>(
"return GetTrainerServiceTypeFilter('AVAILABLE'), GetTrainerServiceTypeFilter('Used')"
)
.unwrap(),
(Some(1), None)
);
let raises = |s: &mut UiScript, call: &str, text: &str| {
let err = s.run(call).expect_err(call).to_string();
assert!(err.contains(text), "{call}: {err}");
};
let usage = r#"Usage: GetTrainerServiceTypeFilter("type")"#;
let bad = "Bad service type in GetTrainerServiceTypeFilter";
raises(&mut s, "GetTrainerServiceTypeFilter()", usage);
raises(&mut s, "GetTrainerServiceTypeFilter({})", usage);
raises(&mut s, "GetTrainerServiceTypeFilter('all')", bad);
raises(&mut s, "GetTrainerServiceTypeFilter(1)", bad);
}
+8 -4
View File
@@ -29,7 +29,7 @@ A setting boots at the reference's default unless it is listed here (`docs/METHO
Each entry is the first sentence of a `Deviation:` comment, linking to where it lives.
- [benilla-app](#benilla-app): 245
- [benilla-app](#benilla-app): 246
- [benilla-assets](#benilla-assets): 6
- [benilla-blp](#benilla-blp): 1
- [benilla-formats](#benilla-formats): 22
@@ -481,6 +481,10 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- An out-of-range slot is dropped, where the reference indexes its 7-slot mirror unchecked, because an out-of-bounds write is not a behaviour to reproduce. ([`src/ui_trade/mod.rs:441`](../crates/benilla-app/src/ui_trade/mod.rs#L441))
#### ui_trainer
- Benilla hides every state from 3 up. The reference shows bytes 32 and up, because x86 `shl` wraps its count (bits 32-34 alias 0-2) and its group counter `[hdr+4+state*4]` (`0x4d7854`) runs past the header. ([`src/ui_trainer/law.rs:280`](../crates/benilla-app/src/ui_trainer/law.rs#L280))
#### video
- `gxWindow "0"` is borderless fullscreen, not 1.12's exclusive mode-set at `gxResolution`, because Wayland has no client mode-setting (winit ignores `Fullscreen::Exclusive` there), X11's XRandR changes the desktop's mode and a crash leaves it changed, and macOS has no exclusive mode. ([`src/video.rs:5`](../crates/benilla-app/src/video.rs#L5))
@@ -793,9 +797,9 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- The reference recurses without a bound (`0x52dbce` builds the product whatever the depth), so a product that teaches a recipe for itself would recurse until the stack overflows; benilla stops here, so a cyclic `Spell.dbc` chain cannot crash the client. ([`src/script/tooltip_item/render.rs:195`](../crates/benilla-ui/src/script/tooltip_item/render.rs#L195))
- The reference's padding repeats a mana stat once per zero entry; not emulated, since only the internal test ring 6674 has one. ([`src/script/tooltip_item/render.rs:551`](../crates/benilla-ui/src/script/tooltip_item/render.rs#L551))
- The unknown-skill "Requires %s" shares it, though its reference red is `0xffff0000`, because a 32/255 difference in G and B does not show. ([`src/script/tooltip_unit.rs:126`](../crates/benilla-ui/src/script/tooltip_unit.rs#L126))
- The selection clears to 0, because the header the reference re-selects (`0x4d7b42`, reading 1) has no spell id; the stock window's `> 1` test treats 0 and 1 alike. ([`src/script/trainer/mod.rs:236`](../crates/benilla-ui/src/script/trainer/mod.rs#L236))
- A header clears it too, because it has no spell id; the reference stores its `0xffffffff` (`0x4d7b05`) and reads back 1, which the stock window's `> 1` test treats the same as 0. ([`src/script/trainer/mod.rs:1077`](../crates/benilla-ui/src/script/trainer/mod.rs#L1077))
- Index 0 is a no-op, because one stray call would spend the purse; the reference buys every visible service for any index at or below 0 (`0x4da244`, `0x4d8a70`). ([`src/script/trainer/mod.rs:1101`](../crates/benilla-ui/src/script/trainer/mod.rs#L1101))
- The selection clears to 0, because the header the reference re-selects (`0x4d7b42`, reading 1) has no spell id; the stock window's `> 1` test treats 0 and 1 alike. ([`src/script/trainer/mod.rs:264`](../crates/benilla-ui/src/script/trainer/mod.rs#L264))
- A header clears it too, because it has no spell id; the reference stores its `0xffffffff` (`0x4d7b05`) and reads back 1, which the stock window's `> 1` test treats the same as 0. ([`src/script/trainer/mod.rs:1171`](../crates/benilla-ui/src/script/trainer/mod.rs#L1171))
- Index 0 is a no-op, because one stray call would spend the purse; the reference buys every visible service for any index at or below 0 (`0x4da244`, `0x4d8a70`). ([`src/script/trainer/mod.rs:1195`](../crates/benilla-ui/src/script/trainer/mod.rs#L1195))
- Kept apart from `Self::vertex_color`, which the reference's `Texture:SetAlpha` (`0x79b580`) rewrites, because the drawn product differs only on a region that sets both, or a gradient and an alpha, which no stock code does, and a FontString's `SetAlpha` then keeps its font colour. ([`src/script/types.rs:421`](../crates/benilla-ui/src/script/types.rs#L421))
- Among whole-name matches ours picks the nearest, because the reference's first-walked pick is order-dependent and reads as a bug. ([`src/script/unit/bindings.rs:988`](../crates/benilla-ui/src/script/unit/bindings.rs#L988))
- The reference's matcher takes a whole-string hit at once only when it is nearer than the best so far (`0x493c88`-`0x493ca2`); an exact hit that is not nearer competes on prefix length alone (`0x493cf8`-`0x493d09`), so a nearer "Bobby" walked first beats an exact "Bob", and the pick follows the object list's order. ([`src/script/unit/by_name.rs:19`](../crates/benilla-ui/src/script/unit/by_name.rs#L19))