From a329f19e0688dd55c16361d24b4733137cff2796 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:54:21 +0700 Subject: [PATCH] Reputation lines, NPC greeting gates, and packet bytes in the trace (#976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 with ." 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 --- crates/benilla-app/src/combat_log/chat.rs | 62 +++- crates/benilla-app/src/net/io.rs | 50 ++- crates/benilla-app/src/net/io/wire_trace.rs | 338 ++++++++++++++++++ crates/benilla-app/src/net/session.rs | 61 +++- .../src/net/session/reputation_tests.rs | 200 +++++++++++ crates/benilla-app/src/sound/greeting.rs | 261 +++++++++++++- crates/benilla-app/src/target/mod.rs | 5 +- crates/benilla-app/src/target/ring.rs | 13 + crates/benilla-app/src/ui_chat/feed.rs | 68 ++++ crates/benilla-app/src/ui_reputation.rs | 278 ++++++++++++++ crates/benilla-assets/src/trace.rs | 4 +- crates/benilla-protocol/src/events.rs | 13 +- crates/benilla-protocol/src/world/mod.rs | 7 +- crates/benilla-protocol/src/world/reader.rs | 2 + crates/benilla-protocol/tests/handshake.rs | 24 +- crates/benilla-ui/src/script/reputation.rs | 40 ++- docs/CONTRIBUTING.md | 10 + docs/MAP.md | 1 + 18 files changed, 1365 insertions(+), 72 deletions(-) create mode 100644 crates/benilla-app/src/net/io/wire_trace.rs create mode 100644 crates/benilla-app/src/net/session/reputation_tests.rs diff --git a/crates/benilla-app/src/combat_log/chat.rs b/crates/benilla-app/src/combat_log/chat.rs index 350443b9..c50cb3a8 100644 --- a/crates/benilla-app/src/combat_log/chat.rs +++ b/crates/benilla-app/src/combat_log/chat.rs @@ -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()); + } } diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 81258673..9544d3f7 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -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; diff --git a/crates/benilla-app/src/net/io/wire_trace.rs b/crates/benilla-app/src/net/io/wire_trace.rs new file mode 100644 index 00000000..3c61d430 --- /dev/null +++ b/crates/benilla-app/src/net/io/wire_trace.rs @@ -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=` always ([`secret`]). Private +//! conversation and bulk lists of other players' names are `hex=` 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 = ""; +/// What stands in for the hex of private text or a list of names, until `WOW_MOVE_TRACE_CHAT=1`. +const WITHHELD: &str = ""; + +/// Whether `WOW_MOVE_TRACE_CHAT` asks for the private bodies; read once. +fn reveal_private() -> bool { + static ON: std::sync::OnceLock = 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 `), 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 ` [B: ]`, 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="), "{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="), "{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="), + "{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 { + 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="), "{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="), "{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="), "{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=" + ); + 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: }" + ); + let pw = dropped(&C::ChannelPassword { + name: "Trade".into(), + password: "hunter2".into(), + }); + assert!(pw.contains("") && !pw.contains("hunter2"), "{pw}"); + // Anything else keeps its `Debug` form. + assert_eq!(dropped(&C::Logout), "Logout"); + } +} diff --git a/crates/benilla-app/src/net/session.rs b/crates/benilla-app/src/net/session.rs index dd841d4a..81537835 100644 --- a/crates/benilla-app/src/net/session.rs +++ b/crates/benilla-app/src/net/session.rs @@ -269,17 +269,56 @@ fn on_clock(In(ev): In, mut b: Bridge) { } } -fn on_reputations(In(ev): In, mut b: Bridge) { +fn on_reputations( + In(ev): In, + mut b: Bridge, + factions: Option>, + mut chat: ResMut, +) { 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 { + 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, 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; diff --git a/crates/benilla-app/src/net/session/reputation_tests.rs b/crates/benilla-app/src/net/session/reputation_tests.rs new file mode 100644 index 00000000..4c089a43 --- /dev/null +++ b/crates/benilla-app/src/net/session/reputation_tests.rs @@ -0,0 +1,200 @@ +//! The chat line "You are now with ." 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::() + .insert_resource(SelfGuid(Some(ME))) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .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::() + .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::() +} + +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::() + .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::>() + .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)]); +} diff --git a/crates/benilla-app/src/sound/greeting.rs b/crates/benilla-app/src/sound/greeting.rs index f9cf16ef..11101fef 100644 --- a/crates/benilla-app/src/sound/greeting.rs +++ b/crates/benilla-app/src/sound/greeting.rs @@ -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, 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, - mut seq: Local, + mut seq: ResMut, units: Query<(&NetEntity, &Transform, Option<&ObjectStore>)>, + self_store: Query<&ObjectStore, With>, + mover: Query<&ObjectStore, With>, + factions: Option>, + reputations: Res, greetings: Option>, kits: Option>, assets: Option>, @@ -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, index: Res, mut prev_active: Local>, + mut select_seq: ResMut, units: Query<(&NetEntity, &Transform, Option<&ObjectStore>)>, + self_store: Query<&ObjectStore, With>, greetings: Option>, kits: Option>, assets: Option>, @@ -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, mut out: NonSendMu /// Registration hook for [`super::SoundPlugin`]. pub(super) fn plugin(app: &mut App) { app.add_message::() + .init_resource::() .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"); + } } diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index 3977ad0f..308ddf50 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -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}; diff --git a/crates/benilla-app/src/target/ring.rs b/crates/benilla-app/src/target/ring.rs index 7f2d494e..0a9cd08f 100644 --- a/crates/benilla-app/src/target/ring.rs +++ b/crates/benilla-app/src/target/ring.rs @@ -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 diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs index 7f9d20b3..7d069bad 100644 --- a/crates/benilla-app/src/ui_chat/feed.rs +++ b/crates/benilla-app/src/ui_chat/feed.rs @@ -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 with ." (`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), @@ -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) = { diff --git a/crates/benilla-app/src/ui_reputation.rs b/crates/benilla-app/src/ui_reputation.rs index f152d286..8949e141 100644 --- a/crates/benilla-app/src/ui_reputation.rs +++ b/crates/benilla-app/src/ui_reputation.rs @@ -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 with ." (`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`. + 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 { + 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 { + 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, + 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` 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, + rank: u8, + female: bool, + faction_name: &str, +) -> Option { + 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)] diff --git a/crates/benilla-assets/src/trace.rs b/crates/benilla-assets/src/trace.rs index 5df4eeba..5105b34c 100644 --- a/crates/benilla-assets/src/trace.rs +++ b/crates/benilla-assets/src/trace.rs @@ -1,7 +1,7 @@ //! The shared debug-trace sink behind `WOW_MOVE_TRACE=`: 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=` 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=` 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=`. diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 43105ee2..93f5be21 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -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, tail: usize, + body: Vec, + }, + /// 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, }, - /// An unparseable packet, skipped to keep the stream aligned; `reason` has a hex preview. - Skipped { opcode: u16, reason: String }, } mod decode; diff --git a/crates/benilla-protocol/src/world/mod.rs b/crates/benilla-protocol/src/world/mod.rs index f5eacae8..55560c23 100644 --- a/crates/benilla-protocol/src/world/mod.rs +++ b/crates/benilla-protocol/src/world/mod.rs @@ -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 = 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(()) } diff --git a/crates/benilla-protocol/src/world/reader.rs b/crates/benilla-protocol/src/world/reader.rs index 35e0b716..90a2ea4e 100644 --- a/crates/benilla-protocol/src/world/reader.rs +++ b/crates/benilla-protocol/src/world/reader.rs @@ -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, }), } } diff --git a/crates/benilla-protocol/tests/handshake.rs b/crates/benilla-protocol/tests/handshake.rs index 23c51f83..83855bb5 100644 --- a/crates/benilla-protocol/tests/handshake.rs +++ b/crates/benilla-protocol/tests/handshake.rs @@ -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> = +static SEEN: std::sync::Mutex)>> = 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)> = 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 = 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()); } diff --git a/crates/benilla-ui/src/script/reputation.rs b/crates/benilla-ui/src/script/reputation.rs index 0dae6603..2ff8549e 100644 --- a/crates/benilla-ui/src/script/reputation.rs +++ b/crates/benilla-ui/src/script/reputation.rs @@ -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::>("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::>("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(); diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 735a1dfb..7d976f38 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -108,6 +108,16 @@ out of scope is closed with the reason. `WOW_GOD=off` leaves it off. - A trace is `WOW_MOVE_TRACE=`, filtered with `WOW_MOVE_TRACE_TAGS` (`"move,snd,in"`) and ended by `WOW_PROBE_EXIT_AT=`; 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=` 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=`, 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`. diff --git a/docs/MAP.md b/docs/MAP.md index 13d511cc..b1e708b3 100644 --- a/docs/MAP.md +++ b/docs/MAP.md @@ -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