Files
benilla-octo/crates/benilla-app/src/vplates.rs
T
gromandClaude Opus 5 d39d3ec5cf cvars: the later clients' per-kind nameplate switches, so an addon's rows are editable
Cinematic's Nameplates page asks `GetCVar(name) ~= nil` before it will offer a
row (`Effects.lua`'s `HasPlateSettings`). A nil answer is not "off" to an
addon written for a later client — it is "this client has no such setting" —
and the whole row goes grey, which is why grom could not touch Friendly NPCs.
The two rows that worked were the two that happen to name
`nameplateShowEnemies`/`nameplateShowFriends`, the only nameplate CVars this
client had, because 1.12 has none at all.

Seventeen rows registered. Sixteen are live: friendly players, friendly NPCs,
hostile players, and each side's pets/guardians/totems/minions — eight names on
the friendly side and four on the hostile one, each side driving one field,
because a 1.12 object update carries an owner and not a kind of owner and an
addon writes the whole set together. `nameplateMaxDistance` joins
`NameplateRange` and `NP_NameplateDistance` as a third name for one reach.

All default "1", the least-restrictive value, for the reason `NameplateRange`
gives: the two masters already decide whether plates show at all, and a
refinement shipped off would take away plates a player has today. Paired
against `VPlateMode::default()` in the defaults test, the pairing the
derived-Default trap forced on `occlusion`.

Five are registered and inert, each saying so on its own row and in the orphan
test's list: `nameplateShowAll` (1.12 has no in-combat-only mode),
`nameplateShowSelf` (no plate over the player),
`nameplateShowOnlyNameForFriendlyPlayerUnits` and
`nameplateForceShowUnitName` (no such plate mode here), and
`nameplateShowEnemyMinus` (the plate path has no creature rank).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 21:15:38 -07:00

1424 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! V-key nameplates, `CGNamePlateFrame` (ctor `0x7cb250`): health-bar plates drawn as a 2-D
//! overlay that replaces a unit's overhead name. This file owns the world side (gate, anchor,
//! projection, seat, basis); the widgets live in [`benilla_ui::script::nameplate`].
//!
//! - Toggles: the `[0xc4da34]` bitmask, bit 0 enemy and bit 3 friendly, both off at boot.
//! - Gate: never the own unit or a `NOT_SELECTABLE` one; enemy or friendly by `CanAttack` from the
//! player (`0x606980`), and a player subject must also pass `CanCooperate` (equal faction-group
//! masks); 20 yd; no occlusion; a unit not projected into view loses its plate (`0x60f600`).
//! - Anchor: the overhead head point (`0x608640`) plus 2/3 yd (`[0x80abfc]`), projected every
//! frame (`0x483ee0`), the plate's top centre on it (`0x509ec0`), sized in gx units of the
//! screen diagonal (`0x41ad10`), outside uiScale. Deviation: the basis is damped past
//! [`PLATE_DIAG_KNEE`], because the reference's unbounded growth (`0x7705b0`) draws plates too
//! large and soft at high resolutions.
//! - Highlight: the mouseover or the target (`0x606f20` into `0x607080`) lights the plate.
//! Deviation: the bar fill brightens by `LIT_BOOST` in place of the reference's ADD
//! `Nameplate-Glow` rim, which reads as hard edge lines under benilla's linear blending.
//! Hovering a plate makes its unit the mouseover (`0x7cb850`) and turns the name yellow; a
//! click on it selects.
//! - With a target, every other plate dims ([`DIM_ALPHA`]).
use bevy::ecs::entity::EntityHashSet;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use benilla_protocol::EntityKind;
use benilla_ui::script::{unit_is_grey, PlateFeed, PlateGeometry, PlateState};
use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
use crate::names::NameCache;
use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, Reputations, SelfPlayer};
use crate::target::{ring_reaction, Factions, Hovered, Selection, TargetUpdate};
use benilla_world::view::WorldCamera;
/// The plate border, sharp-resampled so its 128x32 art stays crisp when magnified.
pub(crate) mod border;
/// The `[0xc4da34]` master bitmask: bit 0 enemy plates, bit 3 friendly, both clear at boot as in
/// the reference (`UIOptionsFrame.lua:180-183` sets the saved globals nil). The gate reads this
/// resource; [`CVAR_ENEMIES`] and [`CVAR_FRIENDS`] persist it. 1.12 registers no nameplate CVar,
/// so those names are not 1.12's.
#[derive(Resource, Clone, Copy)]
pub(crate) struct VPlateMode {
pub(crate) enemies: bool,
pub(crate) friends: bool,
/// `nameplateShowCritters`: whether a critter gets a plate at all.
///
/// **A deviation, and off by default.** The reference plates a critter like any other
/// attackable unit — `0x60f600`'s ladder has no creature-type test — and pfUI's own
/// "Hide Healthbar On Critters" (default on) then hides the bar and *keeps* the level and
/// name, which is a name floating over every rabbit. Its branch needs `UnitCreatureTypeID`
/// on a plate GUID, which needs the `NAME_PLATE_UNIT_ADDED` → `C_NamePlate` → `UnitGUID`
/// chain this client has and a stock 1.12 one does not — so the feature is dormant on the
/// real client and awake here, and the result is a floating name nobody asked for. grom did
/// ask for them gone; `SetCVar("nameplateShowCritters", "1")` puts the reference's behaviour
/// back.
pub(crate) critters: bool,
/// SuperWoW's `NameplateRange` (`docs/ADDON_COMPAT.md`), squared: the reference's own
/// hardcoded 20 yd made tunable, the same CVar `nameplates.rs`'s overhead-name cull reads.
/// Derived-default `0.0` until the boot-time `CvarChanged` observer seeds it, the same
/// bootstrap `enemies`/`friends` already rely on.
pub(crate) range_sq: f32,
/// SuperWoW's `NameplateMotion`: whether overlapping plates are pushed apart.
///
/// **Two of its four options are built, and which two is the honest part.** SuperAPI's panel
/// offers Overlap (0), Default spread (1), Smart spread (2) and Compact spread (3)
/// (`SuperAPIOptions.lua:20-25`). `0` is this flag clear — the plate sits on its own
/// projected point and plates draw through each other — and `1` is it set, which is the
/// reference's own SmartScreenRect solver ([`crate::smart_rect`]) and so today's behaviour.
/// `2` and `3` are the mod's own alternative algorithms, which nothing here can know, and
/// they **fall back to 1**: the deviation is written down in `docs/ADDON_COMPAT.md`, and it
/// is the benign direction (plates still do not overlap) rather than refusing the row and
/// leaving a player no way to turn the spread off at all.
///
/// Defaults **on**, so an untouched client keeps the reference's spread.
pub(crate) spread: bool,
/// The later clients' per-kind plate switches, the refinements under `enemies`/`friends`.
///
/// 1.12 has two nameplate switches and no notion of a kind; every client after it splits them
/// by what the unit *is*, and the addons written against those clients drive the split names
/// rather than the two masters. Cinematic's Nameplates page is the case in hand: each of its
/// rows asks `GetCVar(name) ~= nil` first (`Effects.lua`'s `HasPlateSettings`), greys the
/// whole row when the answer is nil, and so left grom unable to touch Friendly NPCs — the row
/// whose only name is `nameplateShowFriendlyNPCs` — while the rows that happen to name
/// `nameplateShowEnemies`/`nameplateShowFriends` worked. The rows exist so the question has an
/// answer; they gate the plate as well, so the answer is not a lie.
///
/// A kind here is what this client can actually tell apart at the seat: a player, a unit owned
/// by someone (`CREATEDBY`/`SUMMONEDBY`: pet, guardian, totem and minion all at once, which is
/// why the eight later names for that one idea share one field — an addon writes the whole set
/// together, so last-writer-wins is what it meant), or any other NPC. All default **on**: the
/// masters decide whether plates show at all, and a refinement defaulting off would take away
/// plates a 1.12 player has today.
pub(crate) friendly_players: bool,
/// The friendly half of [`Self::friendly_players`] for NPCs nobody owns.
pub(crate) friendly_npcs: bool,
/// Pets, guardians, totems and minions on the friendly side — one field, eight CVar names.
pub(crate) friendly_minions: bool,
/// The hostile half of [`Self::friendly_players`]. (Hostile NPCs have no refinement of their
/// own: `nameplateShowEnemies` is the name every client uses for them, and it is the master.)
pub(crate) enemy_players: bool,
/// Pets, guardians, totems and minions on the hostile side — one field, four CVar names.
pub(crate) enemy_minions: bool,
/// UnitXP_SP3's "Proper Nameplates Occlusion" (`NameplateOcclusion`): hide a plate whose unit
/// the camera cannot see. 1.12 has no such test — a plate is drawn for anything in range,
/// wall or no wall. Seeded by the boot-time `CvarChanged` observer like the rest of this
/// struct, so `false` here is "not yet told", not the shipped default (which is on).
pub(crate) occlusion: bool,
}
/// Written out rather than derived, because **a derived `false` here is not the same thing for
/// every field**. `enemies`/`friends` are deliberately clear at boot (the reference leaves the
/// saved globals nil) and `range_sq` carries its registered default, because the CVar it reads is
/// one a player's `config.toml` usually does *not*. `occlusion`'s CVar is registered `"1"` and is normally *absent*
/// from that file — and a boot only emits `CvarChanged` for rows the file actually carries, so
/// nothing would ever tell this field its real default. A derived `false` therefore shipped the
/// feature off while the CVar read on, and writing `"1"` over a value already `"1"` is a no-op
/// that emits no event, so the toggle could not heal it either.
impl Default for VPlateMode {
fn default() -> Self {
Self {
enemies: false,
friends: false,
critters: false,
// `NameplateRange`'s registered default, 80 yd, squared — and it has to be written
// here for exactly the reason the paragraph above gives. A `0.0` derived default is
// not "no cull": the gate is `distance_squared > range_sq`, so zero culls *every*
// unit at any distance, and a player whose `config.toml` carries no `NameplateRange`
// row (the normal case — a registered default is not written to the file) got no
// nameplates at all, with both checkboxes ticked and V pressed. The overhead names
// kept working the whole time, because `NameConfig::default()` already wrote 80 here;
// that asymmetry is what the pairing below now forbids.
range_sq: 80.0 * 80.0,
// Must equal the `NameplateOcclusion` row's registered default; `the_occlusion_default_
// agrees_with_its_registered_cvar` is that check.
occlusion: true,
// Likewise `NameplateMotion`, whose registered default is `1`: the reference spreads.
spread: true,
// The per-kind refinements, every one registered `"1"`, and written out here for the
// same reason as `occlusion`: their rows are normally absent from `config.toml`, so no
// `CvarChanged` ever arrives to tell a derived `false` otherwise, and the plates of a
// whole kind would go missing with both masters on.
friendly_players: true,
friendly_npcs: true,
friendly_minions: true,
enemy_players: true,
enemy_minions: true,
}
}
}
/// The persisted names of the two toggles.
pub(crate) const CVAR_ENEMIES: &str = benilla_ui::script::CVAR_NAMEPLATE_ENEMIES;
pub(crate) const CVAR_FRIENDS: &str = benilla_ui::script::CVAR_NAMEPLATE_FRIENDS;
/// The units carrying a live plate this frame; the overhead-name driver draws no name for them
/// (the reference's ShouldShowName).
#[derive(Resource, Default)]
pub(crate) struct VPlates(pub(crate) EntityHashSet);
/// The unit whose plate the pointer is inside, from last frame's layout: the plate's OnEnter
/// publishing the mouseover (`0x7cb850` into `[0xb4e2c8]`), since the world pick stands down
/// over UI.
#[derive(Resource, Default)]
pub(crate) struct PlateHover(pub(crate) Option<Entity>);
/// Completed plate clicks, physical or an addon's `plate:Click`, waiting for the targeting chain:
/// the reference's click slot (`0x7cb910`) ends in the same `SetSelection` as a body click.
#[derive(Resource, Default)]
pub(crate) struct PlateClicks {
pub(crate) left: Vec<Entity>,
pub(crate) right: Vec<Entity>,
}
/// The plate frame in gx units (`[0x87d9cc]`, `[0x87d9d0]`); child offsets from `0x7cb250` and
/// `0x7cb6d0`.
const PLATE_W: f32 = 0.1;
const PLATE_H: f32 = 0.025;
/// The health bar: BOTTOMLEFT at the plate's BOTTOMLEFT plus the offset.
const BAR_OFF_X: f32 = 0.0031;
const BAR_OFF_Y: f32 = 0.003125;
const BAR_W: f32 = 0.0804;
const BAR_H: f32 = 0.007025;
/// The name's BOTTOM sits at the plate CENTER; the level's CENTER at plate BOTTOMRIGHT plus
/// (-0.0092, +0.0071); the skull overlays the level. Deviation: `LEVEL_H` is 0.0086, one em under
/// the reference's 0.009, because the level number read too large.
const NAME_H: f32 = 0.01;
const LEVEL_H: f32 = 0.0086;
const LEVEL_OFF_X: f32 = 0.0092;
const LEVEL_OFF_Y: f32 = 0.0071;
const SKULL_SIZE: f32 = 0.01;
/// The raid-target icon (`0x7cb250`): its RIGHT on the border's LEFT, hanging off the plate's
/// left edge.
const RAID_ICON_SIZE: f32 = 0.02;
/// `[0x80abfc]`.
const PLATE_LIFT: f32 = 2.0 / 3.0;
/// Deviation: the reference dims non-target plates to `0x7F`; raised because that fades them too
/// far to read.
const DIM_ALPHA: f32 = 178.0 / 255.0;
/// The bar-fill palette, the dwords at `0xcf60d0`/`e8`/`c8`/`dc`, in sRGB.
const PLATE_HOSTILE: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
const PLATE_NEUTRAL: [f32; 4] = [1.0, 1.0, 0.0, 1.0];
const PLATE_FRIENDLY: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
const PLATE_PLAYER: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
/// `0x7cbaa0`'s test order: reaction 1 or less red, else a player blue, else 4 or more green,
/// else (2 and 3) yellow.
fn plate_tint(rank: u8, is_player: bool) -> [f32; 4] {
if rank <= 1 {
PLATE_HOSTILE
} else if is_player {
PLATE_PLAYER
} else if rank >= 4 {
PLATE_FRIENDLY
} else {
PLATE_NEUTRAL
}
}
/// The level con palette, `0x7cbd50`'s dwords, not FrameXML's `QuestDifficultyColor`.
const CON_RED: [f32; 4] = [1.0, 25.0 / 255.0, 25.0 / 255.0, 1.0];
const CON_ORANGE: [f32; 4] = [1.0, 127.0 / 255.0, 63.0 / 255.0, 1.0];
const CON_YELLOW: [f32; 4] = [1.0, 1.0, 0.0, 1.0];
const CON_GREEN: [f32; 4] = [63.0 / 255.0, 178.0 / 255.0, 63.0 / 255.0, 1.0];
const CON_GRAY: [f32; 4] = [127.0 / 255.0, 127.0 / 255.0, 127.0 / 255.0, 1.0];
/// The con colour (`0x7cbd50`): +5 or more red, +3/+4 orange, -2..+2 yellow, then green until
/// the shared grey test ([`unit_is_grey`], table `[0x81dda8]`) says gray.
fn con_color(pl_level: u32, unit_level: u32) -> [f32; 4] {
let diff = i64::from(unit_level) - i64::from(pl_level);
if diff >= 5 {
CON_RED
} else if diff >= 3 {
CON_ORANGE
} else if diff >= -2 {
CON_YELLOW
} else if !unit_is_grey(pl_level, unit_level) {
CON_GREEN
} else {
CON_GRAY
}
}
/// Snap a logical-pixel coordinate onto the device pixel grid, so the sharp-resampled border
/// blits 1:1 at any scale factor. Deviation: the reference draws at fractional device
/// coordinates; the snap keeps the magnified border crisp. Shared with the chat bubble.
pub(crate) fn device_snap(v: f32, scale: f32) -> f32 {
(v * scale).round() / scale
}
/// Deviation: the reference's plates grow with the diagonal without limit (`0x7705b0`), which
/// reads too big and soft; past this diagonal, where the frame is the border art's native
/// 128x32 px, the basis grows at [`PLATE_GROWTH_DAMP`] of that rate.
const PLATE_DIAG_KNEE: f32 = 1280.0;
/// 0 pins the native size, 1 is the reference's rate.
const PLATE_GROWTH_DAMP: f32 = 0.5;
/// One gx unit in pixels: the screen diagonal (`0x41ad10`), never uiScale, damped past
/// [`PLATE_DIAG_KNEE`]. Shared with the chat bubble so its text holds the plate's em.
pub(crate) fn plate_basis(viewport: Vec2) -> f32 {
let d = viewport.x.hypot(viewport.y);
if d <= PLATE_DIAG_KNEE {
d
} else {
PLATE_DIAG_KNEE + (d - PLATE_DIAG_KNEE) * PLATE_GROWTH_DAMP
}
}
/// gx units to pixels for frame geometry; text takes [`text_px`].
pub(crate) fn gx_px(v: f32, basis: f32) -> f32 {
(v * basis).round()
}
/// A plate FontString height to its em, `min(32, round(h * basis))`: the reference's
/// `0x44d040` and `0x5ca030` net `h` times the diagonal, capped at the 32 px atlas cell, and
/// re-rasterize on every resize (`0x5c2b50`).
pub(crate) fn text_px(h: f32, basis: f32) -> f32 {
(h * basis).round().min(32.0)
}
/// Push the mode into FrameXML's `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON`. `UpdateNameplates`
/// (`UIOptionsFrame.lua:768`) replays these globals into the engine on `VARIABLES_LOADED` and
/// every `PLAYER_ENTERING_WORLD` (`UIParent.lua:234`, `:367`), so they must match the mode or
/// the replay turns plates off and overwrites the persisted CVar.
pub(crate) fn push_plate_globals(script: &benilla_ui::script::UiScript, mode: VPlateMode) {
// The number 1 or nil, never 0: a Lua 0 is truthy.
let on = |b: bool| b.then_some(1i64);
let g = script.lua().globals();
if let Err(e) = g
.set("NAMEPLATES_ON", on(mode.enemies))
.and_then(|()| g.set("FRIENDNAMEPLATES_ON", on(mode.friends)))
{
warn!("nameplates: FrameXML globals: {e}");
}
}
/// Keep the globals in step with the mode, memoised per VM; the world-entry load seeds them
/// before `VARIABLES_LOADED`, which no `Update` system can reach.
fn feed_plate_globals(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mode: Res<VPlateMode>,
mut told: Local<crate::ui_script::VmMemo<Option<(bool, bool)>>>,
) {
let Some(script) = script else {
return;
};
let now = (mode.enemies, mode.friends);
let told = told.get(&script);
if *told != Some(now) {
*told = Some(now);
push_plate_globals(&script, *mode);
}
}
/// The NAMEPLATES, FRIENDNAMEPLATES and ALLNAMEPLATES bindings; ALLNAMEPLATES is 1.12's body,
/// both on unless both already on. A press also writes the CVar, which persists it.
///
/// Deviation: V and Shift-V toggle their own bit independently, where 1.12's bodies
/// (`Bindings.xml:516-537`) turn the other kind off.
fn toggle_vplates(
binds: Res<crate::bindings::BindingsState>,
mut mode: ResMut<VPlateMode>,
mut cvars: ResMut<crate::cvars::Cvars>,
) {
use crate::bindings::cmd;
let (was_enemies, was_friends) = (mode.enemies, mode.friends);
if binds.fired(cmd::NAMEPLATES) {
mode.enemies = !mode.enemies;
info!(
"nameplates: enemy {}",
if mode.enemies { "ON" } else { "OFF" }
);
}
if binds.fired(cmd::FRIEND_NAMEPLATES) {
mode.friends = !mode.friends;
info!(
"nameplates: friendly {}",
if mode.friends { "ON" } else { "OFF" }
);
}
if binds.fired(cmd::ALL_NAMEPLATES) {
let both = mode.enemies && mode.friends;
mode.enemies = !both;
mode.friends = !both;
info!("nameplates: all {}", if !both { "ON" } else { "OFF" });
}
let flag = |b: bool| if b { "1" } else { "0" };
if mode.enemies != was_enemies {
cvars.set(CVAR_ENEMIES, flag(mode.enemies));
}
if mode.friends != was_friends {
cvars.set(CVAR_FRIENDS, flag(mode.friends));
}
}
/// One `nameplate<N>` (or `nameplate<N>target`) snapshot.
///
/// A named function rather than two closures in the gate loop so a test can hold it, because the
/// one thing that must not drift is which builder it goes through: [`SnapshotCtx::full`], not the
/// bare `ui_unit::snapshot` underneath it. `full` adds the reaction, attack, raid-mark, faction
/// and creature-record legs, and three of those are what pfUI asks a plate token for — its aggro
/// scan is `UnitCanAttack` over every `nameplate<N>` (`api/unitframes.lua:186`). A later
/// "simplification" to the bare call would leave all of them at their defaults and turn the aggro
/// glow permanently off with nothing raising.
///
/// `None` for a unit whose descriptor has not streamed, which the plate gate tolerates in
/// principle (every gate arm above already rejects such a unit — health 0 reads as no plate — so
/// in practice this is always `Some`). `sync_nameplates` turns that into a token naming nothing,
/// never a blank unit.
///
/// Built every frame and undiffed: the allocation is the cost either way, since `ui_unit.rs`'s own
/// feed also builds before it diffs, and at ~8 small allocations per snapshot a crowded 40 plates
/// is on the order of 600 a frame — against the `PlateState` clone and name resolve the plate loop
/// already does per plate. An undiffed push cannot leave a token a frame behind its plate.
fn plate_unit_snapshot(
ctx: &crate::ui_unit::SnapshotCtx<'_>,
guid: u64,
store: Option<&ObjectStore>,
at: Vec3,
) -> Option<benilla_ui::script::UnitState> {
// SuperWoW's `UnitPosition`, which the plate feed has in hand and must pass on: the plate was
// just projected from this very translation.
Some(ctx.full(guid, store?, Some((at.x, at.y, at.z))))
}
/// The GUID `nameplate<N>target` should name, from the plate unit's own descriptor:
/// `UNIT_FIELD_TARGET`, with the zero that means "targeting nothing" rejected rather than looked
/// up. The same read and the same zero gate as `ui_unit.rs`'s `target_of`, which serves
/// `"targettarget"` and ClassicAPI's `"focustarget"` — so this is **not** the hop the earlier
/// compat row called unreachable: benilla-ui cannot make it, the app has made it since
/// `"targettarget"` existed.
///
/// The zero gate is restated here even though `unit_target` already applies one, because the
/// consequence is local and silent: GUID 0 handed to the object-manager lookup would, on a
/// collision, snapshot some unrelated object under this token. A unit whose descriptor has not
/// streamed targets nothing — `None`, never a guess. pfUI's aggro scanner tests
/// `UnitIsUnit("nameplate1target", unit)`, so a wrong GUID here would credit a mob with aggro on a
/// unit it is not attacking, and nothing in the scan could tell.
fn plate_target_guid(store: Option<&ObjectStore>) -> Option<u64> {
store.and_then(|s| s.0.unit_target()).filter(|g| *g != 0)
}
/// The world and reaction inputs of the plate gate, bundled under the 16-param ceiling.
#[derive(SystemParam)]
#[allow(clippy::type_complexity)] // one bundled system param
struct PlateWorld<'w, 's> {
units: Query<
'w,
's,
(
Entity,
&'static NetEntity,
&'static Guid,
&'static Transform,
Option<&'static ObjectStore>,
),
Without<SelfPlayer>,
>,
self_q: Query<'w, 's, (&'static Transform, Option<&'static ObjectStore>), With<SelfPlayer>>,
factions: Option<Res<'w, Factions>>,
reputations: Res<'w, Reputations>,
selection: Res<'w, Selection>,
// With `selection`, the highlight's pair: the watcher's globals `[0xb4e2c8]`/`[0xb4e2d8]`.
hovered: Res<'w, Hovered>,
camera: Query<'w, 's, (&'static Camera, &'static Transform), With<WorldCamera>>,
window: Query<'w, 's, &'static Window, With<bevy::window::PrimaryWindow>>,
// The pending ground-target cast, for the plate's hit-test veto (`0x7cba30`).
targeting: Res<'w, crate::spell::SpellTargeting>,
/// `CreatureType.dbc`'s flags, for the critter gate; absent after a load failure, when
/// nothing is filtered.
creature_types: Option<Res<'w, crate::target::scan::CreatureTypes>>,
// ── The `nameplate<N>` / `nameplate<N>target` token feed's own inputs ──
//
// Every one is read-only, which is why none of them moves the ambiguity ceiling: Bevy counts
// a conflicting *pair*, and two readers of the same resource do not conflict.
//
// The object manager's guid lookup (`ClntObjMgrObjectPtr 0x468460`), for the one hop
// `nameplate<N>target` makes off `UNIT_FIELD_TARGET`. `Option` as `factions` is: a UI-only
// harness has no net plugin, and a bare `Res` is a system-validation panic.
index: Option<Res<'w, crate::net::GuidIndex>>,
/// Any streamed object by entity, **the player included** — which [`PlateWorld::units`]
/// cannot answer, since it excludes `SelfPlayer`, and a mob targeting *you* is the case
/// `nameplate<N>target` exists for (pfUI's aggro scanner asks exactly that,
/// `api/unitframes.lua:186`).
held: Query<'w, 's, (&'static ObjectStore, Option<&'static Transform>), With<Guid>>,
/// `ChrClasses.dbc`'s relic-slot column; `None` when it failed to load, where no class has one.
classes: Option<Res<'w, crate::chr_classes::ChrClassTable>>,
}
/// Every frame: gate the units, seat each plate and hand the result to the widget layer as
/// [`PlateState`]s. Runs after the targeting chain, whose selection it reads.
#[allow(clippy::type_complexity)] // one Bevy system's full input set
/// How far above a unit's origin the occlusion ray aims. A unit's transform sits at its feet, so a
/// ray aimed there grazes the ground it stands on and reports every unit on a slope as hidden;
/// this lifts the target to roughly chest height on a humanoid, which is also about where the
/// plate itself floats.
pub(crate) const OCCLUSION_AIM_LIFT: f32 = 1.5;
/// The ray stops short of the unit by this much, so geometry the unit is standing against — the
/// floor under its feet, the wall at its back — cannot count as the thing hiding it.
const OCCLUSION_BACKOFF: f32 = 0.5;
/// Whether the camera has line of sight to a unit, for [`VPlateMode::occlusion`]. Shared with
/// `nameplates.rs`'s overhead names, which carry the same cull off the same CVar: a unit with a
/// plate has no overhead name and vice versa, so the two must agree about what is visible or a
/// unit would lose its plate behind a wall and gain a floating name in its place.
///
/// One ray against the body world (terrain and WMO walking colliders), which is the same geometry
/// the camera's own collision arm tests — so a plate disappears exactly when a wall is between the
/// eye and the unit. Units are not in that layer, so a unit standing in front of another cannot
/// hide it: this is geometry occlusion, as the mod's is, not depth sorting.
pub(crate) fn camera_can_see(
collide: &benilla_world::collision::WorldCollision<'_, '_>,
eye: Vec3,
unit_origin: Vec3,
) -> bool {
match occlusion_probe(eye, unit_origin) {
// No probe to run means nothing can be in the way; see [`occlusion_probe`].
None => true,
Some((dir, reach)) => collide.ray_body(eye, dir, reach).is_none(),
}
}
/// The ray [`camera_can_see`] casts, or `None` when there is no span worth testing and the unit
/// should be treated as visible.
///
/// Split out from the cast so the geometry — the aim lift, the backoff, and the two degenerate
/// cases — is testable without a world. `None` covers a camera sitting on top of the unit (first
/// person, or a unit underfoot): the whole span is inside the backoff, so there is no room for an
/// occluder, and probing anyway would cast a zero or negative-length ray.
fn occlusion_probe(eye: Vec3, unit_origin: Vec3) -> Option<(Dir3, f32)> {
let to_target = (unit_origin + Vec3::Y * OCCLUSION_AIM_LIFT) - eye;
let dir = Dir3::new(to_target).ok()?;
let reach = to_target.length() - OCCLUSION_BACKOFF;
(reach > 0.0).then_some((dir, reach))
}
#[allow(clippy::type_complexity)] // one Bevy system's full input set
fn drive_vplates(
mode: Res<VPlateMode>,
mut plates: ResMut<VPlates>,
mut plate_hover: ResMut<PlateHover>,
mut plate_clicks: ResMut<PlateClicks>,
rig: Res<crate::player::CameraControl>,
world: PlateWorld,
collide: benilla_world::collision::WorldCollision,
names: Res<NameCache>,
net_commands: Res<NetCommands>,
// `None` in a run with no UI VM.
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
ui_scale: Res<crate::ui_script::UiScaleCvar>,
anchor_q: (
Query<&BoneAttach>,
Query<&benilla_world::rig_anim::RigPose>,
Query<&OverheadFallback>,
Query<&GlobalTransform>,
Query<(), With<crate::entities::mount::MountChild>>,
),
// Claim bucket 0, the plates', rebuilt every frame; combat text owns bucket 1.
mut bucket: Local<crate::smart_rect::SmartBucket>,
group: Res<crate::ui_party::GroupState>,
mut mouse_told: Local<crate::ui_script::VmMemo<Option<bool>>>,
) {
plates.0.clear();
plate_hover.0 = None;
bucket.clear();
// Every early return retires the plates first, or the widgets stay on screen; the reference
// hides each live plate and returns it to the pool (`0x608a10`).
let retire_all = |script: Option<NonSendMut<benilla_ui::script::UiScript>>| {
if let Some(mut script) = script {
script.retire_nameplates();
}
};
if !mode.enemies && !mode.friends {
retire_all(script);
return;
}
let (Ok((cam, cam_pose)), Ok((self_tf, self_store))) =
(world.camera.single(), world.self_q.single())
else {
retire_all(script);
return;
};
let Some(mut script) = script else {
return;
};
let cam_tf = GlobalTransform::from(*cam_pose);
let Some(viewport) = cam.logical_viewport_size() else {
script.retire_nameplates();
return;
};
let basis = plate_basis(viewport);
let gx = |v: f32| gx_px(v, basis);
let window = world.window.single().ok();
// Plates stop taking the mouse in freelook (`0x60f830`, called from `0x483e80`/`0x483e70` on
// the transitions), written on the edge per VM.
let looking = rig.is_looking();
if *mouse_told.get(&script) != Some(looking) {
*mouse_told.get(&script) = Some(looking);
script.set_nameplate_mouse(!looking);
}
// While a ground-targeted spell is armed (`0x6e6320`'s `flag & 0x60`), plates refuse the hit
// test (`0x7cba30`, the `+0x3c` override) without clearing the mouse-enabled bit, so
// `IsMouseEnabled()` is unchanged.
script.set_nameplate_hit_test_veto(
world
.targeting
.wants(crate::spell::targeting::TargetingWants::Location),
);
let hovered_key = script.hovered_nameplate();
let clicked = script.take_nameplate_clicks();
let my_level = self_store.and_then(|s| s.0.unit_level()).unwrap_or(1);
let has_target = world.selection.target.is_some();
let (pw, ph) = (gx(PLATE_W), gx(PLATE_H));
let scale = window.map_or(1.0, |w| w.scale_factor());
// Everything below is in window px, divided by this once for the widget layer's units.
let seam = window.map_or(1.0, |w| {
crate::ui_script::seam_scale(w.height(), ui_scale.0)
});
let mut states: Vec<PlateFeed> = Vec::new();
// The shared builder for every token that names a streamed unit (`ui_unit.rs`'s
// [`SnapshotCtx::full`]), so `nameplate1` and `"target"` cannot disagree about one unit.
let snapshot_ctx = crate::ui_unit::SnapshotCtx {
names: &names,
commands: &net_commands,
factions: world.factions.as_deref(),
reputations: &world.reputations,
self_store,
classes: world.classes.as_deref().map(|t| &t.0),
group: &group,
};
// Plates seat in squared-distance order from the gx point (0.4, 0.3), Y mirrored from the
// reference's Y-up space (`0x608870`, `0x6089a0`, walked by `0x608ce0`); each later plate is
// solved off those already claimed. The undamped diagonal: this is position, not size.
let diag = viewport.length();
let sort_pt = Vec2::new(0.4 * diag, viewport.y - 0.3 * diag);
let mut cands = Vec::new();
for (entity, net, guid, tf, store) in &world.units {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
}
// `NOT_SELECTABLE` (flags bit 25) never gets a plate, re-tested every tick (`0x60f622`,
// from CGUnit's OnUpdate `0x607ed0`). A plate hover publishes the mouseover with no
// selectability check (`0x7cb869`, unlike the world hover at `0x482982`), so this gate is
// what keeps such a unit's tooltip away.
if store.is_some_and(|s| s.0.unit_flags() & (1 << 25) != 0) {
continue;
}
// The critter gate ([`VPlateMode::critters`]), keyed on `CreatureType.dbc`'s own no-TAB
// flag rather than on the id 8: that bit is the client's own mark for "not a unit the
// player means to interact with", it is set on Critter alone in the 1.12 data, and the
// TAB scan already gates on it (`target::scan`). An unresolved type keeps its plate, as
// the scan treats an out-of-range index as targetable.
if !mode.critters
&& benilla_protocol::guid::is_creature_or_pet(guid.0)
&& world
.creature_types
.as_deref()
.zip(benilla_protocol::guid::entry(guid.0).and_then(|e| names.creature_type(e)))
.is_some_and(|(types, ty)| types.no_tab_target(ty))
{
continue;
}
// `0x60f600`'s first test: health 0 or less (absent reads 0) has no plate. Feign death
// keeps health, so it passes here.
if store.is_none_or(|s| s.0.unit_health().unwrap_or(0) == 0) {
continue;
}
// `0x60f62e`: `bytes_1` byte 3 `& 3` (ghost or creep) has no plate.
if store.is_some_and(|s| s.0.unit_is_ghost_visual()) {
continue;
}
if (tf.translation - self_tf.translation).length_squared() > mode.range_sq {
continue;
}
// UnitXP_SP3's occlusion: a unit the camera cannot see gets no plate. The ray is cast from
// the CAMERA, not from the player — the plate is drawn where the camera projects it, so a
// wall between the camera and the unit is what actually hides the unit on screen, and in
// third person the two origins disagree often (standing behind a corner the camera has
// already swung past, and the reverse).
if mode.occlusion && !camera_can_see(&collide, cam_tf.translation(), tf.translation) {
continue;
}
// A CREATEDBY owner, no SUMMONEDBY owner and flags bit 9 (`0x200`): no plate in either
// category (`0x60f70b`-`0x60f72d`); the bit's 1.12 name is unknown.
if store.is_some_and(|s| {
s.0.unit_created_by().is_some_and(|g| g != 0)
&& s.0.unit_summoned_by().is_none_or(|g| g == 0)
&& s.0.unit_flags() & 0x200 != 0
}) {
continue;
}
// The category is `CanAttack` from the player (a rep faction answers by its at-war bit);
// the bar colour is the unit's reaction toward us (by standing). A not-at-war neutral NPC
// is a friendly plate with a yellow bar.
let rank = ring_reaction(
world.factions.as_deref(),
&world.reputations,
store,
self_store,
);
let is_player = net.kind == EntityKind::Player;
let friendly = crate::target::ring::plate_is_friendly(
world.factions.as_deref(),
&world.reputations,
store,
self_store,
is_player,
);
if friendly && !mode.friends || !friendly && !mode.enemies {
continue;
}
// Then the kind, under whichever master let it through: the later clients' per-kind
// switches (`VPlateMode::friendly_players` and its siblings). A unit with an owner is a
// minion here whatever kind of minion the newer clients would call it — the distinction
// they draw between a pet, a guardian and a totem needs fields 1.12's `SMSG_UPDATE_OBJECT`
// does not carry, and an addon writes all of those names together anyway.
let owned = store.is_some_and(|s| {
s.0.unit_summoned_by().is_some_and(|g| g != 0)
|| s.0.unit_created_by().is_some_and(|g| g != 0)
});
let kind_shown = match (friendly, is_player, owned) {
(true, true, _) => mode.friendly_players,
(true, false, true) => mode.friendly_minions,
(true, false, false) => mode.friendly_npcs,
(false, true, _) => mode.enemy_players,
(false, false, true) => mode.enemy_minions,
// A hostile NPC nobody owns is what `nameplateShowEnemies` itself names, and that
// master has already answered above.
(false, false, false) => true,
};
if !kind_shown {
continue;
}
// Only on the hostile side, a dead-looking unit has no plate (`0x60f733`): a feigning
// hunter's plate hides from enemies and stays for friends.
if !friendly && store.is_some_and(|s| s.0.unit_reads_dead()) {
continue;
}
// Behind the camera or outside the viewport there is no plate: `0x60f600` destroys it
// before the seat, never clamping it in from off-screen.
let anchor = overhead_anchor(
entity,
tf,
&anchor_q.0,
&anchor_q.1,
&anchor_q.2,
&anchor_q.3,
&anchor_q.4,
);
let Some(screen) =
crate::ui_pass::project_overlay(cam, &cam_tf, anchor + Vec3::Y * PLATE_LIFT, viewport)
else {
continue;
};
cands.push((
screen.distance_squared(sort_pt),
screen,
anchor,
rank,
is_player,
entity,
guid,
store,
tf,
));
}
cands.sort_by(|a, b| a.0.total_cmp(&b.0));
for (_, screen, anchor, rank, is_player, entity, guid, store, tf) in cands {
plates.0.insert(entity);
let alpha = if !has_target || world.selection.target == Some(entity) {
1.0
} else {
DIM_ALPHA
};
let tint = plate_tint(rank, is_player);
// `WOW_VPLATE_TRACE=1` prints each plate rect in logical px.
static TRACE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
let trace = *TRACE.get_or_init(|| std::env::var("WOW_VPLATE_TRACE").as_deref() == Ok("1"));
// The seat (`0x509ec0`): the plate hangs from its top centre on the projected point, is
// solved off the plates already claimed, then clamped half a plate inside the screen.
let desired = Rect::new(
screen.x - pw * 0.5,
screen.y,
screen.x + pw * 0.5,
screen.y + ph,
);
// SuperWoW's `NameplateMotion` 0 ("Overlap") skips the solver: the plate sits on its own
// projected point and plates draw through each other. 1 ("Default spread") is this
// solver, which is the reference's own (`VPlateMode::spread`).
let solved = if mode.spread {
bucket.resolve(desired, viewport)
} else {
desired
};
// Snap the left edge, not the centre, which an odd width would put between texels; the
// claim takes the snapped rect, so later plates dodge what is drawn.
let top = device_snap(solved.min.y, scale);
let left = device_snap(solved.min.x, scale);
let plate = Rect::new(left, top, left + pw, top + ph);
// Trace tag `vpl`, one line per plate per frame: anchor, camera, projected point, solve
// and snapped rect, so plate jitter can be attributed to one of them.
if benilla_assets::trace::enabled_for("vpl") {
let (cp, cf) = (cam_pose.translation, cam_pose.forward());
benilla_assets::trace::line(
"vpl",
&format!(
"e={} vp=({:.0},{:.0}) anchor=[{:.4},{:.4},{:.4}] cam=[{:.4},{:.4},{:.4}] \
fwd=[{:.4},{:.4},{:.4}] scr=({:.3},{:.3}) solved=({:.3},{:.3}) plate=({:.1},{:.1})",
entity.index(),
viewport.x,
viewport.y,
anchor.x,
anchor.y,
anchor.z,
cp.x,
cp.y,
cp.z,
cf.x,
cf.y,
cf.z,
screen.x,
screen.y,
solved.min.x,
solved.min.y,
plate.min.x,
plate.min.y,
),
);
}
// Nothing is claimed in Overlap mode either: a claim with no solve would still push the
// *next* plate off this one, which is half a spread and the worst of both.
if mode.spread {
bucket.claim(plate);
}
let hover = hovered_key == Some(guid.0);
// The mouseover or the target lights the bar; `hover` joins directly so the brighten does
// not lag the yellow name by the frame the mouseover takes to publish.
let lit =
hover || world.hovered.target == Some(entity) || world.selection.target == Some(entity);
if trace {
eprintln!(
"vplate-trace: viewport={viewport:?} screen=({:.2},{:.2}) plate=({:.1},{:.1})..({:.1},{:.1}) lit={lit}",
screen.x, screen.y, plate.min.x, plate.min.y, plate.max.x, plate.max.y
);
}
// ── The plate's state, handed to the widget layer ──
//
// The reference's plates sit outside the uiScale cascade, but benilla folds uiScale into
// one global seam, so dividing by it here keeps plate pixels uiScale-blind.
// The raid-target index is 1-based here, 0-based for the atlas cell.
let mark = group.raid_target_index(guid.0);
let x_units = (plate.min.x + plate.max.x) * 0.5 / seam;
let y_units = (viewport.y - plate.min.y) / seam;
if hovered_key == Some(guid.0) {
plate_hover.0 = Some(entity);
}
for click in &clicked {
if click.key == guid.0 {
match click.button.as_str() {
"RightButton" => plate_clicks.right.push(entity),
_ => plate_clicks.left.push(entity),
}
}
}
// ── The `nameplate<N>` pair's snapshots ──
//
// Built here rather than in a feed system of their own because the token has to name the
// unit *this* pass gated in: a second system re-deriving the plate set would be a second
// gate, and the first time the two disagreed one token would name a unit with no plate.
// Built every frame and undiffed — the allocation is the cost either way (`ui_unit.rs`'s
// own feed builds before it diffs), and at ~8 small allocations per snapshot a crowded 40
// plates is on the order of 600 allocations a frame, against the `PlateState` clone and
// name resolve this loop already does per plate.
//
let unit = plate_unit_snapshot(&snapshot_ctx, guid.0, store, tf.translation);
// `nameplate<N>target`: one hop off this unit's `UNIT_FIELD_TARGET` through the object
// manager, the identical read `"targettarget"` and `"focustarget"` already make. A unit
// targeting nothing, or targeting something not held here, names nothing.
let unit_target = plate_target_guid(store)
.and_then(|g| Some((*world.index.as_ref()?.0.get(&g)?, g)))
.and_then(|(e, g)| {
let (tstore, ttf) = world.held.get(e).ok()?;
let at = ttf.map_or(Vec3::ZERO, |t| t.translation);
plate_unit_snapshot(&snapshot_ctx, g, Some(tstore), at)
});
let state = PlateState {
// A plate stays bound to its unit for its life, as the reference's `[unit+0xe60]`;
// addons cache per plate on it.
key: guid.0,
top_centre: (x_units, y_units),
// Raw health, not a fraction: `healthbar:GetValue()` is `[bar+0x320]` (`0x78f5d0`),
// and addons divide it by the max.
health: store.and_then(|s| s.0.unit_health()).unwrap_or(0) as f32,
max_health: store
.and_then(|s| s.0.unit_max_health())
.unwrap_or(1)
.max(1) as f32,
bar_colour: [tint[0], tint[1], tint[2]],
name: names
.resolve_unit(guid.0, store, &net_commands)
.map(str::to_owned)
.unwrap_or_default(),
// The skull (`0x7cbb40`): rank 3 through `gated_rank` (so a charmed boss shows its
// level), or a hostile 10 or more levels up that is not grey (`0x5f0700`, vacuous
// there but transcribed). A unit with no level yet shows neither.
level: store.and_then(|s| s.0.unit_level()),
skull: store.and_then(|s| s.0.unit_level()).is_some_and(|level| {
crate::names::gated_rank(
store
.and_then(|s| s.0.object_entry())
.and_then(|e| names.creature_record(e)),
store,
) == 3
|| (rank <= 1 && level >= my_level + 10 && !unit_is_grey(my_level, level))
}),
level_colour: {
let c = store
.and_then(|s| s.0.unit_level())
.map_or([1.0, 1.0, 1.0, 1.0], |level| con_color(my_level, level));
[c[0], c[1], c[2]]
},
// 0-based for the atlas: `col = idx & 3`, `row = idx >> 2`.
raid_icon: (mark >= 1).then(|| mark - 1),
alpha,
lit,
hovered: hover,
};
states.push(PlateFeed {
state,
unit: unit.unwrap_or_default(),
unit_target,
});
}
// The widget layer rewrites the static anchors only when this moves, so an addon's own
// re-anchoring survives between resizes.
script.sync_nameplates(
PlateGeometry {
width: pw / seam,
height: ph / seam,
bar_off_x: gx(BAR_OFF_X) / seam,
bar_off_y: gx(BAR_OFF_Y) / seam,
bar_width: gx(BAR_W) / seam,
bar_height: gx(BAR_H) / seam,
level_off_x: gx(LEVEL_OFF_X) / seam,
level_off_y: gx(LEVEL_OFF_Y) / seam,
skull_size: gx(SKULL_SIZE) / seam,
raid_size: gx(RAID_ICON_SIZE) / seam,
name_height: text_px(NAME_H, basis) / seam,
level_height: text_px(LEVEL_H, basis) / seam,
// Deviation: a constant one-logical-pixel shadow, not the reference's 0.001 gx, which
// draws 2 px thick and too heavy at larger windows. Divided by the seam, which the
// shared text arm would otherwise scale it by.
shadow_offset: 1.0 / seam,
},
&states,
);
}
/// The plate driver's set; the overhead-name driver runs after it.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct VPlateSet;
/// `nameplateShowCritters`, this client's own: see [`VPlateMode::critters`].
pub(crate) const CVAR_CRITTERS: &str = "nameplateShowCritters";
/// The eight later-client names for [`VPlateMode::friendly_minions`]. A pet, a guardian, a totem
/// and a "minion" are one thing to this client — a unit with an owner — and an addon that means
/// "show them" writes the whole set, so every name here moves the one field and the last write
/// wins. The `FriendlyPlayer*` four are the newer spelling of the other four; Cinematic writes
/// both sets in one go (`Effects.lua`'s `PLATE_KIND_CVARS.FriendlyMinions`).
pub(crate) const PLATE_FRIENDLY_MINION_CVARS: [&str; 8] = [
"nameplateShowFriendlyMinions",
"nameplateShowFriendlyPets",
"nameplateShowFriendlyGuardians",
"nameplateShowFriendlyTotems",
"nameplateShowFriendlyPlayerMinions",
"nameplateShowFriendlyPlayerPets",
"nameplateShowFriendlyPlayerGuardians",
"nameplateShowFriendlyPlayerTotems",
];
/// The hostile half of [`PLATE_FRIENDLY_MINION_CVARS`]: four names, one field.
pub(crate) const PLATE_ENEMY_MINION_CVARS: [&str; 4] = [
"nameplateShowEnemyMinions",
"nameplateShowEnemyPets",
"nameplateShowEnemyGuardians",
"nameplateShowEnemyTotems",
];
/// V-key nameplates: the toggles and the per-frame gate and draw.
pub(crate) struct VPlatesPlugin;
/// Apply a change to either toggle CVar to the bitmask.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut mode: ResMut<VPlateMode>) {
if ev.is(CVAR_ENEMIES) {
mode.enemies = ev.flag();
} else if ev.is(CVAR_FRIENDS) {
mode.friends = ev.flag();
} else if ev.is("NameplateRange") {
mode.range_sq = ev.num().clamp(10.0, 80.0).powi(2);
} else if ev.is("NameplateOcclusion") {
mode.occlusion = ev.flag();
} else if ev.is(CVAR_CRITTERS) {
mode.critters = ev.flag();
} else if ev.is("NameplateMotion") {
// 0 is Overlap; 1, 2 and 3 all spread, since the mod's own two alternative algorithms are
// unknowable from here and the fallback is the reference's solver (see `VPlateMode::spread`).
mode.spread = ev.num() != 0.0;
} else if ev.is("nameplateShowFriendlyPlayers") {
mode.friendly_players = ev.flag();
} else if ev.is("nameplateShowFriendlyNPCs") {
mode.friendly_npcs = ev.flag();
} else if PLATE_FRIENDLY_MINION_CVARS.iter().any(|n| ev.is(n)) {
mode.friendly_minions = ev.flag();
} else if ev.is("nameplateShowEnemyPlayers") {
mode.enemy_players = ev.flag();
} else if PLATE_ENEMY_MINION_CVARS.iter().any(|n| ev.is(n)) {
mode.enemy_minions = ev.flag();
} else if ev.is("nameplateMaxDistance") {
// The later clients' name for the same reach, clamped as `NP_NameplateDistance` is so a
// write is not narrowed by which of the three names it arrived under.
mode.range_sq = ev.num().clamp(5.0, 200.0).powi(2);
} else if ev.is("NP_NameplateDistance") {
// nampower v2's own nameplate reach (`docs/ADDON_COMPAT.md`): the same knob SuperWoW's
// `NameplateRange` drives, last writer wins, clamped to that addon's 5..200 slider.
mode.range_sq = ev.num().clamp(5.0, 200.0).powi(2);
}
}
impl Plugin for VPlatesPlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
app.init_resource::<VPlateMode>()
.init_resource::<VPlates>()
.init_resource::<PlateHover>()
.init_resource::<PlateClicks>()
.add_systems(
Update,
(
toggle_vplates,
// After the targeting chain, itself after `WorldStage::Input`, so this projects
// through this frame's camera; `ui_script::extract::paint_script` runs after
// it, so the plates paint the same frame.
drive_vplates.after(TargetUpdate),
)
.chain()
.in_set(VPlateSet),
)
// Outside `VPlateSet`, which runs after the targeting chain: it only needs to precede
// the script tick, and a frame's lag is harmless since the globals are read only at
// world entry.
.add_systems(Update, feed_plate_globals.in_set(crate::ui_script::UiFeed));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A boot emits `CvarChanged` only for rows `config.toml` actually carries, so a resource
/// field whose CVar is normally absent from that file is **never told its default** — it keeps
/// whatever `Default` gave it. This pins the two together for `NameplateOcclusion`, which
/// shipped off for exactly this reason: the CVar read `"1"`, the derived `Default` gave
/// `false`, and writing `"1"` over `"1"` is a no-op that emits no event, so neither the
/// checkbox nor the console command could correct it.
#[test]
fn the_plate_range_default_agrees_with_its_registered_cvar() {
let registered: f32 = crate::cvars::registered_pairs()
.find(|(n, _)| n.eq_ignore_ascii_case("NameplateRange"))
.map(|(_, d)| d)
.expect("NameplateRange is registered")
.parse()
.expect("a distance");
assert_eq!(
VPlateMode::default().range_sq,
registered * registered,
"a derived default here is not `no cull` but `cull everything`: the gate is \
`distance_squared > range_sq`, so 0 hides every plate for every player whose \
config.toml has no NameplateRange row — which is every player who never moved the \
slider"
);
assert_eq!(
crate::nameplates::NameConfig::default().range_sq,
VPlateMode::default().range_sq,
"the plate and the overhead name share one cull: if they disagree a unit shows one \
label where it should show the other, which is how this was missed — the names were \
right and only the plates were gone"
);
}
#[test]
fn the_occlusion_default_agrees_with_its_registered_cvar() {
let registered = crate::cvars::registered_pairs()
.find(|(n, _)| n.eq_ignore_ascii_case("NameplateOcclusion"))
.map(|(_, d)| d)
.expect("NameplateOcclusion is registered");
let on = registered != "0";
assert_eq!(
VPlateMode::default().occlusion,
on,
"the V-key plates' default must equal the registered CVar's ({registered:?})"
);
assert_eq!(
crate::nameplates::NameConfig::default().occlusion,
on,
"and the overhead names' must too, or one would cull where the other did not"
);
}
/// The occlusion ray's geometry. The lift is the point: a unit's transform is at its feet, so
/// a ray aimed there skims the ground it stands on and reports a unit on any slope as hidden.
#[test]
fn the_occlusion_probe_aims_above_the_feet_and_stops_short_of_the_unit() {
// Camera 10 yards back at eye height, unit on flat ground straight ahead.
let eye = Vec3::new(0.0, 2.0, 0.0);
let unit = Vec3::new(0.0, 0.0, -10.0);
let (dir, reach) = occlusion_probe(eye, unit).expect("a 10 yard span is probed");
// Aimed at the lift, not at the feet: the ray must rise toward the unit's chest, so its
// Y component is above the straight-to-feet direction.
let to_feet = (unit - eye).normalize();
assert!(
dir.y > to_feet.y,
"the probe must aim higher than the feet: {} vs {}",
dir.y,
to_feet.y
);
let full = ((unit + Vec3::Y * OCCLUSION_AIM_LIFT) - eye).length();
assert!(
(reach - (full - OCCLUSION_BACKOFF)).abs() < 1.0e-4,
"the reach stops short by the backoff: {reach} vs {full}"
);
assert!(
reach < full,
"a ray reaching the unit itself would count the floor it stands on as an occluder"
);
}
/// Degenerate spans answer "visible" rather than casting a zero or negative-length ray: the
/// camera inside the unit (first person) and a unit directly underfoot.
#[test]
fn a_camera_on_top_of_the_unit_is_not_probed_at_all() {
let unit = Vec3::new(5.0, 0.0, 5.0);
// Exactly at the aim point: zero-length, no direction to derive.
assert!(occlusion_probe(unit + Vec3::Y * OCCLUSION_AIM_LIFT, unit).is_none());
// Inside the backoff: a span shorter than the backoff has no room for an occluder.
let near = unit + Vec3::Y * OCCLUSION_AIM_LIFT + Vec3::X * (OCCLUSION_BACKOFF * 0.5);
assert!(
occlusion_probe(near, unit).is_none(),
"a span inside the backoff must not cast a negative-length ray"
);
// Just outside it, a probe appears again.
let out = unit + Vec3::Y * OCCLUSION_AIM_LIFT + Vec3::X * (OCCLUSION_BACKOFF * 2.0);
assert!(occlusion_probe(out, unit).is_some());
}
/// `0x7cbd50` at level 30 (grayband[6] = 7: green down to 23, gray at 22) and the low edge.
#[test]
fn con_color_matches_the_byte_table() {
assert_eq!(con_color(30, 35), CON_RED);
assert_eq!(con_color(30, 33), CON_ORANGE);
assert_eq!(con_color(30, 28), CON_YELLOW);
assert_eq!(
con_color(30, 23),
CON_GREEN,
"gap 7 = grayband[6], still green"
);
assert_eq!(con_color(30, 22), CON_GRAY, "gap 8 crosses the band");
assert_eq!(con_color(1, 1), CON_YELLOW);
assert_ne!(
con_color(5, 1),
CON_GRAY,
"grayband[1] = 4 ≥ the max gap at 5"
);
// 0xFFFF1919, 0xFFFF7F3F, 0xFF3FB23F.
assert_eq!(CON_RED[1], 25.0 / 255.0);
assert_eq!(CON_ORANGE[2], 63.0 / 255.0);
assert_eq!(CON_GREEN[1], 178.0 / 255.0);
}
#[test]
fn the_plate_snaps_on_the_device_grid_not_the_logical_one() {
// At 2x every snapped value is a whole number of physical pixels.
for (v, want) in [(10.0, 10.0), (10.2, 10.0), (10.3, 10.5), (10.6, 10.5)] {
let got = device_snap(v, 2.0);
assert_eq!(got, want, "{v} at 2×");
assert_eq!((got * 2.0).fract(), 0.0, "{got} is a whole physical pixel");
}
assert_eq!(device_snap(10.4, 1.0), 10.0);
assert_eq!(device_snap(10.6, 1.0), 11.0);
assert_eq!((device_snap(10.4, 1.5) * 1.5).fract(), 0.0);
assert_eq!((device_snap(10.9, 1.5) * 1.5).fract(), 0.0);
}
/// Name em 13 at 1152x648 as measured in the reference; at 2560x1440 the reference's 29 and
/// the native 13 damp to 21.
#[test]
fn plate_text_sizes_take_the_damped_diagonal_basis() {
let ref43 = plate_basis(Vec2::new(1024.0, 768.0));
let refwin = plate_basis(Vec2::new(1152.0, 648.0));
assert_eq!(text_px(NAME_H, ref43), 13.0);
assert_eq!(text_px(LEVEL_H, ref43), 11.0);
assert_eq!(text_px(NAME_H, refwin), 13.0);
assert_eq!(text_px(LEVEL_H, refwin), 11.0);
let big = plate_basis(Vec2::new(2560.0, 1440.0));
assert_eq!(text_px(NAME_H, big), 21.0, "midway between 29 and 13");
assert_eq!(text_px(NAME_H, 10_000.0), 32.0, "atlas-cell cap, raw law");
}
/// Without the CVar write, a key-toggled plate would revert at every launch.
#[test]
fn the_v_key_mirrors_into_the_cvar_table() {
use crate::bindings::{cmd, BindingsState};
use crate::cvars::Cvars;
let mut app = App::new();
app.add_systems(Update, toggle_vplates)
.init_resource::<VPlateMode>()
.init_resource::<Cvars>()
.insert_resource(BindingsState::test_fired(&[cmd::NAMEPLATES]));
app.update();
assert!(app.world().resource::<VPlateMode>().enemies, "V turns on");
let moved = |app: &mut App| {
app.world_mut()
.resource_mut::<Cvars>()
.take_events()
.into_iter()
.map(|e| (e.name, e.new))
.collect::<Vec<_>>()
};
assert_eq!(
moved(&mut app),
vec![(CVAR_ENEMIES.to_string(), "1".to_string())],
"the write is an accepted move, so the config dirties and the mirror learns it"
);
assert_eq!(
app.world().resource::<Cvars>().get(CVAR_FRIENDS),
Some("0"),
"untouched"
);
app.world_mut()
.insert_resource(BindingsState::test_fired(&[cmd::NAMEPLATES]));
app.update();
assert!(!app.world().resource::<VPlateMode>().enemies, "V turns off");
assert_eq!(
moved(&mut app),
vec![(CVAR_ENEMIES.to_string(), "0".to_string())]
);
// Shift-V moves only the other bit.
app.world_mut()
.insert_resource(BindingsState::test_fired(&[cmd::FRIEND_NAMEPLATES]));
app.update();
assert!(app.world().resource::<VPlateMode>().friends);
assert_eq!(
moved(&mut app),
vec![(CVAR_FRIENDS.to_string(), "1".to_string())]
);
app.world_mut().insert_resource(BindingsState::default());
app.update();
assert!(moved(&mut app).is_empty());
}
#[test]
fn the_plate_globals_follow_the_mode_and_survive_a_rebuilt_vm() {
let read = |app: &mut App| {
let s = app
.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>();
(
s.lua().globals().get::<Option<i64>>("NAMEPLATES_ON").ok(),
s.lua()
.globals()
.get::<Option<i64>>("FRIENDNAMEPLATES_ON")
.ok(),
)
};
let mut app = App::new();
app.add_systems(Update, feed_plate_globals)
.init_resource::<VPlateMode>()
.insert_non_send_resource(benilla_ui::script::UiScript::new().unwrap());
app.update();
assert_eq!(read(&mut app), (Some(None), Some(None)), "both off ⇒ nil");
app.world_mut().resource_mut::<VPlateMode>().enemies = true;
app.update();
assert_eq!(
read(&mut app),
(Some(Some(1)), Some(None)),
"the reference's own truthiness: the NUMBER 1, never a truthy `0`"
);
// `ReloadUI()`: a fresh VM with the mode unchanged is told again.
app.insert_non_send_resource(benilla_ui::script::UiScript::new().unwrap());
assert_eq!(
read(&mut app),
(Some(None), Some(None)),
"a fresh VM knows nothing"
);
app.update();
assert_eq!(
read(&mut app),
(Some(Some(1)), Some(None)),
"…and is handed the mode again"
);
}
#[test]
fn plate_tint_matches_the_byte_order() {
assert_eq!(plate_tint(1, false), PLATE_HOSTILE);
assert_eq!(plate_tint(1, true), PLATE_HOSTILE, "hostile beats player");
assert_eq!(plate_tint(2, false), PLATE_NEUTRAL, "unfriendly is yellow");
assert_eq!(plate_tint(3, false), PLATE_NEUTRAL);
assert_eq!(plate_tint(3, true), PLATE_PLAYER, "player beats neutral");
assert_eq!(plate_tint(4, false), PLATE_FRIENDLY);
assert_eq!(plate_tint(6, true), PLATE_PLAYER, "player beats friendly");
}
/// At 1024x768 (diagonal 1280) the frame is the border's native 128x32 px; at 1080p the
/// reference's 220 and the native 128 damp to 174.
#[test]
fn plate_geometry_damps_past_the_native_knee() {
let ref43 = plate_basis(Vec2::new(1024.0, 768.0));
assert_eq!(gx_px(PLATE_W, ref43), 128.0);
assert_eq!(gx_px(PLATE_H, ref43), 32.0);
assert_eq!(gx_px(BAR_W, ref43), 103.0);
assert_eq!(gx_px(BAR_H, ref43), 9.0);
assert_eq!(
gx_px(PLATE_W, plate_basis(Vec2::new(1920.0, 1080.0))),
174.0,
"midway between 220 and 128"
);
assert_eq!(gx_px(PLATE_W, plate_basis(Vec2::new(800.0, 600.0))), 100.0);
}
/// `UNIT_FIELD_TARGET` is a guid, so a low/high pair at `fields/mod.rs`'s index 16 — the same
/// two constants `ui_unit.rs`'s own `targettarget`/`focustarget` tests write.
const FIELD_UNIT_TARGET_LOW: u16 = 16;
const FIELD_UNIT_TARGET_HIGH: u16 = 17;
/// `nameplate<N>target` is reachable, unlike what the earlier compat row claimed, because the
/// read is a plain descriptor field — the hop that benilla-ui cannot make, the app makes for
/// `"targettarget"` already. Three answers, and the middle one is the one that matters: a mob
/// targeting *nothing* must give `None`, not GUID 0, because 0 would then be looked up and a
/// zero-GUID snapshot is exactly the wrong-not-absent failure this token must never produce.
#[test]
fn a_plate_units_target_token_reads_unit_field_target_and_refuses_the_zero() {
use benilla_protocol::messages::ObjectFields;
const MOB_TARGET: u64 = 0x0000_0000_0000_0042;
let with = |pairs: &[(u16, u32)]| ObjectStore(ObjectFields::from_pairs(pairs));
assert_eq!(
plate_target_guid(Some(&with(&[
(FIELD_UNIT_TARGET_LOW, MOB_TARGET as u32),
(FIELD_UNIT_TARGET_HIGH, (MOB_TARGET >> 32) as u32),
]))),
Some(MOB_TARGET),
"the descriptor's own target guid, both dwords"
);
assert_eq!(
plate_target_guid(Some(&with(&[
(FIELD_UNIT_TARGET_LOW, 0),
(FIELD_UNIT_TARGET_HIGH, 0),
]))),
None,
"an explicit zero is `targeting nothing`, not object 0"
);
assert_eq!(
plate_target_guid(Some(&with(&[]))),
None,
"an un-streamed descriptor targets nothing"
);
assert_eq!(
plate_target_guid(None),
None,
"and a unit with no descriptor at all targets nothing"
);
}
/// [`plate_unit_snapshot`] goes through [`crate::ui_unit::SnapshotCtx::full`], not the bare
/// `snapshot()` beneath it, and that difference is load-bearing: `UnitCanAttack`,
/// `UnitReaction` and `GetRaidTargetIndex` are three of the four things pfUI asks a plate
/// token for (`api/unitframes.lua:186`'s aggro scan is `UnitCanAttack` over every
/// `nameplate<N>`), and bare `snapshot()` leaves all three at their defaults. A later
/// "simplification" to the bare call would break the aggro glow silently — every scan would
/// just score zero.
#[test]
fn a_plate_tokens_snapshot_carries_the_reaction_and_attack_legs_bare_snapshot_omits() {
use crate::names::NameCache;
use crate::ui_unit::{snapshot, SnapshotCtx};
use benilla_protocol::messages::ObjectFields;
/// `UNIT_FIELD_FACTIONTEMPLATE` (`fields/mod.rs`).
const FIELD_FACTION: u16 = 35;
/// Orgrimmar Grunt's template (vmangos `creature_template`), hostile to Alliance; the
/// player store below carries Stormwind's. Any two mutually hostile templates do.
const HORDE: u32 = 29;
const ALLIANCE: u32 = 11;
const GUID: u64 = 0xF130_0000_0001_0001;
let names = NameCache::default();
let commands = NetCommands(crossbeam_channel::unbounded().0);
let reputations = Reputations::default();
let group = crate::ui_party::GroupState::default();
let mob = ObjectStore(ObjectFields::from_pairs(&[(FIELD_FACTION, HORDE)]));
let me = ObjectStore(ObjectFields::from_pairs(&[(FIELD_FACTION, ALLIANCE)]));
let ctx = SnapshotCtx {
names: &names,
commands: &commands,
// `None`: without `Faction.dbc` the reaction and attack legs fall back to their own
// neutral answers, which is all this test needs — it is about which legs RUN.
factions: None,
reputations: &reputations,
self_store: Some(&me),
classes: None,
group: &group,
};
// The call-site body itself, not `SnapshotCtx::full` directly: the drift this guards is a
// change *here*, in the plate loop, to the cheaper builder.
let full = plate_unit_snapshot(&ctx, GUID, Some(&mob), Vec3::new(1.0, 2.0, 3.0))
.expect("a streamed descriptor snapshots");
let bare = snapshot(&mob, None, 0, None, None);
assert!(
plate_unit_snapshot(&ctx, GUID, None, Vec3::ZERO).is_none(),
"a unit with no descriptor gets no snapshot at all, so its token names nothing"
);
assert_eq!(
full.guid, GUID,
"the full build stamps the guid; bare does not"
);
assert_eq!(bare.guid, 0);
assert!(
full.is_connected,
"`UnitIsConnected` for a streamed unit; bare leaves it false"
);
assert_ne!(
full.reaction, 0,
"`UnitReaction` is 1..=7 for a resolved pair and 0 only where nothing resolved"
);
assert_eq!(bare.reaction, 0, "bare takes the reaction as an argument");
assert_eq!(
full.position,
Some((1.0, 2.0, 3.0)),
"SuperWoW's `UnitPosition`, which the plate feed has and must pass"
);
// And the creature-record leg ran: a guid with no cached record resolves to rank 0 rather
// than being skipped, which is what tells us `enrich_unit` was reached at all.
assert_eq!(full.rank, 0);
assert!(!full.is_player, "a creature guid, not a player one");
}
}