- the four world broadcasts decode and land — a shutdown countdown, the civilian-kill alarm, a defense-tower capture and the whisper-cap refusal all reached benilla as unhandled opcodes; `SMSG_SERVER_MESSAGE` carries a row id and a fill, never a finished line, so `ServerMessages.dbc` ships (the five sentences a shutdown has, plus the client's own `[%d]: %s` row-missing fallback, which is mechanism, not an error path); the two defense broadcasts are CHANNEL lines, not system lines — delivered once per joined channel whose row carries DEFENSE, the LocalDefense copy zone-matched against the PARENT area while the text names the packet's own — so a character in neither channel sees nothing, faithfully, and since nothing auto-joins WorldDefense in 1.12 an Eastern Plaguelands tower capture reaches only players standing there unless they typed /join; the wow-re cross-check then made the length prefix load-bearing — the client borrows a pointer into the receive buffer and composes NULL on an overrun, so an overrunning length is a refusal here too: the packet drops with a log line, nothing displayed - `BankButtonIDToInvSlotID` is carved: `isBag` is a TYPE test, not truthiness — `lua_isnumber` refuses every non-number tag without ever loading the payload, so `true` takes the same arm as `nil` while `0` and `"1"` take the bag arm — the function is TOTAL (our out-of-range 0 was an invention; the only compares in all 118 bytes are on `lua_isnumber`'s return), and the +59/+39 arithmetic stands confirmed, byte-identical to `ContainerIDToInventoryID`'s second arm - a deliberate right-drag turn no longer stands you while seated — the observation was right and our attribution was wrong: the body-facing commit is refused by two independent gates and the stand arm is skipped outright while the button is held; what stands you is the RELEASE, a press under 200 ms with under 2.25 degrees of yaw dispatched as a right-CLICK whose interact reaches `SetStandState(0)` — this lands the deletion half, with the click's own stand held for one more wow-re answer - a ghost walks through closed doors — the whole mechanism is one instruction image-wide, reading bit 15 through the sign flag to answer "not a collision candidate" for a DOOR when the traced body is a player in ghost form; the bit is a property of whose trace it is, not of the door, so the exclusion rides the two mover-only casts and the set is empty on every living frame — the particle snap, the precipitation probe, the mouse pick and the creature conform keep the door's collider untouched, exactly as the binary leaves them Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
174 lines
7.0 KiB
Rust
174 lines
7.0 KiB
Rust
//! `ServerMessages.dbc` — the five sentences the server can say to everybody at once.
|
||
//!
|
||
//! `SMSG_SERVER_MESSAGE` carries a **row id and a fill**, never a finished line: the client indexes
|
||
//! this table by the packet's `messageType` (`0x49dfbb`, the store at `[0xc0d990]`), takes the
|
||
//! row's localized text as a **format string**, and fills its `%s` with the packet's own text
|
||
//! (`snprintf(buf, 0x400, rowText, packetText)` at `0x49dff0`). A packet with an empty text copies
|
||
//! the row verbatim instead (`0x49dfc8` tests the first byte, `0x64a5a0` copies) — which is what
|
||
//! rows 4 and 5 need, having no `%s` at all.
|
||
//!
|
||
//! Layout — the real 5875 file (326 bytes, 5 records × **10 × u32** cols, 106-byte string block):
|
||
//! `ID(0), Text[8](1..8), TextMask(9)`. The whole shipped table, verbatim:
|
||
//!
|
||
//! | ID | enUS text | vmangos sends it with |
|
||
//! |---|---|---|
|
||
//! | 1 | `[SERVER] Shutdown in %s` | `secsToTimeString(remaining)` |
|
||
//! | 2 | `[SERVER] Restart in %s` | `secsToTimeString(remaining)` |
|
||
//! | 3 | `%s` | the operator's own text |
|
||
//! | 4 | `[SERVER] Shutdown cancelled` | `""` |
|
||
//! | 5 | `[SERVER] Restart cancelled` | `""` |
|
||
//!
|
||
//! The ids are vmangos's `enum ServerMessageType` (`src/game/World.h:62`) exactly, and its comment
|
||
//! on that enum is the single word `ServerMessages.dbc` — the server is reading this file's ids out
|
||
//! of the client's own data.
|
||
//!
|
||
//! **The row-missing arm is part of the mechanism, not an error path.** A type with no row renders
|
||
//! through the client's own fallback format `"[%d]: %s"` (`0x844864`, read out of the image), so an
|
||
//! unknown broadcast still reaches the player with its type visible rather than being dropped.
|
||
//! [`ServerMessagesCatalog::compose`] is that whole decision in one function.
|
||
|
||
use anyhow::{Context, Result};
|
||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||
|
||
use crate::chain::Chain;
|
||
use crate::dbc::{parse, str_at, u32_at};
|
||
|
||
const SERVER_MESSAGES: &str = "DBFilesClient\\ServerMessages.dbc";
|
||
|
||
/// The five rows, keyed by id.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct ServerMessagesCatalog {
|
||
rows: Vec<(u32, String)>,
|
||
}
|
||
|
||
impl ServerMessagesCatalog {
|
||
/// A catalog over rows given directly — for a test that must run without an install.
|
||
pub fn from_rows(rows: Vec<(u32, String)>) -> Self {
|
||
ServerMessagesCatalog { rows }
|
||
}
|
||
|
||
/// The localized format string for a `messageType`, if the table has that row.
|
||
pub fn text(&self, message_type: u32) -> Option<&str> {
|
||
self.rows
|
||
.iter()
|
||
.find(|(id, _)| *id == message_type)
|
||
.map(|(_, t)| t.as_str())
|
||
}
|
||
|
||
pub fn is_empty(&self) -> bool {
|
||
self.rows.is_empty()
|
||
}
|
||
|
||
pub fn len(&self) -> usize {
|
||
self.rows.len()
|
||
}
|
||
|
||
/// The line `SMSG_SERVER_MESSAGE` becomes — the client's `0x49dfab`–`0x49e030` in full.
|
||
///
|
||
/// Three arms, in the reference's own order: a row plus a non-empty fill formats; a row plus an
|
||
/// empty fill copies the row (so `[SERVER] Restart cancelled` never grows a stray tail); no row
|
||
/// falls back to `"[%d]: %s"` with the raw type.
|
||
///
|
||
/// The substitution is `replacen(.., 1)` because the reference's is `snprintf` with **one**
|
||
/// vararg: a row carrying two `%s` would fill the second with stack garbage, and filling only
|
||
/// the first is the closest honest thing. No shipped row has two.
|
||
pub fn compose(&self, message_type: u32, fill: &str) -> String {
|
||
match self.text(message_type) {
|
||
Some(row) if !fill.is_empty() => row.replacen("%s", fill, 1),
|
||
Some(row) => row.to_string(),
|
||
None => format!("[{message_type}]: {fill}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn schema() -> Schema {
|
||
let mut s = Schema::new("ServerMessages");
|
||
s.add_field(SchemaField::new("ID", FieldType::UInt32));
|
||
s.add_field(SchemaField::new_array("Text", FieldType::String, 8));
|
||
s.add_field(SchemaField::new("TextMask", FieldType::UInt32));
|
||
s.set_key_field("ID");
|
||
s
|
||
}
|
||
|
||
pub fn load_server_messages_catalog(chain: &mut Chain) -> Result<ServerMessagesCatalog> {
|
||
let bytes = chain
|
||
.read_file(SERVER_MESSAGES)
|
||
.context("reading ServerMessages.dbc")?;
|
||
let rs = parse(&bytes, schema(), "ServerMessages")?;
|
||
let mut rows = Vec::with_capacity(rs.records().len());
|
||
for r in rs.records() {
|
||
let (Some(id), Some(text)) = (u32_at(r, 0), str_at(&rs, r, 1)) else {
|
||
continue;
|
||
};
|
||
rows.push((id, text));
|
||
}
|
||
Ok(ServerMessagesCatalog { rows })
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The real shipped table, row for row — the module doc's table asserted rather than trusted,
|
||
/// because these five strings are the whole vocabulary of a server shutdown. Skips without
|
||
/// client data.
|
||
#[test]
|
||
fn the_real_table_is_the_five_documented_rows() {
|
||
let data = crate::wow_data_or_skip!();
|
||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||
let cat = load_server_messages_catalog(&mut chain).expect("load ServerMessages");
|
||
let got: Vec<(u32, &str)> = cat.rows.iter().map(|(i, t)| (*i, t.as_str())).collect();
|
||
assert_eq!(
|
||
got,
|
||
vec![
|
||
(1, "[SERVER] Shutdown in %s"),
|
||
(2, "[SERVER] Restart in %s"),
|
||
(3, "%s"),
|
||
(4, "[SERVER] Shutdown cancelled"),
|
||
(5, "[SERVER] Restart cancelled"),
|
||
]
|
||
);
|
||
}
|
||
|
||
fn shipped() -> ServerMessagesCatalog {
|
||
ServerMessagesCatalog::from_rows(vec![
|
||
(1, "[SERVER] Shutdown in %s".into()),
|
||
(2, "[SERVER] Restart in %s".into()),
|
||
(3, "%s".into()),
|
||
(4, "[SERVER] Shutdown cancelled".into()),
|
||
(5, "[SERVER] Restart cancelled".into()),
|
||
])
|
||
}
|
||
|
||
/// A countdown: the row is the format, the packet is the fill.
|
||
#[test]
|
||
fn a_countdown_fills_its_row() {
|
||
assert_eq!(
|
||
shipped().compose(1, "15 Minutes"),
|
||
"[SERVER] Shutdown in 15 Minutes"
|
||
);
|
||
assert_eq!(shipped().compose(3, "back in five"), "back in five");
|
||
}
|
||
|
||
/// A cancellation carries no fill, and the row is already a whole sentence — the reference
|
||
/// copies rather than formats, so nothing is appended and no `%s` survives.
|
||
#[test]
|
||
fn a_cancellation_copies_its_row_whole() {
|
||
assert_eq!(shipped().compose(5, ""), "[SERVER] Restart cancelled");
|
||
}
|
||
|
||
/// A row-less type still reaches the player, through the client's own `"[%d]: %s"`.
|
||
#[test]
|
||
fn an_unknown_type_falls_back_to_the_bracketed_form() {
|
||
assert_eq!(shipped().compose(9, "something new"), "[9]: something new");
|
||
}
|
||
|
||
/// The empty-fill test is on the FILL, not on whether the row has a `%s`: a row with a `%s` and
|
||
/// no fill copies verbatim, leaving the token visible — which is what the reference does, and
|
||
/// what no shipped row can actually reach (rows 1/2/3 always come with text).
|
||
#[test]
|
||
fn an_empty_fill_never_formats() {
|
||
assert_eq!(shipped().compose(1, ""), "[SERVER] Shutdown in %s");
|
||
}
|
||
}
|