the shapeshift cloud both ways, and the Honor tab
- druids get the shift cloud back in both directions, by the reference's own mechanism: every shapeshift aura add *or* remove arms a one-slot pending-morph latch that the model-swap rebuild drains and replays — which is why shifting out (a client-side cancel the server sends no visual for) puffs at all, and why a GM morph or a revive correctly shows nothing; the first fix that merely let one-shot effects survive the swap's teardown produced the right pixels for the wrong reason and is superseded - the honor system lands whole: the Honor tab on the character window, the honor page on inspect, and thirteen missing Lua bindings over a real wire model — session/yesterday/this-week/last-week kills, lifetime HKs and DKs, rank progress and badge art - the load-bearing find: your current rank and your highest-ever rank are two separately-streamed fields, and the one we already had was the wrong one for anyone who ever de-ranked; a binary cross-check corrected five display details after the build, down to the rank title never being gendered Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,12 +13,13 @@ use crate::messages::{
|
||||
ActionButton, AttackerState, ChannelNoticeTail, Character, CreateSpline, DamageShield,
|
||||
EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate, GossipOption,
|
||||
GroupLootInfo, GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo,
|
||||
GuildQueryResponse, GuildRoster, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo,
|
||||
LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart,
|
||||
MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells,
|
||||
QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems,
|
||||
QuestTemplate, SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss, TaxiMask,
|
||||
TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
|
||||
GuildQueryResponse, GuildRoster, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo,
|
||||
LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
|
||||
MirrorTimerStart, MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog,
|
||||
PetMode, PetSpells, PvpCredit, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward,
|
||||
QuestRequestItems, QuestTemplate, SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss,
|
||||
TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem,
|
||||
WhoResults, XpGain,
|
||||
};
|
||||
|
||||
/// Coarse entity classification, free of wire types so the app can branch on it without depending on
|
||||
@@ -1028,6 +1029,14 @@ pub enum SessionEvent {
|
||||
},
|
||||
/// Start the duel countdown (`SMSG_DUEL_COUNTDOWN`) — already in whole seconds.
|
||||
DuelCountdown { seconds: u32 },
|
||||
/// Another player's honor stats came back (`MSG_INSPECT_HONOR_STATS` reply, decision 1512) —
|
||||
/// the inspect window's Honor tab. Arrives only in answer to our own ask, and a *refused* ask
|
||||
/// is answered with silence (the server returns without sending), so a consumer must not
|
||||
/// treat "no event yet" as "the data is coming".
|
||||
InspectHonorStats(InspectHonorStats),
|
||||
/// A kill paid out honor (`SMSG_PVP_CREDIT`) — the honor-gain chat line and floating combat
|
||||
/// text. Also fires for a **dishonorable** kill, where `honor` is negative.
|
||||
PvpCredit(PvpCredit),
|
||||
/// One mirror timer started, or was wholly re-stated (`SMSG_START_MIRROR_TIMER`, decision
|
||||
/// 0874) — the breath / fatigue bars. There is no separate update opcode: the server re-sends
|
||||
/// this whole packet whenever direction, remaining time or frozen state changes, so a
|
||||
|
||||
@@ -486,6 +486,8 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
loser,
|
||||
}],
|
||||
ServerPacket::DuelCountdown { seconds } => vec![SessionEvent::DuelCountdown { seconds }],
|
||||
ServerPacket::InspectHonorStats(stats) => vec![SessionEvent::InspectHonorStats(stats)],
|
||||
ServerPacket::PvpCredit(credit) => vec![SessionEvent::PvpCredit(credit)],
|
||||
ServerPacket::MirrorTimerStart(start) => vec![SessionEvent::MirrorTimerStart(start)],
|
||||
ServerPacket::MirrorTimerPause { kind, paused } => {
|
||||
vec![SessionEvent::MirrorTimerPause { kind, paused }]
|
||||
|
||||
@@ -45,6 +45,7 @@ mod parse;
|
||||
mod pet;
|
||||
mod pose;
|
||||
mod progression;
|
||||
mod pvp;
|
||||
mod quest;
|
||||
mod reputation;
|
||||
mod roster;
|
||||
@@ -159,6 +160,7 @@ pub use pet::{
|
||||
};
|
||||
pub use pose::{set_sheathed, stand_state_change};
|
||||
pub use progression::{learn_talent, ExplorationXp, LevelUpInfo, XpGain};
|
||||
pub use pvp::{inspect_honor_stats, InspectHonorStats, PvpCredit};
|
||||
pub use quest::{
|
||||
dialog_status, quest_query, questgiver_accept_quest, questgiver_choose_reward,
|
||||
questgiver_complete_quest, questgiver_hello, questgiver_query_quest, questgiver_request_reward,
|
||||
|
||||
@@ -254,6 +254,11 @@ pub const SMSG_LEVELUP_INFO: u16 = 0x01D4; // 468
|
||||
/// A newly explored area — area id + the XP it granted (0 at max level). Body
|
||||
/// in [`super::progression::read_exploration_xp`] — decision 0828.
|
||||
pub const SMSG_EXPLORATION_EXPERIENCE: u16 = 0x01F8; // 504 (VERIFIED vmangos `Opcodes_1_12_1.h:505`)
|
||||
/// The honor payout (VERIFIED vmangos `Opcodes_1_12_1.h`: 652) — the XP-gain line's PvP twin, and
|
||||
/// the only attributed notice of an honor award: the descriptor's contribution fields move too,
|
||||
/// but they are running totals. Sent for **dishonorable** kills as well, carrying negative honor.
|
||||
/// Body in [`super::pvp::read_pvp_credit`] ([`super::PvpCredit`]) — decision 1512.
|
||||
pub const SMSG_PVP_CREDIT: u16 = 0x028C; // 652
|
||||
|
||||
// The combat-log wire (VERIFIED vmangos `Opcodes_1_12_1.h`: 464/587/590/591/592) — decision 0137
|
||||
// phase 2's floating-combat-text data feed. Bodies in [`super::spells`].
|
||||
@@ -471,6 +476,16 @@ pub const CMSG_CANCEL_CHANNELLING: u16 = 0x013B; // 315
|
||||
/// streamed PUBLIC `PLAYER_VISIBLE_ITEM_*` fields without waiting on it, and no FrameXML handler
|
||||
/// registers an inspect event. Decision 0631.
|
||||
pub const CMSG_INSPECT: u16 = 0x0114; // 276
|
||||
/// 726 (VERIFIED vmangos `Opcodes_1_12_1.h`) — the inspect window's **Honor tab**, and an `MSG_`:
|
||||
/// the one opcode number carries both directions. Our request is a raw 8-byte guid
|
||||
/// ([`super::inspect_honor_stats`]); the server's reply, on the same number, is the 50-byte
|
||||
/// [`super::InspectHonorStats`] body. Nothing needs to disambiguate the two shapes — direction
|
||||
/// does: [`super::parse_server`] only ever sees inbound bodies, so a 0x2D6 there is always the
|
||||
/// reply, and our outbound body never passes through it.
|
||||
///
|
||||
/// Same three silent refusals as [`CMSG_INSPECT`] (`MiscHandler.cpp:962-972`), but *unlike* it
|
||||
/// this handler does not set our selection. Decision 1512.
|
||||
pub const MSG_INSPECT_HONOR_STATS: u16 = 0x02D6; // 726
|
||||
pub const CMSG_SET_SELECTION: u16 = 0x013D; // 317
|
||||
pub const CMSG_ATTACKSWING: u16 = 0x0141; // 321
|
||||
pub const CMSG_ATTACKSTOP: u16 = 0x0142; // 322
|
||||
|
||||
@@ -9,13 +9,14 @@ use super::{
|
||||
CorpseLocation, DamageShield, EnvironmentalDamageLog, ExplorationXp, FriendEntry,
|
||||
FriendStatusUpdate, GameObjectQueryInfo, GossipOption, GossipPoi, GroupLootInfo,
|
||||
GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse,
|
||||
GuildRoster, InitWorldStates, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed,
|
||||
LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, MoveMode,
|
||||
Object, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells, QuestComplete, QuestDetails,
|
||||
QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate,
|
||||
ResurrectRequestBody, SpeedKind, SpellChainTargets, SpellCooldown, SpellDamageLog,
|
||||
SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss, SpellStart, TaxiMask, TradeStatus,
|
||||
TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
|
||||
GuildRoster, InitWorldStates, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo,
|
||||
LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
|
||||
MirrorTimerStart, MoveMode, Object, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells,
|
||||
PvpCredit, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestOption,
|
||||
QuestRequestItems, QuestTemplate, ResurrectRequestBody, SpeedKind, SpellChainTargets,
|
||||
SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss,
|
||||
SpellStart, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose,
|
||||
VendorItem, WhoResults, XpGain,
|
||||
};
|
||||
|
||||
/// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this
|
||||
@@ -983,6 +984,14 @@ pub enum ServerPacket {
|
||||
DuelCountdown {
|
||||
seconds: u32,
|
||||
},
|
||||
/// The `MSG_INSPECT_HONOR_STATS` **reply** — another player's honor stats (decision 1512).
|
||||
/// The opcode is an `MSG_`, so the same number carries our request; direction disambiguates
|
||||
/// (see [`super::opcode::MSG_INSPECT_HONOR_STATS`]). Unsolicited replies do not happen: this
|
||||
/// only ever arrives because we asked, and a refused ask is answered with silence.
|
||||
InspectHonorStats(InspectHonorStats),
|
||||
/// `SMSG_PVP_CREDIT` — an honor payout, the only *attributed* notice of one. Also sent for
|
||||
/// dishonorable kills, with negative honor.
|
||||
PvpCredit(PvpCredit),
|
||||
/// `SMSG_START_MIRROR_TIMER` — start or wholly re-state one mirror timer (the breath /
|
||||
/// fatigue bars, decision 0874). The family has no update opcode: every change re-sends this.
|
||||
MirrorTimerStart(MirrorTimerStart),
|
||||
@@ -1351,6 +1360,8 @@ impl ServerPacket {
|
||||
ServerPacket::DuelComplete { .. } => "SMSG_DUEL_COMPLETE".into(),
|
||||
ServerPacket::DuelWinner { .. } => "SMSG_DUEL_WINNER".into(),
|
||||
ServerPacket::DuelCountdown { .. } => "SMSG_DUEL_COUNTDOWN".into(),
|
||||
ServerPacket::InspectHonorStats(..) => "MSG_INSPECT_HONOR_STATS".into(),
|
||||
ServerPacket::PvpCredit(..) => "SMSG_PVP_CREDIT".into(),
|
||||
ServerPacket::MirrorTimerStart(..) => "SMSG_START_MIRROR_TIMER".into(),
|
||||
ServerPacket::MirrorTimerPause { .. } => "SMSG_PAUSE_MIRROR_TIMER".into(),
|
||||
ServerPacket::MirrorTimerStop { .. } => "SMSG_STOP_MIRROR_TIMER".into(),
|
||||
|
||||
@@ -13,9 +13,9 @@ use crate::wire::{
|
||||
use super::{
|
||||
action_bar, area_trigger, attack, bank, binder, channel, chat, combat_log, death, duel,
|
||||
gameobject, gossip, group, guild, items, loot, mail, mirror_timer, monster_move, movement,
|
||||
opcode, page_text, pet, progression, quest, social, spellbook, spells, taxi, trade, trainer,
|
||||
update_object, vendor, world_state, Character, CreatureQueryInfo, MoveMode, ServerPacket,
|
||||
SpeedKind,
|
||||
opcode, page_text, pet, progression, pvp, quest, social, spellbook, spells, taxi, trade,
|
||||
trainer, update_object, vendor, world_state, Character, CreatureQueryInfo, MoveMode,
|
||||
ServerPacket, SpeedKind,
|
||||
};
|
||||
|
||||
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
|
||||
@@ -964,6 +964,16 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
opcode::SMSG_DUEL_COUNTDOWN => ServerPacket::DuelCountdown {
|
||||
seconds: duel::read_duel_countdown(&mut r)?,
|
||||
},
|
||||
// The honor pair (decision 1512; bodies in `pvp`). MSG_INSPECT_HONOR_STATS is an `MSG_`:
|
||||
// opcode 0x2D6 carries our 8-byte request AND the server's 50-byte reply. There is no
|
||||
// ambiguity to resolve here — `parse_server` is fed only by the world *reader*, so an
|
||||
// 0x2D6 body arriving at this function is always the reply. The request shape is built by
|
||||
// `pvp::inspect_honor_stats` and goes straight out through `world::writer::pvp`; it never
|
||||
// reaches this match.
|
||||
opcode::MSG_INSPECT_HONOR_STATS => {
|
||||
ServerPacket::InspectHonorStats(pvp::read_inspect_honor_stats(&mut r)?)
|
||||
}
|
||||
opcode::SMSG_PVP_CREDIT => ServerPacket::PvpCredit(pvp::read_pvp_credit(&mut r)?),
|
||||
// The mirror timers (decision 0874; bodies in `mirror_timer`). START carries the timer's
|
||||
// whole state and is re-sent on every change — there is no update opcode in the family.
|
||||
opcode::SMSG_START_MIRROR_TIMER => {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! The two honor-system wire messages (decision 1512): the inspect-honor request/reply pair and
|
||||
//! the honor-gain credit. Every shape below is VERIFIED against vmangos source (file:line cites
|
||||
//! per item); the goldens are hand-built against the server's own `AppendBodyTo` order.
|
||||
//!
|
||||
//! The *durable* honor state is not here — it rides the player descriptor, eleven PRIVATE fields
|
||||
//! plus the PUBLIC current-rank byte ([`crate::messages::ObjectFields::player_pvp_rank`] and
|
||||
//! friends). These two packets cover exactly what the descriptor cannot: **somebody else's**
|
||||
//! honor stats (which never stream to us), and the *moment* a kill pays out.
|
||||
//!
|
||||
//! Not modelled here (known siblings, VERIFIED present in vmangos `Opcodes_1_12_1.h`): the
|
||||
//! battleground family — `MSG_PVP_LOG_DATA` (736), `CMSG`/`SMSG_BATTLEFIELD_LIST` (572/573) and
|
||||
//! `…_STATUS` (723/724) — which 0208/0631 defer wholesale, and `SMSG_ZONE_UNDER_ATTACK` (596), a
|
||||
//! world broadcast rather than an honor message.
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::wire::{read_i32_le, read_u16_le, read_u32_le, read_u64_le, read_u8};
|
||||
|
||||
/// Another player's honor stats — the `MSG_INSPECT_HONOR_STATS` **reply** (opcode `0x2D6`/726,
|
||||
/// VERIFIED vmangos `Opcodes_1_12_1.h`; already named in `opcode_names`). A 50-byte body, all
|
||||
/// little-endian with no padding, in the exact order
|
||||
/// `WorldPackets::Misc::InspectHonorStatsResponse::AppendBodyTo` writes it
|
||||
/// (`Server/Packets/Misc.cpp:301-325`). Every `#if SUPPORTED_CLIENT_BUILD >= CLIENT_BUILD_1_6_1`
|
||||
/// block in that function is live for build 5875, so all three conditional members are present.
|
||||
///
|
||||
/// This exists because the honor descriptor fields are PRIVATE: nothing about a *foreign* player's
|
||||
/// kills, honor or progress bar is on the wire until we ask. The one exception is their current
|
||||
/// rank, which streams publicly in `PLAYER_BYTES_3` and so is **not** in this packet —
|
||||
/// [`crate::messages::ObjectFields::player_pvp_rank`] answers it off the target's own descriptor.
|
||||
///
|
||||
/// The server builds every value straight out of the target's descriptor
|
||||
/// (`Handlers/MiscHandler.cpp:962-1010`), so these fields are the inspected player's own
|
||||
/// `PLAYER_FIELD_*` honor block, re-serialized — with two shape differences worth knowing:
|
||||
/// `sessionKills` arrives as the raw packed dword rather than a split pair, and the three weekly
|
||||
/// HK counts arrive as bare `u16`s with a separate (dead) dishonorable slot beside each.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct InspectHonorStats {
|
||||
/// Whose stats these are — echoed from our request (`MiscHandler.cpp:977`). The reply carries
|
||||
/// no error shape at all: the server simply does not answer when the target is missing, out of
|
||||
/// `INSPECT_DISTANCE` (10 yd), or a valid attack target, so a silent non-reply is the refusal.
|
||||
pub player_guid: u64,
|
||||
/// The target's **highest lifetime** honor rank (`GetHighestRank().rank`,
|
||||
/// `MiscHandler.cpp:985`) — the *internal* rank 0..18, the same domain as
|
||||
/// [`crate::messages::ObjectFields::player_honor_rank`], NOT the visual number the UI draws.
|
||||
/// Their *current* rank is the PUBLIC descriptor byte, not this.
|
||||
pub highest_rank: u8,
|
||||
/// Today's kills as the raw packed `PLAYER_FIELD_SESSION_KILLS` dword — low `u16` honorable,
|
||||
/// high `u16` dishonorable (`MiscHandler.cpp:988` reads the whole field with
|
||||
/// `GetUInt32Value`). Split with [`InspectHonorStats::session_kills`].
|
||||
pub session_kills: u32,
|
||||
/// Yesterday's honorable kills (`GetUInt16Value(PLAYER_FIELD_YESTERDAY_KILLS, 0)`).
|
||||
pub yesterday_hk: u16,
|
||||
/// Written `0` by vmangos (`MiscHandler.cpp:994`, comment *"Unknown (deprecated, yesterday
|
||||
/// dishonourable?)"*). Decoded and carried rather than skipped: whether the real client reads
|
||||
/// these three slots at all is a question currently out with an RE orchestrator in
|
||||
/// `wow-5875-re`, and a field we decode and ignore is honest where a silently-skipped one
|
||||
/// would be a landmine if the verdict comes back "it reads them".
|
||||
pub unknown_old1: u16,
|
||||
/// Last week's honorable kills.
|
||||
pub last_week_hk: u16,
|
||||
/// Written `0` by vmangos — see [`InspectHonorStats::unknown_old1`].
|
||||
pub unknown_old2: u16,
|
||||
/// This week's honorable kills (a 1.6.0 addition; live for 5875).
|
||||
pub this_week_hk: u16,
|
||||
/// Written `0` by vmangos — see [`InspectHonorStats::unknown_old1`].
|
||||
pub unknown_old3: u16,
|
||||
/// Lifetime honorable kills (`PLAYER_FIELD_LIFETIME_HONORBALE_KILLS`, vmangos's spelling).
|
||||
pub lifetime_hk: u32,
|
||||
/// Lifetime dishonorable kills.
|
||||
pub lifetime_dhk: u32,
|
||||
/// Yesterday's honor points (`PLAYER_FIELD_YESTERDAY_CONTRIBUTION`).
|
||||
pub yesterday_honor: u32,
|
||||
/// Last week's honor points.
|
||||
pub last_week_honor: u32,
|
||||
/// This week's honor points (a 1.6.0 addition; live for 5875).
|
||||
pub this_week_honor: u32,
|
||||
/// Last week's **standing** — the numeric ladder position, not a rank
|
||||
/// (`PLAYER_FIELD_LAST_WEEK_RANK`; `0` = unranked that week).
|
||||
pub last_week_rank: u32,
|
||||
/// The rank progress bar, `0..255` **within the target's current rank** (a 1.6.0 addition;
|
||||
/// live for 5875). See [`crate::messages::ObjectFields::player_honor_rank_bar`] for the
|
||||
/// server's computation and the negative-rank wrap.
|
||||
pub rank_bar: u8,
|
||||
}
|
||||
|
||||
impl InspectHonorStats {
|
||||
/// [`Self::session_kills`] split into `(honorable, dishonorable)` — the `TWO_SHORT` packing
|
||||
/// the descriptor field uses (low half, then high half). The wire carries the dword whole
|
||||
/// here, so unlike the descriptor accessor this split is ours to do.
|
||||
pub fn session_kills(&self) -> (u16, u16) {
|
||||
(self.session_kills as u16, (self.session_kills >> 16) as u16)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the `MSG_INSPECT_HONOR_STATS` **reply** (see [`InspectHonorStats`]): 50 bytes in
|
||||
/// `AppendBodyTo` order. A short body errors (`UnexpectedEof`) rather than yielding a
|
||||
/// half-populated struct.
|
||||
///
|
||||
/// The opcode is an **`MSG_`**: the same number 0x2D6 carries our request *and* the server's
|
||||
/// reply, and the two bodies are different shapes (8 bytes vs 50). Nothing has to disambiguate
|
||||
/// them, because direction does it: [`super::parse_server`] is fed only by the world *reader*, so
|
||||
/// every 0x2D6 body it ever sees is a reply. Our own request never passes through here — it is
|
||||
/// built by [`inspect_honor_stats`] and handed straight to the writer.
|
||||
pub(super) fn read_inspect_honor_stats(r: &mut &[u8]) -> io::Result<InspectHonorStats> {
|
||||
Ok(InspectHonorStats {
|
||||
player_guid: read_u64_le(r)?,
|
||||
highest_rank: read_u8(r)?,
|
||||
session_kills: read_u32_le(r)?,
|
||||
yesterday_hk: read_u16_le(r)?,
|
||||
unknown_old1: read_u16_le(r)?,
|
||||
last_week_hk: read_u16_le(r)?,
|
||||
unknown_old2: read_u16_le(r)?,
|
||||
this_week_hk: read_u16_le(r)?,
|
||||
unknown_old3: read_u16_le(r)?,
|
||||
lifetime_hk: read_u32_le(r)?,
|
||||
lifetime_dhk: read_u32_le(r)?,
|
||||
yesterday_honor: read_u32_le(r)?,
|
||||
last_week_honor: read_u32_le(r)?,
|
||||
this_week_honor: read_u32_le(r)?,
|
||||
last_week_rank: read_u32_le(r)?,
|
||||
rank_bar: read_u8(r)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Body of the `MSG_INSPECT_HONOR_STATS` **request**: the inspected player's full 8-byte GUID and
|
||||
/// nothing else (VERIFIED vmangos `WorldPackets::Misc::InspectHonorStats::ReadFromWorldPacket`,
|
||||
/// `Server/Packets/Misc.cpp:93-96` — `recv_data >> guid`, a raw `uint64`, not a packed guid).
|
||||
///
|
||||
/// Server gates (`MiscHandler.cpp:962-972`) are the same three `CMSG_INSPECT` applies: the target
|
||||
/// must be an online player, within `INSPECT_DISTANCE` (10.0), and not a valid attack target. Each
|
||||
/// refusal is a silent `return` — there is no error reply, so a consumer must not block on an
|
||||
/// answer that may never come. Unlike `CMSG_INSPECT`, this handler does **not** set our selection
|
||||
/// (`HandleInspectOpcode` opens with `SetSelectionGuid`; this one does not), which is why the send
|
||||
/// lives in `world::writer::pvp` rather than `writer::selection`.
|
||||
pub fn inspect_honor_stats(guid: u64) -> Vec<u8> {
|
||||
guid.to_le_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// An honor payout (`SMSG_PVP_CREDIT`, opcode `0x28C`/652 — VERIFIED vmangos
|
||||
/// `Opcodes_1_12_1.h`; already named in `opcode_names`). A 16-byte body in the order
|
||||
/// `WorldPackets::Misc::PvpCredit::AppendBodyTo` writes it (`Server/Packets/Misc.cpp:376-381`),
|
||||
/// sent from `HonorMgr::SendPVPCredit` (`HonorMgr.cpp:1061-1093`) on every honor row the server
|
||||
/// books — **including a dishonorable one**, which is why [`PvpCredit::honor`] is signed.
|
||||
///
|
||||
/// This is the *only* notice of a specific payout: the descriptor's contribution fields move too,
|
||||
/// but they are running totals with no attribution. The chat line and the floating combat text
|
||||
/// both have to come from here.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PvpCredit {
|
||||
/// Honor points, truncated to an integer server-side (`static_cast<int32>(honor)`).
|
||||
///
|
||||
/// **Signed, and genuinely negative for a dishonorable kill**: `HonorMgr::Add` computes
|
||||
/// `honor = (type == DISHONORABLE) ? -cp : cp` (`HonorMgr.cpp:807`) and hands *that* to
|
||||
/// `SendPVPCredit` (`HonorMgr.cpp:833`). A `u32` here would read a DK penalty as ~4 billion
|
||||
/// honor.
|
||||
pub honor: i32,
|
||||
/// Who we killed. **`0` means there is no victim** — vmangos only fills the guid inside
|
||||
/// `if (victim)` (`HonorMgr.cpp:1069-1071`), and it passes the *pre-fallback* `realSource`, so
|
||||
/// an honor row booked with no source at all arrives with a zero guid. The UI needs a
|
||||
/// victim-less phrasing rather than resolving a name for guid 0.
|
||||
pub victim_guid: u64,
|
||||
/// The victim's **internal** rank — the same `[0..18]` domain as the descriptor's rank bytes
|
||||
/// and [`InspectHonorStats::highest_rank`], and therefore a direct index into the
|
||||
/// `PVP_RANK_<rank>_<team>` GlobalStrings. NOT the visual rank the badge texture uses; do the
|
||||
/// `internal > 4 ? internal - 4 : -internal` conversion (`HonorMgr.cpp:991`) at the display
|
||||
/// edge if a badge is wanted, and do not do it twice.
|
||||
///
|
||||
/// VERIFIED `HonorMgr::SendPVPCredit` (`HonorMgr.cpp:1078-1089`): a **player** victim sends
|
||||
/// `GetHonorMgr().GetRank().rank`, a **creature** victim sends `19` iff it is a racial leader
|
||||
/// and otherwise leaves the field at its `0` default.
|
||||
///
|
||||
/// One quirk to observe rather than imitate: for a player victim vmangos **floors the value at
|
||||
/// 5** (`if (!rank) rank = (HONOR_RANK_COUNT - POSITIVE_HONOR_RANK_COUNT) + 1`, i.e. "at least
|
||||
/// Scout"), with the comment *"Never display just 'HK:' without rank name"*. That is the
|
||||
/// server working around a client that would otherwise print an empty rank name — it is
|
||||
/// **server behaviour we receive**, not a rule we should re-implement: we render whatever
|
||||
/// number arrives, and an unranked-victim `0` from some other server must still come out
|
||||
/// sanely at the display edge.
|
||||
///
|
||||
/// `i32` because that is the wire type; nothing vmangos sends here is negative.
|
||||
pub victim_rank: i32,
|
||||
}
|
||||
|
||||
/// Read `SMSG_PVP_CREDIT` (see [`PvpCredit`]): `i32 honor`, `u64 victimGuid`, `i32 victimRank`.
|
||||
pub(super) fn read_pvp_credit(r: &mut &[u8]) -> io::Result<PvpCredit> {
|
||||
Ok(PvpCredit {
|
||||
honor: read_i32_le(r)?,
|
||||
victim_guid: read_u64_le(r)?,
|
||||
victim_rank: read_i32_le(r)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The 50-byte reply, hand-built in `InspectHonorStatsResponse::AppendBodyTo` order with the
|
||||
/// field boundaries commented — a level-60 target with a rank-11 lifetime best, kills in every
|
||||
/// bucket, and a bar three-quarters of the way through the current rank.
|
||||
#[test]
|
||||
fn inspect_honor_stats_golden() {
|
||||
#[rustfmt::skip]
|
||||
let body: [u8; 50] = [
|
||||
// [0..8) u64 playerGuid = 0x0000_0001_0000_2AB3
|
||||
0xB3, 0x2A, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
|
||||
// [8] u8 highestRank = 11 (internal)
|
||||
0x0B,
|
||||
// [9..13) u32 sessionKills = 0x0002_0011 — 17 HK low, 2 DK high
|
||||
0x11, 0x00, 0x02, 0x00,
|
||||
// [13..15) u16 yesterdayHK = 41
|
||||
0x29, 0x00,
|
||||
// [15..17) u16 unknownOld1 = 0 (server always writes 0)
|
||||
0x00, 0x00,
|
||||
// [17..19) u16 lastWeekHK = 420
|
||||
0xA4, 0x01,
|
||||
// [19..21) u16 unknownOld2 = 0
|
||||
0x00, 0x00,
|
||||
// [21..23) u16 thisWeekHK = 123
|
||||
0x7B, 0x00,
|
||||
// [23..25) u16 unknownOld3 = 0
|
||||
0x00, 0x00,
|
||||
// [25..29) u32 lifetimeHK = 3907
|
||||
0x43, 0x0F, 0x00, 0x00,
|
||||
// [29..33) u32 lifetimeDHK = 12
|
||||
0x0C, 0x00, 0x00, 0x00,
|
||||
// [33..37) u32 yesterdayHonor = 640
|
||||
0x80, 0x02, 0x00, 0x00,
|
||||
// [37..41) u32 lastWeekHonor = 8431
|
||||
0xEF, 0x20, 0x00, 0x00,
|
||||
// [41..45) u32 thisWeekHonor = 1250
|
||||
0xE2, 0x04, 0x00, 0x00,
|
||||
// [45..49) u32 lastWeekRank = 57 (the STANDING)
|
||||
0x39, 0x00, 0x00, 0x00,
|
||||
// [49] u8 rankBar = 191
|
||||
0xBF,
|
||||
];
|
||||
let mut r = body.as_slice();
|
||||
let stats = read_inspect_honor_stats(&mut r).unwrap();
|
||||
assert!(r.is_empty(), "the whole 50-byte body is consumed");
|
||||
assert_eq!(
|
||||
stats,
|
||||
InspectHonorStats {
|
||||
player_guid: 0x0000_0001_0000_2AB3,
|
||||
highest_rank: 11,
|
||||
session_kills: 0x0002_0011,
|
||||
yesterday_hk: 41,
|
||||
unknown_old1: 0,
|
||||
last_week_hk: 420,
|
||||
unknown_old2: 0,
|
||||
this_week_hk: 123,
|
||||
unknown_old3: 0,
|
||||
lifetime_hk: 3_907,
|
||||
lifetime_dhk: 12,
|
||||
yesterday_honor: 640,
|
||||
last_week_honor: 8_431,
|
||||
this_week_honor: 1_250,
|
||||
last_week_rank: 57,
|
||||
rank_bar: 191,
|
||||
}
|
||||
);
|
||||
// The packed today-kills dword splits the same way the descriptor's TWO_SHORT does.
|
||||
assert_eq!(stats.session_kills(), (17, 2));
|
||||
}
|
||||
|
||||
/// A truncated reply must ERROR, not panic and not yield a half-populated struct — the wire is
|
||||
/// hostile input. 49 bytes is the nastiest case: everything but the trailing `rankBar`, which
|
||||
/// is exactly what a pre-1.6.1 server would send.
|
||||
#[test]
|
||||
fn inspect_honor_stats_truncated_body_errors() {
|
||||
let full = [0u8; 50];
|
||||
for len in [0usize, 8, 13, 49] {
|
||||
let mut r = &full[..len];
|
||||
assert!(
|
||||
read_inspect_honor_stats(&mut r).is_err(),
|
||||
"a {len}-byte inspect-honor body must be rejected"
|
||||
);
|
||||
}
|
||||
// …and the full length still parses, so the loop above is testing truncation and not a
|
||||
// decoder that rejects everything.
|
||||
let mut r = full.as_slice();
|
||||
assert!(read_inspect_honor_stats(&mut r).is_ok());
|
||||
}
|
||||
|
||||
/// The request is the bare 8-byte guid — byte-exact against
|
||||
/// `InspectHonorStats::ReadFromWorldPacket`'s single `recv_data >> guid`.
|
||||
#[test]
|
||||
fn inspect_honor_stats_request_golden() {
|
||||
assert_eq!(
|
||||
inspect_honor_stats(0x0000_0001_0000_2AB3),
|
||||
vec![0xB3, 0x2A, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00],
|
||||
);
|
||||
// No sentinel handling: a zero guid is encoded as-is (the server answers by not answering).
|
||||
assert_eq!(inspect_honor_stats(0), vec![0u8; 8]);
|
||||
}
|
||||
|
||||
/// The 16-byte credit body, hand-built in `PvpCredit::AppendBodyTo` order.
|
||||
#[test]
|
||||
fn pvp_credit_golden() {
|
||||
#[rustfmt::skip]
|
||||
let body: [u8; 16] = [
|
||||
// [0..4) i32 honor = 143
|
||||
0x8F, 0x00, 0x00, 0x00,
|
||||
// [4..12) u64 victimGuid = 0x0000_0001_0000_2AB3
|
||||
0xB3, 0x2A, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
|
||||
// [12..16) i32 victimRank = 11 (INTERNAL rank — the PVP_RANK_11_<team> key)
|
||||
0x0B, 0x00, 0x00, 0x00,
|
||||
];
|
||||
let mut r = body.as_slice();
|
||||
let credit = read_pvp_credit(&mut r).unwrap();
|
||||
assert!(r.is_empty(), "the whole 16-byte body is consumed");
|
||||
assert_eq!(
|
||||
credit,
|
||||
PvpCredit {
|
||||
honor: 143,
|
||||
victim_guid: 0x0000_0001_0000_2AB3,
|
||||
victim_rank: 11,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A **dishonorable** kill rides the same packet with a negative `honor`
|
||||
/// (`HonorMgr.cpp:807`, `honor = -cp`) — the reason the field is `i32`. Decoded as `u32` this
|
||||
/// body would read as 4 294 967 291 honor.
|
||||
#[test]
|
||||
fn pvp_credit_dishonorable_kill_is_negative_honor() {
|
||||
let mut body = Vec::with_capacity(16);
|
||||
body.extend_from_slice(&(-5i32).to_le_bytes());
|
||||
body.extend_from_slice(&0x0000_0001_0000_2AB3u64.to_le_bytes());
|
||||
body.extend_from_slice(&5i32.to_le_bytes()); // floored to "Scout" server-side
|
||||
let mut r = body.as_slice();
|
||||
let credit = read_pvp_credit(&mut r).unwrap();
|
||||
assert!(r.is_empty());
|
||||
assert_eq!(credit.honor, -5);
|
||||
assert_eq!(credit.victim_rank, 5);
|
||||
}
|
||||
|
||||
/// The victim-less shape the UI has to phrase differently: vmangos fills the guid only inside
|
||||
/// `if (victim)` and passes the pre-fallback `realSource`, so an honor row with no source
|
||||
/// arrives as a zero guid with the rank left at its `0` default (which is also what a
|
||||
/// non-racial-leader creature victim sends).
|
||||
#[test]
|
||||
fn pvp_credit_zero_guid_means_no_victim() {
|
||||
let mut body = Vec::with_capacity(16);
|
||||
body.extend_from_slice(&5i32.to_le_bytes());
|
||||
body.extend_from_slice(&0u64.to_le_bytes()); // no victim
|
||||
body.extend_from_slice(&0i32.to_le_bytes()); // and so no rank
|
||||
let mut r = body.as_slice();
|
||||
let credit = read_pvp_credit(&mut r).unwrap();
|
||||
assert!(r.is_empty());
|
||||
assert_eq!(credit.victim_guid, 0, "zero guid = no victim");
|
||||
assert_eq!(credit.victim_rank, 0);
|
||||
}
|
||||
|
||||
/// A truncated credit body errors rather than panicking.
|
||||
#[test]
|
||||
fn pvp_credit_truncated_body_errors() {
|
||||
let full = [0u8; 16];
|
||||
for len in [0usize, 4, 12, 15] {
|
||||
let mut r = &full[..len];
|
||||
assert!(
|
||||
read_pvp_credit(&mut r).is_err(),
|
||||
"a {len}-byte credit body must be rejected"
|
||||
);
|
||||
}
|
||||
let mut r = full.as_slice();
|
||||
assert!(read_pvp_credit(&mut r).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,14 @@ const FIELD_PLAYER_BYTES_2: u16 = 194;
|
||||
// PLAYER_BYTES_3 = UNIT_END(188) + 0x7: the low u16 packs `gender | (drunk & 0xFFFE)` (vmangos
|
||||
// `Player::SetDrunkValue`), so byte 1 is the drunk value's high byte — exactly the byte the
|
||||
// reference client reads at `[[unit+0xe68]+0x1d]` (wow-re `drunk_fraction_5e2a90`).
|
||||
// Byte 2 = the city-protector title (a race id), byte 3 = the **current** honor rank
|
||||
// (`PLAYER_BYTES_3_OFFSET_HONOR_RANK`, vmangos `Player.h:351-356`).
|
||||
//
|
||||
// **PUBLIC** (VERIFIED vmangos `UpdateFields_1_12_1.h:125`) — and that flag is the whole reason a
|
||||
// foreign player's rank is knowable: this is the ONE honor value that streams for every visible
|
||||
// player. The rest of the honor block (1250..1260 below, plus `PLAYER_FIELD_BYTES`' highest-rank
|
||||
// byte) is PRIVATE, which is why anyone else's honor stats have to be *asked* for over the wire
|
||||
// (`MSG_INSPECT_HONOR_STATS`, [`crate::messages::InspectHonorStats`]).
|
||||
const FIELD_PLAYER_BYTES_3: u16 = 195;
|
||||
// PLAYER_QUEST_LOG_1_1 = UNIT_END(188) + 0xA = 198 (vmangos `UpdateFields_1_12_1.h:128`; this block
|
||||
// precedes the CONTAINER-onward hex-comment drift — enum arithmetic and hex comment agree here, and
|
||||
@@ -476,6 +484,50 @@ const FIELD_PLAYER_FIELD_BYTES: u16 = 1222; // UNIT_END+0x40A, BYTES — vmangos
|
||||
// flags / comboPoints / actionBars / HIGHEST_HONOR_RANK
|
||||
// (`Player.h` PLAYER_FIELD_BYTES_OFFSET_*).
|
||||
|
||||
// The **honor block** — the eleven fields the two PvP/honor panes read, a contiguous run
|
||||
// UNIT_END(188) + 0x426..0x430 (VERIFIED vmangos `UpdateFields_1_12_1.h:288-298` **enum
|
||||
// arithmetic**; its inline `// 0x4DC`-style comments carry the usual 6-low drift and are not
|
||||
// copied). The run needs no fresh anchor of its own: it closes against already-tested neighbours
|
||||
// at BOTH ends — the 12-slot `BUYBACK_TIMESTAMP_1` array ends exactly where it begins
|
||||
// (1238 + 12 = 1250) and `WATCHED_FACTION_INDEX`, itself chain-locked to the tested COINAGE, sits
|
||||
// exactly one past its end (1260 + 1 = 1261). Eleven fields is precisely the size of that gap.
|
||||
//
|
||||
// **Every one of them is PRIVATE** — the server sends them only on our OWN descriptor — and that
|
||||
// is the mechanical reason the character window's Honor tab works for the local player and for
|
||||
// nobody else. A foreign player's honor stats are not on the wire at all until we ask for them
|
||||
// with `MSG_INSPECT_HONOR_STATS` ([`crate::messages::InspectHonorStats`]); the sole honor value
|
||||
// that *does* stream for every visible player is the current rank in the PUBLIC
|
||||
// `PLAYER_BYTES_3`(195) byte 3 above.
|
||||
//
|
||||
// Types, in run order — `+0x426..0x429` TWO_SHORT, `+0x42A..0x42F` INT, `+0x430` BYTES:
|
||||
//
|
||||
// The four kill counters are TWO_SHORT, `(honorable, dishonorable)` in the low/high halves. Only
|
||||
// SESSION_KILLS is genuinely written as two halves (vmangos `HonorMgr.cpp:913-914`,
|
||||
// `SetUInt16Value(…, 0, todayHK)` / `(…, 1, todayDK)`); the other three go out as a whole dword
|
||||
// (`SetUInt32Value(PLAYER_FIELD_YESTERDAY_KILLS, yesterdayKills)`, `HonorMgr.cpp:917/924/929`), so
|
||||
// their high half is always 0 from this server. The descriptor type is TWO_SHORT regardless and
|
||||
// the real client reads halves, so we decode halves — see the accessors in `player.rs`.
|
||||
//
|
||||
// The six INT fields are the three honor-point "contribution" totals, the two lifetime kill
|
||||
// counts, and LAST_WEEK_RANK — which is the weekly **standing** (our ladder position), not a rank.
|
||||
// All six are `SetUInt32Value` writes from `HonorMgr::Update` (`HonorMgr.cpp:917-935`). Our two
|
||||
// lifetime names fix vmangos's own typo: the header spells them
|
||||
// `PLAYER_FIELD_LIFETIME_HONORBALE_KILLS` / `…DISHONORBALE…`.
|
||||
//
|
||||
// BYTES2's byte 0 is `HONOR_RANK_BAR` (`PLAYER_FIELD_BYTES_2_OFFSET_HONOR_RANK_BAR`, vmangos
|
||||
// `Player.h:366-372`); its bytes 1..3 are a flags byte and two unknowns this server never writes.
|
||||
const FIELD_PLAYER_FIELD_SESSION_KILLS: u16 = 1250;
|
||||
const FIELD_PLAYER_FIELD_YESTERDAY_KILLS: u16 = 1251;
|
||||
const FIELD_PLAYER_FIELD_LAST_WEEK_KILLS: u16 = 1252;
|
||||
const FIELD_PLAYER_FIELD_THIS_WEEK_KILLS: u16 = 1253;
|
||||
const FIELD_PLAYER_FIELD_THIS_WEEK_CONTRIBUTION: u16 = 1254;
|
||||
const FIELD_PLAYER_FIELD_LIFETIME_HONORABLE_KILLS: u16 = 1255;
|
||||
const FIELD_PLAYER_FIELD_LIFETIME_DISHONORABLE_KILLS: u16 = 1256;
|
||||
const FIELD_PLAYER_FIELD_YESTERDAY_CONTRIBUTION: u16 = 1257;
|
||||
const FIELD_PLAYER_FIELD_LAST_WEEK_CONTRIBUTION: u16 = 1258;
|
||||
const FIELD_PLAYER_FIELD_LAST_WEEK_RANK: u16 = 1259;
|
||||
const FIELD_PLAYER_FIELD_BYTES2: u16 = 1260;
|
||||
|
||||
/// The number of `PLAYER_SKILL_INFO` slots the descriptor reserves (384 fields ÷ 3 per slot;
|
||||
/// vmangos `UpdateFields_1_12_1.h`). vmangos itself only ever *populates* `PLAYER_MAX_SKILLS` (127,
|
||||
/// `Player.h:69`) of these — the last reserved slot is never written; `player_skill` honors the
|
||||
|
||||
@@ -560,10 +560,148 @@ impl ObjectFields {
|
||||
/// `PLAYER_FIELD_BYTES_OFFSET_HIGHEST_HONOR_RANK`, written by `HonorMgr`): what the client's
|
||||
/// item-usable gate compares an item's `RequiredHonorRank` against (`0x5ea930` reads the
|
||||
/// values byte at `+0x102b`). `None` before the field streams; rank 0 = never ranked.
|
||||
///
|
||||
/// **Not [`Self::player_pvp_rank`]** — that is the *current* rank, a different byte of a
|
||||
/// different (and PUBLIC) field, and the two diverge the moment a rank is lost: vmangos only
|
||||
/// ever raises this one (`HonorMgr.cpp:894-895`, `SetHighestRank` iff the new visual rank is
|
||||
/// strictly greater), so a demoted player keeps their old title here while the current rank
|
||||
/// falls. The Honor pane shows the current rank; an item requirement reads this one.
|
||||
pub fn player_honor_rank(&self) -> Option<u8> {
|
||||
self.get_u32(FIELD_PLAYER_FIELD_BYTES)
|
||||
.map(|b| ((b >> 24) & 0xff) as u8)
|
||||
}
|
||||
/// `PLAYER_BYTES_3` byte 3 — the player's **current** honor rank (vmangos
|
||||
/// `PLAYER_BYTES_3_OFFSET_HONOR_RANK`, written by `HonorMgr::Update`, `HonorMgr.cpp:900`).
|
||||
/// The *internal* rank — `HonorRankInfo::rank`, whose own comment reads "internal range
|
||||
/// [0..18]": 0 = unranked, 1..4 the negative ranks (Pariah…Dishonored), 5 up the positive ones
|
||||
/// (Scout/Private…). It indexes the `PVP_RANK_<rank>_<team>` GlobalStrings directly; the
|
||||
/// number the UI *draws* is the **visual** rank (`rank > 4 ? rank - 4 : -rank`,
|
||||
/// `HonorMgr.cpp:991`), which is a derivation, not a field — do it at the display edge, not
|
||||
/// here, and only once.
|
||||
///
|
||||
/// **This field is PUBLIC** (vmangos `UpdateFields_1_12_1.h:125`), the only honor value that
|
||||
/// is: it streams for *every visible player*, not just for us. That is what makes a foreign
|
||||
/// unit's rank answerable at all — the reference inspect pane's `UnitPVPRank("target")` reads
|
||||
/// this byte off the target's own descriptor, with no packet involved. Everything else on the
|
||||
/// Honor tab is PRIVATE and has to be requested
|
||||
/// ([`crate::messages::read_inspect_honor_stats`]).
|
||||
///
|
||||
/// Contrast [`Self::player_honor_rank`], the *highest lifetime* rank in a PRIVATE field's
|
||||
/// byte 3; [`Self::player_drunk_byte`] reads byte 1 of this same dword. `None` before the
|
||||
/// field streams.
|
||||
pub fn player_pvp_rank(&self) -> Option<u8> {
|
||||
self.get_u32(FIELD_PLAYER_BYTES_3)
|
||||
.map(|b| ((b >> 24) & 0xff) as u8)
|
||||
}
|
||||
/// `PLAYER_BYTES_3` **byte 2** — the **city-protector title**
|
||||
/// (`PLAYER_BYTES_3_OFFSET_CITY_PROTECTOR_TITLE`, vmangos `Player.h:354`, whose own comment
|
||||
/// says the value is a **race id**). PUBLIC, like [`Self::player_pvp_rank`] beside it in the
|
||||
/// same dword.
|
||||
///
|
||||
/// Its one consumer is `UnitPVPName`, which appends `"\n"` plus the `PVP_MEDAL<n>`
|
||||
/// GlobalString when the byte is non-zero (wow-re `system/ui/scratch/honor-panel-law.md`,
|
||||
/// `0x609370` leg C) — the "Protector of Stormwind" line under a city defender's name.
|
||||
///
|
||||
/// **vmangos never writes it**, so it is 0 for every character on this server and the medal
|
||||
/// line never renders. Decoded anyway: the field is real, the binding that reads it is real,
|
||||
/// and a decoder that answered `None` here would make a live divergence look like an unbuilt
|
||||
/// feature the day a server does write it. `None` before the field streams.
|
||||
pub fn player_pvp_medal(&self) -> Option<u8> {
|
||||
self.get_u32(FIELD_PLAYER_BYTES_3)
|
||||
.map(|b| ((b >> 16) & 0xff) as u8)
|
||||
}
|
||||
/// `PLAYER_FIELD_SESSION_KILLS` — **today's** `(honorable, dishonorable)` kills, low half then
|
||||
/// high half. The one honor kill counter vmangos genuinely writes as two shorts
|
||||
/// (`HonorMgr.cpp:913-914`, `SetUInt16Value(…, 0, todayHK)` / `(…, 1, todayDK)`), so both
|
||||
/// halves are live here. PRIVATE — self only; `None` before the field streams.
|
||||
///
|
||||
/// Despite the name the server recomputes it from *today's* honor-CP rows, not from
|
||||
/// session start (`HonorMgr::Update`'s `itr.date == today` arm) — the reference's
|
||||
/// `GetPVPSessionStats()` pair.
|
||||
pub fn player_session_kills(&self) -> Option<(u16, u16)> {
|
||||
self.get_u16_pair(FIELD_PLAYER_FIELD_SESSION_KILLS)
|
||||
}
|
||||
/// `PLAYER_FIELD_YESTERDAY_KILLS` — `(honorable, dishonorable)` kills for yesterday.
|
||||
///
|
||||
/// **The dishonorable half is always `0` from this server, and that is not a decode bug.**
|
||||
/// The descriptor type really is TWO_SHORT and the real client really does read halves, but
|
||||
/// vmangos writes the field as a whole dword (`HonorMgr.cpp:917`,
|
||||
/// `SetUInt32Value(PLAYER_FIELD_YESTERDAY_KILLS, yesterdayKills)`) — so the high half never
|
||||
/// receives the DK count. We decode the wire faithfully rather than collapsing the field to a
|
||||
/// `u32`: the shape belongs to the descriptor, and a different server (or a fixed vmangos)
|
||||
/// would fill it. Same for [`Self::player_last_week_kills`] and
|
||||
/// [`Self::player_this_week_kills`]. PRIVATE — self only; `None` before the field streams.
|
||||
pub fn player_yesterday_kills(&self) -> Option<(u16, u16)> {
|
||||
self.get_u16_pair(FIELD_PLAYER_FIELD_YESTERDAY_KILLS)
|
||||
}
|
||||
/// `PLAYER_FIELD_LAST_WEEK_KILLS` — `(honorable, dishonorable)` kills for last week. The
|
||||
/// high half is always `0` from vmangos (`HonorMgr.cpp:929` writes the whole dword); see
|
||||
/// [`Self::player_yesterday_kills`]. PRIVATE — self only.
|
||||
pub fn player_last_week_kills(&self) -> Option<(u16, u16)> {
|
||||
self.get_u16_pair(FIELD_PLAYER_FIELD_LAST_WEEK_KILLS)
|
||||
}
|
||||
/// `PLAYER_FIELD_THIS_WEEK_KILLS` — `(honorable, dishonorable)` kills for the current week.
|
||||
/// The high half is always `0` from vmangos (`HonorMgr.cpp:924` writes the whole dword); see
|
||||
/// [`Self::player_yesterday_kills`]. PRIVATE — self only.
|
||||
pub fn player_this_week_kills(&self) -> Option<(u16, u16)> {
|
||||
self.get_u16_pair(FIELD_PLAYER_FIELD_THIS_WEEK_KILLS)
|
||||
}
|
||||
/// `PLAYER_FIELD_THIS_WEEK_CONTRIBUTION` — honor points earned this week (vmangos's
|
||||
/// "contribution": the summed CP of the week's honor rows, floored at 0 —
|
||||
/// `HonorMgr.cpp:925`). PRIVATE — self only; `None` before the field streams.
|
||||
pub fn player_this_week_contribution(&self) -> Option<u32> {
|
||||
self.get_u32(FIELD_PLAYER_FIELD_THIS_WEEK_CONTRIBUTION)
|
||||
}
|
||||
/// `PLAYER_FIELD_YESTERDAY_CONTRIBUTION` — honor points earned yesterday
|
||||
/// (`HonorMgr.cpp:918`). PRIVATE — self only.
|
||||
pub fn player_yesterday_contribution(&self) -> Option<u32> {
|
||||
self.get_u32(FIELD_PLAYER_FIELD_YESTERDAY_CONTRIBUTION)
|
||||
}
|
||||
/// `PLAYER_FIELD_LAST_WEEK_CONTRIBUTION` — honor points earned last week
|
||||
/// (`HonorMgr.cpp:930`). This is the figure the weekly rank calculation actually consumed.
|
||||
/// PRIVATE — self only.
|
||||
pub fn player_last_week_contribution(&self) -> Option<u32> {
|
||||
self.get_u32(FIELD_PLAYER_FIELD_LAST_WEEK_CONTRIBUTION)
|
||||
}
|
||||
/// `PLAYER_FIELD_LAST_WEEK_RANK` — last week's **standing**, i.e. our numeric position on the
|
||||
/// server-wide honor ladder for that week (vmangos `m_standing`, `HonorMgr.cpp:931`), NOT an
|
||||
/// honor rank. `0` means "unranked that week". PRIVATE — self only.
|
||||
pub fn player_last_week_rank(&self) -> Option<u32> {
|
||||
self.get_u32(FIELD_PLAYER_FIELD_LAST_WEEK_RANK)
|
||||
}
|
||||
/// `PLAYER_FIELD_LIFETIME_HONORBALE_KILLS` (vmangos's spelling) — the lifetime honorable-kill
|
||||
/// total (`HonorMgr.cpp:934`). PRIVATE — self only; `None` before the field streams.
|
||||
pub fn player_lifetime_honorable_kills(&self) -> Option<u32> {
|
||||
self.get_u32(FIELD_PLAYER_FIELD_LIFETIME_HONORABLE_KILLS)
|
||||
}
|
||||
/// `PLAYER_FIELD_LIFETIME_DISHONORBALE_KILLS` (vmangos's spelling) — the lifetime
|
||||
/// dishonorable-kill total (`HonorMgr.cpp:935`). PRIVATE — self only.
|
||||
pub fn player_lifetime_dishonorable_kills(&self) -> Option<u32> {
|
||||
self.get_u32(FIELD_PLAYER_FIELD_LIFETIME_DISHONORABLE_KILLS)
|
||||
}
|
||||
/// `PLAYER_FIELD_BYTES2` byte 0 — the **honor rank progress bar**
|
||||
/// (`PLAYER_FIELD_BYTES_2_OFFSET_HONOR_RANK_BAR`, vmangos `HonorMgr.cpp:905-909`):
|
||||
///
|
||||
/// ```text
|
||||
/// honorBar = uint32(|rankPoints|);
|
||||
/// honorBar = uint8(((honorBar - rank.minRP) / (rank.maxRP - rank.minRP))
|
||||
/// * (rank.positive ? 255 : -255));
|
||||
/// ```
|
||||
///
|
||||
/// so the byte is **how far through the CURRENT rank we are, scaled 0..255** — not an absolute
|
||||
/// honor total, and it resets each time the rank changes (`minRP`/`maxRP` are that rank's own
|
||||
/// bounds, `HonorMgr::CalculateRankInfo`). A *negative* rank multiplies by `-255`, and the
|
||||
/// C++ cast of a negative float to `uint8` wraps rather than clamping, so the byte a demoted
|
||||
/// player carries is the wrapped product. We carry the byte the wire sent; interpreting the
|
||||
/// negative-rank case is the display edge's problem, not the decoder's.
|
||||
///
|
||||
/// The field only exists from client 1.6.1 on (vmangos guards the write with
|
||||
/// `SUPPORTED_CLIENT_BUILD >= CLIENT_BUILD_1_6_1`), which is live for build 5875. PRIVATE —
|
||||
/// self only; `None` before the field streams.
|
||||
pub fn player_honor_rank_bar(&self) -> Option<u8> {
|
||||
self.get_u32(FIELD_PLAYER_FIELD_BYTES2)
|
||||
.map(|b| (b & 0xff) as u8)
|
||||
}
|
||||
/// `UNIT_FIELD_BYTES_0` byte 0 — the unit's race. `None` when the field wasn't sent.
|
||||
pub fn unit_race(&self) -> Option<u8> {
|
||||
self.unit_bytes_0().map(|b| (b & 0xff) as u8)
|
||||
|
||||
@@ -96,6 +96,40 @@ fn combat_stat_indices_chain_to_the_tested_anchors() {
|
||||
FIELD_PLAYER_CHARACTER_POINTS2 + 1
|
||||
);
|
||||
assert_eq!(FIELD_PLAYER_TRACK_RESOURCES, UNIT_END + 0x395);
|
||||
// The honor block (decision 1512) is eleven contiguous fields filling EXACTLY the gap between
|
||||
// two already-anchored neighbours — the 12-slot buyback-timestamp array below it and the
|
||||
// watched-faction slot above it — so it needs no anchor of its own. If any of the three ever
|
||||
// drifts, one of these two closures breaks.
|
||||
assert_eq!(
|
||||
FIELD_PLAYER_BUYBACK_TIMESTAMP_1 + 12,
|
||||
FIELD_PLAYER_FIELD_SESSION_KILLS,
|
||||
"the honor block opens where the 12-slot buyback-timestamp array ends"
|
||||
);
|
||||
assert_eq!(
|
||||
FIELD_PLAYER_FIELD_BYTES2 + 1,
|
||||
FIELD_PLAYER_WATCHED_FACTION_INDEX,
|
||||
"and closes one below the watched-faction slot"
|
||||
);
|
||||
assert_eq!(FIELD_PLAYER_FIELD_SESSION_KILLS, UNIT_END + 0x426);
|
||||
assert_eq!(FIELD_PLAYER_FIELD_YESTERDAY_KILLS, UNIT_END + 0x427);
|
||||
assert_eq!(FIELD_PLAYER_FIELD_LAST_WEEK_KILLS, UNIT_END + 0x428);
|
||||
assert_eq!(FIELD_PLAYER_FIELD_THIS_WEEK_KILLS, UNIT_END + 0x429);
|
||||
assert_eq!(FIELD_PLAYER_FIELD_THIS_WEEK_CONTRIBUTION, UNIT_END + 0x42A);
|
||||
assert_eq!(
|
||||
FIELD_PLAYER_FIELD_LIFETIME_HONORABLE_KILLS,
|
||||
UNIT_END + 0x42B
|
||||
);
|
||||
assert_eq!(
|
||||
FIELD_PLAYER_FIELD_LIFETIME_DISHONORABLE_KILLS,
|
||||
UNIT_END + 0x42C
|
||||
);
|
||||
assert_eq!(FIELD_PLAYER_FIELD_YESTERDAY_CONTRIBUTION, UNIT_END + 0x42D);
|
||||
assert_eq!(FIELD_PLAYER_FIELD_LAST_WEEK_CONTRIBUTION, UNIT_END + 0x42E);
|
||||
assert_eq!(FIELD_PLAYER_FIELD_LAST_WEEK_RANK, UNIT_END + 0x42F);
|
||||
assert_eq!(FIELD_PLAYER_FIELD_BYTES2, UNIT_END + 0x430);
|
||||
// The CURRENT rank rides the appearance dword instead, nowhere near that block — which is the
|
||||
// whole point: PLAYER_BYTES_3 is PUBLIC, so it is the one honor value a foreign player streams.
|
||||
assert_eq!(FIELD_PLAYER_BYTES_3, UNIT_END + 0x7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -272,6 +306,103 @@ fn player_field_bytes_splits_into_combo_points_toggles_and_honor_rank() {
|
||||
assert_eq!(ObjectFields::default().player_combo_points(), None);
|
||||
}
|
||||
|
||||
/// `PLAYER_BYTES_3` byte 3 is the **current** honor rank, and it shares its dword with the
|
||||
/// gender+inebriation low u16 — so the fixture carries a non-zero drunk byte on purpose: a wrong
|
||||
/// shift would read `0xA0` (160) as the rank and pass a byte-3-is-zero test.
|
||||
///
|
||||
/// The pairing with `PLAYER_FIELD_BYTES`' byte 3 is the confusable one and the reason both are
|
||||
/// asserted here: current rank (PUBLIC, field 195) vs highest lifetime rank (PRIVATE, field 1222).
|
||||
/// A fixture carrying only 195 must answer `None` for the highest rank, not `0`.
|
||||
#[test]
|
||||
fn player_bytes_3_byte_3_is_the_current_pvp_rank_not_the_highest() {
|
||||
// byte 0 = 0x01 gender, byte 1 = 0xA0 drunk, byte 2 = 0x04 city-protector title,
|
||||
// byte 3 = 0x0B = internal rank 11 (visual 7).
|
||||
let f = ObjectFields::from_pairs(&[(195, 0x0B_04_A0_01)]);
|
||||
assert_eq!(f.player_pvp_rank(), Some(11));
|
||||
assert_eq!(f.player_drunk_byte(), Some(0xA0), "the neighbouring byte");
|
||||
assert_eq!(
|
||||
f.player_honor_rank(),
|
||||
None,
|
||||
"the HIGHEST rank is a different field (1222) — absent means absent"
|
||||
);
|
||||
// The two really do diverge: a demoted player keeps the higher lifetime rank.
|
||||
let demoted = ObjectFields::from_pairs(&[(195, 0x03_00_00_00), (1222, 0x0B_00_00_00)]);
|
||||
assert_eq!(demoted.player_pvp_rank(), Some(3));
|
||||
assert_eq!(demoted.player_honor_rank(), Some(11));
|
||||
assert_eq!(ObjectFields::default().player_pvp_rank(), None);
|
||||
}
|
||||
|
||||
/// The four TWO_SHORT kill counters split low = honorable / high = dishonorable
|
||||
/// (`MAKE_PAIR32`; vmangos writes SESSION_KILLS as two `SetUInt16Value` halves). The other three
|
||||
/// come off vmangos as a whole dword, so their high half is 0 — asserted, because "DK reads 0" is
|
||||
/// a property of *this server*, not of the field, and a future capture that shows otherwise is a
|
||||
/// finding rather than a regression.
|
||||
#[test]
|
||||
fn honor_kill_counters_split_into_honorable_and_dishonorable_halves() {
|
||||
let f = ObjectFields::from_pairs(&[
|
||||
(1250, 0x0003_0011), // SESSION: 17 HK, 3 DK — both halves live
|
||||
(1251, 0x0000_0029), // YESTERDAY: 41 HK, whole-dword write ⇒ DK half 0
|
||||
(1252, 0x0000_01A4), // LAST_WEEK: 420 HK
|
||||
(1253, 0x0000_007B), // THIS_WEEK: 123 HK
|
||||
]);
|
||||
assert_eq!(f.player_session_kills(), Some((17, 3)));
|
||||
assert_eq!(f.player_yesterday_kills(), Some((41, 0)));
|
||||
assert_eq!(f.player_last_week_kills(), Some((420, 0)));
|
||||
assert_eq!(f.player_this_week_kills(), Some((123, 0)));
|
||||
// A value that fills the high half of one of the dword-written fields still decodes as a
|
||||
// pair — we carry the descriptor's shape, not vmangos's habit.
|
||||
let both = ObjectFields::from_pairs(&[(1251, 0x0007_0029)]);
|
||||
assert_eq!(both.player_yesterday_kills(), Some((41, 7)));
|
||||
// Absent stays absent for every one of the four.
|
||||
let empty = ObjectFields::default();
|
||||
assert_eq!(empty.player_session_kills(), None);
|
||||
assert_eq!(empty.player_yesterday_kills(), None);
|
||||
assert_eq!(empty.player_last_week_kills(), None);
|
||||
assert_eq!(empty.player_this_week_kills(), None);
|
||||
}
|
||||
|
||||
/// The INT half of the honor block — three contribution totals, the weekly standing, and the two
|
||||
/// lifetime counters — each reading its own index (a swapped constant shows up here).
|
||||
#[test]
|
||||
fn honor_contributions_standing_and_lifetime_read_their_own_fields() {
|
||||
let f = ObjectFields::from_pairs(&[
|
||||
(1254, 1_250), // THIS_WEEK_CONTRIBUTION
|
||||
(1255, 3_907), // LIFETIME_HONORABLE_KILLS
|
||||
(1256, 12), // LIFETIME_DISHONORABLE_KILLS
|
||||
(1257, 640), // YESTERDAY_CONTRIBUTION
|
||||
(1258, 8_431), // LAST_WEEK_CONTRIBUTION
|
||||
(1259, 57), // LAST_WEEK_RANK — the STANDING, not an honor rank
|
||||
]);
|
||||
assert_eq!(f.player_this_week_contribution(), Some(1_250));
|
||||
assert_eq!(f.player_lifetime_honorable_kills(), Some(3_907));
|
||||
assert_eq!(f.player_lifetime_dishonorable_kills(), Some(12));
|
||||
assert_eq!(f.player_yesterday_contribution(), Some(640));
|
||||
assert_eq!(f.player_last_week_contribution(), Some(8_431));
|
||||
assert_eq!(f.player_last_week_rank(), Some(57));
|
||||
let empty = ObjectFields::default();
|
||||
assert_eq!(empty.player_this_week_contribution(), None);
|
||||
assert_eq!(empty.player_yesterday_contribution(), None);
|
||||
assert_eq!(empty.player_last_week_contribution(), None);
|
||||
assert_eq!(empty.player_last_week_rank(), None);
|
||||
assert_eq!(empty.player_lifetime_honorable_kills(), None);
|
||||
assert_eq!(empty.player_lifetime_dishonorable_kills(), None);
|
||||
}
|
||||
|
||||
/// `PLAYER_FIELD_BYTES2` byte 0 is the rank progress bar; bytes 1..3 are a flags byte and two
|
||||
/// unknowns, so the fixture sets them to catch a wrong shift.
|
||||
#[test]
|
||||
fn player_honor_rank_bar_is_byte_zero_of_player_field_bytes2() {
|
||||
let f = ObjectFields::from_pairs(&[(1260, 0xDE_AD_BE_7F)]);
|
||||
assert_eq!(f.player_honor_rank_bar(), Some(0x7F));
|
||||
// A negative rank's `uint8(fraction * -255)` wraps rather than clamping — we carry the byte
|
||||
// the wire sent and leave the interpretation to the display edge.
|
||||
assert_eq!(
|
||||
ObjectFields::from_pairs(&[(1260, 0x0000_009C)]).player_honor_rank_bar(),
|
||||
Some(0x9C)
|
||||
);
|
||||
assert_eq!(ObjectFields::default().player_honor_rank_bar(), None);
|
||||
}
|
||||
|
||||
/// `PLAYER_FIELD_COMBO_TARGET` reads as one GUID out of its two dwords — the unit the points are
|
||||
/// banked on, which the display gate compares against the current target (decision 0875).
|
||||
#[test]
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//! | [`self_movement`] | our own mover: the `MSG_MOVE_*` stream + every mover ack |
|
||||
//! | [`lifecycle`] | being logged in: ping, logout, the cinematic ack |
|
||||
//! | [`selection`] | the two sends that set our target: select, inspect |
|
||||
//! | [`pvp`] | the honor system's one ask: another player's honor stats |
|
||||
//! | [`names`] | the ask-once name lookups: player, creature, pet |
|
||||
//! | [`chat`] | the chat frame's wire: every `MESSAGECHAT` flavour + `/played`, `/random`, `/wave` |
|
||||
//! | [`channel`] | channel administration: join/leave/list/moderation |
|
||||
@@ -73,6 +74,7 @@ mod pet;
|
||||
mod player_flags;
|
||||
mod pose;
|
||||
mod progression;
|
||||
mod pvp;
|
||||
mod quest;
|
||||
mod reputation;
|
||||
mod selection;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! The one honor-system send: asking for another player's honor stats (decision 1512). The
|
||||
//! family's other message, `SMSG_PVP_CREDIT`, is inbound only.
|
||||
//!
|
||||
//! **Why its own module rather than [`super::selection`]** — that module is "the two sends that
|
||||
//! set our selection", and this send does not: `HandleInspectOpcode` opens with
|
||||
//! `SetSelectionGuid` (vmangos `MiscHandler.cpp:944`) while `HandleInspectHonorStatsOpcode` does
|
||||
//! not (`MiscHandler.cpp:962-972`), so filing it there would make that module's own doc false. It
|
||||
//! also follows `writer/mod.rs`'s stated, mechanical split rule: a send lives in the module named
|
||||
//! after the [`crate::messages`] module its body builder lives in, and this body builder is
|
||||
//! `messages::pvp::inspect_honor_stats`.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::messages::{self, opcode};
|
||||
|
||||
use super::WorldWriter;
|
||||
|
||||
impl WorldWriter {
|
||||
/// Ask for a player's honor stats (`MSG_INSPECT_HONOR_STATS`, a raw 8-byte guid — body and
|
||||
/// server gates documented on [`messages::inspect_honor_stats`]). The reply rides the **same**
|
||||
/// opcode and surfaces as [`crate::events::SessionEvent::InspectHonorStats`].
|
||||
///
|
||||
/// There is no failure reply: each of the server's three refusals (target offline, beyond
|
||||
/// `INSPECT_DISTANCE`, a valid attack target) is a silent `return`. A caller must therefore
|
||||
/// treat the ask as fire-and-forget and keep whatever it already had on screen, exactly as the
|
||||
/// reference pane does — it re-asks on show and simply shows nothing until an answer lands.
|
||||
pub fn inspect_honor_stats(&mut self, guid: u64) -> Result<()> {
|
||||
self.send(
|
||||
opcode::MSG_INSPECT_HONOR_STATS,
|
||||
&messages::inspect_honor_stats(guid),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user