Reputation lines, NPC greeting gates, and packet bytes in the trace (#976)
Player reports and the trace, in one batch. - SetFactionInactive/SetFactionActive raise `UPDATE_FACTION`, so the list moves at once. - NPC greetings: the latch, a dead unit, then the reaction pair with the active mover (`0x60c270`); a dead or ghost player hears none. Only a passing click re-tracks the NPC, and a window hello resets it (`0x60c3d1`). - "You are now <rank> with <faction>." prints on first contact and rank change (`0x62c670`), in packet order, with the real gender. - A standing packet prints its "increased by" line for the first entry only, never a hidden one (`0x4d57dd`). - `WOW_MOVE_TRACE` carries each packet's bytes as hex; chat bodies are opt-in, and dot/bang commands and channel passwords are always redacted. Tests cover each gate, the line order and the redaction. Fixes #927 Fixes #882 Fixes #881 Fixes #867 Fixes #965 Fixes #966 Fixes #967 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7158c58575
commit
a329f19e06
@@ -1139,8 +1139,11 @@ pub(super) fn environmental_damage_log(
|
||||
|
||||
/// `SMSG_SET_FACTION_STANDING`: "Your %s reputation has increased by %d.". The wire carries the
|
||||
/// new total and the sentence the delta, so this runs before the store is overwritten, and an
|
||||
/// unchanged value prints nothing (`0x62c5f0`). No range gate or classifier: the chat type is the
|
||||
/// literal `0x55` `COMBAT_FACTION_CHANGE`. The wire's list id is `Faction.dbc`'s `rep_index`.
|
||||
/// unchanged value prints nothing (`0x4d6344`). Only the packet's first entry prints, and only if
|
||||
/// its faction is not `HIDDEN`: the handler passes `0x4d6330` its print argument as
|
||||
/// `index == 0 && !(flags & 4)` (`0x4d57dd`-`0x4d57ea`), so the spill-over entries of one kill
|
||||
/// update silently. No range gate or classifier: the chat type is the literal `0x55`
|
||||
/// `COMBAT_FACTION_CHANGE`. The wire's list id is `Faction.dbc`'s `rep_index`.
|
||||
pub(crate) fn faction_standing(
|
||||
deltas: &[(u32, i32)],
|
||||
reputations: &Reputations,
|
||||
@@ -1150,11 +1153,15 @@ pub(crate) fn faction_standing(
|
||||
let Some(catalog) = factions.map(|f| f.catalog()) else {
|
||||
return;
|
||||
};
|
||||
for &(list_id, standing) in deltas {
|
||||
let old = reputations
|
||||
for &(list_id, standing) in deltas.iter().take(1) {
|
||||
let (flags, old) = reputations
|
||||
.0
|
||||
.get(list_id as usize)
|
||||
.map_or(0, |(_, standing)| *standing);
|
||||
.copied()
|
||||
.unwrap_or((0, 0));
|
||||
if flags & benilla_formats::faction_flags::HIDDEN != 0 {
|
||||
continue;
|
||||
}
|
||||
let delta = standing - old;
|
||||
if delta == 0 {
|
||||
continue;
|
||||
@@ -1392,4 +1399,49 @@ mod tests {
|
||||
// The direct packet takes the same wire number through one divide only.
|
||||
assert_eq!(leech_figures(1, 100, 1.0), Some((10, 10)));
|
||||
}
|
||||
|
||||
/// A standing packet prints the "increased by" line for its first entry alone (`0x4d57dd`), and
|
||||
/// not at all when that faction is `HIDDEN`; the factions a kill spills over to update
|
||||
/// silently. On the real DBC, Booty Bay moving with Gadgetzan and Ratchet.
|
||||
#[test]
|
||||
fn a_standing_packet_prints_its_first_entry_only_and_never_a_hidden_one() {
|
||||
use crate::net::Reputations;
|
||||
use crate::target::ring::Factions;
|
||||
use crate::ui_chat::ChatLog;
|
||||
use benilla_formats::faction_flags::HIDDEN;
|
||||
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let catalog = benilla_formats::load_faction_catalog(&mut chain).expect("factions");
|
||||
let slot =
|
||||
|id: u32| u32::try_from(catalog.reputation_faction(id).unwrap().rep_index).unwrap();
|
||||
let (booty, gadget, ratchet) = (slot(21), slot(369), slot(470));
|
||||
let factions = Factions::from_catalog(catalog);
|
||||
let packet = [(booty, 100), (gadget, 50), (ratchet, 50)];
|
||||
let kinds = |reps: &Reputations, deltas: &[(u32, i32)]| {
|
||||
let mut log = ChatLog::default();
|
||||
super::faction_standing(deltas, reps, Some(&factions), &mut log);
|
||||
log.pending_kinds()
|
||||
};
|
||||
|
||||
let quiet = Reputations::default();
|
||||
assert_eq!(
|
||||
kinds(&quiet, &packet),
|
||||
["combat"],
|
||||
"one line for the packet"
|
||||
);
|
||||
|
||||
// Flag 4 on the first entry's slot silences the packet; on a later entry it changes nothing.
|
||||
let mut v = vec![(0u8, 0i32); 64];
|
||||
v[booty as usize].0 = HIDDEN;
|
||||
assert!(kinds(&Reputations(v), &packet).is_empty());
|
||||
let mut v = vec![(0u8, 0i32); 64];
|
||||
v[gadget as usize].0 = HIDDEN;
|
||||
assert_eq!(kinds(&Reputations(v), &packet), ["combat"]);
|
||||
|
||||
// An unchanged first entry prints nothing, and the entries behind it still stay silent.
|
||||
let mut v = vec![(0u8, 0i32); 64];
|
||||
v[booty as usize].1 = 100;
|
||||
assert!(kinds(&Reputations(v), &packet).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ pub(super) struct NetHandles {
|
||||
pub(super) fn spawn_net(connect: bool) -> NetHandles {
|
||||
// The outbound opcode trace (tag `out`), armed before any thread can send.
|
||||
if benilla_assets::trace::enabled_for("out") {
|
||||
benilla_protocol::observe_sends(trace_out);
|
||||
benilla_protocol::observe_sends(wire_trace::trace_out);
|
||||
}
|
||||
let (events_tx, events_rx) = crossbeam_channel::unbounded();
|
||||
let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded();
|
||||
@@ -675,6 +675,7 @@ fn run(
|
||||
opcode,
|
||||
events,
|
||||
tail,
|
||||
body,
|
||||
} => {
|
||||
skip_run = 0;
|
||||
// A body is length-framed, so a decoder shorter than the server's layout
|
||||
@@ -686,16 +687,12 @@ fn run(
|
||||
benilla_protocol::messages::opcode_name(opcode).unwrap_or("?"),
|
||||
);
|
||||
}
|
||||
// Every inbound opcode by name (tag `in`), including one that parsed into no
|
||||
// event.
|
||||
if benilla_assets::trace::enabled() {
|
||||
// Every inbound opcode by name, length and bytes (tag `in`), including one that
|
||||
// parsed into no event.
|
||||
if benilla_assets::trace::enabled_for("in") {
|
||||
benilla_assets::trace::line(
|
||||
"in",
|
||||
&format!(
|
||||
"{opcode:#06x} {} ev={}",
|
||||
benilla_protocol::messages::opcode_name(opcode).unwrap_or("?"),
|
||||
events.len()
|
||||
),
|
||||
&wire_trace::in_line(opcode, events.len(), &body),
|
||||
);
|
||||
}
|
||||
for ev in events {
|
||||
@@ -724,14 +721,18 @@ fn run(
|
||||
}
|
||||
}
|
||||
}
|
||||
Poll::Skipped { opcode, reason } => {
|
||||
Poll::Skipped {
|
||||
opcode,
|
||||
reason,
|
||||
body,
|
||||
} => {
|
||||
skip_run += 1;
|
||||
// Every skip, uncapped, into the trace (tag `skip`): otherwise a packet that
|
||||
// failed to parse looks like one that never arrived.
|
||||
if benilla_assets::trace::enabled() {
|
||||
// Every skip, uncapped, into the trace (tag `skip`) with its whole body:
|
||||
// otherwise a packet that failed to parse looks like one that never arrived.
|
||||
if benilla_assets::trace::enabled_for("skip") {
|
||||
benilla_assets::trace::line(
|
||||
"skip",
|
||||
&format!("opcode={opcode:#06x} {reason}"),
|
||||
&wire_trace::skip_line(opcode, &reason, &body),
|
||||
);
|
||||
}
|
||||
// Capped, but enough for a post-teleport burst of unparseable object updates.
|
||||
@@ -777,18 +778,6 @@ fn refuse_once(guid: u64) -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// One `out` trace line per client packet written to the world socket, whichever session or writer
|
||||
/// sent it: the hook [`spawn_net`] installs when the `out` tag is on.
|
||||
fn trace_out(opcode: u16, len: usize) {
|
||||
benilla_assets::trace::line(
|
||||
"out",
|
||||
&format!(
|
||||
"{opcode:#06x} {} len={len}",
|
||||
benilla_protocol::messages::opcode_name(opcode).unwrap_or("?")
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The write thread: app commands, writer swaps and the [`PING_INTERVAL`] keepalive. With no live
|
||||
/// writer, commands drop with a capped warning. Ends when the app drops every command sender.
|
||||
fn writer_loop(
|
||||
@@ -850,11 +839,14 @@ fn writer_loop(
|
||||
if benilla_assets::trace::enabled() {
|
||||
benilla_assets::trace::line(
|
||||
"wire",
|
||||
&format!("DROPPED — no live session: {cmd:?}"),
|
||||
&format!("DROPPED — no live session: {}", wire_trace::dropped(&cmd)),
|
||||
);
|
||||
}
|
||||
if warned < SEND_WARN_CAP {
|
||||
bevy::log::warn!("net: dropping command — not connected: {cmd:?}");
|
||||
bevy::log::warn!(
|
||||
"net: dropping command — not connected: {}",
|
||||
wire_trace::dropped(&cmd)
|
||||
);
|
||||
warned += 1;
|
||||
}
|
||||
continue;
|
||||
@@ -1490,6 +1482,8 @@ fn writer_loop(
|
||||
}
|
||||
}
|
||||
|
||||
mod wire_trace;
|
||||
|
||||
#[cfg(test)]
|
||||
mod cycle_tests;
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! The packet trace's lines: every world packet by opcode name with its length and its body as
|
||||
//! hex, under the `in` and `out` tags of `WOW_MOVE_TRACE` (`benilla_assets::trace`).
|
||||
//!
|
||||
//! The body is the bytes after the 6-byte header, as they cross the socket (the header alone is
|
||||
//! encrypted), so a line is a packet sniffer's row: paste the hex into a decoder. A field is
|
||||
//! appended to each line and no earlier one moves, so a reader that matches the opcode, the name,
|
||||
//! `ev=` or `len=` keeps working.
|
||||
//!
|
||||
//! Traces get attached to public issues, so two kinds of body never reach the file by default.
|
||||
//! A packet that carries a credential is `hex=<redacted>` always ([`secret`]). Private
|
||||
//! conversation and bulk lists of other players' names are `hex=<withheld>` unless
|
||||
//! `WOW_MOVE_TRACE_CHAT=1` asks for them ([`private`]). Both keep `len=`.
|
||||
|
||||
use benilla_protocol::messages::{self, opcode};
|
||||
|
||||
/// What stands in for the hex of a packet that carries a secret, whatever the switches.
|
||||
const REDACTED: &str = "<redacted>";
|
||||
/// What stands in for the hex of private text or a list of names, until `WOW_MOVE_TRACE_CHAT=1`.
|
||||
const WITHHELD: &str = "<withheld>";
|
||||
|
||||
/// Whether `WOW_MOVE_TRACE_CHAT` asks for the private bodies; read once.
|
||||
fn reveal_private() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| {
|
||||
std::env::var_os("WOW_MOVE_TRACE_CHAT").is_some_and(|v| !v.is_empty() && v != "0")
|
||||
})
|
||||
}
|
||||
|
||||
/// A body that is private in either direction: every chat message, sent (`CMSG_MESSAGECHAT`) or
|
||||
/// received (`SMSG_MESSAGECHAT`, whispers and addon messages included), and the bulk lists of
|
||||
/// other players' names, `SMSG_WHO`, `SMSG_FRIEND_LIST`, `SMSG_IGNORE_LIST`, `SMSG_GUILD_ROSTER`
|
||||
/// and `SMSG_CHANNEL_LIST`. A single name (a name query, a friend's status) stays traced: it is
|
||||
/// everywhere and says little alone.
|
||||
fn private(opcode: u16) -> bool {
|
||||
matches!(
|
||||
opcode,
|
||||
opcode::CMSG_MESSAGECHAT
|
||||
| opcode::SMSG_MESSAGECHAT
|
||||
| opcode::SMSG_WHO
|
||||
| opcode::SMSG_FRIEND_LIST
|
||||
| opcode::SMSG_IGNORE_LIST
|
||||
| opcode::SMSG_GUILD_ROSTER
|
||||
| opcode::SMSG_CHANNEL_LIST
|
||||
)
|
||||
}
|
||||
|
||||
/// A client packet whose body is never traced: `CMSG_AUTH_SESSION` carries the account name and
|
||||
/// the proof built from the session key; `CMSG_JOIN_CHANNEL` and `CMSG_CHANNEL_PASSWORD` carry a
|
||||
/// channel's password; and a `CMSG_MESSAGECHAT` that is a server command, which vmangos reads when
|
||||
/// the text starts with `.` or `!` (`Chat.cpp:2031`, `:2047`) and matches by prefix
|
||||
/// (`.acc pass <old> <new>`), may carry the account's. Server packets carry none.
|
||||
fn secret(opcode: u16, body: &[u8]) -> bool {
|
||||
match opcode {
|
||||
opcode::CMSG_AUTH_SESSION | opcode::CMSG_JOIN_CHANNEL | opcode::CMSG_CHANNEL_PASSWORD => {
|
||||
true
|
||||
}
|
||||
opcode::CMSG_MESSAGECHAT => is_command(body),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `CMSG_MESSAGECHAT` body (type u32, language u32, then for a whisper or channel
|
||||
/// message the target's name, then the text) has a message that starts with `.` or `!`. Both
|
||||
/// layouts are tried, so a type this does not know still gets the check.
|
||||
fn is_command(body: &[u8]) -> bool {
|
||||
let Some(rest) = body.get(8..) else {
|
||||
return false;
|
||||
};
|
||||
let after_target = rest
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
.map(|nul| &rest[nul + 1..]);
|
||||
[Some(rest), after_target]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|text| matches!(text.first(), Some(b'.' | b'!')))
|
||||
}
|
||||
|
||||
/// `body` as lowercase hex with no separators; empty for an empty body.
|
||||
fn hex(body: &[u8]) -> String {
|
||||
const DIGITS: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(body.len() * 2);
|
||||
for &b in body {
|
||||
out.push(char::from(DIGITS[usize::from(b >> 4)]));
|
||||
out.push(char::from(DIGITS[usize::from(b & 0xf)]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn name(opcode: u16) -> &'static str {
|
||||
messages::opcode_name(opcode).unwrap_or("?")
|
||||
}
|
||||
|
||||
/// The `hex=` field for a body: redacted, withheld, or the bytes. `outbound` is the client's own
|
||||
/// packets, the only ones that can carry a credential; `reveal` is `WOW_MOVE_TRACE_CHAT`.
|
||||
fn payload(opcode: u16, body: &[u8], outbound: bool, reveal: bool) -> String {
|
||||
if outbound && secret(opcode, body) {
|
||||
REDACTED.to_string()
|
||||
} else if private(opcode) && !reveal {
|
||||
WITHHELD.to_string()
|
||||
} else {
|
||||
hex(body)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `out` line of a client packet: `0x01dc CMSG_PING len=4 hex=…`.
|
||||
fn out_line_with(opcode: u16, body: &[u8], reveal: bool) -> String {
|
||||
format!(
|
||||
"{opcode:#06x} {} len={} hex={}",
|
||||
name(opcode),
|
||||
body.len(),
|
||||
payload(opcode, body, true, reveal)
|
||||
)
|
||||
}
|
||||
|
||||
/// The `in` line of a server packet, decoded into `events` events: `0x01dd SMSG_PONG ev=1 len=4
|
||||
/// hex=…`.
|
||||
fn in_line_with(opcode: u16, events: usize, body: &[u8], reveal: bool) -> String {
|
||||
format!(
|
||||
"{opcode:#06x} {} ev={events} len={} hex={}",
|
||||
name(opcode),
|
||||
body.len(),
|
||||
payload(opcode, body, false, reveal)
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn out_line(opcode: u16, body: &[u8]) -> String {
|
||||
out_line_with(opcode, body, reveal_private())
|
||||
}
|
||||
|
||||
pub(super) fn in_line(opcode: u16, events: usize, body: &[u8]) -> String {
|
||||
in_line_with(opcode, events, body, reveal_private())
|
||||
}
|
||||
|
||||
/// The `skip` line of a server packet that did not parse: its `reason` and then its whole body,
|
||||
/// which the `in` line never shows.
|
||||
pub(super) fn skip_line(opcode: u16, reason: &str, body: &[u8]) -> String {
|
||||
skip_line_with(opcode, reason, body, reveal_private())
|
||||
}
|
||||
|
||||
fn skip_line_with(opcode: u16, reason: &str, body: &[u8], reveal: bool) -> String {
|
||||
let payload = payload(opcode, body, false, reveal);
|
||||
// The reader's reason ends in a ` [<n>B: <hex preview>]`, which would leak what the field hides.
|
||||
let reason = match reason.rfind(" [") {
|
||||
Some(at) if payload == WITHHELD => &reason[..at],
|
||||
_ => reason,
|
||||
};
|
||||
format!("opcode={opcode:#06x} {reason} hex={payload}")
|
||||
}
|
||||
|
||||
/// A command the writer dropped for want of a session, as the trace's `wire` line and the log name
|
||||
/// it. The `Debug` form would print a channel's password and the text of a chat message, a mail or
|
||||
/// a ticket, which these lines keep out as the packet lines do.
|
||||
pub(super) fn dropped(cmd: &crate::net::ClientCommand) -> String {
|
||||
use crate::net::ClientCommand as C;
|
||||
match cmd {
|
||||
C::JoinChannel { name, .. } => {
|
||||
format!("JoinChannel {{ name: {name:?}, password: {REDACTED} }}")
|
||||
}
|
||||
C::ChannelPassword { name, .. } => {
|
||||
format!("ChannelPassword {{ name: {name:?}, password: {REDACTED} }}")
|
||||
}
|
||||
C::Chat { .. } => format!("Chat {{ text: {WITHHELD} }}"),
|
||||
C::AddonMessage { .. } => format!("AddonMessage {{ text: {WITHHELD} }}"),
|
||||
C::SendMail { .. } => format!("SendMail {{ text: {WITHHELD} }}"),
|
||||
C::GmTicketCreate { .. } => format!("GmTicketCreate {{ text: {WITHHELD} }}"),
|
||||
C::GmTicketUpdate { .. } => format!("GmTicketUpdate {{ text: {WITHHELD} }}"),
|
||||
other => format!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The hook [`super::spawn_net`] installs for the `out` tag: one line per client packet written
|
||||
/// to the world socket, whichever session or writer sent it, right after the write.
|
||||
pub(super) fn trace_out(opcode: u16, body: &[u8]) {
|
||||
benilla_assets::trace::line("out", &out_line(opcode, body));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A traced packet's line carries its name, its length and its bytes, after the fields every
|
||||
/// earlier trace had.
|
||||
#[test]
|
||||
fn a_traced_packet_line_carries_its_bytes() {
|
||||
let body = [0x00, 0x0a, 0xff, 0x7e];
|
||||
assert_eq!(
|
||||
out_line_with(opcode::CMSG_PING, &body, false),
|
||||
"0x01dc CMSG_PING len=4 hex=000aff7e"
|
||||
);
|
||||
assert_eq!(
|
||||
in_line_with(opcode::SMSG_PONG, 1, &body, false),
|
||||
"0x01dd SMSG_PONG ev=1 len=4 hex=000aff7e"
|
||||
);
|
||||
assert_eq!(
|
||||
in_line_with(opcode::SMSG_PONG, 0, &[], false),
|
||||
"0x01dd SMSG_PONG ev=0 len=0 hex=",
|
||||
"an empty body is an empty field"
|
||||
);
|
||||
assert_eq!(
|
||||
skip_line_with(
|
||||
opcode::SMSG_PONG,
|
||||
"short read [4B: 00 0a ff 7e]",
|
||||
&body,
|
||||
false
|
||||
),
|
||||
"opcode=0x01dd short read [4B: 00 0a ff 7e] hex=000aff7e"
|
||||
);
|
||||
}
|
||||
|
||||
/// The bytes of a login or a password never reach the file, switch or no switch.
|
||||
#[test]
|
||||
fn a_packet_that_carries_a_secret_is_redacted_always() {
|
||||
let secret = b"SECRETWORD";
|
||||
let needle = hex(secret);
|
||||
for reveal in [false, true] {
|
||||
for op in [
|
||||
opcode::CMSG_AUTH_SESSION,
|
||||
opcode::CMSG_JOIN_CHANNEL,
|
||||
opcode::CMSG_CHANNEL_PASSWORD,
|
||||
] {
|
||||
let line = out_line_with(op, secret, reveal);
|
||||
assert!(line.ends_with("hex=<redacted>"), "{line}");
|
||||
assert!(!line.contains(&needle), "{line}");
|
||||
}
|
||||
// The length stays: it says the packet went out.
|
||||
assert!(out_line_with(opcode::CMSG_AUTH_SESSION, secret, reveal).contains("len=10"));
|
||||
// vmangos reads `.` and `!` commands and matches them by prefix.
|
||||
for command in [
|
||||
".account password old new new",
|
||||
".acc pass old new new",
|
||||
"!acc pass old new new",
|
||||
".ACCOUNT create a b",
|
||||
] {
|
||||
let line = out_line_with(opcode::CMSG_MESSAGECHAT, &chat(command), reveal);
|
||||
assert!(line.ends_with("hex=<redacted>"), "{command}: {line}");
|
||||
// The same text whispered or sent to a channel follows the target's name.
|
||||
let mut targeted = vec![7, 0, 0, 0, 0, 0, 0, 0];
|
||||
targeted.extend_from_slice(b"Alice\0");
|
||||
targeted.extend_from_slice(command.as_bytes());
|
||||
targeted.push(0);
|
||||
let line = out_line_with(opcode::CMSG_MESSAGECHAT, &targeted, reveal);
|
||||
assert!(
|
||||
line.ends_with("hex=<redacted>"),
|
||||
"{command} to Alice: {line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Inbound traffic carries no credential of ours.
|
||||
assert!(in_line_with(opcode::SMSG_AUTH_RESPONSE, 1, secret, false).contains(&needle));
|
||||
}
|
||||
|
||||
/// Chat body: type, language, then the text.
|
||||
fn chat(text: &str) -> Vec<u8> {
|
||||
let mut b = vec![0, 0, 0, 0, 0, 0, 0, 0];
|
||||
b.extend_from_slice(text.as_bytes());
|
||||
b.push(0);
|
||||
b
|
||||
}
|
||||
|
||||
/// An inbound whisper and an outgoing say are withheld by default and shown with the switch;
|
||||
/// the length stays either way, and the lists of names go the same way.
|
||||
#[test]
|
||||
fn private_conversation_is_withheld_unless_asked_for() {
|
||||
let say = chat("meet me at the bank");
|
||||
let whisper = chat("psst, the password is hunter2 for the guild bank");
|
||||
let len = format!("len={}", say.len());
|
||||
|
||||
let line = out_line_with(opcode::CMSG_MESSAGECHAT, &say, false);
|
||||
assert!(line.ends_with("hex=<withheld>"), "{line}");
|
||||
assert!(line.contains(&len), "{line}");
|
||||
assert!(!line.contains(&hex(&say)), "{line}");
|
||||
let line = out_line_with(opcode::CMSG_MESSAGECHAT, &say, true);
|
||||
assert!(line.ends_with(&format!("hex={}", hex(&say))), "{line}");
|
||||
|
||||
let line = in_line_with(opcode::SMSG_MESSAGECHAT, 1, &whisper, false);
|
||||
assert!(line.ends_with("hex=<withheld>"), "{line}");
|
||||
assert!(line.contains(&format!("len={}", whisper.len())), "{line}");
|
||||
assert!(!line.contains(&hex(&whisper)), "{line}");
|
||||
let line = in_line_with(opcode::SMSG_MESSAGECHAT, 1, &whisper, true);
|
||||
assert!(line.ends_with(&format!("hex={}", hex(&whisper))), "{line}");
|
||||
|
||||
for op in [
|
||||
opcode::SMSG_WHO,
|
||||
opcode::SMSG_FRIEND_LIST,
|
||||
opcode::SMSG_IGNORE_LIST,
|
||||
opcode::SMSG_GUILD_ROSTER,
|
||||
opcode::SMSG_CHANNEL_LIST,
|
||||
] {
|
||||
let line = in_line_with(op, 1, b"Alice", false);
|
||||
assert!(line.ends_with("hex=<withheld>"), "{line}");
|
||||
assert!(in_line_with(op, 1, b"Alice", true).contains(&hex(b"Alice")));
|
||||
}
|
||||
// A single name stays traced.
|
||||
assert!(
|
||||
in_line_with(opcode::SMSG_FRIEND_STATUS, 1, b"Alice", false).contains(&hex(b"Alice"))
|
||||
);
|
||||
}
|
||||
|
||||
/// An unparseable chat packet leaks nothing through the skip line's preview either.
|
||||
#[test]
|
||||
fn a_skipped_chat_packet_keeps_neither_its_bytes_nor_its_preview() {
|
||||
let body = chat("hello");
|
||||
let reason = format!(
|
||||
"opcode 0x0096 (SMSG_MESSAGECHAT): short read [{}B: 00 00 68 65]",
|
||||
body.len()
|
||||
);
|
||||
let line = skip_line_with(opcode::SMSG_MESSAGECHAT, &reason, &body, false);
|
||||
assert_eq!(
|
||||
line,
|
||||
"opcode=0x0096 opcode 0x0096 (SMSG_MESSAGECHAT): short read hex=<withheld>"
|
||||
);
|
||||
assert!(
|
||||
skip_line_with(opcode::SMSG_MESSAGECHAT, &reason, &body, true).contains(&hex(&body))
|
||||
);
|
||||
}
|
||||
|
||||
/// A command the writer drops for want of a session is named without its secrets or its text.
|
||||
#[test]
|
||||
fn a_dropped_command_is_named_without_its_password_or_text() {
|
||||
use crate::net::ClientCommand as C;
|
||||
let join = dropped(&C::JoinChannel {
|
||||
name: "Trade".into(),
|
||||
password: "hunter2".into(),
|
||||
});
|
||||
assert_eq!(
|
||||
join,
|
||||
"JoinChannel { name: \"Trade\", password: <redacted> }"
|
||||
);
|
||||
let pw = dropped(&C::ChannelPassword {
|
||||
name: "Trade".into(),
|
||||
password: "hunter2".into(),
|
||||
});
|
||||
assert!(pw.contains("<redacted>") && !pw.contains("hunter2"), "{pw}");
|
||||
// Anything else keeps its `Debug` form.
|
||||
assert_eq!(dropped(&C::Logout), "Logout");
|
||||
}
|
||||
}
|
||||
@@ -269,17 +269,56 @@ fn on_clock(In(ev): In<SessionEvent>, mut b: Bridge) {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_reputations(In(ev): In<SessionEvent>, mut b: Bridge) {
|
||||
fn on_reputations(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut b: Bridge,
|
||||
factions: Option<Res<crate::target::ring::Factions>>,
|
||||
mut chat: ResMut<crate::ui_chat::ChatLog>,
|
||||
) {
|
||||
match ev {
|
||||
SessionEvent::Reputations { standings } => reputations(standings, &mut b.reputations),
|
||||
SessionEvent::ReputationVisible { list_id } => {
|
||||
// The chat line names the rank, so it reads the store before the flag lands.
|
||||
if let (Some(f), Some(me)) = (factions.as_deref(), self_facts(&b)) {
|
||||
let line = crate::ui_reputation::visible_line(
|
||||
list_id,
|
||||
&b.reputations,
|
||||
f.catalog(),
|
||||
me.race,
|
||||
me.class,
|
||||
);
|
||||
crate::ui_reputation::queue_standing_lines(line, f.catalog(), me.female, &mut chat);
|
||||
}
|
||||
reputation_visible(list_id, &mut b.reputations)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The chat line reads the deltas against the store, so it must run before the overwrite.
|
||||
/// What the reputation chat lines read of the local player.
|
||||
struct SelfFacts {
|
||||
race: u8,
|
||||
class: u8,
|
||||
female: bool,
|
||||
}
|
||||
|
||||
/// The local player's race, class and gender, `None` before the self object streams in; the
|
||||
/// reference's `0x62c670` prints nothing without it (`0x62c6bf`). The gender is the real one
|
||||
/// (`UNIT_FIELD_BYTES_0` byte 2), where `0x5ed5b0` reads `PLAYER_BYTES_3` byte 0 whole and so
|
||||
/// takes a drunk female on vmangos for a male: that byte carries the drunk bits.
|
||||
fn self_facts(b: &Bridge) -> Option<SelfFacts> {
|
||||
let entity = *b.index.0.get(&b.self_guid.0?)?;
|
||||
let store = b.stores.get(entity).ok()?;
|
||||
Some(SelfFacts {
|
||||
race: store.0.unit_race()?,
|
||||
class: store.0.unit_class()?,
|
||||
female: store.0.unit_gender() == Some(1),
|
||||
})
|
||||
}
|
||||
|
||||
/// The chat lines read the deltas against the store, so they must run before the overwrite. The
|
||||
/// "increased by" line comes first, then the rank lines, as the reference's entry loop orders them
|
||||
/// (`0x4d57f5`, `0x4d5896`).
|
||||
fn on_reputation_delta(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut b: Bridge,
|
||||
@@ -294,6 +333,21 @@ fn on_reputation_delta(
|
||||
factions.as_deref(),
|
||||
&mut chat_log,
|
||||
);
|
||||
if let (Some(f), Some(me)) = (factions.as_deref(), self_facts(&b)) {
|
||||
let lines = crate::ui_reputation::delta_lines(
|
||||
&standings,
|
||||
&b.reputations,
|
||||
f.catalog(),
|
||||
me.race,
|
||||
me.class,
|
||||
);
|
||||
crate::ui_reputation::queue_standing_lines(
|
||||
lines,
|
||||
f.catalog(),
|
||||
me.female,
|
||||
&mut chat_log,
|
||||
);
|
||||
}
|
||||
reputation_delta(standings, &mut b.reputations, &mut quest);
|
||||
}
|
||||
}
|
||||
@@ -699,3 +753,6 @@ fn packet_dropped(opcode: u16, unparseable: bool, dropped: &mut DroppedOpcodes)
|
||||
tally.unknown += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod reputation_tests;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! The chat line "You are now <rank> with <faction>." end to end: the wire handlers queue it
|
||||
//! against the store as it stood, and the feed prints it as `CHAT_MSG_SYSTEM` from the VM's own
|
||||
//! `GlobalStrings.lua` (`0x62c670`). A first contact is `SMSG_SET_FACTION_VISIBLE` (vmangos
|
||||
//! `ReputationMgr.cpp:298`); a rank change rides `SMSG_SET_FACTION_STANDING`.
|
||||
|
||||
use benilla_protocol::SessionEvent;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{on_reputation_delta, on_reputations};
|
||||
use crate::net::{ObjectStore, Reputations, SelfGuid, SelfPlayer};
|
||||
use benilla_protocol::ObjectFields;
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
const ME: u64 = 0x42;
|
||||
/// `UNIT_FIELD_BYTES_0` of a male human warrior.
|
||||
const HUMAN_WARRIOR: u32 = 1 | (1 << 8);
|
||||
|
||||
/// The handlers and the feed over a logged-in human warrior who has met nothing, on the real
|
||||
/// DBC; `None` without client data. The second value is Stormwind's wire slot.
|
||||
fn app() -> Option<(App, u32)> {
|
||||
let data = benilla_formats::wow_data_or_skip!(None);
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let catalog = benilla_formats::load_faction_catalog(&mut chain).expect("factions");
|
||||
let slot = u32::try_from(catalog.reputation_faction(72)?.rep_index).ok()?;
|
||||
|
||||
let script = UiScript::new().expect("VM");
|
||||
script
|
||||
.run(
|
||||
r#"FACTION_STANDING_CHANGED = "You are now %s with %s."
|
||||
FACTION_STANDING_LABEL4 = "Neutral"
|
||||
FACTION_STANDING_LABEL5 = "Friendly"
|
||||
FACTION_STANDING_LABEL6 = "Honored""#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut app = App::new();
|
||||
app.init_resource::<crate::net::GuidIndex>()
|
||||
.insert_resource(SelfGuid(Some(ME)))
|
||||
.init_resource::<crate::net::NetStatus>()
|
||||
.init_resource::<crate::names::NameCache>()
|
||||
.init_resource::<crate::items::Items>()
|
||||
.init_resource::<crate::spell::Cooldowns>()
|
||||
.init_resource::<Reputations>()
|
||||
.init_resource::<crate::net::ServerTime>()
|
||||
.init_resource::<crate::net::ServerWallClock>()
|
||||
.init_resource::<crate::net::HomeBind>()
|
||||
.init_resource::<crate::net::Proficiencies>()
|
||||
.init_resource::<crate::net::DroppedOpcodes>()
|
||||
.init_resource::<crate::ui_quest::QuestGiver>()
|
||||
.init_resource::<crate::ui_chat::ChatLog>()
|
||||
.insert_resource(crate::target::Factions::from_catalog(catalog))
|
||||
.insert_resource(crate::net::NetCommands(crossbeam_channel::unbounded().0))
|
||||
.insert_non_send_resource(script);
|
||||
let me = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
SelfPlayer,
|
||||
ObjectStore(ObjectFields::from_pairs(&[(36, HUMAN_WARRIOR)])),
|
||||
))
|
||||
.id();
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::net::GuidIndex>()
|
||||
.0
|
||||
.insert(ME, me);
|
||||
Some((app, slot))
|
||||
}
|
||||
|
||||
/// Run one packet's handler as the bridge runs it, in wire order; nothing drains the chat log.
|
||||
fn send(app: &mut App, ev: SessionEvent) {
|
||||
// The handler `register` binds to the event's kind.
|
||||
match ev {
|
||||
SessionEvent::ReputationVisible { .. } => {
|
||||
app.world_mut()
|
||||
.run_system_cached_with(on_reputations, ev)
|
||||
.unwrap();
|
||||
}
|
||||
SessionEvent::ReputationDelta { .. } => {
|
||||
app.world_mut()
|
||||
.run_system_cached_with(on_reputation_delta, ev)
|
||||
.unwrap();
|
||||
}
|
||||
_ => panic!("not a reputation event"),
|
||||
}
|
||||
}
|
||||
|
||||
fn chat(app: &App) -> &crate::ui_chat::ChatLog {
|
||||
app.world().resource::<crate::ui_chat::ChatLog>()
|
||||
}
|
||||
|
||||
fn visible(app: &mut App, slot: u32) {
|
||||
send(app, SessionEvent::ReputationVisible { list_id: slot });
|
||||
}
|
||||
|
||||
fn delta(app: &mut App, slot: u32, standing: i32) {
|
||||
send(
|
||||
app,
|
||||
SessionEvent::ReputationDelta {
|
||||
standings: vec![(slot, standing)],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn xp_gain(app: &mut App) {
|
||||
let xp = benilla_protocol::messages::XpGain {
|
||||
victim: 0,
|
||||
total: 100,
|
||||
base: 100,
|
||||
kill: false,
|
||||
};
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::ui_chat::ChatLog>()
|
||||
.push_xp_gain(&xp);
|
||||
}
|
||||
|
||||
fn rank_lines(app: &App) -> Vec<(String, u8, bool)> {
|
||||
chat(app).pending_standing()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_contact_a_rank_change_and_a_repeat_through_the_wire_handlers() {
|
||||
let Some((mut app, slot)) = app() else {
|
||||
return;
|
||||
};
|
||||
// First contact (what vmangos sends on meeting a faction): the human's base 4000 reads
|
||||
// Friendly, and the line names it.
|
||||
visible(&mut app, slot);
|
||||
assert_eq!(rank_lines(&app), [("Stormwind".to_string(), 4, false)]);
|
||||
// The same packet again is the dedup return: the slot is already listed.
|
||||
visible(&mut app, slot);
|
||||
assert_eq!(rank_lines(&app).len(), 1, "already listed prints nothing");
|
||||
|
||||
// 4000 → 4100: still Friendly.
|
||||
delta(&mut app, slot, 100);
|
||||
assert_eq!(rank_lines(&app).len(), 1, "no rank change, no line");
|
||||
// 4100 → 9100: Honored, a rank change.
|
||||
delta(&mut app, slot, 5100);
|
||||
assert_eq!(
|
||||
rank_lines(&app).last(),
|
||||
Some(&("Stormwind".to_string(), 5, false))
|
||||
);
|
||||
assert_eq!(rank_lines(&app).len(), 2);
|
||||
}
|
||||
|
||||
/// A faction a standing packet reveals prints once, whatever else the rank did.
|
||||
#[test]
|
||||
fn the_standing_packet_that_lists_a_faction_prints_it_once() {
|
||||
let Some((mut app, slot)) = app() else {
|
||||
return;
|
||||
};
|
||||
delta(&mut app, slot, 5100);
|
||||
assert_eq!(
|
||||
rank_lines(&app),
|
||||
[("Stormwind".to_string(), 5, false)],
|
||||
"one line, the rank after the gain, though the rank also moved"
|
||||
);
|
||||
}
|
||||
|
||||
/// The lines keep wire order against the rest of the chat: a standing packet prints "increased
|
||||
/// by" (`0x4d6330`), then the rank (`0x4d5896`), and an XP award that arrives after it in the same
|
||||
/// batch prints behind both. On first contact the VISIBLE packet's rank line leads the state's
|
||||
/// "increased by" (vmangos `ReputationMgr.cpp:264-266`).
|
||||
#[test]
|
||||
fn the_rank_line_keeps_its_place_in_the_packet_batch() {
|
||||
let Some((mut app, slot)) = app() else {
|
||||
return;
|
||||
};
|
||||
delta(&mut app, slot, 5100);
|
||||
xp_gain(&mut app);
|
||||
// A rank-moving standing packet for a faction already listed.
|
||||
assert_eq!(chat(&app).pending_kinds(), ["combat", "standing", "xp"]);
|
||||
|
||||
let Some((mut app, slot)) = self::app() else {
|
||||
return;
|
||||
};
|
||||
visible(&mut app, slot);
|
||||
delta(&mut app, slot, 100);
|
||||
assert_eq!(chat(&app).pending_kinds(), ["standing", "combat"]);
|
||||
}
|
||||
|
||||
/// The wire handlers carry the player's real gender to the line.
|
||||
#[test]
|
||||
fn a_female_player_asks_for_the_female_label() {
|
||||
let Some((mut app, slot)) = app() else {
|
||||
return;
|
||||
};
|
||||
// `UNIT_FIELD_BYTES_0` byte 2 is the gender; a drunk female is still female.
|
||||
let me = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<SelfPlayer>>()
|
||||
.single(app.world())
|
||||
.unwrap();
|
||||
app.world_mut()
|
||||
.entity_mut(me)
|
||||
.insert(ObjectStore(ObjectFields::from_pairs(&[(
|
||||
36,
|
||||
HUMAN_WARRIOR | (1 << 16),
|
||||
)])));
|
||||
visible(&mut app, slot);
|
||||
assert_eq!(rank_lines(&app), [("Stormwind".to_string(), 4, true)]);
|
||||
}
|
||||
@@ -12,20 +12,30 @@
|
||||
//! ([`GreetSeq`], `[0xc4d970]`, reset when the greeted NPC changes) steps `0x623910`
|
||||
//! ([`greet_line`]): five hello takes, then the pissed kit's takes in order, then a wrap.
|
||||
//!
|
||||
//! Gates stand ahead of the select play, in the reference's order. The unit's channel latch comes
|
||||
//! first (`0x60c28c`), then a dead unit (`0x60c2a5`). The reaction pair (`0x6061e0` both ways,
|
||||
//! each at least neutral, `0x60c2d9`-`0x60c2f2`) is between the unit and the active mover, the
|
||||
//! object at `[0xc4da98]` that only `SetActiveMover` writes (`0x6006e0`), so a possessed creature
|
||||
//! or an Eye of Kilrogg stands in for the player there; none streamed bails. The dead or ghost
|
||||
//! gate (`0x605f30` at `0x60c31d`) is on the local player, unresolved or not. Only a click that
|
||||
//! passes all of them re-tracks the NPC (`0x60c326`) and resets the sequence.
|
||||
//!
|
||||
//! ## The window hello and goodbye (`0x60c3b0`, right-click)
|
||||
//!
|
||||
//! Opening any interaction window (`0x4930d0`, 14 call sites, none a mouseover) plays the NPC's
|
||||
//! hello once with a free-picked take (`0x60c464`), the same `+0x4` slot. Closing to nothing plays
|
||||
//! the goodbye (`+0x8`). A swap to another NPC's window plays the new hello and suppresses the old
|
||||
//! goodbye (`0x4930fd`: `dl = (new_guid != 0)`). The active NPC is the union of the window sessions
|
||||
//! ([`MerchantOpen`], [`GossipState`], [`QuestGiver`], [`TrainerOpen`]).
|
||||
//! ([`MerchantOpen`], [`GossipState`], [`QuestGiver`], [`TrainerOpen`]). Every such call also zeroes
|
||||
//! the select greeting's tracked NPC (`0x60c3d1`), so the next left-click starts its sequence over.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_formats::NpcGreetingCatalog;
|
||||
use benilla_protocol::EntityKind;
|
||||
|
||||
use crate::net::{GuidIndex, NetEntity, ObjectStore};
|
||||
use crate::net::{ActiveMover, GuidIndex, NetEntity, ObjectStore, Reputations, SelfPlayer};
|
||||
use crate::target::{both_reactions_at_least_neutral, Factions};
|
||||
use crate::ui_gossip::GossipState;
|
||||
use crate::ui_merchant::MerchantOpen;
|
||||
use crate::ui_quest::QuestGiver;
|
||||
@@ -52,7 +62,7 @@ struct NpcGreetings(NpcGreetingCatalog);
|
||||
|
||||
/// The select greeting's counter `[0xc4d970]`, keyed by the tracked NPC `[0xc4d908]` and reset
|
||||
/// when it changes; it advances only on a play that started (`0x60c28c`).
|
||||
#[derive(Default)]
|
||||
#[derive(Resource, Default)]
|
||||
struct GreetSeq {
|
||||
tracked: Option<Entity>,
|
||||
seq: usize,
|
||||
@@ -149,6 +159,50 @@ fn resolve(
|
||||
Some((row, transform.translation))
|
||||
}
|
||||
|
||||
/// The local player may hear an NPC: streamed, and neither dead nor a ghost (`0x605f30`). The gate
|
||||
/// of the select greeting (`0x60c31d`) and of the window hello and goodbye (`0x60c401`).
|
||||
fn player_may_hear(me: Option<&ObjectStore>) -> bool {
|
||||
me.is_some_and(|s| !s.0.is_dead_or_ghost())
|
||||
}
|
||||
|
||||
/// The select greeting's two gates (`0x60c270`): both reaction directions between the unit and the
|
||||
/// active mover at least neutral (`0x60c2d9`, `0x60c2ef`), none streamed bailing (`0x60c2d0`), then
|
||||
/// [`player_may_hear`] on the local player (`0x60c31d`).
|
||||
fn select_greeting_allowed(
|
||||
factions: Option<&Factions>,
|
||||
reputations: &Reputations,
|
||||
npc: Option<&ObjectStore>,
|
||||
mover: Option<&ObjectStore>,
|
||||
me: Option<&ObjectStore>,
|
||||
) -> bool {
|
||||
mover.is_some()
|
||||
&& both_reactions_at_least_neutral(factions, reputations, npc, mover)
|
||||
&& player_may_hear(me)
|
||||
}
|
||||
|
||||
/// The select greeting's gates and re-track, in the reference's order (`0x60c28c`-`0x60c33e`): a
|
||||
/// sounding line (the latch) or a dead unit ends the click, then [`select_greeting_allowed`]'s
|
||||
/// verdict, and only a click that passes all three tracks the NPC, resetting the sequence when it
|
||||
/// is a different one. `true` means go on to pick the line.
|
||||
fn begin_select(seq: &mut GreetSeq, npc: Entity, latched: bool, dead: bool, allowed: bool) -> bool {
|
||||
if latched || dead || !allowed {
|
||||
return false;
|
||||
}
|
||||
if seq.tracked != Some(npc) {
|
||||
*seq = GreetSeq {
|
||||
tracked: Some(npc),
|
||||
seq: 0,
|
||||
};
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A window hello or goodbye forgets the tracked NPC (`0x60c3d1`, ahead of every gate after the
|
||||
/// unit-type check), so the next click greeting starts over wherever it lands.
|
||||
fn forget_tracked(seq: &mut GreetSeq) {
|
||||
seq.tracked = None;
|
||||
}
|
||||
|
||||
/// Plays one line, kit 0 being none and `None` a free-picked take, gated on the unit's channel
|
||||
/// latch (`0x60c28c`).
|
||||
fn play_line(
|
||||
@@ -189,8 +243,12 @@ fn play_line(
|
||||
/// The select greeting.
|
||||
fn play_select_greetings(
|
||||
mut reqs: MessageReader<NpcGreetingRequest>,
|
||||
mut seq: Local<GreetSeq>,
|
||||
mut seq: ResMut<GreetSeq>,
|
||||
units: Query<(&NetEntity, &Transform, Option<&ObjectStore>)>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mover: Query<&ObjectStore, With<ActiveMover>>,
|
||||
factions: Option<Res<Factions>>,
|
||||
reputations: Res<Reputations>,
|
||||
greetings: Option<Res<NpcGreetings>>,
|
||||
kits: Option<ResMut<SoundKits>>,
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
@@ -208,21 +266,33 @@ fn play_select_greetings(
|
||||
let listener = cam.single().map(|t| t.translation).unwrap_or(Vec3::ZERO);
|
||||
|
||||
for req in reqs.read() {
|
||||
let Some((row, pos)) = resolve(req.npc, &units, &greetings) else {
|
||||
let Ok((net, transform, npc_store)) = units.get(req.npc) else {
|
||||
continue;
|
||||
};
|
||||
// A different NPC resets the sequence counter (`0x60c326`).
|
||||
if seq.tracked != Some(req.npc) {
|
||||
*seq = GreetSeq {
|
||||
tracked: Some(req.npc),
|
||||
seq: 0,
|
||||
};
|
||||
}
|
||||
// The latch gate precedes the counter: a re-click while the line sounds neither plays nor
|
||||
// advances (`0x60c28c`).
|
||||
if kit::source_playing(&out, req.npc) {
|
||||
if !matches!(net.kind, EntityKind::Unit) {
|
||||
continue;
|
||||
}
|
||||
let allowed = select_greeting_allowed(
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
npc_store,
|
||||
mover.single().ok(),
|
||||
self_store.single().ok(),
|
||||
);
|
||||
let dead = npc_store.is_some_and(|s| s.0.unit_is_dead());
|
||||
let latched = kit::source_playing(&out, req.npc);
|
||||
if !begin_select(&mut seq, req.npc, latched, dead, allowed) {
|
||||
continue;
|
||||
}
|
||||
// A unit with no greeting row still took the track above; `0x623910` answers -1 for it.
|
||||
let Some(row) = net
|
||||
.display_id
|
||||
.and_then(|d| greetings.0.for_display(d))
|
||||
.copied()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let pos = transform.translation;
|
||||
let (line, next) = greet_line(seq.seq, kits.variations(row.pissed));
|
||||
let (kit_id, variant) = match line {
|
||||
GreetLine::Hello => (row.hello, None),
|
||||
@@ -250,7 +320,9 @@ fn play_window_greetings(
|
||||
trainer: Res<TrainerOpen>,
|
||||
index: Res<GuidIndex>,
|
||||
mut prev_active: Local<Option<u64>>,
|
||||
mut select_seq: ResMut<GreetSeq>,
|
||||
units: Query<(&NetEntity, &Transform, Option<&ObjectStore>)>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
greetings: Option<Res<NpcGreetings>>,
|
||||
kits: Option<ResMut<SoundKits>>,
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
@@ -279,6 +351,17 @@ fn play_window_greetings(
|
||||
let Some(&npc) = index.0.get(&guid) else {
|
||||
return;
|
||||
};
|
||||
// The call zeroes the select greeting's tracked NPC for any unit, before the gates below.
|
||||
if units
|
||||
.get(npc)
|
||||
.is_ok_and(|(net, ..)| matches!(net.kind, EntityKind::Unit))
|
||||
{
|
||||
forget_tracked(&mut select_seq);
|
||||
}
|
||||
// No reaction gate here (`0x60c3b0`), only the player's: dead or a ghost hears nothing.
|
||||
if !player_may_hear(self_store.single().ok()) {
|
||||
return;
|
||||
}
|
||||
let Some((row, pos)) = resolve(npc, &units, &greetings) else {
|
||||
return;
|
||||
};
|
||||
@@ -299,12 +382,14 @@ fn stop_on_despawn(mut removed: RemovedComponents<NetEntity>, mut out: NonSendMu
|
||||
/// Registration hook for [`super::SoundPlugin`].
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.add_message::<NpcGreetingRequest>()
|
||||
.init_resource::<GreetSeq>()
|
||||
.add_systems(Startup, load_npc_greetings.after(AssetSet::Open))
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
play_select_greetings,
|
||||
play_window_greetings,
|
||||
// Both write the tracked NPC; a click and a window in one frame take it in turn.
|
||||
play_window_greetings.after(play_select_greetings),
|
||||
stop_on_despawn,
|
||||
)
|
||||
.in_set(WorldStage::Present),
|
||||
@@ -373,4 +458,148 @@ mod tests {
|
||||
"gossip→trainer on one NPC is a silent swap — no goodbye",
|
||||
);
|
||||
}
|
||||
|
||||
use benilla_protocol::field::{
|
||||
FIELD_PLAYER_FLAGS, FIELD_UNIT_FACTIONTEMPLATE, FIELD_UNIT_FLAGS, FIELD_UNIT_HEALTH,
|
||||
FIELD_UNIT_MAXHEALTH,
|
||||
};
|
||||
use benilla_protocol::ObjectFields;
|
||||
|
||||
/// A player-controlled player on faction template 1 (Human), with the given health and
|
||||
/// `PLAYER_FLAGS`.
|
||||
fn player(health: u32, player_flags: u32) -> ObjectStore {
|
||||
ObjectStore(ObjectFields::from_pairs(&[
|
||||
(FIELD_UNIT_FACTIONTEMPLATE, 1),
|
||||
(FIELD_UNIT_FLAGS, 1 << 3),
|
||||
(FIELD_UNIT_HEALTH, health),
|
||||
(FIELD_UNIT_MAXHEALTH, 100),
|
||||
(FIELD_PLAYER_FLAGS, player_flags),
|
||||
]))
|
||||
}
|
||||
|
||||
/// `0x605f30` over the local player gates both the select greeting (`0x60c31d`) and the window
|
||||
/// hello and goodbye (`0x60c401`): a corpse (health 0) and a ghost (health 1, the flag) are
|
||||
/// silent, and so is a player that has not streamed in (`0x60c315`).
|
||||
#[test]
|
||||
fn a_dead_or_ghost_or_absent_player_hears_nothing() {
|
||||
assert!(player_may_hear(Some(&player(100, 0))), "alive hears");
|
||||
assert!(!player_may_hear(Some(&player(0, 0))), "a corpse does not");
|
||||
assert!(!player_may_hear(Some(&player(1, 0x10))), "nor a ghost");
|
||||
assert!(!player_may_hear(None), "nor an unstreamed player");
|
||||
}
|
||||
|
||||
/// The select greeting's reaction gate (`0x60c2d9`-`0x60c2f2`) on the real templates: Monster
|
||||
/// (14) is hostile to a Human, a Chicken (31) is neutral, and neutral passes (`cmp eax,3` /
|
||||
/// `jl`). A dead player is refused whatever the reactions.
|
||||
#[test]
|
||||
fn the_select_greeting_refuses_a_hostile_unit_and_a_dead_player() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let factions =
|
||||
Factions::from_catalog(benilla_formats::load_faction_catalog(&mut chain).expect("dbc"));
|
||||
let reps = Reputations::default();
|
||||
let unit = |template: u32| {
|
||||
ObjectStore(ObjectFields::from_pairs(&[(
|
||||
FIELD_UNIT_FACTIONTEMPLATE,
|
||||
template,
|
||||
)]))
|
||||
};
|
||||
let allowed = |npc: &ObjectStore, mover: Option<&ObjectStore>, me: &ObjectStore| {
|
||||
select_greeting_allowed(Some(&factions), &reps, Some(npc), mover, Some(me))
|
||||
};
|
||||
let me = player(100, 0);
|
||||
assert!(
|
||||
!allowed(&unit(14), Some(&me), &me),
|
||||
"a hostile monster is silent"
|
||||
);
|
||||
assert!(
|
||||
allowed(&unit(31), Some(&me), &me),
|
||||
"a neutral critter greets"
|
||||
);
|
||||
assert!(
|
||||
!allowed(&unit(31), Some(&me), &player(0, 0)),
|
||||
"a dead player hears no greeting"
|
||||
);
|
||||
assert!(
|
||||
!allowed(&unit(31), Some(&me), &player(1, 0x10)),
|
||||
"nor does a ghost"
|
||||
);
|
||||
|
||||
// The reaction pair is with the active mover, not the player: a monster hostile to the
|
||||
// player greets the creature we possess, which is its own kind, and a friend of the player
|
||||
// is refused when the mover is hostile to it. No mover streamed, no greeting.
|
||||
let possessed = unit(14);
|
||||
assert!(allowed(&unit(14), Some(&possessed), &me));
|
||||
assert!(!allowed(&unit(14), None, &me));
|
||||
}
|
||||
|
||||
/// The pair is both directions: with no catalog every faction leg reads neutral, so only a rung
|
||||
/// ahead of them (two free-for-all players, hostile both ways) is refused.
|
||||
#[test]
|
||||
fn the_reaction_gate_is_the_pair_not_one_direction() {
|
||||
let reps = Reputations::default();
|
||||
let ffa = ObjectStore(ObjectFields::from_pairs(&[
|
||||
(FIELD_UNIT_FLAGS, 1 << 3),
|
||||
(FIELD_PLAYER_FLAGS, 0x80),
|
||||
]));
|
||||
let other = ObjectStore(ObjectFields::from_pairs(&[
|
||||
(FIELD_UNIT_FLAGS, 1 << 3),
|
||||
(FIELD_PLAYER_FLAGS, 0x80),
|
||||
]));
|
||||
assert!(!select_greeting_allowed(
|
||||
None,
|
||||
&reps,
|
||||
Some(&other),
|
||||
Some(&ffa),
|
||||
Some(&ffa)
|
||||
));
|
||||
let calm = player(100, 0);
|
||||
assert!(select_greeting_allowed(
|
||||
None,
|
||||
&reps,
|
||||
Some(&calm),
|
||||
Some(&calm),
|
||||
Some(&calm)
|
||||
));
|
||||
}
|
||||
|
||||
fn two_npcs() -> (Entity, Entity) {
|
||||
let mut world = World::new();
|
||||
(world.spawn_empty().id(), world.spawn_empty().id())
|
||||
}
|
||||
|
||||
/// The latch is the first gate (`0x60c28c`), ahead of the re-track and reset (`0x60c33e`): a
|
||||
/// click on A while A's line sounds changes nothing, so B's sequence carries on. Every gate
|
||||
/// that refuses leaves the track alone, and only a passing click on a new NPC resets.
|
||||
#[test]
|
||||
fn a_refused_click_leaves_the_tracked_npc_and_its_sequence_alone() {
|
||||
let (a, b) = two_npcs();
|
||||
let mut seq = GreetSeq {
|
||||
tracked: Some(b),
|
||||
seq: 3,
|
||||
};
|
||||
assert!(!begin_select(&mut seq, a, true, false, true), "latched");
|
||||
assert!(!begin_select(&mut seq, a, false, true, true), "dead");
|
||||
assert!(!begin_select(&mut seq, a, false, false, false), "refused");
|
||||
assert_eq!((seq.tracked, seq.seq), (Some(b), 3), "B continues");
|
||||
|
||||
// The same NPC again keeps its count; a different one starts over.
|
||||
assert!(begin_select(&mut seq, b, false, false, true));
|
||||
assert_eq!(seq.seq, 3);
|
||||
assert!(begin_select(&mut seq, a, false, false, true));
|
||||
assert_eq!((seq.tracked, seq.seq), (Some(a), 0));
|
||||
}
|
||||
|
||||
/// A window hello or goodbye zeroes the tracked NPC (`0x60c3d1`): left-click three times, open
|
||||
/// the window and close it, and the next left-click on the same NPC is the first hello again.
|
||||
#[test]
|
||||
fn a_window_greeting_restarts_the_click_sequence() {
|
||||
let (a, _) = two_npcs();
|
||||
let mut seq = GreetSeq::default();
|
||||
assert!(begin_select(&mut seq, a, false, false, true));
|
||||
seq.seq = 3;
|
||||
forget_tracked(&mut seq);
|
||||
assert!(begin_select(&mut seq, a, false, false, true));
|
||||
assert_eq!(seq.seq, 0, "the sequence starts over");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,10 @@ pub(crate) use by_name::{AssistRequest, PlayerLookup, TargetByNameRequest};
|
||||
pub(crate) use by_name::SelectCommit;
|
||||
// The reaction decode and its faction catalog, which also tint the target frame
|
||||
// (`TargetFrame_CheckFaction`); `duel_rung` is the same walk, for `/reaction`.
|
||||
pub(crate) use ring::{duel_rung, ring_reaction, selection_variant, Factions, RingVariant};
|
||||
pub(crate) use ring::{
|
||||
both_reactions_at_least_neutral, duel_rung, ring_reaction, selection_variant, Factions,
|
||||
RingVariant,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use ring::{ring_variant, PlayerPath, SelectorInput};
|
||||
|
||||
|
||||
@@ -625,6 +625,19 @@ pub(crate) fn can_interact_from_player(
|
||||
if target.0.unit_flags() & UNIT_FLAG_NOT_SELECTABLE != 0 || target.0.unit_npc_flags() == 0 {
|
||||
return false;
|
||||
}
|
||||
both_reactions_at_least_neutral(factions, reputations, target_store, self_store)
|
||||
}
|
||||
|
||||
/// Both `UnitReaction` directions between a unit and the local player are neutral or better: the
|
||||
/// unit's toward the player ([`ring_reaction`], `this` the unit) and the player's toward the unit
|
||||
/// ([`reaction_from_player`]), each `>= 3` on the internal scale. The test of `CanInteract`
|
||||
/// (`0x606847`, `0x606854`) and of the select greeting (`0x60c2d9`–`0x60c2f2`).
|
||||
pub(crate) fn both_reactions_at_least_neutral(
|
||||
factions: Option<&Factions>,
|
||||
reputations: &Reputations,
|
||||
target_store: Option<&ObjectStore>,
|
||||
self_store: Option<&ObjectStore>,
|
||||
) -> bool {
|
||||
const NEUTRAL: u8 = Reaction::Neutral as u8;
|
||||
ring_reaction(factions, reputations, target_store, self_store) >= NEUTRAL
|
||||
&& reaction_from_player(factions, reputations, target_store, self_store) >= NEUTRAL
|
||||
|
||||
@@ -98,6 +98,13 @@ enum Pending {
|
||||
},
|
||||
/// An area discovery (`SMSG_EXPLORATION_EXPERIENCE`), fired at the drain, which holds the VM.
|
||||
Discovery { area: String, xp: u32 },
|
||||
/// "You are now <rank> with <faction>." (`0x62c670`), parked by the wire handler in packet
|
||||
/// order and resolved against the VM's strings at the drain.
|
||||
Standing {
|
||||
faction: String,
|
||||
rank: u8,
|
||||
female: bool,
|
||||
},
|
||||
/// One combat-log line, classified at the packet, waiting on its two endpoint names; the
|
||||
/// reference's deferred queue (`0xc4e208`) waits only on an item name.
|
||||
Combat(Box<super::combat::PendingCombat>),
|
||||
@@ -312,11 +319,57 @@ impl ChatLog {
|
||||
});
|
||||
}
|
||||
|
||||
/// Queue a reputation rank line (`FACTION_STANDING_CHANGED`) for the drain to fill.
|
||||
pub(crate) fn push_standing(&mut self, faction: &str, rank: u8, female: bool) {
|
||||
self.pending.push(Pending::Standing {
|
||||
faction: faction.to_string(),
|
||||
rank,
|
||||
female,
|
||||
});
|
||||
}
|
||||
|
||||
/// Drop every pending item on disconnect.
|
||||
pub(crate) fn clear_session(&mut self) {
|
||||
self.pending.clear();
|
||||
}
|
||||
|
||||
/// What is parked, in order, by kind: the order a packet batch left the lines in.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn pending_kinds(&self) -> Vec<&'static str> {
|
||||
self.pending
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Pending::Wire { .. } => "wire",
|
||||
Pending::Notice { .. } => "notice",
|
||||
Pending::Addon { .. } => "addon",
|
||||
Pending::Roll { .. } => "roll",
|
||||
Pending::XpGain { .. } => "xp",
|
||||
Pending::HonorGain { .. } => "honor",
|
||||
Pending::TextEmote { .. } => "emote",
|
||||
Pending::Discovery { .. } => "discovery",
|
||||
Pending::Standing { .. } => "standing",
|
||||
Pending::Combat(_) => "combat",
|
||||
Pending::Event(_) => "event",
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The parked rank lines as `(faction, rank, female)`, in order.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn pending_standing(&self) -> Vec<(String, u8, bool)> {
|
||||
self.pending
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Pending::Standing {
|
||||
faction,
|
||||
rank,
|
||||
female,
|
||||
} => Some((faction.clone(), *rank, *female)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The parked addon lines as `(prefix, message, distribution)`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn pending_addons(&self) -> Vec<(String, String, String)> {
|
||||
@@ -1076,6 +1129,21 @@ pub(super) fn feed_chat(
|
||||
);
|
||||
}
|
||||
}
|
||||
Pending::Standing {
|
||||
faction,
|
||||
rank,
|
||||
female,
|
||||
} => {
|
||||
let line =
|
||||
crate::ui_reputation::standing_text(&globals(&script), rank, female, &faction);
|
||||
if let Some(text) = line {
|
||||
route(
|
||||
&mut script,
|
||||
&mut windows,
|
||||
&ChatEvent::text_only(ChatEventKind::System, text),
|
||||
);
|
||||
}
|
||||
}
|
||||
Pending::Discovery { area, xp } => {
|
||||
// The toast fires every time; the chat line only rides XP.
|
||||
let (toast, line) = {
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
//! stores `standing - BaseRep` (`ReputationMgr.cpp:261`) and reports the sum
|
||||
//! (`ReputationMgr.cpp:82`). The rank is [`benilla_formats::reputation_rank`], shared with the
|
||||
//! unit-reaction decode, which caps it at 6, Revered (`0x606439`); the pane shows it uncapped.
|
||||
//!
|
||||
//! The chat line "You are now <rank> with <faction>." (`FACTION_STANDING_CHANGED`, `0x62c670`) is
|
||||
//! owed by three events: a faction newly listed by `SMSG_SET_FACTION_VISIBLE` (`0x4d5733`), a
|
||||
//! faction listed by the first `SMSG_SET_FACTION_STANDING` that names it (`0x4d5826`), and a
|
||||
//! standing change that moves the rank (`0x4d5896`) unless the faction is `HIDDEN`. The wire
|
||||
//! handlers decide the lines ([`visible_line`], [`delta_lines`]) and park them in the chat log in
|
||||
//! wire order ([`queue_standing_lines`]); the chat feed resolves their strings against the VM,
|
||||
//! which the wire drain cannot reach.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -18,6 +26,7 @@ use benilla_ui::script::{FactionEntry, ReputationSend, ReputationState, UiScript
|
||||
|
||||
use crate::net::{ClientCommand, NetCommands, ObjectStore, Reputations, SelfPlayer};
|
||||
use crate::target::Factions;
|
||||
use crate::ui_chat::ChatLog;
|
||||
use crate::ui_script::{UiFeed, UiInput};
|
||||
|
||||
/// Rank `r` spans `RANK_BOUNDS[r]..RANK_BOUNDS[r + 1]`, the client's table at `0x80928c`: vmangos's
|
||||
@@ -40,6 +49,131 @@ impl Plugin for UiReputationPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// One `FACTION_STANDING_CHANGED` line owed: the faction and the rank it names (`0x62c670`'s
|
||||
/// `ecx` and `edx`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct StandingLine {
|
||||
pub(crate) faction_id: u32,
|
||||
/// The rank, 0 Hated to 7 Exalted; the label is `FACTION_STANDING_LABEL<rank + 1>`.
|
||||
pub(crate) rank: u8,
|
||||
}
|
||||
|
||||
/// The faction a wire slot names, with its row, when the player's race and class list it.
|
||||
fn faction_of_slot(
|
||||
catalog: &benilla_formats::FactionCatalog,
|
||||
list_id: u32,
|
||||
race: u8,
|
||||
class: u8,
|
||||
) -> Option<(u32, &benilla_formats::FactionInfo)> {
|
||||
let index = i32::try_from(list_id).ok()?;
|
||||
let (id, info) = catalog
|
||||
.reputation_factions()
|
||||
.find(|(_, f)| f.rep_index == index)?;
|
||||
info.slot_for(race, class)?;
|
||||
Some((id, info))
|
||||
}
|
||||
|
||||
/// `SMSG_SET_FACTION_VISIBLE` (`0x4d5710`): a slot not yet `VISIBLE` is listed by `0x4d5a70` with
|
||||
/// the print flag set, which names its rank (`0x4d5bfb`); one already `VISIBLE` returns at
|
||||
/// `0x4d5731` and prints nothing. `store` is the state before the packet applies. A header slot
|
||||
/// that a listed child already put in the client's list is a dedup there (`0x4d5a93`); vmangos
|
||||
/// never sends one (`ReputationMgr.cpp:177`, `SetVisible` refuses flag `0x08`), so it is not
|
||||
/// modelled.
|
||||
pub(crate) fn visible_line(
|
||||
list_id: u32,
|
||||
store: &Reputations,
|
||||
catalog: &benilla_formats::FactionCatalog,
|
||||
race: u8,
|
||||
class: u8,
|
||||
) -> Option<StandingLine> {
|
||||
use benilla_formats::faction_flags as flag;
|
||||
let (flags, wire) = store.0.get(list_id as usize).copied().unwrap_or((0, 0));
|
||||
if flags & flag::VISIBLE != 0 {
|
||||
return None;
|
||||
}
|
||||
let (faction_id, info) = faction_of_slot(catalog, list_id, race, class)?;
|
||||
Some(StandingLine {
|
||||
faction_id,
|
||||
rank: benilla_formats::reputation_rank(info.base_for(race, class) + wire),
|
||||
})
|
||||
}
|
||||
|
||||
/// `SMSG_SET_FACTION_STANDING` (`0x4d5760`), per entry against the state before it applies. A
|
||||
/// slot neither `VISIBLE` nor `HIDDEN` is listed first (`0x4d5815`-`0x4d5826`) and that print is
|
||||
/// the entry's only line (`0x4d588b`); otherwise a changed rank prints unless the slot is `HIDDEN`
|
||||
/// (`0x4d5880`-`0x4d5896`). Both name the rank after the new standing.
|
||||
pub(crate) fn delta_lines(
|
||||
deltas: &[(u32, i32)],
|
||||
store: &Reputations,
|
||||
catalog: &benilla_formats::FactionCatalog,
|
||||
race: u8,
|
||||
class: u8,
|
||||
) -> Vec<StandingLine> {
|
||||
use benilla_formats::faction_flags as flag;
|
||||
let mut lines = Vec::new();
|
||||
for &(list_id, standing) in deltas {
|
||||
let Some((faction_id, info)) = faction_of_slot(catalog, list_id, race, class) else {
|
||||
continue;
|
||||
};
|
||||
let base = info.base_for(race, class);
|
||||
let (flags, old) = store.0.get(list_id as usize).copied().unwrap_or((0, 0));
|
||||
let (old_rank, rank) = (
|
||||
benilla_formats::reputation_rank(base + old),
|
||||
benilla_formats::reputation_rank(base + standing),
|
||||
);
|
||||
let listed_now = flags & (flag::VISIBLE | flag::HIDDEN) == 0;
|
||||
let moved = rank != old_rank && flags & flag::HIDDEN == 0;
|
||||
if listed_now || moved {
|
||||
lines.push(StandingLine { faction_id, rank });
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Park the lines in the chat log, after whatever the same packet already queued. The faction's
|
||||
/// name is the row's (`0x62c670` reads the name column), and `female` picks the label's gender.
|
||||
pub(crate) fn queue_standing_lines(
|
||||
lines: impl IntoIterator<Item = StandingLine>,
|
||||
catalog: &benilla_formats::FactionCatalog,
|
||||
female: bool,
|
||||
chat: &mut ChatLog,
|
||||
) {
|
||||
for StandingLine { faction_id, rank } in lines {
|
||||
if let Some(name) = catalog.faction_name(faction_id) {
|
||||
chat.push_standing(name, rank, female);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x62c670`'s sentence: `FACTION_STANDING_CHANGED` filled with the rank label and the faction
|
||||
/// name. The label is `FACTION_STANDING_LABEL<rank + 1>` gendered by `0x612bf0`: a female player
|
||||
/// tries the `_FEMALE` twin and, when that is absent or empty, the bare key (`0x612c36`). A label
|
||||
/// absent altogether is an empty string, not a missing line: `0x703bf0` never returns NULL, and
|
||||
/// `0x62c670` prints with whatever it gave. Only a missing template prints nothing here, where the
|
||||
/// reference would send an empty string to the chat composer.
|
||||
pub(crate) fn standing_text(
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
rank: u8,
|
||||
female: bool,
|
||||
faction_name: &str,
|
||||
) -> Option<String> {
|
||||
let key = format!("FACTION_STANDING_LABEL{}", rank.min(7) + 1);
|
||||
let defined = |k: &str| get(k).filter(|v| !v.is_empty());
|
||||
let label = female
|
||||
.then(|| defined(&format!("{key}_FEMALE")))
|
||||
.flatten()
|
||||
.or_else(|| defined(&key))
|
||||
.unwrap_or_default();
|
||||
let template = defined("FACTION_STANDING_CHANGED")?;
|
||||
Some(benilla_ui::strings::fill(
|
||||
&template,
|
||||
&[
|
||||
benilla_ui::strings::Arg::S(&label),
|
||||
benilla_ui::strings::Arg::S(faction_name),
|
||||
],
|
||||
))
|
||||
}
|
||||
|
||||
/// One faction's row, or `None` when it is not in the player's list at all; a row the pane does
|
||||
/// not draw is still a row, with `visible: false`.
|
||||
pub(crate) fn reputation_row(
|
||||
@@ -352,6 +486,150 @@ mod tests {
|
||||
.count();
|
||||
assert_eq!(visible_parents, 1, "only Alliance carries VISIBLE as well");
|
||||
}
|
||||
|
||||
/// A human warrior (race 1, class 1) and Stormwind's slot on the real DBC: base 4000, Friendly.
|
||||
fn stormwind() -> Option<(benilla_formats::FactionCatalog, u32)> {
|
||||
let data = benilla_formats::wow_data_or_skip!(None);
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let cat = benilla_formats::load_faction_catalog(&mut chain).expect("factions");
|
||||
let slot = u32::try_from(cat.reputation_faction(72).expect("Stormwind").rep_index).unwrap();
|
||||
Some((cat, slot))
|
||||
}
|
||||
|
||||
fn store(slot: u32, flags: u8, standing: i32) -> Reputations {
|
||||
let mut v = vec![(0u8, 0i32); slot as usize + 1];
|
||||
v[slot as usize] = (flags, standing);
|
||||
Reputations(v)
|
||||
}
|
||||
|
||||
/// `0x4d5731` gates the list on `VISIBLE`: an unmet faction prints its rank (base 4000 is
|
||||
/// Friendly, rank 4), one already listed prints nothing (the dedup return).
|
||||
#[test]
|
||||
fn a_newly_listed_faction_names_its_rank_and_a_listed_one_does_not() {
|
||||
use benilla_formats::faction_flags as flag;
|
||||
let Some((cat, slot)) = stormwind() else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(
|
||||
visible_line(slot, &store(slot, 0, 0), &cat, 1, 1),
|
||||
Some(StandingLine {
|
||||
faction_id: 72,
|
||||
rank: 4
|
||||
})
|
||||
);
|
||||
// An empty store is the same first contact: the slot reads (0, 0).
|
||||
assert!(visible_line(slot, &Reputations::default(), &cat, 1, 1).is_some());
|
||||
assert_eq!(
|
||||
visible_line(slot, &store(slot, flag::VISIBLE, 0), &cat, 1, 1),
|
||||
None,
|
||||
"already listed"
|
||||
);
|
||||
// The wire standing rides on the base: 5000 more is 9000, Honored.
|
||||
assert_eq!(
|
||||
visible_line(slot, &store(slot, 0, 5000), &cat, 1, 1).map(|l| l.rank),
|
||||
Some(5)
|
||||
);
|
||||
// A slot the player's race and class do not list, or no faction names: nothing.
|
||||
assert_eq!(visible_line(63, &Reputations::default(), &cat, 1, 1), None);
|
||||
}
|
||||
|
||||
/// `0x4d5760`: a rank change prints the new rank, an unchanged rank prints nothing, `HIDDEN`
|
||||
/// silences a change, and a faction listed by this very entry prints once whatever it moved.
|
||||
#[test]
|
||||
fn a_standing_delta_prints_a_rank_change_and_a_first_listing_once() {
|
||||
use benilla_formats::faction_flags as flag;
|
||||
let Some((cat, slot)) = stormwind() else {
|
||||
return;
|
||||
};
|
||||
let lines = |flags: u8, old: i32, new: i32| {
|
||||
delta_lines(&[(slot, new)], &store(slot, flags, old), &cat, 1, 1)
|
||||
};
|
||||
let rank = |r: u8| {
|
||||
vec![StandingLine {
|
||||
faction_id: 72,
|
||||
rank: r,
|
||||
}]
|
||||
};
|
||||
// 4000 → 9000 crosses into Honored.
|
||||
assert_eq!(lines(flag::VISIBLE, 0, 5000), rank(5));
|
||||
// 4000 → 4100 stays Friendly; no line.
|
||||
assert!(lines(flag::VISIBLE, 0, 100).is_empty());
|
||||
// Down a rank is a change too.
|
||||
assert_eq!(lines(flag::VISIBLE, 0, -1001), rank(3));
|
||||
// HIDDEN keeps the gain off the screen (`0x4d5885`).
|
||||
assert!(lines(flag::VISIBLE | flag::HIDDEN, 0, 5000).is_empty());
|
||||
// Not yet listed: the reveal prints, with no second line for the rank it moved.
|
||||
assert_eq!(lines(0, 0, 100), rank(4));
|
||||
assert_eq!(lines(0, 0, 5000), rank(5));
|
||||
// Several entries print one line each, in order.
|
||||
let two = delta_lines(
|
||||
&[(slot, 5000), (63, 1)],
|
||||
&store(slot, flag::VISIBLE, 0),
|
||||
&cat,
|
||||
1,
|
||||
1,
|
||||
);
|
||||
assert_eq!(two, rank(5), "the unnamed slot prints nothing");
|
||||
}
|
||||
|
||||
/// `0x62c670`: `FACTION_STANDING_CHANGED` filled with the label and the name; a female player
|
||||
/// takes the `_FEMALE` label where there is one; a missing label or template prints nothing.
|
||||
#[test]
|
||||
fn the_sentence_is_the_template_with_the_gendered_label_and_the_name() {
|
||||
let strings = [
|
||||
("FACTION_STANDING_CHANGED", "You are now %s with %s."),
|
||||
("FACTION_STANDING_LABEL5", "Friendly"),
|
||||
("FACTION_STANDING_LABEL6", "Honored"),
|
||||
("FACTION_STANDING_LABEL6_FEMALE", "Honoured (f)"),
|
||||
];
|
||||
let get = |k: &str| {
|
||||
strings
|
||||
.iter()
|
||||
.find(|(key, _)| *key == k)
|
||||
.map(|(_, v)| v.to_string())
|
||||
};
|
||||
assert_eq!(
|
||||
standing_text(&get, 4, false, "Stormwind").as_deref(),
|
||||
Some("You are now Friendly with Stormwind.")
|
||||
);
|
||||
assert_eq!(
|
||||
standing_text(&get, 5, false, "Stormwind").as_deref(),
|
||||
Some("You are now Honored with Stormwind.")
|
||||
);
|
||||
assert_eq!(
|
||||
standing_text(&get, 5, true, "Stormwind").as_deref(),
|
||||
Some("You are now Honoured (f) with Stormwind.")
|
||||
);
|
||||
assert_eq!(
|
||||
standing_text(&get, 4, true, "Stormwind").as_deref(),
|
||||
Some("You are now Friendly with Stormwind."),
|
||||
"a missing twin falls back to the base label"
|
||||
);
|
||||
assert_eq!(
|
||||
standing_text(&get, 0, false, "Stormwind").as_deref(),
|
||||
Some("You are now with Stormwind."),
|
||||
"an absent label is an empty one (`0x703bf0` never returns NULL), not a missing line"
|
||||
);
|
||||
// A `_FEMALE` twin that is defined but empty falls back to the bare key (`0x612c36`).
|
||||
let empty_twin = |k: &str| match k {
|
||||
"FACTION_STANDING_LABEL6_FEMALE" => Some(String::new()),
|
||||
other => get(other),
|
||||
};
|
||||
assert_eq!(
|
||||
standing_text(&empty_twin, 5, true, "Stormwind").as_deref(),
|
||||
Some("You are now Honored with Stormwind.")
|
||||
);
|
||||
assert_eq!(
|
||||
standing_text(
|
||||
&|k| get(k).filter(|_| k != "FACTION_STANDING_CHANGED"),
|
||||
4,
|
||||
false,
|
||||
"X"
|
||||
),
|
||||
None,
|
||||
"no template"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The shared debug-trace sink behind `WOW_MOVE_TRACE=<path>`: one file and one clock for every
|
||||
//! layer's tagged lines, engine and game alike, so it sits below both. `in` and `out` are the full
|
||||
//! inbound and outbound opcode streams; `out` is written after the send, where the mover's `snd`
|
||||
//! is a decision before queueing. Each file opens with a `# t0=<unix epoch>` header so two
|
||||
//! inbound and outbound packet streams, each line an opcode, its name, its length and its body as
|
||||
//! hex; `out` is written after the send, where the mover's `snd` is a decision before queueing. Each file opens with a `# t0=<unix epoch>` header so two
|
||||
//! clients' traces align. `WOW_MOVE_TRACE_TAGS="move,snd,in"` keeps only those tags: every line is
|
||||
//! an unbuffered write under one mutex on the main thread, so a busy tag skews every `t=`.
|
||||
|
||||
|
||||
@@ -1116,14 +1116,21 @@ impl SessionEventKind {
|
||||
/// The result of polling the reader for the next packet's events.
|
||||
pub enum Poll {
|
||||
/// A decoded packet; `events` is empty for an opcode parsed but not modelled. `tail` counts
|
||||
/// body bytes left unread, which shows a decoder shorter than the server's layout.
|
||||
/// body bytes left unread, which shows a decoder shorter than the server's layout. `body` is
|
||||
/// the packet as it came off the wire, after the header, for the packet trace.
|
||||
Events {
|
||||
opcode: u16,
|
||||
events: Vec<SessionEvent>,
|
||||
tail: usize,
|
||||
body: Vec<u8>,
|
||||
},
|
||||
/// An unparseable packet, skipped to keep the stream aligned; `reason` has a hex preview and
|
||||
/// `body` the whole packet.
|
||||
Skipped {
|
||||
opcode: u16,
|
||||
reason: String,
|
||||
body: Vec<u8>,
|
||||
},
|
||||
/// An unparseable packet, skipped to keep the stream aligned; `reason` has a hex preview.
|
||||
Skipped { opcode: u16, reason: String },
|
||||
}
|
||||
|
||||
mod decode;
|
||||
|
||||
@@ -48,8 +48,9 @@ pub(super) fn recv_packet(
|
||||
messages::parse_server(opcode, &body).map_err(|e| anyhow!("parsing opcode {opcode:#x}: {e}"))
|
||||
}
|
||||
|
||||
/// What [`observe_sends`] takes: called with a sent packet's opcode and body length.
|
||||
pub type SendObserver = fn(opcode: u16, body_len: usize);
|
||||
/// What [`observe_sends`] takes: called with a sent packet's opcode and its body, the bytes after
|
||||
/// the 6-byte header and as plain as the caller built them (encryption covers the header alone).
|
||||
pub type SendObserver = fn(opcode: u16, body: &[u8]);
|
||||
|
||||
static SEND_OBSERVER: OnceLock<SendObserver> = OnceLock::new();
|
||||
|
||||
@@ -87,7 +88,7 @@ pub(super) fn send_packet(
|
||||
.write_all(&packet)
|
||||
.map_err(|e| anyhow!("sending opcode {opcode:#x}: {e}"))?;
|
||||
if let Some(observer) = SEND_OBSERVER.get() {
|
||||
observer(opcode, body.len());
|
||||
observer(opcode, body);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ impl WorldReader {
|
||||
opcode,
|
||||
events: crate::decode(packet),
|
||||
tail,
|
||||
body,
|
||||
}),
|
||||
Err(e) => Ok(crate::Poll::Skipped {
|
||||
opcode,
|
||||
@@ -48,6 +49,7 @@ impl WorldReader {
|
||||
messages::opcode_name(opcode).unwrap_or("?"),
|
||||
hex_preview(&body, 64)
|
||||
),
|
||||
body,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,13 +97,13 @@ fn a_warden_server_is_refused_at_the_handshake() {
|
||||
|
||||
/// Every packet the observer saw, with the thread that wrote it: the tests of this file share one
|
||||
/// process, so each filters for its own thread.
|
||||
static SEEN: std::sync::Mutex<Vec<(thread::ThreadId, u16, usize)>> =
|
||||
static SEEN: std::sync::Mutex<Vec<(thread::ThreadId, u16, Vec<u8>)>> =
|
||||
std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn record(opcode: u16, len: usize) {
|
||||
fn record(opcode: u16, body: &[u8]) {
|
||||
SEEN.lock()
|
||||
.unwrap()
|
||||
.push((thread::current().id(), opcode, len));
|
||||
.push((thread::current().id(), opcode, body.to_vec()));
|
||||
}
|
||||
|
||||
/// A packet sent before the session splits (the handshake's `CMSG_AUTH_SESSION`, the world entry's
|
||||
@@ -117,12 +117,12 @@ fn sends_before_the_split_are_observed_in_order() {
|
||||
let (_reader, mut writer) = session.into_split().unwrap();
|
||||
writer.complete_cinematic().unwrap();
|
||||
let me = thread::current().id();
|
||||
let mine: Vec<(u16, usize)> = SEEN
|
||||
let mine: Vec<(u16, Vec<u8>)> = SEEN
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(t, ..)| *t == me)
|
||||
.map(|&(_, op, len)| (op, len))
|
||||
.map(|(_, op, body)| (*op, body.clone()))
|
||||
.collect();
|
||||
let auth_len = messages::auth_session(
|
||||
u32::from(benilla_protocol::CLIENT_BUILD),
|
||||
@@ -132,12 +132,18 @@ fn sends_before_the_split_are_observed_in_order() {
|
||||
&messages::STOCK_SECURE_ADDONS,
|
||||
)
|
||||
.len();
|
||||
// The observer gets the bodies themselves: the auth block by its length (its proof is derived
|
||||
// from the seeds), the rest byte for byte.
|
||||
let ops: Vec<u16> = mine.iter().map(|(op, _)| *op).collect();
|
||||
assert_eq!(
|
||||
mine,
|
||||
ops,
|
||||
[
|
||||
(opcode::CMSG_AUTH_SESSION, auth_len),
|
||||
(opcode::CMSG_PLAYER_LOGIN, messages::full_guid(0x2a).len()),
|
||||
(opcode::CMSG_COMPLETE_CINEMATIC, 0),
|
||||
opcode::CMSG_AUTH_SESSION,
|
||||
opcode::CMSG_PLAYER_LOGIN,
|
||||
opcode::CMSG_COMPLETE_CINEMATIC
|
||||
]
|
||||
);
|
||||
assert_eq!(mine[0].1.len(), auth_len);
|
||||
assert_eq!(mine[1].1, messages::full_guid(0x2a));
|
||||
assert!(mine[2].1.is_empty());
|
||||
}
|
||||
|
||||
@@ -397,9 +397,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// IsFactionInactive / SetFactionInactive / SetFactionActive (`0x4d69b0`, `0x4d6a00`): the flip
|
||||
// is local first too, and moves the row under "Inactive", so the tree regroups here
|
||||
// (`0x4d69b0` calls `0x4d5c40`); the folds survive, as only a server rebuild resets them.
|
||||
// IsFactionInactive / SetFactionInactive / SetFactionActive (`0x4d69b0`, `0x4d6a00` →
|
||||
// `0x4d60f0`): the flip is local first too, and moves the row under "Inactive", so the tree
|
||||
// regroups here and `0x4d5c40` fires `UPDATE_FACTION` (`0x4d617b`, fire site `0x4d5dab`), which
|
||||
// the pane repaints on; the folds survive, as only a server rebuild resets them.
|
||||
g.set(
|
||||
"IsFactionInactive",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -425,6 +426,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
model.reputation_sends.push(send);
|
||||
let groups = build_groups(&model.reputation.entries);
|
||||
model.reputation_groups = groups;
|
||||
drop(model);
|
||||
super::event::fire_global(lua, "UPDATE_FACTION", &[]);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -713,6 +716,37 @@ mod tests {
|
||||
assert!(s.errors().is_empty(), "{:#?}", s.errors());
|
||||
}
|
||||
|
||||
/// The flip moves the row, and the pane repaints on `UPDATE_FACTION` alone: both verbs raise
|
||||
/// it after the regroup (`0x4d60f0` → `0x4d5c40`), so a listener already sees the moved row.
|
||||
#[test]
|
||||
fn the_inactive_verbs_raise_update_faction_after_the_regroup() {
|
||||
let s = seated();
|
||||
s.run(
|
||||
r#"log = {}
|
||||
local f = CreateFrame("Frame") f:RegisterEvent("UPDATE_FACTION")
|
||||
f:SetScript("OnEvent", function()
|
||||
local n = GetNumFactions()
|
||||
tinsert(log, (GetFactionInfo(n)))
|
||||
end)
|
||||
SetFactionInactive(3)"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<Vec<String>>("return log").unwrap(),
|
||||
["Inactive"],
|
||||
"heard once, and already regrouped: Stormwind sits under the new last header"
|
||||
);
|
||||
// Unfold Inactive (9 rows: its child is index 9), then bring Stormwind back.
|
||||
s.run("ExpandFactionHeader(8) log = {}").unwrap();
|
||||
s.run("SetFactionActive(9)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<Vec<String>>("return log").unwrap(),
|
||||
["Bloodsail Buccaneers"],
|
||||
"heard once more, and the emptied Inactive header is gone"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:#?}", s.errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_follows_the_faction_not_the_row_index() {
|
||||
let mut s = seated();
|
||||
|
||||
@@ -108,6 +108,16 @@ out of scope is closed with the reason.
|
||||
`WOW_GOD=off` leaves it off.
|
||||
- A trace is `WOW_MOVE_TRACE=<file>`, filtered with `WOW_MOVE_TRACE_TAGS` (`"move,snd,in"`) and
|
||||
ended by `WOW_PROBE_EXIT_AT=<seconds>`; grep it for the `rly` and `snd` lines.
|
||||
- To sniff the wire, trace the tags `in` and `out` (`WOW_MOVE_TRACE_TAGS="in,out"`): one line
|
||||
per world packet, with its opcode, name, `len=` and the body as `hex=`, the bytes after the
|
||||
packet header. An `in` line also has `ev=`, the events it decoded to; a server packet that
|
||||
failed to parse is a `skip` line with its bytes. Traces get attached to public issues, so two
|
||||
kinds of body are kept out. A client packet that carries a secret, the auth session, a channel
|
||||
password or any chat text starting with `.` or `!` (a server command), shows
|
||||
`hex=<redacted>` always, and so does a dropped command's log line. Chat in both directions
|
||||
and the bulk name lists (`SMSG_WHO`, the friend, ignore, guild roster and channel lists) show
|
||||
`hex=<withheld>`, with `len=` kept, unless `WOW_MOVE_TRACE_CHAT=1` is set, for a developer who
|
||||
needs those bodies and will read the file before sharing it.
|
||||
- A capture runs through `scripts/visual.sh`, whose recipe is the header of
|
||||
`crates/benilla-app/src/capture/mod.rs`. Pixel questions go through `benilla-visual crop`,
|
||||
`series` and `hotspot`.
|
||||
|
||||
@@ -325,6 +325,7 @@
|
||||
- `WOW_MM_ZOOM` — benilla-app/src/minimap/mod.rs
|
||||
- `WOW_MOVE_SPEED` — benilla-app/src/player/setup.rs
|
||||
- `WOW_MOVE_TRACE` — benilla-assets/src/trace.rs
|
||||
- `WOW_MOVE_TRACE_CHAT` — benilla-app/src/net/io/wire_trace.rs
|
||||
- `WOW_MOVE_TRACE_TAGS` — benilla-assets/src/trace.rs
|
||||
- `WOW_MSAA` — benilla-app/src/cvars/mod.rs, benilla-world/src/view.rs
|
||||
- `WOW_MT_EXTRACT` — benilla-app/src/lib.rs
|
||||
|
||||
Reference in New Issue
Block a user