- a right-click on a nameplate selects and interacts again: the plate registers the right button, as the reference's does (2233) - the verb-events table files each fire site against its function's verified extent, and four rows it had misattributed are gone (2257) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
955 lines
48 KiB
Rust
955 lines
48 KiB
Rust
//! **The nameplate widgets** — `CGNamePlateFrame` as the frame-system object it actually is
|
||
//! (decision 2148; wow-re `system/ui/scratch/nameplate-lua-surface.md`, §5 eight-worker round +
|
||
//! orchestrator byte arbitration 2026-09-09, benilla dispatch).
|
||
//!
|
||
//! benilla drew V-plates for a year as quads pushed straight into the UI pass, which made them
|
||
//! invisible to Lua: a live run's `WorldFrame:GetChildren()` returned the two named FrameXML
|
||
//! frames and nothing else, and every 1.12 nameplate addon — all of which find plates by walking
|
||
//! exactly that list — found nothing to work with. The reference does the opposite, and this
|
||
//! module is that difference closed.
|
||
//!
|
||
//! ## What the reference builds, byte for byte
|
||
//!
|
||
//! The plate is a `CSimpleButton` subclass (ctor `0x7cb250`, `…\Ui\NamePlateFrame.cpp`, vtable
|
||
//! `0x81de50`, size `0x518`) whose **base constructor parents it to the `CGWorldFrame` singleton**
|
||
//! `ds:0xb4b2bc` (`0x7786a3` → `0x769318` → `SetParent 0x76ab10`, tail-appending at `0x76abdb`) —
|
||
//! so it is a real member of `[worldframe+0x300]`, returned by `GetChildren` and counted by
|
||
//! `GetNumChildren`, inheriting strata **0 (WORLD)** and taking level **1**.
|
||
//!
|
||
//! - **Identity:** `GetObjectType()` = `"Button"`, `IsObjectType` accepts Button/Frame/Region, and
|
||
//! `GetName()` is **nil** — `[this+0x98]` is zeroed at `0x76c50b` and only XML `name=` ever
|
||
//! writes it. Anonymity is load-bearing: `_Nameplates` and pfUI both reject a *named*
|
||
//! `WorldFrame` child.
|
||
//! - **Six regions, in creation order**: Border `0x7cb2c2`, Glow `0x7cb361`, Name `0x7cb3d7`,
|
||
//! Level `0x7cb479`, Skull `0x7cb512`, RaidIcon `0x7cb5b4`.
|
||
//! - **One child**, the health bar (`GetObjectType()` = `"StatusBar"`), whose own single region is
|
||
//! the BarFill texture `GetStatusBarTexture()` returns. **No cast bar exists in 1.12** — a second
|
||
//! child would be blanked by pfUI, which destructures `healthbar, castbar`.
|
||
//! - **Real anchors.** The ctor writes them through `0x767c70` — the *same* primitive and the same
|
||
//! `[layoutObj + 4 + 4·point]` storage the Lua `SetPoint` binding ends in — so `GetPoint`,
|
||
//! `GetLeft` and `GetWidth` answer truthfully. Name = `("BOTTOM", plate, "CENTER", 0, 0)`
|
||
//! (`0x7cb456`), Level = `("CENTER", plate, "BOTTOMRIGHT", …)` (`0x7cb7f5`); `_Nameplates`
|
||
//! *validates* both pairs and rejects a plate that fails them.
|
||
//! - **Levels, and this is the surprise:** the bar is put **below** the plate, not above it —
|
||
//! `0x7cb32e mov edx,[plate+0xc4]` (=1) · `0x7cb33c dec edx` · `0x7cb33e call 0x76a4f0(bar,0,0)`,
|
||
//! an absolute level that overrides what `SetParent` had just given it. Plate level 1, **bar
|
||
//! level 0**. Since the draw walk's layer loop is *outer* and its frame walk *inner* (`0x765920`:
|
||
//! layer counter `0x76593c`, frame walk `0x7659d0`–`0x7659f8`, `cmp eax,5` at `0x765a63`), the
|
||
//! whole bar frame drains before any plate layer — which is why the border's bevels cap the
|
||
//! fill's ends. The director pinned that look off a reference crop while `nameplate-vkey.md` §7
|
||
//! recorded the opposite order; the §5 round settled it in the director's favour and corrected
|
||
//! the note.
|
||
//! - **Layers** (`GetDrawLayer 0x79a6c0`, table `0x811a80`): Border ARTWORK, Glow **HIGHLIGHT**,
|
||
//! Name/Level/Skull OVERLAY (the ctor re-layers Name and Level off ARTWORK at `0x7cb438`/
|
||
//! `0x7cb4ea`), RaidIcon ARTWORK, BarFill ARTWORK on the bar. **No sub-level exists in 5875**;
|
||
//! within a layer it is creation order.
|
||
//! - **Scripts:** the engine installs none, and every C++ override tails into the base
|
||
//! unconditionally, so an addon's `SetScript` on OnShow/OnHide/OnUpdate/OnEnter/OnLeave/
|
||
//! OnMouseDown/OnMouseUp/OnClick fires. `RegisterForClicks(0x500)` = Left/Right on mouse-**up**.
|
||
//! - **Pooling:** destroy (`0x608a10`) never unparents — it hides and pushes onto a free list, and
|
||
//! reuse re-runs neither the ctor nor `SetParent`. So **the child-list index is stable for the
|
||
//! session**, and a retired plate is still returned *and counted* (the list walk has no
|
||
//! visibility filter). Every corpus addon's `registry[plate]` identity map rests on that.
|
||
//!
|
||
//! ## The consumer contract this has to keep
|
||
//!
|
||
//! Four independent 1.12 addons (`_Nameplates`, `CustomNameplates`, pfUI's vanilla branch,
|
||
//! ShaguTweaks' vanilla branch — all `## Interface: 11200`) destructure the two tuples
|
||
//! *positionally* and identify a plate by `regions[1]:GetTexture()` being exactly
|
||
//! `Interface\Tooltips\Nameplate-Border`. The byte-derived order and the corpus's agree element for
|
||
//! element — an independent empirical control on a byte derivation. So the composition is not ours
|
||
//! to choose; it is an ABI, and the tests transcribe each addon's own walk.
|
||
//!
|
||
//! **An addon takes the plate over**, which is the half a painter never had to respect: pfUI blanks
|
||
//! all six regions and paints its own, sets `SetAlpha(.95)` on non-targets itself, and ShaguTweaks
|
||
//! reparents the raid icon. So [`Plate::create`] writes the static properties (paths, fonts,
|
||
//! layers) exactly **once**, [`Plate::lay_out`] rewrites the window-derived anchors only when the
|
||
//! window moves, and [`Plate::drive`] writes only what actually changed this frame. The one
|
||
//! deliberate exception is the plate's own anchor, which the reference itself re-issues every dirty
|
||
//! frame from the seat `0x509ec0` (`ClearAllPoints` + `SetPoint`), wiping an addon anchor *on the
|
||
//! plate* there too — which is why every corpus addon anchors its overlay **to** the plate and
|
||
//! re-anchors the **bar**, never the plate.
|
||
|
||
use crate::layout::{Anchor, 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 identification test every corpus addon runs, which is why it is a
|
||
/// constant here rather than a caller's argument.
|
||
pub const BORDER_TEXTURE: &str = "Interface\\Tooltips\\Nameplate-Border";
|
||
/// Region 2's. `glow:IsShown()` is how pfUI reads *mouseover* (`nameplates.lua:601`).
|
||
pub const GLOW_TEXTURE: &str = "Interface\\Tooltips\\Nameplate-Glow";
|
||
/// Region 5's — the boss / out-of-range skull (`levelicon` to pfUI, `Boss` to CustomNameplates).
|
||
pub const SKULL_TEXTURE: &str = "Interface\\TargetingFrame\\UI-TargetingFrame-Skull";
|
||
/// Region 6's 4-column atlas.
|
||
pub const RAID_ICON_TEXTURE: &str = "Interface\\TargetingFrame\\UI-RaidTargetingIcons";
|
||
/// The health bar's fill — what `healthbar:GetStatusBarTexture()` hands back.
|
||
pub const BAR_FILL_TEXTURE: &str = "Interface\\TargetingFrame\\UI-TargetingFrame-BarFill";
|
||
/// `NAMEPLATE_FONT` — Friz Quadrata, the plate's own face.
|
||
pub const PLATE_FONT: &str = "Fonts\\FRIZQT__.TTF";
|
||
|
||
/// The plate's frame level, and the bar's — `plate.level − 1`, absolute and clamped ≥ 0
|
||
/// (`0x7cb32e`/`0x7cb33c`/`0x7cb33e` → `0x76a4f0`). The pair is what puts the border over the fill.
|
||
const PLATE_LEVEL: u16 = 1;
|
||
const BAR_LEVEL: u16 = 0;
|
||
|
||
/// Everything about a plate's geometry that follows from the *window* rather than from the unit,
|
||
/// in FrameXML units (the driver has already divided out the seam scale).
|
||
///
|
||
/// Applied to every pooled plate at once, and only when it changes — these are exactly the writes
|
||
/// that must not happen per frame, because an addon that re-anchors the bar would otherwise have
|
||
/// its anchor stamped over at 60 Hz.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct PlateGeometry {
|
||
/// The plate frame's own size.
|
||
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 / skull seat, from the plate's BOTTOMRIGHT (x is subtracted, y added).
|
||
pub level_off_x: f32,
|
||
pub level_off_y: f32,
|
||
/// The skull's square side.
|
||
pub skull_size: f32,
|
||
/// The raid icon's square side; it hangs off the plate's LEFT edge, vertically centred.
|
||
pub raid_size: f32,
|
||
/// Font heights for the name and the level.
|
||
pub name_height: f32,
|
||
pub level_height: f32,
|
||
/// The plate text's drop-shadow step, in the same FrameXML units — **the driver divides the
|
||
/// seam out of it like everything else here**, because the director tuned it as a constant
|
||
/// ONE LOGICAL PIXEL (2026-07-07) and the shared text arm multiplies whatever it finds by the
|
||
/// seam on the way out.
|
||
///
|
||
/// Left as a bare `1.0` unit by the widget port, it became `round(windowH/768 × uiScale)`
|
||
/// drawn px — 2 px from a 1152-tall window up, 3 px at 2160 — which is exactly the "chunky"
|
||
/// the recorded ±0.001 gx law was tuned away from, and it made the shadow the one part of a
|
||
/// plate that was not uiScale-blind.
|
||
pub shadow_offset: f32,
|
||
}
|
||
|
||
/// One plate's per-frame state, as the app hands it over: everything that can move while a plate is
|
||
/// alive. Anything that cannot is written once, at creation.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct PlateState {
|
||
/// The unit this plate belongs to — its GUID, and **the plate's identity**.
|
||
///
|
||
/// The reference binds a plate to its unit for as long as the plate lives (`[unit+0xe60]`
|
||
/// holds the pointer; the free list hands out a plate at create and takes it back at destroy),
|
||
/// and every corpus addon depends on that: pfUI caches the name, level, colour and its own
|
||
/// overlay ON the frame (`registry[plate]`, `nameplate.original.*`) and only refreshes what
|
||
/// its `OnDataChanged` says moved. Handing widget *i* to whichever unit happens to sort i-th
|
||
/// this frame would make every plate's cached state wrong on the frame two units swap places —
|
||
/// which is why the pool is keyed by this rather than indexed by the driver's sort order.
|
||
pub key: u64,
|
||
/// Where the plate's **TOP-CENTRE** lands, in FrameXML units from the screen's bottom-left —
|
||
/// the reference's own seat (`nameplate-vkey.md` §8.5: the plate hangs below the point).
|
||
pub top_centre: (f32, f32),
|
||
/// `UNIT_FIELD_HEALTH` and `UNIT_FIELD_MAXHEALTH`, **raw**: `healthbar:GetValue()` is
|
||
/// `[bar+0x320]` (`0x78f5d0`), not a fraction, and `GetMinMaxValues()` answers `(0, max)`.
|
||
/// pfUI and CustomNameplates both divide, so a fraction here would read as 1 hp out of 1.
|
||
pub health: f32,
|
||
pub max_health: f32,
|
||
/// The bar's reaction colour — the four confirmed dwords. Addons reverse-engineer the unit's
|
||
/// faction from `GetStatusBarColor`, so these have to be exact.
|
||
pub bar_colour: [f32; 3],
|
||
/// The unit's name.
|
||
pub name: String,
|
||
/// The level number to show, or `None` when the unit has no `UNIT_FIELD_LEVEL` yet.
|
||
///
|
||
/// **`None` is not the skull** — that is [`Self::skull`], and conflating them is what made a
|
||
/// unit whose level had not arrived wear a world boss's skull. The old painter kept the whole
|
||
/// level/skull block inside `if let Some(level)`, so no level meant neither.
|
||
pub level: Option<u32>,
|
||
/// Show the SKULL in the level's seat instead of a number — the boss / far-out-of-range mark.
|
||
pub skull: bool,
|
||
/// The level text's con colour.
|
||
pub level_colour: [f32; 3],
|
||
/// The raid-target mark, 0-based (`col = idx & 3`, `row = idx >> 2`), or `None`.
|
||
pub raid_icon: Option<u8>,
|
||
/// The plate's own alpha: **exactly 1.0 for the target**, dimmed otherwise. `GetAlpha() == 1`
|
||
/// is how pfUI and CustomNameplates detect the target plate — a contract, not a look.
|
||
pub alpha: f32,
|
||
/// Mouseover ∪ target: the bar's brighten.
|
||
pub lit: bool,
|
||
/// The plate rect is hovered right now — the name goes yellow (`0x7cb850` OnEnter, name-only)
|
||
/// and the glow region is shown, which is the mouseover signal addons read.
|
||
pub hovered: bool,
|
||
}
|
||
|
||
/// One pooled plate's widgets, in the reference's creation order.
|
||
#[derive(Debug)]
|
||
struct Plate {
|
||
frame: FrameHandle,
|
||
border: RegionHandle,
|
||
glow: RegionHandle,
|
||
name: RegionHandle,
|
||
level: RegionHandle,
|
||
skull: RegionHandle,
|
||
raid: RegionHandle,
|
||
bar: FrameHandle,
|
||
fill: RegionHandle,
|
||
/// Last frame's state, so [`Plate::drive`] writes only what moved. `None` = retired.
|
||
last: Option<PlateState>,
|
||
/// **The geometry this plate's anchors were written under**, or `None` while it has never been
|
||
/// laid out — the fix for a plate that was born invisible.
|
||
///
|
||
/// [`Plate::lay_out`] used to be driven by one per-frame `relayout` flag ("did the window
|
||
/// move?"), which is true exactly once per window size. A plate the pool grew on any LATER
|
||
/// frame — a unit walking into range with no free slot — therefore kept `width: 0`, `height: 0`
|
||
/// and no region anchors, resolved to nothing, and drew nothing for the rest of the session.
|
||
/// The number of plates on screen was capped at however many were up on the frame V was first
|
||
/// pressed. Per-plate state instead of a per-frame flag makes the condition unfalsifiable: a
|
||
/// plate whose anchors are not the current geometry's gets them, whatever the reason.
|
||
laid_out: Option<PlateGeometry>,
|
||
}
|
||
|
||
impl Plate {
|
||
/// The unit this plate is currently bound to, or `None` while it sits in the pool.
|
||
fn key(&self) -> Option<u64> {
|
||
self.last.as_ref().map(|s| s.key)
|
||
}
|
||
}
|
||
|
||
/// The plate pool — grown on demand, **never shrunk**, in creation order.
|
||
///
|
||
/// The reference's pool is unbounded and its free list FIFO; what matters to an addon is only that
|
||
/// an index, once handed out, keeps meaning the same Lua object, and that a retired plate stays in
|
||
/// the child list (hidden). pfUI and ShaguTweaks scan `initialized + 1 .. GetNumChildren()` and
|
||
/// never reset `initialized`, so a client that reordered or compacted this list would make them
|
||
/// miss every plate created afterwards — permanently, and silently.
|
||
#[derive(Debug, Default)]
|
||
pub(crate) struct NamePlates {
|
||
plates: Vec<Plate>,
|
||
/// Which pool slot each live unit holds, by [`PlateState::key`] — the reference's
|
||
/// `[unit+0xe60]`, from this side. A slot leaves this map only when its unit's plate is
|
||
/// retired, and the slot itself never moves.
|
||
assigned: std::collections::HashMap<u64, usize>,
|
||
/// The reverse of the arena's own structure: which slot a frame handle is. Filled at
|
||
/// creation and never removed (plates are never destroyed), so the pointer boundary and the
|
||
/// click funnel can ask "is this frame a plate, and whose?" in one lookup.
|
||
by_frame: std::collections::HashMap<FrameHandle, usize>,
|
||
/// Completed clicks on plates, drained by the app each frame
|
||
/// ([`UiScript::take_nameplate_clicks`]).
|
||
clicks: Vec<NamePlateClick>,
|
||
/// **The plate's own hit-test veto** — `0x7cba30`, the `+0x3c` override, set while a
|
||
/// ground-targeted spell is armed ([`UiScript::set_nameplate_hit_test_veto`]).
|
||
///
|
||
/// Deliberately NOT the mouse-enabled bit: the reference has two distinct mechanisms here and
|
||
/// only one of them is visible from Lua. `0x60f830` (freelook) toggles input kind 2 on every
|
||
/// plate, so `IsMouseEnabled()` answers `false` — while `0x7cba30` refuses the *hit test*
|
||
/// before testing the rect and never touches the bit, so an addon asking a plate whether it
|
||
/// takes the mouse during ground targeting is told `true`, truthfully. Folding the two into
|
||
/// one flag would have been simpler here and wrong there.
|
||
hit_test_vetoed: bool,
|
||
/// The geometry every live plate is currently laid out under.
|
||
geometry: Option<PlateGeometry>,
|
||
}
|
||
|
||
impl NamePlates {
|
||
/// Does the `0x7cba30` veto refuse `frame` this point? True only for a plate, and only while
|
||
/// the veto stands.
|
||
pub(crate) fn vetoes(&self, frame: FrameHandle) -> bool {
|
||
self.hit_test_vetoed && self.by_frame.contains_key(&frame)
|
||
}
|
||
}
|
||
|
||
/// A completed click on a plate — what the reference's own `CGNamePlateFrame` click slot
|
||
/// (`0x7cb910`) turns into a selection.
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct NamePlateClick {
|
||
/// The unit whose plate was clicked ([`PlateState::key`]).
|
||
pub key: u64,
|
||
/// `"LeftButton"` / `"RightButton"` — the plate registers for both, on mouse-UP only
|
||
/// (`RegisterForClicks(0x500)` at `0x7cb637`).
|
||
pub button: String,
|
||
}
|
||
|
||
/// What a sync produced that has to be fired **outside** the model borrow: the frames whose
|
||
/// effective visibility flipped (their `OnShow`/`OnHide`), and the bars whose value moved (their
|
||
/// `OnValueChanged`, which pfUI hooks — `nameplates.lua:393` — and reads `this:GetParent()` in).
|
||
#[derive(Default)]
|
||
struct SyncEffects {
|
||
visibility: Vec<FrameHandle>,
|
||
values: Vec<(FrameHandle, f32)>,
|
||
}
|
||
|
||
impl UiScript {
|
||
/// Drive the plate pool from the app's per-frame verdict: `states[i]` is the i-th **shown**
|
||
/// plate, and every pooled plate past `states.len()` is retired (hidden, never destroyed).
|
||
///
|
||
/// `geometry` is the window-derived half; passing an unchanged value costs nothing.
|
||
pub fn sync_nameplates(&mut self, geometry: PlateGeometry, states: &[PlateState]) {
|
||
let lua = self.lua();
|
||
let effects = {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
// No WorldFrame, no plates: a VM with no interface loaded (a bare test app, the addon
|
||
// harness) has nowhere to parent them, and inventing a root would put plates somewhere
|
||
// an addon's walk could never find them anyway.
|
||
let Some(world) = model.arena.lookup("WorldFrame") else {
|
||
return;
|
||
};
|
||
sync(&mut model, world, geometry, states)
|
||
};
|
||
super::event::fire_visibility_changes(lua, effects.visibility);
|
||
for (bar, value) in effects.values {
|
||
super::statusbar::fire_engine_value_changed(lua, bar, value);
|
||
}
|
||
}
|
||
|
||
/// **The unit whose plate the pointer is inside**, or `None` — the reference's plate OnEnter
|
||
/// (`0x7cb850`) publishing the mouseover global `[0xb4e2c8]`, read from the app's side instead
|
||
/// of pushed.
|
||
///
|
||
/// The app needs this because a plate is real UI here: with the plate mouse-enabled, the UI
|
||
/// pointer pass owns the cursor over it and `target::hover`'s world pick correctly stands
|
||
/// down — so the mouseover has to come from the frame that took it. Exactly the hovered frame,
|
||
/// never an ancestor: an addon frame parented to a plate takes the hover in the reference too,
|
||
/// and its OnEnter is the one that fires.
|
||
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()
|
||
}
|
||
|
||
/// Drain the completed plate clicks — the app turns each into a selection, which is what the
|
||
/// reference's own click slot does. Both a physical click and Lua's `plate:Click("LeftButton")`
|
||
/// arrive here, because both go through the one click funnel.
|
||
pub fn take_nameplate_clicks(&mut self) -> Vec<NamePlateClick> {
|
||
let lua = self.lua();
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
std::mem::take(&mut model.nameplates.clicks)
|
||
}
|
||
|
||
/// **The mouselook toggle** (`0x60f830`): entering camera freelook disables mouse input on
|
||
/// every plate, leaving re-enables it — the reference walks its own intrusive plate list at
|
||
/// `ds:0xc4d92c` doing exactly this, called from `0x483e80` (enter) and `0x483e70` (leave).
|
||
///
|
||
/// Without it a right-drag that starts over a plate would be a plate click instead of a camera
|
||
/// turn, and the plates would keep taking a pointer that is no longer on screen.
|
||
pub fn set_nameplate_mouse(&mut self, enabled: bool) {
|
||
let lua = self.lua();
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
let frames: Vec<FrameHandle> = model.nameplates.plates.iter().map(|p| p.frame).collect();
|
||
for frame in frames {
|
||
model.arena.set_mouse_enabled(frame, enabled);
|
||
}
|
||
}
|
||
|
||
/// **The plate hit-test veto** (`0x7cba30`, the plate's `+0x3c` override — the one of the six
|
||
/// that can refuse *before* testing the rect): while a ground-targeted spell is armed the
|
||
/// plates stop taking the mouse and the click falls through to the `WorldFrame`, so the reticle
|
||
/// can be placed through a plate.
|
||
///
|
||
/// The reference's predicate is `IsTargeting() && TargetingWantsLocation(flag & 0x60) &&
|
||
/// !0x6e6180()`. The caller supplies the first two verbatim — benilla's `TargetingWants::
|
||
/// Location` **is** that `& 0x60` mask. The third is another mask predicate over the same
|
||
/// pending-spell word (`& 0x878e`, per wow-re `item-target-cursor-and-dropitemonunit.md`) whose
|
||
/// meaning is not settled; a word that reaches our targeting cursor with location bits is a
|
||
/// pure ground target (the resolver binds or refuses a unit word before then), so it is left
|
||
/// unmodelled rather than guessed at.
|
||
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** — hide them all and return them to the pool, without touching
|
||
/// the geometry they are laid out under.
|
||
///
|
||
/// The app's every early return owes this: a painter that stopped drawing left no plates
|
||
/// behind, but widgets stay until something hides them, so a V press that turns plates off, a
|
||
/// frame with no camera, or a world exit would otherwise leave the last frame's plates standing
|
||
/// on screen. It is `0x608a10` applied to the whole active list, which is what the reference
|
||
/// does when the master toggle clears.
|
||
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
|
||
};
|
||
super::event::fire_visibility_changes(lua, effects.visibility);
|
||
}
|
||
|
||
/// How many plate widgets the pool holds — the plate driver's census, and the tests'.
|
||
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()
|
||
}
|
||
}
|
||
|
||
/// Record a completed click on `id` if that frame is a live plate — [`super::button::click_button`]
|
||
/// calls this for every button click, physical or scripted.
|
||
pub(super) fn note_click(lua: &mlua::Lua, id: u32, button: &str) {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
let Some(frame) = model.id_to_frame.get(&id).copied() else {
|
||
return;
|
||
};
|
||
let Some(&slot) = model.nameplates.by_frame.get(&frame) else {
|
||
return;
|
||
};
|
||
// A pooled (retired) plate has no unit, and clicking one is not possible anyway — it is hidden.
|
||
let Some(key) = model.nameplates.plates.get(slot).and_then(Plate::key) else {
|
||
return;
|
||
};
|
||
model.nameplates.clicks.push(NamePlateClick {
|
||
key,
|
||
button: button.to_string(),
|
||
});
|
||
}
|
||
|
||
/// The whole per-frame drive: grow, re-lay-out if the window moved, write what changed, retire the
|
||
/// tail.
|
||
fn sync(
|
||
model: &mut Model,
|
||
world: FrameHandle,
|
||
geometry: PlateGeometry,
|
||
states: &[PlateState],
|
||
) -> SyncEffects {
|
||
let mut effects = SyncEffects::default();
|
||
let relayout = model.nameplates.geometry != Some(geometry);
|
||
model.nameplates.geometry = Some(geometry);
|
||
|
||
// Retire first, so a unit that left this frame frees its slot for one that arrived — the
|
||
// reference's destroy-then-create through the same 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);
|
||
}
|
||
}
|
||
|
||
for state in states {
|
||
let slot = match model.nameplates.assigned.get(&state.key) {
|
||
Some(&i) => i,
|
||
None => {
|
||
// The lowest free slot, else a new one at the tail. Lowest-free rather than the
|
||
// reference's FIFO free list: both keep every existing index meaning what it did,
|
||
// which is the only property an addon can observe.
|
||
let free = (0..model.nameplates.plates.len())
|
||
.find(|i| model.nameplates.plates[*i].last.is_none());
|
||
let i = free.unwrap_or_else(|| {
|
||
let plate = Plate::create(model, world, &mut effects);
|
||
model.nameplates.plates.push(plate);
|
||
model.nameplates.plates.len() - 1
|
||
});
|
||
model.nameplates.assigned.insert(state.key, i);
|
||
i
|
||
}
|
||
};
|
||
if model.nameplates.plates[slot].laid_out != Some(geometry) {
|
||
Plate::lay_out(model, slot, geometry);
|
||
}
|
||
Plate::drive(model, slot, world, state, &mut effects);
|
||
}
|
||
|
||
// A geometry change reaches the retired plates too: they are still in the child list, an addon
|
||
// can still read them, and they must not come back next frame laid out for the old window.
|
||
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 under the WorldFrame, its six regions in the reference's
|
||
/// creation order, and the health-bar child with its fill.
|
||
///
|
||
/// Everything here is written **once**. An addon that blanks a region (pfUI's `DisableObject`
|
||
/// does `SetTexture("")`) must find it still blank on the next frame.
|
||
fn create(model: &mut Model, world: FrameHandle, effects: &mut SyncEffects) -> Plate {
|
||
// Anonymous, and that is a requirement rather than an omission (module doc).
|
||
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);
|
||
// The plate takes the mouse, which it is born with: `CSimpleButton`'s ctor writes
|
||
// `[+0xcc] = 0x4` (`0x7786a3`), so the reference's plate needs no `EnableMouse` of its own
|
||
// and neither does ours. Hovering it publishes the mouseover (`0x7cb850` → `0x492890`) and
|
||
// a completed click selects through the button's click slot (`0x7cb910`); both reach the
|
||
// app through [`UiScript::hovered_nameplate`] and [`UiScript::take_nameplate_clicks`].
|
||
// `EnableMouse`/`IsMouseEnabled` are in the corpus's own call set, and pfUI's vanilla
|
||
// click-through block drives them.
|
||
//
|
||
// **And it takes BOTH buttons, on the UP edge**: `RegisterForClicks(0x500)` at `0x7cb637`
|
||
// (`0x779730` → `[this+0x330] = 0x500` = LeftButtonUp | RightButtonUp). The arena's Button
|
||
// default is `{"LeftButtonUp"}` alone, so without this a physical right-click on a plate
|
||
// fired no click at all — [`super::button::wants_click`] refused the release, the funnel
|
||
// never ran, and since 2233 took the camera off a press that lands on a plate there was no
|
||
// other path left: right-clicking a nameplate did nothing. The reference's own slot
|
||
// (`0x7cb910`) takes mask 1 → `0x4925d0` select and mask 4 → `0x492820` select+interact.
|
||
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 blend modes are the ctor's own, one `0x7703f0` call per texture (wow-re
|
||
// `nameplate-vkey.md` §8.1, byte-arbitrated): everything is BLEND(2) except the **glow,
|
||
// which is ADD(3)** (`push 3` at `0x7cb36a` → `0x7cb374`). Getting that one wrong is not a
|
||
// subtlety — see [`is_unpainted_glow`].
|
||
let border = texture(
|
||
model,
|
||
frame,
|
||
DrawLayer::Artwork,
|
||
BORDER_TEXTURE,
|
||
BlendMode::Blend,
|
||
);
|
||
let glow = texture(
|
||
model,
|
||
frame,
|
||
DrawLayer::Highlight,
|
||
GLOW_TEXTURE,
|
||
BlendMode::Add,
|
||
);
|
||
let name = font_string(model, frame, JustifyV::Bottom);
|
||
let level = font_string(model, frame, JustifyV::Middle);
|
||
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. Six regions, and this sequence is the ABI (module doc).
|
||
|
||
// The one child, below the plate's own 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);
|
||
let fill = model
|
||
.arena
|
||
.create_region(bar, RegionKind::Texture, DrawLayer::Artwork, 0)
|
||
.expect("live bar");
|
||
let fill_data = model.region_data.entry(fill).or_default();
|
||
fill_data.texture = Some(BAR_FILL_TEXTURE.to_string());
|
||
// Not overridden by the ctor — the `CSimpleTexture` ctor's own `[+0xd0] = 2` stands
|
||
// (`0x76fc40`; vkey §8.1). Written explicitly because the default is a FACT here, not an
|
||
// omission.
|
||
fill_data.blend = BlendMode::Blend;
|
||
if let Some(KindState::StatusBar(sb)) =
|
||
model.arena.frame_mut(bar).map(|f| &mut f.kind_state)
|
||
{
|
||
sb.bar = Some(fill);
|
||
sb.min = 0.0;
|
||
sb.max = 1.0;
|
||
sb.value = 0.0;
|
||
}
|
||
|
||
// The skull and the raid icon are the two regions that only sometimes show; the glow is
|
||
// shown on hover alone. The rest are always up while the plate is.
|
||
for rh in [glow, skull, raid] {
|
||
model.region_data.entry(rh).or_default().hidden = true;
|
||
}
|
||
|
||
// Born retired: a fresh plate belongs to no unit yet, and `IsShown()` is what an addon
|
||
// reads to tell a live plate from a pooled one.
|
||
effects
|
||
.visibility
|
||
.extend(model.arena.set_shown(frame, false));
|
||
|
||
model
|
||
.nameplates
|
||
.by_frame
|
||
.insert(frame, model.nameplates.plates.len());
|
||
|
||
Plate {
|
||
frame,
|
||
border,
|
||
glow,
|
||
name,
|
||
level,
|
||
skull,
|
||
raid,
|
||
bar,
|
||
fill,
|
||
last: None,
|
||
laid_out: None,
|
||
}
|
||
}
|
||
|
||
/// (Re)lay the static anchors — everything that follows from the window's size rather than the
|
||
/// unit. Called on the first drive and on a geometry change, never per frame.
|
||
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 own size; its position is per-frame and lives in `drive`.
|
||
let input = model.layout_inputs.entry(frame).or_default();
|
||
input.width = g.width;
|
||
input.height = g.height;
|
||
|
||
// The border fills the plate, and so does the glow — a rim over the whole frame.
|
||
for rh in [border, glow] {
|
||
model.region_data.entry(rh).or_default().anchors = vec![
|
||
Anchor::new(Point::TopLeft, plate_id, Point::TopLeft, 0.0, 0.0),
|
||
Anchor::new(Point::BottomRight, plate_id, Point::BottomRight, 0.0, 0.0),
|
||
];
|
||
}
|
||
|
||
// The health bar: BOTTOMLEFT + (off_x, off_y), explicit size.
|
||
let bar_input = model.layout_inputs.entry(bar).or_default();
|
||
bar_input.anchors = vec![Anchor::new(
|
||
Point::BottomLeft,
|
||
plate_id,
|
||
Point::BottomLeft,
|
||
g.bar_off_x,
|
||
g.bar_off_y,
|
||
)];
|
||
bar_input.width = g.bar_width;
|
||
bar_input.height = g.bar_height;
|
||
|
||
// The name — ("BOTTOM", plate, "CENTER", 0, 0), the pair `_Nameplates` validates.
|
||
let name_data = model.region_data.entry(name).or_default();
|
||
name_data.anchors = vec![Anchor::new(
|
||
Point::Bottom,
|
||
plate_id,
|
||
Point::Center,
|
||
0.0,
|
||
0.0,
|
||
)];
|
||
name_data.font_height = Some(g.name_height);
|
||
|
||
// The level — ("CENTER", plate, "BOTTOMRIGHT", −x, +y), the other validated pair.
|
||
for rh in [name, level] {
|
||
model.region_data.entry(rh).or_default().font_shadow = Some(FontShadow {
|
||
offset: [g.shadow_offset, -g.shadow_offset],
|
||
color: [0.0, 0.0, 0.0, 1.0],
|
||
});
|
||
}
|
||
|
||
let level_data = model.region_data.entry(level).or_default();
|
||
level_data.anchors = vec![Anchor::new(
|
||
Point::Center,
|
||
plate_id,
|
||
Point::BottomRight,
|
||
-g.level_off_x,
|
||
g.level_off_y,
|
||
)];
|
||
level_data.font_height = Some(g.level_height);
|
||
level_data.font_explicit.height = true;
|
||
model.touch_measure(level);
|
||
|
||
// The skull replaces the level number, in the level's own seat.
|
||
let skull_data = model.region_data.entry(skull).or_default();
|
||
skull_data.anchors = vec![Anchor::new(
|
||
Point::Center,
|
||
plate_id,
|
||
Point::BottomRight,
|
||
-g.level_off_x,
|
||
g.level_off_y,
|
||
)];
|
||
skull_data.size = Some((g.skull_size, g.skull_size));
|
||
|
||
// The raid icon hangs off the LEFT edge, vertically centred.
|
||
let raid_data = model.region_data.entry(raid).or_default();
|
||
raid_data.anchors = vec![Anchor::new(Point::Right, plate_id, Point::Left, 0.0, 0.0)];
|
||
raid_data.size = Some((g.raid_size, g.raid_size));
|
||
|
||
// Anchor TARGETS moved (from nothing to the plate) on the first lay-out, so this is the
|
||
// conservative touch — the only one in this module, and it runs once per geometry change.
|
||
model.touch_layout();
|
||
model.nameplates.plates[i].laid_out = Some(g);
|
||
}
|
||
|
||
/// The per-frame write: position, health, texts, colours, shown flags, alpha — **and nothing
|
||
/// else**, each only when it moved.
|
||
fn drive(
|
||
model: &mut Model,
|
||
i: usize,
|
||
world: FrameHandle,
|
||
state: &PlateState,
|
||
effects: &mut SyncEffects,
|
||
) {
|
||
let p = &model.nameplates.plates[i];
|
||
let (frame, bar) = (p.frame, p.bar);
|
||
let (glow, name, level, skull, raid, fill) =
|
||
(p.glow, p.name, p.level, p.skull, p.raid, p.fill);
|
||
let last = p.last.clone();
|
||
let world_id = model.frame_id(world);
|
||
|
||
// The seat, re-issued whenever it moves, exactly like the reference's own (`0x509ec0`:
|
||
// ClearAllPoints then SetPoint) — which is why an addon anchor on the plate does not
|
||
// survive there either. A moved OFFSET keeps the graph's edges, so the precise touch
|
||
// applies; the first seat adds an edge and takes the retarget.
|
||
if last
|
||
.as_ref()
|
||
.is_none_or(|l| l.top_centre != state.top_centre)
|
||
{
|
||
let fresh = model
|
||
.layout_inputs
|
||
.get(&frame)
|
||
.is_none_or(|i| i.anchors.is_empty());
|
||
let input = model.layout_inputs.entry(frame).or_default();
|
||
input.anchors = vec![Anchor::new(
|
||
Point::Top,
|
||
world_id,
|
||
Point::BottomLeft,
|
||
state.top_centre.0,
|
||
state.top_centre.1,
|
||
)];
|
||
if fresh {
|
||
model.touch_layout_retarget_frame(frame, &[], &[world_id]);
|
||
} else {
|
||
model.touch_layout_frame(frame);
|
||
}
|
||
}
|
||
|
||
if last.is_none() {
|
||
effects
|
||
.visibility
|
||
.extend(model.arena.set_shown(frame, true));
|
||
}
|
||
if last.as_ref().is_none_or(|l| l.alpha != state.alpha) {
|
||
model.arena.set_alpha(frame, state.alpha);
|
||
}
|
||
|
||
// The bar reports RAW health (module doc). Its fill carries the reaction colour, lifted by
|
||
// a uniform brighten while the unit is the mouseover or the target — 0184's form of the
|
||
// highlight, in place of the additive `Nameplate-Glow` rim.
|
||
if last
|
||
.as_ref()
|
||
.is_none_or(|l| l.health != state.health || l.max_health != state.max_health)
|
||
{
|
||
if let Some(KindState::StatusBar(sb)) =
|
||
model.arena.frame_mut(bar).map(|f| &mut f.kind_state)
|
||
{
|
||
sb.min = 0.0;
|
||
sb.max = state.max_health;
|
||
sb.value = state.health.clamp(0.0, state.max_health);
|
||
}
|
||
effects.values.push((bar, state.health));
|
||
}
|
||
if last
|
||
.as_ref()
|
||
.is_none_or(|l| l.bar_colour != state.bar_colour || l.lit != state.lit)
|
||
{
|
||
let boost = if state.lit { LIT_BOOST } else { 1.0 };
|
||
let c = state.bar_colour;
|
||
model.region_data.entry(fill).or_default().vertex_color =
|
||
Some([c[0] * boost, c[1] * boost, c[2] * boost, 1.0]);
|
||
}
|
||
|
||
// The name: white, yellow while the plate itself is hovered.
|
||
if last.as_ref().is_none_or(|l| l.name != state.name) {
|
||
model.region_data.entry(name).or_default().text = Some(state.name.clone());
|
||
model.touch_measure(name);
|
||
model.touch_layout_region(name);
|
||
}
|
||
if last.as_ref().is_none_or(|l| l.hovered != state.hovered) {
|
||
let c = if state.hovered {
|
||
[1.0, 1.0, 0.0, 1.0]
|
||
} else {
|
||
[1.0, 1.0, 1.0, 1.0]
|
||
};
|
||
model.region_data.entry(name).or_default().vertex_color = Some(c);
|
||
// `glow:IsShown()` IS the mouseover signal (pfUI `nameplates.lua:601`). The region is
|
||
// shown for real; that it paints nothing is 0184's deviation, not a missing region.
|
||
model.region_data.entry(glow).or_default().hidden = !state.hovered;
|
||
}
|
||
|
||
// Level text and skull are mutually exclusive, in one seat.
|
||
if last.as_ref().is_none_or(|l| {
|
||
l.level != state.level || l.skull != state.skull || l.level_colour != state.level_colour
|
||
}) {
|
||
match (state.level, state.skull) {
|
||
// The skull wins the seat, and it is the SKULL FLAG that puts it there.
|
||
(_, true) => {
|
||
model.region_data.entry(level).or_default().hidden = true;
|
||
model.region_data.entry(skull).or_default().hidden = false;
|
||
}
|
||
(Some(n), false) => {
|
||
let c = state.level_colour;
|
||
let data = model.region_data.entry(level).or_default();
|
||
data.text = Some(n.to_string());
|
||
data.vertex_color = Some([c[0], c[1], c[2], 1.0]);
|
||
data.hidden = false;
|
||
model.region_data.entry(skull).or_default().hidden = true;
|
||
model.touch_measure(level);
|
||
model.touch_layout_region(level);
|
||
}
|
||
// No level yet and no skull: the seat stays EMPTY, the old painter's behaviour.
|
||
(None, false) => {
|
||
model.region_data.entry(level).or_default().hidden = true;
|
||
model.region_data.entry(skull).or_default().hidden = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// The raid mark: the 4-column atlas cell, shown only while the unit carries one.
|
||
if last.as_ref().is_none_or(|l| l.raid_icon != state.raid_icon) {
|
||
match state.raid_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]));
|
||
data.hidden = false;
|
||
}
|
||
None => model.region_data.entry(raid).or_default().hidden = true,
|
||
}
|
||
}
|
||
|
||
model.nameplates.plates[i].last = Some(state.clone());
|
||
}
|
||
|
||
/// Retire a plate: **hide it, never destroy it** (`0x608a10` — unlink from the active list,
|
||
/// push onto the free list, `Hide`). It keeps its slot in `WorldFrame`'s child list, keeps its
|
||
/// index, and comes back as the same Lua object, which is the contract every addon's
|
||
/// `registry[plate]` identity map rests on.
|
||
fn retire(model: &mut Model, i: usize, effects: &mut SyncEffects) {
|
||
if model.nameplates.plates[i].last.is_none() {
|
||
return;
|
||
}
|
||
let frame = model.nameplates.plates[i].frame;
|
||
effects
|
||
.visibility
|
||
.extend(model.arena.set_shown(frame, false));
|
||
model.nameplates.plates[i].last = None;
|
||
}
|
||
}
|
||
|
||
/// One of the plate's texture regions, with its path and its blend mode — created in the ABI's
|
||
/// order by the caller. The mode is not decoration: the ctor sets one on every texture it makes
|
||
/// (`0x7703f0`, storing `[texture+0xd0]`), and the glow's is the one that differs — see
|
||
/// [`is_unpainted_glow`].
|
||
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");
|
||
let data = model.region_data.entry(rh).or_default();
|
||
data.texture = Some(path.to_string());
|
||
data.blend = blend;
|
||
rh
|
||
}
|
||
|
||
/// One of the plate's two FontStrings. Both are OVERLAY — the ctor re-layers them off ARTWORK
|
||
/// (`0x7cb438`, `0x7cb4ea`) — and both take `NAMEPLATE_FONT` with the plate's black 1 px drop
|
||
/// shadow; the heights arrive with the geometry.
|
||
///
|
||
/// The black drop shadow is written in [`Plate::lay_out`] instead of here: its step is a
|
||
/// WINDOW-derived number ([`PlateGeometry::shadow_offset`]), not a create-time constant.
|
||
///
|
||
/// `justify_v` is the one benilla-side compensation here, and it is the old painter's, kept: the
|
||
/// plate's name is seated by its **ink**, not by the line box. Our shaper's box is ascent-heavy
|
||
/// for Friz, so a box-seated `BOTTOM` anchor drops the name ~8 px — half of it over the plate —
|
||
/// which is the director's old "the level is not aligned" in its other half. `Bottom` puts the
|
||
/// text where the reference's own metrics put it.
|
||
fn font_string(model: &mut Model, frame: FrameHandle, justify_v: JustifyV) -> 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();
|
||
data.font_path = Some(PLATE_FONT.to_string());
|
||
data.font_explicit.face = true;
|
||
data.justify.set_v(justify_v);
|
||
rh
|
||
}
|
||
|
||
/// **Is this frame a V-plate?** — the seat question, asked of the OWNER (decision 2172).
|
||
///
|
||
/// A plate is a WorldFrame overlay: the driver snaps its rect to the DEVICE pixel grid
|
||
/// (`vplates::device_snap`, 0188/1398) because it slides continuously over the world, and
|
||
/// everything drawn inside it has to be rigid to that rect. The renderer's UI seat snap quantizes
|
||
/// a text block's top on the coarser LOGICAL grid, so with both laws in force a plate's name and
|
||
/// level pop a whole logical pixel every second step the border takes — two quantizers on one
|
||
/// sliding object, and the "janky text walking up to a mob" the director reported the day after
|
||
/// decision 2148 moved these strings out of a painter that never snapped them (its host-measure
|
||
/// rects were degenerate, and a degenerate rect skips the snap — the carve-out the port silently
|
||
/// stopped selecting).
|
||
///
|
||
/// **Asked of the frame rather than written on our six regions**, because it is the frame that
|
||
/// slides: pfUI and ShaguTweaks blank the stock regions and hang their *own* FontStrings on the
|
||
/// plate, and those are drawn inside the same rect and want the same answer. (A string an addon
|
||
/// puts on the plate's health-BAR child is one level further down and still takes the UI grid —
|
||
/// the named residual, and the shape of the fix if it ever bites is an ancestor walk.)
|
||
pub(super) fn is_world_seated(model: &Model, frame: FrameHandle) -> bool {
|
||
model.nameplates.by_frame.contains_key(&frame)
|
||
}
|
||
|
||
/// **The plate's glow region paints nothing here** — the region is real, shown, textured and ADD,
|
||
/// and the quad walk skips it ([`super::extract`]). Decision 0184, and it is the director's call
|
||
/// rather than a gap we could close by trying harder.
|
||
///
|
||
/// The reference draws this region `(SRC_ALPHA, ONE)` over the health bar: an additive
|
||
/// reaction-tinted lift of the bar's cavity. 0183 built exactly that and the director rejected it
|
||
/// on sight — *"you should not change the gradient or anything — just make the color of the yellow
|
||
/// bar brighter"* — so 0184 replaced the rim with [`LIT_BOOST`], and the paint has been the bar's
|
||
/// own brighten ever since. That is what this predicate keeps true now that the plate is a widget
|
||
/// and its regions go through the shared frame→quad path instead of a painter that simply never
|
||
/// emitted a glow quad.
|
||
///
|
||
/// **Why the mode matters even though nothing is drawn.** `Nameplate-Glow.blp` is DXT1 with **no
|
||
/// alpha channel** — every one of its 4096 texels is alpha 255 — and 80.5% of it is exactly
|
||
/// `rgb(0,0,0)`, the surround; the signal is a grey rounded bar (rim 140, interior ~46) in rows
|
||
/// 20-26 that only reads as a glow when it is *added*. Blitted with straight `BLEND` it is
|
||
/// `dst·(1−1) + black·1` — **an opaque black rectangle over the whole plate**, which is precisely
|
||
/// what shipped when the widget port left the mode at the ctor default (director report,
|
||
/// 2026-09-10; wow-re `nameplate-vkey.md` §8.1 had already named this exact bug and its cause in
|
||
/// July). So `GetBlendMode()` answers `"ADD"` truthfully, an addon that re-textures the region
|
||
/// gets its own art painted normally, and only the reference's own glow art on an ADD region is
|
||
/// the one thing this engine declines to draw.
|
||
pub(super) fn is_unpainted_glow(data: &RegionData) -> bool {
|
||
data.blend == BlendMode::Add && data.texture.as_deref() == Some(GLOW_TEXTURE)
|
||
}
|
||
|
||
/// The LIT (mouseover ∪ target) bar brighten — a uniform multiplicative lift of the fill tint,
|
||
/// gradient untouched (0184: the recorded additive `Nameplate-Glow` rim read as hard edge lines on
|
||
/// our linear-blending pipeline, and the director pinned the brighten instead).
|
||
///
|
||
/// Quad colours are client-space sRGB (`srgb_quad_color` linearizes them), so this multiplies
|
||
/// ENCODED texels ~1:1 — the gamma-space modulate the client's FFP would do. **255/215 is the
|
||
/// largest clean value**: the fill texture's encoded peak (215) lands exactly on white, no row
|
||
/// clips, and every row scales by the same visible factor. (A first cut of 1.45 assumed a
|
||
/// linear-space multiply and flattened six rows against white — the very gradient change the
|
||
/// director banned.)
|
||
const LIT_BOOST: f32 = 255.0 / 215.0;
|