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
1424 lines
66 KiB
Rust
1424 lines
66 KiB
Rust
//! 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");
|
||
}
|
||
}
|