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) <noreply@anthropic.com> Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Sam
parent
aa103d5a65
commit
622e1c3e7a
@@ -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"
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowNpcNames" inherits="BenillaOptionsCheckboxRowTemplate">
|
||||
<!-- A child of Player Names on its own CVar, CheckButton23 of
|
||||
21 (`UIOptionsFrame.lua:24`), greyed with it (`:703-709`). -->
|
||||
<Frame name="$parentRowPlayerTitles" inherits="BenillaOptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowGuildNames" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowGuildNames" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
BenillaOptionsRow_OnLoad(this, "UnitNamePlayerPVPTitle", "Player Titles",
|
||||
"OPTION_TOOLTIP_SHOW_PLAYER_TITLES", 1)
|
||||
this.parentRow = "RowPlayerNames"
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowNpcNames" inherits="BenillaOptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowPlayerTitles" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowPlayerTitles" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>BenillaOptionsRow_OnLoad(this, "UnitNameNPC", "NPC Names",
|
||||
"OPTION_TOOLTIP_SHOW_NPC_NAMES")</OnLoad></Scripts>
|
||||
</Frame>
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<NameConfig>(&app).npc);
|
||||
apply(&mut app, "unitnameown", "1");
|
||||
assert!(res::<NameConfig>(&app).own);
|
||||
apply(&mut app, "unitnameplayerpvptitle", "0");
|
||||
assert!(!res::<NameConfig>(&app).player_pvp_title);
|
||||
apply(&mut app, "ChatBubbles", "0");
|
||||
assert!(!res::<BubbleConfig>(&app).all);
|
||||
apply(&mut app, "chatbubblesparty", "0");
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 `<Subname>`; a player's `<AFK>`/`<DND>`/`<GM>` prefixes,
|
||||
//! name and `<Guild>`. 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 `<Subname>`; a player's `<AFK>`/`<DND>`/`<GM>`
|
||||
//! prefixes, the a4 rank prefix (`UnitNamePlayerPVPTitle`, bit `0x20`) and name, and `<Guild>`.
|
||||
//! 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_<rank>_<team>` 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<n>` 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<u64>,
|
||||
/// Moves when a read changes the strings, so a line built from the old ones rebuilds.
|
||||
generation: u64,
|
||||
strings: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl PvpNameStrings {
|
||||
/// The VM session the strings were read from; `None` before the first VM.
|
||||
pub(crate) fn session(&self) -> Option<u64> {
|
||||
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<String, String>) {
|
||||
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<String> {
|
||||
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<TitleKey> {
|
||||
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<TitleKey>, 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<String>,
|
||||
paint: NamePaint,
|
||||
/// `0x608f50`'s line inputs at build time.
|
||||
flags: u32,
|
||||
name: String,
|
||||
bracketed: Option<String>,
|
||||
key: Option<TitleKey>,
|
||||
}
|
||||
|
||||
/// 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<NamePaint, Handle<StandardMaterial>>,
|
||||
meshes: HashMap<Vec<String>, Handle<Mesh>>,
|
||||
/// unit → (plate entity, the (lines, color) it was built with).
|
||||
live: bevy::ecs::entity::EntityHashMap<(Entity, Vec<String>, NamePaint)>,
|
||||
/// unit → its live plate and the inputs the stack was built from.
|
||||
live: bevy::ecs::entity::EntityHashMap<LiveName>,
|
||||
/// The atlas generation the meshes' UVs were built from; `None` before the first build.
|
||||
baked_from: Option<u64>,
|
||||
}
|
||||
@@ -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<usize> {
|
||||
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<crate::ui_party::GroupState>,
|
||||
// The UnitName* CVar mask.
|
||||
Res<NameConfig>,
|
||||
// The rank strings, read off the current VM ([`PvpNameStrings`]).
|
||||
Res<PvpNameStrings>,
|
||||
),
|
||||
names: Res<NameCache>,
|
||||
// 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<crate::entities::mount::MountChild>>,
|
||||
),
|
||||
) {
|
||||
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 `<AFK>` 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<crate::cvars::CvarChanged>, mut names: ResMut<NameConfig>) {
|
||||
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::<Nameplates>()
|
||||
.init_resource::<NameConfig>()
|
||||
.init_resource::<PvpNameStrings>()
|
||||
// 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 `<AFK>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<String, String> {
|
||||
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, "<AFK>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("<Legacy>")),
|
||||
(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"), "<AFK>Sergeant Bob");
|
||||
let off = NameConfig {
|
||||
player_pvp_title: false,
|
||||
..cfg
|
||||
};
|
||||
assert_eq!(
|
||||
line(&off, &sergeant, AFK, "Bob"),
|
||||
"<AFK>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
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<InspectHonor>()
|
||||
.init_resource::<HonorFeedState>()
|
||||
.add_systems(Update, feed_honor.in_set(UiFeed));
|
||||
.init_resource::<crate::nameplates::PvpNameStrings>()
|
||||
.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<NonSend<UiScript>>,
|
||||
mut strings: ResMut<crate::nameplates::PvpNameStrings>,
|
||||
) {
|
||||
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<String, String> {
|
||||
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::<crate::nameplates::PvpNameStrings>();
|
||||
world.run_system_once(feed_pvp_name_strings).unwrap();
|
||||
assert_eq!(
|
||||
world
|
||||
.resource::<crate::nameplates::PvpNameStrings>()
|
||||
.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::<crate::nameplates::PvpNameStrings>();
|
||||
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::<UiScript>()
|
||||
.run(r#"PVP_RANK_7_1 = "Later""#)
|
||||
.expect("global set");
|
||||
world.run_system_once(feed_pvp_name_strings).unwrap();
|
||||
let held = world.resource::<crate::nameplates::PvpNameStrings>();
|
||||
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::<crate::nameplates::PvpNameStrings>();
|
||||
assert_eq!(held.session(), Some(world_session));
|
||||
assert_eq!(held.get("PVP_RANK_7_1"), Some("Feldwebel"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<bool>(
|
||||
"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::<bool>(&format!(
|
||||
"return BenillaOptionsFrameContainerBodyNameplates{row}Check:GetChecked()"
|
||||
))
|
||||
.unwrap(),
|
||||
"{row} defaults checked"
|
||||
);
|
||||
}
|
||||
for row in ["RowNpcNames", "RowOwnName"] {
|
||||
assert!(
|
||||
!s.eval::<bool>(&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::<bool>(
|
||||
"return BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitlesCheck:GetChecked()",
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let cvar = |s: &mut UiScript| -> Option<String> {
|
||||
s.eval::<Option<String>>(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::<bool>(
|
||||
"return BenillaOptionsFrameContainerBodyNameplatesRowGuildNamesCheck:IsEnabled() ~= 0",
|
||||
)
|
||||
let child_on = |s: &mut UiScript, row: &str| -> bool {
|
||||
s.eval::<bool>(&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()")
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<String> {
|
||||
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<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`). Used by `UnitPVPName` and the credit line.
|
||||
fn rank_title_gendered(lua: &Lua, rank: i64, team: i64, female: bool) -> Option<String> {
|
||||
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<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(|| 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_<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); `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)) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user