From 0a1f69466cd7ca299bca7f1ac6b1bc8d4f24a1da Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:21:17 +0700 Subject: [PATCH] The nameplate verbs write two runtime bits, cleared at world entry, and the setting lives in FrameXML's saved variables, not two CVars (#571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../benilla-app/assets/ui/FrameXMLFixes.xml | 18 + crates/benilla-app/assets/ui/OptionsFrame.xml | 38 +- crates/benilla-app/src/bindings/commands.rs | 35 +- crates/benilla-app/src/capture/fixtures.rs | 21 +- crates/benilla-app/src/cvars.rs | 37 +- crates/benilla-app/src/ui_saved.rs | 4 +- .../benilla-app/src/ui_script/layer_tests.rs | 119 +++++ crates/benilla-app/src/ui_script/lifecycle.rs | 55 ++- .../src/ui_script/options_tests.rs | 124 ++---- crates/benilla-app/src/vplates.rs | 415 ++++++++++-------- crates/benilla-ui/src/script/cvars.rs | 46 -- crates/benilla-ui/src/script/mod.rs | 7 +- crates/benilla-ui/src/script/nameplate.rs | 80 ++++ .../src/script/tests/reference_surface.rs | 59 +-- 14 files changed, 666 insertions(+), 392 deletions(-) diff --git a/crates/benilla-app/assets/ui/FrameXMLFixes.xml b/crates/benilla-app/assets/ui/FrameXMLFixes.xml index 26cfff83..461e6f9a 100644 --- a/crates/benilla-app/assets/ui/FrameXMLFixes.xml +++ b/crates/benilla-app/assets/ui/FrameXMLFixes.xml @@ -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") ]]> diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 3b4a4219..924a22bd 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -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 @@ - + @@ -2570,16 +2580,16 @@ - BenillaOptionsRow_OnLoad(this, "nameplateShowEnemies", - "Enemy Name Plates") + BenillaOptionsFuncRow_OnLoad(this, BenillaOptionsFrame_GetEnemyPlates, + BenillaOptionsFrame_SetEnemyPlates, "0", "Enemy Name Plates") - BenillaOptionsRow_OnLoad(this, "nameplateShowFriends", - "Friendly Name Plates") + BenillaOptionsFuncRow_OnLoad(this, BenillaOptionsFrame_GetFriendlyPlates, + BenillaOptionsFrame_SetFriendlyPlates, "0", "Friendly Name Plates") diff --git a/crates/benilla-app/src/bindings/commands.rs b/crates/benilla-app/src/bindings/commands.rs index b8a32735..4c7ab656 100644 --- a/crates/benilla-app/src/bindings/commands.rs +++ b/crates/benilla-app/src/bindings/commands.rs @@ -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", diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 36f377d1..88563974 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -41,7 +41,6 @@ pub(super) fn seed_ui_fixture( mut names: ResMut, icons: Option>, mut script: Option>, - mut vplates: ResMut, mut selection: ResMut, mut player: ResMut, // 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}"); + } +} diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index dd0595b0..e0e1a1fd 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -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 { + 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::(&app).npc); apply(&mut app, "unitnameown", "1"); assert!(res::(&app).own); - apply(&mut app, crate::vplates::CVAR_ENEMIES, "0"); - assert!(!res::(&app).enemies); - apply(&mut app, "nameplateshowfriends", "1"); - assert!(res::(&app).friends); apply(&mut app, "ChatBubbles", "0"); assert!(!res::(&app).all); apply(&mut app, "chatbubblesparty", "0"); @@ -2195,7 +2194,6 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -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); }, diff --git a/crates/benilla-app/src/ui_saved.rs b/crates/benilla-app/src/ui_saved.rs index d5388072..a718e605 100644 --- a/crates/benilla-app/src/ui_saved.rs +++ b/crates/benilla-app/src/ui_saved.rs @@ -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, diff --git a/crates/benilla-app/src/ui_script/layer_tests.rs b/crates/benilla-app/src/ui_script/layer_tests.rs index f50e2be9..edf6805b 100644 --- a/crates/benilla-app/src/ui_script/layer_tests.rs +++ b/crates/benilla-app/src/ui_script/layer_tests.rs @@ -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::(), + |_| {}, + ); + app.update(); + if entry { + crate::vplates::clear_at_world_entry(app.world_mut()); + } + app.world_mut() + .non_send_resource_mut::() + .fire_event("PLAYER_ENTERING_WORLD", vec![]); + app.update(); + app +} + +fn plates(app: &bevy::app::App) -> (bool, bool) { + let m = app.world().resource::(); + (m.enemies, m.friends) +} + +fn plate_globals(app: &bevy::app::App) -> (Option, Option) { + app.world() + .non_send_resource::() + .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::()); + 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::(); + 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); +} diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 58e1841f..086e9a41 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -216,6 +216,8 @@ pub(crate) fn run_pending_entry_load(world: &mut World) { world.remove_resource::(); 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::(); (z.outdoor, z.inside) }; - let plates = world - .get_resource::() - .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::() + .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::().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::default()); + + let mut world = world_with(on); + world.insert_resource(ReloadUiPending(true)); + run_pending_reload(&mut world); + assert!(!world.resource::().0, "the reload ran"); + assert_eq!(*world.resource::(), 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() { diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 29028eee..e52b2211 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -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::("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, Option)>("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::( + "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 diff --git a/crates/benilla-app/src/vplates.rs b/crates/benilla-app/src/vplates.rs index 7bd4cabd..b884f099 100644 --- a/crates/benilla-app/src/vplates.rs +++ b/crates/benilla-app/src/vplates.rs @@ -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>, - mode: Res, - mut told: Local>>, + mut mode: ResMut, ) { - 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, - mut mode: ResMut, - mut cvars: ResMut, +/// 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::() { + let _ = script.take_nameplate_bit_writes(); + } + if let Some(mut mode) = world.get_resource_mut::() { + *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, mut mode: ResMut) { - 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::() .init_resource::() .init_resource::() @@ -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::() - .init_resource::() - .insert_resource(BindingsState::test_fired(&[cmd::NAMEPLATES])); - app.update(); - assert!(app.world().resource::().enemies, "V turns on"); - let moved = |app: &mut App| { - app.world_mut() - .resource_mut::() - .take_events() - .into_iter() - .map(|e| (e.name, e.new)) - .collect::>() - }; - 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::().get(CVAR_FRIENDS), - Some("0"), - "untouched" - ); - - app.world_mut() - .insert_resource(BindingsState::test_fired(&[cmd::NAMEPLATES])); - app.update(); - assert!(!app.world().resource::().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::().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::(); - ( - s.lua().globals().get::>("NAMEPLATES_ON").ok(), - s.lua() - .globals() - .get::>("FRIENDNAMEPLATES_ON") - .ok(), - ) - }; - let mut app = App::new(); - app.add_systems(Update, feed_plate_globals) + app.add_systems(Update, apply_plate_verbs) .init_resource::() .insert_non_send_resource(benilla_ui::script::UiScript::new().unwrap()); + app + } + fn run(app: &mut App, lua: &str) { + app.world() + .non_send_resource::() + .run(lua) + .unwrap(); app.update(); - assert_eq!(read(&mut app), (Some(None), Some(None)), "both off β‡’ nil"); + } - app.world_mut().resource_mut::().enemies = true; + fn mode(app: &App) -> (bool, bool) { + let m = app.world().resource::(); + (m.enemies, m.friends) + } + + fn global(app: &App, name: &str) -> Option { + app.world() + .non_send_resource::() + .lua() + .globals() + .get::>(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::() + .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::() + .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::>("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::>("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::>("return NAMEPLATES_ON").unwrap(), + None, + "an entry of 0 is off, whatever its spelling" ); } diff --git a/crates/benilla-ui/src/script/cvars.rs b/crates/benilla-ui/src/script/cvars.rs index 14ffe513..433117a8 100644 --- a/crates/benilla-ui/src/script/cvars.rs +++ b/crates/benilla-ui/src/script/cvars.rs @@ -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::().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}; diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 8b3decb5..adcdeefc 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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)?; diff --git a/crates/benilla-ui/src/script/nameplate.rs b/crates/benilla-ui/src/script/nameplate.rs index de8eb154..680cc7aa 100644 --- a/crates/benilla-ui/src/script/nameplate.rs +++ b/crates/benilla-ui/src/script/nameplate.rs @@ -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, + /// 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::().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::().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]) { diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index c3490a3a..6d88f31a 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -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