Files
benilla-octo/crates/benilla-app/src/capture/probe_service.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

916 lines
40 KiB
Rust

//! The **NPC-service ladder** live probe (`WOW_PROBE_SERVICE=1`) — decision 1861's end-to-end
//! instrument, and the answer to "does right-clicking a trainer or a quest giver really open their
//! window instead of a gossip menu?".
//!
//! 1861 replaced a cursor-kind dispatch with the reference's own first-match-wins walk over
//! `UNIT_NPC_FLAGS` ([`crate::target::click::service_arm`]; wow-re
//! `object-layer/scratch/interact-dead-fork-and-npc-service-ladder.md` §C). **Bit 0 (GOSSIP) is
//! tested first**, so the flag — not the profession — decides: a trainer or questgiver that also
//! carries GOSSIP still opens a gossip menu, and only a flagless one opens its own window. That
//! precedence is the whole question, and it is invisible to a unit test: the bits come off the
//! wire, and what appears on screen is the *server's* answer to the opcode the ladder picked.
//!
//! So the probe walks four real Northshire/Stormwind NPCs, one per interesting flag shape, and for
//! each one reports the wire's `UNIT_NPC_FLAGS`, the arm the shipped ladder takes, the opcode it
//! sends, and which window actually opened:
//!
//! | NPC | `npc_flags` | arm | window |
//! |---|---|---|---|
//! | Marshal McBride (197) | `0x03` GOSSIP\|QUESTGIVER | Gossip | gossip menu |
//! | Llane Beshere (911) | `0x13` GOSSIP\|QUESTGIVER\|TRAINER | Gossip | gossip menu |
//! | *any pure questgiver with an offer* | `0x02`, GOSSIP clear | Questgiver | quest frame |
//! | Alma Jainrose (812) | `0x10` TRAINER only | Trainer | trainer frame |
//!
//! The flag column is live-DB verified this session (`vmangos-deploy` → `mangos`,
//! `SELECT entry, name, npc_flags FROM creature_template`), and reported again from the wire on
//! every run — a template edit shows up as a note beside the reading rather than as a mystery FAIL.
//!
//! **The ladder is called, never re-implemented.** The probe reads the same two inputs the click
//! reads (`ObjectStore::unit_npc_flags` and [`QuestGiver::status`]) and calls the shipped
//! `service_arm`/`service_action`; only the mouse hit-test and the cursor's range gray sit outside
//! it. A private copy of the bit table is exactly how a probe goes quietly stale (the B249 icon
//! map), so there isn't one.
//!
//! **The pure-questgiver row is found by predicate, not by entry.** Whether Deputy Willem has
//! anything for *this* probe character depends on what it has already turned in, and the bit-1 arm
//! is gated on that status (`[unit+0xcb8] ∉ {0,1}`). So that leg hops into Northshire and takes the
//! first streamed unit that is QUESTGIVER, not GOSSIP, and actually has an offer — reporting which
//! one it picked. If the character has cleared the whole valley it SKIPs and says so.
//!
//! One `PROBE_SERVICE: <leg> PASS/FAIL/SKIP <detail>` line per leg, then a final
//! `PROBE_SERVICE: DONE pass=<n> fail=<m> skip=<k>`. A wrong arm or a wrong window is a FAIL; an
//! environmental problem (the `.go` refused, the NPC never streamed, no offer left in the valley)
//! is a SKIP with the reason.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_DATA=WoW/Data WOW_USER=probe4 WOW_PASS=pprobe4 WOW_CHAR=Probefour \
//! WOW_UNATTENDED=1 WOW_PROBE_SERVICE=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — method.md "The local vmangos server"). Non-combat, GM mode
//! left as found, nothing bought and nothing turned in: every leg opens a window and closes it
//! client-side, which is what the reference's own close does (`ui_gossip`: there is no
//! `CMSG_GOSSIP_CLOSE` in 1.12).
use bevy::prelude::*;
use benilla_protocol::EntityKind;
use super::probes::ProbeClock;
use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::player::Player;
use crate::target::click::{service_action, service_arm, ServiceAction, ServiceArm};
use crate::target::cursor_mode::npc_flags as f;
use crate::ui_gossip::GossipState;
use benilla_ui::script::UiScript;
use crate::ui_merchant::MerchantOpen;
use crate::ui_quest::QuestGiver;
use crate::ui_session::InteractNpc;
use crate::ui_trainer::TrainerOpen;
/// How wide to look around a `.go` landing for the leg's NPC — **and no wider than the reference's
/// own service reach**, because the click cannot act past it either: beyond
/// [`crate::target::SERVICE_RANGE_SQ`] (5.5556 yd, `0xc4c28c`/`0xb4b32c`) the cursor goes gray, the
/// click sends nothing, and vmangos's `GetNPCIfCanInteractWith` refuses the opcode silently.
///
/// This constant is the reason the questgiver leg first read as a client defect: its hop point was
/// an invented Northshire "hub" ~29 yd from Deputy Willem, so the probe called a ladder the click
/// would never have reached, the server dropped the packet on its own distance check, and the
/// timeout printed a FAIL about the client (method.md §6 — prove the run before reading the
/// result). Every hop point is now an NPC's own spawn, and the scan re-checks the reach.
const SCAN_RANGE_SQ: f32 = crate::target::SERVICE_RANGE_SQ;
/// Let the hop land and the tile stream before the first scan.
const SETTLE_SECS: f64 = 3.0;
/// How long a leg waits for its NPC to stream in before calling the hop environmental.
const SCAN_TIMEOUT_SECS: f64 = 20.0;
/// How long a leg waits for its window after the opcode goes out. Generous for the same reason the
/// binder probe's is: the observer is starved during a terrain load, never the wire.
const WINDOW_TIMEOUT_SECS: f64 = 20.0;
/// How long the re-click gate's latch may lag the window it belongs to.
///
/// It no longer lags by construction: `feed_interact_npc` is seated after the net apply since
/// decision 2022, so the token arms on the very frame the window opens. It USED to be allowed to
/// lag a frame (`ui_session` was "deliberately unordered" against the apply, on decision 1741's
/// reading that a window's first frame is invisible) — and "one frame" and "never" are the same
/// reading if you sample once, which is exactly what the first run of this assert did: it failed
/// the quest and trainer legs, whose windows open on the packet, and passed the gossip legs only
/// because the gossip frame holds shut for several frames waiting on its greeting query (B292).
/// The poll stays for the other reason a single sample lies — a starved observer during a terrain
/// load — and prints how long the latch actually took.
const GATE_TIMEOUT_SECS: f64 = 3.0;
/// **The vendor fork's leg** (decision 1914) — Brother Danil, `creature_template.entry = 152`,
/// `npc_flags = 4` (VENDOR and nothing else, so the ladder cannot reach him by any other arm),
/// spawned in Northshire at his own `creature.position_*` (live-DB verified this session).
const VENDOR_ENTRY: u32 = 152;
const VENDOR_AT: [f32; 3] = [-8901.59, -112.716, 82.0314];
const VENDOR_MAP: u32 = 0;
/// Tough Jerky — a 1-copper vendor trash item, `.additem`'d so the sell leg has something of its
/// own to sell and never touches whatever the probe character was already carrying.
const SELL_ITEM_ENTRY: u32 = 117;
/// How long to wait for `.additem` to land in a bag, and for the sold item to leave it.
const BAG_TIMEOUT_SECS: f64 = 20.0;
/// Which NPC a leg is looking for.
enum Ident {
/// A specific `creature_template.entry` — the identity check that cannot be confused by a
/// neighbour who happens to share a flag.
Entry(u32),
/// The bit-1 arm's own shape: QUESTGIVER set, GOSSIP clear, and an offer live for THIS
/// character (`SMSG_QUESTGIVER_STATUS` ∉ {0,1}) — see the module doc.
PureQuestgiverWithOffer,
}
/// Which window the leg's arm must put on screen.
#[derive(Clone, Copy, PartialEq, Debug)]
enum Window {
Gossip,
Quest,
Trainer,
}
/// One leg: hop here, find this NPC, run the ladder on it, expect this arm and this window.
struct Leg {
/// What the log calls it.
name: &'static str,
ident: Ident,
map: u32,
/// The spawn points to try, in order — each an NPC's own `creature.position_*`, so the landing
/// is inside the service reach. A leg with several is one whose subject depends on what this
/// character has already turned in (the questgiver leg); it moves to the next candidate when
/// the one it hopped to has nothing to offer.
at: &'static [[f32; 3]],
/// `creature_template.npc_flags` as the world DB held it when this probe was written — printed
/// beside the wire's value so a template edit reads as a note rather than a mystery.
db_flags: u32,
want_arm: ServiceArm,
want_window: Window,
}
/// The four flag shapes worth a live reading (module doc's table). Northshire first — three of the
/// four legs share one hop's worth of terrain — then the Stormwind first-aid trainer.
const LEGS: &[Leg] = &[
Leg {
name: "Marshal McBride — GOSSIP|QUESTGIVER",
ident: Ident::Entry(197),
map: 0,
at: &[[-8902.59, -162.606, 82.0223]],
db_flags: 0x3,
want_arm: ServiceArm::Gossip,
want_window: Window::Gossip,
},
Leg {
name: "Llane Beshere — GOSSIP|QUESTGIVER|TRAINER",
ident: Ident::Entry(911),
map: 0,
at: &[[-8918.36, -208.411, 82.3088]],
db_flags: 0x13,
want_arm: ServiceArm::Gossip,
want_window: Window::Gossip,
},
Leg {
name: "a pure questgiver with an offer — QUESTGIVER, no GOSSIP",
ident: Ident::PureQuestgiverWithOffer,
map: 0,
// Deputy Willem (823), Eagan Peltskinner (196), Falkhaan Isenstrider (6774) — the three
// pure questgivers of Northshire, each at its own spawn (live-DB verified this session).
at: &[
[-8933.54, -136.523, 83.4466],
[-8869.22, -163.237, 80.9719],
[-9044.56, -45.9817, 88.4193],
],
db_flags: 0x2,
want_arm: ServiceArm::Questgiver,
want_window: Window::Quest,
},
Leg {
name: "Alma Jainrose — TRAINER, no GOSSIP",
ident: Ident::Entry(812),
map: 0,
at: &[[-9237.78, -2041.65, 78.1678]],
db_flags: 0x10,
want_arm: ServiceArm::Trainer,
want_window: Window::Trainer,
},
];
pub(crate) struct ProbeServicePlugin;
impl Plugin for ProbeServicePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ServiceProbe>()
.add_systems(Update, service_probe);
}
}
#[derive(Resource, Default)]
struct ServiceProbe {
phase: Phase,
passes: u32,
fails: u32,
skips: u32,
/// How many frames the current scan actually got to poll in. A starved observer and a missing
/// NPC look identical in a SKIP line otherwise — and on a loaded machine the first is far more
/// likely: at load 61 this probe logged `frame hitch: ~1010 ms` on repeat (the ~1 fps regime
/// decisions 0713/0777/1355 name) and its 20-second scan window bought about twenty samples.
/// `leg.sh` answers the same problem with a load guard (1157); a probe that does one thing and
/// exits is better served by reporting what it actually got.
polls: u32,
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
exited: bool,
}
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
/// Not in-world yet, or between legs: `.go` for leg `i` still to send.
#[default]
Start,
Hop {
i: usize,
/// Which of the leg's spawn points we hopped to.
hop: usize,
sent_at: f64,
},
/// The arm's action has gone out; waiting for the window it must open.
Await {
i: usize,
since: f64,
guid: u64,
},
/// The window is open; waiting for the re-click gate's `"npc"` token to name its NPC
/// (decision 1905). Its own phase because the latch is allowed to lag the window by a frame —
/// see [`GATE_TIMEOUT_SECS`].
Gate {
i: usize,
since: f64,
guid: u64,
},
/// The vendor fork's own chain (decision 1914) — `.go` to the pure vendor.
VendorHop {
sent_at: f64,
},
/// The EMPTY-cursor leg: `CMSG_LIST_INVENTORY` sent, waiting for the merchant window.
VendorOpen {
since: f64,
guid: u64,
},
/// `.additem` sent, waiting for the jerky to appear in a bag so it can be picked up.
VendorArm {
since: f64,
guid: u64,
},
/// The HELD-cursor leg: `CMSG_SELL_ITEM` sent, waiting for the slot to empty.
VendorSell {
since: f64,
bag: i64,
slot: u32,
},
Done,
}
/// The bag slot holding [`SELL_ITEM_ENTRY`], as `"bag,slot"`, or `""` — asked of the live VM
/// through the same bindings a player's bag UI uses, so the probe never needs its own view of the
/// container fields.
fn find_item_slot(script: &UiScript) -> Option<(i64, u32)> {
let found = script
.eval::<String>(&format!(
"for b = 0, 4 do \
local n = GetContainerNumSlots(b) or 0 \
for s = 1, n do \
local l = GetContainerItemLink(b, s) \
if l and string.find(l, \"Hitem:{SELL_ITEM_ENTRY}\") then return b .. \",\" .. s end \
end \
end return \"\""
))
.unwrap_or_default();
let (b, s) = found.split_once(',')?;
Some((b.parse().ok()?, s.parse().ok()?))
}
/// A flag word as the log wants it — the hex plus the bit names, so a reader never has to decode
/// `0x13` by hand.
fn flag_names(flags: u32) -> String {
const NAMES: [(u32, &str); 14] = [
(f::GOSSIP, "GOSSIP"),
(f::QUESTGIVER, "QUESTGIVER"),
(f::VENDOR, "VENDOR"),
(f::FLIGHTMASTER, "FLIGHTMASTER"),
(f::TRAINER, "TRAINER"),
(f::SPIRITHEALER, "SPIRITHEALER"),
(f::SPIRITGUIDE, "SPIRITGUIDE"),
(f::INNKEEPER, "INNKEEPER"),
(f::BANKER, "BANKER"),
(f::PETITIONER, "PETITIONER"),
(f::TABARDDESIGNER, "TABARDDESIGNER"),
(f::BATTLEMASTER, "BATTLEMASTER"),
(f::AUCTIONEER, "AUCTIONEER"),
(f::STABLEMASTER, "STABLEMASTER"),
];
let set: Vec<&str> = NAMES
.iter()
.filter(|(bit, _)| flags & bit != 0)
.map(|(_, n)| *n)
.collect();
match set.is_empty() {
true => format!("{flags:#x} (none)"),
false => format!("{flags:#x} {}", set.join("|")),
}
}
/// Which window is open on `guid`, if any — the observation every leg is judged on.
fn open_window(
guid: u64,
gossip: &GossipState,
giver: &QuestGiver,
trainer: &TrainerOpen,
) -> Option<Window> {
// The gossip frame does not open on the packet: it holds closed until the greeting's
// `CMSG_NPC_TEXT_QUERY` answers (B292's hold, `ui_gossip`), so the greeting is part of "open".
if gossip.npc == Some(guid) && gossip.greeting.is_some() {
return Some(Window::Gossip);
}
if giver.npc == Some(guid) && giver.view.is_some() {
return Some(Window::Quest);
}
if trainer.trainer == Some(guid) {
return Some(Window::Trainer);
}
None
}
fn service_probe(
time: ProbeClock,
mut probe: ResMut<ServiceProbe>,
mut gossip: ResMut<GossipState>,
mut giver: ResMut<QuestGiver>,
mut trainer: ResMut<TrainerOpen>,
// `[0xb4e2d0]`'s mirror — what the re-click gate reads (decision 1905). The probe asserts it
// live because the gate's risk is never the `==`, it is whether this really is armed by a
// window and only by a window.
interact: Res<InteractNpc>,
// The vendor fork's leg (decision 1914): the window the empty-cursor leg must open, the VM the
// held-cursor leg picks an item up in, and the two the app needs to turn a (bag, slot) into
// the guid the wire addresses.
mut merchant: ResMut<MerchantOpen>,
script: Option<NonSendMut<UiScript>>,
items: Res<crate::items::Items>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
self_player: Query<(), With<SelfPlayer>>,
player: Res<Player>,
units: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without<SelfPlayer>>,
net: Res<NetCommands>,
mut exit: MessageWriter<AppExit>,
) {
if self_player.is_empty() {
return; // not in-world yet
}
let now = time.elapsed_secs_f64();
match probe.phase {
Phase::Start => {
hop(&mut probe, &net, 0, 0, now);
}
Phase::Hop { i, hop: h, sent_at } => {
if now - sent_at < SETTLE_SECS {
return;
}
probe.polls += 1;
let leg = &LEGS[i];
let me = player.pos;
let found = units.iter().find(|(guid, net_e, store, tf)| {
// The click's own gate: `dist² > SERVICE_RANGE_SQ` is the cursor's `unable`, and
// the arm never runs. Acting outside it would measure nothing.
if net_e.kind != EntityKind::Unit
|| tf.translation.distance_squared(me) > SCAN_RANGE_SQ
{
return false;
}
let flags = store.0.unit_npc_flags();
match leg.ident {
Ident::Entry(entry) => store.0.object_entry() == Some(entry),
Ident::PureQuestgiverWithOffer => {
flags & f::QUESTGIVER != 0
&& flags & f::GOSSIP == 0
&& crate::target::cursor_mode::questgiver_has_quest(
giver.status(guid.0),
)
}
}
});
let Some((guid, _, store, _)) = found else {
if now - sent_at > SCAN_TIMEOUT_SECS {
if h + 1 < leg.at.len() {
info!(
"PROBE_SERVICE: ({}) nothing eligible at spawn {}/{} — trying the next",
leg.name,
h + 1,
leg.at.len()
);
hop(&mut probe, &net, i, h + 1, now);
return;
}
let polls = probe.polls;
let fps = f64::from(polls) / (now - sent_at).max(0.001);
warn!(
"PROBE_SERVICE: SKIP ({}) — nothing matching streamed inside the \
{:.4}yd service reach of any of this leg's {} spawn point(s) in \
{SCAN_TIMEOUT_SECS}s each. Environmental. Scanned {polls} frame(s) \
({fps:.1} fps){}",
leg.name,
SCAN_RANGE_SQ.sqrt(),
leg.at.len(),
match fps < 5.0 {
// The reading is about the OBSERVER, not the world: below a handful of
// frames a second this leg never really looked (0713/0777/1355).
true =>
" — THE OBSERVER WAS STARVED, so this SKIP says nothing about \
the NPC. Re-run on an idle machine (`uptime`; leg.sh's guard \
is load < 3).",
false =>
" — the machine was keeping up, so the `.go` was refused, the \
tile never streamed, or this leg's NPC genuinely was not \
there.",
}
);
probe.skips += 1;
next(&mut probe, &net, i, now);
}
return;
};
let guid = guid.0;
let flags = store.0.unit_npc_flags();
let entry = store.0.object_entry().unwrap_or(0);
let status = giver.status(guid);
// The shipped ladder, on the shipped inputs.
let arm = service_arm(flags, status);
let note = match flags == leg.db_flags {
true => String::new(),
false => format!(
" [note: the world DB read {} when this leg was written]",
flag_names(leg.db_flags)
),
};
let Some(arm) = arm else {
error!(
"PROBE_SERVICE: FAIL ({}) — entry {entry} guid {guid:#x} flags {} \
status={status:?}: the ladder matched NO bit, so the click sends nothing. \
Wanted {:?}.{note}",
leg.name,
flag_names(flags),
leg.want_arm
);
probe.fails += 1;
next(&mut probe, &net, i, now);
return;
};
if arm != leg.want_arm {
error!(
"PROBE_SERVICE: FAIL ({}) — entry {entry} guid {guid:#x} flags {} \
status={status:?}: the ladder took {arm:?}, wanted {:?}. First-match-wins \
low→high over UNIT_NPC_FLAGS is the reference's own order \
(`0x5f0289`…`0x5f05bc`); a disagreement here is the dispatch, not the \
server.{note}",
leg.name,
flag_names(flags),
leg.want_arm
);
probe.fails += 1;
next(&mut probe, &net, i, now);
return;
}
// A stale window from the previous leg would answer this one's question. The
// reference's own close sends no packet, so a local clear is the whole of it.
gossip.clear();
giver.clear();
trainer.clear();
// **The first click must always get through** (decision 1905). Nothing is open here —
// this leg has just cleared, and the reference arms `[0xb4e2d0]` from window openers
// only — so the re-click gate must read disarmed at the moment of the send. If it ever
// reads armed here, the gate would be eating first clicks and this probe would still
// pass every other leg, which is why the assert is at the send and not after it.
if interact.1.is_some() {
error!(
"PROBE_SERVICE: FAIL ({}) — the npc token is armed on {:#x} BEFORE the send; \
the re-click gate would eat first clicks{note}",
leg.name,
interact.1.unwrap_or(0)
);
probe.fails += 1;
next(&mut probe, &net, i, now);
return;
}
// `None` = an empty cursor: no leg here holds an item, and the vendor arm's fork is
// the merchant probe's business, not this one's (decision 1914).
let sent = match service_action(arm, guid, false, None) {
ServiceAction::Send(cmd) => {
let named = format!("{cmd:?}");
let _ = net.0.send(cmd);
named
}
// No leg here takes a dialog arm; if one ever does, say so rather than hang.
other => {
let why: &str = match other {
ServiceAction::AskBinder => "CONFIRM_BINDER, no packet",
ServiceAction::AskSpiritHealer => "CONFIRM_XP_LOSS, no packet",
ServiceAction::AcquireSpiritGuide => {
"adopts the area spirit healer; the packet leaves from the cache"
}
ServiceAction::Silent(w) => w,
ServiceAction::Send(_) | ServiceAction::SellFromCursor(_) => {
unreachable!()
}
};
warn!(
"PROBE_SERVICE: SKIP ({}) — arm {arm:?} opens no window from a packet \
({why}); this probe's legs are the four that do.{note}",
leg.name
);
probe.skips += 1;
next(&mut probe, &net, i, now);
return;
}
};
info!(
"PROBE_SERVICE: ({}) entry {entry} guid {guid:#x} flags {} status={status:?} \
→ arm {arm:?} → {sent}{note}",
leg.name,
flag_names(flags)
);
probe.phase = Phase::Await {
i,
since: now,
guid,
};
}
Phase::Await { i, since, guid } => {
let leg = &LEGS[i];
match open_window(guid, &gossip, &giver, &trainer) {
Some(open) if open == leg.want_window => {
info!(
"PROBE_SERVICE: PASS ({}) — {:?} window open on {guid:#x}{}",
leg.name,
open,
match open {
Window::Gossip => format!(
" ({} option(s), {} quest row(s), greeting {:?})",
gossip.options.len(),
gossip.quests.len(),
gossip.greeting.as_deref().unwrap_or("")
),
Window::Quest => format!(" ({:?})", giver.view.as_ref().map(discr)),
Window::Trainer => format!(
" ({} service(s), type {})",
trainer.services.len(),
trainer.trainer_type
),
}
);
probe.passes += 1;
probe.phase = Phase::Gate {
i,
since: now,
guid,
};
}
Some(open) => {
error!(
"PROBE_SERVICE: FAIL ({}) — {open:?} window opened on {guid:#x}, wanted \
{:?}",
leg.name, leg.want_window
);
probe.fails += 1;
next(&mut probe, &net, i, now);
}
None if now - since > WINDOW_TIMEOUT_SECS => {
// Distinguish the one near-miss that is a real defect from a dead wire: the
// gossip session latched but its greeting never resolved, so the frame stayed
// shut (`GossipState::resolve_greeting`'s "Missing gossip text!" path).
let held = gossip.npc == Some(guid) && gossip.greeting.is_none();
error!(
"PROBE_SERVICE: FAIL ({}) — no {:?} window on {guid:#x} within \
{WINDOW_TIMEOUT_SECS}s of the send{}",
leg.name,
leg.want_window,
match held {
true => format!(
" (the gossip session latched on text_id {} but the greeting never \
resolved — the frame never opens on that path)",
gossip.text_id
),
false => String::new(),
}
);
probe.fails += 1;
next(&mut probe, &net, i, now);
}
None => {}
}
}
Phase::Gate { i, since, guid } => {
let leg = &LEGS[i];
if interact.1 == Some(guid) {
info!(
"PROBE_SERVICE: PASS ({}) — re-click gate armed on {guid:#x} {:.0} ms after \
the window: a second right-click sends nothing (`0x5f0251`)",
leg.name,
(now - since) * 1000.0
);
probe.passes += 1;
next(&mut probe, &net, i, now);
} else if now - since > GATE_TIMEOUT_SECS {
error!(
"PROBE_SERVICE: FAIL ({}) — the window has been open on {guid:#x} for \
{GATE_TIMEOUT_SECS}s and the npc token still reads {:?}; the re-click gate \
cannot fire and every re-click re-sends",
leg.name, interact.1
);
probe.fails += 1;
next(&mut probe, &net, i, now);
}
}
// ── The vendor fork (decision 1914) ──────────────────────────────────────────────────
Phase::VendorHop { sent_at } => {
if now - sent_at < SETTLE_SECS {
return;
}
let me = player.pos;
let found = units.iter().find(|(_, net_e, store, tf)| {
net_e.kind == EntityKind::Unit
&& store.0.object_entry() == Some(VENDOR_ENTRY)
&& tf.translation.distance_squared(me) <= SCAN_RANGE_SQ
});
let Some((guid, _, store, _)) = found else {
if now - sent_at > SCAN_TIMEOUT_SECS {
warn!(
"PROBE_SERVICE: SKIP (vendor fork) — entry {VENDOR_ENTRY} never streamed \
inside the service reach. Environmental."
);
probe.skips += 1;
probe.phase = Phase::Done;
}
return;
};
let guid = guid.0;
let flags = store.0.unit_npc_flags();
gossip.clear();
giver.clear();
trainer.clear();
merchant.clear();
// The EMPTY-cursor leg. The arm is asserted, not assumed: a vendor who had grown a
// GOSSIP bit would take the gossip arm and this leg would be measuring nothing.
let arm = service_arm(flags, None);
if arm != Some(ServiceArm::Vendor) {
warn!(
"PROBE_SERVICE: SKIP (vendor fork) — entry {VENDOR_ENTRY} reads {} and takes \
{arm:?}, not the vendor arm. Environmental (a world-DB change).",
flag_names(flags)
);
probe.skips += 1;
probe.phase = Phase::Done;
return;
}
match service_action(ServiceArm::Vendor, guid, false, None) {
ServiceAction::Send(cmd) => {
info!(
"PROBE_SERVICE: (vendor fork, empty cursor) {guid:#x} flags {} → {cmd:?}",
flag_names(flags)
);
let _ = net.0.send(cmd);
probe.phase = Phase::VendorOpen { since: now, guid };
}
_ => {
error!(
"PROBE_SERVICE: FAIL (vendor fork, empty cursor) — an EMPTY cursor took \
the sale leg; `0x5df5e0 je` must fall to CMSG_LIST_INVENTORY"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
}
Phase::VendorOpen { since, guid } => {
if merchant.vendor == Some(guid) {
info!(
"PROBE_SERVICE: PASS (vendor fork, empty cursor) — merchant window open on \
{guid:#x}: the zero leg is still the plain list open"
);
probe.passes += 1;
// The window would arm the re-click gate and eat the next click, so close it the
// way its own close button does (no packet) before the held-cursor leg.
merchant.clear();
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".additem {SELL_ITEM_ENTRY} 1"),
});
probe.phase = Phase::VendorArm { since: now, guid };
} else if now - since > WINDOW_TIMEOUT_SECS {
error!(
"PROBE_SERVICE: FAIL (vendor fork, empty cursor) — no merchant window on \
{guid:#x} within {WINDOW_TIMEOUT_SECS}s"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::VendorArm { since, guid } => {
let Some(mut script) = script else {
warn!("PROBE_SERVICE: SKIP (vendor fork, held cursor) — no UI VM in this build");
probe.skips += 1;
probe.phase = Phase::Done;
return;
};
let Some((bag, slot)) = find_item_slot(&script) else {
if now - since > BAG_TIMEOUT_SECS {
warn!(
"PROBE_SERVICE: SKIP (vendor fork, held cursor) — item {SELL_ITEM_ENTRY} \
never reached a bag within {BAG_TIMEOUT_SECS}s of `.additem` \
(a full bag, or the command was refused). Environmental."
);
probe.skips += 1;
probe.phase = Phase::Done;
}
return;
};
// Pick it up exactly as a player does — through the binding, not by writing the model.
if let Err(e) = script.run(&format!("PickupContainerItem({bag}, {slot})")) {
error!("PROBE_SERVICE: SKIP (vendor fork, held cursor) — PickupContainerItem: {e}");
probe.skips += 1;
probe.phase = Phase::Done;
return;
}
let Some(held) = script.cursor_item() else {
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — PickupContainerItem({bag}, \
{slot}) left nothing on the cursor, so the fork has no input"
);
probe.fails += 1;
probe.phase = Phase::Done;
return;
};
let slot0 = u8::try_from(held.slot.saturating_sub(1)).unwrap_or(0);
let Some(item_guid) = self_store
.single()
.ok()
.and_then(|s| crate::ui_items::slot_guid(&s.0, held.bag, slot0, &items))
else {
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — bag {} slot {} is on the \
cursor but resolves to no item guid, so the arm cannot address the packet",
held.bag, held.slot
);
probe.fails += 1;
probe.phase = Phase::Done;
return;
};
match service_action(ServiceArm::Vendor, guid, false, Some(item_guid)) {
ServiceAction::SellFromCursor(cmd) => {
info!(
"PROBE_SERVICE: (vendor fork, held cursor) bag {bag} slot {slot} = item \
{item_guid:#x} → {cmd:?}"
);
let _ = net.0.send(cmd);
script.take_cursor_item_for_sale();
if script.cursor_payload().is_some() {
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — the sell clear left \
the item on the cursor"
);
probe.fails += 1;
}
probe.phase = Phase::VendorSell {
since: now,
bag,
slot,
};
}
other => {
let what = match other {
ServiceAction::Send(cmd) => format!("{cmd:?}"),
_ => "a non-send arm".to_string(),
};
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — an item is on the cursor \
and the vendor arm still answered {what}; `0x5df5e0` must take the sale"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
}
Phase::VendorSell { since, bag, slot } => {
let Some(script) = script else {
probe.phase = Phase::Done;
return;
};
// The server's own verdict: the sold stack leaves the bag. Nothing client-side can
// fake this — it is the inventory update answering the packet.
let gone = script
.eval::<bool>(&format!(
"return GetContainerItemLink({bag}, {slot}) == nil"
))
.unwrap_or(false);
if gone {
info!(
"PROBE_SERVICE: PASS (vendor fork, held cursor) — the server took the sale: \
bag {bag} slot {slot} is empty {:.0} ms after CMSG_SELL_ITEM",
(now - since) * 1000.0
);
probe.passes += 1;
probe.phase = Phase::Done;
} else if now - since > BAG_TIMEOUT_SECS {
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — bag {bag} slot {slot} still \
holds the item {BAG_TIMEOUT_SECS}s after the sale went out; the server \
refused it (wrong vendor guid, wrong item guid, or out of range)"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Done => {
if probe.exited {
return;
}
probe.exited = true;
info!(
"PROBE_SERVICE: DONE pass={} fail={} skip={}",
probe.passes, probe.fails, probe.skips
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE_SERVICE: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
}
}
/// The open quest panel's shape, for the PASS line — `Greeting` (the multi-quest list) vs
/// `Detail` (the single quest's own frame) is exactly the distinction a reader wants here.
fn discr(view: &crate::ui_quest::QuestView) -> &'static str {
use crate::ui_quest::QuestView as V;
match view {
V::Greeting(_) => "Greeting",
V::Detail(_) => "Detail",
V::Progress(_) => "Progress",
V::Reward(_) => "Reward",
}
}
/// Send leg `i`'s `.go` for spawn point `h` and enter [`Phase::Hop`].
fn hop(probe: &mut ServiceProbe, net: &NetCommands, i: usize, h: usize, now: f64) {
let leg = &LEGS[i];
let [x, y, z] = leg.at[h];
info!(
"PROBE_SERVICE: hopping to {} at ({x}, {y}, {z}) map {} (spawn {}/{})",
leg.name,
leg.map,
h + 1,
leg.at.len()
);
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {}", leg.map),
});
probe.polls = 0;
probe.phase = Phase::Hop {
i,
hop: h,
sent_at: now,
};
}
/// Advance past leg `i`, or finish.
fn next(probe: &mut ServiceProbe, net: &NetCommands, i: usize, now: f64) {
match i + 1 < LEGS.len() {
true => hop(probe, net, i + 1, 0, now),
// The ladder legs are done; the vendor fork's own chain runs after them.
false => {
let [x, y, z] = VENDOR_AT;
info!(
"PROBE_SERVICE: hopping to the pure vendor (entry {VENDOR_ENTRY}) at \
({x}, {y}, {z}) map {VENDOR_MAP}"
);
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {VENDOR_MAP}"),
});
probe.phase = Phase::VendorHop { sent_at: now };
}
}
}