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
@@ -493,3 +493,122 @@ fn every_global_the_layer_defines_takes_the_benilla_prefix() {
"layer globals without the Benilla prefix: {unprefixed:?}"
);
}
/// A login as the reference runs one on `s`, over a saved-variables file in `home` holding
/// `saved`: the chunk and `VARIABLES_LOADED` inside the UI load, then the world entry's clear
/// (`0x401639`, after the load at `0x401602`), then `PLAYER_ENTERING_WORLD`; a `ReloadUI()`
/// (`entry` false) skips the clear. The bits start as `before`. Returns the app holding the VM and
/// the plate bits.
fn log_in(
s: UiScript,
home: &std::path::Path,
saved: Option<&str>,
entry: bool,
before: crate::vplates::VPlateMode,
) -> bevy::app::App {
use bevy::prelude::*;
std::fs::create_dir_all(home).unwrap();
if let Some(saved) = saved {
std::fs::write(home.join("saved-variables.lua"), saved).unwrap();
}
let mut app = App::new();
app.add_systems(Update, crate::vplates::apply_plate_verbs)
.insert_resource(before)
.insert_non_send_resource(s);
crate::ui_saved::load_saved_variables(
&mut app.world_mut().non_send_resource_mut::<UiScript>(),
|_| {},
);
app.update();
if entry {
crate::vplates::clear_at_world_entry(app.world_mut());
}
app.world_mut()
.non_send_resource_mut::<UiScript>()
.fire_event("PLAYER_ENTERING_WORLD", vec![]);
app.update();
app
}
fn plates(app: &bevy::app::App) -> (bool, bool) {
let m = app.world().resource::<crate::vplates::VPlateMode>();
(m.enemies, m.friends)
}
fn plate_globals(app: &bevy::app::App) -> (Option<i64>, Option<i64>) {
app.world()
.non_send_resource::<UiScript>()
.eval("return NAMEPLATES_ON, FRIENDNAMEPLATES_ON")
.unwrap()
}
/// On the stock UI alone benilla is 1.12.1: the stock `UpdateNameplates` shows the friendly
/// plates and hides them at once (`UIOptionsFrame.lua:775-776`), so they are off after the entry
/// though the saved variable says on, and the saved variable is left as it was.
#[test]
fn on_the_stock_ui_friendly_plates_are_off_after_entry_and_the_saved_value_stands() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (s, failures) = production_load_with("plates-stock", true, "", |_| {});
assert!(failures.is_empty(), "load failures: {failures:#?}");
let home = std::env::temp_dir().join(format!("benilla-plates-stock-{}", std::process::id()));
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::set("BENILLA_HOME", home.to_str().unwrap());
let app = log_in(
s,
&home,
Some("NAMEPLATES_ON = 1\nFRIENDNAMEPLATES_ON = 1\n"),
true,
Default::default(),
);
assert_eq!(plates(&app), (true, false), "enemy on, friendly off");
assert_eq!(
plate_globals(&app),
(Some(1), Some(1)),
"both saved variables untouched"
);
let _ = std::fs::remove_dir_all(&home);
}
/// With the layer, plates the player turned on stay on: after the entry, through the logout's
/// save, and after a `ReloadUI()` on the saved file.
#[test]
fn with_the_layer_plates_turned_on_stay_on_after_entry_and_reload() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (s, failures) = production_load_with("plates-layer", false, "", |_| {});
assert!(failures.is_empty(), "load failures: {failures:#?}");
let home = std::env::temp_dir().join(format!("benilla-plates-layer-{}", std::process::id()));
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::set("BENILLA_HOME", home.to_str().unwrap());
let mut app = log_in(
s,
&home,
Some("NAMEPLATES_ON = 1\nFRIENDNAMEPLATES_ON = 1\n"),
true,
Default::default(),
);
assert_eq!(plates(&app), (true, true), "both on after the entry");
// The session's end writes the file, the friendly global included.
crate::ui_saved::save(&mut app.world_mut().non_send_resource_mut::<UiScript>());
let file = std::fs::read_to_string(home.join("saved-variables.lua")).unwrap();
for line in ["NAMEPLATES_ON = 1", "FRIENDNAMEPLATES_ON = 1"] {
assert!(file.contains(line), "{line} saved: {file}");
}
// `ReloadUI()`: a new VM over the file just written, and no clear.
let before = *app.world().resource::<crate::vplates::VPlateMode>();
let (s, failures) = production_load_with("plates-reload", false, "", |_| {});
assert!(failures.is_empty(), "load failures: {failures:#?}");
let app = log_in(s, &home, None, false, before);
assert_eq!(plates(&app), (true, true), "both on after the reload");
assert_eq!(plate_globals(&app), (Some(1), Some(1)));
let _ = std::fs::remove_dir_all(&home);
}
+47 -8
View File
@@ -216,6 +216,8 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
world.remove_resource::<PendingEntryUiLoad>();
let start = std::time::Instant::now();
load_ingame_ui_on_world_entry(world);
// The world entry's own step after the UI load, which a `ReloadUI()` never takes.
crate::vplates::clear_at_world_entry(world);
// Every entry logs the load's cost, whether the cover hid it, and the session every
// `VmMemo` keys on.
info!(
@@ -360,10 +362,17 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
let z = world.resource::<crate::minimap::MinimapZoom>();
(z.outdoor, z.inside)
};
let plates = world
.get_resource::<crate::vplates::VPlateMode>()
.copied()
.unwrap_or_default();
// A plate setting `config.toml` still holds, carried into FrameXML's saved variables once.
// Only by a load with the layer, which registers `FRIENDNAMEPLATES_ON` for save; a stock-UI
// load leaves it for the next layered one.
let legacy_plates = if super::manifest::layer_enabled() {
world
.get_resource_mut::<crate::cvars::Cvars>()
.map(|mut cvars| crate::vplates::take_legacy_settings(&mut cvars))
.unwrap_or_default()
} else {
Default::default()
};
// No sound during the load edge, as in the reference's `UI_Init` (`0x48fbfa` → `0x49016d`).
silenced_ui_load(&mut script, |script| {
let _ = load_ingame_ui(script, identity.as_ref(), &roster, version_check);
@@ -378,9 +387,9 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// events (`ChatFrame.lua:1261-1273`).
finish_ui_load_with(
script,
// `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` before `VARIABLES_LOADED`, whose
// `UIParent_OnEvent` arm runs the first `UpdateNameplates()` (`UIParent.lua:231-234`).
|script| crate::vplates::push_plate_globals(script, plates),
// Before `VARIABLES_LOADED`, whose `UIParent_OnEvent` arm runs the first
// `UpdateNameplates()` (`UIParent.lua:231-234`).
|script| crate::vplates::seat_legacy_settings(script, &legacy_plates),
|script| {
crate::ui_chat::restore_chat_looks(world, script);
},
@@ -681,7 +690,8 @@ pub(crate) fn finish_ui_load(script: &mut UiScript) {
}
/// [`finish_ui_load`] with its two host seams. `host_settings` runs between the saved-variables
/// chunk and `VARIABLES_LOADED`, pushing the settings benilla keeps in `config.toml`. `between`
/// chunk and `VARIABLES_LOADED`, carrying over a setting `config.toml` held before it moved into
/// the saved variables. `between`
/// is the chat-cache restore's `UPDATE_CHAT_WINDOWS` + `UPDATE_CHAT_COLOR` burst, after
/// `VARIABLES_LOADED` and before `PLAYER_LOGIN`, where the reference registers its reader
/// (`0x4900d6` → `0x498a20`). On a fresh login the reference's cache (`0x5afe50`) defers that
@@ -818,6 +828,35 @@ mod tests {
assert!(world.get_non_send_resource::<ParkedBootVm>().is_none());
}
/// The plate bits clear at the world entry (`0x401639`, in the entry setup `0x401570`) and
/// not at a `ReloadUI()`, which never runs that setup. A capture world, so no UI is built.
#[test]
fn the_world_entry_clears_the_plate_bits_and_a_reload_does_not() {
use crate::vplates::VPlateMode;
let on = VPlateMode {
enemies: true,
friends: true,
};
let world_with = |on: VPlateMode| {
let mut world = World::new();
world.insert_resource(State::new(crate::char_select::ClientState::InWorld));
world.insert_resource(crate::run_mode::CaptureMode);
world.insert_resource(on);
world
};
let mut world = world_with(on);
world.insert_resource(PendingEntryUiLoad);
run_pending_entry_load(&mut world);
assert_eq!(*world.resource::<VPlateMode>(), VPlateMode::default());
let mut world = world_with(on);
world.insert_resource(ReloadUiPending(true));
run_pending_reload(&mut world);
assert!(!world.resource::<ReloadUiPending>().0, "the reload ran");
assert_eq!(*world.resource::<VPlateMode>(), on, "a reload keeps them");
}
/// The reference re-makes its Lua state inside `UI_Init` (`0x48fe97`).
#[test]
fn the_world_entry_builds_its_own_vm_rather_than_adopting_the_glue_phases() {
@@ -1272,89 +1272,6 @@ fn the_world_detail_slider_writes_the_cvar_and_the_readout_names_its_stop() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// On the real manifest and CVar table: `UIParent_OnEvent` calls `UpdateNameplates()` on
/// `VARIABLES_LOADED` and `PLAYER_ENTERING_WORLD` (`UIParent.lua:234`, `:367`), and here its verbs
/// write the CVar pair that is the store. The setting survives only with both the host seeding
/// `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` first and our `UpdateNameplates` without the stock
/// friendly show-then-hide (`UIOptionsFrame.lua:775-776`).
#[test]
fn a_world_entry_leaves_the_saved_nameplate_setting_alone() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The client never loads the manifest without a player.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
..Default::default()
}),
);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "loader errors: {failures:?}");
let _ = s.errors();
// Both halves on, restored from `config.toml`: a host write, which queues nothing.
let plates = crate::vplates::VPlateMode {
enemies: true,
friends: true,
};
let saved = |s: &mut UiScript| {
s.set_cvar_host(crate::vplates::CVAR_ENEMIES, "1");
s.set_cvar_host(crate::vplates::CVAR_FRIENDS, "1");
assert!(s.take_cvar_changes().is_empty(), "the host write is silent");
};
// The control: with the globals nil, the entry writes both halves off.
saved(&mut s);
s.fire_event("VARIABLES_LOADED", vec![]);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
assert_eq!(
(
s.cvar(crate::vplates::CVAR_ENEMIES),
s.cvar(crate::vplates::CVAR_FRIENDS)
),
(Some("0".into()), Some("0".into())),
"nil globals ⇒ the replay writes the store off — this is what the report saw"
);
let _ = s.take_cvar_changes();
// With the globals seeded by the host, the entry changes nothing.
saved(&mut s);
crate::vplates::push_plate_globals(&s, plates);
assert_eq!(
s.eval::<i64>("return NAMEPLATES_ON").unwrap(),
1,
"the reference's own truthiness — the NUMBER 1, never the truthy `0`"
);
s.fire_event("VARIABLES_LOADED", vec![]);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
assert_eq!(
(
s.cvar(crate::vplates::CVAR_ENEMIES),
s.cvar(crate::vplates::CVAR_FRIENDS)
),
(Some("1".into()), Some("1".into())),
"the player's setting survives the entry"
);
assert!(
s.take_cvar_changes().is_empty(),
"and queues nothing, so `config.toml` is never dirtied by a world entry"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// An addon that moves a global and calls the verb still writes the CVar.
s.run("FRIENDNAMEPLATES_ON = nil UpdateNameplates()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![(crate::vplates::CVAR_FRIENDS.to_string(), "0".to_string())],
"UpdateNameplates keeps its contract — it is adapted, not stubbed"
);
}
#[test]
fn the_nameplates_page_toggles_the_unit_name_cvars() {
benilla_formats::wow_data_or_skip!();
@@ -1398,19 +1315,46 @@ fn the_nameplates_page_toggles_the_unit_name_cvars() {
}
let _ = s.take_sounds();
// The CVars the V and Shift-V bindings write, so the window and the keys agree.
// The plate rows move the verbs and FrameXML's globals together, as the V and Shift-V
// bindings do, so the window and the keys agree and the saved variables carry it; no CVar.
use benilla_ui::script::{PLATE_BIT_ENEMY, PLATE_BIT_FRIEND};
let _ = s.take_nameplate_bit_writes();
let globals = |s: &UiScript| {
s.eval::<(Option<i64>, Option<i64>)>("return NAMEPLATES_ON, FRIENDNAMEPLATES_ON")
.unwrap()
};
s.run("BenillaOptionsFrameContainerBodyNameplatesRowFriendlyPlatesCheck:Click()")
.unwrap();
assert_eq!(s.take_nameplate_bit_writes().apply(0), PLATE_BIT_FRIEND);
assert_eq!(globals(&s), (None, Some(1)));
s.run("BenillaOptionsFrameContainerBodyNameplatesRowEnemyPlatesCheck:Click()")
.unwrap();
assert_eq!(s.take_nameplate_bit_writes().apply(0), PLATE_BIT_ENEMY);
assert_eq!(globals(&s), (Some(1), Some(1)));
s.run("BenillaOptionsFrameContainerBodyNameplatesRowFriendlyPlatesCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![(crate::vplates::CVAR_FRIENDS.to_string(), "1".to_string())]
s.take_nameplate_bit_writes()
.apply(PLATE_BIT_ENEMY | PLATE_BIT_FRIEND),
PLATE_BIT_ENEMY,
"unticking hides"
);
s.run("BenillaOptionsFrameContainerBodyNameplatesRowEnemyPlatesCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![(crate::vplates::CVAR_ENEMIES.to_string(), "1".to_string())]
globals(&s),
(Some(1), None),
"the number 1 or nil, never a truthy \"0\""
);
assert!(s.take_cvar_changes().is_empty(), "no plate CVar");
// A key moved the global behind the window's back: the rows read it.
s.run("FRIENDNAMEPLATES_ON = 1 BenillaOptionsFrameCategoryListRowNameplates:Click()")
.unwrap();
assert!(s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyNameplatesRowFriendlyPlatesCheck:GetChecked()"
)
.unwrap());
s.run("FRIENDNAMEPLATES_ON = nil").unwrap();
let _ = s.take_nameplate_bit_writes();
let _ = s.take_sounds();
// The interface panel's click kit (`PlayClickSound`, `OptionsFrame.lua:509-515`), not the