Files
benilla-octo/crates/benilla-protocol/src/messages/addons.rs
T
SamandClaude Opus 5.5 0668465bf3 comments: protocol, the crate root, guids and the action-bar to channel messages
1,031 comment lines become 320; the code is unchanged. A guid test
called transport 176495 the Menethil-Theramore ferry. The world
database names it the Grom'Gol-Undercity zeppelin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 20:03:55 +07:00

153 lines
6.3 KiB
Rust

//! The addon-info block ending `CMSG_AUTH_SESSION`, written by `0x51d910` after the 20-byte
//! proof: a `u32` uncompressed size, then a zlib (RFC1950) stream to the end of the packet.
//! Uncompressed, it is one record per addon whose `.toc` sets `## Secure:` non-zero, enabled or
//! not, with no count or trailer: `CString` name, `u8` flags (`.pub` byte 0), `u32` CRC-32 of the
//! 256 modulus bytes (`.pub` bytes 1..=256), `u32` CRC-32 of the `.url` string (0 if none).
//!
//! With no secure addons the client appends nothing. Both emulators refuse a zero size: vmangos
//! then skips `SMSG_ADDON_INFO` (`WorldSocket.cpp:447`), cmangos-classic kicks the session
//! (`WorldSocket.cpp:562`).
/// CRC-32 of the stock Blizzard public-key modulus, the emulators' "standard addon CRC"; a server
/// seeing it does not send the 256-byte modulus back in `SMSG_ADDON_INFO`.
pub const STANDARD_MODULUS_CRC: u32 = 0x4C1C_776D;
/// The addons `SMSG_ADDON_INFO` hides: record i answers `sent[i]` (the reply carries no names),
/// and status 2 makes the reference set `[rec+0x29] = 1` (`0x51db84`), dropping it from Lua.
pub fn hidden_from_reply(statuses: &[u8], sent: &[SecureAddon]) -> Vec<String> {
statuses
.iter()
.zip(sent)
.filter(|(status, _)| **status == 2)
.map(|(_, addon)| addon.name.to_string())
.collect()
}
/// One record of the addon block.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SecureAddon<'a> {
/// The addon's folder name (`Blizzard_AuctionUI`), sent as a `CString`.
pub name: &'a str,
/// Byte 0 of the `.pub`: 1 for a stock signature, 0 for none usable. A server may write another
/// back in `SMSG_ADDON_INFO`, which the reference saves to the `.pub` and echoes next logon.
pub flags: u8,
/// CRC-32 of the `.pub`'s 256 modulus bytes; 0 when `flags` is 0.
pub modulus_crc: u32,
/// CRC-32 of the addon's `.url` string. `0` for every stock addon (none ship one).
pub url_crc: u32,
}
impl SecureAddon<'_> {
/// A stock signed built-in, the shape all twelve take on an unmodified install.
const fn stock(name: &str) -> SecureAddon<'_> {
SecureAddon {
name,
flags: 1,
modulus_crc: STANDARD_MODULUS_CRC,
url_crc: 0,
}
}
}
/// The twelve `Blizzard_*` built-ins a stock 1.12.1 install reports, in the client's order
/// (ascending ASCII). Deviation: a fixed list, not read off the install, because no third-party
/// addon sets `## Secure:`, so the two differ only where the `.pub` files were altered.
pub const STOCK_SECURE_ADDONS: [SecureAddon<'static>; 12] = [
SecureAddon::stock("Blizzard_AuctionUI"),
SecureAddon::stock("Blizzard_BattlefieldMinimap"),
SecureAddon::stock("Blizzard_BindingUI"),
SecureAddon::stock("Blizzard_CombatText"),
SecureAddon::stock("Blizzard_CraftUI"),
SecureAddon::stock("Blizzard_GMSurveyUI"),
SecureAddon::stock("Blizzard_InspectUI"),
SecureAddon::stock("Blizzard_MacroUI"),
SecureAddon::stock("Blizzard_RaidUI"),
SecureAddon::stock("Blizzard_TalentUI"),
SecureAddon::stock("Blizzard_TradeSkillUI"),
SecureAddon::stock("Blizzard_TrainerUI"),
];
/// The uncompressed addon buffer: the records concatenated, nothing else.
pub fn addon_block(addons: &[SecureAddon]) -> Vec<u8> {
let mut out = Vec::with_capacity(addons.iter().map(|a| a.name.len() + 10).sum());
for addon in addons {
out.extend_from_slice(addon.name.as_bytes());
out.push(0);
out.push(addon.flags);
out.extend_from_slice(&addon.modulus_crc.to_le_bytes());
out.extend_from_slice(&addon.url_crc.to_le_bytes());
}
out
}
/// The block on the wire: `u32` size and the zlib stream, or nothing when there are no addons.
pub fn addon_tail(addons: &[SecureAddon]) -> Vec<u8> {
if addons.is_empty() {
return Vec::new();
}
let plain = addon_block(addons);
let mut out = Vec::with_capacity(plain.len() / 2 + 8);
out.extend_from_slice(&(plain.len() as u32).to_le_bytes());
let mut encoder =
flate2::write::ZlibEncoder::new(out, flate2::Compression::default() /* level 6 */);
std::io::Write::write_all(&mut encoder, &plain).expect("zlib write to a Vec cannot fail");
encoder.finish().expect("zlib finish on a Vec cannot fail")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stock_block_matches_the_retail_capture() {
let plain = addon_block(&STOCK_SECURE_ADDONS);
assert_eq!(plain.len(), 342, "retail sent 342 uncompressed bytes");
// Parse it as a server does; landing exactly on the end proves no count and no trailer.
let mut rest = &plain[..];
let mut seen = Vec::new();
while !rest.is_empty() {
let nul = rest
.iter()
.position(|&b| b == 0)
.expect("record has a name");
let name = std::str::from_utf8(&rest[..nul]).expect("name is utf-8");
rest = &rest[nul + 1..];
let (flags, tail) = rest.split_first().expect("record has flags");
let modulus_crc = u32::from_le_bytes(tail[0..4].try_into().unwrap());
let url_crc = u32::from_le_bytes(tail[4..8].try_into().unwrap());
assert_eq!(*flags, 1, "{name} is a stock signed addon");
assert_eq!(modulus_crc, STANDARD_MODULUS_CRC, "{name} modulus crc");
assert_eq!(url_crc, 0, "{name} ships no .url");
seen.push(name.to_string());
rest = &tail[8..];
}
assert_eq!(seen.len(), 12);
assert!(seen.iter().all(|n| n.starts_with("Blizzard_")));
let mut sorted = seen.clone();
sorted.sort();
assert_eq!(
seen, sorted,
"the client sends them in ascending ASCII order"
);
}
/// `flate2`'s default is the reference's `Z_DEFAULT_COMPRESSION`, hence the retail 130 bytes.
#[test]
fn stock_tail_is_size_plus_zlib() {
let tail = addon_tail(&STOCK_SECURE_ADDONS);
assert_eq!(
u32::from_le_bytes(tail[0..4].try_into().unwrap()),
342,
"size prefix is the uncompressed length"
);
assert_eq!(tail[4], 0x78, "RFC1950 zlib framing, not raw deflate");
assert_eq!(tail.len(), 4 + 130, "retail compressed to 130 bytes");
}
#[test]
fn no_secure_addons_appends_nothing() {
assert!(addon_tail(&[]).is_empty());
}
}