diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index ccc06b66..7f435f33 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -453,10 +453,11 @@
"RowWeatherDensity",
"RowDisplayMode", "RowVerticalSync", "RowRenderScale",
},
- -- Guild Names is a child of Player Names (UIOptionsFrame.lua:703-709).
+ -- Guild Names and Player Titles are children of Player Names
+ -- (UIOptionsFrame.lua:703-709).
Nameplates = {
"RowEnemyPlates", "RowFriendlyPlates",
- "RowPlayerNames", "RowGuildNames", "RowNpcNames", "RowOwnName",
+ "RowPlayerNames", "RowGuildNames", "RowPlayerTitles", "RowNpcNames", "RowOwnName",
},
-- 1.12's Display box by column, then Help; target-of-target sits under Status Text.
Interface = {
@@ -849,9 +850,13 @@
return
end
if key == "Nameplates" then
- -- UIOptionsFrame.lua:703-709: Player Names off greys Player Guild Names.
- BenillaOptionsRow_SetEnabled(BenillaOptionsFrameContainerBodyNameplatesRowGuildNames,
- GetCVar("UnitNamePlayer") ~= "0")
+ -- UIOptionsFrame.lua:703-709: Player Names off greys Player Guild Names and
+ -- Player Titles.
+ local on = GetCVar("UnitNamePlayer") ~= "0"
+ BenillaOptionsRow_SetEnabled(
+ BenillaOptionsFrameContainerBodyNameplatesRowGuildNames, on)
+ BenillaOptionsRow_SetEnabled(
+ BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitles, on)
return
end
if key == "Interface" then
@@ -2638,11 +2643,24 @@
this.parentRow = "RowPlayerNames"
-
+
+
+
+ BenillaOptionsRow_OnLoad(this, "UnitNamePlayerPVPTitle", "Player Titles",
+ "OPTION_TOOLTIP_SHOW_PLAYER_TITLES", 1)
+ this.parentRow = "RowPlayerNames"
+
+
+
+
+
+
+ BenillaOptionsRow_OnLoad(this, "UnitNameNPC", "NPC Names",
"OPTION_TOOLTIP_SHOW_NPC_NAMES")
diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs
index aad4bb82..dfd42c07 100644
--- a/crates/benilla-app/src/capture/fixtures.rs
+++ b/crates/benilla-app/src/capture/fixtures.rs
@@ -1297,6 +1297,50 @@ pub(super) fn seed_ui_fixture(
// A plated unit draws no floating name, so enemy plates go off.
set_enemy_plates(script.as_deref(), false);
}
+ UiFixture::NameRank => {
+ use benilla_protocol::messages::ObjectFields;
+ // The self player at the eye, as `name-water`; the subject is the other player's name.
+ const SELF_GUID: u64 = 0x51;
+ names.insert_player(SELF_GUID, "Benilla".into(), None);
+ commands.spawn((
+ crate::net::ObjectStore(ObjectFields::from_pairs(&[
+ (34, 2), // UNIT_FIELD_LEVEL
+ (35, 1), // UNIT_FIELD_FACTIONTEMPLATE: human
+ (36, 0x0101), // UNIT_FIELD_BYTES_0: race human, class warrior
+ ])),
+ crate::net::SelfPlayer,
+ crate::net::Guid(SELF_GUID),
+ Transform::from_translation(wow_to_bevy(scenario.eye)),
+ ));
+ // The subject at the `vplates` wolf's dry spot: a human player holding rank,
+ // `PLAYER_BYTES_3` byte 3 (`PLAYER_BYTES_3_OFFSET_HONOR_RANK`, vmangos
+ // `HonorMgr.cpp:900`) at internal 7: the visual rank 3, "Sergeant" (team 1 off race
+ // 1's `ChrRaces` row).
+ const RANKED_GUID: u64 = 0x52;
+ names.insert_player(RANKED_GUID, "Bob".into(), None);
+ commands.spawn((
+ crate::net::Guid(RANKED_GUID),
+ crate::net::NetEntity {
+ kind: benilla_protocol::EntityKind::Player,
+ // HumanMale, the body a human `UNIT_FIELD_DISPLAYID` carries.
+ display_id: Some(49),
+ scale: 1.0,
+ },
+ crate::net::ObjectStore(ObjectFields::from_pairs(&[
+ (34, 60), // UNIT_FIELD_LEVEL
+ (35, 1), // UNIT_FIELD_FACTIONTEMPLATE: human
+ (36, 0x0101), // UNIT_FIELD_BYTES_0: race human, class warrior, male
+ (195, 7 << 24), // PLAYER_BYTES_3 byte 3: the current honor rank, internal 7
+ ])),
+ Transform {
+ translation: wow_to_bevy(WOLF_POS),
+ ..default()
+ },
+ Visibility::default(),
+ ));
+ // A plated unit draws no floating name, so enemy plates go off.
+ set_enemy_plates(script.as_deref(), false);
+ }
// The lighting matrix: one spawn with a streamed entity's component set, anonymous and
// with plates off, so no glyph rides over the body.
UiFixture::Subject { kind, at } => {
diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs
index 213a9f2b..3e566771 100644
--- a/crates/benilla-app/src/capture/scenarios.rs
+++ b/crates/benilla-app/src/capture/scenarios.rs
@@ -111,6 +111,10 @@ pub(super) enum UiFixture {
/// `water-noon` camera). Deep water is opaque (`WATER_DEEP_ALPHA` 1.0), so a name sorted before
/// the liquid is painted out; it must read at full strength.
NameWater,
+ /// The ranked player's overhead name line: a level-60 human holding honor rank 7 (internal;
+ /// the visual rank 3, "Sergeant") at the fixture's dry subject spot, framed close off the
+ /// `vplates` camera's bearing.
+ NameRank,
/// One cell of the lighting matrix: a creature or GameObject spawned through the live path at
/// `at` ([`SubjectKind`], the note above [`SUBJECT_SUN`]).
Subject {
@@ -910,6 +914,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: Some(UiFixture::NameWater),
},
+ // The ranked player's name line at the fixture's subject spot, 5 yd out level with it on the
+ // `vplates` camera's horizontal bearing, so the body and its name line fill the frame.
+ Scenario {
+ name: "name-rank",
+ map: Some(MAP_AZEROTH),
+ eye: [-8953.92, -135.53, 85.5],
+ look: [-8949.95, -132.49, 85.5],
+ minute: 720,
+ ui: Some(UiFixture::NameRank),
+ },
];
/// The `name-close` subject: the `name-water` wolf's name, orbited at any distance. World text is
diff --git a/crates/benilla-app/src/cvars/mod.rs b/crates/benilla-app/src/cvars/mod.rs
index 9d54c1e4..b0aa50b9 100644
--- a/crates/benilla-app/src/cvars/mod.rs
+++ b/crates/benilla-app/src/cvars/mod.rs
@@ -1050,10 +1050,15 @@ mod tests {
assert_eq!(d["UnitNameNPC"] != 0.0, names.npc);
assert_eq!(d["UnitNameOwn"] != 0.0, names.own);
assert_eq!(d["UnitNamePlayerGuild"] != 0.0, names.player_guild);
+ assert_eq!(d["UnitNamePlayerPVPTitle"] != 0.0, names.player_pvp_title);
assert!(
- names.player && !names.npc && !names.own && names.player_guild,
+ names.player
+ && !names.npc
+ && !names.own
+ && names.player_guild
+ && names.player_pvp_title,
"the binary registers UnitNamePlayer \"1\", NPC \"0\", Own \"0\", \
- PlayerGuild \"1\""
+ PlayerGuild \"1\", PlayerPVPTitle \"1\""
);
let camera_opts = crate::player::camera_dynamics::CameraOptions::default();
assert_eq!(d["cameraPivot"] != 0.0, camera_opts.pivot);
@@ -1324,6 +1329,8 @@ mod tests {
assert!(!res::(&app).npc);
apply(&mut app, "unitnameown", "1");
assert!(res::(&app).own);
+ apply(&mut app, "unitnameplayerpvptitle", "0");
+ assert!(!res::(&app).player_pvp_title);
apply(&mut app, "ChatBubbles", "0");
assert!(!res::(&app).all);
apply(&mut app, "chatbubblesparty", "0");
diff --git a/crates/benilla-app/src/cvars/table.rs b/crates/benilla-app/src/cvars/table.rs
index 891fccfa..ed9f2b8a 100644
--- a/crates/benilla-app/src/cvars/table.rs
+++ b/crates/benilla-app/src/cvars/table.rs
@@ -294,9 +294,11 @@ pub(crate) const REGISTERED: &[Registered] = &[
same("UnitNameOwn", "0"),
// `UnitNamePlayerGuild` (`0x86c680`, "1", mask bit `0x10`) is not a show gate: `ShouldShowName`
// (`0x6070a0`) reads bits `0x1/0x2/0x4`, and this gates the `"\n<%s>"` guild line at
- // `0x609085`. `UnitNamePlayerPVPTitle` (bit `0x20`, "1") has no row: nothing here draws the
- // rank prefix.
+ // `0x609085`.
same("UnitNamePlayerGuild", "1"),
+ // `UnitNamePlayerPVPTitle` (`0x86c668`, "1", mask bit `0x20`): the a4 rank prefix on the main
+ // name line (`0x609370`), `PVP_RANK_%d_%d` through `0x5efe60`.
+ same("UnitNamePlayerPVPTitle", "1"),
// `WorldDetail` is no 1.12 CVar but the `GetWorldDetail`/`SetWorldDetail` verb name
// (`OptionsFrame.lua:27`); stops 0/1/2 are `frillDensity` 16/32/48. `SetWorldDetail 0x488dd0`
// also writes `SmallCull` {0.07, 0.04, 0.01}, and `GetWorldDetail` reads only `SmallCull`,
diff --git a/crates/benilla-app/src/nameplates.rs b/crates/benilla-app/src/nameplates.rs
index c950c19e..42db788e 100644
--- a/crates/benilla-app/src/nameplates.rs
+++ b/crates/benilla-app/src/nameplates.rs
@@ -12,12 +12,18 @@
//! - Show gate (`ShouldShowName`, `0x6070a0`): the own unit by `UnitNameOwn`, before the target
//! rescue; the current target (`[0xb4e2d8]`, the selection) regardless of CVars; a dead creature
//! only through that rescue; others by `UnitNamePlayer` or `UnitNameNPC`.
-//! - Lines (`0x608f50`): an NPC's name and ``; a player's ``/``/`` prefixes,
-//! name and ``. The guild (a5) and subname (a6) slots share the format `"\n<%s>"`
-//! (`0x860f9c`) and are never both reached; a5 alone is CVar-gated (`0x609085`).
+//! - Lines (`0x608f50`): an NPC's name and ``; a player's ``/``/``
+//! prefixes, the a4 rank prefix (`UnitNamePlayerPVPTitle`, bit `0x20`) and name, and ``.
+//! The guild (a5) and subname (a6) slots share the format `"\n<%s>"` (`0x860f9c`) and are never
+//! both reached; a5 alone is CVar-gated (`0x609085`).
+//! - The a4 slot is `0x609370`'s ranked-player leg (A, `0x6093a5`): `UNIT_PVP_NAME` filled rank
+//! first, `PVP_RANK__` off the unit's public `PLAYER_BYTES_3` byte 3 and its race's
+//! team digit, and never a creature's.
//!
-//! Not built: the a4 PvP rank prefix (`UnitNamePlayerPVPTitle`, bit `0x20`), whose faction side
-//! `ui_unit` does not resolve for another player; a7 and a8 have no cross-realm wire here.
+//! Not built: `0x609370`'s civilian leg (`0x609449`, `PVP_RANK_CIVILIAN` and a space before a
+//! hostile PvP-flagged civilian NPC's name) and its city-protector line (`0x6093ef`, `"\n"` and
+//! `PVP_MEDAL` for a set `PLAYER_BYTES_3` byte 2, which vmangos's `.character citytitle` sets);
+//! a7 and a8 have no cross-realm wire here.
use std::collections::HashMap;
@@ -49,6 +55,9 @@ pub(crate) struct NameConfig {
/// `UnitNamePlayerGuild`, mask bit `0x10`, registered `"1"`: gates the a5 guild line
/// (`0x609085`), not a whole name.
pub(crate) player_guild: bool,
+ /// `UnitNamePlayerPVPTitle` (`0x86c668`, registered `"1"`), mask bit `0x20`: gates the a4 rank
+ /// prefix on the main name line (`0x609370`).
+ pub(crate) player_pvp_title: bool,
}
impl Default for NameConfig {
@@ -58,6 +67,7 @@ impl Default for NameConfig {
npc: false,
own: false,
player_guild: true,
+ player_pvp_title: true,
}
}
}
@@ -78,37 +88,115 @@ fn flag_prefix(player_flags: u32) -> String {
.collect()
}
-/// Whether `cached` equals the stack [`drive_nameplates`] would build from these inputs, compared
-/// in place so the steady frame allocates nothing; a differential test pins the equivalence.
-fn lines_current(
- cached: &[String],
- player_flags: u32,
- name: &str,
- bracketed: Option<&str>,
-) -> bool {
- let name_line = |line: &str| {
- let mut rest = line;
- for (bit, tag) in FLAG_PREFIXES {
- if player_flags & bit != 0 {
- match rest.strip_prefix(tag) {
- Some(r) => rest = r,
- None => return false,
- }
- }
- }
- rest == name
- };
- match (cached, bracketed) {
- ([l0], None) => name_line(l0),
- ([l0, l1], Some(bracketed)) => {
- name_line(l0)
- && l1
- .strip_prefix('<')
- .and_then(|r| r.strip_suffix('>'))
- .is_some_and(|r| r == bracketed)
- }
- _ => false,
+/// The strings the a4 rank prefix resolves (`UNIT_PVP_NAME` and the `PVP_RANK_*` titles), which
+/// the honor feed reads off each VM once ([`crate::ui_honor`]), where `0x609370` reads them live
+/// at every rebuild: the world-text pass runs without the VM.
+#[derive(Resource, Default)]
+pub(crate) struct PvpNameStrings {
+ /// The VM session the strings were read from; `None` before the first VM.
+ session: Option,
+ /// Moves when a read changes the strings, so a line built from the old ones rebuilds.
+ generation: u64,
+ strings: HashMap,
+}
+
+impl PvpNameStrings {
+ /// The VM session the strings were read from; `None` before the first VM.
+ pub(crate) fn session(&self) -> Option {
+ self.session
}
+
+ /// A VM's strings, every key it defines; a missing one is the builder's own miss. The
+ /// generation moves only when they differ, so a new VM with the same strings rebuilds no plate.
+ pub(crate) fn set(&mut self, session: u64, strings: HashMap) {
+ self.session = Some(session);
+ if strings != self.strings {
+ self.strings = strings;
+ self.generation += 1;
+ }
+ }
+
+ /// One held string, for the feed's tests.
+ #[cfg(test)]
+ pub(crate) fn get(&self, key: &str) -> Option<&str> {
+ self.strings.get(key).map(String::as_str)
+ }
+
+ /// Whether the snapshot holds the template: no install strings, no prefix, as `UnitPVPName`
+ /// answers the plain name with `_G` missing.
+ fn ready(&self) -> bool {
+ self.strings.contains_key("UNIT_PVP_NAME")
+ }
+
+ /// `0x609370`'s ranked-player leg for `key`; `None` where the builder declines.
+ fn decorated(&self, key: TitleKey, name: &str) -> Option {
+ benilla_ui::script::decorated_name(
+ |global| self.strings.get(global).cloned(),
+ key.title,
+ name,
+ )
+ }
+}
+
+/// The a4 rank-prefix inputs a live line was built with: the steady frame compares these fields,
+/// so a rank change, a CVar flip or a new VM's different strings rebuild without reading the
+/// strings again.
+#[derive(Clone, Copy, PartialEq, Eq)]
+struct TitleKey {
+ /// The rank, team and gender the title lookup keyed on
+ /// ([`benilla_ui::script::RankTitle`]).
+ title: benilla_ui::script::RankTitle,
+ /// The a4 bit (`0x20`) was set and the strings held the template when the line was built.
+ on: bool,
+ /// The [`PvpNameStrings`] generation the line was built from.
+ strings: u64,
+}
+
+/// The a4 inputs for one unit, cheap enough for the steady frame: `None` off a player or before
+/// its descriptor arrives.
+fn title_key(
+ cfg: &NameConfig,
+ strings: &PvpNameStrings,
+ net: &NetEntity,
+ store: Option<&ObjectStore>,
+) -> Option {
+ let store = store?;
+ (net.kind == EntityKind::Player).then(|| TitleKey {
+ title: benilla_ui::script::RankTitle {
+ rank: store.0.player_pvp_rank().unwrap_or(0),
+ team: store
+ .0
+ .unit_race()
+ .map_or(-1, crate::ui_unit::race_pvp_team),
+ female: store.0.unit_gender() == Some(1),
+ },
+ on: cfg.player_pvp_title && strings.ready(),
+ strings: strings.generation,
+ })
+}
+
+/// `0x608f50`'s main name line: the a1-a3 flag tags glued on with no separator, then the a4 slot,
+/// `0x609370`'s ranked-player leg where `key` is on and the builder resolves, else the plain name.
+fn name_line(flags: u32, key: Option, strings: &PvpNameStrings, name: &str) -> String {
+ let decorated = key
+ .filter(|key| key.on)
+ .and_then(|key| strings.decorated(key, name));
+ let mut line = flag_prefix(flags);
+ line.push_str(decorated.as_deref().unwrap_or(name));
+ line
+}
+
+/// A live plate: its entity and stack, and the inputs the stack was built from, so a steady frame
+/// compares fields and re-reads neither the name cache nor the install strings.
+struct LiveName {
+ plate: Entity,
+ lines: Vec,
+ paint: NamePaint,
+ /// `0x608f50`'s line inputs at build time.
+ flags: u32,
+ name: String,
+ bracketed: Option,
+ key: Option,
}
/// The name's world scale for a unit whose overhead anchor sits `d` world units above its feet.
@@ -149,8 +237,8 @@ impl NamePaint {
pub(crate) struct Nameplates {
materials: HashMap>,
meshes: HashMap, Handle>,
- /// unit → (plate entity, the (lines, color) it was built with).
- live: bevy::ecs::entity::EntityHashMap<(Entity, Vec, NamePaint)>,
+ /// unit → its live plate and the inputs the stack was built from.
+ live: bevy::ecs::entity::EntityHashMap,
/// The atlas generation the meshes' UVs were built from; `None` before the first build.
baked_from: Option,
}
@@ -165,7 +253,7 @@ impl Nameplates {
/// The line count of `unit`'s live name, for the raid marker's seat (`0x6c70d8`: one pitch
/// above the block, or at the bare anchor when no name shows).
pub(crate) fn line_count(&self, unit: Entity) -> Option {
- self.live.get(&unit).map(|(_, lines, _)| lines.len())
+ self.live.get(&unit).map(|live| live.lines.len())
}
}
@@ -289,6 +377,8 @@ pub(crate) fn drive_nameplates(
Res,
// The UnitName* CVar mask.
Res,
+ // The rank strings, read off the current VM ([`PvpNameStrings`]).
+ Res,
),
names: Res,
// The guild cache, the a5 line's text; `ResMut` because a miss sends `CMSG_GUILD_QUERY`.
@@ -310,7 +400,7 @@ pub(crate) fn drive_nameplates(
Query<(), With>,
),
) {
- let (selection, flash, rig, vplates, bubbles, group, name_cfg) = gates;
+ let (selection, flash, rig, vplates, bubbles, group, name_cfg, pvp_strings) = gates;
let (Ok(cam_tf), Some(atlas)) = (camera.single(), atlas.as_mut()) else {
return;
};
@@ -406,8 +496,8 @@ pub(crate) fn drive_nameplates(
0
};
// The AFK slot `0x5ec9e0` alone emits `` for the active player while the mirror
- // `[0xb6e5cc]` is set, whatever the bit (`0x5ec9fd`). Folded into `flags` so
- // `lines_current` and `flag_prefix` agree.
+ // `[0xb6e5cc]` is set, whatever the bit (`0x5ec9fd`). Folded into `flags`, so the cache
+ // compare and [`name_line`] read the same bit.
let flags = if is_self && mirror.is_afk() {
flags | 0x2
} else {
@@ -448,23 +538,23 @@ pub(crate) fn drive_nameplates(
};
seen.insert(entity);
+ let key = title_key(&name_cfg, &pvp_strings, net, store);
match plates.live.get(&entity) {
- // The steady frame compares the cached stack in place, allocating nothing.
- Some((_, l, c)) if *c == color && lines_current(l, flags, name, bracketed) => {}
+ // The steady frame compares the inputs in place, allocating nothing; a rebuild from
+ // these inputs is the stack `LiveName` holds.
+ Some(live)
+ if live.paint == color
+ && live.flags == flags
+ && live.key == key
+ && live.name == name
+ && live.bracketed.as_deref() == bracketed => {}
stale => {
- if let Some((old, _, _)) = stale {
- let old = *old;
- if let Ok(mut e) = commands.get_entity(old) {
+ if let Some(live) = stale {
+ if let Ok(mut e) = commands.get_entity(live.plate) {
e.despawn();
}
}
- // Keep in sync with `lines_current`; its differential test pins this shape.
- let prefix = flag_prefix(flags);
- let mut lines = vec![if prefix.is_empty() {
- name.to_owned()
- } else {
- format!("{prefix}{name}")
- }];
+ let mut lines = vec![name_line(flags, key, &pvp_strings, name)];
if let Some(bracketed) = bracketed {
lines.push(format!("<{bracketed}>"));
}
@@ -495,16 +585,27 @@ pub(crate) fn drive_nameplates(
let plate = commands
.spawn((Mesh3d(mesh), MeshMaterial3d(material), place, NamePlate))
.id();
- plates.live.insert(entity, (plate, lines, color));
+ plates.live.insert(
+ entity,
+ LiveName {
+ plate,
+ lines,
+ paint: color,
+ flags,
+ name: name.to_owned(),
+ bracketed: bracketed.map(str::to_owned),
+ key,
+ },
+ );
}
}
}
// Plates whose unit despawned or gated off this frame.
- plates.live.retain(|unit, (plate, _, _)| {
+ plates.live.retain(|unit, live| {
if seen.contains(unit) {
true
} else {
- if let Ok(mut e) = commands.get_entity(*plate) {
+ if let Ok(mut e) = commands.get_entity(live.plate) {
e.despawn();
}
false
@@ -540,8 +641,8 @@ fn place_nameplates(
};
let facing = cam_tf.rotation;
let blend = 1.0 - (-ROCK_MEAN_RATE * trace.1.delta_secs()).exp();
- for (&unit, (plate, ..)) in plates.live.iter() {
- let (Ok(tf), Ok((mut ptf, mut pglobal))) = (units.get(unit), plate_tfs.get_mut(*plate))
+ for (&unit, live) in plates.live.iter() {
+ let (Ok(tf), Ok((mut ptf, mut pglobal))) = (units.get(unit), plate_tfs.get_mut(live.plate))
else {
continue; // spawned this frame and not yet flushed, or the unit is despawning
};
@@ -603,13 +704,14 @@ pub(crate) struct NameplatesPlugin;
#[derive(Resource)]
struct NameAnchorTrace(bool);
-/// The overhead-name rows' change callback: the name trio and the guild line.
+/// The overhead-name rows' change callback: the name trio, the guild line and the rank prefix.
pub(crate) fn on_cvar(ev: On, mut names: ResMut) {
match ev.key().as_str() {
"unitnameplayer" => names.player = ev.flag(),
"unitnamenpc" => names.npc = ev.flag(),
"unitnameown" => names.own = ev.flag(),
"unitnameplayerguild" => names.player_guild = ev.flag(),
+ "unitnameplayerpvptitle" => names.player_pvp_title = ev.flag(),
_ => {}
}
}
@@ -622,6 +724,7 @@ impl Plugin for NameplatesPlugin {
))
.init_resource::()
.init_resource::()
+ .init_resource::()
// After targeting, V-key plates and chat bubbles: the gate reads their verdicts.
.add_systems(
Update,
@@ -653,8 +756,8 @@ fn drop_stale_glyph_caches(plates: &mut Nameplates, generation: u64, commands: &
plates.meshes.len(),
plates.live.len(),
);
- for (plate, _, _) in plates.live.values() {
- if let Ok(mut e) = commands.get_entity(*plate) {
+ for live in plates.live.values() {
+ if let Ok(mut e) = commands.get_entity(live.plate) {
e.despawn();
}
}
@@ -750,40 +853,181 @@ mod tests {
const RED: Color = Color::linear_rgb(1.0, 0.0, 0.0);
- /// Pinned against the build over every pair of cases, both ways: an unflagged `Bob`
- /// equals a flagged `Bob`, as the built strings do.
+ /// The rank strings a VM defining `pairs` hands over, as the honor feed sets them.
+ fn strings_of(session: u64, pairs: &[(&str, &str)]) -> PvpNameStrings {
+ let mut strings = PvpNameStrings::default();
+ strings.set(session, set_of(pairs));
+ strings
+ }
+
+ fn set_of(pairs: &[(&str, &str)]) -> HashMap {
+ pairs
+ .iter()
+ .map(|&(k, v)| (k.to_string(), v.to_string()))
+ .collect()
+ }
+
+ const TEMPLATE: (&str, &str) = ("UNIT_PVP_NAME", "%s %s");
+ const SERGEANT: (&str, &str) = ("PVP_RANK_7_1", "Sergeant");
+
+ /// A streamed unit's kind and descriptor.
+ fn unit(kind: EntityKind, fields: &[(u16, u32)]) -> (NetEntity, ObjectStore) {
+ use benilla_protocol::messages::ObjectFields;
+ (
+ NetEntity {
+ kind,
+ display_id: None,
+ scale: 1.0,
+ },
+ ObjectStore(ObjectFields::from_pairs(fields)),
+ )
+ }
+
+ /// `UNIT_FIELD_BYTES_0` (36): race human, class warrior, male.
+ const HUMAN_MALE: (u16, u32) = (36, 0x0101);
+ /// `PLAYER_BYTES_3` (195) byte 3: the current honor rank, internal 7.
+ const RANK_7: (u16, u32) = (195, 7 << 24);
+
+ /// The a4 inputs off the descriptor: the public rank byte, the race's team digit, the sex and
+ /// the CVar's strings-ready bit; a creature has no a4 leg.
#[test]
- fn lines_current_matches_the_built_stack() {
- let build = |flags: u32, name: &str, bracketed: Option<&str>| {
- let mut lines = vec![format!("{}{name}", flag_prefix(flags))];
- if let Some(bracketed) = bracketed {
- lines.push(format!("<{bracketed}>"));
- }
- lines
+ fn title_key_reads_the_rank_byte_the_race_and_the_cvar() {
+ let cfg = NameConfig::default();
+ let ready = strings_of(1, &[TEMPLATE]);
+ let bare = PvpNameStrings::default();
+ // Human (race 1 → team 1), rank 7.
+ let (net, human) = unit(EntityKind::Player, &[HUMAN_MALE, RANK_7]);
+ let key = title_key(&cfg, &ready, &net, Some(&human)).unwrap();
+ assert_eq!(
+ (key.title.rank, key.title.team, key.title.female, key.on),
+ (7, 1, false, true)
+ );
+ // Orc (race 2 → team 0), female; the CVar off, and the strings unloaded, each drop the bit.
+ let (_, orc) = unit(EntityKind::Player, &[(36, 0x010102), RANK_7]);
+ let orc = title_key(&cfg, &ready, &net, Some(&orc)).unwrap();
+ assert_eq!((orc.title.team, orc.title.female), (0, true));
+ let off = NameConfig {
+ player_pvp_title: false,
+ ..cfg
};
- let cases: &[(u32, &str, Option<&str>)] = &[
- (0, "Mankrik", None),
- (0, "Young Wolf", Some("Beast")),
- (0, "Young Wolf", None),
- (0x2, "Bob", None),
- (0x2 | 0x8, "Bob", None),
- (0, "Bob", None),
- // The a5 guild slot: a bracketed name, and one colliding with a subname above.
- (0, "Bob", Some("Legacy")),
- (0x2, "Bob", Some("Legacy")),
- (0, "Bob", Some("")),
- (0, "Young Wolf", Some("Beast Handlers")),
- ];
- for &(flags, name, bracketed) in cases {
- let cached = build(flags, name, bracketed);
- for &(f2, n2, b2) in cases {
- assert_eq!(
- lines_current(&cached, f2, n2, b2),
- build(f2, n2, b2) == cached,
- "cache of ({flags:#x}, {name:?}, {bracketed:?}) vs ({f2:#x}, {n2:?}, {b2:?})"
- );
- }
- }
+ assert!(!title_key(&off, &ready, &net, Some(&human)).unwrap().on);
+ assert!(!title_key(&cfg, &bare, &net, Some(&human)).unwrap().on);
+ let (creature, _) = unit(EntityKind::Unit, &[]);
+ assert!(
+ title_key(&cfg, &ready, &creature, Some(&human)).is_none(),
+ "a creature has no a4"
+ );
+ }
+
+ /// The composed main line, from the descriptor up: `0x608f50` glues the a1-a3 tags onto
+ /// `0x609370`'s a4 with no separator; the CVar off, rank 0 or a creature keep the plain name.
+ #[test]
+ fn the_name_line_glues_the_flag_tags_onto_the_ranked_name() {
+ let strings = strings_of(
+ 1,
+ &[TEMPLATE, SERGEANT, ("PVP_RANK_7_1_FEMALE", "Sergeant (f)")],
+ );
+ let line = |cfg: &NameConfig, (net, store): &(NetEntity, ObjectStore), flags, name| {
+ name_line(
+ flags,
+ title_key(cfg, &strings, net, Some(store)),
+ &strings,
+ name,
+ )
+ };
+ const AFK: u32 = 0x2;
+ let cfg = NameConfig::default();
+ let sergeant = unit(EntityKind::Player, &[HUMAN_MALE, RANK_7]);
+ assert_eq!(line(&cfg, &sergeant, AFK, "Bob"), "Sergeant Bob");
+ let off = NameConfig {
+ player_pvp_title: false,
+ ..cfg
+ };
+ assert_eq!(
+ line(&off, &sergeant, AFK, "Bob"),
+ "Bob",
+ "the CVar off"
+ );
+ let unranked = unit(EntityKind::Player, &[HUMAN_MALE]);
+ assert_eq!(line(&cfg, &unranked, 0, "Bob"), "Bob", "rank 0");
+ // The same bytes on a creature: the kind gates the leg, not the descriptor.
+ let wolf = unit(EntityKind::Unit, &[HUMAN_MALE, RANK_7]);
+ assert_eq!(line(&cfg, &wolf, 0, "Young Wolf"), "Young Wolf");
+ // `UNIT_FIELD_BYTES_0` byte 2 = 1: the `_FEMALE` twin.
+ let female = unit(EntityKind::Player, &[(36, 0x01_0101), RANK_7]);
+ assert_eq!(line(&cfg, &female, 0, "Alice"), "Sergeant (f) Alice");
+ }
+
+ /// The decoration is `0x609370`'s: title first, `None` off rank 0 or a key the install lacks.
+ #[test]
+ fn the_snapshot_decorates_a_ranked_name() {
+ let strings = strings_of(1, &[TEMPLATE, SERGEANT]);
+ let key = TitleKey {
+ title: benilla_ui::script::RankTitle {
+ rank: 7,
+ team: 1,
+ female: false,
+ },
+ on: true,
+ strings: strings.generation,
+ };
+ assert_eq!(
+ strings.decorated(key, "Bob").as_deref(),
+ Some("Sergeant Bob"),
+ "the rank rides in front of the name"
+ );
+ assert_eq!(
+ strings.decorated(
+ TitleKey {
+ title: benilla_ui::script::RankTitle {
+ rank: 0,
+ ..key.title
+ },
+ ..key
+ },
+ "Bob"
+ ),
+ None,
+ "unranked: no a4 line"
+ );
+ assert_eq!(
+ strings.decorated(
+ TitleKey {
+ title: benilla_ui::script::RankTitle {
+ team: 0,
+ ..key.title
+ },
+ ..key
+ },
+ "Bob"
+ ),
+ None,
+ "the Horde key is not the Alliance one"
+ );
+ }
+
+ /// A VM's strings move the key the plate cache compares only when they differ: the same
+ /// strings from a new VM rebuild nothing, and a renamed title reaches the line.
+ #[test]
+ fn a_changed_string_set_moves_the_key_and_reaches_the_line() {
+ let (net, store) = unit(EntityKind::Player, &[HUMAN_MALE, RANK_7]);
+ let cfg = NameConfig::default();
+ let built = |strings: &PvpNameStrings| {
+ let key = title_key(&cfg, strings, &net, Some(&store));
+ (key, name_line(0, key, strings, "Bob"))
+ };
+ let mut strings = strings_of(1, &[TEMPLATE, SERGEANT]);
+ let (boot_key, boot_line) = built(&strings);
+ assert_eq!(boot_line, "Sergeant Bob");
+ strings.set(2, set_of(&[TEMPLATE, SERGEANT]));
+ assert!(built(&strings).0 == boot_key, "a new VM, the same strings");
+ strings.set(3, set_of(&[TEMPLATE, ("PVP_RANK_7_1", "Feldwebel")]));
+ let (key, line) = built(&strings);
+ assert_eq!(line, "Feldwebel Bob", "the new VM's title reaches the line");
+ assert!(
+ key != boot_key,
+ "the cached key moves, so the live plate rebuilds"
+ );
}
/// A parked plate's second placement leaves both change ticks alone; a moved unit still writes.
@@ -800,9 +1044,18 @@ mod tests {
.spawn((Transform::default(), GlobalTransform::default(), NamePlate))
.id();
let mut plates = Nameplates::default();
- plates
- .live
- .insert(unit, (plate, vec!["Bob".to_string()], NamePaint::Flash));
+ plates.live.insert(
+ unit,
+ LiveName {
+ plate,
+ lines: vec!["Bob".to_string()],
+ paint: NamePaint::Flash,
+ flags: 0,
+ name: "Bob".into(),
+ bracketed: None,
+ key: None,
+ },
+ );
world.insert_resource(plates);
// First seat: the anchor fallback (no model) is the unit's own translation.
@@ -847,7 +1100,18 @@ mod tests {
};
plates.meshes.insert(lines.clone(), Handle::default());
plates.materials.insert(NamePaint::Flash, Handle::default());
- plates.live.insert(unit, (plate, lines, NamePaint::Flash));
+ plates.live.insert(
+ unit,
+ LiveName {
+ plate,
+ lines,
+ paint: NamePaint::Flash,
+ flags: 0,
+ name: "Young Wolf".into(),
+ bracketed: None,
+ key: None,
+ },
+ );
plates
}
diff --git a/crates/benilla-app/src/ui_honor.rs b/crates/benilla-app/src/ui_honor.rs
index ccdf2c47..a58775db 100644
--- a/crates/benilla-app/src/ui_honor.rs
+++ b/crates/benilla-app/src/ui_honor.rs
@@ -13,6 +13,8 @@
//! Deviation: the reference watches the whole `PLAYER_BYTES_3` dword, so a drunkenness change also
//! fires `PLAYER_PVP_RANK_CHANGED`; we watch byte 3 alone, because that repaint is identical.
+use std::collections::HashMap;
+
use bevy::prelude::*;
use benilla_ui::script::{HonorState, InspectHonorData, ScriptValue, UiScript};
@@ -64,10 +66,43 @@ impl Plugin for UiHonorPlugin {
net::register(app);
app.init_resource::()
.init_resource::()
- .add_systems(Update, feed_honor.in_set(UiFeed));
+ .init_resource::()
+ .add_systems(Update, feed_honor.in_set(UiFeed))
+ // A VM holder, so after the tick; before the driver, so a new VM's strings reach the
+ // lines the frame it arrives.
+ .add_systems(
+ Update,
+ feed_pvp_name_strings
+ .after(crate::ui_script::UiInput)
+ .before(crate::nameplates::drive_nameplates),
+ );
}
}
+/// The overhead name's rank strings ([`crate::nameplates::PvpNameStrings`]), read off a VM the
+/// first frame it is in the world. Every VM is installed whole, outside `Update` (the boot VM with
+/// `GlobalStrings.lua` run, a world VM after its FrameXML and addon load), so the read sees what
+/// the load defined; any other frame pays one compare.
+fn feed_pvp_name_strings(
+ script: Option>,
+ mut strings: ResMut,
+) {
+ let Some(script) = script else {
+ return;
+ };
+ if strings.session() != Some(script.session()) {
+ strings.set(script.session(), pvp_name_strings(&script));
+ }
+}
+
+/// The [`benilla_ui::script::pvp_name_global_keys`] that `script` defines, with their values.
+fn pvp_name_strings(script: &UiScript) -> HashMap {
+ benilla_ui::script::pvp_name_global_keys()
+ .into_iter()
+ .filter_map(|key| benilla_ui::strings::global(script.lua(), &key).map(|value| (key, value)))
+ .collect()
+}
+
/// Read the honor block off our own descriptor, `None` until it streams. That decides only when
/// the first push and its events happen: the reference reads a field it was never sent as 0
/// (`0x51a4b0`-`0x51a7c0`), and so do our bindings.
@@ -264,4 +299,72 @@ mod tests {
fn the_first_push_fires_both() {
assert_eq!(events_for(None, &state()), (true, true));
}
+
+ fn vm(src: &str) -> UiScript {
+ let script = UiScript::new().expect("a VM");
+ script.run(src).expect("globals set");
+ script
+ }
+
+ /// The read takes the overhead name's keys a VM defines, `_FEMALE` twins included, and
+ /// nothing else.
+ #[test]
+ fn the_rank_strings_read_takes_only_the_title_keys() {
+ let strings = pvp_name_strings(&vm(r#"UNIT_PVP_NAME = "%s %s"
+ PVP_RANK_7_1 = "Sergeant"
+ PVP_RANK_7_1_FEMALE = "Sergeant (f)"
+ GARBAGE_KEY = "x""#));
+ assert_eq!(
+ strings.get("UNIT_PVP_NAME").map(String::as_str),
+ Some("%s %s")
+ );
+ assert_eq!(
+ strings.get("PVP_RANK_7_1_FEMALE").map(String::as_str),
+ Some("Sergeant (f)")
+ );
+ assert!(!strings.contains_key("GARBAGE_KEY"), "only the title keys");
+ }
+
+ /// Each VM is read once, the first frame it is in the world, so a world VM whose addon renames
+ /// a title reaches the overhead line.
+ #[test]
+ fn each_new_vm_is_read_once() {
+ use bevy::ecs::system::RunSystemOnce;
+ let mut world = World::new();
+ world.init_resource::();
+ world.run_system_once(feed_pvp_name_strings).unwrap();
+ assert_eq!(
+ world
+ .resource::()
+ .session(),
+ None,
+ "no VM, no read"
+ );
+ let stock = r#"UNIT_PVP_NAME = "%s %s"
+ PVP_RANK_7_1 = "Sergeant""#;
+ let boot = vm(stock);
+ let boot_session = boot.session();
+ world.insert_non_send_resource(boot);
+ world.run_system_once(feed_pvp_name_strings).unwrap();
+ let held = world.resource::();
+ assert_eq!(held.session(), Some(boot_session));
+ assert_eq!(held.get("PVP_RANK_7_1"), Some("Sergeant"));
+
+ // The same VM is not read again: a global it sets later stays out.
+ world
+ .non_send_resource::()
+ .run(r#"PVP_RANK_7_1 = "Later""#)
+ .expect("global set");
+ world.run_system_once(feed_pvp_name_strings).unwrap();
+ let held = world.resource::();
+ assert_eq!(held.get("PVP_RANK_7_1"), Some("Sergeant"));
+
+ let world_vm = vm(&format!("{stock}\nPVP_RANK_7_1 = \"Feldwebel\""));
+ let world_session = world_vm.session();
+ world.insert_non_send_resource(world_vm);
+ world.run_system_once(feed_pvp_name_strings).unwrap();
+ let held = world.resource::();
+ assert_eq!(held.session(), Some(world_session));
+ assert_eq!(held.get("PVP_RANK_7_1"), Some("Feldwebel"));
+ }
}
diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs
index ce8c4338..4b4917f7 100644
--- a/crates/benilla-app/src/ui_script/options_tests.rs
+++ b/crates/benilla-app/src/ui_script/options_tests.rs
@@ -1346,14 +1346,17 @@ fn the_nameplates_page_toggles_the_unit_name_cvars() {
"{row} defaults unchecked"
);
}
- // The name rows at the reference's registered defaults: player "1", NPC and own "0".
- assert!(
- s.eval::(
- "return BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:GetChecked()"
- )
- .unwrap(),
- "Player Names defaults checked"
- );
+ // The name rows at the reference's registered defaults: player, guild and titles "1", NPC and
+ // own "0".
+ for row in ["RowPlayerNames", "RowGuildNames", "RowPlayerTitles"] {
+ assert!(
+ s.eval::(&format!(
+ "return BenillaOptionsFrameContainerBodyNameplates{row}Check:GetChecked()"
+ ))
+ .unwrap(),
+ "{row} defaults checked"
+ );
+ }
for row in ["RowNpcNames", "RowOwnName"] {
assert!(
!s.eval::(&format!(
@@ -2314,9 +2317,9 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
);
checked += 1;
}
- // 83 rows less the three untipped below; four of the 80 carry a `BENILLA_` key, and a dropdown
+ // 84 rows less the three untipped below; four of the 81 carry a `BENILLA_` key, and a dropdown
// row is checked on the key it wears at rest.
- assert_eq!(checked, 80, "every tipped row carries a live key");
+ assert_eq!(checked, 81, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -2412,8 +2415,8 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
s.errors()
);
}
- // Every tipped row: the same 80 the key census counts.
- assert_eq!(raised, 80, "every row but Auto Loot raises a description");
+ // Every tipped row: the same 81 the key census counts.
+ assert_eq!(raised, 81, "every row but Auto Loot raises a description");
}
/// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a
@@ -4069,13 +4072,6 @@ fn without_a_seated_measurer_the_same_fit_reads_zero() {
// nothing and reads back nil, so its box would offer a setting benilla does not have. A CVar is
// registered only once something reads it.
const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
- (
- "UnitNamePlayerPVPTitle",
- "the PvP rank prefix on the overhead name line — slot a4 of `0x608f50`, bit `0x20` of the \
- same mask, resolved by `0x609370` through the `PVP_RANK_%d_%d` GlobalStrings key. Blocked \
- one step further back than its guild twin: the rank byte streams, but the key's second \
- index is a FACTION SIDE that `ui_unit` does not resolve for an arbitrary player yet",
- ),
// No slider CVar may land here: `UIOptionsFrame_Load` hands `GetCVar` to `Slider:SetValue`
// (`UIOptionsFrame.lua:271-275`), which raises on nil (`0x790980`) and stops the stock
// window's load. A check button's `GetCVar(x) == "1"` is nil-safe.
@@ -4379,7 +4375,48 @@ fn show_target_damage_greys_its_own_pair_and_the_floating_text_master_leaves_it_
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
-/// The reference's rules: Player Guild Names is dead while player names are off
+/// The titles box is the guild row's twin: checked and live at the registered defaults (`"1"`),
+/// and a click survives closing and reopening the panel because the CVar is its store.
+#[test]
+fn the_titles_box_sticks_across_a_panel_reopen() {
+ benilla_formats::wow_data_or_skip!();
+ let mut s = harness_on(audio_harness());
+ s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
+ s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
+ .unwrap();
+ let checked = |s: &mut UiScript| -> bool {
+ s.eval::(
+ "return BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitlesCheck:GetChecked()",
+ )
+ .unwrap()
+ };
+ let cvar = |s: &mut UiScript| -> Option {
+ s.eval::