From 622e1c3e7a683247c5463d4ceca07948e71d6d8d Mon Sep 17 00:00:00 2001 From: goran2711 Date: Sun, 4 Oct 2026 05:04:32 +0100 Subject: [PATCH] fix: overhead names carry the player's PvP title, as 1.12.1 does (#792) - capture: a name-rank scenario shoots a ranked player's overhead name line - Overhead names carry the player's PvP title under UnitNamePlayerPVPTitle, as 1.12.1 does (#550) - review: bundle the rank-title inputs; doc the snapshot and rank-19 limits - nameplates: read rank strings per VM; test the composed name line - pvp: correct the rank-title docs; convert the credit team without a cast - Fix the titles row comment; name-rank reuses WOLF_POS, cites :900 - rank strings: the honor feed reads them off the VM; nameplates only reads the value Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Sam --- crates/benilla-app/assets/ui/OptionsFrame.xml | 30 +- crates/benilla-app/src/capture/fixtures.rs | 44 ++ crates/benilla-app/src/capture/scenarios.rs | 14 + crates/benilla-app/src/cvars/mod.rs | 11 +- crates/benilla-app/src/cvars/table.rs | 6 +- crates/benilla-app/src/nameplates.rs | 458 ++++++++++++++---- crates/benilla-app/src/ui_honor.rs | 105 +++- .../src/ui_script/options_tests.rs | 99 ++-- crates/benilla-ui/src/script/mod.rs | 2 +- crates/benilla-ui/src/script/pvp/mod.rs | 88 +++- crates/benilla-ui/src/script/pvp/tests.rs | 82 ++++ 11 files changed, 793 insertions(+), 146 deletions(-) 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::>(r#"return GetCVar("UnitNamePlayerPVPTitle")"#) + .unwrap() + }; + assert!(checked(&mut s), "the registered default is \"1\""); + assert_eq!(cvar(&mut s).as_deref(), Some("1")); + + // A click writes the CVar; the box and the mirror agree. + s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitlesCheck:Click()") + .unwrap(); + assert!(!checked(&mut s)); + assert_eq!(cvar(&mut s).as_deref(), Some("0")); + + // Closed and reopened, the row re-reads the CVar instead of a staged value. + s.run("HideUIPanel(BenillaOptionsFrame) ShowUIPanel(BenillaOptionsFrame)") + .unwrap(); + s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()") + .unwrap(); + assert!(!checked(&mut s), "the click stuck"); + s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitlesCheck:Click()") + .unwrap(); + assert!(checked(&mut s)); + assert_eq!(cvar(&mut s).as_deref(), Some("1")); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// The reference's rules: Player Guild Names and Player Titles are dead while player names are off /// (`UIOptionsFrame.lua:703-709`), Auto-Follow Speed while the style is Never (`:717-721`). #[test] fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style() { @@ -4387,22 +4424,28 @@ fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style() let mut s = harness_on(audio_harness()); s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap(); - // Nameplates: `UnitNamePlayer` ships "1", so the child arrives live. + // Nameplates: `UnitNamePlayer` ships "1", so both children arrive live. s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()") .unwrap(); - let guild_on = |s: &mut UiScript| -> bool { - s.eval::( - "return BenillaOptionsFrameContainerBodyNameplatesRowGuildNamesCheck:IsEnabled() ~= 0", - ) + let child_on = |s: &mut UiScript, row: &str| -> bool { + s.eval::(&format!( + "return BenillaOptionsFrameContainerBodyNameplates{row}Check:IsEnabled() ~= 0" + )) .unwrap() }; - assert!(guild_on(&mut s)); + for row in ["RowGuildNames", "RowPlayerTitles"] { + assert!(child_on(&mut s, row), "{row} arrives live"); + } s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:Click()") .unwrap(); - assert!(!guild_on(&mut s), "no names, no guild line to gate"); + for row in ["RowGuildNames", "RowPlayerTitles"] { + assert!(!child_on(&mut s, row), "no names, no {row} to gate"); + } s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:Click()") .unwrap(); - assert!(guild_on(&mut s)); + for row in ["RowGuildNames", "RowPlayerTitles"] { + assert!(child_on(&mut s, row)); + } // Controls: the style ships "1" (Smart), so the slider arrives live; "0" is Never. s.run("BenillaOptionsFrameCategoryListRowControls:Click()") diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 55f204f8..ad73cae4 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -227,7 +227,7 @@ pub(crate) use model::Model; pub use model::{FontProbe, TextureProbe, TextureSizeProbe, WorldLocProjector}; pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo}; pub use pet::{PetActionView, PetStats}; -pub use pvp::{HonorState, InspectHonorData}; +pub use pvp::{decorated_name, pvp_name_global_keys, HonorState, InspectHonorData, RankTitle}; pub use quest::{ QuestAction, QuestItemView, QuestPanel, QuestRewardSpell, QuestSelect, QuestState, }; diff --git a/crates/benilla-ui/src/script/pvp/mod.rs b/crates/benilla-ui/src/script/pvp/mod.rs index 53c9734b..6bb8d4d3 100644 --- a/crates/benilla-ui/src/script/pvp/mod.rs +++ b/crates/benilla-ui/src/script/pvp/mod.rs @@ -129,7 +129,15 @@ impl super::UiScript { /// 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 { - rank_title_gendered(self.lua(), i64::from(rank), i64::from(team), female) + rank_title_gendered( + self.lua(), + RankTitle { + rank, + // Past `i8` names no key, the lookup's own miss. + team: i8::try_from(team).ok()?, + female, + }, + ) } } @@ -218,14 +226,35 @@ fn rank_title_ungendered(lua: &Lua, rank: i64, team: i64) -> Option { global_string(lua, &format!("PVP_RANK_{rank}_{team}")) } +/// The `PVP_RANK__[_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`). Used by `UnitPVPName` and the credit line. -fn rank_title_gendered(lua: &Lua, rank: i64, team: i64, female: bool) -> Option { - female - .then(|| global_string(lua, &format!("PVP_RANK_{rank}_{team}_FEMALE"))) +/// 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 { + 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, title: RankTitle) -> Option { + title + .female + .then(|| lookup(&format!("PVP_RANK_{}_{}_FEMALE", title.rank, title.team))) .flatten() - .or_else(|| rank_title_ungendered(lua, rank, team)) + .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"`); @@ -254,14 +283,55 @@ fn format_two_strings(fmt: &str, a: &str, b: &str) -> String { out } +/// The install keys a name-line rank prefix can resolve: `UNIT_PVP_NAME` and every +/// `PVP_RANK__[_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 { + 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, + key: RankTitle, + name: &str, +) -> Option { + 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); `name` is the /// plain `UnitName`. 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 title = rank_title_gendered(lua, i64::from(u.pvp_rank), team_of(u), u.sex == 3); - if let (Some(fmt), Some(title)) = (global_string(lua, "UNIT_PVP_NAME"), title) { - let mut out = format_two_strings(&fmt, &title, name); + 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)) { diff --git a/crates/benilla-ui/src/script/pvp/tests.rs b/crates/benilla-ui/src/script/pvp/tests.rs index 3d71138c..eb5bee1e 100644 --- a/crates/benilla-ui/src/script/pvp/tests.rs +++ b/crates/benilla-ui/src/script/pvp/tests.rs @@ -355,6 +355,88 @@ fn the_rust_side_title_lookup_is_gendered_where_the_binding_is_not() { ); } +/// The lookup-generic builder the world-text pass runs ([`decorated_name`]): the same key +/// construction and `_FEMALE` fallback, and a plain `None` where the builder declines. +#[test] +fn the_lookup_generic_builder_resolves_the_same_keys() { + let strings: std::collections::HashMap<&str, &str> = [ + ("UNIT_PVP_NAME", "%s %s"), + ("PVP_RANK_9_1", "Sergeant Major"), + ("PVP_RANK_9_1_FEMALE", "Sergeant Major (f)"), + ("PVP_RANK_9_0", "Senior Sergeant"), + ] + .into(); + let lookup = |key: &str| strings.get(key).map(|value| (*value).to_string()); + assert_eq!( + decorated_name( + lookup, + RankTitle { + rank: 9, + team: 1, + female: false, + }, + "Bob", + ) + .as_deref(), + Some("Sergeant Major Bob"), + "rank first, name second" + ); + assert_eq!( + decorated_name( + lookup, + RankTitle { + rank: 9, + team: 1, + female: true, + }, + "Bob", + ) + .as_deref(), + Some("Sergeant Major (f) Bob"), + "the female twin wins" + ); + assert_eq!( + decorated_name( + lookup, + RankTitle { + rank: 9, + team: 0, + female: false, + }, + "Bob", + ) + .as_deref(), + Some("Senior Sergeant Bob"), + "team 0 keys the Horde list" + ); + assert_eq!( + decorated_name( + lookup, + RankTitle { + rank: 0, + team: 1, + female: false, + }, + "Bob", + ), + None, + "unranked" + ); + assert_eq!( + decorated_name( + lookup, + RankTitle { + rank: 6, + team: 1, + female: false, + }, + "Bob", + ), + None, + "a key the install lacks declines" + ); +} + /// The panes' `if not rankName` fallback needs a nil, not an empty string or a raise. #[test] fn a_missing_rank_global_reads_nil_not_empty() {