//! 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); /// 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, pub(crate) right: Vec, } /// 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>, mode: Res, mut told: Local>>, ) { 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, mut mode: ResMut, mut cvars: ResMut, ) { 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` (or `nameplatetarget`) 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` (`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 { // 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 `nameplatetarget` 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 { 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, >, self_q: Query<'w, 's, (&'static Transform, Option<&'static ObjectStore>), With>, factions: Option>, 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>, window: Query<'w, 's, &'static Window, With>, // 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>, // ── The `nameplate` / `nameplatetarget` 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 // `nameplatetarget` 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>, /// 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 /// `nameplatetarget` exists for (pfUI's aggro scanner asks exactly that, /// `api/unitframes.lua:186`). held: Query<'w, 's, (&'static ObjectStore, Option<&'static Transform>), With>, /// `ChrClasses.dbc`'s relic-slot column; `None` when it failed to load, where no class has one. classes: Option>, } /// 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, mut plates: ResMut, mut plate_hover: ResMut, mut plate_clicks: ResMut, rig: Res, world: PlateWorld, collide: benilla_world::collision::WorldCollision, names: Res, net_commands: Res, // `None` in a run with no UI VM. script: Option>, ui_scale: Res, anchor_q: ( Query<&BoneAttach>, Query<&benilla_world::rig_anim::RigPose>, Query<&OverheadFallback>, Query<&GlobalTransform>, Query<(), With>, ), // Claim bucket 0, the plates', rebuilt every frame; combat text owns bucket 1. mut bucket: Local, group: Res, mut mouse_told: Local>>, ) { 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>| { 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 = 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 = 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` 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); // `nameplatetarget`: 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, mut mode: ResMut) { 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::() .init_resource::() .init_resource::() .init_resource::() .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::() .init_resource::() .insert_resource(BindingsState::test_fired(&[cmd::NAMEPLATES])); app.update(); assert!(app.world().resource::().enemies, "V turns on"); let moved = |app: &mut App| { app.world_mut() .resource_mut::() .take_events() .into_iter() .map(|e| (e.name, e.new)) .collect::>() }; 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::().get(CVAR_FRIENDS), Some("0"), "untouched" ); app.world_mut() .insert_resource(BindingsState::test_fired(&[cmd::NAMEPLATES])); app.update(); assert!(!app.world().resource::().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::().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::(); ( s.lua().globals().get::>("NAMEPLATES_ON").ok(), s.lua() .globals() .get::>("FRIENDNAMEPLATES_ON") .ok(), ) }; let mut app = App::new(); app.add_systems(Update, feed_plate_globals) .init_resource::() .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::().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; /// `nameplatetarget` 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`), 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"); } }