Bank, gossip, purse and repair costs read live; events at packets (#2542)

The bank, gossip, purse and repair-cost getters read frame-final copies, and their events fired from feeds after the drain.

Now they read live as of each packet:
- the purse, as GetMoney does (`0x518160`)
- the repair costs per slot (`0x4faf30`)
- GetNumBankSlots and GetBankSlotCost, from the player's own field with no banker test (`0x4f85b0`, `0x4f8630`)
- gossip's slots and text (`0x4e28d0`, `0x4e28c0`)

A bank lane carries OPENED (`0x4f8522`) and the refusal lines. PLAYERBANKBAGSLOTS_CHANGED fires at its field (`0x4f8460`). A gossip lane pushes SHOW at the draw (`0x4e22b5`), and asks the NPC's name there. A gossip draw with no player or no NPC held writes nothing and fires nothing (`0x4e20bb`), so a late greeting no longer reopens a closed window.

Four feeds are gone. Tests in `bank_live_tests.rs` and `gossip_live_tests.rs` fail on main. READS_FRAME_FINAL 56 → 52.

Part of #2217

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-26 23:00:30 +07:00
committed by GitHub
parent 15dd7b524c
commit f060e0e1ee
45 changed files with 1755 additions and 1061 deletions
+45 -23
View File
@@ -102,7 +102,6 @@ pub(super) fn seed_ui_fixture(
),
(
ResMut<'static, crate::ui_action::PlayerActions>,
ResMut<'static, crate::ui_bank::BankOpen>,
MessageWriter<'static, AppExit>,
ResMut<'static, crate::loading_screen::LoadingScreen>,
ResMut<'static, crate::ui_gossip::GossipState>,
@@ -127,7 +126,7 @@ pub(super) fn seed_ui_fixture(
vm.turn(|script, parts| {
let (mut loot, mut names, mut selection, mut player, store, rest, set_parts) = parts;
let (mut item_sets, mut item_names) = set_parts;
let (mut actions, mut bank, mut exit, mut loading, mut gossip, mut quest) = rest;
let (mut actions, mut exit, mut loading, mut gossip, mut quest) = rest;
let (mut items, mut index, mut home_bind) = store;
// A glue-screen capture has no world scenario, and no glue screen opens a UI fixture.
let Some(scenario) = ctx.scenario else {
@@ -392,17 +391,21 @@ pub(super) fn seed_ui_fixture(
display_id: 0,
}),
);
gossip.npc = Some(NPC_GUID);
gossip.text_id = 1;
// A greeting long enough that the wrapped options below run past the parchment.
gossip.greeting = Some(
"A man has been caught stealing corn from the fields of a noble, a lord known \
for his harsh taxes throughout the land.$B$BMake your choice!"
.into(),
let greeting = "A man has been caught stealing corn from the fields of a noble, \
a lord known for his harsh taxes throughout the land.$B$BMake \
your choice!";
gossip.remember_record(
1,
vec![benilla_protocol::messages::NpcTextBlock {
probability: 1.0,
male: greeting.into(),
female: greeting.into(),
}],
);
// An available quest row above the options, for the quest-row icon and text
// seating.
gossip.quests = vec![(783, 0, 5, "Eagan Peltskinner".into())];
let quests = vec![(783, 0, 5, "Eagan Peltskinner".into())];
// One short option and four that wrap, taller than the parchment, so the capture
// covers the stock `GossipResize` per-row height, the scroll frame and its
// scrollbar.
@@ -416,26 +419,45 @@ pub(super) fn seed_ui_fixture(
"I allow the man to take enough corn to feed his family for a couple of days, \
encouraging him to leave the land.",
];
gossip.options = vec![benilla_protocol::messages::GossipOption {
let mut options = vec![benilla_protocol::messages::GossipOption {
index: 0,
icon: 1,
coded: false,
message: "Let me browse your goods.".into(),
}];
gossip
.options
.extend(judgement.iter().enumerate().map(|(i, message)| {
benilla_protocol::messages::GossipOption {
index: i as u32 + 1,
icon: 0,
coded: false,
message: (*message).into(),
}
}));
options.extend(judgement.iter().enumerate().map(|(i, message)| {
benilla_protocol::messages::GossipOption {
index: i as u32 + 1,
icon: 0,
coded: false,
message: (*message).into(),
}
}));
// The draw needs the active player and the NPC's object (`0x4e20b3`, `0x4e20bb`).
let npc = commands
.spawn((
crate::net::Guid(NPC_GUID),
crate::net::ObjectStore(
benilla_protocol::messages::ObjectFields::from_pairs(&[(2, 9)]),
),
))
.id();
index.0.insert(NPC_GUID, npc);
commands.spawn((
crate::net::ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(
&[(2, 0x19)],
)),
crate::net::SelfPlayer,
crate::net::Guid(0x51),
));
// The message's own handler, which queues `GOSSIP_SHOW` as a live one does.
commands.queue(move |world: &mut World| {
crate::ui_gossip::net::gossip_menu(world, NPC_GUID, 1, options, quests)
});
}
UiFixture::Bank => {
// A pure banker and the vault, fed through the descriptor and caches that
// `feed_bank` and `ui_items` push into the VM as live wire data would.
// `ui_items` pushes into the VM as live wire data would.
use benilla_protocol::messages::ObjectFields;
names.insert_creature(
NPC_ENTRY,
@@ -519,8 +541,8 @@ pub(super) fn seed_ui_fixture(
crate::net::SelfPlayer,
crate::net::Guid(PLAYER_GUID),
));
// `feed_bank` then fires BANKFRAME_OPENED as a live SMSG_SHOW_BANK would.
bank.open(NPC_GUID);
// The open's own core, which queues BANKFRAME_OPENED as a live SMSG_SHOW_BANK does.
commands.queue(|world: &mut World| crate::ui_bank::open_bank(world, NPC_GUID));
// Opens bank bag 1 once its container feed lands (`GetContainerNumSlots(5) > 0`);
// `BankFrameBag1`'s `GetID()` is 5, the container id its handler passes to
// `ToggleBag`.
+21 -5
View File
@@ -219,6 +219,7 @@ pub(crate) mod id {
pub(crate) const UNIT_INVENTORY_CHANGED: u16 = 186;
pub(crate) const PLAYER_MONEY: u16 = 200;
pub(crate) const PLAYERBANKSLOTS_CHANGED: u16 = 347;
pub(crate) const PLAYERBANKBAGSLOTS_CHANGED: u16 = 350;
pub(crate) const UNIT_CLASSIFICATION_CHANGED: u16 = 187;
pub(crate) const PLAYER_FLAGS_CHANGED: u16 = 407;
pub(crate) const UNIT_QUEST_LOG_CHANGED: u16 = 522;
@@ -381,6 +382,11 @@ const COINAGE: (u16, u16) = (0xf70, 4);
/// `0x5de4e0`'s range, `PLAYER_BYTES_2` byte 3 (`0x5ddb0b`).
const REST_STATE: u16 = 0x1b;
/// The active player's bank-slot watch `0x4f8490`, `PLAYER_BYTES_2` byte 2, the bought bank bag
/// slots, registered at world entry (`0x4f8460`, from `0x4909ba`), behind `0x5de4e0` on the same
/// dword.
const BANK_BAG_SLOTS: (u16, u16) = (0x1a, 1);
/// The skill block: entry `k` at `0x848 + 12k` of the player section, 128 entries (`0x5dda54` to
/// `0x5ddaf0`). Each entry's four watches cover two bytes each: the value at `+4`, the maximum at
/// `+6`, the temporary bonus at `+8` and the permanent bonus at `+0xa`.
@@ -954,6 +960,11 @@ pub(crate) fn placed_values(
);
}
}
// `0x4f8490`: `PLAYERBANKBAGSLOTS_CHANGED` bare (`0x4f8495`), a banker open or not.
if let Some(at) = at(PLAYER_SECTION, BANK_BAG_SLOTS, List::Object) {
let id = id::PLAYERBANKBAGSLOTS_CHANGED;
pass.push(at, FieldNotice::Global { id });
}
for (offset, len, id) in PLAYER_EVENT_WATCHES {
if let Some(at) = at(PLAYER_SECTION, (offset, len), List::Object) {
pass.push(at, FieldNotice::Player { id });
@@ -1224,8 +1235,9 @@ mod tests {
);
}
/// The rank bar watch is one byte of `PLAYER_FIELD_BYTES2`, the rest-state watch one byte of
/// `PLAYER_BYTES_2`: the other bytes of their dwords run neither.
/// The rank bar watch is one byte of `PLAYER_FIELD_BYTES2`, the rest-state watch and the
/// bank-slot watch one byte each of `PLAYER_BYTES_2`: the other bytes of their dwords run
/// neither, and on one dword the rest state's runs first (`0x5ddb0b`, then `0x4f8460`).
#[test]
fn a_byte_watch_reads_its_own_byte_only() {
let mut me = player();
@@ -1236,10 +1248,14 @@ mod tests {
[FieldNotice::Player { id: 524 }]
);
let rest = pf(0x1b);
assert!(block(&mut me, Some(ME), ME, &[(rest, 0x00ff_ffff)]).is_empty());
assert!(block(&mut me, Some(ME), ME, &[(rest, 0x0000_ffff)]).is_empty());
let bank = FieldNotice::Global {
id: id::PLAYERBANKBAGSLOTS_CHANGED,
};
assert_eq!(block(&mut me, Some(ME), ME, &[(rest, 0x0002_ffff)]), [bank]);
assert_eq!(
block(&mut me, Some(ME), ME, &[(rest, 0x02ff_ffff)]),
[FieldNotice::RestState { old: 0, new: 2 }]
block(&mut me, Some(ME), ME, &[(rest, 0x0203_ffff)]),
[FieldNotice::RestState { old: 0, new: 2 }, bank]
);
// The whole `PLAYER_BYTES_3` is the rank watch: its gender byte runs it.
assert_eq!(
-4
View File
@@ -1862,9 +1862,7 @@ pub(crate) mod schedule_tests {
);
let seated = after(units);
let stale: Vec<&str> = [
"::ui_gossip::feed_gossip",
"::ui_quest::feed_quest",
"::ui_bank::feed_bank",
"::ui_auction::feed_auction",
"::ui_trade::feed_trade",
]
@@ -2066,8 +2064,6 @@ pub(crate) mod schedule_tests {
"filled only by a world right-click on a GameObject or a body; the drain sends casts to the wire"),
("ui_action/recount.rs", "recount", Because::Deliberate,
"no system: the item lane's firing calls it per run (`item_lane::fire_run`), inside the lanes' drain, which runs only with the interface up (`ingame_ui_up`)"),
("ui_bank/mod.rs", "feed_bank", Because::PlayerRoundTrip,
"`BankErrors` answers a `BuyBankSlot` click; the OPENED edge rides `VmMemo`s"),
("ui_char.rs", "feed_char", Because::FilledByVm,
"its take is the ammo getters' hits, which only a getter's call leaves, and a frameless VM \
calls none; the doll's own pushes ride `VmMemo`s"),
+4
View File
@@ -482,6 +482,8 @@ mod aura_view_tests;
#[cfg(test)]
mod bag_tests;
#[cfg(test)]
mod bank_live_tests;
#[cfg(test)]
mod battlefield_live_tests;
#[cfg(test)]
mod book_live_tests;
@@ -494,6 +496,8 @@ mod dialog_live_tests;
#[cfg(test)]
mod duration_live_tests;
#[cfg(test)]
mod gossip_live_tests;
#[cfg(test)]
mod group_view_tests;
#[cfg(test)]
mod guard_tests;
@@ -0,0 +1,231 @@
//! The bank getters as a listener reads them, and the bank's events at their packets. The getters
//! read the active player's bought bag slots and the next price at the call (`0x4f85b0`,
//! `0x4f8630`), no banker tested; `SMSG_SHOW_BANK`'s open signals `BANKFRAME_OPENED` inside it
//! (`0x4f8522`), a refusal shows its line inside `SMSG_BUY_BANK_SLOT_RESULT`'s handler
//! (`0x5e3f8d`), and the bought count's watch signals `PLAYERBANKBAGSLOTS_CHANGED` at its packet
//! (`0x4f8490`).
use benilla_protocol::{EntityKind, SessionEvent};
use benilla_ui::script::UiScript;
use bevy::prelude::*;
use crossbeam_channel::Sender;
use super::unit_view_tests::{created, values, wire_packets};
const ME: u64 = 0x7;
const BANKER: u64 = 0xF130_0000_0000_0043;
/// `PLAYER_BYTES_2`, absolute: byte 2 is the bought bank bag slots.
const PLAYER_BYTES_2: u16 = 194;
/// The whole client, headless, in the world.
struct Client {
app: App,
inbound: Sender<SessionEvent>,
}
impl Client {
/// One frame of the client's, with `packets` in the inbound channel, each one packet.
fn frame(&mut self, packets: Vec<Vec<SessionEvent>>) {
for ev in wire_packets(packets) {
self.inbound.send(ev).expect("the inbound channel");
}
self.app.update();
}
/// `chunk`'s answer through the lent host when `live`, else off the VM's copy alone.
fn eval<T: benilla_ui::mlua::FromLuaMulti>(&mut self, live: bool, chunk: &str) -> T {
if live {
crate::script_calls::with_hosted_vm(self.app.world_mut(), |s| {
s.expect("a VM").eval::<T>(chunk).expect("runs")
})
} else {
crate::script_calls::eval_in_vm(self.app.world(), chunk)
}
}
/// What the spy heard since the last call.
fn seen(&mut self) -> Vec<String> {
self.eval(false, "local s = Seen Seen = {} return s")
}
/// `packets` run through their handlers with no frame: the world as the handlers leave it.
fn handle(&mut self, packets: Vec<SessionEvent>) {
crate::net::handlers::dispatch(self.app.world_mut(), packets);
}
}
/// The client in the world with a banker beside us, two bag slots bought, the price table's first
/// rows, and a spy whose line for each bank event and message line carries its name and first
/// argument.
fn client() -> Client {
let mut app = crate::game_plugins::schedule_tests::headless_client();
app.insert_resource(bevy::ecs::error::DefaultErrorHandler(
bevy::ecs::error::ignore,
));
let (tx, _sent) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
let (inbound, rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetEvents::for_test(rx));
app.insert_resource(crate::ui_bank::BankPrices(
benilla_formats::BankBagSlotPrices::from_rows([(1, 1_000), (2, 10_000), (3, 100_000)]),
));
app.update();
let script = UiScript::new().expect("a VM");
script
.run(
r#"
Seen = {}
ERR_BANKSLOT_INSUFFICIENT_FUNDS = "too poor"
local f = CreateFrame("Frame")
for _, e in ipairs({"BANKFRAME_OPENED", "BANKFRAME_CLOSED",
"PLAYERBANKBAGSLOTS_CHANGED", "UI_ERROR_MESSAGE", "CHAT_MSG_MONSTER_SAY"}) do
f:RegisterEvent(e)
end
f:SetScript("OnEvent", function()
table.insert(Seen, event .. ":" .. tostring(arg1))
end)
"#,
)
.unwrap();
app.insert_non_send_resource(script);
app.insert_resource(State::new(crate::client_state::ClientState::InWorld));
benilla_world::world_point::init_world_point_resources(app.world_mut());
app.world_mut().resource_mut::<crate::net::SelfGuid>().0 = Some(ME);
let mut c = Client { app, inbound };
c.frame(vec![vec![
created(ME, EntityKind::Player, &[(PLAYER_BYTES_2, 2 << 16)]),
created(BANKER, EntityKind::Unit, &[]),
]]);
c.frame(vec![]);
c.seen();
c
}
/// A monster's line, which shows inside its own packet's handler on the chat lane.
fn boar_says(text: &str) -> SessionEvent {
SessionEvent::Chat(benilla_protocol::messages::ChatMessage {
chat_type: benilla_protocol::messages::CHAT_MSG_MONSTER_SAY,
language: 0,
sender_guid: 0xF130_0000_0045_0003,
target_guid: 0,
sender_name: Some("Boar".into()),
channel: None,
text: text.into(),
chat_tag: 0,
})
}
const SLOTS: &str = "local n, full = GetNumBankSlots() return n, full == 1, GetBankSlotCost()";
/// The purchase row reads the player at the call, no banker open, with no push between; with no
/// host the VM's copy, which nothing pushes, holds none.
#[test]
fn the_bank_getters_read_the_player_at_the_call() {
let mut c = client();
assert_eq!(c.eval::<(i64, bool, i64)>(true, SLOTS), (2, false, 100_000));
assert_eq!(c.eval::<(i64, bool, i64)>(false, SLOTS), (0, false, 0));
c.handle(vec![
values(ME, &[(PLAYER_BYTES_2, 6 << 16)]),
SessionEvent::ObjectUpdateEnd,
]);
assert_eq!(
c.eval::<(i64, bool, i64)>(true, SLOTS),
(6, true, 0),
"six bought: full, and no seventh row"
);
}
/// The open's `BANKFRAME_OPENED` sits on the bank lane at its handler's stamp, between the stamps
/// of the handlers before and after it.
#[test]
fn the_open_is_stamped_at_its_handler() {
let mut c = client();
let before = crate::lanes::stamp();
c.handle(vec![SessionEvent::ShowBank { banker: BANKER }]);
let after = crate::lanes::stamp();
let stamped = c
.app
.world()
.resource::<crate::ui_bank::BankLane>()
.stamped();
assert_eq!(stamped.len(), 1);
let (at, event) = stamped[0];
assert_eq!(event, crate::ui_bank::BankEvent::Opened);
assert!(before < at && at < after, "stamped inside the handler");
}
/// The open, a refusal's line and a bought slot's watch each fire at their packet's place around
/// a chat line of a packet between them; the watch fires with no banker open too.
#[test]
fn the_bank_events_fire_at_their_packets() {
let mut c = client();
c.frame(vec![
vec![boar_says("a")],
vec![SessionEvent::ShowBank { banker: BANKER }],
vec![boar_says("b")],
vec![SessionEvent::BuyBankSlotResult {
result: benilla_protocol::messages::bank_slot_result::INSUFFICIENT_FUNDS,
}],
vec![boar_says("c")],
vec![values(ME, &[(PLAYER_BYTES_2, 3 << 16)])],
]);
assert_eq!(
c.seen(),
[
"CHAT_MSG_MONSTER_SAY:a",
"BANKFRAME_OPENED:nil",
"CHAT_MSG_MONSTER_SAY:b",
"UI_ERROR_MESSAGE:too poor",
"CHAT_MSG_MONSTER_SAY:c",
"PLAYERBANKBAGSLOTS_CHANGED:nil",
]
);
crate::ui_bank::close_core(c.app.world_mut());
c.seen();
c.frame(vec![vec![values(ME, &[(PLAYER_BYTES_2, 4 << 16)])]]);
assert_eq!(c.seen(), ["PLAYERBANKBAGSLOTS_CHANGED:nil"]);
}
/// A close in the open's frame fires after the open, which a close core run ahead of the drain
/// would otherwise beat. The close signals first and then releases the latch, which re-enters it
/// with the cell still set (`0x4f84df`, `0x4f84eb` → `0x493310` → `0x4f84d0`), so
/// `BANKFRAME_CLOSED` signals twice.
#[test]
fn a_close_fires_after_the_open() {
let mut c = client();
c.handle(vec![SessionEvent::ShowBank { banker: BANKER }]);
crate::ui_bank::close_core(c.app.world_mut());
c.frame(vec![]);
assert_eq!(
c.seen(),
[
"BANKFRAME_OPENED:nil",
"BANKFRAME_CLOSED:nil",
"BANKFRAME_CLOSED:nil"
]
);
assert!(!c
.app
.world()
.resource::<crate::ui_bank::BankOpen>()
.is_open());
}
/// Two bought slots in one frame, each its own packet: the watch's listener reads the count its
/// own packet left (`0x4f8490` signals inside the packet, `0x4f85dd` reads the field at the call),
/// not the frame's last.
#[test]
fn the_bank_slots_read_as_of_each_packet() {
let mut c = client();
c.eval::<()>(
false,
"Slots = {} local f = CreateFrame('Frame') \
f:RegisterEvent('PLAYERBANKBAGSLOTS_CHANGED') \
f:SetScript('OnEvent', function() table.insert(Slots, (GetNumBankSlots())) end)",
);
c.frame(vec![
vec![values(ME, &[(PLAYER_BYTES_2, 3 << 16)])],
vec![values(ME, &[(PLAYER_BYTES_2, 4 << 16)])],
]);
assert_eq!(c.eval::<Vec<i64>>(false, "return Slots"), [3, 4]);
}
@@ -0,0 +1,412 @@
//! The gossip getters as a listener reads them, and `GOSSIP_SHOW` at its packet. The getters read
//! the message's slots (`0xbbbe90`, `0x4e28d0`), its quest rows (`0xbb74c0`) and the text buffer
//! (`0xbbb678`, `0x4e28c0`) at the call; the show fires inside `0x4e2010` (`0x4e22b5`), right after
//! the text's write (`0x4e229a`), which the message runs (`0x4e27df`) and so does its text's
//! arrival (`0x4e22f2`).
use benilla_protocol::field::FIELD_UNIT_HEALTH;
use benilla_protocol::messages::{GossipOption, NpcTextBlock};
use benilla_protocol::{EntityKind, SessionEvent};
use benilla_ui::script::UiScript;
use bevy::prelude::*;
use crossbeam_channel::{Receiver, Sender};
use super::unit_view_tests::{created, values, wire_packets};
use crate::net::ClientCommand;
const ME: u64 = 0x7;
const NPC: u64 = 0xF130_0000_0000_0043;
/// `OBJECT_FIELD_ENTRY`, absolute, and the NPC's creature record.
const OBJECT_ENTRY: u16 = 3;
const NPC_ENTRY: u32 = 0x45;
/// The whole client, headless, in the world.
struct Client {
app: App,
inbound: Sender<SessionEvent>,
sent: Receiver<ClientCommand>,
}
impl Client {
/// One frame of the client's, with `packets` in the inbound channel, each one packet.
fn frame(&mut self, packets: Vec<Vec<SessionEvent>>) {
for ev in wire_packets(packets) {
self.inbound.send(ev).expect("the inbound channel");
}
self.app.update();
}
/// `chunk`'s answer through the lent host when `live`, else off the VM's copy alone.
fn eval<T: benilla_ui::mlua::FromLuaMulti>(&mut self, live: bool, chunk: &str) -> T {
if live {
crate::script_calls::with_hosted_vm(self.app.world_mut(), |s| {
s.expect("a VM").eval::<T>(chunk).expect("runs")
})
} else {
crate::script_calls::eval_in_vm(self.app.world(), chunk)
}
}
/// What the spy heard since the last call.
fn seen(&mut self) -> Vec<String> {
self.eval(false, "local s = Seen Seen = {} return s")
}
/// The pet-name queries sent since the last call, by pet number.
fn pet_name_queries(&mut self) -> Vec<u32> {
crate::query_cache::queries(&self.sent)
.into_iter()
.filter_map(|c| match c {
ClientCommand::PetNameQuery { pet_number, .. } => Some(pet_number),
_ => None,
})
.collect()
}
/// `packets` run through their handlers with no frame: the world as the handlers leave it.
fn handle(&mut self, packets: Vec<SessionEvent>) {
crate::net::handlers::dispatch(self.app.world_mut(), packets);
}
}
/// The client in the world with an NPC beside us, a VM `prepare` seeds, and a spy whose line for
/// each gossip event carries its name and first argument.
fn client_with(prepare: impl FnOnce(&mut UiScript)) -> Client {
let mut app = crate::game_plugins::schedule_tests::headless_client();
app.insert_resource(bevy::ecs::error::DefaultErrorHandler(
bevy::ecs::error::ignore,
));
let (tx, sent) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
let (inbound, rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetEvents::for_test(rx));
app.update();
let mut script = UiScript::new().expect("a VM");
prepare(&mut script);
script
.run(
r#"
Seen = {}
local f = CreateFrame("Frame")
for _, e in ipairs({"GOSSIP_SHOW", "GOSSIP_CLOSED", "CHAT_MSG_MONSTER_SAY"}) do
f:RegisterEvent(e)
end
f:SetScript("OnEvent", function()
table.insert(Seen, event .. ":" .. tostring(arg1))
end)
"#,
)
.unwrap();
app.insert_non_send_resource(script);
app.insert_resource(State::new(crate::client_state::ClientState::InWorld));
benilla_world::world_point::init_world_point_resources(app.world_mut());
app.world_mut().resource_mut::<crate::net::SelfGuid>().0 = Some(ME);
let mut c = Client { app, inbound, sent };
c.frame(vec![vec![
// Alive, so a panel opens (`ShowUIPanel`'s `UnitIsDead` test, `UIParent.lua`).
created(ME, EntityKind::Player, &[(FIELD_UNIT_HEALTH, 100)]),
created(NPC, EntityKind::Unit, &[(OBJECT_ENTRY, NPC_ENTRY)]),
]]);
c.frame(vec![]);
c.seen();
c
}
fn client() -> Client {
client_with(|_| {})
}
fn option(label: &str) -> GossipOption {
GossipOption {
index: 0,
icon: 1,
coded: false,
message: label.into(),
}
}
/// `NPC`'s message on text `text_id` with one option labelled `label`, and one available quest.
fn menu(text_id: u32, label: &str) -> SessionEvent {
SessionEvent::GossipMenu {
npc: NPC,
text_id,
options: vec![option(label)],
quests: vec![(783, 2, 5, "A Threat Within".into())],
}
}
/// Text `text_id`'s record, `line` in both columns.
fn greeting(text_id: u32, line: &str) -> SessionEvent {
SessionEvent::NpcGreeting {
text_id,
blocks: vec![NpcTextBlock {
probability: 1.0,
male: line.into(),
female: line.into(),
}],
}
}
/// A monster's line, which shows inside its own packet's handler on the chat lane.
fn boar_says(text: &str) -> SessionEvent {
SessionEvent::Chat(benilla_protocol::messages::ChatMessage {
chat_type: benilla_protocol::messages::CHAT_MSG_MONSTER_SAY,
language: 0,
sender_guid: 0xF130_0000_0045_0003,
target_guid: 0,
sender_name: Some("Boar".into()),
channel: None,
text: text.into(),
chat_tag: 0,
})
}
const STORE: &str = "local function n(...) return arg.n end \
return GetGossipText() .. ',' .. (GetGossipOptions()) .. ',' .. n(GetGossipOptions()) \
.. ',' .. (GetGossipAvailableQuests())";
/// Each getter reads what the handlers wrote at the call, with no push between: the drawn text,
/// the slots and the quest rows; with no host the VM's copy, which nothing pushes, holds none.
#[test]
fn the_gossip_getters_read_the_world_at_the_call() {
let mut c = client();
c.handle(vec![menu(7, "Browse"), greeting(7, "Hello")]);
assert_eq!(
c.eval::<String>(true, STORE),
"Hello,Browse,2,A Threat Within"
);
assert_eq!(
c.eval::<Option<String>>(false, "return GetGossipText()"),
None
);
// The next message's slots read at once; its text, still out, leaves the last.
c.handle(vec![menu(8, "Fly")]);
assert_eq!(c.eval::<String>(true, STORE), "Hello,Fly,2,A Threat Within");
crate::ui_gossip::close_core(c.app.world_mut());
assert_eq!(
c.eval::<String>(true, STORE),
"Hello,Fly,2,A Threat Within",
"a close leaves the store (`0x4e1fa0`)"
);
}
/// The draw's `GOSSIP_SHOW` sits on the gossip lane at the text's handler, between the stamps of
/// the handlers before and after it.
#[test]
fn the_show_is_stamped_at_its_handler() {
let mut c = client();
c.handle(vec![menu(7, "Browse")]);
assert!(c
.app
.world()
.resource::<crate::ui_gossip::GossipLane>()
.stamped()
.is_empty());
let before = crate::lanes::stamp();
c.handle(vec![greeting(7, "Hello")]);
let after = crate::lanes::stamp();
let stamped = c
.app
.world()
.resource::<crate::ui_gossip::GossipLane>()
.stamped();
assert_eq!(stamped.len(), 1);
let (at, event) = stamped[0];
assert_eq!(event, crate::ui_gossip::GossipEvent::Show);
assert!(before < at && at < after, "stamped inside the handler");
}
/// A cached text shows at its message and a first visit at its text's arrival, each at its
/// packet's place around a chat line of a packet between; the round trip fires nothing.
#[test]
fn the_show_fires_at_its_packet() {
let mut c = client();
c.handle(vec![greeting(7, "Hello")]);
c.frame(vec![
vec![boar_says("a")],
vec![menu(7, "Browse")],
vec![boar_says("b")],
vec![menu(8, "Fly")],
vec![boar_says("c")],
vec![greeting(8, "Safe travels")],
]);
assert_eq!(
c.seen(),
[
"CHAT_MSG_MONSTER_SAY:a",
"GOSSIP_SHOW:nil",
"CHAT_MSG_MONSTER_SAY:b",
"CHAT_MSG_MONSTER_SAY:c",
"GOSSIP_SHOW:nil",
]
);
assert_eq!(
c.eval::<String>(true, "return GetGossipText()"),
"Safe travels"
);
}
/// A close in the show's frame fires after it. A text landing after a close draws nothing: the
/// close cancels no wait (`0x4e1fa0`), but the draw finds no object under the zeroed cell and
/// bails (`0x4e20bb` → `0x4e22ba`), with no text write and no signal. The close's latch release
/// re-enters it with the cell still set (`0x4e1fc5` → `0x493310` → `0x4e1fa0`), so
/// `GOSSIP_CLOSED` signals twice (`0x4e1fdc`).
#[test]
fn a_close_fires_after_the_show_and_a_late_text_draws_nothing() {
let mut c = client();
c.handle(vec![greeting(7, "Hello"), menu(7, "Browse")]);
crate::ui_gossip::close_core(c.app.world_mut());
c.frame(vec![]);
assert_eq!(
c.seen(),
["GOSSIP_SHOW:nil", "GOSSIP_CLOSED:nil", "GOSSIP_CLOSED:nil"]
);
c.frame(vec![vec![menu(9, "Fly")]]);
crate::ui_gossip::close_core(c.app.world_mut());
c.frame(vec![vec![greeting(9, "Too late")]]);
assert_eq!(c.seen(), ["GOSSIP_CLOSED:nil", "GOSSIP_CLOSED:nil"]);
assert_eq!(c.eval::<String>(true, "return GetGossipText()"), "Hello");
}
/// A message whose NPC is no object here draws nothing either (`0x4e20bb`).
#[test]
fn a_menu_of_an_npc_not_held_draws_nothing() {
let mut c = client();
c.handle(vec![greeting(7, "Hello")]);
c.frame(vec![vec![SessionEvent::GossipMenu {
npc: 0xF130_0000_0000_0099,
text_id: 7,
options: vec![option("Browse")],
quests: Vec::new(),
}]]);
assert!(!c.seen().iter().any(|e| e == "GOSSIP_SHOW:nil"));
assert_eq!(c.eval::<String>(true, "return GetGossipText()"), "");
}
/// The show asks the NPC's name as the stock window's `UnitName("npc")` does there
/// (`GossipFrame.lua:33`, `0x5171b8`), at the draw that signals it, a first visit's text; never
/// the round trip before it. A held creature's name is its record, which its create asked for, so
/// the ask is seen on an NPC that names itself by its pet number (`0x609210`).
#[test]
fn the_show_asks_the_npcs_name() {
/// `UNIT_FIELD_PETNUMBER`, absolute.
const PET_NUMBER: u16 = 139;
let mut c = client();
c.handle(vec![
values(NPC, &[(PET_NUMBER, 5)]),
SessionEvent::ObjectUpdateEnd,
]);
c.app
.world_mut()
.resource_mut::<crate::names::NameCache>()
.forget_pet(5);
c.pet_name_queries();
c.handle(vec![menu(8, "Fly")]);
assert!(c.pet_name_queries().is_empty(), "no show, no ask");
c.handle(vec![greeting(8, "Safe travels")]);
assert_eq!(c.pet_name_queries(), [5], "asked at the draw");
}
/// The greeting's macros expand once per message, as `0x4e2010` writes the expanded text to
/// `0xbbb678` at its draw (`0x4e2274`, `0x4e229a`): a later change to the player's name leaves it
/// until the next message.
#[test]
fn the_greeting_expands_once_per_message() {
let mut c = client();
let named = |c: &mut Client, name: &str| {
c.app
.world_mut()
.resource_mut::<crate::names::NameCache>()
.insert_player(ME, name.into(), None);
};
named(&mut c, "Ann");
c.handle(vec![greeting(7, "Well met, $N."), menu(7, "Browse")]);
assert_eq!(
c.eval::<String>(true, "return GetGossipText()"),
"Well met, Ann."
);
named(&mut c, "Bea");
assert_eq!(
c.eval::<String>(true, "return GetGossipText()"),
"Well met, Ann.",
"held until the next message"
);
c.handle(vec![menu(7, "Browse")]);
assert_eq!(
c.eval::<String>(true, "return GetGossipText()"),
"Well met, Bea."
);
}
/// Stock `GossipFrame.xml` through a first-visit sub-menu: the window stays up on the first menu
/// while the text is out, with nothing signalled, and repaints in place once it lands.
#[test]
fn stock_gossip_frame_stays_visible_through_a_first_visit_submenu() {
let _data = benilla_formats::wow_data_or_skip!();
let mut c = client_with(|s| {
s.set_screen_size(1024.0, 768.0);
s.set_text_measurer(Box::new(crate::ui_script::FixedWidthFont(6.0)));
for f in crate::ui_script::test_ui::production_order(&[
crate::ui_script::test_ui::GOSSIP_UI,
&[r"Interface\FrameXML\GossipFrame.xml"],
]) {
crate::ui_script::test_ui::load_ui(s, f);
}
});
// What the window shows: visible?, the greeting, the first row's label.
let window = |c: &mut Client| -> (bool, String, String) {
let s = c.app.world().non_send_resource::<UiScript>();
(
s.eval::<bool>("return GossipFrame:IsVisible()").unwrap(),
s.eval::<String>("return GossipGreetingText:GetText() or ''")
.unwrap(),
s.eval::<String>("return GossipTitleButton1:GetText() or ''")
.unwrap(),
)
};
// A message with one option and no quest row.
let menu = |text_id: u32, label: &str| SessionEvent::GossipMenu {
npc: NPC,
text_id,
options: vec![option(label)],
quests: Vec::new(),
};
c.frame(vec![
vec![greeting(50, "What are you looking for?")],
vec![menu(50, "The bank")],
]);
assert_eq!(c.seen(), ["GOSSIP_SHOW:nil"]);
assert_eq!(
window(&mut c),
(
true,
"What are you looking for?".to_string(),
"The bank".to_string(),
),
"the greeting menu opens"
);
for _ in 0..3 {
c.frame(vec![vec![menu(51, "Goodbye")]]);
assert!(c.seen().is_empty(), "nothing fires during the hold");
assert_eq!(
window(&mut c),
(
true,
"What are you looking for?".to_string(),
"The bank".to_string(),
),
"the window stays up, unchanged, for the round trip"
);
}
c.frame(vec![vec![greeting(51, "The bank is north of here.")]]);
assert_eq!(c.seen(), ["GOSSIP_SHOW:nil"]);
assert_eq!(
window(&mut c),
(
true,
"The bank is north of here.".to_string(),
"Goodbye".to_string(),
),
"an in-place repaint"
);
}
+16 -22
View File
@@ -102,7 +102,6 @@ const NOT_YET_ON_A_LANE: &[(Row, &str, &str)] = &[
"::player::net::SkinnedFeed",
"the skinned state",
),
(Row::Queue, "::ui_bank::BankErrors", "the bank errors"),
(
Row::Queue,
"::player::view_subject::FarSight",
@@ -154,12 +153,6 @@ const NOT_YET_ON_A_LANE: &[(Row, &str, &str)] = &[
"::ui_quest::lines::QuestLines",
"the quest lines",
),
(
Row::HostHeld,
"::ui_gossip::GossipState",
"the gossip message's slots, which `feed_gossip` fires from; the gossip verbs, acting in \
their calls, hold it in the host",
),
(
Row::HostHeld,
"::ui_quest::QuestGiver",
@@ -185,11 +178,6 @@ const NOT_YET_ON_A_LANE: &[(Row, &str, &str)] = &[
"::death::DeathNet",
"the death stores, which `feed_death` fires the offers and the corpse range from; the death verbs, acting in their calls, hold them in the host",
),
(
Row::HostHeld,
"::ui_bank::BankOpen",
"the banker's cell, which `feed_bank` fires from; `PurchaseSlot` and `CloseBankFrame`, acting in their calls, hold it in the host",
),
(
Row::HostHeld,
"::ui_craft::CraftOpen",
@@ -267,6 +255,9 @@ const HOST_HELD_UNREAD: &[&str] = &[
"::target::Selection",
"::ui_action::PlayerActions",
"::ui_auction::AuctionOpen",
// Read through the host by the bank getters, and quiet: its events fire on the bank lane at
// their handlers, and `PurchaseSlot` and `CloseBankFrame` hold it in their calls.
"::ui_bank::BankOpen",
// Read, and quiet: the buff timers read the stamps through the host and no feed fires off
// them; the aura fields' watcher raises `PLAYER_AURAS_CHANGED`.
"::ui_aura::AuraDurations",
@@ -293,6 +284,9 @@ const HOST_HELD_UNREAD: &[&str] = &[
// through the host, and no feed fires off it, as the reply's `INSPECT_HONOR_UPDATE` goes on
// the notice lane at its handler and a re-target's fires in its call.
"::ui_honor::InspectHonor",
// Read through the host by the gossip getters, and quiet: the draw's `GOSSIP_SHOW` fires on the
// gossip lane at its handler, and the gossip verbs hold it in their calls.
"::ui_gossip::GossipState",
"::ui_loot::AutoLootByte",
"::ui_loot::LootLatch",
// Read through the host by the merchant getters, and quiet: its events fire on the merchant
@@ -440,17 +434,9 @@ pub(super) const READS_FRAME_FINAL: &[(&str, &[&str])] = &[
"ui_petition::PetitionRecords"]),
("::ui_items::feed::feed_item_stats", &["items::Items", "net::HomeBind", "net::ObjectStore",
"spell::mods::SpellModifiers"]),
("::ui_gossip::feed_gossip", &["items::Countdowns", "names::NameCache", "net::GuidIndex",
"net::ObjectStore", "ui_gossip::GossipState", "world_state::WorldStates"]),
// The purse the trade and mail verbs read off the VM; `GetMoney` reads it through the host.
("::ui_merchant::feed_purse", &["net::ObjectStore"]),
("::ui_merchant::feed_repair_costs", &["items::Countdowns", "items::Items", "names::NameCache",
"net::GuidIndex", "net::ObjectStore", "net::SelfGuid", "net::reputations::Reputations",
"ui_merchant::MerchantOpen"]),
("::ui_auction::feed_auction", &["items::Items", "names::NameCache",
"ui_auction::AuctionOpen"]),
("::ui_auction::reset_auction_on_world_enter", &["ui_auction::AuctionOpen"]),
("::ui_bank::feed_bank", &["net::ObjectStore", "ui_bank::BankErrors", "ui_bank::BankOpen"]),
("::ui_mail::feed_mail", &["go_templates::GameObjectTemplates", "items::Countdowns",
"items::Items", "names::NameCache", "net::GuidIndex", "net::ObjectStore",
"ui_mail::MailOpen", "ui_mail::pending::MailPending", "world_state::WorldStates"]),
@@ -647,7 +633,7 @@ const READ_ONLY_TO_HANDLERS: &[&str] = &[
];
/// Every lane's queue, by type: what a handler fills there fires in the packets' order.
fn lane_types() -> [TypeId; 31] {
fn lane_types() -> [TypeId; 33] {
[
TypeId::of::<crate::ui_items::feed::BagView>(),
// The views lane: what each packet changed in the stores.
@@ -705,12 +691,16 @@ fn lane_types() -> [TypeId; 31] {
TypeId::of::<crate::ui_petition::PetitionLane>(),
// The trainer lane: the list's and the state passes' pushes and signals.
TypeId::of::<crate::ui_trainer::TrainerLane>(),
// The bank lane: the open's signal and the refusals' lines.
TypeId::of::<crate::ui_bank::BankLane>(),
// The gossip lane: the greeting's draw.
TypeId::of::<crate::ui_gossip::GossipLane>(),
]
}
/// [`lane_types`] by the lane that drains each: a queue is stripped only from its own lane's row,
/// so another lane's read of the same type shows as the state it is.
fn lane_queue_owners() -> [(&'static str, TypeId); 31] {
fn lane_queue_owners() -> [(&'static str, TypeId); 33] {
[
("bag view", TypeId::of::<crate::ui_items::feed::BagView>()),
("unit views", TypeId::of::<crate::ui_unit::views::ViewLog>()),
@@ -764,6 +754,8 @@ fn lane_queue_owners() -> [(&'static str, TypeId); 31] {
("stable", TypeId::of::<crate::ui_stable::StableLane>()),
("charter", TypeId::of::<crate::ui_petition::PetitionLane>()),
("trainer", TypeId::of::<crate::ui_trainer::TrainerLane>()),
("bank", TypeId::of::<crate::ui_bank::BankLane>()),
("gossip", TypeId::of::<crate::ui_gossip::GossipLane>()),
]
}
@@ -917,6 +909,7 @@ fn the_drain_runs_after_the_feeds_and_before_the_tick() {
[
"auction house",
"bag view",
"bank",
"battlefield",
"cast reports",
"cast views",
@@ -928,6 +921,7 @@ fn the_drain_runs_after_the_feeds_and_before_the_tick() {
"dialogs",
"equip errors",
"field notices",
"gossip",
"guild",
"item text",
"item watchers",
@@ -24,8 +24,8 @@ use crate::game_plugins::schedule_tests::{census, type_names_available, SyncPoin
/// The most rows [`super::guard_tests::READS_FRAME_FINAL`] may hold, and the most inputs across
/// them: each only falls, so lower it when a row or an input goes.
const FRAME_FINAL_FEEDS: usize = 56;
const FRAME_FINAL_INPUTS: usize = 295;
const FRAME_FINAL_FEEDS: usize = 52;
const FRAME_FINAL_INPUTS: usize = 277;
/// The systems that may run after the dispatch (`apply_net_updates`) and run ahead of the drain
/// (`fire_in_order`), by a suffix of the system's name, with what each writes of the host's live
@@ -431,6 +431,11 @@ fn client(window: Window) -> Client {
world
.resource_mut::<crate::ui_bank::BankOpen>()
.open(BANKER);
// The banker's cell in the VM, which the open writes beside it (`0x4f850e`), for the vault.
crate::script_calls::with_hosted_vm(world, |s| {
s.expect("a VM")
.set_bank(Some(benilla_ui::script::BankState::default()))
});
match window {
Window::Trade => {
world
@@ -458,3 +458,104 @@ fn a_close_in_the_lists_frame_fires_after_its_show() {
c.frame(vec![vec![list(vec![row(1, 25)])], vec![trainer]]);
assert_eq!(c.seen(), ["MERCHANT_SHOW:nil", "MERCHANT_CLOSED:nil"]);
}
/// The verbs that test the purse read the coinage at the call, as `GetMoney` does (`0x518160`,
/// `+0xf70`): `PickupPlayerMoney`'s gate (`0x494cc0`) passes on a coinage its packet wrote, with no
/// push between, and refuses once a later packet lowers it.
#[test]
fn the_purse_tests_read_the_coinage_at_the_call() {
use benilla_protocol::field::FIELD_PLAYER_FIELD_COINAGE;
let mut c = client();
let pickup = "ClearCursor() PickupPlayerMoney(400) return GetCursorMoney()";
c.handle(vec![values(ME, &[(FIELD_PLAYER_FIELD_COINAGE, 500)])]);
assert_eq!(c.eval::<i64>(true, pickup), 400);
c.handle(vec![values(ME, &[(FIELD_PLAYER_FIELD_COINAGE, 300)])]);
assert_eq!(c.eval::<i64>(true, pickup), 0, "the purse falls short");
}
/// A bag item's repair cost read in a listener of each of two packets in one frame, each packet
/// moving the open vendor's PvP flag and the purse: `SetBagItem` prices the object at the call
/// after the vendor's discount (`0x534948` → `0x4faf30`, `0x4faf8a`), as stock
/// `ContainerFrame.lua:267` reads it in its listeners, so each listener reads the vendor its own
/// packet left, not the frame's last.
#[test]
fn a_repair_cost_reads_as_of_each_packet() {
use benilla_protocol::field::{
FIELD_PLAYER_FIELD_COINAGE, FIELD_PLAYER_INV_SLOT_HEAD, FIELD_UNIT_FLAGS,
};
/// `OBJECT_FIELD_ENTRY`, `ITEM_FIELD_STACK_COUNT`, `ITEM_FIELD_DURABILITY` and its maximum.
const ENTRY: u16 = 3;
const STACK: u16 = 14;
const DURABILITY: u16 = 46;
const MAX_DURABILITY: u16 = 47;
/// `PLAYER_BYTES_3`, absolute: byte 3 is the honor rank the discount steps by.
const PLAYER_BYTES_3: u16 = 195;
const SWORD: u64 = 0x4000_0000_0000_0707;
const SWORD_ENTRY: u32 = 2488;
let (full, discounted) = crate::ui_merchant::purse_fixture::sword_prices();
let mut c = client();
// One level-20 row at 1 copper a point for a sword (weapon subclass 7), quality 1 at 1.
let mut costs = vec![0u32; 29];
costs[7] = 1;
c.app.insert_resource(crate::ui_merchant::RepairTables(
benilla_formats::DurabilityTables::from_rows([(20, costs)], [(3, 1.0)]),
));
let mut sword = crate::items::test_template("Sword");
(sword.class, sword.subclass, sword.quality, sword.item_level) = (2, 7, 1, 20);
c.app
.world_mut()
.resource_mut::<crate::items::Items>()
.insert_template(SWORD_ENTRY, Some(sword));
let pack_1 = FIELD_PLAYER_INV_SLOT_HEAD + 2 * 23;
c.frame(vec![
vec![
SessionEvent::ItemCreate {
guid: SWORD,
container: false,
fields: benilla_protocol::ObjectFields::from_pairs(&[
(2, 0x3),
(ENTRY, SWORD_ENTRY),
(STACK, 1),
(DURABILITY, 35),
(MAX_DURABILITY, 75),
])
.into_created(benilla_protocol::messages::ObjectType::Item),
},
values(
ME,
&[
(pack_1, SWORD as u32),
(pack_1 + 1, (SWORD >> 32) as u32),
(PLAYER_BYTES_3, 8 << 24),
],
),
],
vec![list(vec![row(1, 159)])],
]);
c.frame(vec![]);
{
let script = c.app.world().non_send_resource::<UiScript>();
crate::ui_script::test_ui::load_tooltip_template(script);
}
c.eval::<()>(
false,
"Costs = {} \
CreateFrame('GameTooltip', 'TT', nil, 'TestTooltipTemplate'):SetOwner(CreateFrame('Frame'), 'ANCHOR_NONE') \
local f = CreateFrame('Frame') f:RegisterEvent('PLAYER_MONEY') \
f:SetScript('OnEvent', function() local _, cost = TT:SetBagItem(0, 1) table.insert(Costs, cost) end)",
);
c.frame(vec![
vec![
values(VENDOR, &[(FIELD_UNIT_FLAGS, 0x1000)]),
values(ME, &[(FIELD_PLAYER_FIELD_COINAGE, 1)]),
],
vec![
values(VENDOR, &[(FIELD_UNIT_FLAGS, 0)]),
values(ME, &[(FIELD_PLAYER_FIELD_COINAGE, 2)]),
],
]);
assert_eq!(
c.eval::<Vec<i64>>(false, "return Costs"),
[i64::from(discounted), i64::from(full)]
);
}
@@ -406,6 +406,8 @@ fn client() -> Client {
c.frame(vec![]);
c.frame(vec![]);
c.eval::<()>(false, "Seen = {}");
// The purse's VM copy, which no feed pushes: `GetMoney` reads the coinage through the host.
crate::script_calls::with_hosted_vm(c.app.world_mut(), |s| s.expect("a VM").set_money(123_456));
c
}
+5 -1
View File
@@ -102,8 +102,12 @@ live_reads! {
/// Another player's visible items, the active player's honor block and the inspect-honor
/// slot.
inspect: crate::ui_inspect::InspectLive<'static, 'static>,
/// The vendor window's rows, the buy-back rows and the repair total.
/// The vendor window's rows, the buy-back rows, the repair total and each item's cost.
merchant: crate::ui_merchant::MerchantLive<'static, 'static>,
/// The bank's purchase row.
bank: crate::ui_bank::BankLive<'static, 'static>,
/// The gossip message's slots and drawn text.
gossip: crate::ui_gossip::GossipLive<'static>,
/// The taxi map's slots and fares.
taxi: crate::ui_taxi::TaxiLive<'static, 'static>,
/// The dialogs' latches, clocks, meeting stone, summon and instance terms.
+4 -1
View File
@@ -816,7 +816,10 @@ mod tests {
#[test]
fn our_own_death_clears_the_cursor_after_player_dead() {
let mut c = dying_client();
crate::script_calls::with_hosted_vm(c.world(), |s| s.expect("a VM").set_money(500));
c.values(
ME,
&[(benilla_protocol::field::FIELD_PLAYER_FIELD_COINAGE, 500)],
);
c.lua(
r#"SEEN = {} local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_DEAD") f:RegisterEvent("CURSOR_UPDATE")
+132
View File
@@ -0,0 +1,132 @@
//! The bank lane: what the bank's packet handlers signal, each fired at its packet's place among
//! the other lanes' items ([`crate::lanes`]). In the reference each signals inside itself:
//! `SMSG_SHOW_BANK`'s open writes the banker's cell and signals `BANKFRAME_OPENED` (`0x5e3f78` →
//! `0x4f850e`, `0x4f8522`), and `SMSG_BUY_BANK_SLOT_RESULT`'s refusal shows its line through
//! `DisplayError` (`0x5e3f8d`). A bought slot's `PLAYERBANKBAGSLOTS_CHANGED` is the field watch's,
//! on the notice lane ([`crate::field_watch`]).
use std::collections::VecDeque;
use bevy::prelude::*;
use benilla_ui::script::UiScript;
use crate::lanes::{LaneApp, Stamp, Until};
use crate::ui_action::MessageSink;
/// What an item signals.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum BankEvent {
/// `BANKFRAME_OPENED` (`0x4f8522`), behind the open's cell write (`0x4f850e`).
Opened,
/// A refusal's message line, by its `GlobalStrings` key.
Line(&'static str),
/// What a close core run outside a call signalled while this lane held items, parked in the
/// VM of the session under the key ([`crate::script_calls::park_since`]).
Parked { session: u64, key: u64 },
}
/// The bank lane's queue.
#[derive(Resource, Default)]
pub(crate) struct BankLane {
events: VecDeque<(Stamp, BankEvent)>,
}
impl BankLane {
/// Queue `event` stamped now, behind everything stamped before it.
pub(crate) fn push(&mut self, event: BankEvent) {
let stamp = crate::lanes::stamp();
let at = self.events.partition_point(|(s, _)| *s < stamp);
self.events.insert(at, (stamp, event));
}
fn head(&self) -> Option<Stamp> {
self.events.front().map(|(s, _)| *s)
}
/// The queued items with their stamps, in order.
#[cfg(test)]
pub(crate) fn stamped(&self) -> Vec<(Stamp, BankEvent)> {
self.events.iter().copied().collect()
}
}
/// The lane, and its drop while no in-game interface is up to hear it: a signal is for the
/// interface that was up.
pub(super) fn add_bank_lane(app: &mut App) {
app.init_resource::<BankLane>()
.add_lane(
"bank",
|world| world.get_resource::<BankLane>()?.head(),
fire_bank,
None,
)
.add_systems(
Update,
drop_bank_events
.in_set(crate::ui_script::UiFeed)
.run_if(not(crate::ui_script::ingame_ui_up)),
);
}
fn drop_bank_events(mut lane: ResMut<BankLane>) {
lane.events.clear();
}
/// The lane's firing: each item the drain admits, signalled with the host lent. It stops after an
/// item whose firing stamped anything, so what that pushed fires next.
fn fire_bank(
In(until): In<Until>,
mut vm: crate::script_calls::HostedVm,
mut lane: ResMut<BankLane>,
mut sink: MessageSink,
) {
if !vm.run(|script| script.is_some()) {
return;
}
while let Some(at) = lane.head().filter(|&s| until.admits(s)) {
let Some((_, event)) = lane.events.pop_front() else {
return;
};
let ((), moved) = crate::lanes::firing(at, || {
vm.run(|script| {
if let Some(script) = script {
fire(script, &mut sink, event);
}
})
});
if moved {
return;
}
}
}
/// One item's signal: `BANKFRAME_OPENED` carries no argument (`0x4f8522`), and stock
/// `BankFrame.lua:127` titles with `UnitName("npc")`.
fn fire(script: &mut UiScript, sink: &mut MessageSink, event: BankEvent) {
match event {
BankEvent::Opened => script.fire_event("BANKFRAME_OPENED", vec![]),
// Shown, and spoken, as its message row says: insufficient funds is speech `0x16`.
BankEvent::Line(key) => {
if let Some(line) = crate::ui_action::keyed_line(script, key) {
crate::ui_action::show_now(script, sink, "ui_bank", line);
}
}
BankEvent::Parked { session, key } => {
if script.session() == session {
script.run_parked(key);
}
}
}
}
/// Run the close `core` behind the lane ([`crate::lanes::behind`]), so a close never fires ahead
/// of the open it closes.
pub(super) fn behind_lane(world: &mut World, core: impl FnOnce(&mut World)) {
let head = world.get_resource::<BankLane>().and_then(BankLane::head);
crate::lanes::behind(world, head, core, |world, session, key| {
world
.resource_mut::<BankLane>()
.push(BankEvent::Parked { session, key });
});
}
+48
View File
@@ -0,0 +1,48 @@
//! The bank getters' reads at the call, the call host's half of
//! [`benilla_ui::script::BankSlotsQuery`]: the active player's bought bag slots and the price of
//! the next, as `GetNumBankSlots` and `GetBankSlotCost` read them (`0x4f85b0`, `0x4f8630`), the
//! player's store as of the drain's place ([`crate::ui_unit::views`]).
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use benilla_ui::script::live::LiveAsk;
use benilla_ui::script::{BankSlotsQuery, BankState};
use super::BankPrices;
use crate::net::Objects;
/// What the bank getters read as they run; the price table absent without client data, where
/// every price reads 0.
#[derive(SystemParam)]
pub(crate) struct BankLive<'w, 's> {
objects: Objects<'w, 's>,
stores: crate::ui_unit::views::ViewStores<'w, 's>,
prices: Option<Res<'w, BankPrices>>,
}
impl BankLive<'_, '_> {
/// `PLAYER_BYTES_2` byte 2 of the active player as of the drain's place (`0x4f85dd`) and the
/// price row after it (`0x4f8690`), both 0 with no player (`0x4f85e3`, `0x4f866a`); no banker
/// is tested.
fn slots(&self) -> BankState {
let Some(player) = self.objects.player().and_then(|e| self.stores.get(e).ok()) else {
return BankState::default();
};
let bought = player.0.player_bank_bag_slots_purchased().unwrap_or(0);
BankState {
num_purchased: u32::from(bought),
next_cost: self
.prices
.as_deref()
.and_then(|p| p.0.next_slot_price(bought))
.unwrap_or(0),
}
}
}
impl crate::script_calls::live::LiveFamily for BankLive<'_, '_> {
fn answer(&mut self, ask: &mut LiveAsk<'_>) -> bool {
ask.answer(|_: &BankSlotsQuery| Some(self.slots()))
}
}
+35 -85
View File
@@ -1,7 +1,8 @@
//! The bank window's app side: the open banker, the purchase row, the bank events and the bank
//! verbs' engine half ([`BankSteps`]). The vault itself is descriptor fields that
//! [`crate::ui_items`] feeds as containers `-1` and `5..=10`, and moves items in and out of on a
//! right-click.
//! The bank window's app side: the open banker, whose cell the handlers and the close write into
//! the VM as they write it here, the purchase row the getters read through the host ([`live`]), the
//! bank lane the handlers signal on ([`lane`]) and the bank verbs' engine half ([`BankSteps`]).
//! The vault itself is descriptor fields that [`crate::ui_items`] feeds as containers `-1` and
//! `5..=10`, and moves items in and out of on a right-click.
use benilla_protocol::messages::bank_slot_result;
use bevy::ecs::system::SystemParam;
@@ -10,8 +11,7 @@ use bevy::prelude::*;
use benilla_ui::script::{BankState, UiScript};
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_script::UiFeed;
use crate::ui_session::{CoreCloses, LatchApp, LatchedWindow, NpcWindow};
use crate::ui_session::{LatchApp, LatchedWindow, NpcWindow};
/// `BankBagSlotPrices.dbc`, the client's own slot prices; absent, the purchase row shows 0 and
/// the server still charges its own price.
@@ -60,50 +60,54 @@ impl LatchedWindow for BankOpen {
/// The close core, `0x4f84d0` with a zero guid, as `0x493310` enters it: `BANKFRAME_CLOSED` first
/// and with no test of the cell (`0x4f84df`); then the latch release on the cell as it stands
/// after the fire (`0x4f84eb`), and the cell cleared (`0x4f84f2`). The fire comes before the
/// clear, so `OnHide`'s `CloseBankFrame()` re-enters with the cell set.
/// after the fire (`0x4f84eb`), and the cell cleared (`0x4f84f2`), in the VM too. The fire comes
/// before the clear, so `OnHide`'s `CloseBankFrame()` re-enters with the cell set. It runs behind
/// the bank lane, so it never fires ahead of an open still queued there.
pub(crate) fn close_core(world: &mut World) {
crate::ui_session::fire(world, |script| {
script.fire_event("BANKFRAME_CLOSED", vec![]);
lane::behind_lane(world, |world| {
crate::ui_session::fire(world, |script| {
script.fire_event("BANKFRAME_CLOSED", vec![]);
});
if let Some(banker) = world.resource::<BankOpen>().banker {
crate::ui_session::release(world, banker);
}
if world.resource::<BankOpen>().is_open() {
world.resource_mut::<BankOpen>().clear();
crate::ui_session::note_closed(world, NpcWindow::Bank);
crate::ui_session::with_script(world, |script| script.set_bank(None));
}
});
if let Some(banker) = world.resource::<BankOpen>().banker {
crate::ui_session::release(world, banker);
}
if world.resource::<BankOpen>().is_open() {
world.resource_mut::<BankOpen>().clear();
crate::ui_session::note_closed(world, NpcWindow::Bank);
crate::ui_session::with_script(world, |script| script.set_bank(None));
}
}
/// `SMSG_SHOW_BANK` (`0x5e3f78` → `0x4f84d0(banker)`): the latch first, with no test of the cell
/// (`0x4f8507`), then the cell (`0x4f850e`); the feed fires `BANKFRAME_OPENED` (`0x4f8522`).
/// (`0x4f8507`), then the cell (`0x4f850e`), in the VM too, then `BANKFRAME_OPENED` (`0x4f8522`)
/// on the bank lane at the packet's place.
pub(crate) fn open_bank(world: &mut World, banker: u64) {
crate::ui_session::register(world, banker);
world.resource_mut::<BankOpen>().open(banker);
crate::ui_session::with_script(world, |script| {
script.set_bank(Some(BankState::default()));
});
if let Some(mut lane) = world.get_resource_mut::<BankLane>() {
lane.push(BankEvent::Opened);
}
}
/// `SMSG_BUY_BANK_SLOT_RESULT` codes waiting for the error line; vmangos sends one only on failure.
#[derive(Resource, Default)]
pub(crate) struct BankErrors(pub Vec<u32>);
pub(crate) mod lane;
pub(crate) mod live;
mod net;
pub(crate) use lane::{BankEvent, BankLane};
pub(crate) use live::BankLive;
pub(crate) struct UiBankPlugin;
impl Plugin for UiBankPlugin {
fn build(&self, app: &mut App) {
net::register(app);
lane::add_bank_lane(app);
app.init_resource::<BankOpen>()
.init_resource::<BankErrors>()
// No leg of the sweep closes the bank: its interaction close (`0x490b5f`) does.
.add_latched_window::<BankOpen>()
.add_systems(
Update,
(feed_bank
.in_set(UiFeed)
.in_set(crate::ui_session::NpcWindowFeed),),
);
.add_latched_window::<BankOpen>();
}
}
@@ -119,60 +123,6 @@ fn bank_slot_error_key(result: u32) -> Option<&'static str> {
})
}
/// Push the purchase row and fire the bank events but the close, the core's ([`close_core`]).
/// `BANKFRAME_OPENED` has no arguments: the reference fires it bare (`0x4f8522`), and stock
/// `BankFrame.lua:127` titles with `UnitName("npc")`.
fn feed_bank(
mut vm: crate::script_calls::VmWith<Res<'static, BankOpen>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
prices: Option<Res<BankPrices>>,
mut errors: ResMut<BankErrors>,
mut last: crate::ui_script::WindowMemo<Option<BankState>>,
mut closes: CoreCloses,
mut sink: crate::ui_action::MessageSink,
) {
let banker = vm.own().banker;
vm.run(|script| {
let Some(script) = script else {
return;
};
let last = last.get(script);
if closes.since_last(script, NpcWindow::Bank) {
*last = None;
}
// Refusals show, and speak, as their message rows say: insufficient funds is speech `0x16`.
let lines: Vec<_> = errors
.0
.drain(..)
.filter_map(|result| crate::ui_action::keyed_line(script, bank_slot_error_key(result)?))
.collect();
crate::ui_action::show_messages(script, &mut sink, "ui_bank", lines);
let store = self_q.iter().next();
let purchased = store
.and_then(|s| s.0.player_bank_bag_slots_purchased())
.unwrap_or(0);
let fresh = banker.map(|_| BankState {
num_purchased: u32::from(purchased),
next_cost: prices
.as_ref()
.and_then(|p| p.0.next_slot_price(purchased))
.unwrap_or(0),
});
if fresh == *last {
return;
}
script.set_bank(fresh.clone());
match (&*last, &fresh) {
(None, Some(_)) => script.fire_event("BANKFRAME_OPENED", vec![]),
// The bought count moved: a bought slot has no reply packet, so this confirms it.
(Some(_), Some(_)) => script.fire_event("PLAYERBANKBAGSLOTS_CHANGED", vec![]),
// The core told the VM; a disconnect's clear tells it nothing.
(Some(_), None) | (None, None) => {}
}
*last = fresh;
})
}
/// The bank verbs' engine half, which the call host applies inside each call
/// ([`crate::script_calls::ScriptCallHost`]): the banker's cell, and what `PurchaseSlot` reads and
/// sends through.
+10 -10
View File
@@ -1,9 +1,10 @@
//! The bank's packet handlers: they fill [`BankOpen`] and [`BankErrors`] for the bank feed.
//! The bank's packet handlers: the open writes [`BankOpen`], and each signals on the bank lane
//! at its packet ([`super::lane`]).
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::prelude::*;
use super::{BankErrors, BankOpen};
use super::{BankEvent, BankLane, BankOpen};
use crate::net::NetHandlerApp;
/// Register the bank handlers and the session-end listener.
@@ -23,9 +24,14 @@ fn on_show_bank(In(ev): In<SessionEvent>, mut commands: Commands) {
}
}
fn on_buy_slot_result(In(ev): In<SessionEvent>, mut errors: ResMut<BankErrors>) {
/// `SMSG_BUY_BANK_SLOT_RESULT` (`0x5e3f8d`), a refusal: vmangos sends it only on failure. Its
/// line shows inside the handler, on the bank lane.
fn on_buy_slot_result(In(ev): In<SessionEvent>, mut lane: ResMut<BankLane>) {
if let SessionEvent::BuyBankSlotResult { result } = ev {
bank_buy_slot_result(result, &mut errors);
debug!("net: bank slot purchase refused (code {result})");
if let Some(key) = super::bank_slot_error_key(result) {
lane.push(BankEvent::Line(key));
}
}
}
@@ -33,9 +39,3 @@ fn on_buy_slot_result(In(ev): In<SessionEvent>, mut errors: ResMut<BankErrors>)
fn on_session_end(In(_): In<SessionEvent>, mut bank: ResMut<BankOpen>) {
bank.clear_session();
}
/// `SMSG_BUY_BANK_SLOT_RESULT`, a refusal: vmangos sends it only on failure.
fn bank_buy_slot_result(result: u32, errors: &mut BankErrors) {
debug!("net: bank slot purchase refused (code {result})");
errors.0.push(result);
}
+121
View File
@@ -0,0 +1,121 @@
//! The gossip lane: the greeting's `GOSSIP_SHOW`, fired at its packet's place among the other
//! lanes' items ([`crate::lanes`]). In the reference it signals inside `0x4e2010` (`0x4e22b5`),
//! right after the text's write (`0x4e229a`), which a message runs (`0x4e27df`) and so does its
//! text's arrival (`0x4e22f2`); the getters its listeners call read the store through the host
//! ([`super::live`]).
use std::collections::VecDeque;
use bevy::prelude::*;
use benilla_ui::script::UiScript;
use crate::lanes::{LaneApp, Stamp, Until};
/// What an item signals.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GossipEvent {
/// `GOSSIP_SHOW` (`0x4e22b5`), with no argument.
Show,
/// What a close core run outside a call signalled while this lane held items, parked in the
/// VM of the session under the key ([`crate::script_calls::park_since`]).
Parked { session: u64, key: u64 },
}
/// The gossip lane's queue.
#[derive(Resource, Default)]
pub(crate) struct GossipLane {
events: VecDeque<(Stamp, GossipEvent)>,
}
impl GossipLane {
/// Queue `event` stamped now, behind everything stamped before it.
pub(crate) fn push(&mut self, event: GossipEvent) {
let stamp = crate::lanes::stamp();
let at = self.events.partition_point(|(s, _)| *s < stamp);
self.events.insert(at, (stamp, event));
}
fn head(&self) -> Option<Stamp> {
self.events.front().map(|(s, _)| *s)
}
/// The queued items with their stamps, in order.
#[cfg(test)]
pub(crate) fn stamped(&self) -> Vec<(Stamp, GossipEvent)> {
self.events.iter().copied().collect()
}
}
/// The lane, and its drop while no in-game interface is up to hear it: a signal is for the
/// interface that was up.
pub(super) fn add_gossip_lane(app: &mut App) {
app.init_resource::<GossipLane>()
.add_lane(
"gossip",
|world| world.get_resource::<GossipLane>()?.head(),
fire_gossip,
None,
)
.add_systems(
Update,
drop_gossip_events
.in_set(crate::ui_script::UiFeed)
.run_if(not(crate::ui_script::ingame_ui_up)),
);
}
fn drop_gossip_events(mut lane: ResMut<GossipLane>) {
lane.events.clear();
}
/// The lane's firing: each item the drain admits, signalled with the host lent. It stops after an
/// item whose firing stamped anything, so what that pushed fires next.
fn fire_gossip(
In(until): In<Until>,
mut vm: crate::script_calls::HostedVm,
mut lane: ResMut<GossipLane>,
) {
if !vm.run(|script| script.is_some()) {
return;
}
while let Some(at) = lane.head().filter(|&s| until.admits(s)) {
let Some((_, event)) = lane.events.pop_front() else {
return;
};
let ((), moved) = crate::lanes::firing(at, || {
vm.run(|script| {
if let Some(script) = script {
fire(script, event);
}
})
});
if moved {
return;
}
}
}
fn fire(script: &mut UiScript, event: GossipEvent) {
match event {
GossipEvent::Show => script.fire_event("GOSSIP_SHOW", vec![]),
GossipEvent::Parked { session, key } => {
if script.session() == session {
script.run_parked(key);
}
}
}
}
/// Run the close `core` behind the lane ([`crate::lanes::behind`]), so a close never fires ahead
/// of the show it closes.
pub(super) fn behind_lane(world: &mut World, core: impl FnOnce(&mut World)) {
let head = world
.get_resource::<GossipLane>()
.and_then(GossipLane::head);
crate::lanes::behind(world, head, core, |world, session, key| {
world
.resource_mut::<GossipLane>()
.push(GossipEvent::Parked { session, key });
});
}
+25
View File
@@ -0,0 +1,25 @@
//! The gossip getters' reads at the call, the call host's half of
//! [`benilla_ui::script::GossipQuery`]: the message's slots and the drawn text as the handlers and
//! the selects left them (`0xbbbe90`, `0xbb74c0`, `0xbbb678`), which a close leaves for the getters
//! (`0x4e1fa0`).
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use benilla_ui::script::live::LiveAsk;
use benilla_ui::script::GossipQuery;
use super::GossipState;
/// What the gossip getters read as they run; the store absent in a harness without the gossip
/// window, where the getters read the VM's copy.
#[derive(SystemParam)]
pub(crate) struct GossipLive<'w> {
state: Option<Res<'w, GossipState>>,
}
impl crate::script_calls::live::LiveFamily for GossipLive<'_> {
fn answer(&mut self, ask: &mut LiveAsk<'_>) -> bool {
ask.answer(|_: &GossipQuery| self.state.as_deref().map(super::menu))
}
}
+101 -497
View File
@@ -1,18 +1,17 @@
//! The gossip window's app side: [`GossipState`] holds the menu and the NPC-text records,
//! [`feed_gossip`] pushes the menu and fires its events, the selects apply in their calls over its
//! slots ([`GossipVerbs`]), and so does `CloseGossip`'s close core.
//! As in the reference's `0x4e2010`, a menu opens only once its greeting is drawn.
//! The gossip window's app side: [`GossipState`] holds the menu and the NPC-text records, which
//! the getters read through the host at the call ([`live`]); the greeting's draw signals on the
//! gossip lane at its packet ([`lane`]), the selects apply in their calls over its slots
//! ([`GossipVerbs`]), and so does `CloseGossip`'s close core.
//! As in the reference's `0x4e2010`, a menu shows only once its greeting is drawn.
use benilla_protocol::messages::{select_greeting, GossipOption, NpcTextBlock};
use bevy::prelude::*;
use benilla_ui::script::{GossipMenu, GossipOptionView, GossipQuestRow};
use crate::names::NameCache;
use crate::net::{Guid, NetCommands, ObjectStore, SelfPlayer};
use crate::query_cache::{Ask, QueryCache};
use crate::query_cache::QueryCache;
use crate::ui_quest::row_is_active;
use crate::ui_script::{SweepApp, SweepLeg, UiFeed};
use crate::ui_script::{SweepApp, SweepLeg};
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
@@ -21,8 +20,11 @@ use crate::ui_session::{LatchApp, LatchedWindow, NpcWindow};
pub(crate) struct GossipState {
pub(crate) npc: Option<u64>,
pub(crate) text_id: u32,
/// This open's drawn greeting; `None` while its text query is out.
/// This open's drawn greeting, unexpanded; `None` while its text query is out.
pub(crate) greeting: Option<String>,
/// The text buffer `0xbbb678`: the last drawn greeting, expanded at its draw (`0x4e2274`,
/// `0x4e229a`), which `GetGossipText` returns (`0x4e28c0`). No close or message clears it.
pub(crate) text: String,
pub(crate) options: Vec<GossipOption>,
/// The packet's quest rows, `(quest_id, dialog-status icon, level, title)`. The level is kept
/// because `GetGossipAvailableQuests` and `GetGossipActiveQuests` return `(title, level)`
@@ -35,8 +37,8 @@ pub(crate) struct GossipState {
/// The reference's text-pending latch `[0xbbb670]`: set on the text's cache miss
/// (`0x4e2050`), cleared once the record is found (`0x4e206e`); no close touches it.
pending: bool,
/// The messages latched so far, which the feed shows each of once.
generation: u64,
/// A greeting was drawn: the caller owes its expansion into [`Self::text`] and `GOSSIP_SHOW`.
drawn: bool,
}
impl GossipState {
@@ -84,7 +86,6 @@ impl GossipState {
self.options = options;
self.quests = quests;
self.greeting = None;
self.generation += 1;
// A miss, id 0 included (`0x55d530`), holds the latch until the record lands.
self.pending = true;
if text_id == 0 {
@@ -115,7 +116,10 @@ impl GossipState {
fn resolve_greeting(&mut self, npc_gender: u8) {
self.pending = false;
match self.draw_greeting(self.text_id, npc_gender) {
Some(line) => self.greeting = Some(line),
Some(line) => {
self.greeting = Some(line);
self.drawn = true;
}
None => {
debug!(
"ui_gossip: missing gossip text (id {}) — ending the interaction",
@@ -130,6 +134,14 @@ impl GossipState {
pub(crate) fn take_missing_text(&mut self) -> bool {
std::mem::take(&mut self.missing_text)
}
/// The greeting drawn since the last take, unexpanded: its expansion and `GOSSIP_SHOW` are
/// owed to the caller, which holds the world the expansion reads.
pub(crate) fn take_drawn(&mut self) -> Option<String> {
std::mem::take(&mut self.drawn)
.then(|| self.greeting.clone())
.flatten()
}
}
/// The greeting draw's roll in `[1.0, 2.0)`, built as the reference builds it: random bits in the
@@ -148,6 +160,8 @@ impl GossipState {
self.quests.clear();
self.missing_text = false;
self.pending = false;
self.drawn = false;
self.text.clear();
}
/// The disconnect clear; NPC text is static per id, so the records stay.
@@ -156,8 +170,12 @@ impl GossipState {
}
}
mod net;
pub(crate) mod lane;
pub(crate) mod live;
pub(crate) mod net;
mod verbs;
pub(crate) use lane::{GossipEvent, GossipLane};
pub(crate) use live::GossipLive;
pub(crate) use verbs::GossipVerbs;
#[cfg(test)]
mod verb_tests;
@@ -165,22 +183,17 @@ mod verb_tests;
#[cfg(test)]
pub(crate) use net::gossip_menu as open_menu;
/// The gossip window's handlers and feed.
/// The gossip window's handlers and lane.
pub(crate) struct UiGossipPlugin;
impl Plugin for UiGossipPlugin {
fn build(&self, app: &mut App) {
net::register(app);
lane::add_gossip_lane(app);
app.init_resource::<GossipState>()
.add_latched_window::<GossipState>()
// `0x490ac1` → `0x4e1f60` → `0x4e1fa0(0)`, the core.
.add_sweep_leg(SweepLeg::Gossip, close_core)
.add_systems(
Update,
(feed_gossip
.in_set(UiFeed)
.in_set(crate::ui_session::NpcWindowFeed),),
);
.add_sweep_leg(SweepLeg::Gossip, close_core);
}
}
@@ -204,15 +217,18 @@ impl LatchedWindow for GossipState {
/// them still. A menu still waiting on its text was latched at its open, so its close fires too.
/// `SMSG_GOSSIP_COMPLETE` (`0x4e2824`), the missing-text path (`0x4e2197`), `0x493310` and the
/// leave-world sweep's leg (`0x490ac1` → `0x4e1f60`) call it as it is.
/// It runs behind the gossip lane, so it never fires ahead of a show still queued there.
pub(crate) fn close_core(world: &mut World) {
let Some(npc) = world.resource::<GossipState>().npc else {
return;
};
crate::ui_session::release(world, npc);
world.resource_mut::<GossipState>().npc = None;
crate::ui_session::note_closed(world, NpcWindow::Gossip);
crate::ui_session::fire(world, |script| {
script.fire_event("GOSSIP_CLOSED", vec![]);
lane::behind_lane(world, |world| {
crate::ui_session::release(world, npc);
world.resource_mut::<GossipState>().npc = None;
crate::ui_session::note_closed(world, NpcWindow::Gossip);
crate::ui_session::fire(world, |script| {
script.fire_event("GOSSIP_CLOSED", vec![]);
});
});
}
@@ -247,13 +263,13 @@ const GOSSIP_ICON_TYPES: [&str; 14] = [
"gossip",
];
/// The Lua-facing menu, the slots and the text as the getters read them, which a close leaves;
/// `None` before any text. [`feed_gossip`] never calls it while the text is pending.
fn snapshot(state: &GossipState) -> Option<GossipMenu> {
Some(GossipMenu {
greeting: state.greeting.clone()?,
// Active or available by the wire icon: the reference's `{3,4}` test in `0x4e2430` and
// `0x4e2580`, behind `GetGossipAvailableQuests` and `GetGossipActiveQuests`.
/// The store as the getters read it at the call, which a close leaves: the text buffer, the used
/// option slots in slot order, an index with its top bit set skipped as unused (`0x4e28ec`), and
/// the quest rows, active or available by the wire icon (the `{3,4}` test of `0x4e2430` and
/// `0x4e2580`).
pub(crate) fn menu(state: &GossipState) -> GossipMenu {
GossipMenu {
greeting: state.text.clone(),
quests: state
.quests
.iter()
@@ -266,170 +282,70 @@ fn snapshot(state: &GossipState) -> Option<GossipMenu> {
options: state
.options
.iter()
.filter(|o| (o.index as i32) >= 0)
.map(|o| GossipOptionView {
label: o.message.clone(),
icon_type: gossip_icon_type(o.icon).into(),
})
.collect(),
})
}
/// 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>>,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
names: Res<NameCache>,
commands: Res<NetCommands>,
objects: crate::net::Objects,
unit_names: crate::ui_unit::NameTables,
states: Res<crate::world_state::WorldStates>,
mut shown_message: crate::ui_script::WindowMemo<u64>,
) {
vm.turn(|script, state| {
let Some(script) = script else {
return;
};
let shown_message = shown_message.get(script);
let shown = script.gossip();
// 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);
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.
if state.text_pending() {
return;
}
let mut fresh = snapshot(&state);
let new_message = std::mem::replace(shown_message, state.generation) != state.generation;
// The greeting's `$N`/`$B`/`$G`/`$<n>w` macros expand once per message, at its draw: the
// reference writes the expanded text to `0xbbb678` there (`0x4e2274`, `0x4e229a`), which
// `GetGossipText` returns (`0x4e28c0`) until the next message draws.
if let Some(greeting) = fresh.as_mut().map(|m| &mut m.greeting) {
match shown.as_ref().filter(|_| !new_message) {
Some(drawn) => greeting.clone_from(&drawn.greeting),
None => {
let player =
crate::npc_text::player_identity(&self_q, &names, unit_names.get());
*greeting = crate::npc_text::substitute(
greeting,
&crate::npc_text::MacroContext {
subject: player.as_ref(),
states: &states,
},
);
}
}
}
if fresh != shown {
script.set_gossip(fresh.clone());
}
// `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![]);
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::ClientCommand;
use benilla_ui::script::UiScript;
use bevy::ecs::system::RunSystemOnce;
/// The one type in [`GOSSIP_ICON_TYPES`] with no art in the 1.12 install.
const GOSSIP_ICON_TYPE_UNSHIPPED: &str = "auctioneer";
/// The stock window titles itself with `UnitName("npc")`, which looks the name up with a
/// callback (`0x5171b8`), at the `GOSSIP_SHOW` a menu brings; the frames between read. A
/// refused name is asked by no frame, and again by the next menu.
#[test]
fn a_refused_npc_name_is_asked_at_the_next_menu_never_by_a_frame() {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(NetCommands(tx))
.init_resource::<GossipState>()
.init_resource::<NameCache>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::world_state::WorldStates>()
.add_systems(Update, feed_gossip);
app.insert_non_send_resource(UiScript::new().unwrap());
// A creature not streamed: its name is asked by the guid's entry bits.
const NPC: u64 = 0xF130_0000_4500_0001;
let menu = |app: &mut App| {
let mut state = app.world_mut().resource_mut::<GossipState>();
state.remember_record(
7,
vec![NpcTextBlock {
probability: 1.0,
male: "Hello".into(),
female: "Hello".into(),
}],
);
state.open_menu(NPC, 7, Vec::new(), Vec::new(), 0);
};
menu(&mut app);
app.update();
assert_eq!(crate::query_cache::queries(&rx).len(), 1);
app.world_mut()
.resource_mut::<NameCache>()
.insert_creature(0x45, None);
for _ in 0..600 {
app.update();
fn option(index: u32, icon: u8, label: &str) -> GossipOption {
GossipOption {
index,
icon,
coded: false,
message: label.into(),
}
assert!(crate::query_cache::queries(&rx).is_empty());
menu(&mut app);
app.update();
assert_eq!(crate::query_cache::queries(&rx).len(), 1);
}
/// The getters' store: the text buffer, empty before any draw, and the used slots in slot
/// order, an index with its top bit set skipped (`0x4e28ec`).
#[test]
fn snapshot_is_none_until_a_menu_opens() {
fn the_menu_reads_the_text_buffer_and_the_used_slots() {
let mut state = GossipState::default();
assert!(snapshot(&state).is_none());
state.npc = Some(0x42);
state.greeting = Some("Hello".into());
state.options = vec![GossipOption {
index: 3,
icon: 1,
coded: false,
message: "Browse".into(),
}];
let menu = snapshot(&state).expect("open");
assert_eq!(menu.greeting, "Hello");
assert_eq!(menu.options.len(), 1);
assert_eq!(menu.options[0].icon_type, "vendor");
assert_eq!(menu(&state), GossipMenu::default());
state.text = "Hello".into();
state.options = vec![
option(3, 1, "Browse"),
option(0x8000_0000, 0, "Hidden"),
option(4, 2, "Fly"),
];
let read = menu(&state);
assert_eq!(read.greeting, "Hello");
let rows: Vec<_> = read
.options
.iter()
.map(|o| (o.label.as_str(), o.icon_type.as_str()))
.collect();
assert_eq!(rows, [("Browse", "vendor"), ("Fly", "taxi")]);
}
/// A first visit keeps the menu closed until its text answers, then opens it complete: the
/// reference's greeting write and `GOSSIP_SHOW` sit together on its one success path
/// (`0x4e229a`, `0x4e22b0`), and its cache-miss exit fires nothing.
/// A first visit draws nothing until its text answers, then draws once: the reference's
/// greeting write and `GOSSIP_SHOW` sit together on its one success path (`0x4e229a`,
/// `0x4e22b0`), and its cache-miss exit draws nothing.
#[test]
fn first_visit_holds_the_menu_until_the_text_answers() {
fn first_visit_draws_once_its_text_answers() {
let mut state = GossipState::default();
let options = vec![GossipOption {
index: 0,
icon: 9,
coded: false,
message: "I wish to join the battle!".into(),
}];
let wants_query = state.open_menu(0x42, 50, options, Vec::new(), 0);
assert!(wants_query, "first visit asks for the text");
let options = vec![option(0, 9, "I wish to join the battle!")];
assert!(
snapshot(&state).is_none(),
"menu held closed while the query is in flight"
state.open_menu(0x42, 50, options, Vec::new(), 0),
"first visit asks for the text"
);
assert_eq!(
state.take_drawn(),
None,
"nothing drawn while the query is out"
);
assert_eq!(menu(&state).options.len(), 1, "the slots are the message's");
state.text_arrived(
50,
vec![NpcTextBlock {
@@ -439,325 +355,11 @@ mod tests {
}],
0,
);
let menu = snapshot(&state).expect("text landed — the menu opens now, complete");
assert_eq!(menu.greeting, "The Alliance needs you!");
assert_eq!(menu.options.len(), 1, "options open WITH the greeting");
}
// ── The hold, through the real feed ─────────────────────────────────────────
/// Records the two gossip events the VM sees, in order.
const RECORDER: &str = r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("GOSSIP_SHOW")
f:RegisterEvent("GOSSIP_CLOSED")
f:SetScript("OnEvent", function() table.insert(SEEN, event) end)
"#;
/// The real [`feed_gossip`] over a VM; `prepare` seeds the VM, and the receiver gets what
/// went out.
fn feed_app(
prepare: impl FnOnce(&mut UiScript),
) -> (App, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(NetCommands(tx))
.init_resource::<GossipState>()
.init_resource::<NameCache>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::world_state::WorldStates>()
.add_systems(Update, feed_gossip);
let mut script = UiScript::new().unwrap();
prepare(&mut script);
app.insert_non_send_resource(script);
(app, rx)
}
fn state(app: &mut App) -> Mut<'_, GossipState> {
app.world_mut().resource_mut::<GossipState>()
}
/// One frame, then the events the VM saw and `GetGossipText()` (`""` for nil).
fn tick(app: &mut App) -> (Vec<String>, 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();
let text = s.eval::<String>("return GetGossipText() or ''").unwrap();
(seen, text)
}
fn record(line: &str) -> Vec<NpcTextBlock> {
vec![NpcTextBlock {
probability: 0.0,
male: line.into(),
female: String::new(),
}]
}
fn option(label: &str) -> Vec<GossipOption> {
vec![GossipOption {
index: 0,
icon: 0,
coded: false,
message: label.into(),
}]
}
/// A host that applies the gossip calls alone, as the whole client's applies them.
struct GossipHost<'a, 'w, 's>(&'a mut super::verbs::GossipVerbs<'w, 's>);
impl benilla_ui::script::CallHost for GossipHost<'_, '_, '_> {
fn apply(
&mut self,
script: &mut UiScript,
call: benilla_ui::script::ScriptCall,
) -> Option<benilla_ui::script::CallTail> {
if let benilla_ui::script::ScriptCall::Gossip(call) = call {
self.0.apply(script, call);
}
None
}
}
/// Run `lua` with the gossip host lent.
fn select(app: &mut App, lua: &'static str) {
app.world_mut()
.run_system_once(
move |mut script: NonSendMut<UiScript>, mut verbs: super::verbs::GossipVerbs| {
script
.hosted(&mut GossipHost(&mut verbs), |s| s.run(lua))
.expect("the chunk runs");
},
)
.expect("the hosted chunk runs as a one-shot system");
}
/// The selects sent; the channel also carries the feed's name query.
fn selects_sent(rx: &crossbeam_channel::Receiver<ClientCommand>) -> usize {
rx.try_iter()
.filter(|c| matches!(c, ClientCommand::GossipSelectOption { .. }))
.count()
}
const GUARD: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT
/// A guard's sub-menu on a first-visit text: nothing fires while the query is out, the old
/// menu stays with its selects refused, and the text repaints in place, as the reference's
/// exit at `0x4e2068` leaves it. A close fired at the pending step would flash the window.
#[test]
fn the_hold_fires_nothing_a_pending_submenu_keeps_the_frame_painted() {
let (mut app, rx) = feed_app(|s| s.run(RECORDER).unwrap());
// The greeting menu, its text already cached: opens at once.
{
let mut st = state(&mut app);
st.remember_record(50, record("What are you looking for?"));
assert!(!st.open_menu(GUARD, 50, option("The bank"), Vec::new(), 0));
}
assert_eq!(
tick(&mut app),
(
vec!["GOSSIP_SHOW".to_string()],
"What are you looking for?".to_string()
)
state.take_drawn().as_deref(),
Some("The Alliance needs you!")
);
// "The bank" is clicked: a new menu on the same NPC with an unseen text id. Nothing fires
// for the round trip, and a click on the old menu's row is refused.
assert!(
state(&mut app).open_menu(GUARD, 51, option("Goodbye"), Vec::new(), 0),
"first visit to the sub-menu's text: the query is owed"
);
for _ in 0..3 {
assert_eq!(
tick(&mut app),
(Vec::new(), "What are you looking for?".to_string()),
"the hold fires nothing — the previous menu stays painted"
);
}
select(&mut app, "SelectGossipOption(1)");
let _ = tick(&mut app);
assert_eq!(
selects_sent(&rx),
0,
"a select during the hold is refused (the latch)"
);
// The text lands: one in-place repaint, and selects work again.
state(&mut app).text_arrived(51, record("The bank is north of here."), 0);
assert_eq!(
tick(&mut app),
(
vec!["GOSSIP_SHOW".to_string()],
"The bank is north of here.".to_string()
)
);
select(&mut app, "SelectGossipOption(1)");
let _ = tick(&mut app);
assert_eq!(selects_sent(&rx), 1);
// Controls: a real end still closes, and a first visit from a closed frame stays shut
// until its text answers.
// The close leaves the text and the slots, which the getters still read (`0x4e1fa0`).
close_core(app.world_mut());
assert_eq!(
tick(&mut app),
(
vec!["GOSSIP_CLOSED".to_string()],
"The bank is north of here.".to_string()
)
);
assert!(state(&mut app).open_menu(GUARD, 52, option("Goodbye"), Vec::new(), 0));
assert_eq!(
tick(&mut app),
(Vec::new(), "The bank is north of here.".to_string()),
"hidden stays hidden through the hold"
);
state(&mut app).text_arrived(52, record("Yes?"), 0);
assert_eq!(
tick(&mut app),
(vec!["GOSSIP_SHOW".to_string()], "Yes?".to_string())
);
}
/// `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.
#[test]
fn stock_gossip_frame_stays_visible_through_a_first_visit_submenu() {
use benilla_ui::script::SoundRequest;
let _data = benilla_formats::wow_data_or_skip!();
let (mut app, _rx) = feed_app(|s| {
s.set_screen_size(1024.0, 768.0);
s.set_text_measurer(Box::new(crate::ui_script::FixedWidthFont(6.0)));
for f in crate::ui_script::test_ui::production_order(&[
crate::ui_script::test_ui::GOSSIP_UI,
&[r"Interface\FrameXML\GossipFrame.xml"],
]) {
crate::ui_script::test_ui::load_ui(s, f);
}
s.run(RECORDER).unwrap();
});
// What the window shows: visible?, the greeting, the first row's label, the kits played.
let window = |app: &mut App| -> (bool, String, String, Vec<SoundRequest>) {
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
(
s.eval::<bool>("return GossipFrame:IsVisible()").unwrap(),
s.eval::<String>("return GossipGreetingText:GetText() or ''")
.unwrap(),
s.eval::<String>("return GossipTitleButton1:GetText() or ''")
.unwrap(),
s.take_sounds(),
)
};
{
let mut st = state(&mut app);
st.remember_record(50, record("What are you looking for?"));
st.open_menu(GUARD, 50, option("The bank"), Vec::new(), 0);
}
let _ = tick(&mut app);
assert_eq!(
window(&mut app),
(
true,
"What are you looking for?".to_string(),
"The bank".to_string(),
vec![SoundRequest::KitName("igQuestListOpen".into())]
),
"the greeting menu opens with its kit"
);
// The click's answer holds on its text: the window must not blink.
assert!(state(&mut app).open_menu(GUARD, 51, option("Goodbye"), Vec::new(), 0));
for _ in 0..3 {
let (seen, _) = tick(&mut app);
assert!(seen.is_empty(), "nothing fires during the hold: {seen:?}");
assert_eq!(
window(&mut app),
(
true,
"What are you looking for?".to_string(),
"The bank".to_string(),
Vec::new()
),
"the window stays up, unchanged and silent, for the round trip — a hide here IS the flash"
);
}
// The text lands: repainted in place, with no kit.
state(&mut app).text_arrived(51, record("The bank is north of here."), 0);
let (seen, _) = tick(&mut app);
assert_eq!(seen, vec!["GOSSIP_SHOW".to_string()]);
assert_eq!(
window(&mut app),
(
true,
"The bank is north of here.".to_string(),
"Goodbye".to_string(),
Vec::new()
),
"an in-place repaint: ShowUIPanel on a visible frame is a no-op, no kit"
);
}
/// The greeting's macros expand once per message, as `0x4e2010` writes the expanded text to
/// `0xbbb678` at its draw (`0x4e2274`, `0x4e229a`) and `GetGossipText` returns that buffer
/// (`0x4e28c0`): a later change to the player's name leaves it until the next message.
#[test]
fn the_greeting_expands_once_per_message() {
const ME: u64 = 0x10;
let (mut app, _rx) = feed_app(|s| s.run(RECORDER).unwrap());
app.world_mut().spawn((
SelfPlayer,
Guid(ME),
ObjectStore(benilla_protocol::ObjectFields::default()),
));
let named = |app: &mut App, name: &str| {
app.world_mut()
.resource_mut::<NameCache>()
.insert_player(ME, name.into(), None);
};
named(&mut app, "Ann");
let menu = |app: &mut App| {
let mut st = state(app);
st.remember_record(50, record("Well met, $N."));
st.open_menu(GUARD, 50, option("Goodbye"), Vec::new(), 0);
};
menu(&mut app);
assert_eq!(tick(&mut app).1, "Well met, Ann.");
named(&mut app, "Bea");
assert_eq!(
tick(&mut app).1,
"Well met, Ann.",
"held until the next message"
);
menu(&mut app);
assert_eq!(tick(&mut app).1, "Well met, Bea.");
assert_eq!(state.take_drawn(), None, "drawn once");
}
#[test]
@@ -774,8 +376,9 @@ mod tests {
let wants_query = state.open_menu(0x42, 50, Vec::new(), Vec::new(), 0);
assert!(!wants_query, "cached — no re-query");
assert_eq!(
snapshot(&state).expect("open at once").greeting,
"Back again?"
state.take_drawn().as_deref(),
Some("Back again?"),
"drawn at once"
);
}
@@ -799,12 +402,12 @@ mod tests {
state.take_missing_text(),
"missing gossip text — the close core is owed"
);
assert!(snapshot(&state).is_none());
assert_eq!(state.take_drawn(), None);
// Id 0: no query wanted, and the menu never opens.
// Id 0: no query wanted, and nothing is ever drawn.
let mut state = GossipState::default();
assert!(!state.open_menu(0x42, 0, Vec::new(), Vec::new(), 0));
assert!(snapshot(&state).is_none());
assert_eq!(state.take_drawn(), None);
}
#[test]
@@ -820,7 +423,7 @@ mod tests {
let mut state = GossipState::default();
assert!(state.open_menu(0x42, 70, Vec::new(), Vec::new(), 0));
state.text_arrived(50, blocks(), 0);
assert!(snapshot(&state).is_none(), "still waiting on id 70");
assert_eq!(state.take_drawn(), None, "still waiting on id 70");
assert!(
state.cached_record(50).is_some(),
"the answer seeded the cache"
@@ -828,9 +431,10 @@ mod tests {
// Already open: a duplicate answer leaves the drawn line alone.
state.text_arrived(70, blocks(), 0);
assert_eq!(snapshot(&state).expect("open").greeting, "Hail.");
assert_eq!(state.take_drawn().as_deref(), Some("Hail."));
state.greeting = Some("The drawn line".into());
state.text_arrived(70, blocks(), 0);
assert_eq!(state.take_drawn(), None, "no second draw");
assert_eq!(
state.greeting.as_deref(),
Some("The drawn line"),
+78 -4
View File
@@ -1,12 +1,15 @@
//! The gossip window's packet handlers: they fill [`GossipState`], on the latch, and touch the VM
//! only through the close core.
//! The gossip window's packet handlers: they fill [`GossipState`], on the latch, signal the drawn
//! greeting on the gossip lane ([`super::lane`]), and touch the VM only through the close core.
use benilla_protocol::messages::{GossipOption, NpcTextBlock};
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::prelude::*;
use super::GossipState;
use crate::net::{ClientCommand, GuidIndex, NetCommands, NetHandlerApp, ObjectStore};
use super::{GossipEvent, GossipLane, GossipState};
use crate::names::NameCache;
use crate::net::{
ClientCommand, Guid, GuidIndex, NetCommands, NetHandlerApp, ObjectStore, SelfPlayer,
};
/// Register the gossip handlers and the session-end listener.
pub(super) fn register(app: &mut App) {
@@ -91,6 +94,7 @@ pub(crate) fn gossip_menu(
.send(ClientCommand::NpcTextQuery { text_id, guid: npc });
}
}
show_drawn(world);
close_on_missing_text(world);
}
@@ -104,9 +108,79 @@ fn npc_greeting(world: &mut World, text_id: u32, blocks: Vec<NpcTextBlock>) {
world
.resource_mut::<GossipState>()
.text_arrived(text_id, blocks, gender);
show_drawn(world);
close_on_missing_text(world);
}
/// The draw's tail in `0x4e2010`, once the record is found: with no active player or no object
/// under the cell `[0xbc3f58]` it bails (`0x4e20b3`, `0x4e20bb` → `0x4e22ba`), so a close's zeroed
/// cell writes no text and signals nothing, nor takes the missing-text close. Else the greeting
/// expanded for the player (`0x4e2274`), written to the text buffer (`0x4e229a`), then
/// `GOSSIP_SHOW` (`0x4e22b5`) on the gossip lane, with the NPC's name asked as its listener's
/// `UnitName("npc")` asks it there (`GossipFrame.lua:33`, `0x5171b8`).
fn show_drawn(world: &mut World) {
let player = world
.query_filtered::<(), (With<SelfPlayer>, With<ObjectStore>)>()
.iter(world)
.next()
.is_some();
let npc = world.resource::<GossipState>().npc;
let held = |guid: u64| {
world
.get_resource::<GuidIndex>()
.and_then(|index| index.0.get(&guid).copied())
.is_some_and(|e| world.get::<ObjectStore>(e).is_some())
};
let Some(npc) = npc.filter(|&g| player && held(g)) else {
let mut state = world.resource_mut::<GossipState>();
state.take_drawn();
state.take_missing_text();
return;
};
let Some(raw) = world.resource_mut::<GossipState>().take_drawn() else {
return;
};
let text = world
.run_system_cached_with(expand_greeting, raw.clone())
.unwrap_or(raw);
world.resource_mut::<GossipState>().text = text;
if let Some(mut lane) = world.get_resource_mut::<GossipLane>() {
lane.push(GossipEvent::Show);
}
let _ = world.run_system_cached_with(ask_npc_name, npc);
}
/// The NPC's name looked up with a callback, as `UnitName("npc")` does at the show; its landing
/// fires nothing.
fn ask_npc_name(
In(npc): In<u64>,
names: Res<NameCache>,
commands: Res<NetCommands>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
) {
let store = index.0.get(&npc).and_then(|&e| stores.get(e).ok());
let _ = names.resolve_unit(npc, store, crate::query_cache::Ask::Event(&commands));
}
/// The greeting's `$N`/`$B`/`$G`/`$<n>w` macros for the active player (`0x506f70`).
fn expand_greeting(
In(raw): In<String>,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
names: Res<NameCache>,
unit_names: crate::ui_unit::NameTables,
states: Res<crate::world_state::WorldStates>,
) -> String {
let player = crate::npc_text::player_identity(&self_q, &names, unit_names.get());
crate::npc_text::substitute(
&raw,
&crate::npc_text::MacroContext {
subject: player.as_ref(),
states: &states,
},
)
}
/// The missing-text path's close (`0x4e2197` → `0x4e1fa0(0)`).
fn close_on_missing_text(world: &mut World) {
if world.resource_mut::<GossipState>().take_missing_text() {
+45 -63
View File
@@ -1,11 +1,9 @@
//! The gossip verbs inside their calls, through the whole client's call host: each packet goes
//! out before the verb returns, so a chunk's packets leave in call order.
use benilla_protocol::messages::{GossipOption, NpcTextBlock};
use bevy::prelude::*;
use super::GossipState;
use crate::ui_quest::verb_tests::Client;
use benilla_protocol::messages::{GossipOption, NpcTextBlock};
const NPC: u64 = 0xF130_0000_0000_0007;
const ROLL: &str = "RandomRoll { min: 1, max: 100 }";
@@ -13,33 +11,25 @@ const ROLL: &str = "RandomRoll { min: 1, max: 100 }";
/// `NPC`'s menu, its greeting drawn: option 1 is wire index 3, then active quest 5 (icon 3) and
/// available quest 6 (icon 5).
fn menu(c: &mut Client) {
let world = c.app.world_mut();
{
let mut state = world.resource_mut::<GossipState>();
state.remember_record(
7,
vec![NpcTextBlock {
probability: 1.0,
male: "Hello".into(),
female: "Hello".into(),
}],
);
state.open_menu(
NPC,
7,
vec![GossipOption {
index: 3,
icon: 1,
coded: false,
message: "Browse".into(),
}],
vec![(5, 3, 1, "Active".into()), (6, 5, 1, "Available".into())],
0,
);
}
world
.run_system_cached(super::feed_gossip)
.expect("the feed runs");
menu_of(
c,
vec![GossipOption {
index: 3,
icon: 1,
coded: false,
message: "Browse".into(),
}],
true,
);
}
/// `lua`'s string value, read with the host lent, as the getters read the store at the call.
fn live_eval(c: &mut Client, lua: &str) -> String {
crate::script_calls::with_hosted_vm(c.app.world_mut(), |s| {
s.expect("a VM")
.eval::<String>(lua)
.expect("the chunk runs")
})
}
/// `SelectGossipOption` (`0x4e2a30` → `0x4e2320`, the send at `0x4e23ef`) and the two quest-row
@@ -74,32 +64,28 @@ const RECORDER: &str = "SEEN = {} local f = CreateFrame('Frame') \
f:RegisterEvent('GOSSIP_SHOW') f:RegisterEvent('GOSSIP_ENTER_CODE') \
f:SetScript('OnEvent', function() table.insert(SEEN, event .. ':' .. tostring(arg1)) end)";
/// `NPC`'s menu with `options` and the two quest rows, its text cached when `drawn`.
/// `NPC`'s menu with `options` and the two quest rows through the message's handler, its text
/// cached when `drawn`, and the lanes drained, so its `GOSSIP_SHOW` has fired.
fn menu_of(c: &mut Client, options: Vec<GossipOption>, drawn: bool) {
let world = c.app.world_mut();
{
let mut state = world.resource_mut::<GossipState>();
if drawn {
state.remember_record(
7,
vec![NpcTextBlock {
probability: 1.0,
male: "Hello".into(),
female: "Hello".into(),
}],
);
}
state.open_menu(
NPC,
if drawn {
world.resource_mut::<GossipState>().remember_record(
7,
options,
vec![(5, 3, 1, "Active".into()), (6, 5, 1, "Available".into())],
0,
vec![NpcTextBlock {
probability: 1.0,
male: "Hello".into(),
female: "Hello".into(),
}],
);
}
world
.run_system_cached(super::feed_gossip)
.expect("the feed runs");
super::net::gossip_menu(
world,
NPC,
7,
options,
vec![(5, 3, 1, "Active".into()), (6, 5, 1, "Available".into())],
);
crate::lanes::fire_in_order(world);
}
fn option(index: u32, coded: bool) -> GossipOption {
@@ -129,17 +115,15 @@ fn an_option_select_resets_the_menus_slots() {
)]
);
assert_eq!(
c.eval(
live_eval(
&mut c,
"local function n(...) return arg.n end \
return n(GetGossipOptions()) .. ',' .. n(GetGossipAvailableQuests()) .. ',' .. \
n(GetGossipActiveQuests()) .. ',' .. GetGossipText()"
),
"0,0,0,Hello"
);
c.app
.world_mut()
.run_system_cached(super::feed_gossip)
.expect("the feed runs");
crate::lanes::fire_in_order(c.app.world_mut());
assert_eq!(c.eval("return table.concat(SEEN, ',')"), "");
}
@@ -243,7 +227,8 @@ fn a_code_keeps_its_bytes_up_to_its_nul() {
/// `CloseGossip` (`0x4e2b20` → `0x4e1fa0`) zeroes the cell inside its call (`0x4e1fcc`) and
/// leaves the slots: a select on the chunk's next line sends for guid 0, and so does one after
/// the drain's `GOSSIP_CLOSED`, whose getters still read the slots.
/// the close's `GOSSIP_CLOSED`, whose getters still read the slots. The close's latch release
/// re-enters it with the cell still set (`0x4e1fc5` → `0x493310`), so it signals twice.
#[test]
fn close_gossip_zeroes_the_cell_and_keeps_the_slots() {
let mut c = Client::new(0);
@@ -264,13 +249,10 @@ fn close_gossip_zeroes_the_cell_and_keeps_the_slots() {
.unwrap();
c.sent();
c.run("CloseGossip()").unwrap();
c.app
.world_mut()
.run_system_cached(super::feed_gossip)
.expect("the feed runs");
crate::lanes::fire_in_order(c.app.world_mut());
assert_eq!(
c.eval("local function n(...) return arg.n end return n(GetGossipOptions()) .. ',' .. table.concat(SEEN, ',')"),
"2,GOSSIP_CLOSED"
live_eval(&mut c, "local function n(...) return arg.n end return n(GetGossipOptions()) .. ',' .. table.concat(SEEN, ',')"),
"2,GOSSIP_CLOSED,GOSSIP_CLOSED"
);
c.run("SelectGossipOption(1)").unwrap();
assert_eq!(
+1 -1
View File
@@ -120,7 +120,7 @@ pub(crate) fn slot_guid(
store: &ObjectFields,
bag: i64,
slot0: u8,
objects: &Objects,
objects: &(impl ItemObjects + ?Sized),
) -> Option<u64> {
match bag {
0 => store.player_pack_slot(slot0).filter(|g| *g != 0),
@@ -9,7 +9,7 @@ use benilla_ui::script::{ContainerSlot, ContainerState, MerchantState};
use crossbeam_channel::Receiver;
use super::*;
use crate::net::{ClientCommand, Guid, NetCommands};
use crate::net::{ClientCommand, Guid, NetCommands, SelfPlayer};
use crate::script_calls::with_hosted_vm;
const ME: u64 = 0x10;
+43 -5
View File
@@ -2,20 +2,21 @@
//! [`benilla_ui::script::MerchantHeadQuery`] and its kin: the window's cell and rows
//! (`0xbddfa0`, `0xbdd118`), the player's buy-back fields, items and the open vendor's unit as of
//! the drain's place, each row resolved off the item cache as the reference's getter reads it at
//! the call (`0x4fb150`, `0x4fb310`, `0x4fadb0`, `0x4fbd60`).
//! the call (`0x4fb150`, `0x4fb310`, `0x4fadb0`, `0x4fbd60`), and each item's repair cost the
//! tooltips read at theirs (`0x4faf30`, `0x5da330`).
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use benilla_ui::script::live::LiveAsk;
use benilla_ui::script::{
BuybackRowQuery, MerchantHead, MerchantHeadQuery, MerchantItem, MerchantRowQuery,
RepairAllCostQuery,
BuybackRowQuery, ItemRepairCostQuery, MerchantHead, MerchantHeadQuery, MerchantItem,
MerchantRowQuery, RepairAllCostQuery, RepairCostQuery,
};
use super::{
buyback_order, open_vendor_discount, repair_all, resolve_buyback, resolve_item, vendor_repairs,
MerchantOpen, RepairTables,
buyback_order, item_repair_cost, open_vendor_discount, repair_all, resolve_buyback,
resolve_item, vendor_repairs, MerchantOpen, RepairTables,
};
use crate::entities::ItemDisplays;
use crate::items::Items;
@@ -115,6 +116,41 @@ impl MerchantLive<'_, '_> {
discount,
)))
}
/// The item at the player's `bag` slot `slot` (1-based; the doll's live id with
/// [`benilla_ui::script::EQUIPMENT_BAG`]) priced as `0x4faf30` prices it at the call: the
/// object's own cost (`0x5da330`) after the open vendor's discount, the full price with none
/// open (`0x4faf8a`); 0 for no object, no player or no tables.
pub(super) fn repair_cost(&self, bag: i64, slot: u32) -> Option<u32> {
let items = self.items.as_deref()?;
let Some(player) = self.player() else {
return Some(0);
};
let (Some(slot0), Some(tables)) = (
slot.checked_sub(1).and_then(|s| u8::try_from(s).ok()),
self.tables.as_deref(),
) else {
return Some(0);
};
let objects = self.stores.item_objects(&self.objects);
let Some(guid) = crate::ui_items::slot_guid(&player.0, bag, slot0, &objects) else {
return Some(0);
};
let discount = self.open.as_deref().map_or(0.0, |open| {
open_vendor_discount(self.vendor(open), &self.relations, player)
});
Some(item_repair_cost(guid, &objects, items, tables, discount))
}
/// The item object `guid`'s own cost before any discount (`0x5da330`), as of the drain's
/// place.
pub(super) fn own_repair_cost(&self, guid: u64) -> Option<u32> {
let items = self.items.as_deref()?;
let objects = self.stores.item_objects(&self.objects);
Some(self.tables.as_deref().map_or(0, |tables| {
item_repair_cost(guid, &objects, items, tables, 0.0)
}))
}
}
impl crate::script_calls::live::LiveFamily for MerchantLive<'_, '_> {
@@ -123,5 +159,7 @@ impl crate::script_calls::live::LiveFamily for MerchantLive<'_, '_> {
|| ask.answer(|q: &MerchantRowQuery| self.row(q.0))
|| ask.answer(|q: &BuybackRowQuery| self.buyback(q.0))
|| ask.answer(|_: &RepairAllCostQuery| self.repair_all())
|| ask.answer(|q: &RepairCostQuery| self.repair_cost(q.bag, q.slot))
|| ask.answer(|q: &ItemRepairCostQuery| self.own_repair_cost(q.0))
}
}
+58 -226
View File
@@ -1,21 +1,21 @@
//! The merchant window's app side: [`MerchantOpen`] holds the `SMSG_LIST_INVENTORY` rows, which
//! the getters read with the buy-back rows and the repair total through the host as they run
//! ([`live`]); the handlers signal on the merchant lane ([`lane`]); [`feed_repair_costs`] pushes
//! the items' repair costs, and [`MerchantSteps`] applies the verbs, the close among them, inside
//! ([`live`]), the items' repair costs among them; the handlers signal on the merchant lane
//! ([`lane`]), and [`MerchantSteps`] applies the verbs, the close among them, inside
//! their calls. A bag click sells through [`crate::ui_items`].
use benilla_protocol::messages::{buy_result, sell_result, VendorItem};
use benilla_world::interact::WorldRightPress;
use bevy::prelude::*;
use benilla_ui::script::{ItemStatsHead, MerchantItem, RepairCosts, UiScript};
use benilla_ui::script::{ItemStatsHead, MerchantItem, UiScript};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{ObjectStore, Objects, SelfPlayer};
use crate::net::{ObjectStore, Objects};
use crate::query_cache::Ask;
use crate::ui_items::{item_link, wire_pos};
use crate::ui_script::{gate, SweepApp, SweepLeg, UiFeed};
use crate::ui_script::{SweepApp, SweepLeg};
use crate::ui_session::{LatchApp, LatchedWindow, NpcWindow};
/// A vendor row's unlimited `current_count` (vmangos `ItemHandler.cpp:795`), `-1` to Lua.
@@ -117,19 +117,7 @@ impl Plugin for UiMerchantPlugin {
app.init_resource::<MerchantOpen>()
.add_latched_window::<MerchantOpen>()
// `0x490ada` → `0x4facc0` → `0x4fadf0`, the core itself.
.add_sweep_leg(SweepLeg::Merchant, close_core)
.add_systems(
Update,
(
feed_purse.in_set(UiFeed),
// Before `feed_char`, whose doll push repaints each slot's tooltip cost.
// After the roster publish, whose party slots the vendor's reaction reads.
feed_repair_costs
.in_set(UiFeed)
.after(crate::spell::group_relation::publish_group_roster)
.before(crate::ui_char::feed_char),
),
);
.add_sweep_leg(SweepLeg::Merchant, close_core);
}
}
@@ -317,77 +305,6 @@ fn item_repair_cost(
.repair_cost(points, level, quality, class, subclass, discount)
}
/// Each damaged carried or banked item's cost (`0x4faf30`), keyed as the bindings take it.
fn repair_costs(
store: &benilla_protocol::ObjectFields,
objects: &Objects,
items: &Items,
tables: &RepairTables,
discount: f64,
) -> RepairCosts {
let cost = |guid: u64| {
(guid != 0)
.then(|| item_repair_cost(guid, objects, items, tables, discount))
.filter(|&c| c > 0)
};
// The cost before the discount (`0x5da330`), which the merchant price takes off (`0x52e44a`).
let own = |guid: u64| {
(guid != 0)
.then(|| item_repair_cost(guid, objects, items, tables, 0.0))
.filter(|&c| c > 0)
.map(|c| (guid, c))
};
let mut costs = RepairCosts::default();
for i in 0..19u8 {
if let Some(c) = cost(store.player_inv_slot(i).unwrap_or(0)) {
costs.equipped.insert(u32::from(i) + 1, c);
}
}
for i in 0..16u8 {
let guid = store.player_pack_slot(i).unwrap_or(0);
if let Some(c) = cost(guid) {
costs.bags.insert((0, u32::from(i) + 1), c);
}
costs.own.extend(own(guid));
}
for i in 0..crate::ui_items::BANK_SLOTS {
let guid = store.player_bank_slot(i).unwrap_or(0);
if let Some(c) = cost(guid) {
costs
.bags
.insert((crate::ui_items::BANK_CONTAINER, u32::from(i) + 1), c);
}
costs.own.extend(own(guid));
}
// Bags 1-4 are inventory slots 19-22, bank bags 5-10 the bank bag slots.
let bags = (1..=4u8)
.map(|b| (i64::from(b), store.player_inv_slot(18 + b)))
.chain((0..crate::ui_items::BANK_BAGS).map(|i| {
(
crate::ui_items::BANK_BAG_ID_FIRST + i64::from(i),
store.player_bank_bag_slot(i),
)
}));
for (bag, bag_guid) in bags {
let bag_guid = bag_guid.unwrap_or(0);
if bag_guid == 0 {
continue;
}
let Some(b) = objects.object(bag_guid) else {
continue;
};
let n = b.container_num_slots().unwrap_or(0).min(36) as u8;
for j in 0..n {
let guid = b.container_slot(j).unwrap_or(0);
if let Some(c) = cost(guid) {
costs.bags.insert((bag, u32::from(j) + 1), c);
}
costs.own.extend(own(guid));
}
}
costs
}
/// `GetRepairAllCost`'s walk (`0x4fbd60`): the equipped slots 0 to 18, the backpack's 23 to 38
/// and each item inside the four equipped bags (19 to 22), never the bags themselves, the bank,
/// the keyring or the buy-back slots. An item counts when its flags lack the wrap bit
@@ -505,99 +422,6 @@ impl RepairPurse<'_, '_> {
}
}
/// What [`feed_repair_costs`] watches and last pushed.
#[derive(Default)]
struct RepairFeedMemo {
templates: gate::Watch,
discount: gate::Watch,
pushed: Option<RepairCosts>,
}
/// Push each item's repair cost, which the item tooltips and the paper doll read off the VM.
/// `GetRepairAllCost` reads its total at the call ([`live`]).
fn feed_repair_costs(
mut vm: crate::script_calls::VmWith<(
crate::target::RelationWorld<'static, 'static>,
Res<'static, MerchantOpen>,
)>,
mut inv: crate::items::Inventory,
items: Res<Items>,
tables: Option<Res<RepairTables>>,
mut memo: Local<crate::ui_script::VmMemo<RepairFeedMemo>>,
) {
vm.turn(|script, (relations, open)| {
let Some(script) = script else {
return;
};
let (memo, vm_reset) = memo.get_reset(script);
// No self store at logout: keep the last push, as `feed_char` does.
let Some(player) = inv.self_store.iter().next() else {
return;
};
// The open vendor's price discount (`0x4faf8a`), 0 with none open.
let discount = open_vendor_discount(vendor_unit(&open, &inv.objects), &relations, player);
let objects_moved = inv.changes.moved();
let self_changed = !inv.self_changed.is_empty();
let templates_moved = memo.templates.moved(items.template_epoch());
let discount_moved = memo.discount.moved(discount.to_bits());
let tables_added = tables.as_ref().is_some_and(|t| t.is_added());
gate::trace(
"feed_repair_costs",
&[
("vm_reset", vm_reset),
("objects", objects_moved),
("self", self_changed),
("templates", templates_moved),
("discount", discount_moved),
("tables", tables_added),
],
);
let gate = gate::Gate::new(
vm_reset
|| objects_moved
|| self_changed
|| templates_moved
|| discount_moved
|| tables_added,
);
if gate.skip() {
return;
}
let costs = tables.as_deref().map_or_else(RepairCosts::default, |t| {
repair_costs(&player.0, &inv.objects, &items, t, discount)
});
if memo.pushed.as_ref() != Some(&costs) {
gate.audit("feed_repair_costs", "the repair costs");
script.set_repair_costs(costs.clone());
memo.pushed = Some(costs);
}
})
}
/// Push the purse (`PLAYER_FIELD_COINAGE`) on a change: the trade and mail verbs read it off the
/// VM, and it is `GetMoney`'s copy with no host to ask. Its `PLAYER_MONEY` is the coinage
/// watcher's (`0x5ddf4b`, [`crate::field_watch`]).
fn feed_purse(
mut hosted_vm: crate::script_calls::HostedVm,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
mut last_money: Local<crate::ui_script::VmMemo<Option<u64>>>,
) {
hosted_vm.run(|script| {
let Some(script) = script else {
return;
};
let last_money = last_money.get(script);
let Some(copper) = self_q.iter().next().and_then(|s| s.0.player_money()) else {
return;
};
let copper = u64::from(copper);
if *last_money != Some(copper) {
script.set_money(copper);
*last_money = Some(copper);
}
})
}
/// The vendor's cell `[0xbddfa0]`; `CloseMerchant` (`0x4fb120`) is the core itself, run inside
/// its call ([`crate::script_calls::ScriptCallHost`]).
impl LatchedWindow for MerchantOpen {
@@ -688,7 +512,7 @@ pub(crate) fn end_repair_mode_on_right_press(
#[cfg(test)]
pub(crate) mod purse_fixture {
use super::*;
use crate::net::{Guid, NetCommands};
use crate::net::{Guid, NetCommands, SelfPlayer};
use benilla_protocol::field::{
FIELD_PLAYER_FIELD_COINAGE, FIELD_UNIT_FLAGS, FIELD_UNIT_NPC_FLAGS,
};
@@ -790,6 +614,33 @@ pub(crate) mod purse_fixture {
(app, me)
}
/// `chunk`'s answer with a host lent whose one family is the merchant's ([`live`]): the
/// tooltips' costs read the world at the call.
pub(crate) fn live_eval<T: benilla_ui::mlua::FromLuaMulti>(app: &mut App, chunk: &str) -> T {
struct Host<'a, 'w, 's>(&'a mut live::MerchantLive<'w, 's>);
impl benilla_ui::script::CallHost for Host<'_, '_, '_> {
fn apply(
&mut self,
_: &mut UiScript,
_: benilla_ui::script::ScriptCall,
) -> Option<benilla_ui::script::CallTail> {
None
}
fn read_live(&mut self, ask: &mut benilla_ui::script::live::LiveAsk<'_>) -> bool {
crate::script_calls::live::LiveFamily::answer(self.0, ask)
}
}
let world = app.world_mut();
let mut script = world.remove_non_send_resource::<UiScript>().expect("a VM");
let mut state = bevy::ecs::system::SystemState::<live::MerchantLive>::new(world);
let mut family = state.get_mut(world);
let r = script.hosted(&mut Host(&mut family), |s| {
s.eval::<T>(chunk).expect("runs")
});
world.insert_non_send_resource(script);
r
}
/// A VM with the sword at live id 16, backpack slot 1 and bank slot 1, and a tooltip `TT`.
pub(crate) fn sword_vm() -> UiScript {
let mut script = UiScript::new().unwrap();
@@ -861,7 +712,7 @@ mod call_tests;
#[cfg(test)]
mod tests {
use super::*;
use crate::net::{ClientCommand, Guid, NetCommands};
use crate::net::{ClientCommand, Guid, NetCommands, SelfPlayer};
use benilla_ui::script::MerchantState;
/// `GetRepairAllCost` sums each item's cost after the open merchant's discount (`0x4faf8a`):
@@ -972,28 +823,19 @@ mod tests {
/// merchant price takes off the cost before the discount (`0x52e44a`).
#[test]
fn a_carried_items_own_cost_rides_beside_the_discounted_one() {
use benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD;
use bevy::ecs::system::RunSystemOnce;
use purse_fixture::*;
const PACK_SLOT_1: u16 = 532;
let (full, discounted) = sword_prices();
let mut app = App::new();
app.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<MerchantOpen>();
seat(app.world_mut());
let store = player(PACK_SLOT_1, 0).0;
let steps = f64::from(0.05f32) + f64::from(0.05f32);
let (mut app, _) = repair_app(FIELD_PLAYER_INV_SLOT_HEAD + 2 * 23);
let costs = app
.world_mut()
.run_system_once(
move |objects: Objects, items: Res<Items>, tables: Res<RepairTables>| {
repair_costs(&store, &objects, &items, &tables, steps)
},
)
.run_system_once(|live: live::MerchantLive| {
(live.repair_cost(0, 1), live.own_repair_cost(SWORD))
})
.unwrap();
assert_eq!(costs.bags.get(&(0, 1)), Some(&discounted));
assert_eq!(costs.own.get(&SWORD), Some(&full));
assert_eq!(costs, (Some(discounted), Some(full)));
}
/// `RepairAllItems()` through the feed and the host, at [`purse_fixture`]'s vendor carrying
@@ -1128,18 +970,13 @@ mod tests {
#[test]
fn each_items_cost_takes_the_open_vendors_discount() {
use benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD;
use bevy::ecs::system::RunSystemOnce;
use purse_fixture::*;
let (full, discounted) = sword_prices();
let priced = |slot_field: u16, read: &str| {
let (mut app, _) = repair_app(slot_field);
app.insert_non_send_resource(sword_vm());
let read_cost = |app: &mut App| {
app.world_mut().run_system_once(feed_repair_costs).unwrap();
let script = app.world().non_send_resource::<UiScript>();
script.eval::<i64>(read).unwrap()
};
let read_cost = |app: &mut App| live_eval::<i64>(app, read);
let at_vendor = read_cost(&mut app);
app.world_mut().resource_mut::<MerchantOpen>().clear();
(at_vendor, read_cost(&mut app))
@@ -1168,7 +1005,6 @@ mod tests {
#[test]
fn a_wrapped_items_repair_costs_nothing() {
use benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD;
use bevy::ecs::system::RunSystemOnce;
use purse_fixture::*;
/// `ITEM_FIELD_FLAGS`, and its wrapped bit.
const ITEM_FLAGS: u16 = 21;
@@ -1176,11 +1012,10 @@ mod tests {
let (mut app, _) = repair_app(FIELD_PLAYER_INV_SLOT_HEAD + 2 * 15);
app.insert_non_send_resource(sword_vm());
let cost = |app: &mut App| {
app.world_mut().run_system_once(feed_repair_costs).unwrap();
let script = app.world().non_send_resource::<UiScript>();
script
.eval::<i64>(r#"local _, _, c = TT:SetInventoryItem("player", 16) return c"#)
.unwrap()
live_eval::<i64>(
app,
r#"local _, _, c = TT:SetInventoryItem("player", 16) return c"#,
)
};
assert!(cost(&mut app) > 0, "the damaged sword costs");
let sword = app.world().resource::<crate::net::GuidIndex>().0[&SWORD];
@@ -1194,10 +1029,11 @@ mod tests {
assert_eq!(cost(&mut app), 0, "wrapped, it costs nothing");
}
/// Each gate input alone reopens the feed, through `App::update` so the memo persists; a bank
/// item is priced too.
/// Each cost reads the world at the call: the tables, the durability, the slot, the vendor's
/// discount and the record, each written alone with no push between; a bank item is priced
/// too.
#[test]
fn each_input_alone_reopens_the_repair_cost_gate() {
fn each_cost_reads_the_world_at_the_call() {
use benilla_protocol::field::{FIELD_PLAYER_INV_SLOT_HEAD, FIELD_UNIT_FLAGS};
use benilla_protocol::ObjectFields;
use purse_fixture::*;
@@ -1210,7 +1046,6 @@ mod tests {
let bank_1 = FIELD_PLAYER_INV_SLOT_HEAD + 2 * 39;
let (mut app, me) = repair_app(main_hand);
app.add_systems(Update, feed_repair_costs);
let tables = app.world_mut().remove_resource::<RepairTables>().unwrap();
let mut script = sword_vm();
script.set_merchant(Some(MerchantState {
@@ -1228,18 +1063,15 @@ mod tests {
let mut store = app.world_mut().get_mut::<ObjectStore>(e).unwrap();
store.0.merge(ObjectFields::from_pairs(pairs));
};
// (worn, backpack, bank) after one frame.
// (worn, backpack, bank) at the call.
let frame = |app: &mut App| {
app.update();
app.world()
.non_send_resource::<UiScript>()
.eval::<(i64, i64, i64)>(
r#"local _, _, worn = TT:SetInventoryItem("player", 16)
local _, carried = TT:SetBagItem(0, 1)
local _, banked = TT:SetBagItem(-1, 1)
return worn, carried, banked"#,
)
.unwrap()
live_eval::<(i64, i64, i64)>(
app,
r#"local _, _, worn = TT:SetInventoryItem("player", 16)
local _, carried = TT:SetBagItem(0, 1)
local _, banked = TT:SetBagItem(-1, 1)
return worn, carried, banked"#,
)
};
let vendor = app
.world_mut()
@@ -121,7 +121,12 @@ fn open_everything(world: &mut World) {
.open(STABLE_MASTER, 0, Vec::new());
// The bank last, so it holds the latch: every leg above releases its own, and the interaction
// close finds the banker.
crate::ui_bank::open_bank(world, BANKER);
// The open's latch and cell, its `BANKFRAME_OPENED` long fired (`0x4f8507`, `0x4f850e`), so
// the sweep's close finds no open still queued on the bank lane ahead of it.
crate::ui_session::register(world, BANKER);
world
.resource_mut::<crate::ui_bank::BankOpen>()
.open(BANKER);
world.insert_resource(crate::cinematic::Cinematic::playing_for_test());
}
+3 -3
View File
@@ -509,9 +509,9 @@ pub(super) const MERCHANT_UI: &[&str] = &[
"ScrollTemplates.xml",
];
/// Stock `GossipFrame.xml`'s dependencies in the production order, shared with `ui_gossip`'s feed
/// tests. The greeting pane inherits `UIPanelScrollFrameTemplate`, and a missing template only
/// warns, losing the scrollbar silently. Needs client data.
/// Stock `GossipFrame.xml`'s dependencies in the production order, for the gossip lane's tests.
/// The greeting pane inherits `UIPanelScrollFrameTemplate`, and a missing template only warns,
/// losing the scrollbar silently. Needs client data.
pub(crate) const GOSSIP_UI: &[&str] = &[
r"Interface\FrameXML\GlobalStrings.lua",
r"Interface\FrameXML\Fonts.xml",
@@ -29,6 +29,8 @@ fn client(summon: u64) -> Stable {
let world = app.world_mut();
let me = ObjectFields::from_pairs(&[
(2, 0x19),
// The purse the buy's gate reads (`0x4cb122`).
(benilla_protocol::field::FIELD_PLAYER_FIELD_COINAGE, 10_000),
(SUMMON, summon as u32),
(SUMMON + 1, (summon >> 32) as u32),
])
@@ -95,8 +97,7 @@ fn client(summon: u64) -> Stable {
world.insert_resource(super::StableSlotPrices(
benilla_formats::StableSlotPrices::from_rows([(2, 500)]),
));
let mut script = UiScript::new().expect("a VM");
script.set_money(10_000);
let script = UiScript::new().expect("a VM");
app.world_mut().insert_non_send_resource(script);
// The list's signal, which leaves the VM its copy.
app.world_mut()
@@ -40,6 +40,8 @@ fn me_fields(extra: &[(u16, u32)]) -> ObjectFields {
(22, 100),
(35, fixture::HUMAN),
(46, fixture::PC),
// The purse the money verbs test against (`PLAYER_FIELD_COINAGE`).
(benilla_protocol::field::FIELD_PLAYER_FIELD_COINAGE, 10_000),
(532, CLOTH as u32),
(533, (CLOTH >> 32) as u32),
(534, JERKY as u32),
@@ -87,7 +89,6 @@ fn client() -> Trade {
.insert_player(BOB, "Bob".to_string(), None);
world.resource_mut::<crate::target::Selection>().guid = Some(BOB);
let mut script = UiScript::new().expect("a VM");
script.set_money(10_000);
script.set_unit_guids(&UnitGuids {
player: ME,
target: BOB,
+6 -1
View File
@@ -1289,7 +1289,12 @@ fn feed_trade(
// `SetTradePartner`'s open leg (`0x4bf5f1`): only a trade we started by dropping
// something on the partner moves what the cursor holds into the offer (`0x5d3f80`).
if trade.opens_with_held() {
if let Some(offer) = script.trade_open_money() {
// The coinage `0x5d4570` tests the total against, as the handler finds it.
let purse = objects
.player_fields()
.and_then(|p| p.player_money())
.map_or(0, u64::from);
if let Some(offer) = script.trade_open_money(purse) {
// Coins (`0x4bf5fe`-`0x4bf644`): `0x5d4500` sends the new offer, then the
// cursor clears (`0x4bf61c`), the cache of our money `[0xb71740]` takes it
// (`0x4bf62b`), and `PLAYER_TRADE_MONEY`, `PLAYER_MONEY` and `TRADE_SHOW` fire
@@ -679,6 +679,7 @@ fn global_event(id: u16) -> Option<&'static str> {
id::AREA_SPIRIT_HEALER_IN_RANGE => "AREA_SPIRIT_HEALER_IN_RANGE",
id::AREA_SPIRIT_HEALER_OUT_OF_RANGE => "AREA_SPIRIT_HEALER_OUT_OF_RANGE",
id::INSPECT_HONOR_UPDATE => "INSPECT_HONOR_UPDATE",
id::PLAYERBANKBAGSLOTS_CHANGED => "PLAYERBANKBAGSLOTS_CHANGED",
_ => return None,
})
}
+30 -13
View File
@@ -1,5 +1,7 @@
//! The bank bindings: the app pushes the open bank's snapshot ([`UiScript::set_bank`]), and
//! `PurchaseSlot` and `CloseBankFrame` hand the host their calls ([`BankCall`]).
//! The bank bindings: the purchase row reads the active player through the host at the call
//! ([`BankSlotsQuery`]), the app writes the banker's cell into the VM at the open and the close
//! ([`UiScript::set_bank`]), and `PurchaseSlot` and `CloseBankFrame` hand the host their calls
//! ([`BankCall`]).
//!
//! The vault is container `-1` (24 slots) and containers 5..=10 (the six bags), the reference's
//! own ids (`BankFrame.lua:1-4`), so the container verbs work on it unchanged. The bag items
@@ -8,9 +10,11 @@
use mlua::{Lua, MultiValue, Value};
use super::live::{ask, LiveQuery};
use super::Model;
/// The open bank's purchase row, pushed whole by the app.
/// The bank's purchase row: `Some` in the VM while a banker's cell is set, which the container
/// getters' bank gate reads; its two numbers are the getters' answer with no host to ask.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct BankState {
/// Purchased bag slots, 0..=6: `PLAYER_BYTES_2` byte 2.
@@ -20,8 +24,27 @@ pub struct BankState {
pub next_cost: u32,
}
/// The question `GetNumBankSlots` (`0x4f85b0`) and `GetBankSlotCost` (`0x4f8630`) ask at the
/// call: the active player's `PLAYER_BYTES_2` byte 2 (`+0x1a`, `0x4f85dd`) and the price of the
/// row after it (`0x4f8663` → `0x4f8690`), both 0 with no player (`0x4f85e3`, `0x4f866a`). Neither
/// tests the banker.
pub struct BankSlotsQuery;
impl LiveQuery for BankSlotsQuery {
type Answer = BankState;
}
/// The purchase row now: the host's, else the VM's copy, which only a test sets.
fn slots(lua: &Lua) -> BankState {
ask(lua, &BankSlotsQuery).unwrap_or_else(|| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.bank.clone().unwrap_or_default()
})
}
impl super::UiScript {
/// Push (or clear, with `None`) the open bank's snapshot.
/// Write (or clear, with `None`) the banker's cell `[0xbdd038]` into the VM, at the open's
/// signal and the close's clear.
pub fn set_bank(&mut self, state: Option<BankState>) {
self.model_mut().bank = state;
}
@@ -54,10 +77,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"GetNumBankSlots",
lua.create_function(|lua, ()| {
let n = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.bank.as_ref().map_or(0, |b| b.num_purchased)
};
let n = slots(lua).num_purchased;
let full = if n >= 6 {
Value::Integer(1)
} else {
@@ -70,14 +90,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetBankSlotCost(numSlots): the argument is unread; the pushed state names the next slot.
// GetBankSlotCost(numSlots): the argument is unread; the next slot's price.
g.set(
"GetBankSlotCost",
// Reads no argument at all (`0x4f8630`): any call shape answers.
lua.create_function(|lua, _args: mlua::MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.bank.as_ref().map_or(0, |b| b.next_cost)))
})?,
lua.create_function(|lua, _args: mlua::MultiValue| Ok(i64::from(slots(lua).next_cost)))?,
)?;
// PurchaseSlot(): the `CONFIRM_BUY_BANK_SLOT` popup's accept (`StaticPopup.lua:52`).
+11 -13
View File
@@ -600,22 +600,20 @@ impl Model {
self.containers.get(&bag)?.slots.get(&n)
}
/// The player's repair cost at live id `slot`, the vault and keyring bands by their container
/// slots.
pub(super) fn inv_repair_cost(&self, slot: usize) -> u32 {
let costs = &self.repair_costs;
let cost = if BANK_INV_SLOTS.contains(&slot) {
let n = (slot - BANK_INV_SLOTS.start() + 1) as u32;
costs.bags.get(&(BANK_CONTAINER, n))
/// The player's repair cost at live id `slot` now (`0x5332e1`), the vault and keyring bands by
/// their container slots.
pub(super) fn inv_repair_cost(&self, lua: &mlua::Lua, slot: usize) -> u32 {
let (bag, n) = if BANK_INV_SLOTS.contains(&slot) {
(BANK_CONTAINER, slot - BANK_INV_SLOTS.start() + 1)
} else if KEYRING_INV_SLOTS.contains(&slot) {
let n = (slot - KEYRING_INV_SLOTS.start() + 1) as u32;
costs.bags.get(&(KEYRING_CONTAINER, n))
(KEYRING_CONTAINER, slot - KEYRING_INV_SLOTS.start() + 1)
} else {
u32::try_from(slot)
.ok()
.and_then(|s| costs.equipped.get(&s))
(super::EQUIPMENT_BAG, slot)
};
cost.copied().unwrap_or(0)
let Ok(n) = u32::try_from(n) else {
return 0;
};
super::merchant::repair_cost_at(lua, self, bag, n)
}
/// The use cooldown `(start, duration, enabled)` in `GetTime` seconds of the player's item at
+9 -6
View File
@@ -52,7 +52,9 @@ fn install_money(lua: &Lua, copper: u32) {
pub(crate) fn pickup_player_money(lua: &Lua, copper: u32) -> bool {
let allowed = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
copper != 0 && model.player_control && u64::from(copper) <= model.money
copper != 0
&& model.player_control
&& u64::from(copper) <= crate::script::stat_read::purse(lua, &model)
};
if allowed {
install_money(lua, copper);
@@ -70,16 +72,16 @@ pub(crate) fn drop_cursor_money(model: &mut Model) -> bool {
true
}
/// The new offer `0x5d4500` sends for the held coins: offer plus cursor, when the purse covers it
/// (`0x5d4570`); `None` with no coins held.
pub(crate) fn trade_money_total(model: &Model) -> Option<u32> {
/// The new offer `0x5d4500` sends for the held coins: offer plus cursor, when `purse`, the coinage,
/// covers it (`0x5d4570`); `None` with no coins held.
pub(crate) fn trade_money_total(model: &Model, purse: u64) -> Option<u32> {
let copper = cursor_money(model);
if copper == 0 {
return None;
}
let offer = model.trade.as_ref().map_or(0, |t| t.player.gold);
let total = u64::from(offer) + u64::from(copper);
(total <= model.money).then_some(total as u32)
(total <= purse).then_some(total as u32)
}
/// Our money offer `[0xc4be70]` as a sender stores it after its send.
@@ -97,7 +99,8 @@ pub(crate) fn set_own_offer(model: &mut Model, copper: u32) {
pub(crate) fn add_trade_money(lua: &Lua) -> mlua::Result<bool> {
let (held, total) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
(cursor_money(&model) != 0, trade_money_total(&model))
let purse = crate::script::stat_read::purse(lua, &model);
(cursor_money(&model) != 0, trade_money_total(&model, purse))
};
let Some(total) = total else {
return Ok(held);
+49 -43
View File
@@ -1,20 +1,21 @@
//! The gossip bindings: the app pushes the open menu ([`UiScript::set_gossip`]), each select hands
//! the host its call ([`GossipCall`]), which reads the message's slots and sends before the verb
//! returns, and `CloseGossip` leaves its teardown to the app.
//! The gossip bindings: the getters read the message's slots and the drawn text through the host
//! at the call ([`GossipQuery`]), each select hands the host its call ([`GossipCall`]), which
//! reads the slots and sends before the verb returns, and `CloseGossip` leaves its teardown to the
//! app.
//!
//! `GetGossipOptions()` returns flat `(text, type)` pairs, `type` the lowercase icon name the app
//! maps from the wire `GOSSIP_ICON`. A coded option selected with no code fires
//! `GOSSIP_ENTER_CODE`, whose stock popup (`StaticPopup.lua:1383`) calls
//! `SelectGossipOption(index, code)`.
//!
//! A menu is pushed only once its greeting (`SMSG_NPC_TEXT_UPDATE`) has arrived; until then the
//! VM keeps its last menu, as the reference's frame keeps its last paint: its handler returns on a
//! cache miss without an event, and the greeting write and `GOSSIP_SHOW` share one success path
//! (`0x4e2010`, `0x4e22b0`).
//! The message writes the slots at once (`0x4e26e0`), and the text only once its greeting is
//! drawn (`0x4e229a`), right before `GOSSIP_SHOW`; while the text query is out the getters read
//! the new slots beside the last text, and nothing signals, so the frame keeps its last paint.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi;
use super::live::{ask, LiveQuery};
use super::Model;
/// One gossip option; its 1-based index is its place in [`GossipMenu::options`].
@@ -38,10 +39,11 @@ pub struct GossipQuestRow {
pub active: bool,
}
/// One open gossip menu, pushed whole by the app.
/// The gossip store as the getters read it: the host's answer at the call, else the VM's copy,
/// which only a test sets.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GossipMenu {
/// The NPC greeting, always resolved.
/// The drawn text `0xbbb678`, expanded at its draw, which `GetGossipText` returns (`0x4e28c0`).
pub greeting: String,
/// Quest rows from the same packet.
pub quests: Vec<GossipQuestRow>,
@@ -63,22 +65,36 @@ pub enum GossipCall {
Close,
}
/// The question the gossip getters ask at the call: the text buffer (`0xbbb678`), the used option
/// slots in slot order (`0xbbbe90`, `0x4e28d0`) and the quest rows (`0xbb74c0`).
pub struct GossipQuery;
impl LiveQuery for GossipQuery {
type Answer = GossipMenu;
}
/// `f` over the store now: the host's, else the VM's copy; `None` with neither.
fn read<T>(lua: &Lua, f: impl FnOnce(&GossipMenu) -> T) -> Option<T> {
match ask(lua, &GossipQuery) {
Some(menu) => Some(f(&menu)),
None => {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.gossip.as_ref().map(f)
}
}
}
/// Hand `call` to the host, which applies it before this returns ([`super::calls::submit`]).
fn submit(lua: &Lua, call: GossipCall) -> mlua::Result<()> {
super::calls::submit(lua, super::ScriptCall::Gossip(call))
}
impl super::UiScript {
/// Push (or clear, with `None`) the open gossip menu.
/// Set (or clear, with `None`) the VM's copy of the store, for a test.
pub fn set_gossip(&mut self, menu: Option<GossipMenu>) {
self.model_mut().gossip = menu;
}
/// The menu the VM holds, as last pushed.
pub fn gossip(&self) -> Option<GossipMenu> {
self.model_ref().gossip.clone()
}
/// `ResetGossipOptions` (`0x4e26a0`) as the getters see it: no option and no quest row; the
/// text stays.
pub fn reset_gossip_slots(&mut self) {
@@ -114,15 +130,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"GetGossipText",
lua.create_function(|lua, ()| {
let text = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.gossip.as_ref().map(|m| m.greeting.clone())
};
match text {
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
None => Ok(Value::Nil),
}
lua.create_function(|lua, ()| match read(lua, |m| m.greeting.clone()) {
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
None => Ok(Value::Nil),
})?,
)?;
@@ -130,15 +140,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"GetGossipOptions",
lua.create_function(|lua, ()| {
// Copy out under one short borrow; build the Lua values with none held.
let pairs: Vec<(String, String)> = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.gossip.as_ref().map_or_else(Vec::new, |m| {
m.options
.iter()
.map(|o| (o.label.clone(), o.icon_type.clone()))
.collect()
})
};
let pairs: Vec<(String, String)> = read(lua, |m| {
m.options
.iter()
.map(|o| (o.label.clone(), o.icon_type.clone()))
.collect()
})
.unwrap_or_default();
let mut out: Vec<Value> = Vec::with_capacity(pairs.len() * 2);
for (label, icon) in pairs {
out.push(Value::String(lua.create_string(&label)?));
@@ -180,16 +188,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// The reference splits the quest rows by wire icon, 3 and 4 active and the rest available,
// behind two vararg verbs (`0x4e2430`, `0x4e2580`); the app splits them at parse time.
fn quest_rows(lua: &Lua, active: bool) -> mlua::Result<MultiValue> {
let rows: Vec<(String, u32)> = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.gossip.as_ref().map_or_else(Vec::new, |m| {
m.quests
.iter()
.filter(|q| q.active == active)
.map(|q| (q.title.clone(), q.level))
.collect()
})
};
let rows: Vec<(String, u32)> = read(lua, |m| {
m.quests
.iter()
.filter(|q| q.active == active)
.map(|q| (q.title.clone(), q.level))
.collect()
})
.unwrap_or_default();
let mut out = Vec::with_capacity(rows.len() * 2);
for (title, level) in rows {
out.push(Value::String(lua.create_string(&title)?));
+13 -5
View File
@@ -382,7 +382,12 @@ pub enum MailRowVerb {
impl MailRowVerb {
/// `Ok` when the reference sends for `row`, else the refusal's message key, if it shows one.
fn sends(self, model: &Model, row: &MailInboxRow) -> Result<(), Option<&'static str>> {
fn sends(
self,
lua: &Lua,
model: &Model,
row: &MailInboxRow,
) -> Result<(), Option<&'static str>> {
// An item (`[rec+0x120]`, `0x4af96d`), then the player's object (`0x4af992`) and the purse
// to pay its COD (`[rec+0x144]` against the coinage, `0x4af9c5`); either miss shows
// `ERR_NOT_ENOUGH_MONEY` (`0x4afa4c`, `push 0x25`).
@@ -391,7 +396,7 @@ impl MailRowVerb {
return Err(None);
}
let player = model.units_by_lower.contains_key("player");
if !player || model.money < u64::from(row.cod) {
if !player || super::stat_read::purse(lua, model) < u64::from(row.cod) {
return Err(Some("ERR_NOT_ENOUGH_MONEY"));
}
return Ok(());
@@ -679,7 +684,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// the lock or the mailbox.
let model = lua.app_data_ref::<Model>().expect("model app_data");
let can = row_at(&model, index)
.is_some_and(|r| MailRowVerb::Delete.sends(&model, &r).is_ok());
.is_some_and(|r| MailRowVerb::Delete.sends(lua, &model, &r).is_ok());
Ok(flag(can))
})?,
)?;
@@ -764,7 +769,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let Some(row) = row_at(&model, index as usize) else {
return Ok(());
};
if let Err(refusal) = verb.sends(&model, &row) {
if let Err(refusal) = verb.sends(lua, &model, &row) {
drop(model);
return match refusal {
Some(key) => super::calls::display_error(lua, key),
@@ -874,7 +879,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
copper,
"Usage: SetSendMailMoney(amount)",
)? as u32;
let money = lua.app_data_ref::<Model>().expect("model app_data").money;
let money = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
super::stat_read::purse(lua, &model)
};
if u64::from(n) > money {
super::calls::display_error(lua, "ERR_NOT_ENOUGH_MONEY")?;
return Ok(Value::Nil);
+42 -2
View File
@@ -72,7 +72,8 @@ pub struct ItemStatsHead {
pub sell_price: u32,
}
/// Each damaged item's repair cost in copper after the vendor's discount (`0x4faf30`).
/// Each damaged item's repair cost in copper after the vendor's discount (`0x4faf30`): the VM's
/// copy the tooltips read with no host to ask ([`RepairCostQuery`]), which only a test sets.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RepairCosts {
/// Equipped items by live inventory id.
@@ -138,6 +139,45 @@ impl LiveQuery for RepairAllCostQuery {
type Answer = Option<(u32, bool)>;
}
/// The repair cost the item tooltips return for the item a slot holds, read at the call
/// (`0x534948`, `0x5332e1`): `0x4faf30`, the object's own cost after the open vendor's discount,
/// 0 for no object. `bag` is the Lua container id, or [`super::EQUIPMENT_BAG`] with a doll live id.
pub struct RepairCostQuery {
pub bag: i64,
pub slot: u32,
}
impl LiveQuery for RepairCostQuery {
type Answer = u32;
}
/// An item object's own repair cost before any discount (`0x5da330`), which the merchant price
/// takes off (`0x52e44a`); 0 for no such object.
pub struct ItemRepairCostQuery(pub u64);
impl LiveQuery for ItemRepairCostQuery {
type Answer = u32;
}
/// The repair cost of the item at `bag`'s 1-based `slot` now, as [`RepairCostQuery`] reads it:
/// the host's, else the VM's copy, which only a test sets.
pub(crate) fn repair_cost_at(lua: &Lua, model: &Model, bag: i64, slot: u32) -> u32 {
ask(lua, &RepairCostQuery { bag, slot }).unwrap_or_else(|| {
let costs = &model.repair_costs;
let cost = match bag {
super::EQUIPMENT_BAG => costs.equipped.get(&slot),
_ => costs.bags.get(&(bag, slot)),
};
cost.copied().unwrap_or(0)
})
}
/// The item object `guid`'s own repair cost now, as [`ItemRepairCostQuery`] reads it.
pub(crate) fn item_repair_cost(lua: &Lua, model: &Model, guid: u64) -> u32 {
ask(lua, &ItemRepairCostQuery(guid))
.unwrap_or_else(|| model.repair_costs.own.get(&guid).copied().unwrap_or(0))
}
/// The window's head now: the host's, else the VM's copy, which only a test sets (every
/// production entry that runs Lua lends the host).
pub(crate) fn head(lua: &Lua, model: &Model) -> MerchantHead {
@@ -198,7 +238,7 @@ impl super::UiScript {
self.model_mut().merchant = state;
}
/// Push the repair costs the item tooltip bindings return.
/// Set the repair costs the item tooltip bindings return with no host to ask, for a test.
pub fn set_repair_costs(&mut self, costs: RepairCosts) {
self.model_mut().repair_costs = costs;
}
+5 -4
View File
@@ -241,7 +241,7 @@ pub use aura::{
AuraCall, AuraListRead, AuraRead, AuraState, PlayerBuffExpiry, TrackingState, UnitAuraList,
};
pub use backdrop::{Backdrop, Insets};
pub use bank::{BankCall, BankState};
pub use bank::{BankCall, BankSlotsQuery, BankState};
pub use battlefield_live::{
BattlefieldFlagQuery, BattlefieldIconScaleQuery, BattlefieldListQuery,
BattlefieldPositionCountQuery, BattlefieldPositionQuery, BattlefieldScoreRead,
@@ -317,7 +317,7 @@ pub use duel::DuelRequest;
pub use follow::FollowRequest;
pub use gm_survey::{GmSurveyTables, SurveyAnswerSend};
pub use gm_ticket::{GmTicketCall, GmTicketIntent, GmTicketWrite};
pub use gossip::{GossipCall, GossipMenu, GossipOptionView, GossipQuestRow};
pub use gossip::{GossipCall, GossipMenu, GossipOptionView, GossipQuery, GossipQuestRow};
pub use guild::{
GuildCall, GuildCells, GuildMemberInfo, GuildRankEdit, GuildRankInfo, GuildState, LastOnline,
UnitGuild, MAX_RANKS, MIN_RANKS, RANK_RIGHT_BITS,
@@ -376,8 +376,9 @@ pub use mail::{
};
pub use measure::TextMeasure;
pub use merchant::{
BuybackRowQuery, ItemStatsHead, MerchantCall, MerchantHead, MerchantHeadQuery, MerchantItem,
MerchantRowQuery, MerchantState, RepairAllCostQuery, RepairCosts,
BuybackRowQuery, ItemRepairCostQuery, ItemStatsHead, MerchantCall, MerchantHead,
MerchantHeadQuery, MerchantItem, MerchantRowQuery, MerchantState, RepairAllCostQuery,
RepairCostQuery, RepairCosts,
};
pub(crate) use minimap::apply_model_attrs as apply_minimap_model_attrs;
pub use minimap::{
+2 -2
View File
@@ -665,8 +665,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, ()| {
let allowed = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
// Affordability reads `model.money`, the purse `GetMoney()` answers.
let money = model.money;
// Affordability reads the purse `GetMoney()` answers (`0x4cb122`).
let money = super::stat_read::purse(lua, &model);
view(lua, &model).state.as_ref().is_some_and(|s| {
s.num_stable_slots as usize != NUM_STABLE_SLOTS - 1
&& s.next_slot_cost != 0
@@ -348,3 +348,12 @@ pub(crate) fn read_self<T>(lua: &Lua, f: impl FnOnce(&dyn SelfRead) -> T) -> T {
}
}
}
/// The purse now, `PLAYER_FIELD_COINAGE` (`+0xf70`), as `GetMoney` reads it (`0x518160`): the
/// active player's through the lent host, 0 while it holds none (`0x5181db`), else `model`'s
/// copy. For a caller that holds the model already.
pub(crate) fn purse(lua: &Lua, model: &Model) -> u64 {
let mut out = None;
super::host::read_host(lua, |h| h.read_self(&mut |own| out = Some(own.money())));
out.unwrap_or(model.money)
}
@@ -318,10 +318,9 @@ pub(in crate::script) fn build_item(
// An item object's charges and repair cost (`0x52e426` tests the object).
let object = build.inst.as_ref().filter(|i| i.object);
let charges = object.map(|i| i.object_charges[0]);
let cost = object
.and_then(|i| model.repair_costs.own.get(&i.guid))
.copied()
.unwrap_or(0);
let cost = object.map_or(0, |i| {
crate::script::merchant::item_repair_cost(lua, &model, i.guid)
});
let amount = sell_amount(v.sell_price, v.spell_charges_0, charges, cost, count);
(v.sell_price, amount)
});
@@ -545,7 +545,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
})
});
let cost = if player {
model.inv_repair_cost(slot)
model.inv_repair_cost(lua, slot)
} else {
0
};
@@ -633,7 +633,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
let (item_id, count, link, quality, inst, repair_cost) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let cost = model.repair_costs.bags.get(&(bag, slot)).copied();
let cost = crate::script::merchant::repair_cost_at(lua, &model, bag, slot);
match model.bag_slot(bag, slot).filter(|s| s.item_id != 0) {
// `0x534620` takes the p6=1 leg iff the cooldown query `0x6e2ed0` reports
// enable, start and duration all non-zero; that boolean is the inverse of the
@@ -658,7 +658,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
// p6 = 0 iff no cooldown runs; mid-cooldown ITEM_COOLDOWN_TIME takes
// the openable line's place.
inst,
cost.unwrap_or(0),
cost,
)
}
None => return Ok(MultiValue::from_vec(vec![Value::Nil])),
+9 -5
View File
@@ -233,13 +233,13 @@ impl super::UiScript {
}
/// `SetTradePartner`'s open leg for a trade we started by dropping coins (`0x4bf5fe`-
/// `0x4bf61c`): the offer becomes offer plus the held coins through `0x5d4500` when the purse
/// covers it. The new offer, for the app's send, the coins still held for
/// `0x4bf61c`): the offer becomes offer plus the held coins through `0x5d4500` when `purse`,
/// the coinage the app reads at the handler, covers it. The new offer, for the app's send, the coins still held for
/// [`Self::trade_open_money_clear`]; `None` with no coins held or a purse short of the total,
/// which leaves the cursor as it is.
pub fn trade_open_money(&mut self) -> Option<u32> {
pub fn trade_open_money(&mut self, purse: u64) -> Option<u32> {
super::tick::verb(&self.lua, |model| {
let total = cursor::money::trade_money_total(model)?;
let total = cursor::money::trade_money_total(model, purse)?;
cursor::money::set_own_offer(model, total);
Some(total)
})
@@ -1129,7 +1129,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
copper,
"Usage: SetTradeMoney(amount)",
)? as u32;
if u64::from(n) > lua.app_data_ref::<Model>().expect("model app_data").money {
let money = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
crate::script::stat_read::purse(lua, &model)
};
if u64::from(n) > money {
return Ok(());
}
submit(lua, TradeCall::Gold(n))?;
+3 -3
View File
@@ -345,7 +345,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
#### ui_gossip
- 14 (a NULL, pushed as `nil`) and 15 up (a read past the table's end) give `"gossip"`, because the reference's behaviour there is a missing bounds check. ([`src/ui_gossip/mod.rs:222`](../crates/benilla-app/src/ui_gossip/mod.rs#L222))
- 14 (a NULL, pushed as `nil`) and 15 up (a read past the table's end) give `"gossip"`, because the reference's behaviour there is a missing bounds check. ([`src/ui_gossip/mod.rs:238`](../crates/benilla-app/src/ui_gossip/mod.rs#L238))
#### ui_guild
@@ -767,8 +767,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- A listing that finds no icon stays empty, to be listed again on the next ask, where the reference's dedup then reads below its null list (`0x4f0134`-`0x4f0145`) and faults. ([`src/script/macros.rs:547`](../crates/benilla-ui/src/script/macros.rs#L547))
- Each line is trimmed, because the reference sends an indented `" /say hi"` as plain chat, never as the command meant. ([`src/script/macros/bind.rs:30`](../crates/benilla-ui/src/script/macros/bind.rs#L30))
- The `/cast` argument is trimmed, because the reference binds `/cast Fireball` (two spaces) to a name that never resolves. ([`src/script/macros/bind.rs:50`](../crates/benilla-ui/src/script/macros/bind.rs#L50))
- The client's Lua has no such bound, as nothing sets a hook (`lua_sethook`, `0x6fba40`, has no caller and no pointer to it), so a load that never ends hangs it. ([`src/script/mod.rs:975`](../crates/benilla-ui/src/script/mod.rs#L975))
- The reference sets the cells only when glue or the interface is built, `0x41ad10` from `0x46a828` (`0x46a7b0`) and `0x48fe2b` (`0x48fbf0`), and a resize reaches them only through the rebuild it asks for. ([`src/script/mod.rs:1037`](../crates/benilla-ui/src/script/mod.rs#L1037))
- The client's Lua has no such bound, as nothing sets a hook (`lua_sethook`, `0x6fba40`, has no caller and no pointer to it), so a load that never ends hangs it. ([`src/script/mod.rs:976`](../crates/benilla-ui/src/script/mod.rs#L976))
- The reference sets the cells only when glue or the interface is built, `0x41ad10` from `0x46a828` (`0x46a7b0`) and `0x48fe2b` (`0x48fbf0`), and a resize reaches them only through the rebuild it asks for. ([`src/script/mod.rs:1038`](../crates/benilla-ui/src/script/mod.rs#L1038))
- A path that does not resolve is stored, where the reference raises `Invalid model file: %s` (`0x878b44`), because the file loads asynchronously here and the call cannot know yet. ([`src/script/modelframe.rs:339`](../crates/benilla-ui/src/script/modelframe.rs#L339))
- The reference's free list is FIFO; either keeps every index stable, all an addon can observe. ([`src/script/nameplate.rs:455`](../crates/benilla-ui/src/script/nameplate.rs#L455))
- The reference adds this rim over the bar; we brighten the bar (`LIT_BOOST`), because the rim reads as hard edge lines in our linear-blending pipeline. ([`src/script/nameplate.rs:911`](../crates/benilla-ui/src/script/nameplate.rs#L911))