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:
Sam Sung
2026-10-12 11:54:21 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7158c58575
commit a329f19e06
18 changed files with 1365 additions and 72 deletions
+57 -5
View File
@@ -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());
}
}
+22 -28
View File
@@ -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;
+338
View File
@@ -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");
}
}
+59 -2
View File
@@ -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)]);
}
+245 -16
View File
@@ -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");
}
}
+4 -1
View File
@@ -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};
+13
View File
@@ -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
+68
View File
@@ -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) = {
+278
View File
@@ -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)]
+2 -2
View File
@@ -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=`.
+10 -3
View File
@@ -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;
+4 -3
View File
@@ -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,
}),
}
}
+15 -9
View File
@@ -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());
}
+37 -3
View File
@@ -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();
+10
View File
@@ -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`.
+1
View File
@@ -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