Files
moonwell-client-core/crates/benilla-ui/src/script/nameplate.rs
T
Sam Sung bf1ea25f58 Nameplates move at their units' callbacks, in their watchers' link order (#2847)
Nameplates were driven from the frame's end, so a listener between two packets saw a plate as it stood before them. 1.12.1 moves each plate at its unit's field callbacks, inside the packet.

**Changed**
- A plate's bar, level and colour change at their unit's callbacks. The bar reads health signed (`0x7cc41b`) and the level drunkenness lowers (`0x7cbce0`).
- A unit's watchers run in link order (`0x467f00`): its event bridge, its plate bars, the tooltip's bar. The walk reads the live list (`0x4655df`).
- The token flags follow their writers in place: selection, party, raid, create, pet, return. They survive a reload, as they do on the unit (`+0xc90`).
- A tooltip links a watcher only on a unit in view (`0x467e86`).

**Tests:** each read between two packets, the watch order across a reload, the party recolour. Each fails on main.

Part of #2217.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:05:43 +07:00

1449 lines
60 KiB
Rust

//! The nameplate widgets: the reference's `CGNamePlateFrame` (ctor `0x7cb250`), an anonymous
//! `Button` parented to the `WorldFrame` (`0x7786a3`) at strata WORLD, level 1. Plate addons
//! (`_Nameplates`, `CustomNameplates`, pfUI, ShaguTweaks) find it in `WorldFrame:GetChildren()`
//! and read it positionally: six regions in creation order, then one child, the health bar (1.12
//! has no cast bar). Addons take plates over, so static properties are written once, anchors
//! when the window changes and state when it moves.
//!
//! Also the four verbs that switch plates on and off, which write two bits of the engine's
//! runtime dword `[0xc4da34]` and nothing else.
use std::collections::{HashSet, VecDeque};
use mlua::Lua;
use crate::layout::{Anchor, Anchors, Len, Point};
use crate::order::DrawLayer;
use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle, RegionKind};
use super::model::Model;
use super::UiScript;
use super::{BlendMode, FontShadow, JustifyV, RegionData, TexCoords};
/// Region 1's art, and the path plate addons identify a plate by.
pub const BORDER_TEXTURE: &str = "Interface\\Tooltips\\Nameplate-Border";
/// Region 2's art; pfUI reads mouseover from `glow:IsShown()` (`nameplates.lua:601`).
pub const GLOW_TEXTURE: &str = "Interface\\Tooltips\\Nameplate-Glow";
/// Region 5's art, the boss and out-of-range skull.
pub const SKULL_TEXTURE: &str = "Interface\\TargetingFrame\\UI-TargetingFrame-Skull";
/// Region 6's art, a 4-column atlas.
pub const RAID_ICON_TEXTURE: &str = "Interface\\TargetingFrame\\UI-RaidTargetingIcons";
/// The health bar's fill, which `GetStatusBarTexture()` returns.
pub const BAR_FILL_TEXTURE: &str = "Interface\\TargetingFrame\\UI-TargetingFrame-BarFill";
/// The plate's level; the bar's is one less, set absolutely (`0x7cb33e` → `0x76a4f0`). The draw
/// walks levels outermost and each level's layers inside (`0x765920`), so the whole bar draws
/// before the border that caps it.
const PLATE_LEVEL: u16 = 1;
const BAR_LEVEL: u16 = 0;
/// The plate geometry that follows from the window rather than the unit, in FrameXML units with
/// the seam scale divided out. Written only when it changes, so an addon's bar anchor survives.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PlateGeometry {
pub width: f32,
pub height: f32,
/// The health bar, from the plate's BOTTOMLEFT.
pub bar_off_x: f32,
pub bar_off_y: f32,
pub bar_width: f32,
pub bar_height: f32,
/// The level text and skull seat, from the plate's BOTTOMRIGHT (x is subtracted, y added).
pub level_off_x: f32,
pub level_off_y: f32,
pub skull_size: f32,
pub raid_size: f32,
pub name_height: f32,
pub level_height: f32,
/// The text's drop-shadow step, seam divided out like the rest: one logical pixel at any
/// window size, since the text path multiplies it by the seam.
pub shadow_offset: f32,
}
/// One plate's state as the frame's gate reads it (`0x60f600` from `0x607ef9`): the bind's for a
/// unit it gives a plate (`0x7cb6d0`), and the frame's own fields for every plate it keeps: the
/// seat (`0x608ce0`), the alpha (`0x7cb9f9`), the lit bar and the hover.
#[derive(Clone, Debug, PartialEq)]
pub struct PlateState {
/// The unit's GUID and the plate's identity: a plate is bound to its unit for its life
/// (`[unit+0xe60]`), and addons cache per-plate state on the frame.
pub key: u64,
/// Where the plate's top centre lands, in FrameXML units from the screen's bottom-left; the
/// plate hangs below it (`0x509ec0`).
pub top_centre: (f32, f32),
/// `UNIT_FIELD_HEALTH` and `UNIT_FIELD_MAXHEALTH`, raw: `GetValue()` is the health, not a
/// fraction (`0x78f5d0`), and `GetMinMaxValues()` is `(0, max)`; plate addons divide.
pub health: f32,
pub max_health: f32,
/// The bar's reaction colour, exact: addons read the unit's reaction from `GetStatusBarColor`.
pub bar_colour: [f32; 3],
pub name: String,
/// The level to show, or `None` before `UNIT_FIELD_LEVEL` arrives: an empty seat, not a skull.
pub level: Option<u32>,
/// Show the skull in the level's seat instead of a number.
pub skull: bool,
pub level_colour: [f32; 3],
/// The raid-target mark, 0-based, or `None`.
pub raid_icon: Option<u8>,
/// The plate's alpha byte: 255 for the target, dimmed otherwise. Addons find the target
/// plate by `GetAlpha() == 1`.
pub alpha: u8,
/// Mouseover or target: the bar brightens.
pub lit: bool,
/// The pointer is on the plate: the name goes yellow (`0x7cb850`) and the glow region shows.
pub hovered: bool,
}
impl PlateState {
/// What the bind reads of the unit.
fn unit(&self) -> PlateUnit {
PlateUnit {
health: self.health,
max_health: self.max_health,
bar_colour: self.bar_colour,
name: Some(self.name.clone()),
level: self.level,
skull: self.skull,
level_colour: self.level_colour,
raid_icon: self.raid_icon,
}
}
}
/// What a plate's bind reads of its unit (`0x7cb6d0`), and what the unit's callbacks write again
/// inside their packets, each its own part: the bar's colour (`0x7cbaa0`), the level and skull
/// (`0x7cbb40`) and the raid icon (`0x7cbe20`). The name is the bind's alone (`0x7cb70c`).
#[derive(Clone, Debug, PartialEq)]
pub struct PlateUnit {
/// `UNIT_FIELD_HEALTH` and `UNIT_FIELD_MAXHEALTH`, as [`PlateState::health`].
pub health: f32,
pub max_health: f32,
pub bar_colour: [f32; 3],
/// `GetUnitName`'s answer (`0x609210`); `None` for its miss, the `UNKNOWNOBJECT` text the VM
/// holds (`0x609324`).
pub name: Option<String>,
pub level: Option<u32>,
pub skull: bool,
pub level_colour: [f32; 3],
pub raid_icon: Option<u8>,
}
/// The unit `guid` as a plate's callback reads it as it runs, asked of the lent host; no answer
/// for no unit held under the guid (`0x468460`, mask 8). `party`, when set, is the four party
/// slots as they stood where the callback ran, for one that runs inside a handler that is still
/// writing them (`ClearParty`, `AddPartyMember`), where the host's stand as the handler left them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PlateUnitQuery {
pub guid: u64,
pub party: Option<[u64; 4]>,
}
impl super::live::LiveQuery for PlateUnitQuery {
type Answer = PlateUnit;
}
/// The gate `0x60f600` on `guid` as it runs inside a packet, from `InvalidateNameDesc`
/// (`0x60706a`): the unit as the bind reads it when the gate gives it a plate (`0x60f7d9`), else
/// `None`, which releases one (`0x60f742`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PlateGateQuery(pub u64);
impl super::live::LiveQuery for PlateGateQuery {
type Answer = Option<PlateUnit>;
}
/// A bound plate: its unit and the frame's fields as the last sync wrote them, each `None` until
/// the first sync after a bind inside a packet writes it.
#[derive(Clone, Debug)]
struct Shown {
/// [`PlateState::key`], `[unit+0xe60]`'s back link.
key: u64,
/// The point the frame's seat anchored the plate at (`0x608ce0` → `0x509ec0`): a bind inside
/// a packet stores its point (`0x60872f`) and leaves the anchor to the frame's seat.
seat: Option<(f32, f32)>,
alpha: Option<u8>,
lit: Option<bool>,
hovered: Option<bool>,
}
/// One pooled plate's widgets, in the reference's creation order.
#[derive(Clone, Debug)]
struct Plate {
frame: FrameHandle,
border: RegionHandle,
glow: RegionHandle,
name: RegionHandle,
level: RegionHandle,
skull: RegionHandle,
raid: RegionHandle,
bar: FrameHandle,
/// `None` when the fill's file failed to load (`0x7831b4`).
fill: Option<RegionHandle>,
/// The name's flash timer `+0x514`, in milliseconds: [`FLASH_MS`] when a hit arms it
/// (`0x7cba77`), 0 at every bind (`0x7cb80e`), counted down by the plate's update; a negative
/// timer is idle, and while it is above 0 the hover leaves the name red.
flash_ms: i32,
/// The bound unit and what the frame last wrote, so [`Plate::frame`] writes only what moved;
/// `None` while retired.
shown: Option<Shown>,
/// `[plate+0x4e8]`, the unit last bound ([`PlateState::key`]): 0 from the constructor
/// (`0x7cb2a6`), written by each bind (`0x7cb6e0`) and left by the retire (`0x608a10`), so a
/// hidden pooled plate's click still names its last unit (`0x7cb91c`).
guid: u64,
/// `[bar+0x338]`, the unit the bar's two watchers watch (`0x7cc510`): set by each bind
/// (`0x7cc405`) and kept by the retire, which unwatches nothing, so a retired plate's bar
/// follows its last unit until a bind moves it (`0x7cc3e2` → `0x7cc5d0`) or the unit's
/// object, which holds the watch list, goes; 0 for none.
bar_unit: u64,
/// The link of the bar's watchers on its unit's list ([`super::unit_watch`]), which orders
/// them among the unit's other watchers (`0x467f65`-`0x467f76`).
bar_watched_at: u64,
/// The geometry its anchors were written under, `None` before the first lay-out. Kept per
/// plate, so a plate the pool grows later is laid out too.
laid_out: Option<PlateGeometry>,
}
impl Plate {
fn key(&self) -> Option<u64> {
self.shown.as_ref().map(|s| s.key)
}
}
/// The plate pool, grown on demand and never shrunk or reordered: addons scan only the children
/// past the count they last saw, so a compacted list would hide every later plate from them.
#[derive(Clone, Debug, Default)]
pub(crate) struct NamePlates {
plates: Vec<Plate>,
/// Each live unit's pool slot, by [`PlateState::key`]: the reference's `[unit+0xe60]`.
assigned: std::collections::HashMap<u64, usize>,
/// Frame to pool slot, never removed: plates are never destroyed.
by_frame: std::collections::HashMap<FrameHandle, usize>,
/// World-render shows after the WORLD batch build; these plates join the next UI tick.
pending_paint: std::collections::HashSet<FrameHandle>,
/// Plates this sync hid while they were in the built batch: re-shown in the same pass, they
/// still draw from the records the batch copied (`0x772fd0`).
retired_in_batch: std::collections::HashSet<FrameHandle>,
/// The hit-test veto (`0x7cba30`). Not the mouse-enabled bit: it leaves `IsMouseEnabled()`
/// true, where freelook (`0x60f830`) clears the bit.
hit_test_vetoed: bool,
geometry: Option<PlateGeometry>,
/// The fills of the lit plates, which draw boosted ([`lit_fill_boost`]).
lit_fills: std::collections::HashSet<RegionHandle>,
/// Bit `0x4` of `[0xc4da34]` (`0x605550`): the next sync refreshes the level and skull of each
/// plate it keeps (`0x60875f` → `0x7cbb40`), then the latch clears (`0x48362e`).
levels_due: bool,
}
impl NamePlates {
/// The next UI update precedes the next batch build (`0x764340`, `0x764347`).
pub(super) fn begin_update(&mut self) {
self.pending_paint.clear();
}
/// Whether `frame` is a plate, whose leave publishes the empty mouseover (`0x7cb8b0`).
pub(crate) fn is_plate(&self, frame: FrameHandle) -> bool {
self.by_frame.contains_key(&frame)
}
/// The plate frames, for the update walk to visit: each runs [`update`] ahead of its
/// `OnUpdate` (`0x7cb950` → `0x76b2c0`).
pub(super) fn frames(&self) -> impl Iterator<Item = FrameHandle> + '_ {
self.by_frame.keys().copied()
}
/// Whether the `0x7cba30` veto refuses `frame`: only a plate, only while the veto stands.
pub(crate) fn vetoes(&self, frame: FrameHandle) -> bool {
self.hit_test_vetoed && self.by_frame.contains_key(&frame)
}
}
/// The flash a hit starts, in milliseconds (`0x7cba77`, `0x1388`).
const FLASH_MS: i32 = 5000;
/// The name colour while it flashes (`0x7cba88`, `0xffff0000`), and at rest: yellow while the glow
/// shows, white otherwise (`0x7cb89e`, `0x7cb991`, `0x7cb8ed`).
const FLASH_RED: crate::color::Rgba8 = crate::color::Rgba8::rgba(255, 0, 0, 255);
const NAME_YELLOW: crate::color::Rgba8 = crate::color::Rgba8::rgba(255, 255, 0, 255);
/// The plate's `OnUpdate` override (`0x7cb950`), the flash half: a timer at or above 0 loses
/// `ftol(elapsed * 1000)` (`0x7cb962`-`0x7cb976`), and once it is 0 or less the name takes its
/// resting colour, by whether the glow shows (`0x7cb97e`-`0x7cb9ac`). A negative timer does
/// nothing, so the bind's 0 resets the name on the first update and then idles. It runs ahead of
/// the plate's own `OnUpdate` script, which the override tail-calls (`0x76b2c0`).
pub(super) fn update(model: &mut Model, frame: FrameHandle, elapsed: f32) {
let Some(&slot) = model.nameplates.by_frame.get(&frame) else {
return;
};
let plate = &mut model.nameplates.plates[slot];
if plate.flash_ms < 0 {
return;
}
plate.flash_ms -= super::binding_abi::ftol(f64::from(elapsed) * 1000.0);
if plate.flash_ms > 0 {
return;
}
let colour = if plate
.shown
.as_ref()
.is_some_and(|s| s.hovered == Some(true))
{
NAME_YELLOW
} else {
crate::color::Rgba8::WHITE
};
let name = plate.name;
model
.region_data
.entry(name)
.or_default()
.set_vertex_color(Some(colour));
}
/// A plate's click as its slot (`CGNamePlateFrame::OnClick 0x7cb910`) hands it to
/// `ClickObjectByAction` (`0x7cb92a` → `0x4949f0`), which the host applies inside the click
/// ([`super::ScriptCall::PlateClick`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PlateClick {
/// The plate's unit, its guid at `[plate+0x4e8]` (`0x7cb91c`, [`PlateState::key`]).
pub guid: u64,
/// What the button's bit asks of `0x4949f0`.
pub action: PlateAction,
/// A Lua `Click()`, which passes the slot 1 (`0x7827b2`) where the mouse passes 0
/// (`0x779280`, `0x7793a4`): no press of the mouse latched the unit, so the right button's
/// interact classes it as it stands.
pub scripted: bool,
/// The gate's tag 2 (`0x6e5ba5`), asked where the click ran, for the select's bind to a
/// standing targeting cursor and for the casts the interact tries.
pub may_cast: bool,
}
/// The two button bits `0x4949f0` acts on (`0x494a15`, `0x494a2c`); any other does nothing
/// (`0x494a3e`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlateAction {
/// Bit 1, the left button: `SelectObjectByClick` (`0x494a22` → `0x4925d0`).
Select,
/// Bit 4, the right button: `InteractWithObjectByClick` (`0x494a39` → `0x492820`).
Interact,
}
/// What a sync runs outside the model borrow, in the order it issued them: the plates' `Show`
/// and `Hide`, whose transitions fire handlers, and `OnValueChanged` for moved bars (pfUI hooks
/// it, `nameplates.lua:393`). A bind's bar fires inside it, ahead of the plate's show
/// (`0x608707` before `0x60871e`).
#[derive(Default)]
struct SyncEffects(Vec<Effect>);
enum Effect {
Shown(FrameHandle, bool),
Value(FrameHandle, f32),
}
impl SyncEffects {
fn shown(&mut self, frame: FrameHandle, shown: bool) {
self.0.push(Effect::Shown(frame, shown));
}
fn values(&mut self, bar: FrameHandle, fires: impl IntoIterator<Item = Option<f32>>) {
self.0
.extend(fires.into_iter().flatten().map(|v| Effect::Value(bar, v)));
}
/// Run them, each a full transition or handler.
fn apply(self, lua: &Lua) {
for effect in self.0 {
match effect {
Effect::Shown(frame, shown) => super::visibility::set_shown(lua, frame, shown),
Effect::Value(bar, v) => super::statusbar::fire_engine_value_changed(lua, bar, v),
}
}
}
}
/// Bit `0x1` of `[0xc4da34]`: plates on every unit that is not friendly, set by `ShowNameplates`
/// and cleared by `HideNameplates` (setter `0x6054f0`).
pub const PLATE_BIT_ENEMY: u8 = 0x1;
/// Bit `0x8` of `[0xc4da34]`: plates on friendly units, set by `ShowFriendNameplates` and cleared
/// by `HideFriendNameplates` (setter `0x605510`).
pub const PLATE_BIT_FRIEND: u8 = 0x8;
/// One plate verb's write of its bit of `[0xc4da34]`, which the host applies inside the call
/// ([`super::ScriptCall::Nameplates`]): the setter `or`s or `and`s the bit into the live dword.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PlateBitWrite {
pub bit: u8,
pub on: bool,
}
impl PlateBitWrite {
/// `bits` with this write applied.
pub fn apply(self, bits: u8) -> u8 {
if self.on {
bits | self.bit
} else {
bits & !self.bit
}
}
}
/// The plate writes a harness's VM kept ([`UiScript::take_nameplate_bit_writes`]), folded in call
/// order, so per bit the last write wins; bits `0x2` and `0x4` are the frame loop's own latches
/// and no verb reaches them.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PlateBitWrites {
set: u8,
clear: u8,
}
impl PlateBitWrites {
fn write(&mut self, bit: u8, on: bool) {
if on {
self.set |= bit;
self.clear &= !bit;
} else {
self.clear |= bit;
self.set &= !bit;
}
}
/// `bits` with these writes applied.
pub fn apply(self, bits: u8) -> u8 {
(bits | self.set) & !self.clear
}
/// No verb ran since the last take.
pub fn is_empty(self) -> bool {
self.set | self.clear == 0
}
}
/// The four nameplate verbs: `ShowNameplates 0x489450`, `HideNameplates 0x489460`,
/// `ShowFriendNameplates 0x489470`, `HideFriendNameplates 0x489480`. Each reads no argument
/// (`ShowNameplates(false)` still shows), returns zero values and writes its bit of `[0xc4da34]`,
/// which the engine clears at every world entry (`0x60394b`, `0x603952`). There is no getter and
/// no nameplate CVar: FrameXML keeps its own `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` and replays
/// them through these verbs.
pub(super) fn install_verbs(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
for (name, bit, on) in [
("ShowNameplates", PLATE_BIT_ENEMY, true),
("HideNameplates", PLATE_BIT_ENEMY, false),
("ShowFriendNameplates", PLATE_BIT_FRIEND, true),
("HideFriendNameplates", PLATE_BIT_FRIEND, false),
] {
g.set(
name,
// The empty `MultiValue` returns zero values, which is not nil.
lua.create_function(move |lua, _: mlua::MultiValue| {
super::calls::submit(
lua,
super::ScriptCall::Nameplates(PlateBitWrite { bit, on }),
)?;
Ok(mlua::MultiValue::new())
})?,
)?;
}
Ok(())
}
impl UiScript {
/// The plate writes a harness's VM kept ([`Self::keep_calls`]), folded in call order.
pub fn take_nameplate_bit_writes(&mut self) -> PlateBitWrites {
let mut writes = PlateBitWrites::default();
for write in self.take_calls_where(|c| match c {
super::ScriptCall::Nameplates(write) => Some(*write),
_ => None,
}) {
writes.write(write.bit, write.on);
}
writes
}
/// The frame's gate over the pool (`0x60f600` from `0x607ef9`), one state per unit it keeps
/// a plate on: a unit with no plate is bound (`0x6086e0` → `0x7cb6d0`), a unit with one has
/// only the frame's fields moved, the seat, the alpha, the lit bar and the hover, and its
/// level too while the latch asks ([`Self::latch_nameplate_levels`]); a unit with no state
/// has its plate retired (hidden, never destroyed). What a bound plate shows of its unit
/// moves inside the packets, at its callbacks ([`Self::nameplate_health`] and the rest). An
/// unchanged `geometry` costs nothing.
pub fn sync_nameplates(&mut self, geometry: PlateGeometry, states: &[PlateState]) {
let lua = self.lua();
// The constructor's two lookups (`0x7cb3f9`, `0x7cb49b`), for each plate about to be built
// and none for a built one. Done before the Model is borrowed: a lookup honours an `_G`
// `__index` (`lua_gettable` at `0x704373`), which may call any verb.
let mut fonts: VecDeque<Option<String>> = {
let built = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
// No WorldFrame, no plates, and no lookups for them.
if model.arena.lookup("WorldFrame").is_none() {
return;
}
plates_to_build(&model, states)
};
fonts_for(lua, built)
};
let effects = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// No WorldFrame (no interface loaded), no plates: no addon walks an invented root.
let Some(world) = model.arena.lookup("WorldFrame") else {
return;
};
sync(&mut model, world, geometry, states, &mut fonts)
};
effects.apply(lua);
}
/// Set the level latch (bit `0x4` of `[0xc4da34]`, `0x605550`): the active player's level
/// moved (`0x5edb93`), or its drunkenness (`0x5e2b42`), so the next sync refreshes each kept
/// plate's level and skull, whose colour, threshold and shown level read them.
pub fn latch_nameplate_levels(&mut self) {
self.model_mut().nameplates.levels_due = true;
}
/// The bar's watcher `0x7cc570` on a change of `guid`'s `UNIT_FIELD_HEALTH` or
/// `UNIT_FIELD_MAXHEALTH`, inside its packet, on each plate whose bar watches the unit, in the
/// order their watches were linked, with no other watcher among them
/// ([`Self::unit_health_watchers`]).
pub fn nameplate_health(&mut self, guid: u64, health: f32, max: f32) {
let watch = super::HealthWatch {
bar: Some((health, max)),
tooltip: None,
};
self.unit_health_watchers(guid, watch, |_| {});
}
/// One plate bar's watcher `0x7cc570`: the range (`0x7cc5a8` → `0x783270`), which re-applies
/// the held value, then the value (`0x7cc5be`), each firing `OnValueChanged` where it moves.
pub(super) fn bar_watch(&mut self, bar: FrameHandle, health: f32, max: f32) {
let lua = self.lua();
for write in [BarWrite::Range(max), BarWrite::Value(health)] {
let fired = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
match model.arena.frame_mut(bar).map(|f| &mut f.kind_state) {
Some(KindState::StatusBar(sb)) => match write {
BarWrite::Range(max) => sb.set_min_max(0.0, max),
BarWrite::Value(v) => sb.set_value(v),
},
_ => None,
}
};
if let Some(v) = fired {
super::statusbar::fire_engine_value_changed(lua, bar, v);
}
}
}
/// `RefreshNameDesc`'s plate leg (`0x607080` → `0x7cbaa0`) on `guid`'s plate, if it has one
/// (`0x60708e`), inside its packet: the bar's colour from the unit's reaction as it stands
/// ([`PlateUnitQuery`]). Its callers: the health watcher (`0x60471b`), the reaction's refresh
/// of a unit, its summon and its charm (`0x606e3c`, `0x606e8a`, `0x606ecf`) and the active
/// player's sweep (`0x606fa2`).
pub fn refresh_nameplate_colour(&mut self, guid: u64) {
self.refresh_bound(PlateUnitQuery { guid, party: None }, |model, i, unit| {
Plate::colour(model, i, unit.bar_colour)
});
}
/// [`Self::refresh_nameplate_colour`] from a party slot writer, the reaction read with the
/// slots `party` as they stood there: `AddPartyMember`'s on the member it seats (`0x4e83dd`)
/// and `ClearParty`'s on each it empties, ahead of the slot's zeroing (`0x4e8568`, `0x4e858c`).
pub fn refresh_nameplate_colour_in_party(&mut self, guid: u64, party: [u64; 4]) {
let query = PlateUnitQuery {
guid,
party: Some(party),
};
self.refresh_bound(query, |model, i, unit| {
Plate::colour(model, i, unit.bar_colour)
});
}
/// `OnLevelChanged`'s plate leg (`0x60cdd0` at `0x60ce5b` → `0x7cbb40`) on `guid`'s plate, if
/// it has one (`0x60ce58`), inside its packet: the level and skull as they stand.
pub fn refresh_nameplate_level(&mut self, guid: u64) {
self.refresh_bound(PlateUnitQuery { guid, party: None }, Plate::level);
}
/// `f` on the plate bound to the query's unit, with the unit as the lent host reads it now.
fn refresh_bound(
&mut self,
query: PlateUnitQuery,
f: impl FnOnce(&mut Model, usize, &PlateUnit),
) {
let guid = query.guid;
let lua = self.lua();
if bound_slot(&lua.app_data_ref::<Model>().expect("model app_data"), guid).is_none() {
return;
}
let Some(unit) = super::live::ask(lua, &query) else {
return;
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(i) = bound_slot(&model, guid) {
f(&mut model, i, &unit);
}
}
/// `0x608a90` on each unit with a plate: its plate's raid icon from the board's slot for it as
/// it stands (`0x608aa2` → `0x4bb190`, `0x608ab3` → `0x7cbe20`), as the raid target handler
/// runs it on each unit whose slot moved (`0x4ba28a`, `0x4ba308`, `0x4ba32f`) and the board's
/// clear on each it held (`0x4ba5cb`), ahead of `RAID_TARGET_UPDATE`. A unit whose slot did
/// not move reads the icon it shows.
pub fn refresh_nameplate_raid_icons(&mut self) {
let units: Vec<u64> = self
.model_ref()
.nameplates
.assigned
.keys()
.copied()
.collect();
for guid in units {
self.refresh_bound(PlateUnitQuery { guid, party: None }, |model, i, unit| {
Plate::raid_icon(model, i, unit.raid_icon)
});
}
}
/// The gate `0x60f600` on `guid` inside its packet, from `InvalidateNameDesc` (`0x607050` at
/// `0x60706a`), as the lent host judges it ([`PlateGateQuery`]): a unit it gives a plate and
/// has none is bound (`0x6086e0`) and shown, its seat left for the frame's layout
/// (`0x608ce0`); one it keeps a plate on has its level and skull refreshed while the latch is
/// set (`0x60875f` → `0x7cbb40`), as the frame's gate does; one it refuses has its plate
/// released (`0x60f742`). With no host to ask, nothing moves.
pub fn gate_nameplate(&mut self, guid: u64) {
let lua = self.lua();
let Some(verdict) = super::live::ask(lua, &PlateGateQuery(guid)) else {
return;
};
let Some(unit) = verdict else {
self.release_nameplate(guid);
return;
};
let lua = self.lua();
let built = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.arena.lookup("WorldFrame").is_none() || model.nameplates.geometry.is_none() {
return;
}
if model.nameplates.assigned.contains_key(&guid) {
if let Some(i) = bound_slot(&model, guid).filter(|_| model.nameplates.levels_due) {
Plate::level(&mut model, i, &unit);
}
return;
}
usize::from(free_slot(&model).is_none())
};
let mut fonts = fonts_for(lua, built);
let name = unit
.name
.clone()
.unwrap_or_else(|| self.unknown_object_text());
let lua = self.lua();
let effects = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let (Some(world), Some(geometry)) =
(model.arena.lookup("WorldFrame"), model.nameplates.geometry)
else {
return;
};
let mut effects = SyncEffects::default();
let i = take_slot(&mut model, world, guid, &mut fonts);
if model.nameplates.plates[i].laid_out != Some(geometry) {
Plate::lay_out(&mut model, i, geometry);
}
Plate::bind(&mut model, i, guid, &unit, name, &mut effects);
effects
};
effects.apply(lua);
}
/// `ReleaseNamePlate` (`0x608a10`) on `guid`'s plate, if it has one, inside its packet: hidden
/// and returned to the pool, its guid and its bar's watch kept. The name and creature answers
/// release it ahead of their name update (`0x5fb86e`, `0x6046c1`).
pub fn release_nameplate(&mut self, guid: u64) {
let lua = self.lua();
let effects = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let mut effects = SyncEffects::default();
if let Some(i) = model.nameplates.assigned.remove(&guid) {
Plate::retire(&mut model, i, &mut effects);
}
effects
};
effects.apply(lua);
}
/// The unit `guid` leaves the world inside its packet: its release frees its plate
/// (`CGUnit_C::Release` at `0x5fbba9`, the destructor at `0x5fb620`), and its object, which
/// holds the bar and tooltip watches, goes with them, so neither follows it again.
pub fn nameplate_unit_gone(&mut self, guid: u64) {
self.release_nameplate(guid);
let mut model = self.model_mut();
let tooltips: Vec<_> = model
.unit_watches
.linked_tooltips()
.filter(|&h| super::tooltip_unit::shows(&model, h, guid))
.collect();
model.unit_watches.gone(guid, &tooltips);
for plate in &mut model.nameplates.plates {
if plate.bar_unit == guid {
plate.bar_unit = 0;
}
}
}
/// A hit on the unit `key`: `FlashNamePlate` (`0x6122e0` → `0x7cba70`) starts the flash on the
/// unit's plate, if it has one (`[unit+0xe60]`, `0x6122e6`): the timer to 5000 ms and the
/// name red, whatever the hover. Called inside the packet for a health drop (`0x6046f0` at
/// `0x604764`), a direct spell hit (`0x62cd80` at `0x62ce43`) and environmental damage
/// (`0x62aac0` at `0x62abac`).
pub fn flash_nameplate(&mut self, key: u64) {
let lua = self.lua();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(&slot) = model.nameplates.assigned.get(&key) else {
return;
};
let plate = &mut model.nameplates.plates[slot];
if plate.shown.is_none() {
return;
}
plate.flash_ms = FLASH_MS;
let name = plate.name;
model
.region_data
.entry(name)
.or_default()
.set_vertex_color(Some(FLASH_RED));
}
/// The unit whose plate is the hovered frame, the mouseover its OnEnter publishes (`0x7cb850`
/// → `0x492890`, `[0xb4e2c8]`); an addon frame on a plate takes the hover from it.
pub fn hovered_nameplate(&self) -> Option<u64> {
let lua = self.lua();
let model = lua.app_data_ref::<Model>().expect("model app_data");
let frame = model.mouseover?;
let slot = *model.nameplates.by_frame.get(&frame)?;
model.nameplates.plates.get(slot)?.key()
}
/// Apply `click` through the host lent now, as the plate's slot does inside its click
/// ([`super::ScriptCall::PlateClick`]): for a test of the click's legs without the widget.
pub fn submit_plate_click(&mut self, click: PlateClick) {
submit(self.lua(), click);
}
/// The freelook toggle (`0x60f830`): entering camera freelook disables mouse input on every
/// plate and leaving re-enables it (called from `0x483e80` and `0x483e70`). The disable is
/// `EnableMouse(false)`'s (`0x76af50(2)`), whose removal tail (`0x764ba0`, flag 0, `0x76af7f`)
/// ends a hovered plate's hover through its leave (`0x7cb8b0`).
pub fn set_nameplate_mouse(&mut self, enabled: bool) {
let lua = self.lua();
let frames: Vec<FrameHandle> = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.nameplates.plates.iter().map(|p| p.frame).collect()
};
for frame in frames {
super::visibility::set_mouse_enabled(lua, frame, enabled);
}
}
/// The plate hit-test veto (`0x7cba30`): while a ground-targeted spell is armed, clicks fall
/// through plates to the `WorldFrame`. The caller supplies the first two terms of the
/// reference's `IsTargeting() && (flag & 0x60) && !0x6e6180()`; the third, a test of the same
/// word against `0x878e` whose meaning is unsettled, is not applied.
pub fn set_nameplate_hit_test_veto(&mut self, vetoed: bool) {
let lua = self.lua();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.nameplates.hit_test_vetoed = vetoed;
}
/// Retire every live plate (`0x608a10` over the active list, as when the master toggle
/// clears); the app's every early return owes this, since a widget stays up until hidden.
pub fn retire_nameplates(&mut self) {
let lua = self.lua();
let effects = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let mut effects = SyncEffects::default();
model.nameplates.assigned.clear();
for i in 0..model.nameplates.plates.len() {
Plate::retire(&mut model, i, &mut effects);
}
effects
};
effects.apply(lua);
}
/// How many plate widgets the pool holds.
pub fn nameplate_pool_len(&self) -> usize {
let lua = self.lua();
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.nameplates.plates.len()
}
}
/// One of the bar watcher's two writes (`0x7cc570`).
#[derive(Clone, Copy)]
enum BarWrite {
Range(f32),
Value(f32),
}
/// Each plate bar watching `guid` ([`Plate::bar_unit`]), a retired one's included, by its link.
pub(super) fn bars_watching(model: &Model, guid: u64) -> Vec<(u64, FrameHandle)> {
model
.nameplates
.plates
.iter()
.filter(|p| p.bar_unit == guid)
.map(|p| (p.bar_watched_at, p.bar))
.collect()
}
/// The slot of the plate bound to `guid` (`[unit+0xe60]`), if it has one.
fn bound_slot(model: &Model, guid: u64) -> Option<usize> {
let &i = model.nameplates.assigned.get(&guid)?;
model.nameplates.plates[i].shown.is_some().then_some(i)
}
/// The lowest free slot. Deviation: the reference's free list is FIFO (`0x6087f0`, `0x608a5a`);
/// either keeps every index stable, all an addon can observe of the pool's order.
fn free_slot(model: &Model) -> Option<usize> {
(0..model.nameplates.plates.len()).find(|&i| model.nameplates.plates[i].shown.is_none())
}
/// `0x6087f0` for `guid`: the lowest free slot, else a plate built at the tail, assigned to it.
fn take_slot(
model: &mut Model,
world: FrameHandle,
guid: u64,
fonts: &mut VecDeque<Option<String>>,
) -> usize {
let i = free_slot(model).unwrap_or_else(|| {
let plate = Plate::create(model, world, fonts);
model.nameplates.plates.push(plate);
model.nameplates.plates.len() - 1
});
model.nameplates.assigned.insert(guid, i);
i
}
/// The constructor's two lookups (`0x7cb3f9`, `0x7cb49b`) for each of `built` plates, name then
/// level, done before the Model is borrowed: a lookup honours an `_G` `__index` (`lua_gettable`
/// at `0x704373`), which may call any verb.
fn fonts_for(lua: &Lua, built: usize) -> VecDeque<Option<String>> {
(0..2 * built)
.map(|_| super::font::nameplate_font(lua))
.collect()
}
/// A plate's click slot (`0x7cb910`) ahead of the button's own click: `0x4949f0` on the plate's
/// unit (`0x7cb92a`), applied inside the click, so the plate's `OnClick` (`0x7cb936` →
/// `0x779540`) and the line after a `Click()` read what it did. Every run of the slot comes here, a
/// press's (`0x779285`) or a release's (`0x7793a9`), the mouse's or `Click()`'s, on an enabled
/// plate or not: `RunClick`'s gate (`0x779552`) is past it, and on a retired plate, whose guid is
/// still its last unit's. A frame that is no plate, a plate never bound, or any button but the
/// left and the right, does nothing.
pub(super) fn click(lua: &mlua::Lua, frame: FrameHandle, button: &str, scripted: bool) {
let action = match button {
"LeftButton" => PlateAction::Select,
"RightButton" => PlateAction::Interact,
_ => return,
};
let guid = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(&slot) = model.nameplates.by_frame.get(&frame) else {
return;
};
match model.nameplates.plates.get(slot).map(|p| p.guid) {
Some(guid) if guid != 0 => guid,
_ => return,
}
};
let may_cast = super::protected::select_may_cast(lua);
submit(
lua,
PlateClick {
guid,
action,
scripted,
may_cast,
},
);
}
/// Hand `click` to the host, which applies it before this returns.
fn submit(lua: &mlua::Lua, click: PlateClick) {
if let Err(e) = super::calls::submit(lua, super::ScriptCall::PlateClick(click)) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_lua_error(&e);
}
}
/// How many new plates [`sync`] builds for `states`: the units without a plate, less the free
/// slots, which are the retired ones and those the departing units are about to free.
fn plates_to_build(model: &Model, states: &[PlateState]) -> usize {
let np = &model.nameplates;
let mut free = np.plates.iter().filter(|p| p.shown.is_none()).count();
free += np
.assigned
.iter()
.filter(|(k, &i)| !states.iter().any(|s| s.key == **k) && np.plates[i].shown.is_some())
.count();
let arriving: HashSet<u64> = states
.iter()
.map(|s| s.key)
.filter(|k| !np.assigned.contains_key(k))
.collect();
arriving.len().saturating_sub(free)
}
/// The frame's gate over the pool: retire departed units, bind arriving ones, lay out, and move
/// the frame's fields of every kept plate, with its level while the latch asks.
fn sync(
model: &mut Model,
world: FrameHandle,
geometry: PlateGeometry,
states: &[PlateState],
fonts: &mut VecDeque<Option<String>>,
) -> SyncEffects {
let mut effects = SyncEffects::default();
let relayout = model.nameplates.geometry != Some(geometry);
model.nameplates.geometry = Some(geometry);
model.nameplates.retired_in_batch.clear();
// Retire first, so a departing unit's slot is free for an arriving one, as the reference
// destroys then creates through one free list in the same tick.
let gone: Vec<u64> = model
.nameplates
.assigned
.keys()
.copied()
.filter(|k| !states.iter().any(|s| s.key == *k))
.collect();
for key in gone {
if let Some(i) = model.nameplates.assigned.remove(&key) {
Plate::retire(model, i, &mut effects);
}
}
let levels_due = std::mem::take(&mut model.nameplates.levels_due);
for state in states {
let kept = model.nameplates.assigned.get(&state.key).copied();
let slot = kept.unwrap_or_else(|| take_slot(model, world, state.key, fonts));
if model.nameplates.plates[slot].laid_out != Some(geometry) {
Plate::lay_out(model, slot, geometry);
}
match kept {
// `0x6086e0` on a unit with a plate: the level while the latch asks (`0x60875f` →
// `0x7cbb40`), and the point stored for the seat.
Some(_) if levels_due => Plate::level(model, slot, &state.unit()),
Some(_) => {}
// A new plate's bind (`0x608707`), then its show (`0x60871e`). WORLD batches precede
// the world callback's allocations (`0x765896`, `0x7658e2`).
None => {
let frame = model.nameplates.plates[slot].frame;
if !model.nameplates.retired_in_batch.contains(&frame) {
model.nameplates.pending_paint.insert(frame);
}
Plate::bind(
model,
slot,
state.key,
&state.unit(),
state.name.clone(),
&mut effects,
);
}
}
Plate::frame(model, slot, world, state);
}
// A geometry change reaches retired plates too: addons still read them in the child list.
if relayout {
for i in 0..model.nameplates.plates.len() {
if model.nameplates.plates[i].laid_out != Some(geometry) {
Plate::lay_out(model, i, geometry);
}
}
}
effects
}
impl Plate {
/// Build one plate: the Button, its six regions in the reference's order, and the health bar.
/// Written once, so a region an addon blanks stays blank.
fn create(
model: &mut Model,
world: FrameHandle,
fonts: &mut VecDeque<Option<String>>,
) -> Plate {
// Anonymous, as the reference's name is zeroed (`0x76c50b`): plate addons reject a named
// `WorldFrame` child.
let frame = model.arena.create(FrameKind::Button, None, Some(world));
let strata = model
.arena
.frame(world)
.map(|f| f.strata)
.unwrap_or_default();
model.arena.set_frame_strata(frame, strata);
model.arena.set_frame_level(frame, PLATE_LEVEL, true);
// Mouse-enabled from birth by `CSimpleButton`'s ctor (`0x7786a3`), and registered for
// both buttons on the up edge (`RegisterForClicks(0x500)`, `0x7cb637`); the arena's
// Button default is the left alone.
if let Some(KindState::Button(bs)) = model.arena.frame_mut(frame).map(|f| &mut f.kind_state)
{
bs.registered_clicks = ["LeftButtonUp", "RightButtonUp"]
.into_iter()
.map(str::to_string)
.collect();
}
// The ctor's blend modes, one `0x7703f0` call per texture: BLEND, but the glow is ADD
// (`0x7cb36a`), which [`is_unpainted_glow`] depends on.
let border = texture(
model,
frame,
DrawLayer::Artwork,
BORDER_TEXTURE,
BlendMode::Blend,
);
let glow = texture(
model,
frame,
DrawLayer::Highlight,
GLOW_TEXTURE,
BlendMode::Add,
);
// Each FontString looks `NAMEPLATE_FONT` up itself, name then level (`0x7cb3f9`,
// `0x7cb49b`), at construction only.
let name = font_string(model, frame, JustifyV::Bottom, fonts.pop_front().flatten());
let level = font_string(model, frame, JustifyV::Middle, fonts.pop_front().flatten());
let skull = texture(
model,
frame,
DrawLayer::Overlay,
SKULL_TEXTURE,
BlendMode::Blend,
);
let raid = texture(
model,
frame,
DrawLayer::Artwork,
RAID_ICON_TEXTURE,
BlendMode::Blend,
);
// The order above is the ABI addons walk and, as 5875 has no sub-level, the draw order
// within a layer.
// The one child, below the plate's level so the border draws over the fill.
let bar = model.arena.create(FrameKind::StatusBar, None, Some(frame));
model.arena.set_frame_strata(bar, strata);
model.arena.set_frame_level(bar, BAR_LEVEL, true);
// The fill is `SetStatusBarTexture`'s path leg on layer 2 (`0x7cb31a`-`0x7cb329`), so it
// is installed, flagged to cut its rect and pinned to the bar like any bar texture; a file
// that fails to load leaves the bar without one. The bar's ctor (`0x7cc340` → `0x782d80`)
// sets no range and no value.
super::statusbar::set_bar_path_model(model, bar, BAR_FILL_TEXTURE, DrawLayer::Artwork)
.expect("live bar");
let fill = match model.arena.frame(bar).map(|f| &f.kind_state) {
Some(KindState::StatusBar(sb)) => sb.bar,
_ => None,
};
for rh in [glow, skull, raid] {
model.set_region_hidden(rh, true);
}
// Born retired and hidden: addons tell a live plate from a pooled one by `IsShown()`.
// Silent, since nothing can have set a handler on a frame not yet handed out.
model.set_shown_silent(frame, false);
model
.nameplates
.by_frame
.insert(frame, model.nameplates.plates.len());
Plate {
frame,
border,
glow,
name,
level,
skull,
raid,
bar,
fill,
flash_ms: 0,
shown: None,
guid: 0,
bar_unit: 0,
bar_watched_at: 0,
laid_out: None,
}
}
/// Write the window-derived sizes and anchors, on the first drive and a geometry change. The
/// reference's are real anchors too, through `SetPoint`'s own store (`0x767c70`).
fn lay_out(model: &mut Model, i: usize, g: PlateGeometry) {
let p = &model.nameplates.plates[i];
let (frame, bar) = (p.frame, p.bar);
let (border, glow, name, level, skull, raid) =
(p.border, p.glow, p.name, p.level, p.skull, p.raid);
let plate_id = model.frame_id(frame);
// The plate's size; its position is the frame's seat, in `frame`.
let input = model.layout_inputs.entry(frame).or_default();
input.width = crate::layout::Len::Script(g.width);
input.height = crate::layout::Len::Script(g.height);
for rh in [border, glow] {
model.region_data.entry(rh).or_default().anchors = Anchors::from([
Anchor::new(Point::TopLeft, plate_id, Point::TopLeft, 0.0, 0.0),
Anchor::new(Point::BottomRight, plate_id, Point::BottomRight, 0.0, 0.0),
]);
}
let bar_input = model.layout_inputs.entry(bar).or_default();
bar_input.anchors = Anchors::from([Anchor::new(
Point::BottomLeft,
plate_id,
Point::BottomLeft,
g.bar_off_x,
g.bar_off_y,
)]);
bar_input.width = crate::layout::Len::Script(g.bar_width);
bar_input.height = crate::layout::Len::Script(g.bar_height);
// The name's (`0x7cb456`) and level's (`0x7cb7f5`) anchors, which `_Nameplates` checks.
let name_data = model.region_data.entry(name).or_default();
name_data.anchors = Anchors::from([Anchor::new(
Point::Bottom,
plate_id,
Point::Center,
0.0,
0.0,
)]);
name_data.font_height = Some(Len::Script(g.name_height));
for rh in [name, level] {
model.region_data.entry(rh).or_default().font_shadow = Some(FontShadow {
offset: [
crate::layout::Len::Script(g.shadow_offset),
crate::layout::Len::Script(-g.shadow_offset),
],
color: crate::color::Rgba8::BLACK,
});
}
let level_data = model.region_data.entry(level).or_default();
level_data.anchors = Anchors::from([Anchor::new(
Point::Center,
plate_id,
Point::BottomRight,
-g.level_off_x,
g.level_off_y,
)]);
level_data.font_height = Some(Len::Script(g.level_height));
model.touch_measure(level);
let skull_data = model.region_data.entry(skull).or_default();
skull_data.anchors = Anchors::from([Anchor::new(
Point::Center,
plate_id,
Point::BottomRight,
-g.level_off_x,
g.level_off_y,
)]);
skull_data.size = Some((Len::Script(g.skull_size), Len::Script(g.skull_size)));
let raid_data = model.region_data.entry(raid).or_default();
raid_data.anchors =
Anchors::from([Anchor::new(Point::Right, plate_id, Point::Left, 0.0, 0.0)]);
raid_data.size = Some((Len::Script(g.raid_size), Len::Script(g.raid_size)));
// Once per geometry change: each node it wrote is invalidated, as the plate setup's
// `SetPoint`/`SetAllPoints` with the invalidate flag do (`0x7cb456`..`0x7cb806`).
model.invalidate_frame(frame, true);
model.invalidate_frame(bar, true);
for rh in [border, glow, name, level, skull, raid] {
model.invalidate_region(rh, true);
}
model.nameplates.plates[i].laid_out = Some(g);
}
/// The bind `0x7cb6d0` of plate `i` to the unit `key`, at the frame's gate or inside a packet:
/// the guid (`0x7cb6e0`), the name white (`0x7cb701`) with `GetUnitName`'s text (`0x7cb70c`),
/// the bar bound to the unit and set from it (`0x7cb724` → `0x7cc3d0`), the flash timer 0
/// (`0x7cb80e`), the bar's colour (`0x7cb818`), the level (`0x7cb820`) and the raid icon
/// (`0x7cb837`); then the show (`0x60871e`). The seat is the frame's ([`Self::frame`]).
fn bind(
model: &mut Model,
i: usize,
key: u64,
unit: &PlateUnit,
name: String,
effects: &mut SyncEffects,
) {
let binds = model.unit_watches.link();
let p = &mut model.nameplates.plates[i];
p.guid = key;
p.flash_ms = 0;
p.shown = Some(Shown {
key,
seat: None,
alpha: None,
lit: None,
hovered: None,
});
let (frame, bar, name_rh, fill) = (p.frame, p.bar, p.name, p.fill);
model
.region_data
.entry(name_rh)
.or_default()
.set_vertex_color(Some(crate::color::Rgba8::WHITE));
// The name goes through `SetText` with its expand flag: `GetUnitName` takes the one stack
// argument and leaves the `push 1` ahead of it for the call (`0x7cb706`-`0x7cb718`).
let text = model.localized_text(name);
model.region_data.entry(name_rh).or_default().text = Some(text);
model.touch_measure(name_rh);
// A new string clears the valid bit and invalidates (`0x771ea4`).
model.clear_region_valid(name_rh);
// The bar's bind unwatches its last unit and watches this one (`0x7cc3e2`, `0x7cc416`),
// then sets its range and its value (`0x7cc42c`, `0x7cc442`), each firing where it moves.
let p = &mut model.nameplates.plates[i];
p.bar_unit = key;
p.bar_watched_at = binds;
let fires = super::statusbar::set_health(model, bar, unit.max_health, unit.health);
effects.values(bar, fires);
// The lit boost is the frame's to set again ([`Self::frame`]).
if let Some(fill) = fill {
model.nameplates.lit_fills.remove(&fill);
}
Plate::colour(model, i, unit.bar_colour);
Plate::level(model, i, unit);
Plate::raid_icon(model, i, unit.raid_icon);
effects.shown(frame, true);
}
/// The frame's own fields of plate `i`, each only when it moved: the seat (`0x608ce0` →
/// `0x509ec0`), the alpha (`OnUpdate`, `0x7cb9f9`), the lit bar and the hover.
fn frame(model: &mut Model, i: usize, world: FrameHandle, state: &PlateState) {
let p = &model.nameplates.plates[i];
let (frame, glow, name, fill) = (p.frame, p.glow, p.name, p.fill);
let Some(shown) = p.shown.clone() else {
return;
};
let world_id = model.frame_id(world);
// Re-seated whenever it moves, as the reference does (`0x509ec0`).
if shown.seat != Some(state.top_centre) {
let input = model.layout_inputs.entry(frame).or_default();
input.anchors = Anchors::from([Anchor::new(
Point::Top,
world_id,
Point::BottomLeft,
state.top_centre.0,
state.top_centre.1,
)]);
// `SetPoint` with the invalidate flag (`0x50a044`).
model.invalidate_frame(frame, true);
}
if shown.alpha != Some(state.alpha) {
model.arena.set_alpha(frame, state.alpha);
}
// The fill's reaction colour brightens while lit (`LIT_BOOST`); the boost rides the draw
// ([`lit_fill_boost`]), as it can pass 1, which the packed colour cannot hold.
if shown.lit != Some(state.lit) {
if let Some(fill) = fill {
if state.lit {
model.nameplates.lit_fills.insert(fill);
} else {
model.nameplates.lit_fills.remove(&fill);
}
}
}
if shown.hovered != Some(state.hovered) {
// The enter and leave notifies (`0x7cb883`, `0x7cb8d9`) set the name's colour only
// with the timer at 0 or less; a flash outlasts the hover.
if model.nameplates.plates[i].flash_ms <= 0 {
let c = if state.hovered {
NAME_YELLOW
} else {
crate::color::Rgba8::WHITE
};
model
.region_data
.entry(name)
.or_default()
.set_vertex_color(Some(c));
}
// The glow really shows, as addons read mouseover from it; it only paints nothing.
model.set_region_hidden(glow, !state.hovered);
}
model.nameplates.plates[i].shown = Some(Shown {
seat: Some(state.top_centre),
alpha: Some(state.alpha),
lit: Some(state.lit),
hovered: Some(state.hovered),
..shown
});
}
/// `SetBarColor` (`0x7cbaa0`): the fill's reaction colour, exact, as addons read the unit's
/// reaction from `GetStatusBarColor`.
fn colour(model: &mut Model, i: usize, c: [f32; 3]) {
if let Some(fill) = model.nameplates.plates[i].fill {
model.region_data.entry(fill).or_default().vertex_color =
Some(crate::color::Rgba8::from_f32([c[0], c[1], c[2], 1.0]));
}
}
/// `UpdateLevel` (`0x7cbb40`): the level text in its colour, or the skull in its seat.
fn level(model: &mut Model, i: usize, unit: &PlateUnit) {
let p = &model.nameplates.plates[i];
let (level, skull) = (p.level, p.skull);
match (unit.level, unit.skull) {
// The skull flag wins the seat, whatever the level.
(_, true) => {
model.set_region_hidden(level, true);
model.set_region_hidden(skull, false);
}
(Some(n), false) => {
let c = unit.level_colour;
// The level goes through `SetText` with its expand flag (`0x7cbcca`).
let shown = model.localized_text(n.to_string());
let data = model.region_data.entry(level).or_default();
data.text = Some(shown);
data.set_vertex_color(Some(crate::color::Rgba8::from_f32([c[0], c[1], c[2], 1.0])));
model.set_region_hidden(level, false);
model.set_region_hidden(skull, true);
model.touch_measure(level);
model.clear_region_valid(level);
}
// No level yet and no skull: the seat stays empty.
(None, false) => {
model.set_region_hidden(level, true);
model.set_region_hidden(skull, true);
}
}
}
/// `0x7cbe20`: the raid icon's atlas cell, column `idx & 3` and row `idx >> 2`, or hidden.
fn raid_icon(model: &mut Model, i: usize, icon: Option<u8>) {
let raid = model.nameplates.plates[i].raid;
match icon {
Some(idx) => {
let (u0, v0) = (f32::from(idx & 3) * 0.25, f32::from(idx >> 2) * 0.25);
let data = model.region_data.entry(raid).or_default();
data.tex_coords = Some(TexCoords::Rect([u0, u0 + 0.25, v0, v0 + 0.25]));
model.set_region_hidden(raid, false);
}
None => model.set_region_hidden(raid, true),
}
}
/// Retire a plate: hide it, never destroy it (`0x608a10`), so it keeps its child-list index and
/// comes back as the same Lua object addons key their per-plate state by. Its guid stays: the
/// retire never writes `+0x4e8`, and its bar still watches the unit.
fn retire(model: &mut Model, i: usize, effects: &mut SyncEffects) {
if model.nameplates.plates[i].shown.is_none() {
return;
}
let frame = model.nameplates.plates[i].frame;
if !model.nameplates.pending_paint.remove(&frame) {
model.nameplates.retired_in_batch.insert(frame);
}
effects.shown(frame, false);
model.nameplates.plates[i].shown = None;
}
}
/// A newly shown plate and its children miss the WORLD batches already built (`0x765896`).
pub(super) fn awaits_batch(model: &Model, target: crate::order::ZTarget) -> bool {
if model.nameplates.pending_paint.is_empty() {
return false;
}
let mut frame = match target {
crate::order::ZTarget::Frame(frame) => Some(frame),
crate::order::ZTarget::Region(region) => model.arena.region(region).map(|r| r.owner),
};
while let Some(owner) = frame {
if model.nameplates.pending_paint.contains(&owner) {
return true;
}
frame = model.arena.frame(owner).and_then(|f| f.parent);
}
false
}
/// One of the plate's textures, with its path and the blend mode the ctor sets (`0x7703f0`).
fn texture(
model: &mut Model,
frame: FrameHandle,
layer: DrawLayer,
path: &str,
blend: BlendMode,
) -> RegionHandle {
let rh = model
.arena
.create_region(frame, RegionKind::Texture, layer, 0)
.expect("live plate");
model.set_texel(rh, super::texel::TexSet::File(path.to_string()));
let data = model.region_data.entry(rh).or_default();
data.blend = blend;
rh
}
/// One of the plate's two FontStrings, OVERLAY as the ctor re-layers them (`0x7cb438`,
/// `0x7cb4ea`), with the `NAMEPLATE_FONT` lookup's answer: `None` sets no font, as `SetFont`
/// rejects the empty path (`0x44d05c`). `justify_v` seats the name by its ink: our shaper's Friz
/// line box is ascent-heavy and would drop it ~8 px below where the reference's metrics put it.
fn font_string(
model: &mut Model,
frame: FrameHandle,
justify_v: JustifyV,
font: Option<String>,
) -> RegionHandle {
let rh = model
.arena
.create_region(frame, RegionKind::FontString, DrawLayer::Overlay, 0)
.expect("live plate");
let data = model.region_data.entry(rh).or_default();
// The engine's own load (`0x7714f0`) clears no inherit bit, so a later `SetFontObject`
// repaints the plate's text as any FontString's.
data.font_path = font;
data.justify.set_v(justify_v);
rh
}
/// Whether a text's owner is a plate or a chat bubble. Their rects snap to device pixels as they
/// slide, and their text must follow that rect, not the UI's coarser grid, or it jumps. Asked of
/// the frame so an addon's strings on one get it too; a string on the plate's health bar does not.
pub(super) fn is_world_seated(model: &Model, frame: FrameHandle) -> bool {
model.nameplates.by_frame.contains_key(&frame) || super::chat_bubble::is_bubble(model, frame)
}
/// Whether this is the plate's own glow art on its ADD mode, which the quad walk
/// ([`super::extract`]) skips. Deviation: the reference adds this rim over the bar; we brighten
/// the bar ([`LIT_BOOST`]), because the rim reads as hard edge lines in our linear-blending
/// pipeline. The art has no alpha and is mostly black, so as BLEND it would be an opaque box.
pub(super) fn is_unpainted_glow(data: &RegionData) -> bool {
data.blend == BlendMode::Add && data.tex.path().as_deref() == Some(GLOW_TEXTURE)
}
/// The draw-time multiply of plate fill `rh`'s tint: [`LIT_BOOST`] while its plate is lit.
pub(super) fn lit_fill_boost(model: &Model, rh: RegionHandle) -> Option<f32> {
model
.nameplates
.lit_fills
.contains(&rh)
.then_some(LIT_BOOST)
}
/// The lit bar's brighten, a multiply of the fill tint in encoded sRGB like the client's modulate:
/// 255/215 takes the fill texture's encoded peak exactly to white, so no gradient row clips.
const LIT_BOOST: f32 = 255.0 / 215.0;