Files
benilla-octo/crates/benilla-protocol/examples/cooldown_probe.rs
T
SamandClaude Fable 5 c1ba444119 the micro menu, the ping meter, and the PvP flag
- The main menu bar's two empty recesses fill. The micro menu — the eight arched
  buttons: character, spellbook, talents, quest log, socials, world map, main menu,
  help — sits in the dark panel between the page arrows and the bags, transcribed
  from the reference's own layout: the character button wears your portrait, a button
  holds itself down while its panel is up, and the talent button appears at level 10.
  The latency meter fills the one slot the bar art leaves transparent for exactly it,
  tinted green/yellow/red on the reference's 300/600 ms thresholds and drawn UNDER
  the bar art, seen through its window, the way 1.12 actually does it. The socials
  and main-menu buttons find their panels one snapshot later; help still waits.
- The UI explains itself on hover: 1.12 ships its detailed two-line tooltips ON — a
  FrameXML default, not the opt-in extra it is often remembered as — so the micro
  buttons, your own portrait, the XP bar, the five bag buttons and the ping meter
  all answer a hover with the reference's own explanations.
- The PvP flag, end to end: crossed swords on the player, target and party frames,
  driven by the two descriptor bits the wire actually carries, and `/pvp` speaks —
  the yellow toast plus the system chat line, the OFF sentence being the one that
  warns the flag lingers for five minutes. It really does linger: turning it off
  waits out the server's timer, which looks like a bug and is the reference.
- Quest markers stopped freezing. The grey `!` over a questgiver above your level
  goes gold the moment you ding, an NPC re-entering view is asked afresh instead of
  wearing a session-old answer, and the marker drops when the questgiver bit goes
  off — the re-ask triggers mirror the reference's own set, plus one named addition
  (your own level changing).
- Water and heights: swimming downhill no longer bounces between swim and fall ten
  times a second — the surface line is a two-sided constraint, so the swimmer
  settles with the river instead of being left behind by it. And collision height
  is per-unit now, read from the unit's own model: a gnome used to swim with a
  third of a yard of water over her head because five depth lines were quoting a
  human's number.
- A round of pixel honesty in text and chat: the newest chat line no longer wears a
  wider drop shadow than every line above it (shadows move in whole pixels now),
  keeps its descenders, and hover boxes stop painting over the chat body. The chat
  input's history browse survives programmatic edits, a failed script chunk warns
  instead of vanishing, and recall deepens to 64 lines. Idle animation got honest
  too: a model's idle is Stand resolved through its own lookup table — never
  file order, which had the duel flag bobbing on its spawn clip — and an armed idle
  is culled on the box its animation authors, not the bind pose, so the flag stops
  vanishing at most camera angles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:17:47 +07:00

225 lines
9.3 KiB
Rust

//! Diagnostic probe: measure the server's ACTUAL cooldown window for Charge (spell 100,
//! `recoveryTime 0 / category 44 / categoryRecoveryTime 15000`) against the client-rendered
//! 15 s sweep — the "I can charge before the indicator is up" report.
//!
//! Flow: log in the slot's probe warrior (`Probe<N-spelled>`), `.learn 100`, teleport into the Northshire kobold
//! cluster, Battle Stance, pick a Kobold Vermin (entry 6) from the create stream, Charge it,
//! then spam re-casts every 200 ms. vmangos checks the cooldown at the TOP of `CheckCast`
//! (`Spell.cpp:5369`), so the flip from `SPELL_FAILED_NOT_READY` (60) to any other code (or a
//! second `SMSG_SPELL_GO`) timestamps the server's cooldown end relative to the first GO.
//!
//! Run: `cargo run -p benilla-protocol --example cooldown_probe -- probeN pprobeN [host]` — the
//! slot-keyed probe account (method.md "The local vmangos server"; NEVER `one`, the director's
//! account — a probe login there kicks their live session). Two caveats: this is a COMBAT probe,
//! so it runs director-supervised (method.md: no unattended combat probes), and `.learn` is
//! SEC_DEVELOPER (5); probe accounts are gmlevel 6 (SEC_ADMINISTRATOR), so it lands. The vmangos
//! console for the run and restore it after.
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use benilla_protocol::{decode, EntityKind, SessionEvent, WorldSession, WORLD_PORT};
const CHARGE: u32 = 100;
const BATTLE_STANCE: u32 = 2457;
const KOBOLD_VERMIN: u32 = 6;
fn fail_name(reason: u8) -> &'static str {
match reason {
0 => "AFFECTING_COMBAT",
10 => "BAD_TARGETS",
23 => "DONT_REPORT",
42 => "LINE_OF_SIGHT",
50 => "NOPATH",
56 => "NOT_KNOWN",
60 => "NOT_READY",
86 => "ONLY_SHAPESHIFT",
89 => "OUT_OF_RANGE",
_ => "?",
}
}
fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
let user = args
.next()
.context("usage: cooldown_probe -- <probeN> <pprobeN> [host] (slot-keyed account)")?;
let pass = args
.next()
.context("usage: cooldown_probe -- <probeN> <pprobeN> [host] (slot-keyed account)")?;
let host = args.next().unwrap_or_else(|| "localhost".into());
let logon = benilla_protocol::logon(&host, &user, &pass)?;
let world_addr = logon
.realms
.first()
.map(|r| r.address.clone())
.unwrap_or_else(|| format!("{host}:{WORLD_PORT}"));
let mut session = WorldSession::connect(&world_addr, &user, logon.session_key)?;
let characters = session.char_enum()?;
let character = characters
.iter()
.find(|c| c.name == "Tri")
.or_else(|| characters.first())
.context("no characters")?;
let self_guid = character.guid;
println!("logging in '{}' (guid {self_guid})", character.name);
session.player_login(self_guid)?;
session.set_active_mover(self_guid)?;
session.set_read_timeout(Some(Duration::from_millis(100)))?;
let mut kobolds: Vec<u64> = Vec::new();
let drain = |session: &mut WorldSession,
kobolds: &mut Vec<u64>,
secs: f32,
mut on_event: &mut dyn FnMut(f64, SessionEvent)|
-> Result<()> {
let until = Instant::now() + Duration::from_secs_f32(secs);
let t0 = Instant::now();
while Instant::now() < until {
let Ok(msg) = session.recv() else { continue };
for ev in decode(msg) {
match &ev {
SessionEvent::ObjectCreate {
guid, kind, fields, ..
} if matches!(kind, EntityKind::Unit)
&& fields.object_entry() == Some(KOBOLD_VERMIN)
&& !kobolds.contains(guid) =>
{
kobolds.push(*guid);
}
// A `.go` teleport must be acked or the server freezes us and streams nothing.
SessionEvent::Teleport { guid, counter, .. } => {
println!(" teleport → acking (counter {counter})");
session.teleport_ack(*guid, *counter)?;
}
_ => {}
}
on_event(t0.elapsed().as_secs_f64(), ev);
}
}
let _ = &mut on_event;
Ok(())
};
let mut quiet = |_t: f64, ev: SessionEvent| match ev {
SessionEvent::Chat(m) => println!(" chat: {:?}", m.text),
SessionEvent::SpellLearned { spell_id } => println!(" learned: {spell_id}"),
_ => {}
};
// World-enter settle, then arm the character: learn Charge, stance, teleport to the kobolds.
drain(&mut session, &mut kobolds, 3.0, &mut quiet)?;
session.set_selection(self_guid)?;
session.send_chat(".learn 100")?;
session.send_chat(".go xyz -8785 -150 82.5")?;
drain(&mut session, &mut kobolds, 3.0, &mut quiet)?;
session.cast_spell(BATTLE_STANCE, None)?;
let mut chatty = |_t: f64, ev: SessionEvent| match ev {
SessionEvent::Chat(m) => println!(" chat: {:?}", m.text),
SessionEvent::CastResult {
spell_id,
success,
reason,
} => println!(
" pre: cast_result spell={spell_id} success={success} reason={reason:?} ({})",
reason.map(fail_name).unwrap_or("-")
),
_ => {}
};
drain(&mut session, &mut kobolds, 2.0, &mut chatty)?;
println!("kobolds seen: {}", kobolds.len());
// First Charge: try candidates until one GO lands.
let mut target: Option<u64> = None;
let mut charge_go_at: Option<Instant> = None;
for &guid in kobolds.clone().iter() {
println!("charging kobold {guid:#x}…");
session.set_selection(guid)?;
session.cast_spell(CHARGE, Some(guid))?;
// The anchor is stamped INSIDE the watch, at GO decode — the drain window runs its full
// length regardless, and a window-end stamp would smear the anchor by up to 1.5 s.
let mut verdict: Option<(bool, Instant)> = None;
let mut watch = |_t: f64, ev: SessionEvent| match ev {
SessionEvent::SpellGo {
caster, spell_id, ..
} if caster == self_guid && spell_id == CHARGE => {
verdict = Some((true, Instant::now()));
}
SessionEvent::CastResult {
spell_id,
success: false,
reason,
} if spell_id == CHARGE => {
println!(
" refused: {:?} ({})",
reason,
reason.map(fail_name).unwrap_or("-")
);
verdict = Some((false, Instant::now()));
}
_ => {}
};
drain(&mut session, &mut kobolds, 1.5, &mut watch)?;
if let Some((true, at)) = verdict {
println!(" GO — cooldown armed");
target = Some(guid);
charge_go_at = Some(at);
break;
}
}
let target = target.context("no charge landed — no measurement")?;
let t0 = charge_go_at.unwrap();
// The spam loop: recast every 200 ms for 20 s; every result timestamps the server verdict.
println!("t=0.00s — first GO received; spamming recasts…");
let mut last_not_ready: Option<f64> = None;
let mut first_free: Option<(f64, String)> = None;
while t0.elapsed() < Duration::from_secs(20) {
session.cast_spell(CHARGE, Some(target))?;
let mut watch = |_t: f64, ev: SessionEvent| {
let at = t0.elapsed().as_secs_f64();
match ev {
SessionEvent::CastResult {
spell_id,
success,
reason,
} if spell_id == CHARGE => {
let name = reason.map(fail_name).unwrap_or("-");
println!(
"t={at:5.2}s cast_result success={success} reason={reason:?} ({name})"
);
if reason == Some(60) {
last_not_ready = Some(at);
} else if first_free.is_none() {
first_free = Some((at, format!("result {reason:?} ({name})")));
}
}
SessionEvent::SpellGo {
caster, spell_id, ..
} if caster == self_guid && spell_id == CHARGE => {
println!("t={at:5.2}s SPELL_GO (recast SUCCEEDED)");
if first_free.is_none() {
first_free = Some((at, "SPELL_GO".into()));
}
}
SessionEvent::SpellCooldowns { cooldowns, .. } => {
println!("t={at:5.2}s SMSG_SPELL_COOLDOWN {cooldowns:?}");
}
SessionEvent::CooldownEvent { spell_id, .. } => {
println!("t={at:5.2}s SMSG_COOLDOWN_EVENT spell={spell_id}");
}
SessionEvent::ClearCooldown { spell_id, .. } => {
println!("t={at:5.2}s SMSG_CLEAR_COOLDOWN spell={spell_id}");
}
_ => {}
}
};
drain(&mut session, &mut kobolds, 0.2, &mut watch)?;
}
println!("---");
println!("last NOT_READY: {last_not_ready:?}");
println!("first non-NOT_READY after GO: {first_free:?}");
Ok(())
}