diff --git a/crates/benilla-app/src/ui_gossip/mod.rs b/crates/benilla-app/src/ui_gossip/mod.rs index 6cbbea463..dbf0081ae 100644 --- a/crates/benilla-app/src/ui_gossip/mod.rs +++ b/crates/benilla-app/src/ui_gossip/mod.rs @@ -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 { }) } -/// 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>, @@ -292,28 +292,21 @@ fn feed_gossip( objects: crate::net::Objects, unit_names: crate::ui_unit::NameTables, states: Res, - mut last_name: crate::ui_script::WindowMemo>, mut shown_message: crate::ui_script::WindowMemo, - 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::() + .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. diff --git a/crates/benilla-app/src/ui_merchant/mod.rs b/crates/benilla-app/src/ui_merchant/mod.rs index 3ec17e01b..0e7545b26 100644 --- a/crates/benilla-app/src/ui_merchant/mod.rs +++ b/crates/benilla-app/src/ui_merchant/mod.rs @@ -616,7 +616,6 @@ fn feed_merchant( mut errors: ResMut, mut last: crate::ui_script::WindowMemo>, mut last_money: Local>>, - mut last_name: crate::ui_script::WindowMemo>, 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::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .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 { + app.update(); + let s = app.world_mut().non_send_resource_mut::(); + let seen = s.eval::>("return SEEN").unwrap(); + s.run("SEEN = {}").unwrap(); + seen + }; + const VENDOR: u64 = 0x10; + app.world_mut() + .resource_mut::() + .open(VENDOR, Vec::new()); + assert_eq!(tick(&mut app), ["MERCHANT_SHOW"]); + app.world_mut() + .resource_mut::() + .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] diff --git a/crates/benilla-app/src/ui_quest/mod.rs b/crates/benilla-app/src/ui_quest/mod.rs index 0497b881b..3112808c0 100644 --- a/crates/benilla-app/src/ui_quest/mod.rs +++ b/crates/benilla-app/src/ui_quest/mod.rs @@ -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, } /// 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>, objects: crate::net::Objects, @@ -512,7 +535,6 @@ pub(crate) fn feed_quest( materials: Option>, mut sink: MessageSink, mut last: crate::ui_script::WindowMemo>, - mut last_name: crate::ui_script::WindowMemo>, 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 { + app.world_mut().run_system(feed).unwrap(); + let s = app.world_mut().non_send_resource_mut::(); + let seen = s.eval::>("return SEEN").unwrap(); + s.run("SEEN = {}").unwrap(); + seen + }; + assert_eq!(tick(&mut app), ["QUEST_GREETING"]); + app.world_mut() + .resource_mut::() + .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 { + app.world_mut().run_system(feed).unwrap(); + let s = app.world_mut().non_send_resource_mut::(); + let seen = s.eval::>("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::() + .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::() + .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" + ); + } } diff --git a/crates/benilla-app/src/ui_script/trainer_tests.rs b/crates/benilla-app/src/ui_script/trainer_tests.rs index f292eb373..a6c609dc6 100644 --- a/crates/benilla-app/src/ui_script/trainer_tests.rs +++ b/crates/benilla-app/src/ui_script/trainer_tests.rs @@ -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, diff --git a/crates/benilla-app/src/ui_session/release.rs b/crates/benilla-app/src/ui_session/release.rs index a63f4510e..4f9e5354b 100644 --- a/crates/benilla-app/src/ui_session/release.rs +++ b/crates/benilla-app/src/ui_session/release.rs @@ -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, 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| cell(world) == Some(guid); + if lost & f::GOSSIP != 0 + && holds(world, |w| { + w.get_resource::()?.npc + }) + { + crate::ui_gossip::close_core(world); + } + if lost & f::QUESTGIVER != 0 + && holds(world, |w| { + w.get_resource::()?.npc + }) + { + crate::ui_quest::close_core(world, false); + } + if lost & f::FLIGHTMASTER != 0 + && holds(world, |w| { + Some( + w.get_resource::()? + .open + .as_ref()? + .flightmaster, + ) + }) + { + crate::ui_taxi::close_core(world); + } + if lost & f::TRAINER != 0 + && holds(world, |w| { + w.get_resource::()?.trainer + }) + { + crate::ui_trainer::close_core(world); + } + if lost & f::BANKER != 0 + && holds(world, |w| { + w.get_resource::()?.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, me: Query>, @@ -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)); } } } diff --git a/crates/benilla-app/src/ui_session/release_tests.rs b/crates/benilla-app/src/ui_session/release_tests.rs index 55fcea433..6de8a4e50 100644 --- a/crates/benilla-app/src/ui_session/release_tests.rs +++ b/crates/benilla-app/src/ui_session/release_tests.rs @@ -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)); +} diff --git a/crates/benilla-app/src/ui_taxi/mod.rs b/crates/benilla-app/src/ui_taxi/mod.rs index e71cf14ac..16ea08b24 100644 --- a/crates/benilla-app/src/ui_taxi/mod.rs +++ b/crates/benilla-app/src/ui_taxi/mod.rs @@ -200,7 +200,6 @@ fn feed_taxi( self_q: Query<&ObjectStore, With>, relations: crate::target::RelationWorld, mut last: crate::ui_script::WindowMemo>, - mut last_name: crate::ui_script::WindowMemo>, 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::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .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 { + app.update(); + let s = app.world_mut().non_send_resource_mut::(); + let seen = s.eval::>("return SEEN").unwrap(); + s.run("SEEN = {}").unwrap(); + seen + }; + app.world_mut().resource_mut::().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::() + .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 diff --git a/crates/benilla-app/src/ui_trainer/law.rs b/crates/benilla-app/src/ui_trainer/law.rs index 584c10bcf..483937628 100644 --- a/crates/benilla-app/src/ui_trainer/law.rs +++ b/crates/benilla-app/src/ui_trainer/law.rs @@ -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 { + 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, ) -> Option { 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 { + 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, ) -> Option { 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, } } diff --git a/crates/benilla-app/src/ui_trainer/mod.rs b/crates/benilla-app/src/ui_trainer/mod.rs index b97e1e9ae..0a05647b1 100644 --- a/crates/benilla-app/src/ui_trainer/mod.rs +++ b/crates/benilla-app/src/ui_trainer/mod.rs @@ -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, + /// 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, } 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, mut tooltip_subjects: ResMut, mut last: crate::ui_script::WindowMemo>, - mut last_name: crate::ui_script::WindowMemo>, 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; diff --git a/crates/benilla-app/src/ui_trainer/reeval.rs b/crates/benilla-app/src/ui_trainer/reeval.rs index 3b91bb91f..e7651ebfa 100644 --- a/crates/benilla-app/src/ui_trainer/reeval.rs +++ b/crates/benilla-app/src/ui_trainer/reeval.rs @@ -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; diff --git a/crates/benilla-app/src/ui_trainer/tests.rs b/crates/benilla-app/src/ui_trainer/tests.rs index c76c09775..1152d8287 100644 --- a/crates/benilla-app/src/ui_trainer/tests.rs +++ b/crates/benilla-app/src/ui_trainer/tests.rs @@ -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::() + .spells + .insert(RANK2); + app.world_mut().resource_mut::().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::() + .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::() + .eval::>( + "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::() + .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::() + .open(0x10, 1, vec![row], String::new()); + }; + let header = |app: &mut App| -> String { + app.world_mut() + .non_send_resource_mut::() + .eval::( + "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::() + .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::() + .insert_resource(NetCommands(tx)) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .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 { + app.update(); + let s = app.world_mut().non_send_resource_mut::(); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + let seen = s.eval::>("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::(); + s.eval::(chunk).unwrap() +} + +/// `chunk`'s list of numbers. +fn lua_numbers(app: &mut App, chunk: &str) -> Vec { + let s = app.world_mut().non_send_resource_mut::(); + s.eval::>(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::().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::() + .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::() + .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 { + 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::().open( + TRAINER, + TRAINER_TYPE_TRADESKILL, + services, + String::new(), + ); + }; + let land = |app: &mut App| { + app.world_mut() + .resource_mut::() + .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::() + .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::().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::(); + 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::() + .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::() + .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"); +} diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index fb4919919..34b86fa11 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -829,8 +829,10 @@ pub(crate) struct Model { pub(crate) trainer: Option, pub(crate) trainer_selection: Option, 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, /// Collapsed skill-line ids, kept across a re-push and cleared when the trainer closes. pub(crate) trainer_collapsed: HashSet, /// 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, + /// The item records `GetQuestItemInfo`'s calls missed, one per call, until the app takes them. + pub(crate) quest_item_waits: Vec, 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(), diff --git a/crates/benilla-ui/src/script/quest.rs b/crates/benilla-ui/src/script/quest.rs index 02634cc4a..bb80f0b92 100644 --- a/crates/benilla-ui/src/script/quest.rs +++ b/crates/benilla-ui/src/script/quest.rs @@ -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 { + 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::() + .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 diff --git a/crates/benilla-ui/src/script/trainer/mod.rs b/crates/benilla-ui/src/script/trainer/mod.rs index 7e73cc2f5..14dff76f6 100644 --- a/crates/benilla-ui/src/script/trainer/mod.rs +++ b/crates/benilla-ui/src/script/trainer/mod.rs @@ -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 { - 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 { + [ + 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, pub texture: Option, + /// 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, /// The expanded text; `None` answers nil (`0x4d9d90`). pub description: Option, + /// 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, /// 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) { @@ -199,6 +221,12 @@ impl super::UiScript { } fn push_trainer(&mut self, state: Option, finalizes: bool) { + // The known group's header text, which `0x4d8b11` reads by name. + let known_header = self + .lua() + .globals() + .get::("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 = 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 { + 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 { +/// `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 { let mut groups: Vec = 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 = 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::().expect("model app_data"); - opt_str(lua, service(&model, index).and_then(|s| s.texture.as_ref())) + let mut model = lua.app_data_mut::().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::().expect("model app_data"); - opt_str( - lua, - service(&model, index).and_then(|s| s.description.as_ref()), - ) + let mut model = lua.app_data_mut::().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::().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::() + .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(()) })?, )?; diff --git a/crates/benilla-ui/src/script/trainer/tests.rs b/crates/benilla-ui/src/script/trainer/tests.rs index e026eac9f..2e518b3e9 100644 --- a/crates/benilla-ui/src/script/trainer/tests.rs +++ b/crates/benilla-ui/src/script/trainer/tests.rs @@ -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::>(&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::("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::("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::("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, Option, Option) = 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::>(), + ["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::>(), + ["Ram Riding", "Ram Riding"] + ); + assert!(s + .eval::>("return GetTrainerSkillLineFilter(3)") + .unwrap() + .is_none()); + + // A class trainer builds no such group. + s.set_trainer(Some(trainer())); + assert_eq!( + s.eval::("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, Option)>( + "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); +} diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 101645615..ac3454f20 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -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))