The nameplate verbs write two runtime bits, cleared at world entry, and the setting lives in FrameXML's saved variables, not two CVars (#571)

The nameplate verbs now answer as the reference's (`0x489450`-`0x489480`): they flip bits `0x1` (enemy) and `0x8` (friend) of runtime state, write no CVar, and world entry clears both (`0x401639` → `0x6033c0`, after `VARIABLES_LOADED`). The setting persists only through FrameXML's `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON`, as on 1.12.1.

- The `nameplateShowEnemies`/`nameplateShowFriends` CVar rows and the host's plate push are gone. A player's saved plate setting in `config.toml` moves once into the saved variables at the first world entry.
- benilla's layer keeps what players see today. Its `UpdateNameplates` fix drops the stock show-then-hide (`UIOptionsFrame.lua:775-776`), and it registers `FRIENDNAMEPLATES_ON` for saving, which the stock file never does (`:180-183`). So plates turned on come back after login.
- With the layer off, benilla behaves as 1.12.1: friendly plates are off after entry.
- The V keys flip the verb and the saved global together. V and Shift-V keep their recorded deviation; #570 tracks moving the binding table onto the stock `Bindings.xml`.

Verified:
- New tests for the verbs, the entry clear, the stock-only and layered persistence across entry and reload, the V keys and the one-time migration, each failing with its change undone.
- `scripts/gates.sh` and `scripts/smoke.sh` are green.
- A live run with `WOW_GM=off` turned both kinds on, relogged and read both back, from the file too.

Built on macOS; no platform seam touched.

Fixes #552

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-29 01:21:17 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 05e204de10
commit 0a1f69466c
14 changed files with 666 additions and 392 deletions
-46
View File
@@ -379,13 +379,6 @@ const USAGE_REGISTER_CVAR: &str = "Usage: RegisterCVar(\"cvar\" [, default])";
/// `InitializeGame` does before `UI_Init`.
pub const IN_WORLD_READ_ONLY_CVARS: [&str; 3] = ["realmList", "realmName", "scriptMemory"];
/// The enemy-plate toggle's CVar, which the app reads rather than re-spelling. Deviation: 1.12
/// registers no nameplate CVar; the toggles persist under the later clients' names rather than
/// invented ones.
pub const CVAR_NAMEPLATE_ENEMIES: &str = "nameplateShowEnemies";
/// The friendly-plate half of [`CVAR_NAMEPLATE_ENEMIES`].
pub const CVAR_NAMEPLATE_FRIENDS: &str = "nameplateShowFriends";
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `RegisterCVar(name, value)` (`0x488b00`) declares a CVar the client does not ship, which is
// how an addon persists a setting. Re-declaring a live name is a no-op: the reference never
@@ -556,7 +549,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
install_nameplate_verbs(lua)?;
install_world_detail_verbs(lua)?;
super::video_pairs::install(lua)
}
@@ -898,44 +890,6 @@ const USAGE_SET_WORLD_DETAIL: &str = "Usage: SetWorldDetail(value)";
/// `0x8423b8`, verbatim: the lowercase `v` and the odd comma are the reference's.
const RANGE_SET_WORLD_DETAIL: &str = "value must be in the range 0, 2";
/// The four nameplate verbs: `ShowNameplates 0x489450`, `HideNameplates 0x489460`,
/// `ShowFriendNameplates 0x489470`, `HideFriendNameplates 0x489480`. Each ignores its arguments
/// (`ShowNameplates(false)` still shows) and returns zero values, and there is no getter; the
/// state is bits `0x1` (enemy) and `0x8` (friend) of `[0xc4da34]`.
///
/// Deviation: the state lives in two CVars, so a player's plates survive a zone-in; the reference
/// registers no nameplate CVar (none of `0x63db90`'s sites), clears both bits on every
/// `EnterWorld`, and FrameXML replays its `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` saved variables.
fn install_nameplate_verbs(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
for (name, cvar, on) in [
("ShowNameplates", CVAR_NAMEPLATE_ENEMIES, true),
("HideNameplates", CVAR_NAMEPLATE_ENEMIES, false),
("ShowFriendNameplates", CVAR_NAMEPLATE_FRIENDS, true),
("HideFriendNameplates", CVAR_NAMEPLATE_FRIENDS, false),
] {
g.set(
name,
// Any arguments are ignored, as the reference reads none; the empty `MultiValue`
// returns zero values, which is not nil.
lua.create_function(move |lua, _: mlua::MultiValue| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let key = cvar.to_ascii_lowercase();
if let Some(slot) = model.cvars.get_mut(&key) {
let value = if on { "1" } else { "0" };
if slot.value != value {
slot.value = value.to_string();
let reg_name = slot.name.clone();
model.cvar_changes.push((reg_name, value.to_string()));
}
}
Ok(mlua::MultiValue::new())
})?,
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{MultisampleFormat, SeededCvar};
+4 -3
View File
@@ -89,7 +89,7 @@ mod merchant;
mod messageframe;
mod minimap;
pub mod nameplate;
pub use nameplate::{PlateGeometry, PlateState};
pub use nameplate::{PlateBitWrites, PlateGeometry, PlateState, PLATE_BIT_ENEMY, PLATE_BIT_FRIEND};
mod model;
mod modelframe;
mod net_stats;
@@ -180,8 +180,8 @@ pub use cursor::{
pub use cvars::{small_cull_text, sstr_to_int};
pub use cvars::{
MultisampleFormat, ScreenResolution, SeededCvar, VideoCaps, CVAR_FRILL_DENSITY, CVAR_GAMMA,
CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS, CVAR_SMALL_CULL, CVAR_WORLD_DETAIL,
IN_WORLD_READ_ONLY_CVARS, VIDEO_DEFAULT_CVARS, WORLD_DETAIL_STOPS,
CVAR_SMALL_CULL, CVAR_WORLD_DETAIL, IN_WORLD_READ_ONLY_CVARS, VIDEO_DEFAULT_CVARS,
WORLD_DETAIL_STOPS,
};
pub use death::{DeathAction, DeathUiState};
pub use dressup::DressUpIntent;
@@ -524,6 +524,7 @@ impl UiScript {
death::install(&lua)?;
aura::install(&lua)?;
cvars::install(&lua)?;
nameplate::install_verbs(&lua)?;
saved::install(&lua)?;
keybind::install(&lua)?;
sound::install(&lua)?;
+80
View File
@@ -4,6 +4,11 @@
//! and read it positionally: six regions in creation order, then one child, the health bar (1.12
//! has no cast bar). Addons take plates over, so static properties are written once, anchors
//! when the window changes and state when it moves.
//!
//! Also the four verbs that switch plates on and off, which write two bits of the engine's
//! runtime dword `[0xc4da34]` and nothing else.
use mlua::Lua;
use crate::layout::{Anchor, Point};
use crate::order::DrawLayer;
@@ -127,6 +132,9 @@ pub(crate) struct NamePlates {
/// true, where freelook (`0x60f830`) clears the bit.
hit_test_vetoed: bool,
geometry: Option<PlateGeometry>,
/// The verbs' writes to the two script bits, drained by
/// [`UiScript::take_nameplate_bit_writes`].
bit_writes: PlateBitWrites,
}
impl NamePlates {
@@ -154,7 +162,79 @@ struct SyncEffects {
values: Vec<(FrameHandle, f32)>,
}
/// Bit `0x1` of `[0xc4da34]`: plates on every unit that is not friendly, set by `ShowNameplates`
/// and cleared by `HideNameplates` (setter `0x6054f0`).
pub const PLATE_BIT_ENEMY: u8 = 0x1;
/// Bit `0x8` of `[0xc4da34]`: plates on friendly units, set by `ShowFriendNameplates` and cleared
/// by `HideFriendNameplates` (setter `0x605510`).
pub const PLATE_BIT_FRIEND: u8 = 0x8;
/// The plate verbs' writes since the last drain. Each setter `or`s or `and`s its bit into the live
/// dword in call order, so per bit the last write wins; bits `0x2` and `0x4` are the frame loop's
/// own latches and no verb reaches them.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PlateBitWrites {
set: u8,
clear: u8,
}
impl PlateBitWrites {
fn write(&mut self, bit: u8, on: bool) {
if on {
self.set |= bit;
self.clear &= !bit;
} else {
self.clear |= bit;
self.set &= !bit;
}
}
/// `bits` with these writes applied.
pub fn apply(self, bits: u8) -> u8 {
(bits | self.set) & !self.clear
}
/// No verb ran since the last drain.
pub fn is_empty(self) -> bool {
self.set | self.clear == 0
}
}
/// The four nameplate verbs: `ShowNameplates 0x489450`, `HideNameplates 0x489460`,
/// `ShowFriendNameplates 0x489470`, `HideFriendNameplates 0x489480`. Each reads no argument
/// (`ShowNameplates(false)` still shows), returns zero values and writes its bit of `[0xc4da34]`,
/// which the engine clears at every world entry (`0x60394b`, `0x603952`). There is no getter and
/// no nameplate CVar: FrameXML keeps its own `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` and replays
/// them through these verbs.
pub(super) fn install_verbs(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
for (name, bit, on) in [
("ShowNameplates", PLATE_BIT_ENEMY, true),
("HideNameplates", PLATE_BIT_ENEMY, false),
("ShowFriendNameplates", PLATE_BIT_FRIEND, true),
("HideFriendNameplates", PLATE_BIT_FRIEND, false),
] {
g.set(
name,
// The empty `MultiValue` returns zero values, which is not nil.
lua.create_function(move |lua, _: mlua::MultiValue| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.nameplates.bit_writes.write(bit, on);
Ok(mlua::MultiValue::new())
})?,
)?;
}
Ok(())
}
impl UiScript {
/// Drain the plate verbs' writes for the engine's `[0xc4da34]`, which the app owns.
pub fn take_nameplate_bit_writes(&mut self) -> PlateBitWrites {
let lua = self.lua();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
std::mem::take(&mut model.nameplates.bit_writes)
}
/// Drive the pool from the app's per-frame states, one per shown plate; a unit with no state
/// has its plate retired (hidden, never destroyed). An unchanged `geometry` costs nothing.
pub fn sync_nameplates(&mut self, geometry: PlateGeometry, states: &[PlateState]) {
@@ -1459,58 +1459,67 @@ fn the_pfui_hdgraphic_extended_arm_runs() {
/// `ShowNameplates 0x489450`, `HideNameplates 0x489460`, `ShowFriendNameplates 0x489470` and
/// `HideFriendNameplates 0x489480` read no argument and return nothing: four 10-byte bodies over
/// two setters, differing only in an `or`/`and` mask.
/// two setters, differing only in an `or`/`and` mask. They write bits `0x1` and `0x8` of the
/// runtime dword `[0xc4da34]` and no CVar: 1.12 registers none (no `0x63db90` site names a plate).
#[test]
fn the_nameplate_verbs_ignore_their_arguments_and_return_nothing() {
let s = script();
s.register_cvars([
(crate::script::CVAR_NAMEPLATE_ENEMIES, "1"),
(crate::script::CVAR_NAMEPLATE_FRIENDS, "0"),
]);
fn the_nameplate_verbs_write_two_runtime_bits_and_no_cvar() {
use crate::script::{PLATE_BIT_ENEMY as ENEMY, PLATE_BIT_FRIEND as FRIEND};
let mut s = script();
let get = |s: &crate::script::UiScript, n: &str| s.cvar(n);
assert_eq!(
get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(),
Some("1")
s.run("ShowNameplates()").unwrap();
assert_eq!(s.take_nameplate_bit_writes().apply(0), ENEMY);
assert!(
s.take_nameplate_bit_writes().is_empty(),
"a drain empties the queue"
);
s.run("HideNameplates()").unwrap();
assert_eq!(
get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(),
Some("0")
);
assert_eq!(
get(&s, crate::script::CVAR_NAMEPLATE_FRIENDS).as_deref(),
Some("0"),
s.take_nameplate_bit_writes().apply(ENEMY | FRIEND),
FRIEND,
"the friendly bit is a separate setter — hiding enemies must not touch it"
);
s.run("ShowFriendNameplates()").unwrap();
assert_eq!(
get(&s, crate::script::CVAR_NAMEPLATE_FRIENDS).as_deref(),
Some("1")
);
assert_eq!(
get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(),
Some("0"),
s.take_nameplate_bit_writes().apply(0),
FRIEND,
"...and back the other way"
);
s.run("HideFriendNameplates()").unwrap();
assert_eq!(s.take_nameplate_bit_writes().apply(FRIEND), 0);
// In call order, the last write per bit standing, as the setters `or`/`and` the live dword.
s.run("ShowFriendNameplates() HideFriendNameplates() HideNameplates() ShowNameplates()")
.unwrap();
assert_eq!(s.take_nameplate_bit_writes().apply(FRIEND), ENEMY);
// The frame loop's latches, bits `0x2` and `0x4`, are no verb's to touch.
s.run("HideNameplates() HideFriendNameplates()").unwrap();
assert_eq!(s.take_nameplate_bit_writes().apply(0xff), !(ENEMY | FRIEND));
s.run("ShowNameplates(false)").unwrap();
assert_eq!(
get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(),
Some("1"),
s.take_nameplate_bit_writes().apply(0),
ENEMY,
"the verb IS the value — a falsy argument does not invert it"
);
for call in ["HideNameplates(1, 2, 3)", "ShowFriendNameplates({})"] {
s.run(call)
.unwrap_or_else(|e| panic!("{call} must not raise: {e}"));
}
let _ = s.take_nameplate_bit_writes();
assert_eq!(
s.arity("ShowNameplates()").unwrap(),
0,
"zero values — observably different from nil for a caller that counts"
);
assert!(
s.take_cvar_changes().is_empty(),
"no CVar written: the dword is not a setting"
);
for name in ["nameplateShowEnemies", "nameplateShowFriends"] {
assert_eq!(s.cvar(name), None, "{name}: no plate CVar is registered");
}
}
/// The host seams benilla's own interface used are gone, now that it speaks 1.12 alone: the key