Files
benilla-octo/crates/benilla-ui/src/script/pvp/mod.rs
T
bb84f1f1fb Unit tooltips title the unit with its PvP name, as 1.12.1 does (#802)
A unit tooltip's title read the bare name where 1.12.1 decorates it.

The tooltip calls the name builder `0x609370` itself (`0x52a1ab`, or `0x52a1db` when there is no second part) with the decoration flag a literal `1`, the same call `UnitPVPName` makes, so no CVar gates it. With the flag on, a ranked player reads `UNIT_PVP_NAME` of the gendered `PVP_RANK_<rank>_<team>` and the name, a city protector's `PVP_MEDAL<n>` follows on a second line (`0x6093ef`), and a civilian kill (`0x612550`) reads the `PVP_RANK_CIVILIAN` prefix (`0x609461`).

The title now runs the builder `UnitPVPName` already uses. The test covers rank, gender, medal, civilian, an unranked player and a creature; an on-demand `ui-tooltip-rank` capture shows a ranked player's tooltip.

Fixes #794

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
2026-10-05 22:08:33 +07:00

616 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `TogglePVP` and the thirteen honor bindings: the character and inspect windows' Honor tabs and
//! the rank any unit frame can read off another player.
//!
//! The local player's counters are private descriptor fields the app pushes as a [`HonorState`];
//! another player's arrive as a `MSG_INSPECT_HONOR_STATS` reply ([`InspectHonorData`]), whose
//! presence is `HasInspectHonorData`. With nothing held every getter answers zeros at full width,
//! as the reference reads zero-initialised slots ungated. A foreign player's current rank is
//! public (`PLAYER_BYTES_3` byte 3) and rides [`UnitState::pvp_rank`](super::UnitState::pvp_rank).
//!
//! The internal rank (0 none, 1..=4 dishonorable, 5..=18 Scout or Private to High Warlord or
//! Grand Marshal, 19 the racial "Leader") is what the wire carries and what keys the
//! `PVP_RANK_<rank>_<team>` GlobalStrings; the visual rank indexes the badge texture, and every
//! conversion goes through [`visual_rank`].
//!
//! Rank titles are read off the VM's globals, which the install's `GlobalStrings.lua` fills: the
//! key is `"PVP_RANK_%d_%d"` (`0x8445b4`) with team 0 Horde and 1 Alliance. There is no
//! `PVP_RANK_0_*`, and both panes fall back to `NONE` on its nil.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::{number_arg, string_arg};
use super::unit::{check_unit_token, is_civilian_kill};
use super::Model;
/// The local player's honor snapshot, decoded by the app from the private honor descriptor
/// fields. Kill counts are halves of `TWO_SHORT` fields, low honorable and high dishonorable.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct HonorState {
/// `PLAYER_FIELD_SESSION_KILLS` low half.
pub session_hk: u16,
/// `PLAYER_FIELD_SESSION_KILLS` high half.
pub session_dk: u16,
/// `PLAYER_FIELD_YESTERDAY_KILLS` halves.
pub yesterday_hk: u16,
pub yesterday_dk: u16,
/// `PLAYER_FIELD_YESTERDAY_CONTRIBUTION`.
pub yesterday_honor: u32,
/// `PLAYER_FIELD_THIS_WEEK_KILLS` low half; `GetPVPThisWeekStats` has no dishonorable count.
pub this_week_hk: u16,
/// `PLAYER_FIELD_THIS_WEEK_CONTRIBUTION`.
pub this_week_honor: u32,
/// `PLAYER_FIELD_LAST_WEEK_KILLS` halves.
pub last_week_hk: u16,
pub last_week_dk: u16,
/// `PLAYER_FIELD_LAST_WEEK_CONTRIBUTION`.
pub last_week_honor: u32,
/// `PLAYER_FIELD_LAST_WEEK_RANK`: last week's ladder standing, not a rank.
pub last_week_standing: u32,
/// `PLAYER_FIELD_LIFETIME_HONORBALE_KILLS` / `…_DISHONORBALE_KILLS` (the server's spellings).
pub lifetime_hk: u32,
pub lifetime_dk: u32,
/// `PLAYER_FIELD_BYTES` byte 3: the highest lifetime rank, internal scale. Carried raw: only
/// `GetPVPLifetimeStats` hides a value below 5 (`0x51a843`).
pub highest_rank: u8,
/// `PLAYER_BYTES_3` byte 3: the current rank, internal scale, 0 for none; the same public
/// byte as [`UnitState::pvp_rank`](super::UnitState).
pub rank: u8,
/// `PLAYER_FIELD_BYTES2` byte 0: progress through the current rank, the raw byte.
pub rank_bar: u8,
}
/// One `MSG_INSPECT_HONOR_STATS` reply as the app decoded it. It has no dishonorable counts past
/// the session pair (the server writes zeros there), so `GetInspectHonorData` returns twelve.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct InspectHonorData {
/// The player the reply was for, the app's key; never returned to Lua.
pub guid: u64,
pub session_hk: u16,
pub session_dk: u16,
pub yesterday_hk: u16,
pub yesterday_honor: u32,
pub this_week_hk: u16,
pub this_week_honor: u32,
pub last_week_hk: u16,
pub last_week_honor: u32,
/// The reply's `lastWeekRank`, the ladder standing: `GetInspectHonorData`'s ninth return.
pub last_week_standing: u32,
pub lifetime_hk: u32,
pub lifetime_dk: u32,
/// The reply's `highestRank`, internal scale: `GetInspectHonorData`'s twelfth return
/// (`lifetimeRank`), unfiltered, as `0x4c9620` pushes it with no below-5 gate.
pub highest_rank: u8,
/// The reply's trailing `rankBar`, read only by `GetInspectPVPRankProgress`.
pub rank_bar: u8,
}
impl super::UiScript {
/// Drain the queued `TogglePVP` calls, one `CMSG_TOGGLE_PVP` each; the packet is empty, so
/// two toggles in a frame are two sends.
pub fn take_pvp_toggles(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().pvp_toggles)
}
/// Queue a toggle for `/pvp`, which benilla parses in Rust; the reference's `/pvp` is Lua over
/// `TogglePVP`.
pub fn queue_pvp_toggle(&mut self) {
self.model_mut().pvp_toggles += 1;
}
/// Push or clear the local player's honor snapshot, the self getters' only source. It fires
/// no event: the app fires `PLAYER_PVP_KILLS_CHANGED`/`PLAYER_PVP_RANK_CHANGED` alongside.
pub fn set_honor(&mut self, honor: Option<HonorState>) {
self.model_mut().honor = honor;
}
/// Push or clear the last `MSG_INSPECT_HONOR_STATS` reply, paired with the pane's
/// `INSPECT_HONOR_UPDATE`. Its presence is `HasInspectHonorData`, so the app clears it on
/// `ClearInspectPlayer` and the pane's `OnShow` asks again for the next player. Either way
/// the in-flight latch clears, as the engine's reply (`0x4c6f4c`) and re-key (`0x4c6f9d`) do.
pub fn set_inspect_honor(&mut self, data: Option<InspectHonorData>) {
let mut model = self.model_mut();
model.inspect_honor = data;
model.inspect_honor_pending = false;
}
/// Drain the queued `RequestInspectHonorData` calls, each a `MSG_INSPECT_HONOR_STATS` send for
/// the app's inspect target. At most 1: the `pending` latch (`0x4c80b6`) refuses another until
/// [`Self::set_inspect_honor`] resolves the first.
pub fn take_inspect_honor_requests(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().inspect_honor_requests)
}
/// A rank's localized title for the app's `SMSG_PVP_CREDIT` chat line, `None` if missing or
/// empty. Gendered, unlike the pane's: the credit formatter resolves through `0x612bf0`. No
/// range check, so rank 19 names "Leader"; `rank` is the internal rank the credit carries
/// (`HonorMgr::SendPVPCredit` sends `GetRank().rank`).
///
/// The caller owns two facts: the gender is the local player's while the team is the
/// victim's (`0x625374`), and a victim whose team is -1 gets no line at all (`0x625321`),
/// hence `team: u8`.
pub fn pvp_rank_title(&self, rank: u8, team: u8, female: bool) -> Option<String> {
rank_title_gendered(
self.lua(),
RankTitle {
rank,
// Past `i8` names no key, the lookup's own miss.
team: i8::try_from(team).ok()?,
female,
},
)
}
}
/// Internal rank to visual rank (`0x51aa31`): 5..=18 subtract 4, and 1..=4 subtract 5 to give
/// -4..=-1 (`0x51aa38`). The server's `visualRank` negates instead (`HonorMgr.cpp:991`), so the
/// two disagree on ranks 1..=4; this is the client's. Only 1..=18 reach it, as `GetPVPRankInfo`
/// gates first.
fn visual_rank(rank: i64) -> i64 {
if rank >= 5 {
rank - 4
} else {
rank - 5
}
}
/// The f32 at `0x8026c8` (bytes `81 80 80 3b`) that both rank-progress getters multiply the bar
/// byte by: the f32 nearest 1/255, `0.003921568859368563`, and not 1/255.
const RANK_BAR_SCALE: f64 = f32::from_bits(0x3B80_8081) as f64;
/// The rank bar's byte as the fraction the reference feeds a 0..1 `StatusBar`: `fild` then `fmul`
/// by [`RANK_BAR_SCALE`] (`0x51aace`), with no clamp. It differs from `bar / 255.0` for every byte
/// but 0, and a full bar gives `1.0000000091389835`, over the bar's maximum.
fn rank_progress(bar: u8) -> f64 {
f64::from(bar) * RANK_BAR_SCALE
}
fn honor(lua: &Lua) -> Option<HonorState> {
lua.app_data_ref::<Model>().expect("model app_data").honor
}
fn inspect_honor(lua: &Lua) -> Option<InspectHonorData> {
lua.app_data_ref::<Model>()
.expect("model app_data")
.inspect_honor
}
/// A GlobalString off the VM's globals, `None` when absent or empty, as `GetPVPRankInfo` tests
/// both (`0x51aa1c` NULL, `0x51aa20` empty).
fn global_string(lua: &Lua, key: &str) -> Option<String> {
lua.globals()
.get::<Option<String>>(key)
.ok()
.flatten()
.filter(|s| !s.is_empty())
}
/// A unit's PvP team digit, `0x5efe00`'s tri-state from its race (`ChrRaces` to `FactionTemplate`,
/// group mask `& 4` then `& 2`): 0 Horde, 1 Alliance, -1 no side; resolved by the app as
/// [`UnitState::pvp_team`](super::UnitState). Not `faction_group`, the live faction template: a
/// vmangos GM's template 35 has no side while his race keeps one. -1 misses every key.
fn team_of(u: &super::UnitState) -> i64 {
i64::from(u.pvp_team)
}
/// `GetPVPRankInfo`'s second argument as a team digit, the engine's three-way dispatch
/// (`0x51a98c`/`0x51a9af`/`0x51a9c8`): a number is the digit itself, truncated; a string is that
/// unit's [`team_of`], or 0 if it names nothing or a non-player; anything else is the local
/// player's, or 0 with no player. That 0 is the team register's initial value, so a miss names
/// off the Horde list, as in the reference. An unknown token raises, as `0x515970` does.
fn team_arg(lua: &Lua, v: Value) -> mlua::Result<i64> {
// `lua_isnumber` before `lua_isstring`, which is what puts a numeric string on the number arm.
if let Some(n) = lua.coerce_number(v.clone())? {
return Ok(i64::from(n as i64 as i32));
}
let Some(token) = lua.coerce_string(v)? else {
// Absent, boolean or table: the local player.
let model = lua.app_data_ref::<Model>().expect("model app_data");
return Ok(model.unit("player").map_or(0, team_of));
};
let token = Some(token.to_str()?.to_owned());
check_unit_token(&token)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(token
.as_ref()
.and_then(|t| model.unit(t))
// `0x51a9af`'s player gate (`shr edx,4; test dl,1`), applied here unlike in `UnitPVPRank`:
// on this arm nothing else tells a creature, which has a race too, from a player.
.filter(|u| u.is_player)
.map_or(0, team_of))
}
/// `PVP_RANK_<internal>_<team>`, ungendered: `GetPVPRankInfo`'s lookup, `0x703bf0(key, -1, 0)`
/// (`0x51aa0f` pushes gender 0), so the panes show a female character the default title. Do not
/// gender it: that would diverge from the reference.
fn rank_title_ungendered(lua: &Lua, rank: i64, team: i64) -> Option<String> {
global_string(lua, &format!("PVP_RANK_{rank}_{team}"))
}
/// The `PVP_RANK_<rank>_<team>[_FEMALE]` inputs, kept together because the live lookup and the
/// world-text pass's builder key on the same three.
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct RankTitle {
/// `PLAYER_BYTES_3` byte 3 (`UnitPVPRank`), the internal rank 0..=18; 19 is the racial
/// "Leader" the keys still name.
pub rank: u8,
/// The team digit from the race: 0 Horde, 1 Alliance, -1 no side.
pub team: i8,
/// Female takes the `_FEMALE` twin (`0x612bf0`) and falls back to the base key.
pub female: bool,
}
/// The same title gendered, `0x612bf0`: it passes 2 (male) or 3 (female) to `0x703bf0`, which
/// asks FrameXML's `GetText` to append `_FEMALE` for 3 (the engine never builds that key), and
/// retries ungendered on a miss (`0x612c2d`). The credit line's lookup; `UnitPVPName` and the
/// overhead name reach the same keys through [`decorated_name`].
fn rank_title_gendered(lua: &Lua, title: RankTitle) -> Option<String> {
rank_title(&|key| global_string(lua, key), title)
}
/// [`rank_title_gendered`]'s key construction over a string lookup, so a caller with no VM (the
/// world-text pass) resolves the same `_FEMALE` twin and fallback.
fn rank_title(lookup: &impl Fn(&str) -> Option<String>, title: RankTitle) -> Option<String> {
title
.female
.then(|| lookup(&format!("PVP_RANK_{}_{}_FEMALE", title.rank, title.team)))
.flatten()
.or_else(|| lookup(&format!("PVP_RANK_{}_{}", title.rank, title.team)))
}
/// Fill a two-`%s` C format string, the install's template (enUS `UNIT_PVP_NAME` is `"%s %s"`);
/// `%%` is a literal `%` and any other specifier passes through. Deviation: a third `%s` gets an
/// empty string, because the reference pushes two varargs (`0x6093f2 add esp,0x14`) and would
/// read uninitialised stack.
fn format_two_strings(fmt: &str, a: &str, b: &str) -> String {
let mut out = String::with_capacity(fmt.len() + a.len() + b.len());
let mut args = [a, b].into_iter();
let mut chars = fmt.chars();
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
continue;
}
match chars.next() {
Some('s') => out.push_str(args.next().unwrap_or("")),
Some('%') => out.push('%'),
Some(other) => {
out.push('%');
out.push(other);
}
None => out.push('%'),
}
}
out
}
/// The install keys a name-line rank prefix can resolve: `UNIT_PVP_NAME` and every
/// `PVP_RANK_<rank>_<team>[_FEMALE]` for ranks 1..=19 and both team digits. The range reaches 19
/// because the builder is range-unchecked and the install's `PVP_RANK_19_*` names the racial
/// "Leader", past the player rank byte's 0..=18. The world-text pass, which runs without the VM,
/// reads them off each VM once; `UnitPVPName` looks them up live.
pub fn pvp_name_global_keys() -> Vec<String> {
let mut keys = Vec::with_capacity(1 + 19 * 2 * 2);
keys.push("UNIT_PVP_NAME".to_string());
for rank in 1..=19 {
for team in 0..=1 {
keys.push(format!("PVP_RANK_{rank}_{team}"));
keys.push(format!("PVP_RANK_{rank}_{team}_FEMALE"));
}
}
keys
}
/// `0x609370`'s ranked-player leg over a string lookup: `UNIT_PVP_NAME` filled rank first, the
/// same builder [`pvp_name`] runs for `UnitPVPName`. `None` for rank 0, or a title or template
/// the lookup lacks, where the callers keep the plain name: the reference prints a missing title
/// as `""` (`" " + name`), which a playable race's 0..=18 rank never reaches in the stock strings.
pub fn decorated_name(
lookup: impl Fn(&str) -> Option<String>,
key: RankTitle,
name: &str,
) -> Option<String> {
if key.rank == 0 {
return None;
}
let title = rank_title(&lookup, key)?;
let template = lookup("UNIT_PVP_NAME")?;
Some(format_two_strings(&template, &title, name))
}
/// `0x609370`, the name builder behind `UnitPVPName` (its legs are at the binding) and the unit
/// tooltip's title (`0x52a1ab`), both with the decoration flag `1`; `name` is the plain `UnitName`.
pub(super) fn pvp_name(lua: &Lua, u: &super::UnitState, name: &str, player_level: u32) -> String {
// Leg A: a ranked player; the title is gendered by this unit and range-unchecked.
if u.is_player && u.pvp_rank != 0 {
let decorated = decorated_name(
|key| global_string(lua, key),
RankTitle {
rank: u.pvp_rank,
team: u.pvp_team,
female: u.sex == 3,
},
name,
);
if let Some(mut out) = decorated {
// Leg A′: the city-protector medal, on its own line, ungendered (`0x60941d push 0`).
if u.pvp_medal != 0 {
if let Some(medal) = global_string(lua, &format!("PVP_MEDAL{}", u.pvp_medal)) {
out.push('\n');
out.push_str(&medal);
}
}
return out;
}
return name.to_owned();
}
// Leg B: the dishonorable-kill warning, on the same predicate the tooltip's CIVILIAN line uses.
if is_civilian_kill(u, player_level) {
if let Some(civilian) = global_string(lua, "PVP_RANK_CIVILIAN") {
return format!("{civilian} {name}");
}
}
// Leg C.
name.to_owned()
}
/// Register the PvP + honor globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// TogglePVP(): no argument in 1.12, as the opcode's one-byte state form has no binding.
// Registered at `0x48d700`; the shipped UI calls it only from `SlashCmdList["PVP"]`.
g.set(
"TogglePVP",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.pvp_toggles += 1;
Ok(())
})?,
)?;
// ── The self getters ─────────────────────────────────────────────────────────────────────
// Full arity on every path: the panes destructure straight into `format()` arguments, so
// before the first push they read as a zeroed snapshot, as a new character's would.
// GetPVPSessionStats() → hk, dk.
g.set(
"GetPVPSessionStats",
lua.create_function(|lua, ()| {
let h = honor(lua).unwrap_or_default();
Ok((i64::from(h.session_hk), i64::from(h.session_dk)))
})?,
)?;
// GetPVPYesterdayStats() → hk, dk, contribution.
g.set(
"GetPVPYesterdayStats",
lua.create_function(|lua, ()| {
let h = honor(lua).unwrap_or_default();
Ok((
i64::from(h.yesterday_hk),
i64::from(h.yesterday_dk),
i64::from(h.yesterday_honor),
))
})?,
)?;
// GetPVPThisWeekStats() → hk, contribution: two, with no this-week dishonorable count.
g.set(
"GetPVPThisWeekStats",
lua.create_function(|lua, ()| {
let h = honor(lua).unwrap_or_default();
Ok((i64::from(h.this_week_hk), i64::from(h.this_week_honor)))
})?,
)?;
// GetPVPLastWeekStats() → hk, dk, contribution, standing: the ladder standing, which the pane
// prints through `PVP_RANK_LAST_WEEK`, not a rank.
g.set(
"GetPVPLastWeekStats",
lua.create_function(|lua, ()| {
let h = honor(lua).unwrap_or_default();
Ok((
i64::from(h.last_week_hk),
i64::from(h.last_week_dk),
i64::from(h.last_week_honor),
i64::from(h.last_week_standing),
))
})?,
)?;
// GetPVPLifetimeStats() → hk, dk, highestRank, the highest lifetime rank (internal). One
// below 5 answers 0 (`0x51a843 cmp al,5; jb`, unsigned), so the pane shows NONE for a
// dishonorable best; 0, not nil, so its `GetPVPRankInfo(highestRank)` still gets a number.
g.set(
"GetPVPLifetimeStats",
lua.create_function(|lua, ()| {
let h = honor(lua).unwrap_or_default();
let highest = if h.highest_rank >= 5 {
h.highest_rank
} else {
0
};
Ok((
i64::from(h.lifetime_hk),
i64::from(h.lifetime_dk),
i64::from(highest),
))
})?,
)?;
// GetPVPRankProgress() → the bar fraction (`rank_progress`), 0.0 before the first push.
g.set(
"GetPVPRankProgress",
lua.create_function(|lua, ()| Ok(rank_progress(honor(lua).unwrap_or_default().rank_bar)))?,
)?;
// GetPVPRankInfo(rank [, unit]) → rankName, rankNumber: the title and the visual rank for an
// internal rank, always two values; every failure answers `nil, 0`, which both panes turn
// into `NONE`. `0x51a930` accepts [1, 18], signed (`0x51a9f0`/`0x51a9f5`), so rank 19,
// "Leader", is refused although its GlobalStrings exist and the server sends it. The second
// argument is `team_arg`'s three-way dispatch.
g.set(
"GetPVPRankInfo",
lua.create_function(|lua, (rank, unit): (Value, Value)| {
let rank = i64::from(number_arg(
lua,
rank,
"Usage: GetPVPRankInfo(rank [, unit])",
)?);
let team = team_arg(lua, unit)?;
// The range gate first: outside [1, 18] no key is even built.
let name = if (1..=18).contains(&rank) {
rank_title_ungendered(lua, rank, team)
} else {
None
};
let Some(name) = name else {
return Ok(MultiValue::from_vec(vec![Value::Nil, Value::Integer(0)]));
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&name)?),
Value::Integer(visual_rank(rank)),
]))
})?,
)?;
// UnitPVPRank(unit) → the unit's current rank, internal, 0 for none. `0x51a8a0` reads the
// named object's own byte (`[+0xe68]+0x1f`, in public `PLAYER_BYTES_3`), which is how the
// inspect pane reads a foreign rank. Anything unresolved reads 0, never nil (`0x51a916`); a
// non-string argument raises (`0x51a8ac`).
//
// The engine's player gate (`0x51a8e1`) is not re-applied: it tests the type mask, while our
// `is_player` comes from the app's later guid-keyed enrichment, so a snapshot pushed before
// it would zero a real player's rank. A creature has no player block, so its byte is 0.
g.set(
"UnitPVPRank",
lua.create_function(|lua, token: Value| {
let token = Some(string_arg(lua, token, "Usage: UnitPVPRank(\"unit\")")?);
check_unit_token(&token)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(token
.as_ref()
.and_then(|t| model.unit(t))
.map_or(0i64, |u| i64::from(u.pvp_rank)))
})?,
)?;
// UnitPVPName(unit) → the name decorated by rank, `0x5172b0` into the builder `0x609370`:
// A a ranked player: `UNIT_PVP_NAME` filled title first, then name; the title is gendered
// by this unit (`0x5efe60`, `0x612bf0`) with no range check, so rank 19 renders "Leader".
// A′ and, if `PLAYER_BYTES_3` byte 2 (the city-protector title) is set, a second line with
// `PVP_MEDAL<n>`, ungendered.
// B else a unit passing the civilian predicate (`0x612550`): `PVP_RANK_CIVILIAN`, a space
// and the name. Only creatures reach it.
// C else the plain name.
// No snapshot or an unresolved name answers nil, and so does a token resolving to guid 0,
// where the reference returns one value it never pushed (`0x517343`).
//
// Deviation: with `UNIT_PVP_NAME` or the title missing from `_G` (no install) this answers
// the plain name, where the reference would format an empty string, because the decoration
// is install data.
g.set(
"UnitPVPName",
lua.create_function(|lua, token: Value| {
let token = Some(string_arg(lua, token, "Usage: UnitPVPName(\"unit\")")?);
check_unit_token(&token)?;
let (unit, player_level) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
(
token.as_ref().and_then(|t| model.unit(t)).cloned(),
model.player_req.level,
)
};
let Some(u) = unit else {
return Ok(Value::Nil);
};
let Some(name) = u.name.clone() else {
return Ok(Value::Nil);
};
let decorated = pvp_name(lua, &u, &name, player_level);
Ok(Value::String(lua.create_string(&decorated)?))
})?,
)?;
// ── The inspect half ─────────────────────────────────────────────────────────────────────
// RequestInspectHonorData(): `InspectHonorFrame_OnShow` calls it while
// `HasInspectHonorData()` is false; the app sends `MSG_INSPECT_HONOR_STATS` for its inspect
// target. Zero return values (`0x4c9610`).
//
// `0x4c80a0` bails silently if data is held (`0x4c80a6`), if a query is in flight (`pending`,
// `0x4c80b6`, `[0xb71fcc]`) or if the target guid is 0 (`0x4c80c2`). The first two are here;
// the guid is the app's, which drops a request with no target. Our `pending` latches on
// queue, not send, so a dropped request holds it until the next `set_inspect_honor`, which
// `ClearInspectPlayer` calls as `0x4c6f70` clears the engine's.
g.set(
"RequestInspectHonorData",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.inspect_honor.is_some() || model.inspect_honor_pending {
return Ok(());
}
model.inspect_honor_requests += 1;
model.inspect_honor_pending = true;
Ok(())
})?,
)?;
// HasInspectHonorData() → 1 or nil: whether a reply is held.
g.set(
"HasInspectHonorData",
lua.create_function(|lua, ()| {
Ok(if inspect_honor(lua).is_some() {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
// GetInspectHonorData() → the reference's twelve, in its order:
// sessionHK, sessionDK, yesterdayHK, yesterdayHonor, thisweekHK, thisweekHonor,
// lastweekHK, lastweekHonor, lastweekStanding, lifetimeHK, lifetimeDK, lifetimeRank
// Twelve on every path: `0x4c9620` never checks for data and reads zero-initialised slots.
// Clearing drops the data here, while the reference's re-key zeroes only the flags and can
// still answer the previous target's numbers; matching that needs a latch apart from the data.
g.set(
"GetInspectHonorData",
lua.create_function(|lua, ()| {
let d = inspect_honor(lua).unwrap_or_default();
let out = vec![
Value::Integer(i64::from(d.session_hk)),
Value::Integer(i64::from(d.session_dk)),
Value::Integer(i64::from(d.yesterday_hk)),
Value::Integer(i64::from(d.yesterday_honor)),
Value::Integer(i64::from(d.this_week_hk)),
Value::Integer(i64::from(d.this_week_honor)),
Value::Integer(i64::from(d.last_week_hk)),
Value::Integer(i64::from(d.last_week_honor)),
Value::Integer(i64::from(d.last_week_standing)),
Value::Integer(i64::from(d.lifetime_hk)),
Value::Integer(i64::from(d.lifetime_dk)),
Value::Integer(i64::from(d.highest_rank)),
];
debug_assert_eq!(out.len(), 12, "the tuple is twelve wide on every path");
Ok(MultiValue::from_vec(out))
})?,
)?;
// GetInspectPVPRankProgress() → the reply's `rankBar` through `rank_progress`: `0x51ab00`'s
// kernel at `0x51ab04` is byte-identical to `0x51aace`, so it too reads 0.0 when empty.
g.set(
"GetInspectPVPRankProgress",
lua.create_function(|lua, ()| {
Ok(rank_progress(
inspect_honor(lua).unwrap_or_default().rank_bar,
))
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests;