zone channels survive a border crossing, and nameplates survive a login

- crossing a zone border prints one Changed Channel line instead of a leave and a join, and General and LocalDefense keep printing in the new zone; walking out of a capital keeps Trade's number and its registration instead of dropping it (2130)
- outside a capital the channel numbers are the reference's: a fresh character holds 1 General, 2 Trade (suspended until a city) and 3 LocalDefense, so /2 reaches the same channel it does on the real client (2137)
- logging into a character reads that character's zone, not the previous one's, so a login in the Eastern Plaguelands no longer joins and leaves Stormwind's channels first; a chat cache with General saved as a custom name gets its id back (2130)
- the Show Tutorials option survives a /reload: it loaded unchecked and clicking it reset every dismissed tutorial account-wide; a world entry no longer switches off nameplates you had turned on (2131, 2132)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 20:51:41 +07:00
co-authored by Claude Fable 5.1
parent 374bc5898d
commit fad9ba02c3
21 changed files with 1444 additions and 135 deletions
+31 -8
View File
@@ -444,9 +444,23 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// any `CHAT_MSG_*` (ref `ChatFrame.lua` l.1261-1273) — as an `Update` system it landed after
// the session's first chat had already been routed, and the login MOTD went to a window
// registered for nothing.
finish_ui_load_with(&mut script, |script| {
crate::ui_chat::restore_chat_looks(world, script);
});
// The plate pair is read out of the world FIRST: the second closure borrows `world` for the
// chat-cache restore, and a `Copy` of two bools costs nothing next to fighting that borrow.
// Absent in a bare test world, where "both off" is also the resource's own default.
let plates = world
.get_resource::<crate::vplates::VPlateMode>()
.copied()
.unwrap_or_default();
finish_ui_load_with(
&mut script,
// `NAMEPLATES_ON` / `FRIENDNAMEPLATES_ON` (2132). This seat, and not the `Update` feed
// beside it, is what fixes the bug: `UIParent_OnEvent`'s first `UpdateNameplates()` runs
// inside the `VARIABLES_LOADED` fired at the end of this very call.
|script| crate::vplates::push_plate_globals(script, plates),
|script| {
crate::ui_chat::restore_chat_looks(world, script);
},
);
// **Say it out loud when an addon didn't load** (decision 1495). Every failure the walk found
// is retained now, but a log nobody knows to open does not fix silence — and silence is the
// actual defect B293 reports: *"there are a lot of addons that still doesn't work"*, with
@@ -758,7 +772,7 @@ pub(crate) fn shutdown_on_exit(
/// restore has to sit inside it. A test that only wants the tail keeps the plain shape.
#[cfg(test)]
pub(crate) fn finish_ui_load(script: &mut UiScript) {
finish_ui_load_with(script, |_| {});
finish_ui_load_with(script, |_| {}, |_| {});
}
/// [`finish_ui_load`] with the one step that has to land **between** `VARIABLES_LOADED` and
@@ -773,14 +787,23 @@ pub(crate) fn finish_ui_load(script: &mut UiScript) {
/// an addon reading its chat colours out of a `VARIABLES_LOADED` handler would see the file's
/// values where the reference shows it the boot ones.
///
/// A callback rather than a split pair because the budget re-arm and the two fires are one edge,
/// and a caller that forgets the middle step should not be able to compile.
pub(crate) fn finish_ui_load_with(script: &mut UiScript, between: impl FnOnce(&mut UiScript)) {
/// `host_settings` is the earlier of the two seams — **between the saved-variables chunk and
/// `VARIABLES_LOADED`** — where the settings benilla keeps in `config.toml` rather than in that
/// file are pushed into the VM (decision 2132; the rationale for the exact seat is on
/// [`crate::ui_saved::load_saved_variables`]).
///
/// Callbacks rather than a split trio because the budget re-arm and the two fires are one edge,
/// and a caller that forgets a middle step should not be able to compile.
pub(crate) fn finish_ui_load_with(
script: &mut UiScript,
host_settings: impl FnOnce(&mut UiScript),
between: impl FnOnce(&mut UiScript),
) {
// Still the load edge, so still bounded (1306) — re-armed because the walk's last addon left
// an arbitrary amount on the counter, and the saved-variables chunk plus every PLAYER_LOGIN
// handler deserve the full allowance. The entry edge disarms after this returns.
script.set_instruction_budget(addons::LOAD_INSTRUCTION_BUDGET);
crate::ui_saved::load_saved_variables(script);
crate::ui_saved::load_saved_variables(script, host_settings);
between(script);
script.fire_event("PLAYER_LOGIN", vec![]);
}
@@ -1363,6 +1363,98 @@ fn the_world_detail_slider_writes_the_cvar_and_the_readout_names_its_stop() {
/// The Nameplates page (0992 — live at last): the three 1.12 UnitName* checkbox rows read the
/// table on select and write their flags on the interface panel's own click kit (checked →
/// CheckBoxOn; these rows carry no soundQuirk).
/// **A world entry must leave the saved nameplate setting alone** (decision 2132) — the report,
/// on the real manifest with a real CVar table, which is the one combination no test had.
///
/// `UIParent_OnEvent` calls `UpdateNameplates()` twice per entry — `UIParent.lua` l.234
/// (VARIABLES_LOADED) and l.367 (PLAYER_ENTERING_WORLD) — and it acts on
/// `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON`. On benilla the four verbs it calls write the CVar pair,
/// and the CVar pair IS the store, so with the globals left nil every entry wrote `"0"` over the
/// player's `"1"` and `config.toml` then dropped the line as "at default".
///
/// Two mechanisms are needed and this asserts both: the host seeding the globals ahead of
/// `VARIABLES_LOADED` (the enemy half), and our re-declared `UpdateNameplates` without the stock
/// `ShowFriendNameplates(); HideFriendNameplates();` typo (the friendly half). Drop either and the
/// matching row here goes back to `"0"`.
#[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);
// A manifest load with no player is a state the client never reaches (1848).
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();
// The player has both halves on, restored from `config.toml` — a host write, so nothing is
// queued and the file is not re-dirtied by having been read.
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 bug, kept as the control: with the globals nil the entry erases both halves ────────
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();
// ── The fix: the host seeds the globals, and the entry is a no-op end to end ───────────────
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());
// The other direction still works: an addon that moves a global and calls the verb.
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() {
let mut s = harness_on(audio_harness());
+3 -2
View File
@@ -227,8 +227,9 @@ pub(super) const UIPARENT_STAND_INS: &str = r#"
RaidOptionsFrame_UpdatePartyFrames = RaidOptionsFrame_UpdatePartyFrames or function() end
LocalizeFrames = LocalizeFrames or function() end
updateContainerFrameAnchors = updateContainerFrameAnchors or function() end
-- Ours, not the reference's file: 1.12 keeps UpdateNameplates in UIOptionsFrame.lua and
-- benilla's own options window carries it (the options family stays ours).
-- 1.12 keeps UpdateNameplates in UIOptionsFrame.lua, which a kit reaches only at manifest
-- l.21; our own OptionsFrame.xml re-declares it below that (decision 2132). Both are plain
-- `function X()` writes, so a full kit ends on ours and a short one keeps this no-op.
UpdateNameplates = UpdateNameplates or function() end
CloseAllBags = CloseAllBags or function() end
OpenBackpack = OpenBackpack or function() end