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:
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user