Files
benilla-octo/crates/benilla-app/src/ui_dialog_verbs.rs
T
SamandClaude Fable 5.1 fd8ba04fa8 benilla enters a battleground, and a ghost finds its way back
- the area spirit healer works: a ghost at a battleground graveyard is picked up by the reference's 20-yard poll, the resurrect dialog shows, and the wave brings the body back (2291)
- WOW_PROBE_BG enters Warsong Gulch by the real road, battlemaster, queue, port, and censuses the match from inside: terrain, world states, the readout, the scoreboard, the graveyard (2290)
- the battlefield minimap is driven end to end by a harness: the demand load, the tiles, the overlays, the blips and the tab (2293)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 16:43:22 +07:00

1761 lines
77 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The dialog engine's verbs, app half (decision 1963): the feeds behind the stock
//! `StaticPopup.lua` dialogs benilla never raised and the drains behind their buttons, each to
//! wow-re's `staticpopup-dialog-bindings.md` (VERIFIED at the bytes unless a line says INFERRED).
//!
//! * **Pet trainer** — `SMSG_PET_UNLEARN_CONFIRM {guid, cost}` latches both and owes
//! `CONFIRM_PET_UNLEARN(cost)`; `ConfirmPetUnlearn()` answers with `CMSG_PET_UNLEARN {guid}` unless
//! the cost outruns the purse (`ERR_NOT_ENOUGH_MONEY`, nothing sent). The talent-wipe twin
//! (`crate::ui_talent_wipe`), latch for latch, leash for leash: the trainer walking out of
//! `INTERACT_DISTANCE` closes the question, which is what `CheckPetUntrainerDist()` polls.
//! * **Instance boot** — every `SMSG_RAID_GROUP_ONLY {delayMs, reason}` fires an event: a positive
//! delay arms the deadline and `INSTANCE_BOOT_START`, zero clears it and `INSTANCE_BOOT_STOP`,
//! and only the zero leg names reason 1/2 on screen. `GetInstanceBootTimeRemaining()` reads
//! whole seconds off the deadline; nothing clears it but a zero packet.
//! * **Area spirit healer** — `SMSG_AREA_SPIRIT_HEALER_TIME {guid, ms}` arms the wave clock and
//! fires `AREA_SPIRIT_HEALER_IN_RANGE` when the guid is the cached healer's; Accept sends
//! `0x2E3` with that guid, Cancel is the cancel-aura of spell 2584 plus `_OUT_OF_RANGE`. **The
//! cache has no writer yet**: the reference's per-frame proximity scan (20 yd to acquire, 22 to
//! retain, `0x4923b0`) is not carved for its unit filter, so no healer is ever cached here and
//! Accept stays silent — named in the record, not guessed at.
//! * **Battlefield queue** — `SMSG_BATTLEFIELD_STATUS` fills one of three slots and fires
//! `UPDATE_BATTLEFIELD_STATUS`; `AcceptBattlefieldPort(index, accept)` sends the slot's map id
//! with the answer as one byte.
//! * **Meeting stone** (wow-re `meeting-stone-status.md`, 1974) — two globals: the queued area
//! (`[0xb72038]`) and the cached status text (`[0xb7203c]`). `SMSG 0x295 {areaId, status}`
//! latches the old area, stores the new one unconditionally, prints one of five chat lines by
//! the status byte (with the two asymmetries §8 records: status 0 names the OLD area and is
//! silent when it has no row; status 1 is skipped entirely when the area did not change, names
//! the NEW one with an `UNKNOWN` fallback, and plays the `HARDCODED Meeting Stone Join` visual
//! on the player), then — on EVERY path, an out-of-range status included — rebuilds the text
//! (`MEETINGSTONE_TOOLTIP` over the area's name or `UNKNOWN`, into a 256-byte buffer) and fires
//! `MEETINGSTONE_CHANGED`. World enter resets the text to the bare `UNKNOWN` and sends the empty
//! `CMSG 0x296` once per world session; world leave drops the text to none.
//! `CancelMeetingStoneRequest()` sends `0x293` unless in a party led by someone else
//! (`ERR_MEETING_STONE_NOT_LEADER`). The four display-only replies (`0x297/0x298/0x299/0x2BB`)
//! are chat lines with no state; the status-1 arm also triggers the Meeting Stones tutorial
//! (`crate::tutorial`, 1976). **JOINING** is the click's own leg (decision 2283): type 23's
//! use slot is its own validator, not the shared `CMSG_GAMEOBJ_USE` sender — four client-side
//! refusals ([`meeting_stone_join_refusal`]), then `CMSG 0x292 {u64 goGuid}`.
use std::time::Instant;
use benilla_protocol::messages::{BattlefieldStatus, MeetingStoneNotice};
use benilla_ui::script::{ScriptValue, UiScript};
use bevy::ecs::system::NonSendMut;
use bevy::prelude::*;
use crate::area::AreaTableRes;
use crate::creature_anim::spell_visual::{meeting_stone_join_fx, SpellKitFx, SpellVisuals};
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfGuid, SelfPlayer};
use crate::ui_party::GroupState;
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
/// The pet trainer's pending question: the latch (`0xc4d7b0/b4`) and the cost (`0xc4d7b8`).
#[derive(Resource, Default)]
pub(crate) struct PetUnlearnState {
npc: Option<u64>,
cost: u32,
ask: bool,
}
impl PetUnlearnState {
/// The inbound `SMSG_PET_UNLEARN_CONFIRM`: latch and owe the dialog.
pub(crate) fn ask(&mut self, npc: u64, cost: u32) {
self.npc = Some(npc);
self.cost = cost;
self.ask = true;
}
fn pending(&self) -> Option<u64> {
self.npc
}
}
impl NpcSession for PetUnlearnState {
fn npc(&self) -> Option<u64> {
self.npc
}
fn close(&mut self) {
self.npc = None;
self.cost = 0;
self.ask = false;
}
}
/// The instance-boot clock (`[0xb4e34c]`), and what the last packet owes the UI.
#[derive(Resource, Default)]
pub(crate) struct InstanceBoot {
deadline: Option<Instant>,
/// Events owed, in arrival order (`INSTANCE_BOOT_START` / `_STOP`).
events: Vec<&'static str>,
/// Error lines owed (`ERR_RAID_GROUP_ONLY` / `_FULL`), the zero-delay leg's.
errors: Vec<&'static str>,
}
impl InstanceBoot {
/// `SMSG_RAID_GROUP_ONLY`: `delay > 0` arms, else clears — and the event fires either way.
pub(crate) fn apply(&mut self, delay_ms: u32, reason: u32, now: Instant) {
if delay_ms > 0 {
self.deadline = Some(now + std::time::Duration::from_millis(u64::from(delay_ms)));
self.events.push("INSTANCE_BOOT_START");
} else {
self.deadline = None;
self.events.push("INSTANCE_BOOT_STOP");
match reason {
1 => self.errors.push("ERR_RAID_GROUP_ONLY"),
2 => self.errors.push("ERR_RAID_GROUP_FULL"),
_ => {}
}
}
}
/// Whole seconds left, 0 when idle or past — the reference's unsigned divide of a clamped
/// millisecond remainder.
pub(crate) fn secs(&self, now: Instant) -> u32 {
self.deadline
.map(|d| d.saturating_duration_since(now).as_secs())
.map_or(0, |s| u32::try_from(s).unwrap_or(u32::MAX))
}
}
/// `SPIRITGUIDE` — `UNIT_NPC_FLAGS` bit 6, the flag the acquire callback `0x4924c0` tests at
/// `0x4924fc shr eax,0x6; test al,1` (wow-re `interact-dead-fork-and-npc-service-ladder.md` §C).
const NPC_FLAG_SPIRITGUIDE: u32 = 1 << 6;
/// The area spirit healer's aura, `0xA18` = 2584 — the one spell `0x4921c0`'s cancel leg and
/// `CancelAreaSpiritHeal` both name, and the only spell id `0x6e7040` fires
/// `AREA_SPIRIT_HEALER_OUT_OF_RANGE` for (`0x6e70b6 cmp esi,0xa18`).
pub(crate) const AREA_SPIRIT_HEALER_AURA: u32 = 2584;
/// The area spirit healer's **acquire** radius — the `.rdata` f32 `[0x8044d0] = 20.0`, compared
/// squared in the enumerate callback `0x4924c0`.
const SPIRIT_GUIDE_ACQUIRE_YD: f32 = 20.0;
/// The **retain** radius: the same f32 times `[0x804580] = 1.1`, i.e. 22.0 (`0x492406`). A healer
/// is adopted inside 20 yd and kept until 22 — the hysteresis is the reference's, and without it
/// a body standing on the boundary would send a query every frame it jittered across.
const SPIRIT_GUIDE_RETAIN_YD: f32 = SPIRIT_GUIDE_ACQUIRE_YD * 1.1;
/// What [`AreaSpiritHealer::set_healer`] owes the rest of the frame.
///
/// The reference does both of these inside `0x4921c0` itself; here the resource is a plain data
/// type with no access to the VM or the socket, so it *reports* them and the systems pay them.
#[derive(Default, PartialEq, Eq, Debug)]
pub(crate) struct SetHealerOutcome {
/// A wave deadline was pending and the healer changed, so `0x4921fc mov ecx,0xa18;
/// call 0x6e7040` ran: `AREA_SPIRIT_HEALER_OUT_OF_RANGE` fires and `CMSG_CANCEL_AURA(2584)`
/// goes out. **This is how walking away from a graveyard closes the wave dialog** — no Lua
/// call is involved, which is why the event has no argument and no caller.
pub(crate) cancel_aura: bool,
/// The newly adopted healer, if the change landed on a non-zero guid: `CMSG 0x2E2` with it.
pub(crate) query: Option<u64>,
}
/// The current-area spirit healer (`[0xb4e330/334]`) and its wave clock (`[0xb4e338]`).
///
/// **The writer is [`Self::set_healer`], and it is the reference's `0x4921c0` to the branch.**
/// 1963 shipped this resource with the note "no writer yet" because the acquire side was thought
/// to be uncarved; it is not — wow-re recorded the whole trio, in two different nodes, before that
/// record landed (`ui/scratch/staticpopup-dialog-bindings.md` §6 for the setter and the poll's
/// radii, `object-layer/scratch/interact-dead-fork-and-npc-service-ladder.md` §C row 6 for the
/// click arm). Both roads in are built here.
#[derive(Resource, Default)]
pub(crate) struct AreaSpiritHealer {
/// The cached healer (`[0xb4e330/334]`), written only by [`Self::set_healer`].
healer: Option<u64>,
deadline: Option<Instant>,
in_range: bool,
}
impl AreaSpiritHealer {
/// `SMSG_AREA_SPIRIT_HEALER_TIME`: for the cached healer with a positive time, arm the clock
/// (a zero-landing deadline reads as 1 ms in the reference) and owe `_IN_RANGE`.
pub(crate) fn on_time(&mut self, healer: u64, ms: u32, now: Instant) {
if self.healer == Some(healer) && ms > 0 {
self.deadline = Some(now + std::time::Duration::from_millis(u64::from(ms)));
self.in_range = true;
}
}
/// **`0x4921c0` — "set current area spirit healer"**, decoded at the branch:
///
/// ```text
/// if (cached == new) return; // 0x4921cf/0x4921dd — guards EVERYTHING below
/// cached = new; // 0x4921e5 / 0x4921f5
/// if (deadline != 0) CancelAura(0xA18); // 0x4921fc — fires _OUT_OF_RANGE, sends 0x136
/// deadline = 0; // 0x492211
/// if (cached != 0) send CMSG 0x2E2; // 0x492217 / 0x492219
/// ```
///
/// The early return at the top is the load-bearing part and the one a paraphrase loses: with
/// the guid unchanged this routine does **nothing at all** — no cancel, no deadline clear, no
/// packet. That is what lets the per-frame poll call it unconditionally (it clears by calling
/// `set_healer(None)` every frame it has no ghost) at zero cost.
pub(crate) fn set_healer(&mut self, new: Option<u64>) -> SetHealerOutcome {
// The reference keeps a 0:0 guid where we keep `None`; normalise so a zero guid arriving
// from the wire cannot masquerade as a real healer.
let new = new.filter(|&g| g != 0);
if self.healer == new {
return SetHealerOutcome::default();
}
self.healer = new;
let cancel_aura = self.deadline.take().is_some();
if cancel_aura {
// The cancel closes the dialog, so the `_IN_RANGE` this frame would have owed is
// stale — the reference cannot have one pending here either (the event is fired from
// the handler, which runs the poll first).
self.in_range = false;
}
SetHealerOutcome {
cancel_aura,
query: new,
}
}
/// The **spirit-guide click arm**'s two calls (`0x5df950`: `0x4921c0(0,0)` then
/// `0x4921c0(guid)`). The first is a deliberate cache-bust — its own early return suppresses
/// the send for a zero guid — so the second **always** transmits, even for the healer already
/// cached. Clicking the guide you are standing next to therefore re-asks for the clock, which
/// is exactly what a player does when the dialog has been dismissed.
pub(crate) fn click_guide(&mut self, guid: u64) -> SetHealerOutcome {
let bust = self.set_healer(None);
let set = self.set_healer(Some(guid));
SetHealerOutcome {
cancel_aura: bust.cancel_aura || set.cancel_aura,
query: set.query,
}
}
/// The cached healer — `AcceptAreaSpiritHeal`'s guid and the poll's retain subject.
pub(crate) fn healer(&self) -> Option<u64> {
self.healer
}
fn secs(&self, now: Instant) -> u32 {
self.deadline
.map(|d| d.saturating_duration_since(now).as_secs())
.map_or(0, |s| u32::try_from(s).unwrap_or(u32::MAX))
}
}
/// The three battleground queue slots (`0xb6e9d0`, stride `0x20`), each with the moment its
/// status landed — the clock every stamp in the slot is relative to.
#[derive(Resource, Default)]
pub(crate) struct BattlefieldQueue {
slots: [Option<(BattlefieldStatus, Instant)>; 3],
changed: bool,
/// The slot the player is IN (`[0x8457cc]`, the status-3 arm) and its map.
active: Option<(usize, u32)>,
/// The instance's two clocks (`[0xb6ebbc]`/`[0xb6ebb8]`, wow-re `battlefield-verb-family.md`
/// §4.2): the run-time stamp `now − Δ₂` and the expiration `now + Δ₁`, set by a status-3
/// message and zeroed by ANY non-clearing message of another status — whatever slot it is
/// about (§10's anomaly 4, reproduced: 1972 zeroed them only for the active slot; 1974
/// corrects it to the handler's unconditional clear).
run_started: Option<Instant>,
instance_expiration: Option<Instant>,
/// The status-3 arm rebuilds the scoreboard and fires `UPDATE_BATTLEFIELD_SCORE` before
/// `UPDATE_BATTLEFIELD_STATUS` (§4.2's ordering) — the score feed reads this first.
score_dirty: bool,
/// The handler's two tutorial arms (`0x2f` on queued, `0x30` on confirm; 1976), owed to the
/// tutorial system on the next feed.
tutorials: Vec<u32>,
}
impl BattlefieldQueue {
/// `SMSG_BATTLEFIELD_STATUS` (§4.2): an out-of-range slot abandons the message; a zero map
/// takes the clear arm (the slot emptied, the instance clocks zeroed only when this was the
/// active slot — and the active index itself left alone, as the handler leaves `[0x8457cc]`);
/// status 3 stamps the instance clocks and names the slot active; every other status zeroes
/// the instance clocks unconditionally and un-names the slot if it was the active one.
pub(crate) fn apply(&mut self, status: BattlefieldStatus) {
self.apply_at(status, Instant::now());
}
fn apply_at(&mut self, status: BattlefieldStatus, now: Instant) {
let index = status.slot as usize;
let Some(slot) = self.slots.get_mut(index) else {
return;
};
if status.map_id == 0 {
if self.active.is_some_and(|(i, _)| i == index) {
self.run_started = None;
self.instance_expiration = None;
}
*slot = None;
self.changed = true;
return;
}
match status.status {
1 => self.tutorials.push(crate::tutorial::id::BATTLEGROUND_QUEUE),
2 => self
.tutorials
.push(crate::tutorial::id::PORT_TO_BATTLEGROUND),
_ => {}
}
match status.in_progress {
Some((expires_ms, elapsed_ms)) => {
self.active = Some((index, status.map_id));
self.instance_expiration = (expires_ms != 0)
.then(|| now + std::time::Duration::from_millis(u64::from(expires_ms)));
self.run_started = (elapsed_ms != 0)
.then(|| now - std::time::Duration::from_millis(u64::from(elapsed_ms)));
self.score_dirty = true;
}
None => {
self.run_started = None;
self.instance_expiration = None;
if self.active.is_some_and(|(i, _)| i == index) {
self.active = None;
}
}
}
*slot = Some((status, now));
self.changed = true;
}
/// The three slots with the instant each status landed — the queue verbs' view builder
/// (`crate::ui_battlefield`) reduces their stamps against `now`.
pub(crate) fn slots(&self) -> &[Option<(BattlefieldStatus, Instant)>; 3] {
&self.slots
}
/// `GetBattlefieldInstanceExpiration()`: `deadline − now` in ms, 0 when unset or past
/// (`[0xb6ebb8]`, the `jns` guard).
pub(crate) fn instance_expiration_ms(&self, now: Instant) -> u32 {
self.instance_expiration.map_or(0, |d| {
d.saturating_duration_since(now)
.as_millis()
.min(u128::from(u32::MAX)) as u32
})
}
/// The map of the battleground the player is in — `LeaveBattlefield`'s payload; `None` = 0.
pub(crate) fn active_map(&self) -> Option<u32> {
self.active.map(|(_, map)| map)
}
/// `GetBattlefieldInstanceRunTime()`: ms since the status-3 stamp, 0 with none.
pub(crate) fn run_time_ms(&self, now: Instant) -> u32 {
self.run_started.map_or(0, |t| {
now.saturating_duration_since(t)
.as_millis()
.min(u128::from(u32::MAX)) as u32
})
}
/// The status-3 arm's scoreboard rebuild, once per arrival.
pub(crate) fn take_score_dirty(&mut self) -> bool {
std::mem::take(&mut self.score_dirty)
}
/// The map id `AcceptBattlefieldPort` sends for a 1-based slot, if the slot holds a queue.
fn map_id(&self, index: u8) -> Option<u32> {
self.slots
.get(usize::from(index).checked_sub(1)?)
.and_then(|s| s.as_ref())
.map(|(s, _)| s.map_id)
}
}
/// The cached status text's three states (`[0xb7203c]`): none from process start and after world
/// leave; the bare localized `UNKNOWN` from world enter until the server's `0x295` lands; a
/// built line after that. The two localized halves are resolved against the VM at push time.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
enum StoneText {
#[default]
None,
Unknown,
Built(String),
}
/// `SStrPrintf`'s buffer at the rebuild: 256 bytes, so 255 of text.
const STONE_TEXT_BYTES: usize = 255;
/// The meeting-stone queue: the two globals and what the wire still owes the screen.
#[derive(Resource, Default)]
pub(crate) struct MeetingStone {
/// `[0xb72038]` — the queued area id, `0` = none.
pub(crate) area: u32,
text: StoneText,
/// `0x295` arrivals since the last feed: `(the area BEFORE the store, status)`; the new area
/// is already in `area` (the handler stores it before it switches).
updates: Vec<(u32, u8)>,
notices: Vec<MeetingStoneNotice>,
/// `0x299` guids whose name has not resolved yet.
pending_members: Vec<u64>,
/// The VM's copy of the two globals is stale.
dirty: bool,
}
impl MeetingStone {
/// `SMSG 0x295`: the area is stored unconditionally; the line, the rebuild and the event
/// follow on the next feed, with the VM.
pub(crate) fn apply(&mut self, area: u32, status: u8) {
let old = self.area;
self.area = area;
self.updates.push((old, status));
}
/// One of the four display-only replies.
pub(crate) fn apply_notice(&mut self, notice: MeetingStoneNotice) {
self.notices.push(notice);
}
/// The enter-world bring-up (`0x4c9f40`): the text becomes the bare `UNKNOWN`; the area is
/// untouched (the server's reply resets it).
fn enter_world(&mut self) {
self.text = StoneText::Unknown;
self.dirty = true;
}
/// The leave-world sweep (`0x4c9f80`): the text goes, the area stays.
fn leave_world(&mut self) {
self.text = StoneText::None;
self.dirty = true;
}
}
/// A `GlobalStrings` value as the client's `GetText` reads it: the string, or `""` when the Lua
/// global is missing (`0x882748`, the shared empty-string constant — never NULL).
fn global_text(script: &UiScript, key: &str) -> String {
script
.lua()
.globals()
.get::<String>(key)
.unwrap_or_default()
}
/// The area's localized name, or `None` where the reference's three-part AreaTable resolve fails.
fn area_name(areas: Option<&AreaTableRes>, id: u32) -> Option<&str> {
areas.and_then(|a| a.0.name(id))
}
/// The status-text rebuild `0x4ca070`: `MEETINGSTONE_TOOLTIP` (or `""` when missing) over the
/// queued area's name (or `UNKNOWN`), printed into a 256-byte buffer.
fn build_stone_text(script: &UiScript, areas: Option<&AreaTableRes>, area: u32) -> String {
let name = area_name(areas, area)
.map(str::to_string)
.unwrap_or_else(|| global_text(script, "UNKNOWN"));
let mut text = global_text(script, "MEETINGSTONE_TOOLTIP").replacen("%s", &name, 1);
if text.len() > STONE_TEXT_BYTES {
let mut cut = STONE_TEXT_BYTES;
while !text.is_char_boundary(cut) {
cut -= 1;
}
text.truncate(cut);
}
text
}
/// The `0x295` handler's five-way table (§8), as the line it prints for `(old, new, status)` —
/// `None` where the reference prints nothing: status 0 with no row for the OLD area, status 1
/// with an unchanged area, and any status past 4.
fn stone_line(
script: &UiScript,
areas: Option<&AreaTableRes>,
old: u32,
new: u32,
status: u8,
) -> Option<crate::ui_action::Shown> {
match status {
0 => {
let name = area_name(areas, old)?;
crate::ui_action::keyed_line_s(script, "ERR_MEETING_STONE_LEFT_QUEUE_S", &[name])
}
1 => {
if new == old {
return None;
}
let name = area_name(areas, new)
.map(str::to_string)
.unwrap_or_else(|| global_text(script, "UNKNOWN"));
crate::ui_action::keyed_line_s(script, "ERR_MEETING_STONE_IN_QUEUE_S", &[&name])
}
2 => crate::ui_action::keyed_line(script, "ERR_MEETING_STONE_OTHER_MEMBER_LEFT"),
3 => crate::ui_action::keyed_line(script, "ERR_MEETING_STONE_PARTY_KICKED_FROM_QUEUE"),
4 => crate::ui_action::keyed_line(script, "ERR_MEETING_STONE_MEMBER_STILL_IN_QUEUE"),
_ => None,
}
}
/// The inputs the meeting-stone feed reads beside the VM and its own state — bundled because the
/// feed sits at Bevy's parameter ceiling otherwise.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct MeetingStoneInputs<'w, 's> {
areas: Option<Res<'w, AreaTableRes>>,
names: Res<'w, NameCache>,
commands: Res<'w, NetCommands>,
visuals: Option<Res<'w, SpellVisuals>>,
fx: MessageWriter<'w, SpellKitFx>,
self_q: Query<'w, 's, Entity, With<SelfPlayer>>,
tutorials: Option<MessageWriter<'w, crate::tutorial::TutorialEvent>>,
}
/// The meeting stone's feed: the `0x295` lines, the rebuild and `MEETINGSTONE_CHANGED` per
/// arrival; the four display replies; the two globals pushed when they moved.
fn feed_meeting_stone(
script: Option<NonSendMut<UiScript>>,
mut stone: ResMut<MeetingStone>,
mut inputs: MeetingStoneInputs,
mut sink: crate::ui_action::MessageSink,
) {
let Some(mut script) = script else {
return;
};
let areas = inputs.areas.as_deref();
let mut lines = Vec::new();
for (old, status) in std::mem::take(&mut stone.updates) {
let new = stone.area;
lines.extend(stone_line(&script, areas, old, new, status));
if status == 1 && new != old {
// The status-1 arm's extra block: `Effect_C` kind `0xc` on the local player, then
// the Meeting Stones tutorial (`0x4ca363`, 1976).
if let (Some(visuals), Ok(entity)) = (inputs.visuals.as_deref(), inputs.self_q.single())
{
if let Some(fx) = meeting_stone_join_fx(visuals, entity) {
inputs.fx.write(fx);
}
}
if let Some(t) = inputs.tutorials.as_mut() {
t.write(crate::tutorial::TutorialEvent::trigger(
crate::tutorial::id::MEETING_STONES,
));
}
}
// Every path — the silent legs and an out-of-range status included — rebuilds and fires.
// The two globals reach the VM BEFORE the event: the stock handler's first act is
// `IsInMeetingStoneQueue()`, which has to see the area this packet stored.
let text = build_stone_text(&script, areas, new);
stone.text = StoneText::Built(text.clone());
stone.dirty = false;
script.set_meeting_stone(new, Some(text));
script.fire_event("MEETINGSTONE_CHANGED", vec![]);
}
for notice in std::mem::take(&mut stone.notices) {
match notice {
MeetingStoneNotice::Success => {
lines.extend(crate::ui_action::keyed_line(
&script,
"ERR_MEETING_STONE_SUCCESS",
));
}
MeetingStoneNotice::InProgress => {
lines.extend(crate::ui_action::keyed_line(
&script,
"ERR_MEETING_STONE_IN_PROGRESS",
));
}
MeetingStoneNotice::MemberAdded { guid } => stone.pending_members.push(guid),
MeetingStoneNotice::JoinFailed { code } => {
let key = match code {
1 => "ERR_MEETING_STONE_MUST_BE_LEADER",
2 => "ERR_MEETING_STONE_GROUP_FULL",
3 => "ERR_MEETING_STONE_NO_RAID_GROUP",
_ => continue,
};
lines.extend(crate::ui_action::keyed_line(&script, key));
}
}
}
// `0x299`'s name-cache callback: the line when the name lands, nothing until then.
let pending = std::mem::take(&mut stone.pending_members);
for guid in pending {
match inputs
.names
.resolve(guid, &inputs.commands)
.map(str::to_string)
{
Some(name) => lines.extend(crate::ui_action::keyed_line_s(
&script,
"ERR_MEETING_STONE_MEMBER_ADDED_S",
&[&name],
)),
None => stone.pending_members.push(guid),
}
}
if !lines.is_empty() {
crate::ui_action::show_messages(&mut script, &mut sink, "ui_dialog_verbs", lines);
}
if std::mem::take(&mut stone.dirty) {
let text = match &stone.text {
StoneText::None => None,
StoneText::Unknown => Some(global_text(&script, "UNKNOWN")),
StoneText::Built(t) => Some(t.clone()),
};
script.set_meeting_stone(stone.area, text);
}
}
/// The enter-world bring-up's meeting-stone leg: the text reset, then the empty `CMSG 0x296`.
///
/// **Once per UI LIFECYCLE, not once per world session**, and that distinction is the whole bug.
/// The reference's run-once byte `[0xb4b424]` is *cleared by the UI teardown* — `0x490bd0` calls
/// `0x490a80` at `0x490c20`, which zeroes it at `0x490a8d` — and `UI_Init` (`0x48fbf0`) then calls
/// `0x4908c0` at `0x490168` behind the same live-player gate, finds the byte clear, and re-runs
/// this entire bring-up: `0x490a14` → `0x4c9f40` → `0x4ca1c0` → `PutUInt32(0x296)` + Send. So a
/// `ReloadUI()` **re-asks the server**, and that is what brings the icon back.
///
/// Gated on `MessageReader<EnteredWorldMessage>`, this leg never ran for a `/reload`, which never
/// leaves the world (1291) and so produces no such message. Nothing re-armed
/// [`MeetingStone::dirty`] and nothing re-queried, so `IsInMeetingStoneQueue()` and
/// `GetMeetingStoneStatusText()` answered nil for the rest of the session — and stock
/// `Minimap.xml`'s `MiniMapMeetingStoneFrame` (built `hidden="true"`, shown only by
/// `MEETINGSTONE_CHANGED`, whose single firing site image-wide is the `0x295` handler at
/// `0x4ca38f`) stayed gone while the player was still queued.
///
/// **A [`crate::ui_script::VmMemo`] claim IS the reference's gate.** A byte the UI teardown clears
/// is exactly "once per VM", and 1290's name for that is `claim` — so this reads as the same
/// question the binary asks, rather than as a workaround for the missing message. The one
/// round trip during which the icon is genuinely absent is faithful, not a defect: the reference
/// has the same gap, because only the server's reply fires the event.
///
/// The queued area itself is untouched here, matching `[0xb72038]`, which the RE round found is
/// referenced six times image-wide and by nothing in either reload closure — it survives, and
/// [`MeetingStone::enter_world`]'s `dirty` is what re-pushes it to the fresh VM.
fn meeting_stone_enter_world(
script: Option<NonSendMut<UiScript>>,
mut stone: ResMut<MeetingStone>,
commands: Res<NetCommands>,
mut asked: Local<crate::ui_script::VmMemo<bool>>,
) {
let Some(script) = script else {
return;
};
if !asked.claim(&script) {
return;
}
stone.enter_world();
let _ = commands.0.send(ClientCommand::MeetingStoneStatusQuery);
}
/// A right-click on a `GAMEOBJECT_TYPE_MEETINGSTONE` (23) that got past the shared gates — the
/// GO click ladder's hand-off to this module (decision 2283).
///
/// It travels as a message for the same reason the GameObject opener's cast does (2199): the
/// click system sits at Bevy's 16-`SystemParam` ceiling and cannot also hold the roster, the
/// template cache and the wire. The reference has no such split — `0x5f69d0` is one function —
/// so the *verdict* stays one function here too ([`meeting_stone_join_refusal`]); only the
/// plumbing is two systems.
#[derive(Message, Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct MeetingStoneUse {
pub(crate) go_guid: u64,
}
/// What MEETINGSTONE(23)'s own use slot `0x5f69d0` does with one click — its three outcomes, as
/// the binary has them (decision 2283).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum StoneJoin {
/// `0x5f69f8` — no local player object: `false` with **no message and no packet**. Not a
/// refusal; the validator never starts.
Silent,
/// One of the four refusals: `push <id>; call 0x496720` then `xor al,al; ret`. Every one is a
/// catalog **kind 2** row, so it paints the error frame (`UI_ERROR_MESSAGE`), never a chat
/// line — and none carries a sound (type tag `0x44`, cue `"NONE"`).
Refuse(&'static str),
/// The tail `0x5f6af6`: `CMSG 0x292 {u64 goGuid}`, and nothing else at all.
Send,
}
/// The four client-side refusals inside MEETINGSTONE(23)'s own use slot (`0x5f69d0`, whose tail
/// `0x5f6af6` is the sole caller of the `CMSG 0x292` builder `0x4c9ff0`) — **VERIFIED at the
/// bytes** by wow-re's §5 round on that function (four cold workers plus the orchestrator's own
/// derivation, arbitrated; `system/object-layer/scratch/meeting-stone-use-validator.md` §4).
///
/// Two gates run before any of them. `0x5f69f8`: no local player ⇒ [`StoneJoin::Silent`].
/// `0x5f6a10 je 0x5f6a65`: **not in a group ⇒ both group refusals are skipped**, and a solo player
/// drops straight to the level test.
///
/// | # | at | refusal | predicate | key |
/// |---|---|---|---|---|
/// | 1 | `0x5f6a2f` | in a group we do not lead | the leader guid `[0xbc75f8]` against the active player's, full 64-bit | `ERR_MEETING_STONE_MUST_BE_LEADER` (`0x1b1`) |
/// | 2 | `0x5f6a4f` | the group is full | `0x4e86d0` is `GetNumPartyMembers` — the **other** members, 0..4 — and `cmp eax,4 / jb` refuses at ≥ 4 | `ERR_MEETING_STONE_GROUP_FULL` (`0x1ae`) |
/// | 3 | `0x5f6ab4` | the wrong level for this stone | `data[0] <= level <= data[1]`, **both bounds inclusive and unsigned**, over the player's own `UNIT_FIELD_LEVEL` | `ERR_MEETING_STONE_INVALID_LEVEL` (`0x1b0`) |
/// | 4 | `0x5f6ad3` | in a raid | `[0xb713e0] != 0` — the raid member **count** (`GetNumRaidMembers`) | `ERR_MEETING_STONE_NO_RAID_GROUP` (`0x1b2`) |
///
/// **Three of the four are refused a second time by the server** (vmangos
/// `HandleMeetingStoneJoinOpcode` → `MEETINGSTONE_FAIL_PARTYLEADER` / `_FULL_GROUP` /
/// `_RAID_GROUP`, which come back as `SMSG 0x2BB` and print the same three strings 1974 already
/// built). That copy is not redundant — it is what makes the refusal instant. **The level term is
/// the client's alone**: `HandleMeetingStoneJoinOpcode` reads `gInfo->meetingstone.areaID` and
/// nothing else off the template, so a client that skips it queues a level-1 character for a
/// sixty-level dungeon and the server agrees.
///
/// **An unanswered template REFUSES, and that is the reference's own arithmetic rather than a
/// fail-closed choice of ours.** `0x5f8150` reads the cached template through `[GO+0x214]`, and an
/// uncached object returns **0 for both bounds** — so every level ≥ 1 falls outside `0..=0` and
/// takes the level refusal. The permissive "skip the term while the query is in flight" default
/// the highlight column takes is *wrong* here, and the first cut of this had it. The same
/// arithmetic makes a shipped `data[0] = data[1] = 0` refuse everyone; `0/60` is the open band.
pub(crate) fn meeting_stone_join_refusal(
group: Option<&GroupState>,
self_guid: Option<u64>,
level: Option<u32>,
stone: Option<crate::go_templates::MeetingStoneTemplate>,
) -> StoneJoin {
// `0x5f69f8` — the validator needs an active player before it asks anything.
let (Some(self_guid), Some(level)) = (self_guid, level) else {
return StoneJoin::Silent;
};
// `0x5f6a10` — no group at all skips past both group terms.
let in_group = group.is_some_and(|g| g.in_group);
if in_group {
if group.map(|g| g.leader) != Some(self_guid) {
return StoneJoin::Refuse("ERR_MEETING_STONE_MUST_BE_LEADER");
}
if group.is_some_and(|g| g.members.len() >= MEETING_STONE_PARTY_CAP) {
return StoneJoin::Refuse("ERR_MEETING_STONE_GROUP_FULL");
}
}
// An uncached template reads `0/0` here, which refuses — see the doc above.
let (min_level, max_level) = stone.map_or((0, 0), |s| (s.min_level, s.max_level));
if level < min_level || level > max_level {
return StoneJoin::Refuse("ERR_MEETING_STONE_INVALID_LEVEL");
}
if in_group && group.is_some_and(|g| g.group_type == crate::ui_party::GROUPTYPE_RAID) {
return StoneJoin::Refuse("ERR_MEETING_STONE_NO_RAID_GROUP");
}
StoneJoin::Send
}
/// How many **other** party members make the stone's `GROUP_FULL` refusal fire — a party it could
/// add nobody to.
///
/// **Four, not five, and the difference is a real trap.** A vanilla party holds five *including*
/// the player, and vmangos refuses on exactly that: `Group::IsFull()` is
/// `m_memberSlots.size() >= MAX_GROUP_SIZE (5)`, counting the leader
/// (`Group/Group.h:49,232`) → `MEETINGSTONE_FAIL_FULL_GROUP`. But `SMSG_GROUP_LIST` never lists
/// the recipient (0440), so [`GroupState::members`] is the other four and the comparison is
/// against **4** with no `+ 1`. Adding one — the first cut of this did — refuses a legal
/// four-person party the server would have queued, and the string says which reading is right:
/// `ERR_MEETING_STONE_GROUP_FULL` is *"You are already in a full group"*, and a group of four is
/// not full.
const MEETING_STONE_PARTY_CAP: usize = 4;
/// The join drain: MEETINGSTONE(23)'s use slot, with the click's guid.
fn drain_meeting_stone_joins(
script: Option<NonSendMut<UiScript>>,
mut uses: MessageReader<MeetingStoneUse>,
group: Option<Res<GroupState>>,
self_guid: Res<SelfGuid>,
templates: Res<crate::go_templates::GameObjectTemplates>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
commands: Res<NetCommands>,
mut sink: crate::ui_action::MessageSink,
) {
let Some(mut script) = script else {
uses.clear();
return;
};
let level = self_q.single().ok().and_then(|s| s.0.unit_level());
let mut lines = Vec::new();
for &MeetingStoneUse { go_guid } in uses.read() {
let stone = templates.get(go_guid).and_then(|t| t.meeting_stone);
let verdict = meeting_stone_join_refusal(group.as_deref(), self_guid.0, level, stone);
// The interact chain's last link for this type, on the same `use` tag the click's own
// lines carry (2283): a stone that goes nowhere is one of four refusals, the no-player
// silence, or a send the server ignored — and one trace now says which.
if benilla_assets::trace::enabled_for("use") {
benilla_assets::trace::line(
"use",
&match verdict {
StoneJoin::Silent => format!("meeting stone {go_guid:#x}: no local player"),
StoneJoin::Refuse(key) => {
format!("meeting stone {go_guid:#x} refused: {key}")
}
StoneJoin::Send => {
format!("SEND CMSG_MEETINGSTONE_JOIN guid={go_guid:#x}")
}
},
);
}
match verdict {
StoneJoin::Silent => {}
StoneJoin::Refuse(key) => lines.extend(crate::ui_action::keyed_line(&script, key)),
StoneJoin::Send => {
debug!("meeting stone: join {go_guid:#x}");
let _ = commands.0.send(ClientCommand::MeetingStoneJoin { go_guid });
}
}
}
if !lines.is_empty() {
crate::ui_action::show_messages(&mut script, &mut sink, "ui_dialog_verbs", lines);
}
}
/// The leave-world sweep's leg: the text dropped, the area kept.
fn meeting_stone_leave_world(mut stone: ResMut<MeetingStone>) {
stone.leave_world();
}
pub(crate) fn feed_dialog_verbs(
script: Option<NonSendMut<UiScript>>,
mut pet: ResMut<PetUnlearnState>,
mut boot: ResMut<InstanceBoot>,
mut spirit: ResMut<AreaSpiritHealer>,
mut queue: ResMut<BattlefieldQueue>,
mut sink: crate::ui_action::MessageSink,
mut tutorials: Option<MessageWriter<crate::tutorial::TutorialEvent>>,
) {
let Some(mut script) = script else {
return;
};
let now = Instant::now();
script.set_pet_untrainer_pending(pet.pending().is_some());
if pet.ask {
pet.ask = false;
script.fire_event(
"CONFIRM_PET_UNLEARN",
vec![ScriptValue::Int(i64::from(pet.cost))],
);
}
script.set_instance_boot_secs(boot.secs(now));
for event in std::mem::take(&mut boot.events) {
script.fire_event(event, vec![]);
}
let lines: Vec<_> = std::mem::take(&mut boot.errors)
.into_iter()
.filter_map(|key| crate::ui_action::keyed_line(&script, key))
.collect();
if !lines.is_empty() {
crate::ui_action::show_messages(&mut script, &mut sink, "ui_dialog_verbs", lines);
}
let secs = spirit.secs(now);
script.set_area_spirit_healer(spirit.healer().is_some(), secs);
if std::mem::take(&mut spirit.in_range) {
script.fire_event("AREA_SPIRIT_HEALER_IN_RANGE", vec![]);
}
for id in std::mem::take(&mut queue.tutorials) {
if let Some(t) = tutorials.as_mut() {
t.write(crate::tutorial::TutorialEvent::trigger(id));
}
}
if std::mem::take(&mut queue.changed) {
script.fire_event("UPDATE_BATTLEFIELD_STATUS", vec![]);
}
}
/// The pet trainer's confirm and the spirit healer's accept — the two drains over a latch.
fn drain_latch_verbs(
script: Option<NonSendMut<UiScript>>,
pet: Res<PetUnlearnState>,
spirit: Res<AreaSpiritHealer>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
commands: Res<NetCommands>,
mut sink: crate::ui_action::MessageSink,
) {
let Some(mut script) = script else {
return;
};
// ConfirmPetUnlearn: the latch, then the money gate — `cost > coinage` shows
// ERR_NOT_ENOUGH_MONEY and sends nothing; otherwise `0x2F0` with the latched guid.
let confirms = script.take_pet_unlearn_confirms();
if confirms > 0 {
if let Some(npc) = pet.pending() {
let money = self_q
.single()
.ok()
.and_then(|store| store.0.player_money())
.unwrap_or(0);
if pet.cost > money {
if let Some(line) = crate::ui_action::keyed_line(&script, "ERR_NOT_ENOUGH_MONEY") {
crate::ui_action::show_messages(
&mut script,
&mut sink,
"ui_dialog_verbs",
[line],
);
}
} else {
for _ in 0..confirms {
let _ = commands.0.send(ClientCommand::PetUnlearn { trainer: npc });
}
}
}
}
// AcceptAreaSpiritHeal: the cached healer's guid (the binding was silent without one).
let accepts = script.take_area_spirit_accepts();
if let Some(healer) = spirit.healer() {
for _ in 0..accepts {
let _ = commands
.0
.send(ClientCommand::AreaSpiritHealerQueue { healer });
}
}
}
/// The battleground port and the meeting-stone leave — the two drains over a queue.
fn drain_queue_verbs(
script: Option<NonSendMut<UiScript>>,
queue: Res<BattlefieldQueue>,
group: Option<Res<GroupState>>,
self_guid: Res<SelfGuid>,
commands: Res<NetCommands>,
mut sink: crate::ui_action::MessageSink,
) {
let Some(mut script) = script else {
return;
};
// AcceptBattlefieldPort: the slot's map id and the one-byte answer.
for (index, accept) in script.take_battlefield_port_requests() {
if let Some(map_id) = queue.map_id(index) {
let _ = commands
.0
.send(ClientCommand::BattlefieldPort { map_id, accept });
}
}
// CancelMeetingStoneRequest: in a party and not its leader → ERR_MEETING_STONE_NOT_LEADER;
// otherwise `0x293`, whatever is or is not queued.
let cancels = script.take_meeting_stone_cancels();
if cancels > 0 {
let not_leader = group
.as_deref()
.is_some_and(|g| g.in_group && Some(g.leader) != self_guid.0);
if not_leader {
if let Some(line) =
crate::ui_action::keyed_line(&script, "ERR_MEETING_STONE_NOT_LEADER")
{
crate::ui_action::show_messages(&mut script, &mut sink, "ui_dialog_verbs", [line]);
}
} else {
for _ in 0..cancels {
let _ = commands.0.send(ClientCommand::MeetingStoneLeave);
}
}
}
}
/// **The per-frame area-spirit-healer poll — `0x4923b0`**, which the reference runs from
/// `CGWorldFrame`'s own `OnUpdate` (`0x4818ca`) on **every frame**, mouse focus or not.
///
/// ```text
/// player = the active player object; none -> set_healer(None); return
/// not a GHOST ([[player+0xe68]+8] bit 4) -> set_healer(None); return
/// if cached:
/// the cached guid no longer resolves to a unit -> set_healer(None)
/// else d²(player, healer) > 22² -> set_healer(None)
/// if still cached: return // 0x492487
/// enumerate every object: the first (last, really — // 0x492495
/// the callback never stops the walk) unit that is
/// SPIRITGUIDE, CanAssist, and within 20 yd -> set_healer(it)
/// ```
///
/// **Ghost-gated at the top**, so a living player never holds a healer and never sends a query —
/// the graveyard's wave clock only exists for the dead. That gate is why this costs nothing in
/// ordinary play: one flag read per frame and out.
///
/// The callback `0x4924c0` returns 1 unconditionally, so the enumeration is **not** stopped by a
/// match — the last qualifying unit in enumeration order is the one that sticks. `set_healer`'s
/// own early return makes that harmless for a stable set (only a genuine change sends), and
/// reproducing "last wins" rather than "nearest wins" matters at a graveyard with two guides in
/// range: the reference does not pick the closer one, and neither does this.
fn poll_area_spirit_healer(
mut spirit: ResMut<AreaSpiritHealer>,
me: Query<(&ObjectStore, &Transform), With<SelfPlayer>>,
units: Query<
(
&crate::net::Guid,
&crate::net::NetEntity,
&ObjectStore,
&Transform,
),
Without<SelfPlayer>,
>,
factions: Option<Res<crate::target::Factions>>,
reputations: Res<crate::net::Reputations>,
index: Option<Res<crate::net::GuidIndex>>,
stores: Query<&ObjectStore>,
commands: Res<NetCommands>,
mut script: Option<NonSendMut<UiScript>>,
) {
let mut apply = |outcome: SetHealerOutcome| {
if outcome.cancel_aura {
// `0x6e7040(0xA18)` does both halves, and both are the reference's: the event with no
// argument, and the packet with no guid.
if let Some(script) = script.as_deref_mut() {
script.fire_event("AREA_SPIRIT_HEALER_OUT_OF_RANGE", vec![]);
}
let _ = commands.0.send(ClientCommand::CancelAura {
spell_id: AREA_SPIRIT_HEALER_AURA,
});
}
if let Some(healer) = outcome.query {
let _ = commands
.0
.send(ClientCommand::AreaSpiritHealerQuery { healer });
}
};
let Ok((self_store, self_tf)) = me.single() else {
apply(spirit.set_healer(None));
return;
};
if !self_store.0.player_is_ghost() {
apply(spirit.set_healer(None));
return;
}
let here = self_tf.translation;
// The retain leg. A cached guid that no longer streams to us is dropped exactly as one that
// walked out of range is — the reference's `ObjectPtr` miss falls into the same clear.
if let Some(cached) = spirit.healer() {
let still = units.iter().find(|(guid, ..)| guid.0 == cached);
let keep = still.is_some_and(|(_, _, _, tf)| {
tf.translation.distance_squared(here) <= SPIRIT_GUIDE_RETAIN_YD * SPIRIT_GUIDE_RETAIN_YD
});
if !keep {
apply(spirit.set_healer(None));
}
}
if spirit.healer().is_some() {
return;
}
// The acquire walk. `can_assist` is `0x6066f0`, the same predicate the target scanner and the
// buff gate already run — its owner chase wants a store by guid, which is what the index is
// for.
let store_of = |guid: u64| -> Option<ObjectStore> {
let entity = *index.as_ref()?.0.get(&guid)?;
stores.get(entity).ok().cloned()
};
let mut adopted = None;
for (guid, kind, store, tf) in units.iter() {
if kind.kind != benilla_protocol::EntityKind::Unit {
continue;
}
if store.0.unit_npc_flags() & NPC_FLAG_SPIRITGUIDE == 0 {
continue;
}
if tf.translation.distance_squared(here) > SPIRIT_GUIDE_ACQUIRE_YD * SPIRIT_GUIDE_ACQUIRE_YD
{
continue;
}
if !crate::target::can_assist(
Some(store),
factions.as_deref(),
&reputations,
Some(self_store),
store_of,
) {
continue;
}
adopted = Some(guid.0); // last wins — the callback never stops the walk
}
if let Some(guid) = adopted {
apply(spirit.set_healer(Some(guid)));
}
}
pub(crate) struct UiDialogVerbsPlugin;
impl Plugin for UiDialogVerbsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<PetUnlearnState>()
.init_resource::<InstanceBoot>()
.init_resource::<AreaSpiritHealer>()
.init_resource::<BattlefieldQueue>()
.init_resource::<MeetingStone>()
.add_message::<MeetingStoneUse>()
.add_systems(
Update,
(
close_npc_session_out_of_range::<PetUnlearnState>.before(feed_dialog_verbs),
// Gated on the interface being up (decision 2279): `InstanceBoot::events`
// is server-driven — vmangos sends `SMSG_RAID_GROUP_ONLY` from
// `Player::UpdateHomebindTime` on the first map tick after a login inside a
// raid instance with no raid group, i.e. inside the login burst's own
// drain — and `INSTANCE_BOOT_START`, the event that raises the stock
// countdown popup, would be fired at the boot VM in 2214's one-frame window
// and lost. Gated, the queue waits; nothing here clears without a VM.
feed_dialog_verbs
.before(UiInput)
.run_if(crate::ui_script::ingame_ui_up),
// **After the frame's pick, which is the reference's own order.** The poll
// is `0x4923b0`, called from `CGWorldFrame`'s OnUpdate at `0x4818ca` — and
// that same function ran the mouse pick eighty bytes earlier, at `0x48184a`.
// So a right-click that adopts a spirit guide is decided BEFORE the poll gets
// to look, not after; ordering it `.after(TargetUpdate)` (the set
// `act_on_right_click` chains inside) reproduces that, and settles the one
// real write-write pair this system has — both it and the click's
// `ServiceArms` hold `AreaSpiritHealer`.
//
// The cost is that an adopt reaches `feed_dialog_verbs` on the NEXT frame
// rather than this one, because `UiInput` sits before `WorldStage::Input` and
// `TargetUpdate` after it — the two orders cannot both hold. That is the
// right way round to lose: the event this system fires itself
// (`_OUT_OF_RANGE`) is immediate, and what lags is one frame of a clock the
// server ticks in seconds.
//
// **Gated on the interface being up** like its neighbour, and safely so: this
// poll is LEVEL-triggered, not edge-triggered. It re-derives the whole cache
// from the ghost flag, the streamed guides and the distances every frame, so a
// frame it does not run is a frame it simply has not got to yet — the very
// next one adopts and queries. That is what makes it exempt in substance from
// the one-shot loss 2214's window causes (2220/2232): there is no edge here to
// lose. The `_OUT_OF_RANGE` it can fire is the one thing that needs the VM,
// and it can only follow an adopt this same system made.
poll_area_spirit_healer
.after(crate::target::TargetUpdate)
.run_if(crate::ui_script::ingame_ui_up)
.in_set(crate::char_select::InWorldGated),
// In-world only: the claim is about the VM, but the query is a world
// packet, and the boot VM exists at the glue screen too.
meeting_stone_enter_world
.before(feed_meeting_stone)
.in_set(crate::char_select::InWorldGated),
feed_meeting_stone.before(UiInput),
drain_latch_verbs.after(UiInput),
drain_queue_verbs.after(UiInput),
// MEETINGSTONE(23)'s use slot (2283): the click resolved the object, this
// runs the validator and sends. Ordered after the **target chain**, not just
// after the input pass like its neighbours — `UiInput` sits *before*
// `WorldStage::Input` and `TargetUpdate` after it, so "after UiInput" alone
// says nothing about the writer and would let the join drift a frame. A
// message survives that (two-frame lifetime) and 16 ms would not be visible,
// but a click's own packet should not leave on an undefined frame.
drain_meeting_stone_joins
.after(UiInput)
.after(crate::target::TargetUpdate),
),
)
.add_systems(
OnExit(crate::char_select::ClientState::InWorld),
meeting_stone_leave_world,
);
}
}
/// The area spirit healer's aura as the test below names it — the same 2584 as
/// [`AREA_SPIRIT_HEALER_AURA`], kept under its own name because the test is about
/// `CancelAreaSpiritHeal`'s spell and the constant above is about `0x4921c0`'s.
#[cfg(test)]
const AREA_SPIRIT_HEALER_SPELL: u32 = AREA_SPIRIT_HEALER_AURA;
/// The generic cancel-aura routine's refusal (`0x6e7040`, wow-re `staticpopup-dialog-bindings.md`
/// §6): it returns without sending when the spell's `AttributesEx` has bit 13 set and bit 2
/// clear **and** `0x5ee290(player)` holds. Whether the third leg ever matters for spell 2584 is
/// decided by the first two, read off the shipped Spell.dbc in [`tests::spell_2584_never_trips_the_cancel_gate`].
#[cfg(test)]
fn cancel_gate_could_apply(attributes_ex: u32) -> bool {
attributes_ex & 0x2000 != 0 && attributes_ex & 0x4 == 0
}
#[cfg(test)]
mod tests {
use super::*;
/// `UNIT_FIELD_LEVEL`, absolute field 34.
const LEVEL_FIELD: u16 = 34;
/// **`0x4921c0`'s early return guards everything.** The first two compares
/// (`0x4921cf cmp ecx,eax` / `0x4921dd cmp edx,ecx`) jump straight to the epilogue at
/// `0x492282` when the guid is unchanged — so a repeat call sends no packet, cancels no aura
/// and does **not** clear the pending deadline. That last one is the part a paraphrase loses,
/// and it is what lets the per-frame poll call this routine unconditionally.
#[test]
fn setting_the_same_healer_is_a_complete_no_op() {
let mut spirit = AreaSpiritHealer::default();
assert_eq!(
spirit.set_healer(Some(7)),
SetHealerOutcome {
cancel_aura: false,
query: Some(7)
},
"a fresh adopt asks for the clock"
);
let now = Instant::now();
spirit.on_time(7, 30_000, now);
assert_eq!(spirit.secs(now), 30, "the wave clock is armed");
assert_eq!(
spirit.set_healer(Some(7)),
SetHealerOutcome::default(),
"the same guid again: no query, no cancel"
);
assert_eq!(
spirit.secs(now),
30,
"and the deadline SURVIVES — 0x492211's clear sits past the early return"
);
}
/// The change legs, in the order the routine runs them: a pending deadline is cancelled
/// (`0x4921fc`, which is how walking out of range fires `_OUT_OF_RANGE` with no Lua call),
/// the deadline is zeroed unconditionally (`0x492211`), and only a **non-zero** new guid
/// sends (`0x492217`'s `je`).
#[test]
fn dropping_a_healer_cancels_the_wave_and_sends_nothing() {
let mut spirit = AreaSpiritHealer::default();
spirit.set_healer(Some(7));
let now = Instant::now();
spirit.on_time(7, 30_000, now);
assert_eq!(
spirit.set_healer(None),
SetHealerOutcome {
cancel_aura: true,
query: None
},
"walking away: the aura cancel, and no query for a zero guid"
);
assert_eq!(spirit.secs(now), 0, "the clock is zeroed");
assert_eq!(spirit.healer(), None);
// And a drop with no clock armed cancels nothing.
spirit.set_healer(Some(9));
assert_eq!(
spirit.set_healer(None),
SetHealerOutcome {
cancel_aura: false,
query: None
},
"no deadline pending — 0x4921fa's `je` skips the cancel"
);
}
/// `SMSG_AREA_SPIRIT_HEALER_TIME` is addressed: a clock for a healer that is **not** the
/// cached one is dropped (`0x4922a0 cmp eax,[ebp+8]`), which is what keeps a stale reply from
/// a graveyard you already left out of the dialog.
#[test]
fn a_clock_for_another_healer_is_ignored() {
let mut spirit = AreaSpiritHealer::default();
spirit.set_healer(Some(7));
let now = Instant::now();
spirit.on_time(8, 30_000, now);
assert_eq!(spirit.secs(now), 0, "not our healer");
spirit.on_time(7, 0, now);
assert_eq!(spirit.secs(now), 0, "a zero time arms nothing");
spirit.on_time(7, 25_000, now);
assert_eq!(spirit.secs(now), 25);
}
/// **The click's cache-bust.** `0x5df950` calls the setter twice — `(0,0)` then the guid —
/// precisely so the second call is never swallowed by the unchanged-guid early return. So
/// clicking the guide you are already standing next to DOES re-ask for the clock, which is
/// the behaviour a player relies on after dismissing the dialog.
#[test]
fn clicking_the_cached_guide_still_asks_again() {
let mut spirit = AreaSpiritHealer::default();
spirit.set_healer(Some(7));
let now = Instant::now();
spirit.on_time(7, 30_000, now);
let outcome = spirit.click_guide(7);
assert_eq!(
outcome.query,
Some(7),
"the bust makes the second call a real change"
);
assert!(
outcome.cancel_aura,
"and the bust's own leg cancelled the pending wave"
);
assert_eq!(spirit.healer(), Some(7));
}
/// The two radii are the reference's `.rdata` f32s, not round numbers someone picked:
/// `[0x8044d0] = 20.0` to acquire and `× [0x804580] = 1.1` to retain. The hysteresis is the
/// point — equal radii would re-send a query every frame a body jittered across the boundary.
#[test]
fn the_acquire_and_retain_radii_are_the_reference_pair() {
assert!((SPIRIT_GUIDE_ACQUIRE_YD - 20.0).abs() < f32::EPSILON);
assert!((SPIRIT_GUIDE_RETAIN_YD - 22.0).abs() < 1e-5);
const { assert!(SPIRIT_GUIDE_RETAIN_YD > SPIRIT_GUIDE_ACQUIRE_YD) };
}
/// The four client-side refusals of MEETINGSTONE(23)'s use slot (`0x5f69d0`, decision 2283),
/// in the reference's own order — and the pass that reaches `CMSG 0x292`.
#[test]
fn the_meeting_stone_join_refuses_in_the_references_order() {
use crate::go_templates::MeetingStoneTemplate;
const ME: u64 = 0x5e1f;
const MATE: u64 = 0xa11e;
// A stone anybody 15-60 may use — the shape most of 1.12's dungeon stones carry.
let stone = Some(MeetingStoneTemplate {
min_level: 15,
max_level: 60,
area: 1519,
});
let party = |leader: u64, others: usize, group_type: u8| GroupState {
in_group: true,
group_type,
leader,
members: (0..others)
.map(|i| benilla_protocol::messages::GroupMemberEntry {
name: format!("Mate{i}"),
guid: 0xb000 + i as u64,
status: 1,
flags: 0,
})
.collect(),
..GroupState::default()
};
// Solo, in range of the level band: nothing refuses.
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(40), stone),
StoneJoin::Send
);
// Leading a party of FOUR (three others): still fine — a full party is five, and the
// stone's job is to find the fifth. This is the assertion the `+ 1` bug failed.
assert_eq!(
meeting_stone_join_refusal(Some(&party(ME, 3, 0)), Some(ME), Some(40), stone),
StoneJoin::Send
);
// 1 — in a party someone else leads.
assert_eq!(
meeting_stone_join_refusal(Some(&party(MATE, 1, 0)), Some(ME), Some(40), stone),
StoneJoin::Refuse("ERR_MEETING_STONE_MUST_BE_LEADER")
);
// 2 — leading a FULL party: four others, five including us, which is what
// `Group::IsFull()` refuses server-side too.
assert_eq!(
meeting_stone_join_refusal(Some(&party(ME, 4, 0)), Some(ME), Some(40), stone),
StoneJoin::Refuse("ERR_MEETING_STONE_GROUP_FULL")
);
// 3 — the level band, both ends, inclusive.
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(14), stone),
StoneJoin::Refuse("ERR_MEETING_STONE_INVALID_LEVEL")
);
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(61), stone),
StoneJoin::Refuse("ERR_MEETING_STONE_INVALID_LEVEL")
);
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(15), stone),
StoneJoin::Send
);
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(60), stone),
StoneJoin::Send
);
// 4 — a raid. Its leader is refused too, which is what puts it BELOW the leader term.
assert_eq!(
meeting_stone_join_refusal(Some(&party(ME, 1, 1)), Some(ME), Some(40), stone),
StoneJoin::Refuse("ERR_MEETING_STONE_NO_RAID_GROUP")
);
// The order is observable where two terms hold at once: a raid we do not lead answers
// MUST_BE_LEADER, not NO_RAID_GROUP.
assert_eq!(
meeting_stone_join_refusal(Some(&party(MATE, 4, 1)), Some(ME), Some(40), stone),
StoneJoin::Refuse("ERR_MEETING_STONE_MUST_BE_LEADER")
);
// **An unanswered template refuses**, because `0x5f8150` reads `0/0` off an uncached
// object and every level >= 1 is outside `0..=0`. The permissive "skip the term while the
// query is in flight" default the highlight column takes is not this slot's — the first
// cut of this code had it, and the §5 round is what corrected it.
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(5), None),
StoneJoin::Refuse("ERR_MEETING_STONE_INVALID_LEVEL")
);
// The same arithmetic on a shipped `0/0` band; `0/60` is the open one.
let closed = Some(MeetingStoneTemplate {
min_level: 0,
max_level: 0,
area: 1519,
});
let open = Some(MeetingStoneTemplate {
min_level: 0,
max_level: 60,
area: 1519,
});
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(1), closed),
StoneJoin::Refuse("ERR_MEETING_STONE_INVALID_LEVEL")
);
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), Some(60), open),
StoneJoin::Send
);
// `0x5f69f8` — no active player: SILENT, and not one of the four refusals. Neither a
// missing guid nor a level the store has not streamed reaches a message or a packet.
assert_eq!(
meeting_stone_join_refusal(None, Some(ME), None, stone),
StoneJoin::Silent
);
assert_eq!(
meeting_stone_join_refusal(None, None, Some(40), stone),
StoneJoin::Silent
);
}
/// The join drain end to end: a clicked stone the player may use puts `CMSG 0x292` on the
/// wire with that object's guid, and a refused one puts **nothing** there.
#[test]
fn the_join_drain_sends_the_clicked_stone_and_refuses_silently_on_the_wire() {
use crate::go_templates::GameObjectTemplates;
// A real GameObject guid: `counter | (entry << 24) | (HIGH_GAMEOBJECT << 48)` — the
// template cache is keyed by the entry the guid carries, so a made-up number would
// silently miss and skip the level term.
const STONE_ENTRY: u32 = 0x5701;
const STONE: u64 = 0xF110 << 48 | (STONE_ENTRY as u64) << 24 | 0x22;
const ME: u64 = 0x5e1f;
const MATE: u64 = 0xa11e;
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
let mut templates = GameObjectTemplates::default();
let mut data = [0i32; 24];
(data[0], data[1], data[2]) = (15, 60, 1519);
assert_eq!(benilla_protocol::guid::entry(STONE), Some(STONE_ENTRY));
templates.insert(STONE_ENTRY, 23, "Stonard Meeting Stone".into(), &data);
app.insert_resource(templates)
.insert_resource(NetCommands(tx))
.insert_resource(SelfGuid(Some(ME)))
.init_resource::<GroupState>()
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.add_message::<MeetingStoneUse>()
.add_systems(Update, drain_meeting_stone_joins);
app.insert_non_send_resource(UiScript::new().expect("VM"));
app.world_mut().spawn((
SelfPlayer,
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
LEVEL_FIELD,
40,
)])),
));
let joins = |rx: &crossbeam_channel::Receiver<ClientCommand>| {
rx.try_iter()
.filter_map(|c| match c {
ClientCommand::MeetingStoneJoin { go_guid } => Some(go_guid),
_ => None,
})
.collect::<Vec<_>>()
};
app.world_mut()
.write_message(MeetingStoneUse { go_guid: STONE });
app.update();
assert_eq!(joins(&rx), vec![STONE], "the clicked stone's own guid");
// Now in a party led by someone else: the same click sends nothing at all.
app.world_mut().resource_mut::<GroupState>().in_group = true;
app.world_mut().resource_mut::<GroupState>().leader = MATE;
app.world_mut()
.write_message(MeetingStoneUse { go_guid: STONE });
app.update();
assert!(
joins(&rx).is_empty(),
"a refusal is a local line, never a packet"
);
}
/// **The `/reload` re-query** — the meeting-stone half of 1290's class, and the reference's
/// own behaviour rather than an invention of ours.
///
/// wow-re's §5 trio (3/3 unanimous, every byte re-decoded from the raw image) settled that
/// the run-once byte `[0xb4b424]` is cleared by the UI teardown at `0x490a8d` and that
/// `UI_Init` re-runs the bring-up and re-sends `CMSG 0x296`. So the gate is once per **VM**,
/// not once per world session — and against the old `MessageReader<EnteredWorldMessage>`
/// shape this fails on the second VM, which is exactly the reported symptom: the queued
/// player's minimap icon never comes back after a `/reload`.
///
/// It also corrects wow-re's own `meeting-stone-status.md` §6, which asserted the query "is
/// sent exactly once per world session … has no other trigger" — true of the entry points it
/// enumerated, but it never asked who *clears* the byte.
///
/// **A registered schedule, not `run_system_once`**: the gate is a `Local<VmMemo<bool>>`, and
/// `run_system_once` builds a fresh system — and so a fresh `Local` — on every call, which
/// would make the claim look unclaimed every frame (the same trap `ui_loot`'s tests name).
#[test]
fn a_rebuilt_vm_re_asks_the_server_for_the_meeting_stone_queue() {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<MeetingStone>()
.insert_resource(NetCommands(tx))
.add_systems(Update, meeting_stone_enter_world);
let queries = |rx: &crossbeam_channel::Receiver<ClientCommand>| {
rx.try_iter()
.filter(|c| matches!(c, ClientCommand::MeetingStoneStatusQuery))
.count()
};
// No VM yet — the glue screen: nothing is asked.
app.update();
assert_eq!(queries(&rx), 0, "no VM, no bring-up");
// The first VM: the bring-up runs once, however many frames pass.
app.insert_non_send_resource(UiScript::new().expect("VM"));
app.update();
assert_eq!(queries(&rx), 1, "the first VM asks the server");
app.update();
app.update();
assert_eq!(queries(&rx), 0, "…and does not ask again on later frames");
// The server answers: the player IS queued, and the host holds that.
app.world_mut().resource_mut::<MeetingStone>().area = 1519;
app.world_mut().resource_mut::<MeetingStone>().dirty = false;
// `ReloadUI()`: a fresh VM, and nothing on the wire.
app.insert_non_send_resource(UiScript::new().expect("VM"));
app.update();
assert_eq!(
queries(&rx),
1,
"a rebuilt VM re-asks — the reference's UI teardown clears the run-once byte, so \
`UI_Init` re-sends `CMSG 0x296` (0x490a8d / 0x490168)"
);
let stone = app.world().resource::<MeetingStone>();
assert!(
stone.dirty,
"the bring-up re-armed the push, so the fresh VM is told the queued area again"
);
assert_eq!(
stone.area, 1519,
"the queued area itself is untouched, matching `[0xb72038]` surviving the reload"
);
}
/// Spell 2584's flags off the shipped Spell.dbc: the cancel-aura gate's first two legs.
#[test]
fn spell_2584_never_trips_the_cancel_gate() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc");
let ex = catalog
.get(AREA_SPIRIT_HEALER_SPELL)
.map(|d| d.attributes_ex)
.expect("spell 2584 in Spell.dbc");
assert!(
!cancel_gate_could_apply(ex),
"spell 2584 AttributesEx = {ex:#x}: the gate's third leg would decide, and it is uncarved"
);
}
#[test]
fn the_boot_clock_arms_on_a_delay_and_clears_on_zero_with_the_reason() {
let mut boot = InstanceBoot::default();
let now = Instant::now();
boot.apply(60_500, 0, now);
assert_eq!(boot.events, vec!["INSTANCE_BOOT_START"]);
assert_eq!(boot.secs(now), 60, "whole seconds, truncated");
boot.apply(0, 1, now);
assert_eq!(
boot.events,
vec!["INSTANCE_BOOT_START", "INSTANCE_BOOT_STOP"]
);
assert_eq!(boot.errors, vec!["ERR_RAID_GROUP_ONLY"]);
assert_eq!(boot.secs(now), 0);
boot.apply(0, 7, now);
assert_eq!(boot.errors.len(), 1, "a reason outside 1/2 names nothing");
}
#[test]
fn the_spirit_healer_clock_only_arms_for_the_cached_healer() {
let mut spirit = AreaSpiritHealer::default();
let now = Instant::now();
spirit.on_time(0x77, 30_000, now);
assert!(!spirit.in_range, "no healer cached: the packet is ignored");
spirit.healer = Some(0x77);
spirit.on_time(0x78, 30_000, now);
assert!(!spirit.in_range, "another guid: ignored");
spirit.on_time(0x77, 0, now);
assert!(!spirit.in_range, "a zero time: ignored");
spirit.on_time(0x77, 30_000, now);
assert!(spirit.in_range);
assert_eq!(spirit.secs(now), 30);
}
#[test]
fn the_queue_keeps_three_slots_and_answers_a_port_by_map() {
let mut q = BattlefieldQueue::default();
let status = |slot, map_id| BattlefieldStatus {
slot,
map_id,
bracket: 0,
instance_id: 0,
status: 2,
time_ms: Some(0),
in_progress: None,
queued: None,
};
q.apply(status(1, 489));
q.apply(status(5, 30));
assert_eq!(
q.map_id(2),
Some(489),
"1-based from Lua, 0-based on the wire"
);
assert_eq!(q.map_id(1), None);
assert_eq!(q.map_id(4), None);
q.apply(status(1, 0));
assert_eq!(q.map_id(2), None, "a zero map clears the slot");
}
/// The instance clocks (§4.2): stamped by status 3, zeroed by any other status of ANY slot,
/// and by a clear of the active slot only — which leaves the active index alone.
#[test]
fn the_instance_clocks_follow_the_status_handler() {
let mut q = BattlefieldQueue::default();
let now = Instant::now();
let mut active = BattlefieldStatus {
slot: 0,
map_id: 489,
bracket: 0,
instance_id: 3,
status: 3,
time_ms: None,
in_progress: Some((90_000, 30_000)),
queued: None,
};
q.apply_at(active.clone(), now);
assert_eq!(q.active_map(), Some(489));
assert_eq!(q.instance_expiration_ms(now), 90_000);
assert_eq!(q.run_time_ms(now), 30_000);
assert!(q.take_score_dirty());
// A QUEUED update for slot 2 zeroes both clocks — the handler's unconditional arm.
let mut queued = active.clone();
queued.slot = 1;
queued.map_id = 529;
queued.status = 1;
queued.in_progress = None;
queued.queued = Some((60_000, 5_000));
q.apply_at(queued, now);
assert_eq!(
q.active_map(),
Some(489),
"another slot's status leaves the active index"
);
assert_eq!(q.instance_expiration_ms(now), 0);
assert_eq!(q.run_time_ms(now), 0);
assert_eq!(q.map_id(2), Some(529));
// Re-arm, then clear the ACTIVE slot: the clocks go, the index stays (`[0x8457cc]`).
q.apply_at(active.clone(), now);
active.map_id = 0;
q.apply_at(active, now);
assert_eq!(q.instance_expiration_ms(now), 0);
assert_eq!(q.map_id(1), None);
assert_eq!(
q.active_map(),
Some(489),
"the clear arm never resets the active index"
);
}
/// `0x295`'s store is unconditional and the old id is latched first; the feed reads both.
#[test]
fn the_stone_latches_the_old_area_before_storing_the_new() {
let mut stone = MeetingStone::default();
stone.apply(1519, 1);
stone.apply(1519, 1);
stone.apply(0, 0);
stone.apply(12, 9);
assert_eq!(stone.area, 12);
assert_eq!(stone.updates, vec![(0, 1), (1519, 1), (1519, 0), (0, 9)]);
stone.enter_world();
assert_eq!(stone.text, StoneText::Unknown);
assert_eq!(
stone.area, 12,
"the enter-world reset leaves the area alone"
);
stone.leave_world();
assert_eq!(stone.text, StoneText::None);
}
/// The five-way table with its two asymmetries, and the rebuild's fallbacks and buffer.
#[test]
fn the_status_table_and_the_rebuild_follow_the_handler() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let areas =
AreaTableRes(benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable"));
let s = UiScript::new().unwrap();
s.run(
r#"MEETINGSTONE_TOOLTIP = "Looking for more for %s" UNKNOWN = "Unknown"
ERR_MEETING_STONE_LEFT_QUEUE_S = "You are no longer queued for %s."
ERR_MEETING_STONE_IN_QUEUE_S = "You are now in the queue to join a party for %s."
ERR_MEETING_STONE_OTHER_MEMBER_LEFT = "left"
ERR_MEETING_STONE_PARTY_KICKED_FROM_QUEUE = "kicked"
ERR_MEETING_STONE_MEMBER_STILL_IN_QUEUE = "still""#,
)
.unwrap();
let a = Some(&areas);
let text = |l: Option<crate::ui_action::Shown>| l.map(|l| l.text().to_string());
// Status 0 names the OLD area — and is silent when it has no row.
assert_eq!(
text(stone_line(&s, a, 1519, 0, 0)).as_deref(),
Some("You are no longer queued for Stormwind City.")
);
assert_eq!(
text(stone_line(&s, a, 0, 0, 0)),
None,
"no row for area 0: silent"
);
assert_eq!(text(stone_line(&s, a, 999_999, 0, 0)), None);
// Status 1 names the NEW area, falls back to UNKNOWN, and is skipped when unchanged.
assert_eq!(
text(stone_line(&s, a, 0, 1519, 1)).as_deref(),
Some("You are now in the queue to join a party for Stormwind City.")
);
assert_eq!(
text(stone_line(&s, a, 0, 999_999, 1)).as_deref(),
Some("You are now in the queue to join a party for Unknown.")
);
assert_eq!(
text(stone_line(&s, a, 1519, 1519, 1)),
None,
"unchanged: skipped"
);
assert_eq!(text(stone_line(&s, a, 0, 0, 2)).as_deref(), Some("left"));
assert_eq!(text(stone_line(&s, a, 0, 0, 3)).as_deref(), Some("kicked"));
assert_eq!(text(stone_line(&s, a, 0, 0, 4)).as_deref(), Some("still"));
assert_eq!(
text(stone_line(&s, a, 0, 0, 5)),
None,
"past the table: nothing"
);
// The rebuild.
assert_eq!(
build_stone_text(&s, a, 1519),
"Looking for more for Stormwind City"
);
assert_eq!(build_stone_text(&s, a, 0), "Looking for more for Unknown");
s.run("MEETINGSTONE_TOOLTIP = string.rep('x', 300) .. '%s'")
.unwrap();
assert_eq!(
build_stone_text(&s, a, 1519).len(),
STONE_TEXT_BYTES,
"the 256-byte buffer"
);
s.run("MEETINGSTONE_TOOLTIP = nil").unwrap();
assert_eq!(
build_stone_text(&s, a, 1519),
"",
"a missing template is GetText's empty string, not the unreachable %s fallback"
);
}
#[test]
fn the_pet_question_latches_and_closes_like_the_talent_wipe() {
let mut pet = PetUnlearnState::default();
pet.ask(0x2b, 10_000);
assert_eq!(pet.pending(), Some(0x2b));
assert!(pet.ask);
pet.close();
assert_eq!(pet.pending(), None);
assert_eq!(pet.cost, 0);
}
}