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
@@ -29,5 +29,23 @@
UIParent_ManageFramePositions()
end)
end
-- Deviation: friendly plates the player turned on come back at every world entry. The
-- stock UpdateNameplates (UIOptionsFrame.lua:768-780) shows them and hides them again at
-- once (l.775-776), and FRIENDNAMEPLATES_ON is never saved, since l.182 registers
-- NAMEPLATES_ON a second time.
function UpdateNameplates()
if ( NAMEPLATES_ON ) then
ShowNameplates()
else
HideNameplates()
end
if ( FRIENDNAMEPLATES_ON ) then
ShowFriendNameplates()
else
HideFriendNameplates()
end
end
RegisterForSave("FRIENDNAMEPLATES_ON")
]]></Script>
</Ui>
+24 -14
View File
@@ -63,23 +63,33 @@
end
end
-- UIOptionsFrame.lua:768-780. Deviation: stock l.776 is dropped. On 1.12 its show-then-hide
-- clears volatile bits (`[0xc4da34]`); here the verbs write the CVar pair that is the
-- store, so it would erase the saved setting at every world entry.
function UpdateNameplates()
if ( NAMEPLATES_ON ) then
-- The two plate rows' store: FrameXML's own NAMEPLATES_ON/FRIENDNAMEPLATES_ON, the number 1
-- or nil (a "0" would be truthy), applied through the verbs as the bindings do.
function BenillaOptionsFrame_GetEnemyPlates()
return NAMEPLATES_ON
end
function BenillaOptionsFrame_SetEnemyPlates(value)
if ( value == "1" ) then
NAMEPLATES_ON = 1
ShowNameplates()
else
NAMEPLATES_ON = nil
HideNameplates()
end
if ( FRIENDNAMEPLATES_ON ) then
end
function BenillaOptionsFrame_GetFriendlyPlates()
return FRIENDNAMEPLATES_ON
end
function BenillaOptionsFrame_SetFriendlyPlates(value)
if ( value == "1" ) then
FRIENDNAMEPLATES_ON = 1
ShowFriendNameplates()
else
FRIENDNAMEPLATES_ON = nil
HideFriendNameplates()
end
end
-- The category tree in list order; keys are the row name suffixes.
BENILLA_OPTIONS_CATEGORY_KEYS = {
"Controls", "Interface", "ActionBars", "Combat", "Chat", "Keybindings", "Nameplates",
@@ -2556,9 +2566,9 @@
</Frame>
</Frames>
</Frame>
<!-- The Nameplates page: the V-key plates (`[0xc4da34]` bits 0 and
3), then 1.12's UnitName* checkboxes. The plate rows are era
rows: 1.12 has only the keybinding. -->
<!-- The Nameplates page: the V-key plates, then 1.12's UnitName*
checkboxes. The plate rows are era rows: 1.12 has only the
keybindings. -->
<Frame name="$parentNameplates" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
@@ -2570,16 +2580,16 @@
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>BenillaOptionsRow_OnLoad(this, "nameplateShowEnemies",
"Enemy Name Plates")</OnLoad></Scripts>
<Scripts><OnLoad>BenillaOptionsFuncRow_OnLoad(this, BenillaOptionsFrame_GetEnemyPlates,
BenillaOptionsFrame_SetEnemyPlates, "0", "Enemy Name Plates")</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowFriendlyPlates" inherits="BenillaOptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowEnemyPlates" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowEnemyPlates" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>BenillaOptionsRow_OnLoad(this, "nameplateShowFriends",
"Friendly Name Plates")</OnLoad></Scripts>
<Scripts><OnLoad>BenillaOptionsFuncRow_OnLoad(this, BenillaOptionsFrame_GetFriendlyPlates,
BenillaOptionsFrame_SetFriendlyPlates, "0", "Friendly Name Plates")</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowPlayerNames" inherits="BenillaOptionsCheckboxRowTemplate">
<Anchors>
+29 -6
View File
@@ -100,9 +100,6 @@ pub(crate) mod cmd {
pub(crate) const TOGGLE_RUN: Cmd = by_name("TOGGLERUN");
pub(crate) const TARGET_NEAREST_ENEMY: Cmd = by_name("TARGETNEARESTENEMY");
pub(crate) const TARGET_PREVIOUS_ENEMY: Cmd = by_name("TARGETPREVIOUSENEMY");
pub(crate) const NAMEPLATES: Cmd = by_name("NAMEPLATES");
pub(crate) const FRIEND_NAMEPLATES: Cmd = by_name("FRIENDNAMEPLATES");
pub(crate) const ALL_NAMEPLATES: Cmd = by_name("ALLNAMEPLATES");
pub(crate) const ATTACK_TARGET: Cmd = by_name("ATTACKTARGET");
pub(crate) const TOGGLE_UI: Cmd = by_name("TOGGLEUI");
pub(crate) const CAMERA_ZOOM_IN: Cmd = by_name("CAMERAZOOMIN");
@@ -793,15 +790,41 @@ const TABLE: &[Spec] = &[
Some("F"),
None
),
spec!("NAMEPLATES", TARGETING, Kind::Host, Some("V"), None),
// Deviation: V and Shift-V toggle their own kind alone, where 1.12's bodies
// (`Bindings.xml:516-537`) turn the other kind off.
spec!(
"NAMEPLATES",
TARGETING,
Kind::Edge(
"if ( NAMEPLATES_ON ) then HideNameplates(); NAMEPLATES_ON = nil; \
else ShowNameplates(); NAMEPLATES_ON = 1; end"
),
Some("V"),
None
),
spec!(
"FRIENDNAMEPLATES",
TARGETING,
Kind::Host,
Kind::Edge(
"if ( FRIENDNAMEPLATES_ON ) then HideFriendNameplates(); FRIENDNAMEPLATES_ON = nil; \
else ShowFriendNameplates(); FRIENDNAMEPLATES_ON = 1; end"
),
Some("SHIFT-V"),
None
),
spec!("ALLNAMEPLATES", TARGETING, Kind::Host, Some("CTRL-V"), None),
// 1.12's body (`Bindings.xml:538-550`).
spec!(
"ALLNAMEPLATES",
TARGETING,
Kind::Edge(
"if ( not (NAMEPLATES_ON and FRIENDNAMEPLATES_ON) ) then \
ShowNameplates(); NAMEPLATES_ON = 1; ShowFriendNameplates(); FRIENDNAMEPLATES_ON = 1; \
else HideNameplates(); NAMEPLATES_ON = nil; HideFriendNameplates(); \
FRIENDNAMEPLATES_ON = nil; end"
),
Some("CTRL-V"),
None
),
spec!("ATTACKTARGET", TARGETING, Kind::Host, Some("T"), None),
spec!(
"PETATTACK",
+17 -4
View File
@@ -41,7 +41,6 @@ pub(super) fn seed_ui_fixture(
mut names: ResMut<crate::names::NameCache>,
icons: Option<Res<crate::entities::ItemDisplays>>,
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut vplates: ResMut<crate::vplates::VPlateMode>,
mut selection: ResMut<crate::target::Selection>,
mut player: ResMut<crate::player::Player>,
// A nested tuple, one param under Bevy's 16-param cap.
@@ -944,7 +943,8 @@ pub(super) fn seed_ui_fixture(
Visibility::default(),
))
.id();
vplates.enemies = true;
// As the V binding does, so FrameXML's replay at `PLAYER_ENTERING_WORLD` agrees.
set_enemy_plates(script.as_deref(), true);
// The wolf is the target, so its plate draws lit with the target ring.
selection.target = Some(wolf);
selection.guid = Some(WOLF_GUID);
@@ -1295,7 +1295,7 @@ pub(super) fn seed_ui_fixture(
Visibility::default(),
));
// A plated unit draws no floating name, so enemy plates go off.
vplates.enemies = false;
set_enemy_plates(script.as_deref(), false);
}
// The lighting matrix: one spawn with a streamed entity's component set, anonymous and
// with plates off, so no glyph rides over the body.
@@ -1339,7 +1339,7 @@ pub(super) fn seed_ui_fixture(
));
}
}
vplates.enemies = false;
set_enemy_plates(script.as_deref(), false);
}
}
}
@@ -1605,3 +1605,16 @@ fn seed_equipped_bags(
}
}
}
/// Turn enemy plates on or off as the V binding does, the verb and FrameXML's global together,
/// so FrameXML's own replay agrees.
fn set_enemy_plates(script: Option<&benilla_ui::script::UiScript>, on: bool) {
let lua = if on {
"NAMEPLATES_ON = 1; ShowNameplates()"
} else {
"NAMEPLATES_ON = nil; HideNameplates()"
};
if let Some(Err(e)) = script.map(|s| s.run(lua)) {
warn!("nameplates: {e}");
}
}
+16 -21
View File
@@ -319,11 +319,6 @@ pub(crate) const REGISTERED: &[Registered] = &[
// `0x609085`. `UnitNamePlayerPVPTitle` (bit `0x20`, "1") has no row: nothing here draws the
// rank prefix.
same("UnitNamePlayerGuild", "1"),
// 1.12 has no nameplate CVar: the bitmask `[0xc4da34]` (bits 0 and 3) persists through
// FrameXML's `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON`, so these take the later-era names. Both
// boot off (`UIOptionsFrame.lua:180-183`, `:769-775`): no plates until V.
same(crate::vplates::CVAR_ENEMIES, "0"),
same(crate::vplates::CVAR_FRIENDS, "0"),
// `WorldDetail` is no 1.12 CVar but the `GetWorldDetail`/`SetWorldDetail` verb name
// (`OptionsFrame.lua:27`); stops 0/1/2 are `frillDensity` 16/32/48. `SetWorldDetail 0x488dd0`
// also writes `SmallCull` {0.07, 0.04, 0.01}, and `GetWorldDetail` reads only `SmallCull`,
@@ -846,6 +841,22 @@ impl Cvars {
self.last_change = Some(Instant::now());
}
/// Remove a `config.toml` entry no row claims, a setting retired from the table, and return
/// its value; the next save writes the file without it.
pub(crate) fn retire_file_entry(&mut self, name: &str) -> Option<String> {
if self.index.contains_key(&name.to_ascii_lowercase()) {
return None;
}
let key = self
.file
.keys()
.find(|k| k.eq_ignore_ascii_case(name))?
.clone();
let value = self.file.remove(&key);
self.touch();
value
}
/// A write from either side of the VM boundary. A `from_vm` write is not echoed back, but a
/// refusal is, because the mirror already stored the refused value.
fn write(&mut self, name: &str, value: &str, from_vm: bool) -> SetOutcome {
@@ -1488,7 +1499,6 @@ mod tests {
use crate::ui_loot::LootConfig;
use crate::ui_script::{UiScaleCvar, DEFAULT_UI_SCALE};
use crate::video::VideoConfig;
use crate::vplates::VPlateMode;
use crate::world_backdrop::{RenderScale, RENDER_SCALE_RANGE};
use benilla_ui::widget::MINIMAP_ZOOM_LEVELS;
use benilla_world::clutter::ClutterConfig;
@@ -1697,13 +1707,6 @@ mod tests {
d["weatherDensity"],
f32::from(benilla_world::weather::WeatherState::default().weather_density)
);
let plates = VPlateMode::default();
assert_eq!(d[crate::vplates::CVAR_ENEMIES] != 0.0, plates.enemies);
assert_eq!(d[crate::vplates::CVAR_FRIENDS] != 0.0, plates.friends);
assert!(
!plates.enemies && !plates.friends,
"a fresh 1.12 client draws no plates until V is pressed"
);
// `ClutterConfig::default()` reads `$WOW_CLUTTER_DENSITY`; stop 1 is its env-less ×2.
assert_eq!(d["WorldDetail"], 1.0);
assert_eq!(
@@ -1885,10 +1888,6 @@ mod tests {
assert!(!res::<NameConfig>(&app).npc);
apply(&mut app, "unitnameown", "1");
assert!(res::<NameConfig>(&app).own);
apply(&mut app, crate::vplates::CVAR_ENEMIES, "0");
assert!(!res::<VPlateMode>(&app).enemies);
apply(&mut app, "nameplateshowfriends", "1");
assert!(res::<VPlateMode>(&app).friends);
apply(&mut app, "ChatBubbles", "0");
assert!(!res::<BubbleConfig>(&app).all);
apply(&mut app, "chatbubblesparty", "0");
@@ -2195,7 +2194,6 @@ mod tests {
.init_resource::<crate::target::AssistAttack>()
.init_resource::<LootConfig>()
.init_resource::<NameConfig>()
.init_resource::<VPlateMode>()
.init_resource::<ClutterConfig>()
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
@@ -2259,9 +2257,6 @@ mod tests {
|app| {
app.add_observer(crate::nameplates::on_cvar);
},
|app| {
app.add_observer(crate::vplates::on_cvar);
},
|app| {
app.add_observer(crate::game_tip::on_cvar);
},
+2 -2
View File
@@ -26,8 +26,8 @@ impl Plugin for UiSavedPlugin {
/// A malformed or unreadable file warns and is held ([`UiScript::hold_saved_file`]), so the
/// shutdown write leaves it alone. Read as bytes: the writer keeps a Lua string's raw bytes.
///
/// `host_settings` seats the `RegisterForSave` globals kept in `config.toml` (the nameplate pair):
/// after the chunk, so a stale line here cannot outvote `config.toml`, and before the event, where
/// `host_settings` seats what the host carries into a `RegisterForSave` global (a plate setting
/// left in `config.toml`): after the chunk, so it outranks the file, and before the event, where
/// the consumers run (`UIParent_OnEvent` → `UpdateNameplates`).
pub(crate) fn load_saved_variables(
script: &mut UiScript,
@@ -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
+242 -173
View File
@@ -23,7 +23,9 @@ use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use benilla_protocol::EntityKind;
use benilla_ui::script::{unit_is_grey, PlateGeometry, PlateState};
use benilla_ui::script::{
unit_is_grey, PlateGeometry, PlateState, PLATE_BIT_ENEMY, PLATE_BIT_FRIEND,
};
use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
use crate::names::NameCache;
@@ -34,19 +36,30 @@ use benilla_world::view::WorldCamera;
/// The plate border, sharp-resampled so its 128x32 art stays crisp when magnified.
pub(crate) mod border;
/// The `[0xc4da34]` master bitmask: bit 0 enemy plates, bit 3 friendly, both clear at boot as in
/// the reference (`UIOptionsFrame.lua:180-183` sets the saved globals nil). The gate reads this
/// resource; [`CVAR_ENEMIES`] and [`CVAR_FRIENDS`] persist it. 1.12 registers no nameplate CVar,
/// so those names are not 1.12's.
#[derive(Resource, Default, Clone, Copy)]
/// The two script bits of `[0xc4da34]`, bit `0x1` enemy plates and bit `0x8` friendly: engine
/// state, written only by the four plate verbs ([`apply_plate_verbs`]) and cleared at every world
/// entry ([`clear_at_world_entry`]). Nothing persists it: FrameXML replays its own
/// `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` saved variables through the verbs
/// (`UIOptionsFrame.lua:768-780`, `UIParent.lua:234`, `:367`).
#[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct VPlateMode {
pub(crate) enemies: bool,
pub(crate) friends: bool,
}
/// The persisted names of the two toggles.
pub(crate) const CVAR_ENEMIES: &str = benilla_ui::script::CVAR_NAMEPLATE_ENEMIES;
pub(crate) const CVAR_FRIENDS: &str = benilla_ui::script::CVAR_NAMEPLATE_FRIENDS;
impl VPlateMode {
fn bits(self) -> u8 {
let bit = |on: bool, b: u8| if on { b } else { 0 };
bit(self.enemies, PLATE_BIT_ENEMY) | bit(self.friends, PLATE_BIT_FRIEND)
}
fn from_bits(bits: u8) -> Self {
Self {
enemies: bits & PLATE_BIT_ENEMY != 0,
friends: bits & PLATE_BIT_FRIEND != 0,
}
}
}
/// The units carrying a live plate this frame; the overhead-name driver draws no name for them
/// (the reference's ShouldShowName).
@@ -176,78 +189,80 @@ pub(crate) fn text_px(h: f32, basis: f32) -> f32 {
(h * basis).round().min(32.0)
}
/// Push the mode into FrameXML's `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON`. `UpdateNameplates`
/// (`UIOptionsFrame.lua:768`) replays these globals into the engine on `VARIABLES_LOADED` and
/// every `PLAYER_ENTERING_WORLD` (`UIParent.lua:234`, `:367`), so they must match the mode or
/// the replay turns plates off and overwrites the persisted CVar.
pub(crate) fn push_plate_globals(script: &benilla_ui::script::UiScript, mode: VPlateMode) {
// The number 1 or nil, never 0: a Lua 0 is truthy.
let on = |b: bool| b.then_some(1i64);
let g = script.lua().globals();
if let Err(e) = g
.set("NAMEPLATES_ON", on(mode.enemies))
.and_then(|()| g.set("FRIENDNAMEPLATES_ON", on(mode.friends)))
{
warn!("nameplates: FrameXML globals: {e}");
}
}
/// Keep the globals in step with the mode, memoised per VM; the world-entry load seeds them
/// before `VARIABLES_LOADED`, which no `Update` system can reach.
fn feed_plate_globals(
/// Every frame: fold the plate verbs' writes into the bits, in the order the VM made them.
pub(crate) fn apply_plate_verbs(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mode: Res<VPlateMode>,
mut told: Local<crate::ui_script::VmMemo<Option<(bool, bool)>>>,
mut mode: ResMut<VPlateMode>,
) {
let Some(script) = script else {
let Some(mut script) = script else {
return;
};
let now = (mode.enemies, mode.friends);
let told = told.get(&script);
if *told != Some(now) {
*told = Some(now);
push_plate_globals(&script, *mode);
let writes = script.take_nameplate_bit_writes();
if writes.is_empty() {
return;
}
let now = VPlateMode::from_bits(writes.apply(mode.bits()));
if *mode != now {
info!(
"nameplates: enemy {}, friendly {}",
if now.enemies { "ON" } else { "OFF" },
if now.friends { "ON" } else { "OFF" }
);
*mode = now;
}
}
/// The NAMEPLATES, FRIENDNAMEPLATES and ALLNAMEPLATES bindings; ALLNAMEPLATES is 1.12's body,
/// both on unless both already on. A press also writes the CVar, which persists it.
///
/// Deviation: V and Shift-V toggle their own bit independently, where 1.12's bodies
/// (`Bindings.xml:516-537`) turn the other kind off.
fn toggle_vplates(
binds: Res<crate::bindings::BindingsState>,
mut mode: ResMut<VPlateMode>,
mut cvars: ResMut<crate::cvars::Cvars>,
/// The world-entry clear of both script bits (`0x60394b`, `0x603952`, in `0x6033c0`), which the
/// entry setup `0x401570` runs at `0x401639`, after the UI load at `0x401602` (`InitializeGame`,
/// whose `UI_Init` fires `VARIABLES_LOADED` at `0x4900b2`): the load's replay is cleared, and
/// the one at `PLAYER_ENTERING_WORLD` decides. A `ReloadUI()` never reaches it.
pub(crate) fn clear_at_world_entry(world: &mut World) {
if let Some(mut script) = world.get_non_send_resource_mut::<benilla_ui::script::UiScript>() {
let _ = script.take_nameplate_bit_writes();
}
if let Some(mut mode) = world.get_resource_mut::<VPlateMode>() {
*mode = VPlateMode::default();
}
}
/// The `config.toml` names benilla once kept the two bits under, with the FrameXML global each
/// carries over to. 1.12 has no nameplate CVar; the setting is FrameXML's saved variables.
const LEGACY_CVARS: [(&str, &str); 2] = [
("nameplateShowEnemies", "NAMEPLATES_ON"),
("nameplateShowFriends", "FRIENDNAMEPLATES_ON"),
];
/// A plate setting still in `config.toml` from before it moved to FrameXML's saved variables:
/// each global and whether it was on.
#[derive(Default)]
pub(crate) struct LegacyPlateSettings(Vec<(&'static str, bool)>);
/// Take the legacy plate settings out of `config.toml`, once: the next save drops them, and from
/// then on the saved-variables file carries them.
pub(crate) fn take_legacy_settings(cvars: &mut crate::cvars::Cvars) -> LegacyPlateSettings {
LegacyPlateSettings(
LEGACY_CVARS
.iter()
.filter_map(|&(cvar, global)| {
let value = cvars.retire_file_entry(cvar)?;
Some((global, value.trim() == "1"))
})
.collect(),
)
}
/// Seat the legacy settings over the saved-variables chunk's values, before `VARIABLES_LOADED`,
/// as the number 1 or nil (a Lua 0 is truthy). Until now the setting was the CVar, which
/// outranked the file.
pub(crate) fn seat_legacy_settings(
script: &benilla_ui::script::UiScript,
legacy: &LegacyPlateSettings,
) {
use crate::bindings::cmd;
let (was_enemies, was_friends) = (mode.enemies, mode.friends);
if binds.fired(cmd::NAMEPLATES) {
mode.enemies = !mode.enemies;
info!(
"nameplates: enemy {}",
if mode.enemies { "ON" } else { "OFF" }
);
}
if binds.fired(cmd::FRIEND_NAMEPLATES) {
mode.friends = !mode.friends;
info!(
"nameplates: friendly {}",
if mode.friends { "ON" } else { "OFF" }
);
}
if binds.fired(cmd::ALL_NAMEPLATES) {
let both = mode.enemies && mode.friends;
mode.enemies = !both;
mode.friends = !both;
info!("nameplates: all {}", if !both { "ON" } else { "OFF" });
}
let flag = |b: bool| if b { "1" } else { "0" };
if mode.enemies != was_enemies {
cvars.set(CVAR_ENEMIES, flag(mode.enemies));
}
if mode.friends != was_friends {
cvars.set(CVAR_FRIENDS, flag(mode.friends));
let g = script.lua().globals();
for &(global, on) in &legacy.0 {
if let Err(e) = g.set(global, on.then_some(1i64)) {
warn!("nameplates: carrying {global} over: {e}");
}
}
}
@@ -619,18 +634,8 @@ pub(crate) struct VPlateSet;
/// V-key nameplates: the toggles and the per-frame gate and draw.
pub(crate) struct VPlatesPlugin;
/// Apply a change to either toggle CVar to the bitmask.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut mode: ResMut<VPlateMode>) {
if ev.is(CVAR_ENEMIES) {
mode.enemies = ev.flag();
} else if ev.is(CVAR_FRIENDS) {
mode.friends = ev.flag();
}
}
impl Plugin for VPlatesPlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
app.init_resource::<VPlateMode>()
.init_resource::<VPlates>()
.init_resource::<PlateHover>()
@@ -638,7 +643,8 @@ impl Plugin for VPlatesPlugin {
.add_systems(
Update,
(
toggle_vplates,
// A drain of the verbs the tick ran (a binding body, `UpdateNameplates`).
apply_plate_verbs.after(crate::ui_script::UiInput),
// After the targeting chain, itself after `WorldStage::Input`, so this projects
// through this frame's camera; `ui_script::extract::paint_script` runs after
// it, so the plates paint the same frame.
@@ -646,11 +652,7 @@ impl Plugin for VPlatesPlugin {
)
.chain()
.in_set(VPlateSet),
)
// Outside `VPlateSet`, which runs after the targeting chain: it only needs to precede
// the script tick, and a frame's lag is harmless since the globals are read only at
// world entry.
.add_systems(Update, feed_plate_globals.in_set(crate::ui_script::UiFeed));
);
}
}
@@ -711,103 +713,170 @@ mod tests {
assert_eq!(text_px(NAME_H, 10_000.0), 32.0, "atlas-cell cap, raw law");
}
/// Without the CVar write, a key-toggled plate would revert at every launch.
#[test]
fn the_v_key_mirrors_into_the_cvar_table() {
use crate::bindings::{cmd, BindingsState};
use crate::cvars::Cvars;
/// An app with the drain and a bare VM: the four verbs are all it needs.
fn verb_app() -> App {
let mut app = App::new();
app.add_systems(Update, toggle_vplates)
.init_resource::<VPlateMode>()
.init_resource::<Cvars>()
.insert_resource(BindingsState::test_fired(&[cmd::NAMEPLATES]));
app.update();
assert!(app.world().resource::<VPlateMode>().enemies, "V turns on");
let moved = |app: &mut App| {
app.world_mut()
.resource_mut::<Cvars>()
.take_events()
.into_iter()
.map(|e| (e.name, e.new))
.collect::<Vec<_>>()
};
assert_eq!(
moved(&mut app),
vec![(CVAR_ENEMIES.to_string(), "1".to_string())],
"the write is an accepted move, so the config dirties and the mirror learns it"
);
assert_eq!(
app.world().resource::<Cvars>().get(CVAR_FRIENDS),
Some("0"),
"untouched"
);
app.world_mut()
.insert_resource(BindingsState::test_fired(&[cmd::NAMEPLATES]));
app.update();
assert!(!app.world().resource::<VPlateMode>().enemies, "V turns off");
assert_eq!(
moved(&mut app),
vec![(CVAR_ENEMIES.to_string(), "0".to_string())]
);
// Shift-V moves only the other bit.
app.world_mut()
.insert_resource(BindingsState::test_fired(&[cmd::FRIEND_NAMEPLATES]));
app.update();
assert!(app.world().resource::<VPlateMode>().friends);
assert_eq!(
moved(&mut app),
vec![(CVAR_FRIENDS.to_string(), "1".to_string())]
);
app.world_mut().insert_resource(BindingsState::default());
app.update();
assert!(moved(&mut app).is_empty());
}
#[test]
fn the_plate_globals_follow_the_mode_and_survive_a_rebuilt_vm() {
let read = |app: &mut App| {
let s = app
.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>();
(
s.lua().globals().get::<Option<i64>>("NAMEPLATES_ON").ok(),
s.lua()
.globals()
.get::<Option<i64>>("FRIENDNAMEPLATES_ON")
.ok(),
)
};
let mut app = App::new();
app.add_systems(Update, feed_plate_globals)
app.add_systems(Update, apply_plate_verbs)
.init_resource::<VPlateMode>()
.insert_non_send_resource(benilla_ui::script::UiScript::new().unwrap());
app
}
fn run(app: &mut App, lua: &str) {
app.world()
.non_send_resource::<benilla_ui::script::UiScript>()
.run(lua)
.unwrap();
app.update();
assert_eq!(read(&mut app), (Some(None), Some(None)), "both off ⇒ nil");
}
app.world_mut().resource_mut::<VPlateMode>().enemies = true;
fn mode(app: &App) -> (bool, bool) {
let m = app.world().resource::<VPlateMode>();
(m.enemies, m.friends)
}
fn global(app: &App, name: &str) -> Option<i64> {
app.world()
.non_send_resource::<benilla_ui::script::UiScript>()
.lua()
.globals()
.get::<Option<i64>>(name)
.unwrap()
}
/// Both bits start clear: a fresh 1.12 client draws no plates until something shows them.
#[test]
fn the_verbs_move_the_bits_in_call_order() {
let mut app = verb_app();
app.update();
assert_eq!(mode(&app), (false, false), "boot: both clear");
run(&mut app, "ShowNameplates()");
assert_eq!(mode(&app), (true, false));
run(&mut app, "ShowFriendNameplates() HideNameplates()");
assert_eq!(mode(&app), (false, true), "each verb its own bit");
run(
&mut app,
"ShowNameplates() HideNameplates() HideFriendNameplates() ShowFriendNameplates()",
);
assert_eq!(mode(&app), (false, true), "the last write per bit stands");
}
/// The entry clears the bits after the UI load (`0x401639` after `0x401602`), so the
/// `VARIABLES_LOADED` replay does not survive it and `PLAYER_ENTERING_WORLD`'s decides.
#[test]
fn world_entry_clears_both_bits_and_the_loads_replay_with_them() {
let mut app = verb_app();
run(&mut app, "ShowNameplates() ShowFriendNameplates()");
assert_eq!(mode(&app), (true, true));
// The load's replay, not yet drained when the entry clears.
app.world()
.non_send_resource::<benilla_ui::script::UiScript>()
.run("ShowNameplates()")
.unwrap();
clear_at_world_entry(app.world_mut());
app.update();
assert_eq!(mode(&app), (false, false), "both clear after the entry");
run(&mut app, "ShowFriendNameplates()");
assert_eq!(
read(&mut app),
(Some(Some(1)), Some(None)),
"the reference's own truthiness: the NUMBER 1, never a truthy `0`"
mode(&app),
(false, true),
"and the next verb moves them again"
);
}
/// The V, Shift-V and Ctrl-V bodies: each flips the verb and FrameXML's global together, so
/// the saved variable carries what the key did, and none writes a CVar.
#[test]
fn the_v_keys_move_the_bits_and_the_saved_globals_together() {
use crate::bindings::commands::{Kind, SPECS};
let body = |name: &str| match SPECS.iter().find(|s| s.name == name).map(|s| &s.kind) {
Some(Kind::Edge(lua)) => *lua,
_ => panic!("{name} runs a Lua body"),
};
let mut app = verb_app();
let state = |app: &App| {
(
mode(app),
global(app, "NAMEPLATES_ON"),
global(app, "FRIENDNAMEPLATES_ON"),
)
};
run(&mut app, body("NAMEPLATES"));
assert_eq!(state(&app), ((true, false), Some(1), None), "V turns on");
run(&mut app, body("FRIENDNAMEPLATES"));
assert_eq!(
state(&app),
((true, true), Some(1), Some(1)),
"Shift-V moves only the other kind"
);
run(&mut app, body("NAMEPLATES"));
assert_eq!(state(&app), ((false, true), None, Some(1)), "V turns off");
run(&mut app, body("ALLNAMEPLATES"));
assert_eq!(
state(&app),
((true, true), Some(1), Some(1)),
"Ctrl-V: both on"
);
run(&mut app, body("ALLNAMEPLATES"));
assert_eq!(
state(&app),
((false, false), None, None),
"...then both off"
);
assert!(
app.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.take_cvar_changes()
.is_empty(),
"no CVar: nothing reaches config.toml"
);
}
/// A player's plate setting from `config.toml` is carried into the saved-variables globals
/// once, over what the file said, and leaves `config.toml`.
#[test]
fn a_legacy_config_setting_carries_over_once() {
use crate::cvars::Cvars;
let mut cvars = Cvars::with_value("nameplateShowFriends", "1");
let legacy = take_legacy_settings(&mut cvars);
assert_eq!(legacy.0, vec![("FRIENDNAMEPLATES_ON", true)]);
assert!(
take_legacy_settings(&mut cvars).0.is_empty(),
"taken once: the file no longer holds it"
);
assert!(
!cvars
.orphans()
.iter()
.any(|(k, _)| k == "nameplateShowFriends"),
"and the next save drops it"
);
// `ReloadUI()`: a fresh VM with the mode unchanged is told again.
app.insert_non_send_resource(benilla_ui::script::UiScript::new().unwrap());
let s = benilla_ui::script::UiScript::new().unwrap();
// The saved-variables chunk said off; the CVar was the store, so it wins.
s.run("FRIENDNAMEPLATES_ON = nil NAMEPLATES_ON = 1")
.unwrap();
seat_legacy_settings(&s, &legacy);
assert_eq!(
read(&mut app),
(Some(None), Some(None)),
"a fresh VM knows nothing"
s.eval::<Option<i64>>("return FRIENDNAMEPLATES_ON").unwrap(),
Some(1),
"the number 1, never a truthy `0`"
);
app.update();
assert_eq!(
read(&mut app),
(Some(Some(1)), Some(None)),
"…and is handed the mode again"
s.eval::<Option<i64>>("return NAMEPLATES_ON").unwrap(),
Some(1),
"a global with no legacy entry is left as the file set it"
);
let mut off = Cvars::with_value("NamePlateShowEnemies", "0");
let legacy = take_legacy_settings(&mut off);
s.run("NAMEPLATES_ON = 1").unwrap();
seat_legacy_settings(&s, &legacy);
assert_eq!(
s.eval::<Option<i64>>("return NAMEPLATES_ON").unwrap(),
None,
"an entry of 0 is off, whatever its spelling"
);
}
-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