nameplates are frames, guild names go overhead, and the camera pivots

- a nameplate is a real frame under WorldFrame, six regions and a health bar in the reference's order, so pfUI, ShaguTweaks and _Nameplates find and restyle plates instead of seeing none; the plate stays out of the mouse's way in this cut (2148)
- a player's guild shows under their overhead name in angle brackets, the line an NPC's subname already used, gated by the UnitNamePlayerGuild option at its stock on (2149)
- smart camera pivot is on, as 1.12 ships it: a mostly vertical drag with the camera pressed against something tilts the view instead of swinging the arm, easing home on release; in water the pivot rises for a swimmer (2149)
- Follow Terrain and head bob are built and ship off, as in 1.12: Follow Terrain leans the camera up or down the slope you are walking onto, and head bob sways the first-person eye in a figure of eight while you move (2149)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 20:55:15 +07:00
co-authored by Claude Fable 5.1
parent 99b55f58ee
commit afc0731ccc
20 changed files with 4166 additions and 548 deletions
+203 -3
View File
@@ -450,6 +450,14 @@ pub(crate) const REGISTERED: &[Registered] = &[
same("UnitNamePlayer", "1"),
same("UnitNameNPC", "0"),
same("UnitNameOwn", "0"),
// The fourth of the same registrar's five (2149): `UnitNamePlayerGuild` `0x86c680` -> `"1"`
// `0x82e748`, mask bit `0x10`. It is NOT a show gate like the three above — `ShouldShowName
// 0x6070a0` consults only bits `0x1/0x2/0x4` — it gates ONE LINE of the player stack, the a5
// `"\n<%s>"` guild decoration at `0x609085` (wow-re `object-layer/scratch/overhead-name.md`
// Q4 point 3 + the registrar table). Its fifth sibling `UnitNamePlayerPVPTitle` (bit `0x20`,
// also `"1"`) has no row: a4's rank prefix needs a faction side `ui_unit` cannot resolve for
// an arbitrary player, so there is no reader and 1134 §4 says no key.
same("UnitNamePlayerGuild", "1"),
// The two V-plate toggles over `VPlateMode` — the engine bitmask `[0xc4da34]`'s bit 0 and
// bit 3. 1.12 registers NO nameplate CVar (wow-re, VERIFIED — the bitmask is a plain runtime
// global, persisted FrameXML-side as the `RegisterForSave`'d `NAMEPLATES_ON` /
@@ -665,6 +673,69 @@ pub(crate) const REGISTERED: &[Registered] = &[
// It sets the transition's DURATION (`|dyaw| / rate * factor`), so it is an average rate, not a
// slew. No row yet — the slider is a one-line follow-on now that the knob exists.
same("cameraYawSmoothSpeed", "180"),
// **The four 1.12 camera-option toggles** (decision 2149) — the `UIOptionsFrame` checkboxes
// FOLLOW_TERRAIN / HEAD_BOB / SMART_PIVOT / WATER_COLLISION, all four of which sat on the
// unbacked-CVar census with a byte-level spec and no feature until now. Defaults are the
// registrar's own (`re/cvar/cvar-register-sites.tsv`), and two of them are **"1"** — which is
// why building them was not cosmetic: benilla was the divergence on those, not the reference.
//
// `cameraPivot` `[0xbe10a4]` "1" (`0x50bda3`) — smart pivot. Mechanism: wow-re
// `ui/scratch/camera-cvar-gates.md` §3 (gate `0x510690`, routing `0x50fee0`, release
// `0x5107f0`); ours is `player::camera_dynamics::SmartPivot`.
same("cameraPivot", "1"),
// Its two drag-shape thresholds, both read by that routing (`0x50fff5`/`0x510004`) and both
// in RADIANS of camera rotation — the deltas they are compared against are already scaled by
// `camera<Yaw|Pitch>MoveSpeed · π/180`, so unlike the sensitivity itself these two transfer
// to benilla's raw-device units exactly (see `camera::LOOK_YAW_PER_SPEED`'s note).
same("cameraPivotDXMax", "0.05"),
same("cameraPivotDYMin", "0"),
// The rate the pitch bias eases back on once the pivot lets go, deg/s (`[0xbe0fc8]`,
// `0x512a50`'s `duration = |Δ| / (rate · π/180)`). No panel row here or there — the reader is
// the host, exactly like `cameraSmoothTrackingStyle` above it.
same("cameraTargetSmoothSpeed", "90"),
// `cameraWaterCollision` `[0xbe1088]` "1" (`0x50bd63`) — the other of the two that ships ON.
// Two consumers (wow-re `camera-cvar-gates.md` §4): the solver's sweep class word gains the
// `0xf0000` ADT-liquid nibble at `0x50e5ec`, and the driver's band-CROSSING pitch kick at
// `0x50ecae`. What it actually MOVES is the framing pivot's floor/cap, re-based by `0x511ad0`'s
// bands (`pivot-height-glide.md` §5) — the arm itself stays liquid-blind either way, which is
// that note's own verdict and is why benilla having no liquid collider costs nothing here.
same("cameraWaterCollision", "1"),
// Its band-crossing pair, both `"5.0"` (`[0xbe0f2c]`/`[0xbe0fdc]`) — the ABSOLUTE pitch the
// camera eases to when the liquid band crosses, in the reference's own degrees (positive =
// looking down; `player::camera_dynamics::WaterPitch` negates once, for benilla's sign).
same("cameraSurfaceFinalPitch", "5"),
same("cameraSubmergeFinalPitch", "5"),
// **`cameraPitchSmoothSpeed`** (`[0xbe0ce4]`, "45.0") — 2115 §7's third partner write, and
// 2147 §7's "the only one still unbuilt: no reader, so no row". It has one now, but a PARTIAL
// one, and the row says so rather than implying the whole channel exists: it is the rate the
// water kick's own pitch channel arms at. The reference reads it for the aim pitch as a whole
// (`0x510120` arms `[cam+0x1e0]`, `0x50f160` eases `[cam+0xf4]`); benilla's mouse pitch is an
// immediate write and stays one — see `WaterPitch`'s divergence note.
same("cameraPitchSmoothSpeed", "45"),
// `cameraTerrainTilt` `[0xbe0fd4]` **"0"** (`0x50bcfd`) — Follow Terrain, and the one of the
// four that ships OFF, so building it changed nothing until a player ticks the box. Mechanism:
// wow-re `camera-cvar-kernels.md` §2 (the ahead-probe and the five-step staircase) and
// `camera-smooth-style.md` §9 (the arm); ours is `player::camera_dynamics::TerrainTilt`.
same("cameraTerrainTilt", "0"),
// The ground channel's rate, deg/s (`[0xbe0fc0]`) and the duration bound its `Factor` scales
// (`[0xbe1050]`/`[0xbe1054]`, seconds). The floor always binds — `20° / 7.5°/s` is 2.67 s
// against a 3 s minimum — which is why a followed terrain leans rather than tracks.
same("cameraGroundSmoothSpeed", "7.5"),
same("cameraTerrainTiltTimeMin", "3"),
same("cameraTerrainTiltTimeMax", "10"),
// `cameraBobbing` `[0xbe10c0]` **"0"** (`0x50b76d`) — head bob, the fourth of the four and the
// second that ships OFF. Mechanism: wow-re `camera-cvar-kernels.md` §4 and
// `camera-cvar-gates.md` §2; ours is `player::camera_dynamics::HeadBob`.
same("cameraBobbing", "0"),
// Its four numeric siblings. The two amplitudes are in the CVar's own units — the kernel
// scales both by 1/36 (`[0x7ff9d0]`) to reach yards. `cameraBobbingSmoothSpeed` is the odd one
// and its name is the trap: it is **not** a bob rate, it is the DECAY rate, and its single
// image-wide read is in the disarm `0x51113a`, where `|largest component| / speed` becomes the
// ramp's duration (~0.069 s at these defaults).
same("cameraBobbingLRAmplitude", "2"),
same("cameraBobbingUDAmplitude", "2"),
same("cameraBobbingFrequency", "0.8"),
same("cameraBobbingSmoothSpeed", "0.8"),
// Status Text (1140): 1.12's `statusBarText`, the "always show value / max on a status bar"
// switch. **No host knob** — its consumer is Lua (TextStatusBar.xml, decision 1082, which was
// written waiting for this key and reads it on every repaint). Default "0": the reference's
@@ -1129,6 +1200,7 @@ pub(crate) struct KnobParams<'w> {
bubbles: ResMut<'w, BubbleConfig>,
zoom: ResMut<'w, ZoomLimit>,
follow: ResMut<'w, FollowConfig>,
camera_opts: ResMut<'w, crate::player::camera_dynamics::CameraOptions>,
video: ResMut<'w, VideoConfig>,
render_scale: ResMut<'w, RenderScale>,
tex_filter: ResMut<'w, benilla_assets::TexFilterSetting>,
@@ -1171,6 +1243,7 @@ impl KnobParams<'_> {
bubbles: &mut self.bubbles,
zoom: &mut self.zoom,
follow: &mut self.follow,
camera_opts: &mut self.camera_opts,
video: &mut self.video,
render_scale: &mut self.render_scale,
tex_filter: &mut self.tex_filter,
@@ -1209,6 +1282,7 @@ struct Knobs<'a> {
bubbles: &'a mut BubbleConfig,
zoom: &'a mut ZoomLimit,
follow: &'a mut FollowConfig,
camera_opts: &'a mut crate::player::camera_dynamics::CameraOptions,
video: &'a mut VideoConfig,
render_scale: &'a mut RenderScale,
tex_filter: &'a mut benilla_assets::TexFilterSetting,
@@ -1364,6 +1438,27 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"unitnameplayer" => knobs.names.player = v != 0.0,
"unitnamenpc" => knobs.names.npc = v != 0.0,
"unitnameown" => knobs.names.own = v != 0.0,
"unitnameplayerguild" => knobs.names.player_guild = v != 0.0,
// The camera options (2149). The three numeric ones take the value straight: the
// reference's own validator on them is `0x50b330`'s range REFUSAL, which lives in
// `benilla_ui`'s `SetCVar` path, not here.
"camerapivot" => knobs.camera_opts.pivot = v != 0.0,
"camerapivotdxmax" => knobs.camera_opts.pivot_dx_max = v,
"camerapivotdymin" => knobs.camera_opts.pivot_dy_min = v,
"cameratargetsmoothspeed" => knobs.camera_opts.target_smooth_speed = v,
"camerawatercollision" => knobs.camera_opts.water_collision = v != 0.0,
"camerasurfacefinalpitch" => knobs.camera_opts.surface_final_pitch = v,
"camerasubmergefinalpitch" => knobs.camera_opts.submerge_final_pitch = v,
"camerapitchsmoothspeed" => knobs.camera_opts.pitch_smooth_speed = v,
"cameraterraintilt" => knobs.camera_opts.terrain_tilt = v != 0.0,
"cameragroundsmoothspeed" => knobs.camera_opts.ground_smooth_speed = v,
"cameraterraintilttimemin" => knobs.camera_opts.tilt_time_min = v,
"cameraterraintilttimemax" => knobs.camera_opts.tilt_time_max = v,
"camerabobbing" => knobs.camera_opts.bobbing = v != 0.0,
"camerabobbinglramplitude" => knobs.camera_opts.bob_lr_amplitude = v,
"camerabobbingudamplitude" => knobs.camera_opts.bob_ud_amplitude = v,
"camerabobbingfrequency" => knobs.camera_opts.bob_frequency = v,
"camerabobbingsmoothspeed" => knobs.camera_opts.bob_smooth_speed = v,
// The two V-plate toggles — the bitmask's two bits, flags like every other checkbox.
// Lowercased here like every arm; `VPlateMode`'s consts carry the registered spelling.
"nameplateshowenemies" => knobs.plates.enemies = v != 0.0,
@@ -1681,6 +1776,7 @@ fn sync_cvars(
// Read-only borrows for the seed: field access through `ResMut`'s `Deref` flags nothing,
// which is the half of 0992's change-detection trap this system has to keep.
let KnobParams {
camera_opts,
sound,
scale,
view,
@@ -1737,7 +1833,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 69] = [
let session: [(&str, String); 87] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1774,6 +1870,56 @@ fn sync_cvars(
follow.tracking_style.cvar().to_string(),
),
("cameraYawSmoothSpeed", follow.yaw_speed.to_string()),
("cameraPivot", flag(camera_opts.pivot)),
("cameraPivotDXMax", camera_opts.pivot_dx_max.to_string()),
("cameraPivotDYMin", camera_opts.pivot_dy_min.to_string()),
(
"cameraTargetSmoothSpeed",
camera_opts.target_smooth_speed.to_string(),
),
("cameraWaterCollision", flag(camera_opts.water_collision)),
(
"cameraSurfaceFinalPitch",
camera_opts.surface_final_pitch.to_string(),
),
(
"cameraSubmergeFinalPitch",
camera_opts.submerge_final_pitch.to_string(),
),
(
"cameraPitchSmoothSpeed",
camera_opts.pitch_smooth_speed.to_string(),
),
("cameraTerrainTilt", flag(camera_opts.terrain_tilt)),
(
"cameraGroundSmoothSpeed",
camera_opts.ground_smooth_speed.to_string(),
),
(
"cameraTerrainTiltTimeMin",
camera_opts.tilt_time_min.to_string(),
),
(
"cameraTerrainTiltTimeMax",
camera_opts.tilt_time_max.to_string(),
),
("cameraBobbing", flag(camera_opts.bobbing)),
(
"cameraBobbingLRAmplitude",
camera_opts.bob_lr_amplitude.to_string(),
),
(
"cameraBobbingUDAmplitude",
camera_opts.bob_ud_amplitude.to_string(),
),
(
"cameraBobbingFrequency",
camera_opts.bob_frequency.to_string(),
),
(
"cameraBobbingSmoothSpeed",
camera_opts.bob_smooth_speed.to_string(),
),
("autoLootDefault", flag(loot.auto_loot)),
("showLootSpam", flag(loot.show_loot_spam)),
("guildMemberNotify", flag(guild_notify.0)),
@@ -1781,6 +1927,7 @@ fn sync_cvars(
("UnitNamePlayer", flag(names.player)),
("UnitNameNPC", flag(names.npc)),
("UnitNameOwn", flag(names.own)),
("UnitNamePlayerGuild", flag(names.player_guild)),
(crate::vplates::CVAR_ENEMIES, flag(plates.enemies)),
(crate::vplates::CVAR_FRIENDS, flag(plates.friends)),
// The session density on the panel scale (×1..×3 → 0..2). An env-driven off-grid
@@ -2247,9 +2394,59 @@ mod tests {
assert_eq!(d["UnitNamePlayer"] != 0.0, names.player);
assert_eq!(d["UnitNameNPC"] != 0.0, names.npc);
assert_eq!(d["UnitNameOwn"] != 0.0, names.own);
assert_eq!(d["UnitNamePlayerGuild"] != 0.0, names.player_guild);
assert!(
names.player && !names.npc && !names.own,
"the binary registers UnitNamePlayer \"1\", NPC \"0\", Own \"0\""
names.player && !names.npc && !names.own && names.player_guild,
"the binary registers UnitNamePlayer \"1\", NPC \"0\", Own \"0\", \
PlayerGuild \"1\""
);
// The camera options weld to `CameraOptions::default()` the same way (2149) — and the two
// that matter here are the ones registered "1": a `cameraPivot` that shipped OFF would be
// benilla diverging from the reference on a feature it now has.
let camera_opts = crate::player::camera_dynamics::CameraOptions::default();
assert_eq!(d["cameraPivot"] != 0.0, camera_opts.pivot);
assert_eq!(
d["cameraWaterCollision"] != 0.0,
camera_opts.water_collision
);
assert!(
camera_opts.pivot && camera_opts.water_collision,
"the binary registers cameraPivot and cameraWaterCollision both \"1\""
);
assert_eq!(
d["cameraSurfaceFinalPitch"],
camera_opts.surface_final_pitch
);
assert_eq!(
d["cameraSubmergeFinalPitch"],
camera_opts.submerge_final_pitch
);
assert_eq!(d["cameraPitchSmoothSpeed"], camera_opts.pitch_smooth_speed);
assert_eq!(d["cameraTerrainTilt"] != 0.0, camera_opts.terrain_tilt);
assert!(
!camera_opts.terrain_tilt,
"the binary registers cameraTerrainTilt \"0\""
);
assert_eq!(
d["cameraGroundSmoothSpeed"],
camera_opts.ground_smooth_speed
);
assert_eq!(d["cameraTerrainTiltTimeMin"], camera_opts.tilt_time_min);
assert_eq!(d["cameraTerrainTiltTimeMax"], camera_opts.tilt_time_max);
assert_eq!(d["cameraBobbing"] != 0.0, camera_opts.bobbing);
assert!(
!camera_opts.bobbing && !camera_opts.terrain_tilt,
"the binary registers cameraBobbing and cameraTerrainTilt both \"0\""
);
assert_eq!(d["cameraBobbingLRAmplitude"], camera_opts.bob_lr_amplitude);
assert_eq!(d["cameraBobbingUDAmplitude"], camera_opts.bob_ud_amplitude);
assert_eq!(d["cameraBobbingFrequency"], camera_opts.bob_frequency);
assert_eq!(d["cameraBobbingSmoothSpeed"], camera_opts.bob_smooth_speed);
assert_eq!(d["cameraPivotDXMax"], camera_opts.pivot_dx_max);
assert_eq!(d["cameraPivotDYMin"], camera_opts.pivot_dy_min);
assert_eq!(
d["cameraTargetSmoothSpeed"],
camera_opts.target_smooth_speed
);
// The V-plate pair welds to VPlateMode's defaults — both OFF, which is the reference's
// own boot state on both of its halves (the `[0xc4da34]` bitmask and FrameXML's
@@ -2348,7 +2545,9 @@ mod tests {
let mut fps_journal = crate::perf::FpsJournalSetting::default();
let mut text_filter = crate::text_filter::TextFilterSwitches::default();
let mut game_tip = crate::game_tip::GameTipSetting::default();
let mut camera_opts = crate::player::camera_dynamics::CameraOptions::default();
let mut knobs = Knobs {
camera_opts: &mut camera_opts,
sound: &mut sound,
auto_self_cast: &mut auto_self_cast,
text_filter: &mut text_filter,
@@ -2684,6 +2883,7 @@ mod tests {
formats: vec![(32, 32, 1), (32, 32, 2), (32, 32, 4)],
})
.init_resource::<LookConfig>()
.init_resource::<crate::player::camera_dynamics::CameraOptions>()
.init_resource::<crate::ui_chat::combat::CombatLogRanges>()
.init_resource::<crate::combat_text::DamageTextGates>()
.init_resource::<crate::ui_chat::combat::LogPeriodicSpells>()
+69 -27
View File
@@ -47,11 +47,26 @@
//! byte leg is a flagged pin); players → `UnitNamePlayer` (default ON); NPCs → `UnitNameNPC`
//! and own → `UnitNameOwn` (both default OFF, the binary's own — see [`NameConfig`]).
//! - **Lines** (`0x608f50`): NPC = name + `<Subname>` (an empty wire subname is no line); player =
//! [flag prefixes +] name (guild/PVP-title lines wait on data that doesn't stream yet). The
//! prefixes are the a1–a3 vtable-slot decorations of the line stack (`+0x7c/+0x80/+0x84`),
//! glued straight onto the name by the `%s%s…` assembly — `<AFK>`/`<DND>`/`<GM>` in that slot
//! order from `PLAYER_FLAGS` (see [`flag_prefix`]; byte-verified, wow-re Q4a). The trio is
//! unique to the overhead name binary-wide (`CHAT_FLAG_GM`'s only xref is the a3 slot).
//! [flag prefixes +] name + `<Guild>`. The prefixes are the a1–a3 vtable-slot decorations of the
//! line stack (`+0x7c/+0x80/+0x84`), glued straight onto the name by the `%s%s…` assembly —
//! `<AFK>`/`<DND>`/`<GM>` in that slot order from `PLAYER_FLAGS` (see [`flag_prefix`];
//! byte-verified, wow-re Q4a). The trio is unique to the overhead name binary-wide
//! (`CHAT_FLAG_GM`'s only xref is the a3 slot).
//!
//! **The guild line is a5 and the subname line is a6, and the two can never both appear** —
//! wow-re Q4's *branch exclusivity*, VERIFIED: the player branch emits a1–3 · a4 · a5 · a8 and
//! never reaches a6/a7; the NPC branch emits a1–3 · a4 · a6 · a7 and never reaches a5/a8. Both
//! slots share the one format string `"\n<%s>"` (`0x860f9c`), so one `Option<&str>` carries
//! whichever the unit's branch supplies ([`lines_current`]'s `bracketed`). a5 alone is
//! CVar-gated — mask bit `0x10`, `UnitNamePlayerGuild`, registered `"1"` (`0x609085`); the
//! guild NAME comes from the identity cache `GetGuildInfo` reads
//! ([`crate::ui_guild::unit_guild_name`]), so an unqueried guild draws no line and the line
//! appears when the query answers.
//!
//! **Still absent, and it is a5's twin:** a4's PvP rank/title prefix (`UnitNamePlayerPVPTitle`,
//! bit `0x20`) — the rank byte streams, but `PVP_RANK_%d_%d`'s second index is a faction side
//! `ui_unit` does not resolve for an arbitrary player yet. a7 (the NPC relationship line) and a8
//! (the foreign-server label) have no cross-realm wire here at all.
use std::collections::HashMap;
@@ -90,6 +105,10 @@ pub(crate) struct NameConfig {
pub(crate) player: bool,
pub(crate) npc: bool,
pub(crate) own: bool,
/// `UnitNamePlayerGuild` — mask bit `0x10`, registered `"1"`. Unlike the trio above this one
/// does **not** gate a whole name: it gates one *line* of the player stack (`0x609085`, the a5
/// slot), which is why it reads at the line build rather than in the show ladder.
pub(crate) player_guild: bool,
}
impl Default for NameConfig {
@@ -98,6 +117,7 @@ impl Default for NameConfig {
player: true,
npc: false,
own: false,
player_guild: true,
}
}
}
@@ -129,8 +149,14 @@ fn flag_prefix(player_flags: u32) -> String {
/// these inputs — compared in place, because the steady frame must not allocate three strings per
/// shown unit just to learn nothing changed. Equivalent to string equality against the built stack
/// (the differential test pins it): line 0 is the set [`FLAG_PREFIXES`] tags in slot order glued
/// onto the name, line 1 the `<{sub}>` decoration when a subname shows.
fn lines_current(cached: &[String], player_flags: u32, name: &str, sub: Option<&str>) -> bool {
/// onto the name, line 1 the `<{bracketed}>` decoration — the a5 guild line for a player, the a6
/// subname line for an NPC, never both (the module doc's branch exclusivity).
fn lines_current(
cached: &[String],
player_flags: u32,
name: &str,
bracketed: Option<&str>,
) -> bool {
let name_line = |line: &str| {
let mut rest = line;
for (bit, tag) in FLAG_PREFIXES {
@@ -143,14 +169,14 @@ fn lines_current(cached: &[String], player_flags: u32, name: &str, sub: Option<&
}
rest == name
};
match (cached, sub) {
match (cached, bracketed) {
([l0], None) => name_line(l0),
([l0, l1], Some(sub)) => {
([l0, l1], Some(bracketed)) => {
name_line(l0)
&& l1
.strip_prefix('<')
.and_then(|r| r.strip_suffix('>'))
.is_some_and(|r| r == sub)
.is_some_and(|r| r == bracketed)
}
_ => false,
}
@@ -385,6 +411,9 @@ pub(crate) fn drive_nameplates(
Res<NameConfig>,
),
mut names: ResMut<NameCache>,
// The guild-identity cache (1257) — the a5 line's text, and the lazy `CMSG_GUILD_QUERY` a
// miss sends. `ResMut` because the read IS the ask ([`crate::ui_guild::unit_guild_name`]).
mut guilds: ResMut<crate::ui_guild::GuildState>,
net_commands: Res<NetCommands>,
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
@@ -551,14 +580,19 @@ pub(crate) fn drive_nameplates(
} else {
flags
};
let sub = if net.kind == EntityKind::Unit {
benilla_protocol::guid::entry(guid.0)
// The one `"\n<%s>"` slot: a6 (the creature subtitle) on the NPC branch, a5 (the guild)
// on the player branch — never both, and never in the other's branch (the module doc's
// branch exclusivity, wow-re Q4, VERIFIED). a5 is the only one of the two that a CVar
// gates: mask bit `0x10` at `0x609085`.
let bracketed = match net.kind {
EntityKind::Unit => benilla_protocol::guid::entry(guid.0)
.and_then(|e| names.creature_subname(e))
// vmangos ships "" (present-but-empty) for most templates — an empty wire
// subname is NO subname, never an empty `<>` line.
.filter(|s| !s.trim().is_empty())
} else {
None
.filter(|s| !s.trim().is_empty()),
EntityKind::Player if name_cfg.player_guild => store
.and_then(|s| crate::ui_guild::unit_guild_name(&s.0, &mut guilds, &net_commands)),
_ => None,
};
// The color: the ring's byte-verified rank + the shared palette selector.
let rank = ring_reaction(
@@ -592,7 +626,7 @@ pub(crate) fn drive_nameplates(
// The steady frame — very nearly all of them — compares the cached stack in place
// ([`lines_current`]): the line stack used to be BUILT here (three allocations per
// shown unit per frame) only to equality-compare against the cache and be dropped.
Some((_, l, c)) if *c == color && lines_current(l, flags, name, sub) => {}
Some((_, l, c)) if *c == color && lines_current(l, flags, name, bracketed) => {}
stale => {
if let Some((old, _, _)) = stale {
let old = *old;
@@ -608,8 +642,8 @@ pub(crate) fn drive_nameplates(
} else {
format!("{prefix}{name}")
}];
if let Some(sub) = sub {
lines.push(format!("<{sub}>"));
if let Some(bracketed) = bracketed {
lines.push(format!("<{bracketed}>"));
}
debug!("nameplates: rebuild {entity} -> {lines:?} ({color:?})");
let mesh = plates
@@ -934,10 +968,10 @@ mod tests {
/// flagged `Bob`, exactly as the built strings do.
#[test]
fn lines_current_matches_the_built_stack() {
let build = |flags: u32, name: &str, sub: Option<&str>| {
let build = |flags: u32, name: &str, bracketed: Option<&str>| {
let mut lines = vec![format!("{}{name}", flag_prefix(flags))];
if let Some(sub) = sub {
lines.push(format!("<{sub}>"));
if let Some(bracketed) = bracketed {
lines.push(format!("<{bracketed}>"));
}
lines
};
@@ -948,14 +982,22 @@ mod tests {
(0x2, "Bob", None),
(0x2 | 0x8, "Bob", None),
(0, "<AFK>Bob", None),
// The a5 slot (2149). It shares the a6 shape exactly, which is the point — the two
// are one `"\n<%s>"` and the unit's branch picks which fills it. The guild names
// below are the awkward ones: a guild the player left (`None` again), one whose own
// name carries the brackets, and one that collides with a subname above.
(0, "Bob", Some("Legacy")),
(0x2, "Bob", Some("Legacy")),
(0, "Bob", Some("<Legacy>")),
(0, "Young Wolf", Some("Beast Handlers")),
];
for &(flags, name, sub) in cases {
let cached = build(flags, name, sub);
for &(f2, n2, s2) in cases {
for &(flags, name, bracketed) in cases {
let cached = build(flags, name, bracketed);
for &(f2, n2, b2) in cases {
assert_eq!(
lines_current(&cached, f2, n2, s2),
build(f2, n2, s2) == cached,
"cache of ({flags:#x}, {name:?}, {sub:?}) vs ({f2:#x}, {n2:?}, {s2:?})"
lines_current(&cached, f2, n2, b2),
build(f2, n2, b2) == cached,
"cache of ({flags:#x}, {name:?}, {bracketed:?}) vs ({f2:#x}, {n2:?}, {b2:?})"
);
}
}
+6
View File
@@ -44,6 +44,11 @@ mod arc;
// Writing the frame onto the body we drive — pose, MovementState, the counter-twist gap.
mod body_pose;
pub(crate) mod camera;
// The one smoothed-scalar channel the reference instantiates four times (wow-re
// `camera-cvar-gates.md` §8) — pitch, pitch-bias, ground tilt and the pivot height, one template.
mod camera_channel;
// The four 1.12 camera option toggles and the mechanisms behind them (decision 2149).
pub(crate) mod camera_dynamics;
// The per-frame controller itself — the one system, split out so the root stays the map.
mod controller;
mod world_focus;
@@ -324,6 +329,7 @@ impl Plugin for PlayerPlugin {
app.init_resource::<camera::LookConfig>();
app.init_resource::<camera::ZoomLimit>();
app.init_resource::<camera::FollowConfig>();
app.init_resource::<camera_dynamics::CameraOptions>();
// Far sight: resolve `PLAYER_FARSIGHT` into a pose before `control` reads it to seat the
// camera. A separate system rather than another query on `control` for a hard reason —
// `control` already holds the self entity's `Transform` mutably, so it cannot also read an
+218 -83
View File
@@ -12,6 +12,8 @@ use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow};
use avian3d::prelude::*;
use super::camera_channel::{Arm, SmoothChannel};
use super::camera_dynamics::{DynamicsInput, HeadBob, SmartPivot, TerrainTilt, WaterPitch};
use crate::creature_anim::wrap_pi;
use crate::net::Embodied;
use crate::ui_script::PointerOverUi;
@@ -686,15 +688,14 @@ pub(crate) fn head_height(pivot: Option<&CameraPivot>, scale: f32) -> f32 {
/// cosine profile peaks at `π/2 ×` it in the middle and is zero at both ends. There is no duration
/// clamp on this channel (unlike the yaw channel's `[0.1 s, 2.0 s]`).
const CAM_PIVOT_SMOOTH_SPEED: f32 = 1.2;
/// The pivot setter's "already there / already arming this" epsilon — VERIFIED `0.001` (`[0x801360]`,
/// `0x5126b0`). It is what makes the per-frame re-arm a no-op in steady state.
const CAM_PIVOT_EPS: f32 = 0.001;
/// **The camera's pivot-height channel** — the height the framing pivot actually rides, chasing the
/// model-derived target with a cosine smoothstep instead of taking it raw.
/// **The camera's pivot-height channel** — the height the framing pivot actually rides, chasing
/// the model-derived target with a cosine smoothstep instead of taking it raw.
///
/// The reference's live `cam+0xfc` chasing target `cam+0x1c8`: armed by `0x5126b0` → `0x512790`,
/// stepped by `0x50f160`'s `[0x50f36a, 0x50f417)` block (wow-re `pivot-height-glide.md`, §5 round).
/// stepped by `0x50f160`'s `[0x50f36a, 0x50f417)` block (wow-re `pivot-height-glide.md`, §5 round)
/// — the **fourth instantiation** of the channel template [`SmoothChannel`] holds (wow-re
/// `camera-cvar-gates.md` §8), which is why nothing of the tween lives here any more. What is left
/// is the two things this instantiation does that its three siblings do not.
/// **This is why a druid shapeshift does not snap the reference's camera**, and it glides in *both*
/// directions: the solver's `max(target, live)` (`0x50e5a9`) is only the collision-corridor seed, and
/// the far chain clamps the result back down to the live value (`0x50e767`), so an unobstructed pivot
@@ -708,92 +709,42 @@ const CAM_PIVOT_EPS: f32 = 0.001;
/// skips the whole camera update while the preset is stale, `0x50e907`). Ours is the `None` target:
/// during the frames a swapped-in model is loading, the pivot stays where it is and one glide runs
/// when the new height lands.
#[derive(Default)]
pub(super) struct PivotGlide {
/// The live height — what the camera uses (`cam+0xfc`).
live: f32,
/// Where the move started (`cam+0x1cc`) and where it is going (`cam+0x1c8`).
from: f32,
to: f32,
/// Seconds since arming, and the move's total (`cam+0x1c0`/`+0x1c4`); `None` = nothing in
/// flight (the reference's armed bit `[cam+0x90] & 0x20000000`).
flight: Option<(f32, f32)>,
/// The channel itself — **linear**, not angular: its live value is yards, so the armer's `2π`
/// rewrap must not run on it.
channel: SmoothChannel,
/// Has the channel ever been armed? The reference's latch bit `0x80` — false only until the
/// first model-derived height arrives, which is therefore a snap.
seeded: bool,
}
impl Default for PivotGlide {
fn default() -> Self {
Self {
live: CAM_PIVOT_FALLBACK,
from: CAM_PIVOT_FALLBACK,
to: CAM_PIVOT_FALLBACK,
flight: None,
seeded: false,
}
}
}
impl PivotGlide {
/// Arm the channel with this frame's model-derived target (`None` while the subject has no
/// model — hold), step whatever is in flight, and return the height to frame at.
///
/// Called every frame, which is the reference's own cadence (`0x50f880` from the driver tail
/// `0x50f011`): the epsilon tests below turn a steady target into a no-op, so "arm per frame"
/// and "arm on change" are the same thing except at the instant the target actually moves.
/// `0x50f011`): the armer's own two epsilon refusals turn a steady target into a no-op, so
/// "arm per frame" and "arm on change" are the same thing except at the instant the target
/// actually moves.
pub(super) fn advance(&mut self, target: Option<f32>, dt: f32) -> f32 {
if let Some(target) = target {
self.arm(target);
}
if let Some((elapsed, dur)) = self.flight.as_mut() {
*elapsed += dt;
let s = *elapsed / *dur;
if s >= 1.0 {
self.live = self.to;
self.flight = None;
if self.seeded {
self.channel.arm(&Arm::at(target, CAM_PIVOT_SMOOTH_SPEED));
} else {
// The reference's kernel `0x5b7bb0` — the same cosine smoothstep the yaw channel
// and the render-scale ease use: `a + (b − a)·(1 − cos(πs))/2`.
let e = (1.0 - (std::f32::consts::PI * s).cos()) * 0.5;
self.live = self.from + (self.to - self.from) * e;
// The latch: the first height a camera ever sees is established, not travelled to.
self.seeded = true;
self.channel.snap(target);
}
}
self.live
}
/// The arming half (`0x5126b0` → `0x512790`), in its own order: the re-arm memo, the
/// already-there test, then the duration — and the once-per-camera snap.
fn arm(&mut self, target: f32) {
if !self.seeded {
// The latch: the first height a camera ever sees is established, not travelled to.
self.seeded = true;
self.live = target;
self.to = target;
self.from = target;
self.flight = None;
return;
}
// Already arming exactly this — a no-op, so a per-frame re-arm cannot restart the move
// from its own midpoint (which would stretch it forever, asymptotically never arriving).
if self.flight.is_some() && (self.to - target).abs() < CAM_PIVOT_EPS {
return;
}
if (self.live - target).abs() < CAM_PIVOT_EPS {
// Already there: park the target and disarm. The steady-state path, every frame.
self.to = target;
self.flight = None;
return;
}
self.from = self.live;
self.to = target;
self.flight = Some((0.0, (target - self.live).abs() / CAM_PIVOT_SMOOTH_SPEED));
self.channel.advance(dt)
}
/// What the channel is doing, for `WOW_CAM_DUMP`: `(live, target)`. A pivot question is a
/// *timing* question — "does it snap?" is answered by these two columns on a trace, never by
/// watching a capture (method: timing is measured, never eyeballed).
pub(super) fn probe(&self) -> (f32, f32) {
(self.live, self.to)
self.channel.probe()
}
}
@@ -842,6 +793,24 @@ pub(crate) struct CameraControl {
/// body, which is why it glides *through* a change of subject (a shapeshift, a far-sight
/// switch) instead of being reset by one.
pub(super) pivot: PivotGlide,
/// `cameraPivot`'s pitch-bias channel ([`SmartPivot`], decision 2149) — pose, like the two
/// above it.
pub(super) smart_pivot: SmartPivot,
/// `cameraWaterCollision`'s band-crossing pitch kick ([`WaterPitch`], decision 2149) — also
/// pose: the band it remembers is the *previous frame's*, which is the whole selector.
pub(super) water_pitch: WaterPitch,
/// `cameraTerrainTilt`'s ground-pitch channel and its 100 ms probe throttle ([`TerrainTilt`],
/// decision 2149).
pub(super) terrain_tilt: TerrainTilt,
/// `cameraBobbing`'s session latch and eye offset ([`HeadBob`], decision 2149).
pub(super) head_bob: HeadBob,
/// **Did the collision sweep actually clip the camera on the frame just seated?** The
/// reference's `[cam+0x90] & 0x30000`, which nothing but the solver `0x50e570` writes and the
/// driver ORs in per frame — and which [`SmartPivot`]'s gate reads as its sixth conjunct, so
/// that an unobstructed camera never pivots. Written by [`seat_camera`]; read a frame later by
/// [`run_look_session`], exactly as the reference's input handler reads the flags the last
/// driver pass left (`0x50fee0`'s sole caller `0x514446` precedes the mover lookup).
pub(super) clipped: bool,
}
impl CameraControl {
@@ -1033,6 +1002,10 @@ pub(super) fn run_look_session(
left_click: &mut Option<PressGesture>,
right_click: &mut Option<PressGesture>,
look_cfg: LookConfig,
// The camera-option knobs + this frame's gate facts (decision 2149). `cameraPivot`'s routing
// lives in the look session because the reference's does: `0x50fee0` IS the mouse-motion
// handler, and the whole decision is "does THIS motion event go into the pitch or the bias".
dynamics: &DynamicsInput,
// Seconds on the app clock — the press predicate's two time gates are measured against it.
now: f32,
) {
@@ -1147,14 +1120,36 @@ pub(super) fn run_look_session(
// turns the character (its facing tracks the camera yaw); left-drag leaves the character facing.
if let Some(active) = rig.look {
let delta = mouse_motion.delta;
cam.yaw -= delta.x * yaw_rate;
let d_yaw = -delta.x * yaw_rate;
cam.yaw += d_yaw;
// `mouseInvertPitch` flips only the pitch axis (the 1.12 checkbox's whole meaning).
let dy = if look_cfg.invert_pitch {
-delta.y
} else {
delta.y
};
cam.pitch = (cam.pitch - dy * pitch_rate).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
// **The pivot's fork** (`0x50fee0`, decision 2149): a pinned camera looking level-or-up,
// dragged mostly vertically, spends this delta on the view's pitch BIAS and leaves the
// arm alone. `None` is the reference's pure-pivot frame — the integrator does not run.
let d_pitch = -dy * pitch_rate;
if d_pitch != 0.0 {
// **The hand does not cancel the kick — it carries it** (wow-re
// `camera-cvar-kernels.md` Q5). `0x510120` writes all three of the pitch channel's
// fields by the same delta — live `[cam+0xf4]`, start `[cam+0x1e4]` and target
// `[cam+0x1e0]` — so a drag during a `*FinalPitch` ease keeps the ease's *remaining
// travel* instead of losing it. Cancelling was the reading before that answer landed.
rig.water_pitch.nudge(d_pitch);
}
if let Some(d_pitch) = rig.smart_pivot.route_pitch(
d_pitch,
d_yaw,
cam.pitch,
&dynamics.subject,
rig.clipped,
&dynamics.options,
) {
cam.pitch = (cam.pitch + d_pitch).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
}
if active == LookButton::Right || both_buttons {
*face_yaw = cam.yaw;
}
@@ -1210,6 +1205,8 @@ pub(super) fn seat_on_subject(
collide: &benilla_world::collision::WorldCollision<'_, '_>,
cam_probe: &Collider,
follow: &FollowInput,
dynamics: &DynamicsInput,
world: &benilla_world::world_point::WorldPoint<'_, '_>,
) {
// The sweep origin moves with the subject too; rooting it at our own head would cast the boom
// across the world and jam it on the first wall in between
@@ -1218,6 +1215,28 @@ pub(super) fn seat_on_subject(
Some(v) => (v.feet, v.sweep_origin()),
None => (feet, head),
};
// The **fourth** substitution, and it belongs here for the same reason the other three do:
// `cameraWaterCollision`'s bands are a fact about the FOLLOWED unit's liquid (`0x511ad0` takes
// the unit, and the room claim is that unit's), so a far-sight subject in a lake is what
// re-bases the pivot, never our own body's puddle. Every liquid, not only water (0634).
let feet_wow = benilla_assets::coords::bevy_to_wow(orbit_pos);
let who = view
.remote
.map_or(benilla_world::world_point::Subject::Player, |v| {
benilla_world::world_point::Subject::Unit(v.entity)
});
let dynamics = &super::camera_dynamics::DynamicsInput {
liquid: super::camera_dynamics::SubjectLiquid {
// Classified **unconditionally**, as the reference does: `0x511ad0` is called at
// `0x50eb45` whatever `cameraWaterCollision` says, and the CVar gates only whether the
// bands reach the corridor (`0x50e5ec`) and the crossing kick (`0x50ecb9`). The bits
// have a third reader that is NOT gated by it — `0x5103e0`'s `cameraDive` pair — so
// gating the query here would be a trap for whoever builds that.
surface_y: world.liquid_at(who, feet_wow).map(|h| h.surface_z),
feet_y: feet_wow[2],
},
..*dynamics
};
// The framing height is the **channel's**, not this frame's target: it eases there over
// `|Δh| / 1.2` s with a cosine profile, so a shapeshift, a mount, a growth aura or a far-sight
// switch move the camera smoothly instead of teleporting it ([`PivotGlide`]; wow-re
@@ -1226,6 +1245,74 @@ pub(super) fn seat_on_subject(
let orbit_pivot = rig
.pivot
.advance(view.remote.map(|v| v.pivot_height).or(body_pivot), dt);
// **`cameraWaterCollision`'s pivot corridor** (decision 2149; wow-re `pivot-height-glide.md`
// §5 + `camera-cvar-gates.md` §4a). The CVar's *first* consumer is `0x50e5ec`, which folds the
// ADT liquid nibble into the solver's sweep class word — but the recorded verdict for the arm
// is unchanged by that (`camera-arm-liquid-blind.md`: the SWEEP stays liquid-blind), and what
// actually moves is the **framing pivot's** floor and cap, re-based by `0x511ad0`'s bands.
// With the CVar off, or out of liquid, `clamp(live, 5/6, live)` is the live height itself.
let (band, d) = dynamics.liquid.band(rig.pivot.probe().1);
let orbit_pivot = if dynamics.options.water_collision {
let (floor, cap) = band.pivot_corridor(d, orbit_pivot);
orbit_pivot.clamp(floor, cap.max(floor))
} else {
orbit_pivot
};
// The CVar's other consumer: a band CROSSING arms an absolute pitch through the kick's own
// channel. Run here, before the seat, so the pitch this frame renders at is the eased one —
// and unconditionally, because the previous-frame snapshot it keeps is taken at `0x50eb14`,
// ahead of the CVar test at `0x50ecb9`.
// **`cameraTerrainTilt`'s probe and channel.** The probe looks at the ground AHEAD of the
// subject, not under it: a horizontal ray along its facing from `feet + 5/3`, its hit pulled
// back `5/18`, then a `64/9` drop — so the slope is the rise of the ground you are walking
// ONTO over the run to it. Rooted at the subject, which is what makes far sight tilt to the
// totem's hill rather than to ours.
let ground_probe = || {
let origin = orbit_pos + Vec3::Y * super::camera_dynamics::PROBE_LIFT;
let fwd = Quat::from_rotation_y(dynamics.subject.facing) * Vec3::NEG_Z;
let reach = Dir3::new(fwd)
.ok()
.and_then(|d| collide.ray_body(origin, d, super::camera_dynamics::PROBE_REACH))
.map_or(super::camera_dynamics::PROBE_REACH, |h| {
h.distance - super::camera_dynamics::PROBE_BACKOFF
});
let ahead = origin + fwd * reach;
let ground_y = collide
.ray_body(ahead, Dir3::NEG_Y, super::camera_dynamics::PROBE_DROP)
.map_or(ahead.y - super::camera_dynamics::PROBE_DROP, |h| {
ahead.y - h.distance
});
// `L = √(Δx² + Δy²)` is a length, so a hit closer than the backoff runs the probe
// *behind* the subject and still divides by a positive run — the reference's own
// arithmetic, not a guard added here.
(ground_y - orbit_pos.y) / reach.abs().max(1.0e-3)
};
rig.terrain_tilt.advance(
ground_probe,
dynamics.options.terrain_tilt,
&dynamics.subject,
dynamics.smooth_style,
&dynamics.options,
dt,
);
// **`cameraBobbing`'s latch and kernel.** The session is armed off the input-command word, not
// off this gate, so it runs whatever the CVar says and only the OUTPUT is gated — which is what
// makes turning the CVar on mid-run start the bob at the phase the session has reached.
// `rig.distance` and not `collision_distance`: the reference's first conjunct is on the zoom
// (`[cam+0xec]`), so a camera squeezed against a wall is not thereby in first person.
rig.head_bob
.advance(rig.distance, &dynamics.subject, &dynamics.options, dt);
// `[cam+0x90] & 1` is FREELOOK — the player holding mouse-look — which picks the HARD SNAP
// over the ease (`0x50ed0e`; wow-re `camera-cvar-kernels.md` Q3).
let freelook = rig.look.is_some();
if let Some(pitch) = rig
.water_pitch
.advance(band, cam.pitch, freelook, &dynamics.options, dt)
{
cam.pitch = pitch.clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
}
seat_camera(
dt,
turn_delta,
@@ -1238,6 +1325,7 @@ pub(super) fn seat_on_subject(
collide,
cam_probe,
follow,
dynamics,
);
}
@@ -1267,6 +1355,7 @@ pub(super) fn seat_camera(
collide: &benilla_world::collision::WorldCollision<'_, '_>,
cam_probe: &Collider,
follow: &FollowInput,
dynamics: &DynamicsInput,
) {
// A keyboard turn carries the camera RIGIDLY (char and camera rotate as one — the reference
// look, director's call closing 0050's open "camera follow on turn"): an eased chase of a
@@ -1301,19 +1390,45 @@ pub(super) fn seat_camera(
// instead of overshooting (the old min-distance floor used to force the camera *past* a too-close
// hit — gone; collision wins outright). `cast_move` ignores origin penetration, so a head grazing
// a surface still casts outward.
let rotation = Quat::from_euler(EulerRot::YXZ, cam.yaw, cam.pitch, 0.0);
// **The seat is built from the UNBIASED pitch and the view from the biased one** — the whole
// of `cameraPivot` is that ordering (decision 2149). The reference computes the eye at
// `0x50edcc → 0x50de00` and stores it, and only *then* rotates the camera basis `[cam+0x14]`
// by `[cam+0x104]` at `0x50ee32`; so the arm never swings and the look direction does. The
// bias is zero at every default until a pinned camera is dragged, so `arm_rotation` and
// `rotation` are the same quaternion on almost every frame.
let bias = rig.smart_pivot.bias();
// **The ground tilt IS part of the arm's pitch**, unlike the bias: `0x50f710` composes
// `[cam+0xf4] + [cam+0x108]` and clamps the SUM to ±89° before the basis is built, and the eye
// is then seated from that basis. So a followed terrain moves the camera; a smart pivot does
// not (wow-re `camera-cvar-kernels.md` §1).
let arm_pitch = (cam.pitch + rig.terrain_tilt.pitch()).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
let arm_rotation = Quat::from_euler(EulerRot::YXZ, cam.yaw, arm_pitch, 0.0);
// **The composite is deliberately NOT re-clamped to ±89°.** The reference's clamp at
// `0x50f710` binds `[cam+0xf4] + [cam+0x108]` — the pitch channel plus the ground tilt — and
// the bias is composed **after** it, at `0x50ee58`, so the rendered pitch is not bounded by it
// (wow-re `camera-cvar-kernels.md` §1). The bound that does apply to the bias is the one-sided
// one on its own accumulate ([`SmartPivot::route_pitch`]), and the ±89° on the body hand-off
// (`0x5103e0`), which is where `mover_pitch` takes it.
let rotation = if bias == 0.0 {
arm_rotation
} else {
Quat::from_euler(EulerRot::YXZ, cam.yaw, arm_pitch + bias, 0.0)
};
// `Transform::forward()` is exactly `rotation * -Z` (no renormalize), computed here from the
// local so the write below can be gated.
let cam_fwd = rotation * Vec3::NEG_Z;
let cam_fwd = arm_rotation * Vec3::NEG_Z;
let pivot = player_pos + Vec3::Y * cam_pivot_height;
let seat = pivot - cam_fwd * rig.distance;
let boom = seat - head;
let boom_len = boom.length().max(1.0e-3);
// The camera collides with the WMO *camera/LOS* faces (keeps DETAIL overhangs like forge pipes,
// drops NOCAMCOLLIDE) + terrain/doodads/GameObjects — its own audience, not the walking mesh.
let open = collide
.cast_camera(cam_probe, head, Quat::IDENTITY, boom, 0.0)
.map_or(boom_len, |h| h.distance);
let hit = collide.cast_camera(cam_probe, head, Quat::IDENTITY, boom, 0.0);
// The solver's own clip verdict (`0x50e570`'s `0x30000` return, OR'd into `[cam+0x90]` by the
// driver) — [`SmartPivot`]'s sixth conjunct, and the reason an unobstructed camera never
// pivots. Written here because here is the only place that knows.
rig.clipped = hit.is_some();
let open = hit.map_or(boom_len, |h| h.distance);
// Snap in instantly when geometry intrudes (a wall must never sit between camera and character);
// ease back out to the open arm length once it clears — the vanilla snap-close-then-glide-back.
rig.collision_distance = if open < rig.collision_distance {
@@ -1323,7 +1438,13 @@ pub(super) fn seat_camera(
rig.collision_distance + (open - rig.collision_distance) * t
};
let frac = (rig.collision_distance / boom_len).clamp(0.0, 1.0);
let translation = head + boom * frac;
let seated = head + boom * frac;
// **The head bob is a pure world-space translation of the eye**, added last, into the same
// slot [`crate::camera_shake`] writes — which is exactly what the reference does (`0x50eb0f`
// folds the bob into the shake's accumulator and `0x50de00` applies the pair once). Zero on
// every frame nothing is bobbing, so the no-op write gate below still holds a parked camera
// bit-stable.
let translation = seated + rig.head_bob.offset();
// The no-op write gate (decision 1362 — 1355's clamp lesson, at the camera): a parked
// camera's pose is bit-stable once the collision ease settles, but writing it anyway marked
// the camera's transform changed every frame — which re-ran its propagation and told every
@@ -1386,11 +1507,24 @@ pub(super) fn seat_camera(
);
}
// The pivot bias's own per-frame half (`0x50ed77` → `0x5107f0`): with the gate no longer true
// the bias eases back to zero at `cameraTargetSmoothSpeed`; with it true an ease in flight is
// cancelled where it stands. Runs after the sweep, so `rig.clipped` is this frame's.
rig.smart_pivot.advance(
cam.pitch,
&dynamics.subject,
rig.clipped,
&dynamics.options,
dt,
);
// Fade the avatar as the camera nears its pivot (zoom-in / a wall pulling the boom in): opaque
// in third-person, ramping to invisible in first-person. Keyed off the *realized* camera→pivot
// distance (collision-pulled), so backing into a wall also thins you — the faithful behavior.
// Off the SEATED eye, not the bobbed one: the fade is a statement about how far the boom was
// pulled in, and a 5 cm wobble is not that.
rig.self_fade_alpha =
self_model_fade_alpha((translation - pivot).length(), CAM_NEAR, SELF_FADE_WINDOW);
self_model_fade_alpha((seated - pivot).length(), CAM_NEAR, SELF_FADE_WINDOW);
}
/// Apply the self-avatar zoom-in fade ([`CameraControl::self_fade_alpha`], computed in [`control`]) to
@@ -1703,6 +1837,7 @@ pub(super) fn fly_free(
#[cfg(test)]
mod tests {
use super::super::camera_channel::CHANNEL_EPS;
use super::*;
use benilla_assets::BillboardInfo;
use benilla_formats::BillboardKind;
@@ -1741,10 +1876,10 @@ mod tests {
let expected = (to - from).abs() / CAM_PIVOT_SMOOTH_SPEED;
let frames = glide_run(&mut g, Some(to), expected * 2.0);
// It arrives, and only at the end.
assert!((frames.last().copied().unwrap() - to).abs() < CAM_PIVOT_EPS);
assert!((frames.last().copied().unwrap() - to).abs() < CHANNEL_EPS);
let arrived = frames
.iter()
.position(|h| (h - to).abs() < CAM_PIVOT_EPS)
.position(|h| (h - to).abs() < CHANNEL_EPS)
.unwrap();
let took = arrived as f32 / 60.0;
assert!(
@@ -1792,7 +1927,7 @@ mod tests {
g.advance(Some(1.0), 1.0 / 60.0);
let frames = glide_run(&mut g, Some(2.2), 2.0);
assert!(
(frames.last().copied().unwrap() - 2.2).abs() < CAM_PIVOT_EPS,
(frames.last().copied().unwrap() - 2.2).abs() < CHANNEL_EPS,
"a per-frame re-arm must still arrive"
);
}
@@ -0,0 +1,377 @@
//! **The camera's smoothed-scalar channel** — the one template the reference instantiates four
//! times, in one place instead of four.
//!
//! wow-re `ui/scratch/camera-cvar-gates.md` §8 is the finding this module exists to honour: the
//! camera's pitch, pitch-bias and ground-tilt channels (`0x512830`/`0x512980`,
//! `0x512a50`/`0x512ba0`, `0x512490`/`0x5125e0`) are **one compiler-emitted template** at three
//! sets of field offsets, and `0x5126b0`/`0x512790` — the pivot **height** channel this client
//! already had ([`super::camera::PivotGlide`]) — is a fourth. Their per-frame step is one block of
//! one function (`0x50f160`).
//!
//! | role | live | start | target | start ms | duration | armed bit | rate CVar |
//! |---|---|---|---|---|---|---|---|
//! | pitch | `+0xf4` | `+0x1e4` | `+0x1e0` | `+0x1d8` | `+0x1dc` | `0x2000000` | `cameraPitchSmoothSpeed` `45.0` |
//! | bias | `+0x104` | `+0x1fc` | `+0x1f8` | `+0x1f0` | `+0x1f4` | `0x8000000` | `cameraTargetSmoothSpeed` `90.0` |
//! | ground | `+0x108` | `+0x1b4` | `+0x1b0` | `+0x1a8` | `+0x1ac` | `0x10000000` | `cameraGroundSmoothSpeed` `7.5` |
//! | height | `+0xfc` | `+0x1cc` | `+0x1c8` | `+0x1c0` | `+0x1c4` | `0x20000000` | `cameraHeightSmoothSpeed` `1.2` |
//!
//! The armer's contract, byte-derived and identical at all four: shortest-path-rewrap the live
//! value into `[target − π, target + π]`; **refuse** the request if the channel is already arming
//! the same `{target, delay, factor}` within `0.001`, or if the live value is already within
//! `0.001` of the target; otherwise compute `duration = |target − live| / rate · factor` and hand
//! the tween to the inner. The step is `s = elapsed / duration`, `s ≥ 1` → `live = target`, else
//! the cosine smoothstep `0x5b7bb0` — `a + (b − a)·(1 − cos πs)/2`.
//!
//! **Two things a re-implementation gets wrong.** `+0x1dc`/`+0x1f4`/`+0x1ac`/`+0x1c4` are
//! **durations in seconds**, not rates — the consumer *divides* elapsed seconds by them; and the
//! rate is in the **live value's own units**, so the three angular channels convert their deg/s
//! CVar with `π/180` while the height channel's `1.2` is already yd/s. [`Arm::rate`] is therefore
//! stated in live-units/s and the conversion belongs to the caller, where the unit is known.
//!
//! **The delay is a HOLD here, and the reference's is not** — a named divergence. `0x512830` backs
//! the start time up by `delay` (`now − (int)(p2 · −1000)`), so `0x50f160`'s `s` goes *negative*
//! for that long; and because the profile is `cos(π·s)` — an even function — a negative `s`
//! evaluates the ease as though `|s|`, which makes the live value jump away from `from` at the
//! instant of arming and walk back. That is an unclamped divide, not an intent, and it is
//! unreachable at every default this client reads: `Delay` is `0.0` on all thirty rows of the
//! `cameraTerrainTilt<Style><State>` table, and both the bias and pitch arms pass `0` literally.
//! benilla holds at `from` until the delay elapses — [`super::camera::FollowRig`] already does
//! exactly that for the one family whose delay is genuinely nonzero (`Track`/`Fear` under Smart,
//! `0.4`), so this is the house reading, not a new one.
/// The channel's "already there / already arming this" epsilon — VERIFIED `0.001` (`[0x801360]`),
/// shared by all four instantiations and by the `0x5107f0`/`0x5106f0` displacement predicates.
pub(super) const CHANNEL_EPS: f32 = 0.001;
/// What one `arm` call asks for — the armer's four arguments plus the one caller-side clamp.
#[derive(Clone, Copy, Debug)]
pub(super) struct Arm {
/// Where the channel should end up, in the live value's units.
pub(super) target: f32,
/// Dead time before the tween starts, seconds (the module doc's named divergence).
pub(super) delay: f32,
/// Stretches the duration: `duration = |Δ| / rate · factor`. `1.0` is "at the rate".
pub(super) factor: f32,
/// The channel's rate **in the live value's units per second** — `cvar.to_radians()` for the
/// three angular channels, the raw yd/s for the height one.
pub(super) rate: f32,
/// The duration bound `(min, max)`, when the caller has one. Only the ground channel does:
/// `0x50dd29` clamps `[cam+0x1ac]` to `[Factor × cameraTerrainTiltTimeMin, Factor × …TimeMax]`
/// *after* the arm, which is why it is the armer's business and not the tween's.
pub(super) duration: Option<(f32, f32)>,
}
impl Arm {
/// An arm with no delay, no stretch and no duration bound — the shape both the pitch
/// (`0x512830(target, 0, 1.0f, now)`) and bias (`0x512a50(cam, 0, 0, 1.0f, now)`) sites pass.
pub(super) fn at(target: f32, rate: f32) -> Self {
Self {
target,
delay: 0.0,
factor: 1.0,
rate,
duration: None,
}
}
}
/// A tween in flight — the reference's `{start, target, startMs, duration}` quadruple plus the
/// `{p2, p3}` staleness memo, with the armed bit expressed as `Option<Flight>` rather than a bit.
#[derive(Clone, Copy, Debug)]
struct Flight {
/// Seconds since the arm, and the dead time in front of the tween.
elapsed: f32,
delay: f32,
/// The tween's own length, seconds — never a rate.
duration: f32,
/// What it was armed with (`+0x1ec`/`+0x1e8`), so a repeat asking for the same move is a
/// no-op instead of restarting it from its own midpoint.
memo: (f32, f32),
}
/// One smoothed scalar channel of the camera — see the module doc.
///
/// `Default` is the **linear** channel, because the one instantiation whose live value is not an
/// angle is the pivot height (yards) — see [`SmoothChannel::angular`].
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct SmoothChannel {
live: f32,
from: f32,
to: f32,
flight: Option<Flight>,
/// Does the armer's shortest-path rewrap apply? It is a **`2π` rewrap**, so it is meaningful
/// only on the three channels whose live value is an angle in radians; running it on the
/// height channel would fold yards around a circle.
wrap: bool,
}
/// What an [`SmoothChannel::arm`] did — the reference's own three-way return, kept because the
/// callers branch on it (`0x512a50` returns 1 for "already arming this", 0 for "already there").
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Armed {
/// A tween was started.
Started,
/// The channel is already arming this exact request — nothing changed.
Already,
/// The live value is already within [`CHANNEL_EPS`] of the target — nothing changed, and
/// nothing is in flight afterwards either.
AtRest,
}
impl SmoothChannel {
/// An **angular** channel — pitch, pitch-bias or ground tilt, whose live value is radians and
/// whose armer therefore rewraps it into `[target − π, target + π]` (`0x512abe`).
pub(super) fn angular() -> Self {
Self {
wrap: true,
..Self::default()
}
}
/// The value the camera uses this frame.
pub(super) fn live(&self) -> f32 {
self.live
}
/// Is a tween in flight? (The reference's armed bit of `[cam+0x90]`.)
pub(super) fn in_flight(&self) -> bool {
self.flight.is_some()
}
/// Establish the channel at `v` with nothing in flight — the reference's **hard snap**, which
/// is a real leg of two of the four: the water band-crossing writes `[cam+0xf4]` *and*
/// `[cam+0x1e0]` directly when `[cam+0x90] & 1` (`0x50ed13`), and the height channel's first
/// arm of a camera's life snaps (`0x5127d4`).
pub(super) fn snap(&mut self, v: f32) {
self.live = v;
self.from = v;
self.to = v;
self.flight = None;
}
/// The armer (`0x512830`/`0x512a50`/`0x512490`/`0x5126b0`), in its own order.
pub(super) fn arm(&mut self, arm: &Arm) -> Armed {
// The shortest-path rewrap: the live value is brought into `[target − π, target + π]`
// first, so a channel that has accumulated past a half-turn takes the short way round
// (`0x512abe fst` — it is stored back, not merely used for the compare).
if self.wrap {
let two_pi = std::f32::consts::TAU;
while self.live - arm.target > std::f32::consts::PI {
self.live -= two_pi;
}
while arm.target - self.live > std::f32::consts::PI {
self.live += two_pi;
}
}
if let Some(f) = self.flight {
if (self.to - arm.target).abs() < CHANNEL_EPS
&& (f.memo.0 - arm.delay).abs() < CHANNEL_EPS
&& (f.memo.1 - arm.factor).abs() < CHANNEL_EPS
{
return Armed::Already;
}
}
let gap = (arm.target - self.live).abs();
if gap < CHANNEL_EPS {
// Already there: park the target and disarm. This is the steady-state path, and it is
// what makes arming every frame identical to arming on change.
self.to = arm.target;
self.flight = None;
return Armed::AtRest;
}
let mut duration = gap / arm.rate.max(f32::EPSILON) * arm.factor;
if let Some((lo, hi)) = arm.duration {
duration = duration.clamp(lo, hi);
}
self.from = self.live;
self.to = arm.target;
self.flight = Some(Flight {
elapsed: 0.0,
delay: arm.delay,
duration: duration.max(f32::EPSILON),
memo: (arm.delay, arm.factor),
});
Armed::Started
}
/// **Shift the whole channel by `delta`** — live, start and target together, so a tween in
/// flight keeps its remaining travel and lands `delta` further along.
///
/// This is the reference's `0x510120`, which is not a "set the pitch" at all: it adds the same
/// delta to `[cam+0xf4]`, `[cam+0x1e4]` and `[cam+0x1e0]` and clamps each. That is what keeps
/// the live angle and the tween's endpoints in lockstep, and it is why a mouse drag during a
/// `*FinalPitch` ease carries the ease rather than cancelling it
/// (wow-re `camera-cvar-kernels.md` Q5).
pub(super) fn shift(&mut self, delta: f32) {
self.live += delta;
self.from += delta;
self.to += delta;
}
/// Step whatever is in flight and return the live value — `0x50f160`'s block, shared by all
/// four channels.
pub(super) fn advance(&mut self, dt: f32) -> f32 {
if let Some(f) = self.flight.as_mut() {
f.elapsed += dt;
let t = f.elapsed - f.delay;
if t >= 0.0 {
let s = t / f.duration;
if s >= 1.0 {
self.live = self.to;
self.flight = None;
} else {
// `0x5b7bb0` — the cosine smoothstep, eased at both ends.
let e = (1.0 - (std::f32::consts::PI * s).cos()) * 0.5;
self.live = self.from + (self.to - self.from) * e;
}
}
}
self.live
}
/// `(live, target)` — what a `WOW_CAM_DUMP` line prints. A channel question is a *timing*
/// question, and these two columns are how a trace answers it (method: timing is measured).
pub(super) fn probe(&self) -> (f32, f32) {
(self.live, self.to)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Step at 60 Hz for `secs` and return every live value it passed through.
fn run(c: &mut SmoothChannel, secs: f32) -> Vec<f32> {
let dt = 1.0 / 60.0;
(0..(secs / dt).round() as usize)
.map(|_| c.advance(dt))
.collect()
}
/// The template's duration law is `|Δ| / rate · factor`, and it is a **duration**, not a rate:
/// the tween takes exactly that long and the value arrives only at its end.
#[test]
fn the_duration_is_the_gap_over_the_rate_times_the_factor() {
for (gap, rate, factor) in [
(10.0_f32, 45.0_f32, 1.0_f32),
(30.0, 90.0, 2.0),
(1.0, 7.5, 1.0),
] {
let mut c = SmoothChannel::default();
let expected = gap / rate * factor;
assert_eq!(
c.arm(&Arm {
target: gap,
delay: 0.0,
factor,
rate,
duration: None
}),
Armed::Started
);
let frames = run(&mut c, expected * 2.0);
let arrived = frames
.iter()
.position(|v| (v - gap).abs() < CHANNEL_EPS)
.expect("arrives");
assert!(
(arrived as f32 / 60.0 - expected).abs() < 0.05,
"|Δ|/rate·factor = {expected:.3}s, took {:.3}s",
arrived as f32 / 60.0
);
assert!(!c.in_flight(), "and disarms on arrival");
}
}
/// Arming every frame with a steady target is exactly arming once — the property the whole
/// per-frame-arm design rests on. A memo that only compared the target would still restart the
/// tween whenever the *factor* changed, so both are in the compare.
#[test]
fn a_per_frame_re_arm_neither_restarts_nor_stretches_the_tween() {
let mut once = SmoothChannel::default();
let mut every = SmoothChannel::default();
// Rate 0.5 over a gap of 1.0 = a 2 s tween, so the second below is spent mid-flight.
let arm = Arm::at(1.0, 0.5);
once.arm(&arm);
every.arm(&arm);
let dt = 1.0 / 60.0;
for _ in 0..60 {
assert_eq!(every.arm(&arm), Armed::Already);
assert_eq!(every.advance(dt), once.advance(dt));
}
// …and once it is there, the re-arm reports rest rather than starting a zero move.
run(&mut once, 1.5);
run(&mut every, 1.5);
assert_eq!(every.arm(&arm), Armed::AtRest);
assert!(!every.in_flight());
}
/// The rewrap takes the short way round: a channel sitting just under `+π` asked for a target
/// just over `−π` travels the small gap, not the long one.
#[test]
fn the_arm_rewraps_the_live_value_to_the_short_side() {
let mut c = SmoothChannel::angular();
c.snap(3.1);
let target = -3.1_f32;
c.arm(&Arm::at(target, 1.0));
// The gap the tween covers is the short one (≈0.083), not 6.2.
let dur = (target - (3.1 - std::f32::consts::TAU)).abs();
let frames = run(&mut c, 1.0);
assert!(
(frames[frames.len() - 1] - target).abs() < CHANNEL_EPS,
"arrives at the target"
);
assert!(dur < 0.1, "the short way is {dur}");
// Nothing in between left the short arc `[3.1 − 2π, −3.1]`.
let wrapped = 3.1 - std::f32::consts::TAU;
assert!(frames
.iter()
.all(|v| *v >= wrapped - CHANNEL_EPS && *v <= target + CHANNEL_EPS));
// …and a LINEAR channel of the same numbers does not wrap: it travels the long way,
// because yards are not radians.
let mut linear = SmoothChannel::default();
linear.snap(3.1);
linear.arm(&Arm::at(target, 1.0));
assert!(run(&mut linear, 0.5).iter().any(|v| *v > 0.0));
}
/// The delay holds at the start value and then runs the full tween — the module doc's named
/// divergence, pinned so it cannot silently become the reference's negative-`s` jump.
#[test]
fn a_delay_holds_the_channel_before_the_tween_rather_than_jumping_it() {
let mut c = SmoothChannel::default();
c.arm(&Arm {
target: 1.0,
delay: 0.5,
factor: 1.0,
rate: 1.0,
duration: None,
});
let frames = run(&mut c, 0.45);
assert!(frames.iter().all(|v| *v == 0.0), "held for the delay");
let frames = run(&mut c, 1.1);
assert!((frames[frames.len() - 1] - 1.0).abs() < CHANNEL_EPS);
}
/// The ground channel's own clamp (`0x50dd29`): the duration, not the rate, is bounded.
#[test]
fn a_duration_bound_clamps_the_tween_not_the_gap() {
let mut c = SmoothChannel::default();
c.arm(&Arm {
target: 100.0,
delay: 0.0,
factor: 1.0,
rate: 7.5,
duration: Some((0.1, 0.5)),
});
let frames = run(&mut c, 0.6);
let arrived = frames
.iter()
.position(|v| (v - 100.0).abs() < CHANNEL_EPS)
.expect("arrives");
assert!(
(arrived as f32 / 60.0 - 0.5).abs() < 0.05,
"|Δ|/rate would be 13.3 s; the bound caps it at 0.5"
);
}
}
File diff suppressed because it is too large Load Diff
+58 -6
View File
@@ -36,6 +36,8 @@ pub(super) fn control(
Res<camera::LookConfig>,
Res<camera::ZoomLimit>,
Res<camera::FollowConfig>,
// The four 1.12 camera option toggles and their numeric siblings (decision 2149).
Res<camera_dynamics::CameraOptions>,
),
// The net bridge, bundled into one param (16-param limit): the outbound command channel + the
// inbound teleport/worldport messages `apply_net_updates` wrote earlier this frame
@@ -179,7 +181,6 @@ pub(super) fn control(
},
..*pointer.3
};
let dt = time.delta_secs();
// While a focused UI EditBox (the chat input, a mail field) owns the keyboard, keyboard reads see
// "no keys held" — so the avatar isn't also driven while typing (a `.tele` command). Mouse still
@@ -204,6 +205,47 @@ pub(super) fn control(
follow_command,
} = input::look_input(binds, &player, &rig);
// The camera-option inputs (decision 2149). `translating` is **last frame's** wire word, and
// deliberately: the reference's `0x50fee0` runs from the input handler `0x514446`, which
// precedes the mover lookup, so it reads the movement flags the previous update left. It is
// also all this path can read — `flags::this_frame` runs several hundred lines below, after
// the mover.
let dynamics = camera_dynamics::DynamicsInput {
options: *pointer.4,
smooth_style: pointer.3.style,
subject: camera_dynamics::SubjectState {
move_flags: player.move_flags,
// Off the DRIVEN body's descriptor (1277) — a possessed creature's taxi state is what
// the camera follows, not ours.
taxi: body
.single()
.ok()
.and_then(|(_, _, _, _, _, store, ..)| store)
.is_some_and(|s| s.0.unit_flags() & 0x0010_0000 != 0),
track: player.server_riding,
fear: player.control_lost,
facing: player.face_yaw,
mounted: body
.single()
.ok()
.and_then(|(_, _, _, _, _, store, ..)| store)
.is_some_and(|s| s.0.unit_mount_display_id() > 0),
// `GetCurrentSpeed 0x7c4c90` on the mover's own speed set, against LAST frame's flag
// word — the same pairing the reference's bob kernel makes, and the only speed either
// camera seat below can ask for (the driving path's own is computed hundreds of lines
// later, and the stand-down path never computes one at all).
speed: body
.single()
.ok()
.and_then(|(_, _, _, _, _, _, _, speeds, ..)| speeds)
.map_or(0.0, |s| crate::net::current_speed(&s.0, player.move_flags)),
command: follow_command,
scoped: scoped.active(),
},
// Resolved inside `seat_on_subject`, which owns the far-sight substitution.
liquid: camera_dynamics::SubjectLiquid::default(),
};
// The look session gets a SHADOW copy of `CursorOptions`, written back only on a real change:
// handing it the component's `Mut` directly reborrowed mutably every frame, which marks it
// Changed regardless of writes — and bevy_winit's `changed_cursor_options` then re-applied
@@ -316,6 +358,7 @@ pub(super) fn control(
left_click,
right_click,
look_cfg,
&dynamics,
time.elapsed_secs(),
);
// A stun freezes the BODY, not the view. The look session has already moved `cam.yaw` (and, on
@@ -493,6 +536,8 @@ pub(super) fn control(
face_yaw: player.face_yaw,
command: follow_command,
},
&dynamics,
world,
);
// Flush a stale run once, so observers stop extrapolating it — but never under a ride,
// whose FORWARD report is deliberate and would be cancelled every frame.
@@ -817,11 +862,16 @@ pub(super) fn control(
// The push is **per mouse-move, not per frame** ([`Player::aim_pitch_seen`]): the ref's
// enqueue hangs off the mouse-MOTION event `0x400500cb`, so a still mouse pushes nothing
// and the other writers of the field — the wobble, StopSwim's levelling — survive.
if mouselook && cam.pitch != player.aim_pitch_seen {
player.aim_pitch_seen = cam.pitch;
player.mover_pitch = cam
.pitch
.clamp(-MOUSELOOK_PITCH_CLAMP, MOUSELOOK_PITCH_CLAMP);
//
// **The aim is the COMPOSITE, not the camera's own pitch** (decision 2149): the reference's
// camera→body hand-off `0x5103e0` clamps `[cam+0x104] + [cam+0xf4]` to ±89° before passing
// the pitch on, so a smart-pivot frame — where the arm's pitch does not move and the view's
// does — still aims the body. The bias is zero except on those frames, so this reads as
// `cam.pitch` everywhere else.
let aim_pitch = cam.pitch + rig.smart_pivot.bias();
if mouselook && aim_pitch != player.aim_pitch_seen {
player.aim_pitch_seen = aim_pitch;
player.mover_pitch = aim_pitch.clamp(-MOUSELOOK_PITCH_CLAMP, MOUSELOOK_PITCH_CLAMP);
}
let mut swim_pitch = 0.0_f32;
// The ground height the mover starts this frame at (pre-step feet Y). For a jump this is the
@@ -1058,6 +1108,8 @@ pub(super) fn control(
&collide,
cam_probe,
&follow,
&dynamics,
world,
);
// The cast bar's local self-cancel trigger (`ui_cast::local_self_cancel`): a fresh
+66
View File
@@ -560,6 +560,29 @@ pub(crate) fn unit_guild(
})
}
/// A unit's guild NAME alone — the **a5 line of the overhead name stack** (`"\n<%s>"`,
/// `0x860f9c`), which the reference resolves through the very same guild-identity cache this
/// serves `GetGuildInfo` from: `0x609085` tests the render mask's bit `0x10`
/// (`UnitNamePlayerGuild`) and then reads `0x5e09f0` off the unit's own `[CGUnit+0xe68]+0x8/0xc`
/// guild GUID (wow-re `object-layer/scratch/overhead-name.md` Q4 point 3).
///
/// [`unit_guild`] without the rank — and, deliberately, **without its two `String` clones**: this
/// is read once per shown player per frame by [`crate::nameplates::drive_nameplates`], whose whole
/// steady-frame design is that a name nothing changed allocates nothing.
///
/// The `None` legs are [`unit_guild`]'s, unchanged: a guildless player, a creature, and a guild id
/// whose `CMSG_GUILD_QUERY` has not answered yet — the last of which is also what *sends* it, so
/// the line appears a round trip later and the rebuild arm picks it up on that frame.
pub(crate) fn unit_guild_name<'a>(
fields: &ObjectFields,
guild: &'a mut GuildState,
commands: &NetCommands,
) -> Option<&'a str> {
guild
.resolve_identity(fields.player_guild_id(), commands)
.map(|identity| identity.name.as_str())
}
/// A unit's guild **tabard**, for the body composite — the emblem five of `SMSG_GUILD_QUERY_RESPONSE`,
/// joined off the unit's own PUBLIC `PLAYER_GUILDID` and asking for the identity if we do not hold
/// it, exactly like [`unit_guild`].
@@ -1207,6 +1230,49 @@ mod tests {
);
}
/// The overhead a5 line's reader answers exactly what `GetGuildInfo`'s does, on the same
/// three legs and with the same lazy ask — the one thing that could silently drift between
/// them is which of the two sends the query, and neither may skip it.
#[test]
fn unit_guild_name_is_unit_guilds_name_on_every_leg() {
let (commands, rx) = net_commands();
let mut guild = GuildState::default();
guild
.identities
.insert(7, identity("Legacy", &["GM", "Off"]));
// The negative cache: a query that came back empty is "no such guild", not a blank name.
guild.identities.insert(8, identity("", &[]));
let guildless = ObjectFields::from_pairs(&[]);
assert_eq!(unit_guild_name(&guildless, &mut guild, &commands), None);
assert_eq!(rx.try_iter().count(), 0, "guild id 0 asks nothing");
let unknown = ObjectFields::from_pairs(&[(191, 9)]);
assert_eq!(
unit_guild_name(&unknown, &mut guild, &commands),
None,
"a query in flight draws no line"
);
assert_eq!(rx.try_iter().count(), 1, "and the miss asked for it");
let blank = ObjectFields::from_pairs(&[(191, 8)]);
assert_eq!(unit_guild_name(&blank, &mut guild, &commands), None);
assert_eq!(
rx.try_iter().count(),
0,
"the negative cache re-asks nothing"
);
let member = ObjectFields::from_pairs(&[(191, 7), (192, 1)]);
assert_eq!(
unit_guild_name(&member, &mut guild, &commands),
Some("Legacy")
);
let via_line = unit_guild_name(&member, &mut guild, &commands).map(str::to_owned);
let via_api = unit_guild(&member, &mut guild, &commands).map(|g| g.name);
assert_eq!(via_line, via_api, "the two readers of one cache disagree");
}
/// A command channel whose receiver stays alive, so a send neither blocks nor is dropped.
fn net_commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
@@ -394,6 +394,7 @@ fn verify_splice(
s: f32,
w: f32,
h: f32,
dpi: f32,
assets: &mut Option<ResMut<WorldAssets>>,
images: &mut Assets<Image>,
font_atlas: &mut Option<ResMut<UiFontAtlas>>,
@@ -411,6 +412,7 @@ fn verify_splice(
s,
w,
h,
dpi,
assets,
images,
font_atlas,
@@ -1001,6 +1003,7 @@ pub(super) fn drive_script(
s,
w,
h,
dpi,
&mut assets,
&mut images,
&mut font_atlas,
@@ -1092,6 +1095,7 @@ pub(super) fn drive_script(
s,
w,
h,
dpi,
&mut assets,
&mut images,
&mut font_atlas,
@@ -1153,6 +1157,7 @@ pub(super) fn drive_script(
s,
w,
h,
dpi,
&mut assets,
&mut images,
&mut font_atlas,
@@ -1281,6 +1286,9 @@ fn convert_entry(
// particle unit run on the screen diagonal (decision 2013).
w: f32,
h: f32,
// The window's DEVICE scale, for the one arm that resamples its art to physical pixels: the
// nameplate border's 0188 sharpen, which is a no-op at any other size.
dpi: f32,
assets: &mut Option<ResMut<WorldAssets>>,
images: &mut Assets<Image>,
font_atlas: &mut Option<ResMut<UiFontAtlas>>,
@@ -1587,7 +1595,22 @@ fn convert_entry(
// generated 128×64 / 128×32 image — white RGB carrying the emblem BLP's own
// alpha — into the Texture the setter was handed; the region carries that as
// a token path and the resolver builds exactly that image.
let resolved = if let Some(blp) = benilla_ui::script::emblem_mask_path(p) {
let resolved = if p == benilla_ui::script::nameplate::BORDER_TEXTURE {
// The V-plate's frame art, resampled to the quad's exact PHYSICAL size
// with the sharp kernel instead of GPU-magnified (0188 — the director's
// "sharpen the same frame", carried across decision 2148's move of the
// plate into the frame system). Keyed by that size, so it re-rasterises on
// a resize and is a cache hit on every frame in between. An addon that
// blanks the border with `SetTexture("")` never reaches here at all: the
// path is gone, and the arm above draws nothing.
let px = |v: f32| (v * dpi).round().max(1.0) as u32;
a.resampled_sprite(
p,
(px(rect.width()), px(rect.height())),
images,
crate::vplates::border::resample_sharp,
)
} else if let Some(blp) = benilla_ui::script::emblem_mask_path(p) {
a.emblem_mask_texture(blp, images)
} else if circular {
a.portrait_texture(p, images)
@@ -4395,16 +4395,6 @@ const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
keyboard controller and /follow (mode 3). Registered default is \"0\" on every locale but \
koKR (`0x603374` selects on the locale index), so stock West ships it OFF",
),
(
"UnitNamePlayerGuild",
"the `\\n<%s>` guild line in the OVERHEAD name stack — slot a5 of `0x608f50`, gated by bit \
`0x10` of the render mask `[0xce8720]`, player branch only, text from `0x5e09f0` through \
the WGLD guild-identity cache. NOT the V-key nameplate (`ShouldShowName 0x6070a0` \
returns 0 whenever a plate frame is live) and NOT the tooltip (FrameXML's own \
`GetGuildInfo`). The data resolves here now — `ui_guild::GuildState` serves \
`GetGuildInfo` — so what is missing is the third line in `nameplates::drive_nameplates` \
plus its `lines_current` comparator arm and that comparator's differential test",
),
(
"UnitNamePlayerPVPTitle",
"the PvP rank prefix on the overhead name line — slot a4 of `0x608f50`, bit `0x20` of the \
@@ -4412,44 +4402,6 @@ const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
one step further back than its guild twin: the rank byte streams, but the key's second \
index is a FACTION SIDE that `ui_unit` does not resolve for an arbitrary player yet",
),
(
"cameraTerrainTilt",
"the camera's ground-pitch channel. VERIFIED end to end by the §5 round this work \
dispatched (wow-re `ui/scratch/camera-cvar-gates.md`): the CVar gates predicate \
`0x5105a0`, whose false leg zeroes `[cam+0xa0]` at `0x50d922` and skips both `0x672170` \
terrain raycasts; `0x50d900` forms `slope = (Δz + 5/3)/√(Δx²+Δy²)`, walks the 10-entry \
slope→pitch table at `0x808a40`, clamps ±20°, throttled to 100 ms. Registered \"0\", so \
building it changes nothing until a player asks",
),
(
"cameraBobbing",
"head bob. VERIFIED by the same §5 round, including the threshold nobody had read: \
`[0x8089ac] = 1/6` is a camera DISTANCE and the compare is inclusive, so head bob is \
FIRST-PERSON ONLY. `0x511920` computes `ampH/ampV` from the two amplitude CVars × 1/36, \
`speed = clamp(moveSpeed/7.2, [0.5,1.5])`, horizontal at `freq` and vertical at `2×freq`, \
into the same eye-translation accumulator as the shake; `0x5106f0` is the DECAY \
predicate and fades the residual rather than snapping it. Registered \"0\"",
),
(
"cameraWaterCollision",
"two consumers, both VERIFIED by the §5 round. (1) the collision sweep's class mask \
becomes `0x1f0171` instead of `0x100171` — the `0xf0000` nibble is the ADT liquid layers \
— and `0x511ad0`'s bands re-base the pivot floor/cap. (2) a band CROSSING (not a level \
test: `0x50eb14` snapshots the previous frame's bits before `0x511ad0` rewrites them) \
arms a 5° pitch through the `+0xf4` channel. Registered \"1\", so this one is ON in the \
reference and benilla is the divergence — but the eye/arm verdict of \
`camera-arm-liquid-blind.md` still stands: it is the PIVOT that is liquid-aware",
),
(
"cameraPivot",
"smart pivot. VERIFIED by the §5 round, which also corrected the ±89° claim: predicate \
`0x510690` is `enabled ∧ TYPEMASK_UNIT ∧ not moving/strafing (`&0xf`, so turning in place \
passes) ∧ pitched down ∧ the collision solver's own clip flags `0x30000` nonzero` — i.e. \
it only engages in a frame the camera was ACTUALLY clipped. A mostly-vertical drag then \
routes into the pitch-BIAS channel `cam+0x104` instead of the pitch integrator, with a \
one-sided floor, and `0x5107f0` eases it back at `cameraTargetSmoothSpeed` when the \
predicate drops. Registered \"1\"",
),
// **The SLIDERS' half is empty, and that is the point.** 2115 extended this census from
// `UIOptionsFrameCheckButtons` to `UIOptionsFrameSliders` and it caught exactly one row —
// `cameraYawMoveSpeed`, the MOUSE_LOOK_SPEED slider — which was not cosmetic:
@@ -216,6 +216,11 @@ mod tests {
// re-seats off `!script.has_text_measurer()` — it interrogates the VM instead of a memo,
// which is the same guarantee arrived at the other way.
("ui_script/extract/mod.rs", "last_seam"),
// The plate driver's anti-overlap scratch (decision 2148 put the VM in that system's
// hands). It is cleared at the top of every run and rebuilt from this frame's plates —
// a reused allocation, not memory: nothing in it survives the call it was filled in, so
// a new VM has nothing to stale.
("vplates.rs", "bucket"),
// The window's `scale_factor` beside it (decision 1342) — the other term a measure is
// only correct under, since a logical height becomes an integer DEVICE-pixel raster size.
// A fact about the window, not about the VM; it gates the same re-seat `last_seam` does,
+112 -379
View File
@@ -79,20 +79,17 @@ use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use benilla_protocol::EntityKind;
use benilla_ui::script::{unit_is_grey, JustifyH, JustifyV, Outline};
use benilla_ui::script::{unit_is_grey, PlateGeometry, PlateState};
use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
use crate::names::NameCache;
use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, Reputations, SelfPlayer};
use crate::target::{ring_reaction, Factions, Hovered, Selection, TargetUpdate};
use crate::ui_pass::{overlay_z, UiQuad, UiQuadAppend, UiQuads, UvRect};
use crate::ui_text::{layout_text_quads, FontSpec, Justify, UiFontAtlas};
use benilla_assets::{AssetSet, WorldAssets};
use benilla_world::view::WorldCamera;
/// Sharp-resampling the frame border ([`border::resample_sharp`]) so the 128×32 art reads crisp at
/// the plate's larger size instead of bilinear-magnified soft (0188).
mod border;
pub(crate) mod border;
/// The master bitmask (`[0xc4da34]`): bit 0 enemy plates, bit 3 friendly. **Both boot OFF, the
/// real client's own state** — see the derivation below. Enemy plates booted ON here from the 0167
@@ -144,13 +141,6 @@ pub(crate) struct VPlates(pub(crate) EntityHashSet);
#[derive(Resource, Default)]
pub(crate) struct PlateRects(pub(crate) Vec<(Rect, Entity)>);
/// The plate's textures — its own art, chain-verified present (§7 draw list). The name
/// `NAMEPLATE_FONT` resolves to Friz Quadrata, our atlas default.
const BAR_TEXTURE: &str = "Interface\\TargetingFrame\\UI-TargetingFrame-BarFill";
const BORDER_TEXTURE: &str = "Interface\\Tooltips\\Nameplate-Border";
const SKULL_TEXTURE: &str = "Interface\\TargetingFrame\\UI-TargetingFrame-Skull";
const RAID_TEXTURE: &str = "Interface\\TargetingFrame\\UI-RaidTargetingIcons";
/// The plate frame, gx screen-height units (`[0x87d9cc]`/`[0x87d9d0]`): 0.1 × 0.025. The border
/// SetAllPoints-fills it; everything else anchors inside it (§7, byte-verified offsets).
const PLATE_W: f32 = 0.1;
@@ -184,25 +174,6 @@ const MAX_DIST_SQ: f32 = 20.0 * 20.0;
/// (2026-07-07) from the faithful `0x7F` (0.5), which faded the other plates too hard; raised so
/// non-target plates stay readable. Tunable: 0.5 = the byte law, 1.0 = no dim.
const DIM_ALPHA: f32 = 178.0 / 255.0;
/// The LIT (mouseover ∪ target) bar brighten — a uniform multiplicative lift of the fill tint
/// (director-pinned form: brighter colour, gradient untouched). Quad colors 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, 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;
/// The plate quads' z keys — back→front: bar fill, then the border OVER it (the border art's
/// rounded inner bevels cap the fill's square ends and crop the gradient's soft edges — the
/// reference look, director-pinned from a ref crop 2026-07-07; corrects the §7 transcription's
/// border-first order), then texts → skull; all above the floating combat text and the chat
/// bubbles (the append lane's lower bands, [`crate::ui_pass::overlay_z`]) and far below every
/// scripted-UI packed key.
const Z_FILL: u64 = overlay_z::VPLATE;
const Z_BORDER: u64 = overlay_z::VPLATE + 1;
const Z_TEXT: u64 = overlay_z::VPLATE + 2;
const Z_RAID: u64 = overlay_z::VPLATE + 3;
const Z_SKULL: u64 = overlay_z::VPLATE + 4;
/// The bar-fill palette — the byte-confirmed dwords (`0xcf60d0/e8/c8/dc`): pure
/// red/blue/yellow/green (NOT the ring's pale player-blue). Client-space sRGB, the [`UiQuads`]
@@ -461,69 +432,6 @@ struct PlateWorld<'w, 's> {
window: Query<'w, 's, &'static Window, With<bevy::window::PrimaryWindow>>,
}
/// The plate bitmap art, loaded once at STARTUP (not lazily on the first shown frame — the
/// director's first-toggle screenshot caught plates drawing before their textures had decoded).
/// The frame border is NOT here — it lives in [`PlateBorder`], resampled per size.
#[derive(Resource)]
struct PlateArt {
fill: Handle<Image>,
skull: Handle<Image>,
raid_icons: Handle<Image>,
}
/// The frame border: the decoded `Nameplate-Border` BLP (`src`, the exact 128×32 art) plus the
/// sharpened texture cached at the plate's current physical size. Blitted at the plate's larger
/// size the GPU bilinear-magnifies the BLP into a soft blur, so instead we resample the SAME pixels
/// to the display size with sharp bilinear ([`border::resample_sharp`]) — crisp edges, same art
/// (0188, the director's "sharpen the same frame"). Redrawn only when the size changes.
#[derive(Resource)]
struct PlateBorder {
src_w: u32,
src_h: u32,
src: Vec<u8>,
size: Option<(u32, u32)>,
handle: Option<Handle<Image>>,
}
/// Warm the plate textures at boot so the first V press draws complete plates. Through
/// [`WorldAssets::sprite_texture`] — the player-UI decode (**sRGB**, clamp, mip 0), the format the
/// UI pass's linearize-multiply-reencode contract requires. The raw `asset_server.load` this
/// shipped with took the `WorldArt` default (gamma-space `Unorm`, repeat): every plate texel got
/// sampled as linear and re-encoded brighter — the director's washed-out lime-instead-of-gold.
fn load_plate_art(
mut commands: Commands,
assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
) {
let Some(mut assets) = assets else {
return; // no game data (bare test app) — drive_vplates tolerates the missing resource
};
let fill = assets.sprite_texture(BAR_TEXTURE, &mut images);
let skull = assets.sprite_texture(SKULL_TEXTURE, &mut images);
let raid_icons = assets.sprite_texture(RAID_TEXTURE, &mut images);
let (Some(fill), Some(skull), Some(raid_icons)) = (fill, skull, raid_icons) else {
warn!("vplates: plate art missing from the patch chain — plates will not draw");
return;
};
// Decode the border BLP's raw pixels once; drive_vplates resamples them per plate size.
let Some((src_w, src_h, src)) = assets.decode_rgba(BORDER_TEXTURE) else {
warn!("vplates: border art missing from the patch chain — plates will not draw");
return;
};
commands.insert_resource(PlateArt {
fill,
skull,
raid_icons,
});
commands.insert_resource(PlateBorder {
src_w,
src_h,
src,
size: None,
handle: None,
});
}
/// Gate + draw, every frame: decide which units carry a plate (into [`VPlates`], the
/// name-exclusivity verdict) and append the §7 draw list — border, left-cropped reaction fill,
/// shadowed name (yellow under the mouse), con-colored level or the skull — at constant
@@ -537,12 +445,11 @@ fn drive_vplates(
world: PlateWorld,
mut names: ResMut<NameCache>,
net_commands: Res<NetCommands>,
mut atlas: Option<ResMut<UiFontAtlas>>,
mut quads: ResMut<UiQuads>,
art: Option<Res<PlateArt>>,
// The border source + size-keyed cache, and the image store it resamples into.
plate_border: Option<ResMut<PlateBorder>>,
mut images: ResMut<Assets<Image>>,
// The widget layer this drives (decision 2148). `None` in a VM-less run (a capture with the
// interface off, a bare test app) — the gate below then costs one early return.
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
// The seam this frame's px↔unit conversion runs at (`windowH/768 × uiScale`).
ui_scale: Res<crate::ui_script::UiScaleCvar>,
// The overhead-anchor inputs ([`overhead_anchor`]).
anchor_q: (
Query<&BoneAttach>,
@@ -563,13 +470,9 @@ fn drive_vplates(
if !mode.enemies && !mode.friends {
return;
}
let (Ok((cam, cam_pose)), Ok((self_tf, self_store)), Some(atlas), Some(art), Some(mut border)) = (
world.camera.single(),
world.self_q.single(),
atlas.as_mut(),
art.as_deref(),
plate_border,
) else {
let (Ok((cam, cam_pose)), Ok((self_tf, self_store)), Some(mut script)) =
(world.camera.single(), world.self_q.single(), script)
else {
return;
};
let cam_tf = GlobalTransform::from(*cam_pose);
@@ -583,24 +486,15 @@ fn drive_vplates(
let my_level = self_store.and_then(|s| s.0.unit_level()).unwrap_or(1);
let has_target = world.selection.target.is_some();
// The plate frame is constant-size this frame; resample the 128×32 border BLP to its exact
// PHYSICAL pixel size (logical × scale_factor — like the glyph atlas) with sharp bilinear, and
// cache it. Redrawn only when that size changes; drawn 1:1 so the SAME art reads crisp instead
// of GPU-bilinear-magnified soft (0188 — the director's "sharpen the same frame").
let (pw, ph) = (gx(PLATE_W), gx(PLATE_H));
let scale = window.map_or(1.0, |w| w.scale_factor());
let tex = (
(pw * scale).round().max(1.0) as u32,
(ph * scale).round().max(1.0) as u32,
);
if border.size != Some(tex) {
let rgba = border::resample_sharp(&border.src, border.src_w, border.src_h, tex.0, tex.1);
border.handle = Some(images.add(benilla_assets::sprite_image(tex.0, tex.1, rgba)));
border.size = Some(tex);
}
let Some(border_tex) = border.handle.clone() else {
return;
};
// The px↔unit seam. Every number below is computed in window px exactly as it always was —
// the projection, the seat, the solve, the device snap — and divided through this once, at
// the boundary, because the widget layer speaks FrameXML units.
let seam = window.map_or(1.0, |w| {
crate::ui_script::seam_scale(w.height(), ui_scale.0)
});
let mut states: Vec<PlateState> = Vec::new();
// The seat PRIORITY (`0x608870`/`0x6089a0` + the per-frame walk `0x608ce0`): plates seat in
// squared-distance order from the fixed gx point (0.4, 0.3) — the 4:3 screen center, a
@@ -741,10 +635,6 @@ fn drive_vplates(
};
// The bar tint: the NAMEPLATE palette in `0x7cbaa0`'s exact order.
let tint = plate_tint(rank, is_player);
let frac = store
.and_then(|s| Some(s.0.unit_health()? as f32 / s.0.unit_max_health()?.max(1) as f32))
.unwrap_or(1.0)
.clamp(0.0, 1.0);
// The frame SEAT (§8 Q5, byte-confirmed): the plate's TOP-CENTER lands on the projected
// point — the plate HANGS BELOW head + 2/3 yd — and the point is edge-clamped half a
// plate inside every screen border (`SetPoint(TOP ← root.BOTTOMLEFT, clampedX/Y)`).
@@ -823,258 +713,91 @@ fn drive_vplates(
screen.x, screen.y, plate.min.x, plate.min.y, plate.max.x, plate.max.y
);
}
// The border, drawn OVER the fill (z 5 > z 4): its rounded inner bevels cap the fill's
// square ends and crop the gradient's soft top/bottom rows — the reference look
// (director-pinned from a ref crop; corrects the §7 transcription's border-first order).
quads.overlays.push(UiQuad {
rect: plate,
z_key: Z_BORDER,
texture: Some(border_tex.clone()),
uv: UvRect::FULL,
color: [1.0, 1.0, 1.0, alpha],
..default()
});
// The health fill, bottommost — a left-anchored CROP (quad right = left + frac·width,
// u1 = frac; byte-verified `SetValue → 0x770410`), reaction-tinted, instant. No backing
// quad — the border alone sits behind missing health.
let bar_left = plate.min.x + gx(BAR_OFF_X);
let bar_bottom = plate.max.y - gx(BAR_OFF_Y);
let bar = Rect::new(
bar_left,
bar_bottom - gx(BAR_H),
bar_left + gx(BAR_W),
bar_bottom,
);
if trace {
eprintln!(
"vplate-trace: fill=({:.1},{:.1})..({:.1},{:.1}) frac={frac:.3}",
bar.min.x, bar.min.y, bar.max.x, bar.max.y
);
}
// The highlight: LIT (mouseover ∪ target), the bar's own colour simply brightens — a
// uniform [`LIT_BOOST`] lift of the fill tint, nothing else changes (director-pinned
// 2026-07-07: no rim, no gradient change — the additive Nameplate-Glow rim we tried
// first read as hard edge lines, see 0184).
let boost = if lit { LIT_BOOST } else { 1.0 };
quads.overlays.push(UiQuad {
rect: Rect::new(
bar.min.x,
bar.min.y,
bar.min.x + bar.width() * frac,
bar.max.y,
),
z_key: Z_FILL,
texture: Some(art.fill.clone()),
uv: UvRect {
corners: [[0.0, 0.0], [frac, 0.0], [frac, 1.0], [0.0, 1.0]],
},
color: [tint[0] * boost, tint[1] * boost, tint[2] * boost, alpha],
..default()
});
// Director-tuned (2026-07-07): a constant 1 logical px. The recorded ±0.001 gx rounds
// to 1 px at the ref's 1152×648 but 2 px at larger windows — which read chunky.
let shadow = 1.0;
// The name — its BOTTOM at the plate CENTER, white (yellow while hovered, never
// reaction-colored), with the FontString's black drop shadow.
if let Some(name) = names.resolve(guid.0, &net_commands).map(str::to_owned) {
let color = if hover {
[1.0, 1.0, 0.0, alpha]
} else {
[1.0, 1.0, 1.0, alpha]
};
plate_text(
atlas,
&mut quads,
&name,
Vec2::new(
(plate.min.x + plate.max.x) * 0.5,
(plate.min.y + plate.max.y) * 0.5,
),
true,
text_px(NAME_H, basis),
shadow,
color,
trace,
);
}
// The level element — CENTER at plate BOTTOMRIGHT + (−0.0092, +0.0071): the skull
// replaces the number for a hostile ≥ 10 levels up that isn't trivial-gray (the
// world-boss rank leg waits on caching the query's rank field; the exact trivial
// predicate `0x5f0700` is INFERRED — we use the con gray).
let lvl_at = Vec2::new(plate.max.x - gx(LEVEL_OFF_X), plate.max.y - gx(LEVEL_OFF_Y));
if let Some(level) = store.and_then(|s| s.0.unit_level()) {
let con = con_color(my_level, level);
// The skull's two legs (`0x7cbb40`, §7-VERIFIED): a WORLD BOSS — creature-
// classification rank 3, from the ask-once creature record (it lands with the
// name query; a miss just means no skull yet) — unconditionally; or a hostile
// ≥ 10 levels up that isn't trivial-grey (`0x5f0700`, the shared grey check).
// The rank goes through the client's own getter (`gated_rank`, decision 0782), which is
// what makes a MIND-CONTROLLED world boss show its NUMBER here rather than a skull:
// `0x7cbb40` reads rank via `0x605620`, pet gate and all.
let world_boss = crate::names::gated_rank(
benilla_protocol::guid::entry(guid.0).and_then(|e| names.creature_record(e)),
store,
) == 3;
if world_boss || (rank <= 1 && level >= my_level + 10 && !unit_is_grey(my_level, level))
{
let half = gx(SKULL_SIZE) * 0.5;
quads.overlays.push(UiQuad {
rect: Rect::new(
lvl_at.x - half,
lvl_at.y - half,
lvl_at.x + half,
lvl_at.y + half,
),
z_key: Z_SKULL,
texture: Some(art.skull.clone()),
uv: UvRect::FULL,
color: [1.0, 1.0, 1.0, alpha],
..default()
});
} else {
let mut c = con;
c[3] *= alpha;
plate_text(
atlas,
&mut quads,
&level.to_string(),
lvl_at,
false,
text_px(LEVEL_H, basis),
shadow,
c,
trace,
);
}
}
// The raid icon (vkey §7 + "Raid icon", VERIFIED — the 0434 §6 board finally streams):
// shown iff the unit's guid holds a mark; RIGHT ← border.LEFT (0,0) so it hangs off the
// plate's left edge, 0.02×0.02, the 4-column atlas cell (col=idx&3, row=idx>>2, cell
// 0.25). Drawn between the level and the skull (the §7 draw order).
// ── The plate's state, handed to the widget layer ─────────────────────────────────
//
// Everything above is unchanged: the gate, the anchor, the projection's ACCEPT verdict,
// the distance-sorted seat and the bucket solve are all about the WORLD, and they stay
// the app's. What changed in decision 2148 is only what happens with the answer — it used
// to become six quads, and it now becomes one [`PlateState`] for a real widget, which the
// shared frame→quad path draws and an addon can read, hook, restyle or take over.
//
// The seam scale (`windowH/768 × uiScale`) converts our window px into the FrameXML units
// the widget layer speaks. Dividing it out here is deliberate and is a DIVERGENCE worth
// naming: the reference's plates are outside the `uiScale` cascade (uiScale is
// `UIParent`'s own frame scale there, and the WorldFrame is a sibling root), while
// benilla folds uiScale into one global seam (0582/0584). Until that seam grows a
// per-cascade answer, the driver compensates, so a plate's PIXELS stay uiScale-blind the
// way the reference's are.
// The raid-target board (decision 0434 §6): 1-based here, 0-based for the atlas cell.
let mark = group.raid_target_index(guid.0);
if mark >= 1 {
let i = u32::from(mark - 1);
let (u0, v0) = ((i & 3) as f32 * 0.25, (i >> 2) as f32 * 0.25);
let size = gx(RAID_ICON_SIZE);
let cy = (plate.min.y + plate.max.y) * 0.5;
quads.overlays.push(UiQuad {
rect: Rect::new(
plate.min.x - size,
cy - size * 0.5,
plate.min.x,
cy + size * 0.5,
),
z_key: Z_RAID,
texture: Some(art.raid_icons.clone()),
uv: UvRect {
corners: [
[u0, v0],
[u0 + 0.25, v0],
[u0 + 0.25, v0 + 0.25],
[u0, v0 + 0.25],
],
},
color: [1.0, 1.0, 1.0, alpha],
..default()
});
}
let x_units = (plate.min.x + plate.max.x) * 0.5 / seam;
let y_units = (viewport.y - plate.min.y) / seam;
states.push(PlateState {
// The plate's identity is the UNIT's, for the life of the plate — the reference's
// `[unit+0xe60]` binding, which every addon's per-plate cache rests on.
key: guid.0,
top_centre: (x_units, y_units),
// RAW health and its max — `healthbar:GetValue()` is `[bar+0x320]`, not a fraction
// (`nameplate-lua-surface.md` Q5, which corrected wow-re's own note). pfUI and
// CustomNameplates both divide, and a fraction here would read as full health.
health: store.and_then(|s| s.0.unit_health()).unwrap_or(0) as f32,
max_health: store
.and_then(|s| s.0.unit_max_health())
.unwrap_or(1)
.max(1) as f32,
bar_colour: [tint[0], tint[1], tint[2]],
name: names
.resolve(guid.0, &net_commands)
.map(str::to_owned)
.unwrap_or_default(),
// `None` = the skull takes the level's seat. Its two legs (`0x7cbb40`, §7-VERIFIED):
// a WORLD BOSS — creature-classification rank 3, through the client's own getter
// (`gated_rank`, decision 0782, which is why a MIND-CONTROLLED boss shows its number)
// — unconditionally; or a hostile ≥ 10 levels up that isn't trivial-grey (`0x5f0700`,
// the shared check, vacuous on a ≥ +10 hostile and kept for transcription fidelity).
level: store.and_then(|s| s.0.unit_level()).and_then(|level| {
let world_boss = crate::names::gated_rank(
benilla_protocol::guid::entry(guid.0).and_then(|e| names.creature_record(e)),
store,
) == 3;
let skull = world_boss
|| (rank <= 1 && level >= my_level + 10 && !unit_is_grey(my_level, level));
(!skull).then_some(level)
}),
level_colour: {
let c = store
.and_then(|s| s.0.unit_level())
.map_or([1.0, 1.0, 1.0, 1.0], |level| con_color(my_level, level));
[c[0], c[1], c[2]]
},
// The 0434 §6 board, 0-based for the atlas (`col = idx & 3`, `row = idx >> 2`).
raid_icon: (mark >= 1).then(|| mark - 1),
alpha,
lit,
hovered: hover,
});
}
}
/// One line of plate text at `anchor` — ink centered on `anchor.x`; `bottom_seated` puts the
/// ink's bottom edge on `anchor.y` (the name's BOTTOM anchor), else its ink middle (the level's
/// CENTER) — with the plate FontString's black drop shadow one [`SHADOW_OFF`] right+down.
/// Both seats are computed from the **measured ink**, never the layout's line box: cosmic-text's
/// `Middle` centers the em box (ascent-heavy for Friz), which sat the level digit ~8 px below its
/// anchor — half outside the plate, the director's "level is not aligned".
/// `NAMEPLATE_FONT` resolves to Friz, the atlas default.
#[allow(clippy::too_many_arguments)]
fn plate_text(
atlas: &mut UiFontAtlas,
quads: &mut UiQuads,
text: &str,
anchor: Vec2,
bottom_seated: bool,
px: f32,
shadow_px: f32,
color: [f32; 4],
trace: bool,
) {
// Laid out AT the target em. Plate ems are window-derived (8 at 768, 11 at 1080,
// [`text_px`]) and so landed on almost nothing in the old fixed size ladder: every plate on
// screen used to be shaped at the nearest rung and rescaled about the anchor. Since decision
// 1342 the raster follows the request, so the rescale — and its sub-pixel scatter — is gone.
let mut e = atlas.lock();
let spec = FontSpec {
path: None,
height: Some(px),
outline: Outline::None,
alpha_gradient: None,
};
let justify = Justify {
h: JustifyH::Center,
v: JustifyV::Middle,
};
let mut main = layout_text_quads(
&mut e,
text,
Rect::from_center_size(anchor, Vec2::ZERO),
color,
justify,
Z_TEXT,
spec,
// The window-derived half, once per frame — the widget layer rewrites the static anchors only
// when this actually moves, which is what keeps an addon's own re-anchoring alive between
// resizes (decision 2148 §5).
script.sync_nameplates(
PlateGeometry {
width: pw / seam,
height: ph / seam,
bar_off_x: gx(BAR_OFF_X) / seam,
bar_off_y: gx(BAR_OFF_Y) / seam,
bar_width: gx(BAR_W) / seam,
bar_height: gx(BAR_H) / seam,
level_off_x: gx(LEVEL_OFF_X) / seam,
level_off_y: gx(LEVEL_OFF_Y) / seam,
skull_size: gx(SKULL_SIZE) / seam,
raid_size: gx(RAID_ICON_SIZE) / seam,
name_height: text_px(NAME_H, basis) / seam,
level_height: text_px(LEVEL_H, basis) / seam,
},
&states,
);
drop(e);
if main.is_empty() {
return;
}
// Seat by measured INK, then the shadow is the same run offset one step right+down.
let dy = {
let bottom = main.iter().map(|q| q.rect.max.y).fold(f32::MIN, f32::max);
if bottom_seated {
anchor.y - bottom
} else {
let top = main.iter().map(|q| q.rect.min.y).fold(f32::MAX, f32::min);
anchor.y - (top + bottom) * 0.5
}
};
for q in main.iter_mut() {
q.rect = Rect::new(
q.rect.min.x,
q.rect.min.y + dy,
q.rect.max.x,
q.rect.max.y + dy,
);
}
let mut shadow: Vec<UiQuad> = main
.iter()
.map(|q| UiQuad {
rect: Rect::new(
q.rect.min.x + shadow_px,
q.rect.min.y + shadow_px,
q.rect.max.x + shadow_px,
q.rect.max.y + shadow_px,
),
color: [0.0, 0.0, 0.0, color[3]],
..q.clone()
})
.collect();
if trace {
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for q in main.iter() {
x0 = x0.min(q.rect.min.x);
y0 = y0.min(q.rect.min.y);
x1 = x1.max(q.rect.max.x);
y1 = y1.max(q.rect.max.y);
}
eprintln!(
"vplate-trace: text {text:?} px={px:.1} ink=({x0:.1},{y0:.1})..({x1:.1},{y1:.1}) anchor=({:.1},{:.1})",
anchor.x, anchor.y
);
}
// Shadow first, main over it (equal z resolves by push order — the stable sort).
quads.overlays.append(&mut shadow);
quads.overlays.append(&mut main);
}
/// The set the plate drive runs in — the overhead-name driver orders after it (the
@@ -1090,14 +813,24 @@ impl Plugin for VPlatesPlugin {
app.init_resource::<VPlateMode>()
.init_resource::<VPlates>()
.init_resource::<PlateRects>()
.add_systems(Startup, load_plate_art.after(AssetSet::Open))
.add_systems(
Update,
(
toggle_vplates,
// After the targeting chain (selection/hover verdicts), inside the UI-quad
// append window (the mesh rebuild waits on it).
drive_vplates.after(TargetUpdate).in_set(UiQuadAppend),
// After the targeting chain (selection/hover verdicts) — which is
// `.after(WorldStage::Input)`, so the camera this projects through is THIS
// frame's, the freshest data a plate can be built from.
//
// **The paint is therefore one frame behind, and that is a known cost of
// decision 2148.** The UI's tick→resolve→extract (`UiInput`) runs
// `.before(WorldStage::Input)`, so the anchors written here are drawn by the
// NEXT frame's extract. Running earlier does not help — the camera would then
// be last frame's instead, and the lag would be identical with staler
// targeting. Removing it means moving the UI's resolve/extract after the
// camera update, which is a seam change with its own blast radius (the hover
// hit-test reads those rects before `WorldStage::Input`) and is recorded as
// 2148's follow-up rather than smuggled in here.
drive_vplates.after(TargetUpdate),
)
.chain()
.in_set(VPlateSet),
+1 -1
View File
@@ -16,7 +16,7 @@
/// **premultiplied** sRGB so alpha edges don't dark-fringe. On magnification the per-axis boundary
/// ramp is compressed to ~1 output pixel (crisp); on minification it falls back to plain bilinear
/// (`sharpen = max(scale, 1)`), so a plate smaller than native still resolves cleanly.
pub(super) fn resample_sharp(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
pub(crate) fn resample_sharp(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
let fetch = |x: i32, y: i32| -> [f32; 4] {
let x = x.clamp(0, sw as i32 - 1) as u32;
let y = y.clamp(0, sh as i32 - 1) as u32;
@@ -64,6 +64,14 @@ pub struct WorldAssets {
/// each address-mode pair needs its own GPU image (the sampler is baked into the `Image`).
/// See [`Self::sprite_texture_wrapped`].
tiled_sprites: HashMap<(String, bool, bool), Option<Handle<Image>>>,
/// Sprites RESAMPLED to an exact physical size, by `(path, w, h)` — the nameplate border's
/// 0188 sharpen and nothing else so far. Keyed by size because that is the whole point: the
/// art is re-rasterised whenever the plate's pixel size moves, and reused on every frame in
/// between (a resize is rare; a plate is drawn 60 times a second).
resampled_sprites: HashMap<(String, u32, u32), Option<Handle<Image>>>,
/// The decoded source pixels behind [`Self::resampled_sprites`], so a resize re-samples
/// instead of re-decoding the BLP.
resample_sources: HashMap<String, Option<(u32, u32, Vec<u8>)>>,
/// Decoded **portrait** sprites — [`Self::sprite_texture`]'s clamp/sRGB sprite with a circular
/// alpha mask baked in ([`portrait_image`]). Its own cache, like `tiled_sprites`: the mask bakes
/// into the GPU image, so the same BLP wanted as a plain icon and as a portrait needs two images.
@@ -385,6 +393,8 @@ impl WorldAssets {
chain: Arc::new(Mutex::new(chain)),
textures: SpatialCache::default(),
sprites: HashMap::new(),
resampled_sprites: HashMap::new(),
resample_sources: HashMap::new(),
tiled_sprites: HashMap::new(),
portraits: HashMap::new(),
masks: HashMap::new(),
@@ -466,6 +476,38 @@ impl WorldAssets {
decode_sprite(&self.chain, self.loose_root.as_deref(), path)
}
/// A UI sprite **resampled to an exact pixel size** by the caller's own kernel, cached by that
/// size — the nameplate border (decision 0188).
///
/// The plate's frame art is a 128 × 32 BLP drawn at whatever size the plate is, which past the
/// 1024×768 knee (and always on a retina framebuffer) is a magnification: the GPU's bilinear
/// filter smears the 1 px gold bevel across several soft output pixels, which is the director's
/// "blurry border". Resampling the SAME pixels to the target size with a sharp kernel keeps the
/// art and loses the smear. This lives here rather than in the plate driver because since
/// decision 2148 the plate is a widget like any other and its border is an ordinary texture
/// region — the substitution has to happen where a texture path becomes an image.
pub fn resampled_sprite(
&mut self,
path: &str,
(w, h): (u32, u32),
images: &mut Assets<Image>,
resample: impl FnOnce(&[u8], u32, u32, u32, u32) -> Vec<u8>,
) -> Option<Handle<Image>> {
let key = (path.to_string(), w, h);
if let Some(cached) = self.resampled_sprites.get(&key) {
return cached.clone();
}
if !self.resample_sources.contains_key(path) {
let decoded = decode_sprite(&self.chain, self.loose_root.as_deref(), path);
self.resample_sources.insert(path.to_string(), decoded);
}
let made = self.resample_sources[path]
.as_ref()
.map(|(sw, sh, src)| images.add(sprite_image(w, h, resample(src, *sw, *sh, w, h))));
self.resampled_sprites.insert(key, made.clone());
made
}
/// A UI sprite decoded with **repeat** (wrap) addressing on both axes — the frame `Backdrop`
/// tiled pieces (`backdrop-mechanism.md`): a border edge strip samples UVs `[0..N]` and a tiled
/// bg `[0..w/period]`, so the texture must wrap, not clamp. [`Self::sprite_texture_wrapped`]
+2
View File
@@ -110,6 +110,8 @@ mod measure;
mod merchant;
mod messageframe;
mod minimap;
pub mod nameplate;
pub use nameplate::{PlateGeometry, PlateState};
mod model;
mod modelframe;
mod net_stats;
+6
View File
@@ -1619,6 +1619,11 @@ pub(crate) struct Model {
/// The world-map seam ([`worldmap`](super::worldmap)): the pushed catalog/feed + the
/// engine-owned selection.
pub(crate) worldmap: super::worldmap::WorldMapState,
/// The V-key nameplate pool — engine-owned `Button` widgets under the `WorldFrame`, grown on
/// demand and never shrunk, in creation order (decision 2148). Lives here rather than on
/// [`super::UiScript`] because it IS model state: the plates are arena frames, and an addon
/// walking `WorldFrame:GetChildren()` reaches them like any other.
pub(crate) nameplates: super::nameplate::NamePlates,
/// The always-up world-state readout's rows ([`worldstate`](super::worldstate)), already
/// gated and resolved app-side.
pub(crate) worldstate: super::worldstate::WorldStateUiState,
@@ -2139,6 +2144,7 @@ impl Model {
quest_log_watched: Vec::new(),
server_unix_time: None,
worldmap: super::worldmap::WorldMapState::default(),
nameplates: super::nameplate::NamePlates::default(),
worldstate: super::worldstate::WorldStateUiState::default(),
pending_events: Vec::new(),
cursor_pos: (0.0, 0.0),
+668
View File
@@ -0,0 +1,668 @@
//! **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::{FontShadow, JustifyV, 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,
}
/// 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 text, or `None` to show the skull in the level's seat instead.
pub level: Option<u32>,
/// 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 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 geometry every live plate is currently laid out under.
geometry: Option<PlateGeometry>,
}
/// 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);
}
}
/// 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()
}
}
/// 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 relayout {
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].last.is_none() {
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 mouse bit stays OFF, and that is a deliberate gap, not an omission** (2148 §8).
// The reference's plate is mouse-enabled — hovering it publishes the mouseover
// (`0x7cb850` → `0x492890`) and a click selects through the button's own click slot. On
// this engine, a mouse-enabled frame under the cursor is what makes `PointerOverUi` true,
// and `target::hover::update_hover` returns early on exactly that: setting the bit without
// an engine-side hover/click seam takes the hover and gives nothing back — no model
// brighten, no lit bar, and no selection, because the click stops at the UI too. The seam
// comes first; then the bit, in the same change.
// `a_plate_does_not_yet_claim_the_ui_pointer` is the tripwire.
//
// It has to be turned OFF explicitly: a `Button` is born mouse-enabled here, faithfully —
// `CSimpleButton`'s ctor writes `[+0xcc] = 0x4` (`0x7786a3`), which is why the plate needs
// no `EnableMouse` of its own in the reference.
model.arena.set_mouse_enabled(frame, false);
let border = texture(model, frame, DrawLayer::Artwork, BORDER_TEXTURE);
let glow = texture(model, frame, DrawLayer::Highlight, GLOW_TEXTURE);
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);
let raid = texture(model, frame, DrawLayer::Artwork, RAID_ICON_TEXTURE);
// ↑ 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");
model.region_data.entry(fill).or_default().texture = Some(BAR_FILL_TEXTURE.to_string());
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));
Plate {
frame,
border,
glow,
name,
level,
skull,
raid,
bar,
fill,
last: 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.
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();
}
/// 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.level_colour != state.level_colour)
{
match state.level {
Some(n) => {
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);
}
None => {
model.region_data.entry(level).or_default().hidden = true;
model.region_data.entry(skull).or_default().hidden = false;
}
}
}
// 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 — created in the ABI's order by the caller.
fn texture(model: &mut Model, frame: FrameHandle, layer: DrawLayer, path: &str) -> RegionHandle {
let rh = model
.arena
.create_region(frame, RegionKind::Texture, layer, 0)
.expect("live plate");
model.region_data.entry(rh).or_default().texture = Some(path.to_string());
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.
///
/// `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;
// The plate text's shadow (`nameplate-vkey.md` §7: black, ±0.001 gx, director-tuned to a
// constant 1 px — the recorded value rounds to 2 px at a large window and reads chunky).
data.font_shadow = Some(FontShadow {
offset: [1.0, -1.0],
color: [0.0, 0.0, 0.0, 1.0],
});
data.justify.set_v(justify_v);
rh
}
/// 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;
+26
View File
@@ -264,6 +264,32 @@ fn fire_value_changed(lua: &Lua, this: &Table, changed: Option<f32>) -> mlua::Re
Ok(())
}
/// `OnValueChanged` for a bar the ENGINE moved rather than Lua — the nameplate health bars, whose
/// value the plate driver re-sets from the unit's descriptor each time it changes (decision 2148).
///
/// The reference fires the same script from the same place: the plate's bar is driven by a
/// GUID-watch callback (`0x7cc570`, registered `0x467e70`) that re-reads health and re-sets the bar
/// synchronously on every server update, and `SetValue` is `SetValue`. pfUI hooks this
/// (`nameplates.lua:393` `HookScript(healthbar, "OnValueChanged", …)`) and reads `this:GetParent()`
/// inside the handler, so a driver that moved the value silently would leave it blind.
pub(super) fn fire_engine_value_changed(lua: &Lua, bar: crate::widget::FrameHandle, value: f32) {
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.frame_id(bar)
};
if let Err(e) = event::fire_widget_handler(
lua,
id,
"OnValueChanged",
vec![Value::Number(f64::from(value))],
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
}
}
/// A Lua number-ish → f32 (nil/other → 0.0), for the color-form arguments.
fn num_f32(v: &Value) -> f32 {
match v {
@@ -28,6 +28,7 @@ mod model_clock;
mod modelframe;
mod movable;
mod name_targets;
mod nameplate;
mod object_model;
mod reference_surface;
mod region_map;
@@ -0,0 +1,489 @@
//! The nameplate ABI, as the four vanilla addons actually read it (decision 2148).
//!
//! These are not "does the pool work" tests. Each is a transcription of a real corpus idiom — the
//! same walk, the same destructuring, the same identification test — so a change that would break
//! a shipped addon fails **here** rather than on the director's screen.
//!
//! Sources, all `## Interface: 11200`: `pfUI/modules/nameplates.lua` (+ `compat/vanilla.lua`),
//! `ShaguTweaks/libs/libnameplate.lua`, `CustomNameplates/CustomNameplates.lua`,
//! `_Nameplates/_Nameplates.lua`. The byte side is wow-re
//! `system/ui/scratch/nameplate-lua-surface.md` (§5, 2026-09-09).
use crate::script::{PlateGeometry, PlateState, UiScript};
/// A VM with a `WorldFrame` to hang plates off — the only thing [`UiScript::sync_nameplates`]
/// requires of a session.
fn vm() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(r#"WorldFrame = CreateFrame("WorldFrame", "WorldFrame") WorldFrame:SetAllPoints()"#)
.unwrap();
s.resolve();
s
}
/// The plate size at the capture window (`vplates` scenario: one gx unit = 1280 px, so the
/// 0.1 × 0.025 plate is 128 × 32 px, and at seam scale 1.0 that is 128 × 32 units).
fn geometry() -> PlateGeometry {
PlateGeometry {
width: 128.0,
height: 32.0,
bar_off_x: 4.0,
bar_off_y: 4.0,
bar_width: 103.0,
bar_height: 9.0,
level_off_x: 11.8,
level_off_y: 9.1,
skull_size: 12.8,
raid_size: 25.6,
name_height: 12.8,
level_height: 11.0,
}
}
fn plate(name: &str, health: f32, max: f32) -> PlateState {
PlateState {
// The unit's GUID stands in as a hash of its name: what matters to these tests is that the
// same unit keeps the same key across frames and two units never share one.
key: name.bytes().map(u64::from).sum(),
top_centre: (500.0, 400.0),
health,
max_health: max,
bar_colour: [1.0, 0.0, 0.0],
name: name.to_string(),
level: Some(12),
level_colour: [1.0, 1.0, 0.0],
raid_icon: None,
alpha: 1.0,
lit: false,
hovered: false,
}
}
/// Drive N plates and settle the layout, the way one app frame does.
fn drive(s: &mut UiScript, states: &[PlateState]) {
s.sync_nameplates(geometry(), states);
s.resolve();
}
/// pfUI + ShaguTweaks share one lineage: `GetNumChildren` grew, so walk the tail.
///
/// `initialized` is never reset in either addon, so a client whose child list SHRANK or REORDERED
/// would make them miss every plate created after the old high-water mark — permanently, and with
/// no error. This is the test that a retired plate keeps its slot.
#[test]
fn the_worldframe_child_list_only_grows_and_keeps_its_order() {
let mut s = vm();
assert_eq!(
s.eval::<i64>("return WorldFrame:GetNumChildren()").unwrap(),
0
);
drive(
&mut s,
&[plate("Wolf", 30.0, 40.0), plate("Bear", 10.0, 90.0)],
);
assert_eq!(
s.eval::<i64>("return WorldFrame:GetNumChildren()").unwrap(),
2
);
// One unit leaves: the plate is HIDDEN, and the child list does not shrink.
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
assert_eq!(
s.eval::<i64>("return WorldFrame:GetNumChildren()").unwrap(),
2
);
assert!(s
.eval::<bool>(
"local a, b = WorldFrame:GetChildren() return a:IsShown() == 1 and b:IsShown() == nil"
)
.unwrap());
// A third unit arrives: it takes the pooled plate back — the count goes to 2, not 3.
drive(
&mut s,
&[plate("Wolf", 30.0, 40.0), plate("Boar", 5.0, 50.0)],
);
assert_eq!(
s.eval::<i64>("return WorldFrame:GetNumChildren()").unwrap(),
2
);
assert_eq!(s.nameplate_pool_len(), 2);
}
/// pfUI's `IsNamePlate` (`nameplates.lua:70`) and ShaguTweaks' (`libnameplate.lua:12`), verbatim in
/// shape: the object type is `"Button"`, and region #1 is a `Texture` whose `GetTexture()` is
/// exactly `Interface\Tooltips\Nameplate-Border`.
///
/// `CustomNameplates` runs the texture half against **every** WorldFrame child
/// (`CustomNameplates.lua:93`), so `GetRegions()` must also be safe to call on a frame with none.
#[test]
fn a_plate_identifies_by_button_plus_the_border_texture() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
assert!(s
.eval::<bool>(
r#"
local plate = WorldFrame:GetChildren()
if plate:GetObjectType() ~= "Button" then return false end
local region = plate:GetRegions()
if not region or not region.GetTexture then return false end
if region:GetObjectType() ~= "Texture" then return false end
return region:GetTexture() == "Interface\\Tooltips\\Nameplate-Border"
"#
)
.unwrap());
}
/// The two tuples, positionally — the ABI decision 2148 §3 records.
///
/// `CustomNameplates.lua:212` binds `Border, Glow, Name, Level, Boss, RaidTargetIcon`; pfUI and
/// ShaguTweaks bind the same six by the `NAMEPLATE_OBJECTORDER` table. A **seventh** region is a
/// bug (pfUI blanks any extra with `SetTexture("")`), and a **second** child is a bug too (pfUI
/// destructures `healthbar, castbar` and would blank ours as a TBC cast bar).
#[test]
fn the_region_and_child_tuples_are_six_and_one() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
assert_eq!(
s.eval::<i64>("local p = WorldFrame:GetChildren() return p:GetNumRegions()")
.unwrap(),
6
);
assert_eq!(
s.eval::<i64>("local p = WorldFrame:GetChildren() return p:GetNumChildren()")
.unwrap(),
1
);
assert!(s
.eval::<bool>(
r#"
local p = WorldFrame:GetChildren()
local border, glow, name, level, levelicon, raidicon = p:GetRegions()
return border:GetTexture() == "Interface\\Tooltips\\Nameplate-Border"
and glow:GetTexture() == "Interface\\Tooltips\\Nameplate-Glow"
and name:GetObjectType() == "FontString"
and level:GetObjectType() == "FontString"
and levelicon:GetTexture() == "Interface\\TargetingFrame\\UI-TargetingFrame-Skull"
and raidicon:GetTexture() == "Interface\\TargetingFrame\\UI-RaidTargetingIcons"
"#
)
.unwrap());
// ShaguTweaks `libnameplate.lua:39`: `plate.healthbar = plate:GetChildren()` — one child, and
// it is the StatusBar. Its own single region is the fill `GetStatusBarTexture()` hands back.
assert!(s
.eval::<bool>(
r#"
local p = WorldFrame:GetChildren()
local healthbar = p:GetChildren()
if healthbar:GetObjectType() ~= "StatusBar" then return false end
if healthbar:GetNumRegions() ~= 1 then return false end
local fill = healthbar:GetStatusBarTexture()
return fill:GetTexture() == "Interface\\TargetingFrame\\UI-TargetingFrame-BarFill"
"#
)
.unwrap());
}
/// `_Nameplates.Nameplate.Initialize` (`_Nameplates.lua:163`) is the strictest consumer: it
/// content-addresses the regions and **validates the two FontString anchors**, rejecting any plate
/// whose name is not `BOTTOM`→`CENTER` and whose level is not `CENTER`→`BOTTOMRIGHT`. Those are the
/// reference's own (ctor `0x7cb456`, bind `0x7cb7f5`).
#[test]
fn the_two_fontstring_anchors_are_what_nameplates_validates() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
assert!(s
.eval::<bool>(
r#"
local plate = WorldFrame:GetChildren()
local Name, Level
for _, region in ipairs({ plate:GetRegions() }) do
if region:GetObjectType() == "FontString" then
local point, _, relativePoint = region:GetPoint()
if point == "BOTTOM" and relativePoint == "CENTER" then Name = region end
if point == "CENTER" and relativePoint == "BOTTOMRIGHT" then Level = region end
end
end
return Name ~= nil and Level ~= nil
"#
)
.unwrap());
}
/// The bar reports **raw** health, not a fraction — `GetValue 0x78f5d0` → `[bar+0x320]`, and
/// `GetMinMaxValues` → `(0, maxHealth)`. pfUI (`:595`) and CustomNameplates (`:471`) both divide,
/// so a fraction here would read as full health on every plate.
///
/// This one corrected wow-re's own note: `nameplate-vkey.md` §4 had recorded `0x783380`, which is
/// the internal fill *fraction*, as the Lua getter.
#[test]
fn the_healthbar_reports_raw_health_and_its_max() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
assert!(s
.eval::<bool>(
r#"
local healthbar = WorldFrame:GetChildren():GetChildren()
local min, max = healthbar:GetMinMaxValues()
return healthbar:GetValue() == 30 and min == 0 and max == 40
"#
)
.unwrap());
}
/// The plate is anonymous. `_Nameplates.lua:164` rejects any WorldFrame child with a name, and
/// pfUI's bubble filter does the same — a named plate is invisible to both.
#[test]
fn a_plate_is_anonymous() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
assert!(s
.eval::<bool>("return WorldFrame:GetChildren():GetName() == nil")
.unwrap());
}
/// The pooling contract: a retired plate comes back as the **same Lua object**. Every addon keys
/// its per-plate state off exactly that identity — pfUI's `registry[plate]` (`:342`),
/// `_Nameplates.Frames[Nameplate]` (`:205`) — and re-runs `OnCreate` for an object it has not seen.
#[test]
fn a_retired_plate_returns_as_the_same_lua_object() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
s.run("FirstPlate = WorldFrame:GetChildren()").unwrap();
drive(&mut s, &[]);
drive(&mut s, &[plate("Bear", 10.0, 90.0)]);
assert!(s
.eval::<bool>("return WorldFrame:GetChildren() == FirstPlate")
.unwrap());
}
/// **A plate belongs to its UNIT, not to this frame's sort order.**
///
/// The driver hands plates over in distance-sorted order — the seat priority the reference solves
/// overlap in — and that order changes whenever two units cross. If the pool were indexed by it,
/// widget #1 would swap units mid-session, and every addon that caches state on the frame would be
/// reading the other unit's: pfUI's `nameplate.original.name`/`level`, its `registry[plate]`
/// bookkeeping, `_Nameplates`' per-plate button. So the pool is keyed by the unit's GUID, and this
/// is the test that says so.
#[test]
fn a_plate_belongs_to_its_unit_across_a_sort_order_swap() {
let mut s = vm();
drive(
&mut s,
&[plate("Wolf", 30.0, 40.0), plate("Bear", 10.0, 90.0)],
);
s.run(
r#"
WolfPlate = nil
for _, p in ipairs({ WorldFrame:GetChildren() }) do
local _, _, name = p:GetRegions()
if name:GetText() == "Wolf" then WolfPlate = p end
end
"#,
)
.unwrap();
assert!(s.eval::<bool>("return WolfPlate ~= nil").unwrap());
// The two units cross: the driver now sorts Bear first. Nothing about either plate's identity
// may move with it.
drive(
&mut s,
&[plate("Bear", 10.0, 90.0), plate("Wolf", 30.0, 40.0)],
);
assert!(s
.eval::<bool>(
r#"
local _, _, name = WolfPlate:GetRegions()
return name:GetText() == "Wolf"
"#
)
.unwrap());
}
/// `plate:GetAlpha() == 1` is how pfUI (`:879`) and CustomNameplates (`:287`) detect the TARGET
/// plate: with a target up, every other plate is dimmed. So alpha is a contract, not a look.
#[test]
fn the_target_plate_is_the_opaque_one() {
let mut s = vm();
let mut target = plate("Wolf", 30.0, 40.0);
let mut other = plate("Bear", 10.0, 90.0);
target.alpha = 1.0;
other.alpha = 0.5;
drive(&mut s, &[target, other]);
assert!(s
.eval::<bool>(
"local a, b = WorldFrame:GetChildren() return a:GetAlpha() == 1 and b:GetAlpha() < 1"
)
.unwrap());
}
/// pfUI hooks the bar's `OnValueChanged` (`:393`) and reads `this:GetParent()` inside the handler
/// to find the plate. The driver moves that value from Rust, so the handler has to fire from there
/// too — the reference's own bar is driven by a GUID-watch callback and fires it the same way.
#[test]
fn an_engine_health_write_fires_onvaluechanged_with_the_plate_as_parent() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
s.run(
r#"
Fired = 0
ParentIsPlate = false
local plate = WorldFrame:GetChildren()
local healthbar = plate:GetChildren()
healthbar:SetScript("OnValueChanged", function()
Fired = Fired + 1
ParentIsPlate = (this:GetParent() == plate)
end)
"#,
)
.unwrap();
drive(&mut s, &[plate("Wolf", 12.0, 40.0)]);
assert_eq!(s.eval::<i64>("return Fired").unwrap(), 1);
assert!(s.eval::<bool>("return ParentIsPlate").unwrap());
// Unmoved health fires nothing: the driver writes only what changed.
drive(&mut s, &[plate("Wolf", 12.0, 40.0)]);
assert_eq!(s.eval::<i64>("return Fired").unwrap(), 1);
}
/// **An addon takes the plate over.** pfUI blanks all six regions (`DisableObject` →
/// `SetTexture("")`) and paints its own art; ShaguTweaks reparents the raid icon. A driver that
/// re-stamped every property every frame would undo that at 60 Hz — so static properties are
/// written once, at creation, and never again.
#[test]
fn the_driver_does_not_overwrite_an_addons_takeover() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
s.run(
r#"
local plate = WorldFrame:GetChildren()
for _, region in ipairs({ plate:GetRegions() }) do
if region.SetTexture then region:SetTexture("") end
end
"#,
)
.unwrap();
// Several frames of ordinary driving — health moving, the unit renamed, the plate re-seated.
for hp in [29.0, 28.0, 27.0] {
let mut state = plate("Wolf", hp, 40.0);
state.top_centre = (500.0 + hp, 400.0);
drive(&mut s, &[state]);
}
assert!(s
.eval::<bool>(
r#"
local border = WorldFrame:GetChildren():GetRegions()
return border:GetTexture() == "" or border:GetTexture() == nil
"#
)
.unwrap());
}
/// pfUI reads `glow:IsShown()` as the MOUSEOVER signal (`:601`, `:880`). benilla does not paint the
/// additive rim (0184 — it read as hard edge lines on our pipeline, and the director pinned a bar
/// brighten instead), but the region is structurally present and really shown, because the
/// deviation is about pixels and this is about data.
#[test]
fn the_glow_region_is_the_mouseover_signal() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
assert!(s
.eval::<bool>(
"local _, glow = WorldFrame:GetChildren():GetRegions() return glow:IsShown() == nil"
)
.unwrap());
let mut hovered = plate("Wolf", 30.0, 40.0);
hovered.hovered = true;
drive(&mut s, &[hovered]);
assert!(s
.eval::<bool>(
"local _, glow = WorldFrame:GetChildren():GetRegions() return glow:IsShown() == 1"
)
.unwrap());
}
/// The level number and the skull share one seat and are mutually exclusive — `level = None` is
/// how the driver says "world boss, or ten levels up". pfUI gates on `levelicon:IsShown()` and
/// CustomNameplates on `Boss:IsVisible()`, so both flags have to move.
#[test]
fn the_skull_replaces_the_level_number() {
let mut s = vm();
let mut skulled = plate("Wolf", 30.0, 40.0);
skulled.level = None;
drive(&mut s, &[skulled]);
assert!(s
.eval::<bool>(
r#"
local _, _, _, level, levelicon = WorldFrame:GetChildren():GetRegions()
return level:IsShown() == nil and levelicon:IsShown() == 1
and levelicon:IsVisible() == 1
"#
)
.unwrap());
}
/// An addon's own frame parented to `WorldFrame` shares the list with the plates —
/// `_NameplatesFrame` and `pfUICombatScreen` both are — which is why the identification test in
/// §2 is load-bearing and why the count-grew heuristic re-scans. It must not be mistaken for one.
#[test]
fn an_addon_frame_under_the_worldframe_is_not_a_plate() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
s.run(r#"CreateFrame("Frame", "pfUICombatScreen", WorldFrame)"#)
.unwrap();
assert_eq!(
s.eval::<i64>("return WorldFrame:GetNumChildren()").unwrap(),
2
);
assert_eq!(
s.eval::<i64>(
r#"
local found = 0
for _, frame in ipairs({ WorldFrame:GetChildren() }) do
local region = frame:GetRegions()
if frame:GetObjectType() == "Button" and region and region.GetTexture
and region:GetTexture() == "Interface\\Tooltips\\Nameplate-Border" then
found = found + 1
end
end
return found
"#
)
.unwrap(),
1
);
}
/// **The plate does NOT claim the UI pointer — not yet, and deliberately.**
///
/// The reference's plate is a mouse-enabled `Button`: hovering it publishes the mouseover
/// (`0x7cb850` OnEnter → `0x492890`) and a click selects the unit through the button's own click
/// slot. benilla cannot simply set that bit, because on this engine the UI pointer pass owning a
/// frame is what makes `PointerOverUi` true, and `target::hover::update_hover` returns early on
/// exactly that — so a mouse-enabled plate with no engine-side seam takes the hover and gives
/// nothing back: no model brighten, no lit bar, and no selection on click, because the click never
/// reaches the world path either.
///
/// So the bit stays off until the seam exists (2148 §8), and this test is the tripwire: turning it
/// on without wiring the hover and click through the engine fails here, in the ABI suite, rather
/// than under the director's cursor.
#[test]
fn a_plate_does_not_yet_claim_the_ui_pointer() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
// The plate's TOP-CENTRE is (500, 400) and it hangs 32 units below, so this is its middle.
// `mouse_move` speaks the same y-up FrameXML space the anchors do.
//
// The WorldFrame itself IS a legitimate hit here — it is mouse-enabled by construction, and
// the app filters exactly that one id out (`input::feed_ui_input`, decision 1983) so camera
// look, world clicks and hover targeting stay live over it. So the assertion mirrors the
// app's own filter: whatever the pointer lands on must be the WorldFrame, never a plate.
let focus = s.mouse_move(500.0, 384.0);
assert!(
focus.is_none_or(|id| s.is_world_frame(id)),
"a plate claimed the UI pointer: the hover and click seams (2148 §8) have to land in the \
same change that enables the mouse, or plate hover and click-select both die"
);
}