the wheel binds, one AddOns screen, text at the true scale
- SetBinding accepts the mouse wheel — the refusal was ours, not the client's — so wheel-bound action buttons press and release like the real thing - one addon load law: a single arbiter for "can this addon load, and why not", with the out-of-date version gate live - one AddOns screen — the character-select list, wearing the authored reference layout: roster dropdown, per-character tri-state checkboxes, force-load; the ESC-menu panel is retired, since 1.12's own menu has no AddOns row - the glyph atlas follows the window scale, so maximised text stops washing out and a title stops measuring wider than it draws; an unpositioned frame positions nothing, instead of landing at the screen origin Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,480 +0,0 @@
|
||||
//! The shipped `assets/ui/AddonList.xml` — the in-game AddOns list (decision 1197).
|
||||
//!
|
||||
//! **The window is the test of the API as much as the API is the test of the window.** This panel
|
||||
//! is written entirely against the eleven AddOn verbs 1191 landed — `GetNumAddOns`,
|
||||
//! `GetAddOnInfo`, `GetAddOnDependencies`, `EnableAddOn`, `DisableAddOn`, `EnableAllAddOns`,
|
||||
//! `DisableAllAddOns` — with no host support of its own. So driving it drives them, through the
|
||||
//! same Lua an addon author would write, which is worth more than a Rust test of each verb.
|
||||
//!
|
||||
//! What these guard: the row painter's three-colour rule and its status column against a registry
|
||||
//! with a real dependency graph; the checkbox round-trip through `EnableAddOn`/`DisableAddOn`;
|
||||
//! Cancel restoring what the panel found (`ResetAddOns` written out, since that verb is
|
||||
//! glue-namespace and absent in-world); the scroll clamp; and the Enable All / Disable All pair.
|
||||
|
||||
use benilla_ui::script::{AddOnInfo, UiScript};
|
||||
|
||||
/// Fonts + the panel, and a registry with a shape worth painting: one plain addon, a library, a
|
||||
/// dependent on that library, and one whose dependency is not installed.
|
||||
fn harness() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for file in [
|
||||
"Fonts.xml",
|
||||
"UiPanels.xml",
|
||||
"GameTooltip.xml",
|
||||
"AddonList.xml",
|
||||
] {
|
||||
let text = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("assets/ui")
|
||||
.join(file),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||||
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
|
||||
assert!(
|
||||
report.errors.is_empty(),
|
||||
"{file}: loader errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
}
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
let info = |name: &str, title: &str, deps: &[&str]| AddOnInfo {
|
||||
name: name.into(),
|
||||
title: Some(title.into()),
|
||||
notes: Some(format!("{name}'s notes")),
|
||||
url: None,
|
||||
secure: false,
|
||||
load_on_demand: false,
|
||||
dependencies: deps.iter().map(|d| (*d).to_string()).collect(),
|
||||
directives: Default::default(),
|
||||
files: Vec::new(),
|
||||
saved_variables: Vec::new(),
|
||||
saved_variables_per_character: Vec::new(),
|
||||
enabled: true,
|
||||
loaded: false,
|
||||
};
|
||||
s.register_addons(
|
||||
vec![
|
||||
info("Plain", "Plain Addon", &[]),
|
||||
info("Lib", "The Library", &[]),
|
||||
info("Needs", "Needs The Library", &["Lib"]),
|
||||
info("Orphan", "Orphaned", &["Nowhere"]),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
/// The text of row `i`'s title and status, as the panel painted them.
|
||||
fn row(s: &UiScript, i: usize) -> (String, String) {
|
||||
let title = s
|
||||
.eval::<String>(&format!("return AddonListEntry{i}Title:GetText() or ''"))
|
||||
.unwrap();
|
||||
let status = s
|
||||
.eval::<String>(&format!("return AddonListEntry{i}Status:GetText() or ''"))
|
||||
.unwrap();
|
||||
(title, status)
|
||||
}
|
||||
|
||||
/// The panel paints the registry: title (`## Title` else the folder name), the reference's status
|
||||
/// token, and a checkbox per row — all through `GetAddOnInfo`.
|
||||
#[test]
|
||||
fn the_panel_paints_the_registry_through_the_addon_api() {
|
||||
let s = harness();
|
||||
s.run("AddonList_Show()").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
assert!(s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap());
|
||||
assert_eq!(
|
||||
row(&s, 1).0,
|
||||
"Plain Addon",
|
||||
"`## Title` wins over the folder"
|
||||
);
|
||||
assert_eq!(row(&s, 3).0, "Needs The Library");
|
||||
|
||||
// Rows past the registry are hidden, not left showing a stale addon.
|
||||
assert!(
|
||||
!s.eval::<bool>("return AddonListEntry5:IsVisible()")
|
||||
.unwrap(),
|
||||
"row 5 has no addon behind it"
|
||||
);
|
||||
// Four addons is well under MAX_ADDONS_DISPLAYED, so the scroll pair stays hidden — the
|
||||
// reference's own GlueScrollFrame_Update condition.
|
||||
assert!(!s
|
||||
.eval::<bool>("return AddonListScrollUp:IsVisible()")
|
||||
.unwrap());
|
||||
|
||||
// A dependency that is not installed is a status, not a checkbox state.
|
||||
assert_eq!(
|
||||
row(&s, 4).1,
|
||||
"DEP_MISSING",
|
||||
"the reference's own reason token, shown when GlobalStrings has no ADDON_ string for it"
|
||||
);
|
||||
}
|
||||
|
||||
/// The checkbox round-trips through `EnableAddOn`/`DisableAddOn`, and the effect on a *dependent*
|
||||
/// shows up in its status column — which is the whole reason the panel re-reads rather than
|
||||
/// caching.
|
||||
#[test]
|
||||
fn a_checkbox_click_disables_through_the_api_and_the_dependent_says_so() {
|
||||
let s = harness();
|
||||
s.run("AddonList_Show()").unwrap();
|
||||
|
||||
// Row 2 is the library. Click its checkbox the way the XML does.
|
||||
s.run("AddonList_ToggleEntry(2)").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
assert_eq!(row(&s, 2).1, "DISABLED", "the player turned it off");
|
||||
assert_eq!(
|
||||
row(&s, 3).1,
|
||||
"DEP_DISABLED",
|
||||
"its dependent reports the reference's dependency token, not a stale clean row"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("return AddonListEntry2Check:GetChecked()")
|
||||
.unwrap(),
|
||||
"and the checkbox follows the registry rather than its own memory"
|
||||
);
|
||||
|
||||
// Clicking again re-enables, and the dependent recovers.
|
||||
s.run("AddonList_ToggleEntry(2)").unwrap();
|
||||
assert_eq!(row(&s, 2).1, "");
|
||||
assert_eq!(row(&s, 3).1, "");
|
||||
}
|
||||
|
||||
/// **Cancel restores what the panel found** — the reference's `ResetAddOns`, written out in Lua
|
||||
/// because that verb is glue-namespace and deliberately absent in-world (1191 §6).
|
||||
#[test]
|
||||
fn cancel_restores_the_state_the_panel_opened_with() {
|
||||
let s = harness();
|
||||
s.run("AddonList_Show()").unwrap();
|
||||
s.run("AddonList_ToggleEntry(1) AddonList_ToggleEntry(2)")
|
||||
.unwrap();
|
||||
assert_eq!(row(&s, 1).1, "DISABLED");
|
||||
|
||||
s.run("AddonList_OnCancel()").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert!(!s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap());
|
||||
|
||||
// Re-open: nothing the cancelled session did survived.
|
||||
s.run("AddonList_Show()").unwrap();
|
||||
assert_eq!(row(&s, 1).1, "");
|
||||
assert_eq!(row(&s, 2).1, "");
|
||||
}
|
||||
|
||||
/// Okay keeps the edit — the other half of the pair, and the one that would silently pass if
|
||||
/// `AddonList_OnOk` accidentally called the restore loop.
|
||||
#[test]
|
||||
fn okay_keeps_the_edit() {
|
||||
let s = harness();
|
||||
s.run("AddonList_Show()").unwrap();
|
||||
s.run("AddonList_ToggleEntry(1)").unwrap();
|
||||
s.run("AddonList_OnOk()").unwrap();
|
||||
assert!(!s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap());
|
||||
|
||||
s.run("AddonList_Show()").unwrap();
|
||||
assert_eq!(row(&s, 1).1, "DISABLED", "Okay is not a disguised Cancel");
|
||||
}
|
||||
|
||||
/// Enable All / Disable All go through the API's own pair, and the panel repaints.
|
||||
#[test]
|
||||
fn enable_all_and_disable_all_move_every_row() {
|
||||
let s = harness();
|
||||
s.run("AddonList_Show()").unwrap();
|
||||
|
||||
s.run("AddonList_SetAll(nil)").unwrap();
|
||||
for i in 1..=4 {
|
||||
assert_eq!(row(&s, i).1, "DISABLED", "row {i} after Disable All");
|
||||
}
|
||||
|
||||
s.run("AddonList_SetAll(1)").unwrap();
|
||||
assert_eq!(row(&s, 1).1, "");
|
||||
assert_eq!(
|
||||
row(&s, 4).1,
|
||||
"DEP_MISSING",
|
||||
"Enable All cannot fix a dependency that is not installed — the status is not a checkbox"
|
||||
);
|
||||
}
|
||||
|
||||
/// The scroll offset clamps at both ends, and a list that fits never scrolls.
|
||||
#[test]
|
||||
fn the_scroll_offset_clamps() {
|
||||
let s = harness();
|
||||
s.run("AddonList_Show()").unwrap();
|
||||
s.run("AddonList_Scroll(5)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return AddonList.offset").unwrap(),
|
||||
0,
|
||||
"four addons fit in nineteen rows — there is nothing to scroll"
|
||||
);
|
||||
s.run("AddonList_Scroll(-5)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return AddonList.offset").unwrap(), 0);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The ESC-menu path, end to end** — the director's ask stated as a test: open the game menu,
|
||||
/// click AddOns, and the list is up with the menu gone.
|
||||
///
|
||||
/// Loaded in the shipped manifest's own order (`AddonList.xml` before `GameMenuFrame.xml`), which
|
||||
/// is what makes the button's `AddonList_Toggle()` resolve at click time. A test that loaded them
|
||||
/// the other way round would pass on the button's `IsEnabled()` and fail on the click, which is
|
||||
/// exactly the seam worth covering.
|
||||
#[test]
|
||||
fn the_esc_menu_addons_entry_opens_the_list() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for file in [
|
||||
"Fonts.xml",
|
||||
"UiPanels.xml",
|
||||
"GameTooltip.xml",
|
||||
"AddonList.xml",
|
||||
"GameMenuFrame.xml",
|
||||
] {
|
||||
let text = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("assets/ui")
|
||||
.join(file),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||||
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
|
||||
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
|
||||
}
|
||||
|
||||
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return GameMenuButtonAddOns:IsEnabled()")
|
||||
.unwrap(),
|
||||
"the entry is live — it stopped being GameMenuButton_Pending in 1197"
|
||||
);
|
||||
|
||||
s.run("GameMenuButtonAddOns:Click()").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert!(
|
||||
s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap(),
|
||||
"the list came up"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
|
||||
"and the menu went away — the Macros entry's own body shape"
|
||||
);
|
||||
}
|
||||
|
||||
/// **ESC cancels the list**, the reference's own `AddonList_OnKeyDown` arm — and it discards.
|
||||
///
|
||||
/// This engine gives plain frames no raw key events, so the window joins `ToggleGameMenu`'s
|
||||
/// shared ESC chain like the stack-split spinner and the world map. The rung sits above the
|
||||
/// options window and below the popup engine, so a confirm dialog still wins the key.
|
||||
#[test]
|
||||
fn escape_cancels_the_list_and_discards_the_edit() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for file in [
|
||||
"Fonts.xml",
|
||||
"UiPanels.xml",
|
||||
"GameTooltip.xml",
|
||||
"AddonList.xml",
|
||||
] {
|
||||
let text = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("assets/ui")
|
||||
.join(file),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||||
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
|
||||
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
|
||||
}
|
||||
s.register_addons(
|
||||
vec![benilla_ui::script::AddOnInfo {
|
||||
name: "Solo".into(),
|
||||
title: Some("Solo".into()),
|
||||
notes: None,
|
||||
url: None,
|
||||
secure: false,
|
||||
load_on_demand: false,
|
||||
dependencies: Vec::new(),
|
||||
directives: Default::default(),
|
||||
files: Vec::new(),
|
||||
saved_variables: Vec::new(),
|
||||
saved_variables_per_character: Vec::new(),
|
||||
enabled: true,
|
||||
loaded: false,
|
||||
}],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
s.run("AddonList_Show()").unwrap();
|
||||
s.run("AddonList_ToggleEntry(1)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return AddonListEntry1Status:GetText() or ''")
|
||||
.unwrap(),
|
||||
"DISABLED"
|
||||
);
|
||||
|
||||
s.run("ToggleGameMenu()").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert!(
|
||||
!s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap(),
|
||||
"ESC put the list away"
|
||||
);
|
||||
s.run("AddonList_Show()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return AddonListEntry1Status:GetText() or ''")
|
||||
.unwrap(),
|
||||
"",
|
||||
"and discarded the edit — ESC is Cancel, not Okay"
|
||||
);
|
||||
}
|
||||
|
||||
/// The hover tooltip runs — `AddonTooltip_Update`'s three lines (title, notes, dependencies) on
|
||||
/// the shared `GameTooltip` rather than a private frame.
|
||||
///
|
||||
/// Worth its own test because it is the one path the other seven never touch: a load error would
|
||||
/// have shown up in the harness, but a *runtime* error in a hover handler goes to
|
||||
/// `UiScript::errors` and nothing else, which is exactly the silent kind.
|
||||
#[test]
|
||||
fn hovering_a_row_raises_the_tooltip_with_notes_and_dependencies() {
|
||||
let s = harness();
|
||||
s.run("AddonList_Show()").unwrap();
|
||||
|
||||
// Row 3 is `Needs`, which depends on `Lib` — so it exercises both optional lines.
|
||||
s.run("AddonList_RowEnter(AddonListEntry3)").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert!(s
|
||||
.eval::<bool>("return GameTooltip:IsOwned(AddonListEntry3)")
|
||||
.unwrap());
|
||||
|
||||
s.run("AddonList_RowLeave()").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
// And a row with no dependencies takes the other branch without erroring.
|
||||
s.run("AddonList_RowEnter(AddonListEntry1)").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The ESC-menu list reports an out-of-date `## Interface`, exactly like its char-select twin.**
|
||||
///
|
||||
/// The two screens are the same job over two views of one folder (1197 §1, §3), and they were
|
||||
/// answering *differently* about the same addon: the glue panel read `## Interface` off the manifest
|
||||
/// and showed "Out of date", while this one asked `GetAddOnInfo` — whose `reason` has no interface
|
||||
/// arm, and correctly so, because 1191 §6 reports the mismatch and never enforces it. Not being a
|
||||
/// reason the addon cannot load, it was simply never shown here. Nothing errored; the column was
|
||||
/// just blank, which is the silent-gap shape this arc keeps finding.
|
||||
///
|
||||
/// The three arms are the whole rule: mismatched reports, matching does not, and **absent is silent
|
||||
/// rather than out-of-date** — the same case `a manifest with no ## Interface is silent` pins on the
|
||||
/// glue side.
|
||||
/// Asserted on the STATUS COLUMN the player reads, not on the helper — the helper being right
|
||||
/// while the column stayed blank is precisely the bug this replaces.
|
||||
#[test]
|
||||
fn the_list_reports_an_out_of_date_interface_like_the_char_select_twin() {
|
||||
let mut s = harness();
|
||||
|
||||
// Three real manifests, differing only in `## Interface` — the directive `GetAddOnMetadata`
|
||||
// reads, so this drives the production path rather than stubbing it.
|
||||
let with_interface = |name: &str, declared: Option<&str>| AddOnInfo {
|
||||
name: name.into(),
|
||||
title: Some(name.into()),
|
||||
directives: declared
|
||||
.map(|v| vec![("Interface".to_string(), v.to_string())])
|
||||
.unwrap_or_default(),
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
s.register_addons(
|
||||
vec![
|
||||
with_interface("Old", Some("11100")),
|
||||
with_interface("Current", Some("11200")),
|
||||
with_interface("Silent", None),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
s.run("AddonList_Show()").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
assert_eq!(
|
||||
row(&s, 1).1,
|
||||
"Out of date",
|
||||
"an older `## Interface` must reach the status column — it was blank here while the \
|
||||
char-select twin said so about the same addon"
|
||||
);
|
||||
assert_eq!(row(&s, 2).1, "", "our own interface is current");
|
||||
assert_eq!(
|
||||
row(&s, 3).1,
|
||||
"",
|
||||
"no `## Interface` at all is silent, not out of date"
|
||||
);
|
||||
}
|
||||
|
||||
/// `UIParentLoadAddOn(name)` — the on-demand loader 5 corpus addons name (FuBar_MCPFu,
|
||||
/// Fubar_RareTrackerFu, MCP, MikScrollingBattleText, SpecialTalent), and which raised as a nil
|
||||
/// global until every piece it needs was joined up in `BasicControls.xml`.
|
||||
///
|
||||
/// Two things are asserted, and the second is the one a reconstruction from the name would miss:
|
||||
/// it answers `LoadAddOn`'s own falsey result so a caller can branch, and `FailedAddOnLoad` shows
|
||||
/// the failure **once per addon name**, not once per attempt (ref `UIParent.lua` l.641-643).
|
||||
#[test]
|
||||
fn ui_parent_load_addon_reports_once_and_returns_the_load_result() {
|
||||
let s = UiScript::new().unwrap();
|
||||
let text = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/BasicControls.xml"),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||||
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
|
||||
assert!(
|
||||
report.errors.is_empty(),
|
||||
"loader errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
|
||||
// The two GlobalStrings this leans on. The real client has them from FrameXML's first file and
|
||||
// the corpus survey runs the real `GlobalStrings.lua`; a bare VM does not, so they are seated
|
||||
// here rather than left to make `format` raise on a nil.
|
||||
s.run(
|
||||
r#"
|
||||
ADDON_LOAD_FAILED = "Couldn't load %s: %s"
|
||||
ADDON_MISSING = "Missing"
|
||||
shown = {}
|
||||
message = function(t) table.insert(shown, t) end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// A name no registry has: LoadAddOn answers (nil, "MISSING"), so this answers falsey.
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return UIParentLoadAddOn("NoSuchAddonAnywhere") == nil"#)
|
||||
.unwrap(),
|
||||
"must hand back LoadAddOn's own falsey result for a caller to branch on"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return shown[1]").unwrap(),
|
||||
"Couldn't load NoSuchAddonAnywhere: Missing",
|
||||
"the reason word resolves through ADDON_..reason"
|
||||
);
|
||||
|
||||
// The latch: a retrying addon gets one modal, not one per attempt.
|
||||
for _ in 0..3 {
|
||||
s.run(r#"UIParentLoadAddOn("NoSuchAddonAnywhere")"#)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return table.getn(shown)").unwrap(),
|
||||
1,
|
||||
"FailedAddOnLoad must suppress every repeat for the same name"
|
||||
);
|
||||
|
||||
// A DIFFERENT name is a different latch entry, so it still reports.
|
||||
s.run(r#"UIParentLoadAddOn("AlsoNotThere")"#).unwrap();
|
||||
assert_eq!(s.eval::<i64>("return table.getn(shown)").unwrap(), 2);
|
||||
}
|
||||
@@ -382,7 +382,11 @@ pub(crate) fn info_from_toc(name: &str, toc: &Toc) -> benilla_ui::script::AddOnI
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
enabled: true, // an addon nobody has disabled is enabled; the file below overrides
|
||||
saved_enabled: true, // re-stamped from `enabled` at registration — see `register_addons`
|
||||
loaded: false,
|
||||
// The version gate's dword (decision 1292) — the client's own parse: leading integer,
|
||||
// 0 when the manifest is silent (and 0 is out of date, not "unknown").
|
||||
interface: toc.interface_version(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,11 +446,14 @@ pub(crate) struct InstalledAddOn {
|
||||
pub(crate) notes: Option<String>,
|
||||
pub(crate) url: Option<String>,
|
||||
pub(crate) dependencies: Vec<String>,
|
||||
/// `## Interface`, as written — the out-of-date check, reported and not enforced (1191 §6).
|
||||
pub(crate) interface: Vec<u32>,
|
||||
/// `## Interface` as the version gate reads it (decision 1292): the leading integer, `0`
|
||||
/// when absent — and now ENFORCED by the load walk when `checkAddonVersion` is on,
|
||||
/// superseding 1191 §6's report-only interim (the RE answer it was waiting for landed:
|
||||
/// wow-re `addon-version-gate.md`).
|
||||
pub(crate) interface: u32,
|
||||
/// `## LoadOnDemand: 1` — shown as a status hint rather than a checkbox state, because a
|
||||
/// LoadOnDemand addon is not "off", it is waiting for a `LoadAddOn` call (1191 §6).
|
||||
#[allow(dead_code)] // read by the in-game AddOns panel's status column (decision 1197)
|
||||
/// Read by the char-select AddOns screen's rows (`char_select::addons`).
|
||||
pub(crate) load_on_demand: bool,
|
||||
/// From this character's enable file; an addon the file never mentions is **enabled** (1191 §7).
|
||||
pub(crate) enabled: bool,
|
||||
@@ -455,21 +462,15 @@ pub(crate) struct InstalledAddOn {
|
||||
impl InstalledAddOn {
|
||||
/// The list's display name — `## Title` when it has one, else the folder name. The reference's
|
||||
/// own `if (title) … else name` (`AddonList_Update`).
|
||||
///
|
||||
/// (An `out_of_date()` convenience lived here too until 1293 moved the glue screen onto the
|
||||
/// gate — the version compare has exactly one home now, `addon_gate::can_load`'s check 6,
|
||||
/// and a screen-side copy is the per-asker drift 1292 §Rejected names.)
|
||||
pub(crate) fn display_title(&self) -> &str {
|
||||
self.title.as_deref().unwrap_or(&self.name)
|
||||
}
|
||||
|
||||
/// Is this addon's `## Interface` the one we implement? Reported, never enforced — 1191 §6
|
||||
/// chose that deliberately, and the corpus says enforcing would silently drop one addon in
|
||||
/// eight. A manifest declaring no `## Interface` at all is not out of date; it is silent.
|
||||
pub(crate) fn out_of_date(&self) -> bool {
|
||||
!self.interface.is_empty() && !self.interface.contains(&INTERFACE_VERSION)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `## Interface` benilla implements — 1.12.1.
|
||||
const INTERFACE_VERSION: u32 = 11200;
|
||||
|
||||
/// Every installed addon, in load order, with `character`'s enable state applied.
|
||||
///
|
||||
/// The AddOns screens' whole data source. `identity` is `(realm, character)`; `None` reads the
|
||||
@@ -490,7 +491,7 @@ pub(crate) fn installed(identity: Option<&(String, String)>) -> Vec<InstalledAdd
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
interface: addon.toc.interface_versions(),
|
||||
interface: addon.toc.interface_version(),
|
||||
load_on_demand: addon.toc.load_on_demand(),
|
||||
name: addon.name,
|
||||
})
|
||||
@@ -614,6 +615,7 @@ const SAVED_HEADER: &str = "\
|
||||
pub(super) fn load_third_party(
|
||||
script: &mut UiScript,
|
||||
identity: Option<&(String, String)>,
|
||||
version_check: bool,
|
||||
) -> Vec<String> {
|
||||
let addons = discover();
|
||||
// Register the AddOn API's view even when the list is empty: `GetNumAddOns()` must answer 0
|
||||
@@ -655,6 +657,7 @@ pub(super) fn load_third_party(
|
||||
);
|
||||
let mut state = Walk {
|
||||
disabled: disabled_names,
|
||||
version_check,
|
||||
..Walk::default()
|
||||
};
|
||||
for addon in &addons {
|
||||
@@ -688,6 +691,12 @@ struct Walk {
|
||||
/// happened first, and an empty set means "everything enabled" — which is exactly what an
|
||||
/// absent enable-state file means too.
|
||||
disabled: HashSet<String>,
|
||||
/// The `checkAddonVersion` gate (decision 1292), resolved by the caller from the persisted
|
||||
/// CVar — passed in for the same explicitness reason as `disabled`. When on, an addon whose
|
||||
/// `## Interface` is not exactly the client's is skipped like a disabled one (the
|
||||
/// reference's `AddOn_CanLoad` check 6, before the dependency loop), and a dependent gets
|
||||
/// `Err` — its `DEP_INTERFACE_VERSION`.
|
||||
version_check: bool,
|
||||
}
|
||||
|
||||
impl Walk {
|
||||
@@ -729,6 +738,24 @@ impl Walk {
|
||||
self.failed.insert(addon.name.clone());
|
||||
return Err(());
|
||||
}
|
||||
// **The version gate** (decision 1292; `AddOn_CanLoad` check 6, in the checks' own order —
|
||||
// after the enable state, before the dependency loop). Exact `==` against the client's
|
||||
// build; a missing `## Interface` parses as 0 and is out of date. Not a `failures` entry
|
||||
// for the same reason disabled is not: the state is the player's to see on the AddOns
|
||||
// screens (`ADDON_INTERFACE_VERSION`), and the *Load out of date AddOns* checkbox — the
|
||||
// `checkAddonVersion` CVar this flag carries — is the reference's own escape.
|
||||
if self.version_check
|
||||
&& addon.toc.interface_version() != benilla_ui::script::addon_gate::CLIENT_INTERFACE
|
||||
{
|
||||
info!(
|
||||
"ui_script: {key} is out of date (## Interface: {}, client {}) — not loaded \
|
||||
(the AddOns screen's 'Load out of date AddOns' loads it anyway)",
|
||||
addon.toc.interface_version(),
|
||||
benilla_ui::script::addon_gate::CLIENT_INTERFACE
|
||||
);
|
||||
self.failed.insert(addon.name.clone());
|
||||
return Err(());
|
||||
}
|
||||
if self.loading.iter().any(|n| n == key) {
|
||||
let chain = self.loading.join(" → ");
|
||||
let e = format!("{key}: dependency cycle ({chain} → {key})");
|
||||
@@ -1310,7 +1337,7 @@ mod tests {
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_screen_size(1024.0, 768.0);
|
||||
script.run("EventLog = {}").unwrap();
|
||||
let failures = load_third_party(&mut script, None);
|
||||
let failures = load_third_party(&mut script, None, true);
|
||||
assert!(failures.is_empty(), "load errors: {failures:?}");
|
||||
crate::ui_script::finish_ui_load(&mut script);
|
||||
|
||||
@@ -1346,6 +1373,79 @@ mod tests {
|
||||
(home, guard)
|
||||
}
|
||||
|
||||
/// **The version gate holds the startup walk, and force-load opens it** (decision 1292):
|
||||
/// the byte-verified `AddOn_CanLoad` check 6 — exact `==`, missing `## Interface` = 0 = out
|
||||
/// of date, a dependent of a gated addon blocked like a dependent of a disabled one — and
|
||||
/// the `checkAddonVersion` flag (the *Load out of date AddOns* checkbox inverted) loading
|
||||
/// the very same folder in full.
|
||||
#[test]
|
||||
fn the_version_gate_holds_the_walk_and_force_load_opens_it() {
|
||||
let _l = crate::local_state::test_env::ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (home, _guard) = hermetic_root("version-gate");
|
||||
write_addon(
|
||||
&home,
|
||||
"Fresh",
|
||||
"## Interface: 11200\nmain.lua\n",
|
||||
&[("main.lua", "FreshRan = true\n")],
|
||||
);
|
||||
write_addon(
|
||||
&home,
|
||||
"Old",
|
||||
"## Interface: 11100\nmain.lua\n",
|
||||
&[("main.lua", "OldRan = true\n")],
|
||||
);
|
||||
write_addon(
|
||||
&home,
|
||||
"Silent",
|
||||
"main.lua\n", // no ## Interface at all — parses as 0, out of date (not "unknown")
|
||||
&[("main.lua", "SilentRan = true\n")],
|
||||
);
|
||||
write_addon(
|
||||
&home,
|
||||
"NeedsOld",
|
||||
"## Interface: 11200\n## Dependencies: Old\nmain.lua\n",
|
||||
&[("main.lua", "NeedsOldRan = true\n")],
|
||||
);
|
||||
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let failures = load_third_party(&mut script, None, true);
|
||||
assert!(
|
||||
failures.iter().any(|f| f.contains("NeedsOld")),
|
||||
"the gated dependency is the dependent's failure: {failures:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
script.eval::<bool>("return FreshRan == true").ok(),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
script
|
||||
.eval::<bool>("return OldRan == nil and SilentRan == nil")
|
||||
.ok(),
|
||||
Some(true),
|
||||
"out-of-date and interface-less addons are held by the gate"
|
||||
);
|
||||
assert_eq!(
|
||||
script.eval::<bool>("return NeedsOldRan == nil").ok(),
|
||||
Some(true),
|
||||
"…and so is their dependent (DEP_INTERFACE_VERSION territory)"
|
||||
);
|
||||
|
||||
// Force-load: the same folder, the checkbox's other state — everything loads.
|
||||
let mut open = UiScript::new().unwrap();
|
||||
let failures = load_third_party(&mut open, None, false);
|
||||
assert!(failures.is_empty(), "force-load load errors: {failures:?}");
|
||||
assert_eq!(
|
||||
open.eval::<bool>(
|
||||
"return OldRan == true and SilentRan == true and NeedsOldRan == true"
|
||||
)
|
||||
.ok(),
|
||||
Some(true),
|
||||
"'Load out of date AddOns' loads the very same folder in full"
|
||||
);
|
||||
}
|
||||
|
||||
fn write_addon(home: &Path, name: &str, toc: &str, files: &[(&str, &str)]) {
|
||||
let dir = home.join("AddOns").join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
@@ -1408,7 +1508,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let failures = load_third_party(&mut script, None);
|
||||
let failures = load_third_party(&mut script, None, true);
|
||||
assert!(failures.is_empty(), "load errors: {failures:?}");
|
||||
|
||||
// Discovery saw it at all — the `.toc` decoded rather than read as absent.
|
||||
@@ -1508,7 +1608,7 @@ mod tests {
|
||||
&[],
|
||||
);
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let _ = load_third_party(&mut script, None);
|
||||
let _ = load_third_party(&mut script, None, true);
|
||||
|
||||
assert_eq!(script.eval::<i64>("return GetNumAddOns()").ok(), Some(1));
|
||||
assert_eq!(
|
||||
@@ -1605,7 +1705,7 @@ mod tests {
|
||||
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let id = ("Realm".to_string(), "Char".to_string());
|
||||
let failures = load_third_party(&mut script, Some(&id));
|
||||
let failures = load_third_party(&mut script, Some(&id), true);
|
||||
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
@@ -1663,7 +1763,7 @@ mod tests {
|
||||
);
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_screen_size(1024.0, 768.0);
|
||||
let _ = load_third_party(&mut script, None);
|
||||
let _ = load_third_party(&mut script, None, true);
|
||||
|
||||
// Discovered and described, but NOT run.
|
||||
assert_eq!(script.eval::<i64>("return GetNumAddOns()").ok(), Some(1));
|
||||
@@ -1745,7 +1845,7 @@ mod tests {
|
||||
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let id = ("Realm".to_string(), "Char".to_string());
|
||||
let _ = load_third_party(&mut script, Some(&id));
|
||||
let _ = load_third_party(&mut script, Some(&id), true);
|
||||
|
||||
for (call, want) in [
|
||||
("LoadAddOn('NoSuchAddon')", "MISSING"),
|
||||
@@ -1786,7 +1886,7 @@ mod tests {
|
||||
let id = ("Realm".to_string(), "Char".to_string());
|
||||
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let _ = load_third_party(&mut script, Some(&id));
|
||||
let _ = load_third_party(&mut script, Some(&id), true);
|
||||
script.run("DisableAddOn('Drop')").unwrap();
|
||||
save_enable_state(&script, Some(&id));
|
||||
|
||||
@@ -1798,7 +1898,7 @@ mod tests {
|
||||
|
||||
// A fresh session reads it back and the addon stays off.
|
||||
let mut next = UiScript::new().unwrap();
|
||||
let _ = load_third_party(&mut next, Some(&id));
|
||||
let _ = load_third_party(&mut next, Some(&id), true);
|
||||
assert_eq!(
|
||||
next.eval::<bool>("return DropRan == nil").ok(),
|
||||
Some(true),
|
||||
@@ -1849,7 +1949,7 @@ mod tests {
|
||||
// ── session one: defaults, then the addon changes them ──
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_screen_size(1024.0, 768.0);
|
||||
let failures = load_third_party(&mut script, Some(&id));
|
||||
let failures = load_third_party(&mut script, Some(&id), true);
|
||||
assert!(failures.is_empty(), "{failures:?}");
|
||||
assert_eq!(
|
||||
script.eval::<i64>("return KeeperSawAtEvent").ok(),
|
||||
@@ -1864,7 +1964,7 @@ mod tests {
|
||||
// ── session two: a fresh VM reads them back ──
|
||||
let mut next = UiScript::new().unwrap();
|
||||
next.set_screen_size(1024.0, 768.0);
|
||||
let failures = load_third_party(&mut next, Some(&id));
|
||||
let failures = load_third_party(&mut next, Some(&id), true);
|
||||
assert!(failures.is_empty(), "{failures:?}");
|
||||
assert_eq!(
|
||||
next.eval::<i64>("return KeeperDB.count").ok(),
|
||||
@@ -1912,7 +2012,7 @@ mod tests {
|
||||
);
|
||||
let id = ("Realm".to_string(), "Char".to_string());
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let _ = load_third_party(&mut script, Some(&id));
|
||||
let _ = load_third_party(&mut script, Some(&id), true);
|
||||
script
|
||||
.run("GramDB = { ['on'] = true, ['n'] = 2, ['s'] = 'a\\\"b', ['t'] = { 1 } } GramChar = 5")
|
||||
.unwrap();
|
||||
@@ -1974,7 +2074,7 @@ mod tests {
|
||||
let id = ("Realm".to_string(), "Char".to_string());
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_screen_size(1024.0, 768.0);
|
||||
let _ = load_third_party(&mut script, Some(&id));
|
||||
let _ = load_third_party(&mut script, Some(&id), true);
|
||||
crate::ui_script::shutdown_ui_state(&mut script, Some(&id));
|
||||
|
||||
let written = std::fs::read_to_string(home.join("saved/Last.lua")).unwrap();
|
||||
|
||||
@@ -83,6 +83,9 @@ pub(super) fn drive_script(
|
||||
// VM has no cached measures to invalidate and no measurer at all, and the re-seat below already
|
||||
// catches that on its own test (`!script.has_text_measurer()`).
|
||||
mut last_seam: Local<f32>,
|
||||
// The atlas bake this pass last seated a measurer from (decision 1296) — the other half of the
|
||||
// same staleness edge. `None` until an atlas exists, which is also a real edge to catch.
|
||||
mut last_bake: Local<Option<u64>>,
|
||||
// The uiScale dial folded into the seam scale (decision 0584).
|
||||
ui_scale: Res<super::UiScaleCvar>,
|
||||
// ── The extract gate's memory (decision 0740): last frame's conversion inputs ─────────────
|
||||
@@ -124,12 +127,19 @@ pub(super) fn drive_script(
|
||||
// percent — enough that a boot-size measure fails the fullscreen fit test and the ellipsis
|
||||
// eats fitting text: the director's "Contr..." rows, reproduced by `WOW_RESIZE`). Declare the
|
||||
// staleness at the seam and let every round-trip re-answer under the new `s`.
|
||||
let seam_moved = *last_seam != s;
|
||||
//
|
||||
// A **re-bake** is the same staleness by the other route: the atlas now follows the seam
|
||||
// (decision 1296), and a fresh bake replaces the whole step table — so a cached metric answered
|
||||
// off the old table is stale even when `s` itself did not move (a pure DPI hop, a monitor
|
||||
// change). Watch the generation beside the seam and treat either edge identically.
|
||||
let generation = font_atlas.as_deref().map(|a| a.generation);
|
||||
let seam_moved = *last_seam != s || *last_bake != generation;
|
||||
if seam_moved {
|
||||
if *last_seam != 0.0 {
|
||||
script.invalidate_text_measures();
|
||||
}
|
||||
*last_seam = s;
|
||||
*last_bake = generation;
|
||||
}
|
||||
// …and re-seat the VM's own font engine at the same edge, for the same reason: an
|
||||
// [`crate::ui_text::AtlasMeasurer`] answers only for the seam it was built under. This is what
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! The shipped `assets/ui/GameMenuFrame.xml` — the frame ESC opens (decision 0674).
|
||||
//!
|
||||
//! What these guard, in order: the ladder geometry (the ERA menu shape — 200×288, eight rungs in
|
||||
//! three 20-gapped sections, the era layout engine's own numbers precomputed in the XML's header
|
||||
//! note); the four greyed pending entries; the live buttons' wire intents and sounds;
|
||||
//! What these guard, in order: the ladder geometry (the ERA menu shape minus its AddOns rung —
|
||||
//! 200×267, seven rungs in three 20-gapped sections, the era layout engine's own numbers
|
||||
//! precomputed in the XML's header note); the greyed pending entries; the live buttons' wire
|
||||
//! intents and sounds;
|
||||
//! the ESC ladder's two new rungs (open when nothing is left to eat, close before everything
|
||||
//! else); the micro button's `clicked` toggle; the native-center rule that makes the menu take
|
||||
//! the screen (windows close on the way in, and nothing opens while it is up); and the camp/quit
|
||||
@@ -50,13 +51,14 @@ fn harness() -> UiScript {
|
||||
harness_with(&[])
|
||||
}
|
||||
|
||||
/// The eight buttons, top to bottom — the ERA ladder, its own shape again (the director's call
|
||||
/// on the 0951 review; GameMenuFrame.xml's header SCOPE note quotes the era source). 0997's
|
||||
/// carved Key Bindings seat left with its standalone window — decision 1008 folded key
|
||||
/// bindings into the Options window, where the era menu always pointed.
|
||||
const LADDER: [&str; 8] = [
|
||||
/// The seven buttons, top to bottom — the ERA ladder, its own shape (the director's call on the
|
||||
/// 0951 review; GameMenuFrame.xml's header SCOPE note quotes the era source), minus two seats:
|
||||
/// 0997's carved Key Bindings seat left with its standalone window (decision 1008 folded key
|
||||
/// bindings into the Options window, where the era menu always pointed), and the AddOns rung
|
||||
/// left with the in-game panel when the director made the char-select AddOns screen the only
|
||||
/// addon UI (it had been live since 1197).
|
||||
const LADDER: [&str; 7] = [
|
||||
"GameMenuButtonOptions",
|
||||
"GameMenuButtonAddOns",
|
||||
"GameMenuButtonEditMode",
|
||||
"GameMenuButtonSupport",
|
||||
"GameMenuButtonMacros",
|
||||
@@ -66,10 +68,10 @@ const LADDER: [&str; 8] = [
|
||||
];
|
||||
|
||||
/// The ladder geometry — the era layout engine's own numbers (MainMenuFrameTemplates: padding
|
||||
/// 32/28/28/28, spacing 0, AddSection gap 20): 200×288, each button 144×21 at x=28, tops at
|
||||
/// 32/73/94/115/136/177/198/239 — three sections split by the 20-unit gaps after Options,
|
||||
/// after Macros, and before Return to Game. First thing to break if the era shape is ever
|
||||
/// "tidied".
|
||||
/// 32/28/28/28, spacing 0, AddSection gap 20) over our seven rungs: 200×267, each button
|
||||
/// 144×21 at x=28, tops at 32/73/94/115/156/177/218 — three sections split by the 20-unit
|
||||
/// gaps after Options, after Macros, and before Return to Game. First thing to break if the
|
||||
/// era shape is ever "tidied".
|
||||
#[test]
|
||||
fn the_menu_has_the_era_frame_and_button_ladder() {
|
||||
let mut s = harness();
|
||||
@@ -79,11 +81,11 @@ fn the_menu_has_the_era_frame_and_button_ladder() {
|
||||
let (w, h) = s
|
||||
.eval::<(f64, f64)>("return GameMenuFrame:GetWidth(), GameMenuFrame:GetHeight()")
|
||||
.unwrap();
|
||||
assert_eq!((w, h), (200.0, 288.0), "the era frame size");
|
||||
assert_eq!((w, h), (200.0, 267.0), "the era frame size");
|
||||
|
||||
let top = s.eval::<f64>("return GameMenuFrame:GetTop()").unwrap();
|
||||
let left = s.eval::<f64>("return GameMenuFrame:GetLeft()").unwrap();
|
||||
const TOPS: [f64; 8] = [32.0, 73.0, 94.0, 115.0, 136.0, 177.0, 198.0, 239.0];
|
||||
const TOPS: [f64; 7] = [32.0, 73.0, 94.0, 115.0, 156.0, 177.0, 218.0];
|
||||
for (name, down) in LADDER.iter().zip(TOPS) {
|
||||
let (bw, bh, btop, bleft) = s
|
||||
.eval::<(f64, f64, f64, f64)>(&format!(
|
||||
@@ -112,7 +114,8 @@ fn the_menu_has_the_era_frame_and_button_ladder() {
|
||||
/// The two entries with nothing behind them — Edit Mode, Support — are DISABLED (the pending
|
||||
/// idiom: grey label, `-Disabled` art, exactly how the era menu greys a dead entry). Everything
|
||||
/// else in the ladder is live. **Macros left this list in decision 0983**, when the macro window
|
||||
/// landed behind it; **AddOns left it in 1197**, when `AddonList.xml` did.
|
||||
/// landed behind it. (AddOns had left it in 1197 and then left the ladder entirely with its
|
||||
/// panel — the char-select AddOns screen is the only addon UI.)
|
||||
#[test]
|
||||
fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
|
||||
let s = harness();
|
||||
@@ -127,7 +130,6 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
|
||||
}
|
||||
for name in [
|
||||
"GameMenuButtonOptions",
|
||||
"GameMenuButtonAddOns",
|
||||
"GameMenuButtonMacros",
|
||||
"GameMenuButtonLogout",
|
||||
"GameMenuButtonQuit",
|
||||
@@ -139,13 +141,8 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
|
||||
"{name} is live"
|
||||
);
|
||||
}
|
||||
// The labels read the era strings (AddOns through the ADDONS global — live now, and its
|
||||
// label still has to come from the string rather than a literal).
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GameMenuButtonAddOns:GetText()")
|
||||
.unwrap(),
|
||||
"AddOns"
|
||||
);
|
||||
// The labels read the era strings (Edit Mode through the HUD_EDIT_MODE_MENU global — the
|
||||
// label has to come from the string rather than a literal).
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GameMenuButtonEditMode:GetText()")
|
||||
.unwrap(),
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
//! flow — select a capsule → the host arm arms → the canonical chord lands through
|
||||
//! `KeyBindings_OnHostKey` — binds LIVE-COMMIT (every mutation queues the host persist,
|
||||
//! 1008's law; closing the window keeps everything), steals with the red 1.12 message only
|
||||
//! when the victim goes bare, refuses the wheel on press+release commands and restores the
|
||||
//! old key; the character-specific checkbox runs 1.12's set model with the era's
|
||||
//! when the victim goes bare, takes the wheel onto ANY command (1295 — the engine refuses
|
||||
//! nothing but a key string that is not a key, and that refusal restores the old key); the
|
||||
//! character-specific checkbox runs 1.12's set model with the era's
|
||||
//! confirm-on-uncheck-only; search surfaces binding matches as LIVE rows under the
|
||||
//! Keybindings redirect head; and the action bar's abbreviation law (`GetBindingText`
|
||||
//! transcribed in UIParent.xml) reads `s-2`, the ref's own Lua.
|
||||
@@ -233,12 +234,31 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
|
||||
.contains("ATTACKTARGET"),
|
||||
"the newly-bare victim is named"
|
||||
);
|
||||
// The wheel refusal: MOVEFORWARD has press+release state — SetBinding refuses the wheel
|
||||
// and the slot's old key is restored (1.12's KeyBindingFrame_SetBinding).
|
||||
// **The wheel binds like any other key** (B265, decision 1295) — including onto MOVEFORWARD,
|
||||
// which has press+release state. This block used to assert the opposite; `0x4b7490` never
|
||||
// reads a command node, so the refusal it asserted is not in the client.
|
||||
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
|
||||
s.run(r#"KeyBindings_OnHostKey("MOUSEWHEELUP")"#).unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>(r#"local k1 = GetBindingKey("MOVEFORWARD"); return k1 == "F""#)
|
||||
s.eval::<bool>(r#"local k1 = GetBindingKey("MOVEFORWARD"); return k1 == "MOUSEWHEELUP""#)
|
||||
.unwrap(),
|
||||
"the notch takes the slot"
|
||||
);
|
||||
// …and the notch STEALS the wheel from the camera, whose last key it was — so the page says
|
||||
// so in red, exactly as it would for any other stolen key. That the wheel's own default
|
||||
// victim is a real command is half of why the old refusal read as plausible.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
|
||||
.unwrap(),
|
||||
"|cffff0000CAMERAZOOMIN Function is Now Unbound!|r"
|
||||
);
|
||||
// The refusal that IS there is the key-string validator, and this is the page arm that
|
||||
// reports it: a chord the engine will not take restores the slot's old key and says why
|
||||
// (1.12's KeyBindingFrame_SetBinding, its wheel-shaped message and all).
|
||||
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
|
||||
s.run(r#"KeyBindings_OnHostKey("SCROLLLOCK")"#).unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>(r#"local k1 = GetBindingKey("MOVEFORWARD"); return k1 == "MOUSEWHEELUP""#)
|
||||
.unwrap(),
|
||||
"the refused slot restored its key"
|
||||
);
|
||||
@@ -660,13 +680,14 @@ fn the_pet_lane_is_registered_under_the_action_bar_header() {
|
||||
.unwrap();
|
||||
assert_eq!(key, format!("CTRL-{}", i % 10), "{name}'s 1.12 default");
|
||||
}
|
||||
// …and it is a runOnUp pair like the action bar's own rows, which is what makes the wheel
|
||||
// refusal apply to it (SetBinding answers nil rather than binding MOUSEWHEELUP).
|
||||
assert!(
|
||||
// …and it is a runOnUp pair like the action bar's own rows — which decides that a press
|
||||
// delivers a release half, and NOT whether the row may wear a wheel chord (1295). It takes
|
||||
// the wheel like any other command; the notch then runs both halves of the pair.
|
||||
assert_eq!(
|
||||
s.eval::<Option<u32>>(r#"return SetBinding("MOUSEWHEELUP", "BONUSACTIONBUTTON1")"#)
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"press+release commands refuse the wheel"
|
||||
.unwrap(),
|
||||
Some(1),
|
||||
"a press+release command takes the wheel — the refusal was ours, B265"
|
||||
);
|
||||
|
||||
// Page side: search finds it and paints a live row with the capsule filled.
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
//! **The UI session's lifecycle** — the VM's birth, its identity, its death, and the reload
|
||||
//! (decisions 1051/1290/1291). Split from `mod.rs` when the reload verb landed and the file held
|
||||
//! two concerns: this, and the per-frame extract/input bridge that stayed behind.
|
||||
//!
|
||||
//! The shape, end to end: `Startup` installs a **boot VM** (strings, emote tokens, fonts — the
|
||||
//! state the glue screens run against); entering the world loads the in-game UI and every addon
|
||||
//! onto it under the picked character's identity; leaving the world runs the reference's ordered
|
||||
//! shutdown tail and installs a fresh boot VM; and `ReloadUI()` is the two edges back to back
|
||||
//! without leaving the world. Every function here is an edge — nothing per-frame lives here.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_assets::LockRecover;
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use super::{
|
||||
load_font_registry, load_ingame_ui, CursorPayloadHeld, PlayerUiHover, UiClock,
|
||||
UiKeyboardCapture,
|
||||
};
|
||||
use crate::ui_script::addons;
|
||||
|
||||
pub(crate) fn setup_script(world: &mut World) {
|
||||
install_boot_vm(world);
|
||||
}
|
||||
|
||||
/// Build a **boot VM** and install it: a Lua state carrying the strings, the emote tokens and the
|
||||
/// font-object registry, and no frames at all.
|
||||
///
|
||||
/// This is the state the client sits at outside the world — the login and character screens — and
|
||||
/// the state a world entry loads the in-game UI *onto* ([`load_ingame_ui_on_world_entry`]). It is
|
||||
/// installed twice over a session: once at `Startup`, and again at every [`end_ui_session`], which
|
||||
/// is what makes a second login a genuine second load rather than a re-entry into the first
|
||||
/// login's Lua state.
|
||||
///
|
||||
/// ONLY the font-object registry at boot (1051). The glyph atlas bakes once, on the first `Update`,
|
||||
/// from `script.font_objects()` — and our native glue screens share that one atlas, so the registry
|
||||
/// has to exist before the login screen or in-game text loses its outlined variants and its
|
||||
/// registry-declared sizes for the whole session. The other 55 files are in-game UI and load at
|
||||
/// world entry ([`load_ingame_ui`]); the reference splits at exactly this seam, with GlueXML
|
||||
/// carrying its own `GlueFonts.xml`.
|
||||
///
|
||||
/// **Since 1290 this runs per login, and that is load-bearing rather than incidental.** The
|
||||
/// reference re-loads `Fonts.xml` on every rebuild and *must*: `0x48fbf0`'s toc loop has no
|
||||
/// already-loaded gate, and both halves of a font object die with the old session — the Lua handle
|
||||
/// with `_G`, and the native `CSimpleFont` registry with the frame-script owner (`0x7839c0`, torn
|
||||
/// down at `0x490c97`). Because [`end_ui_session`] routes every login through here, every session's
|
||||
/// VM gets the registry and every `inherits="GameFontNormal"` in FrameXML resolves. A design that
|
||||
/// dropped the VM and skipped index 0 on the in-game load would fail exactly there — verified in
|
||||
/// wow-re's §5 for this change (1291).
|
||||
fn install_boot_vm(world: &mut World) {
|
||||
let script = match UiScript::new() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("ui_script: VM init failed: {e}");
|
||||
world.remove_non_send_resource::<UiScript>();
|
||||
return;
|
||||
}
|
||||
};
|
||||
load_global_strings(world, &script);
|
||||
load_emote_tokens(world, &script);
|
||||
if ui_wanted(world) {
|
||||
// Errors are already logged per file as they happen; the returned list is the test-side
|
||||
// assertion (`shipped_xml_tests`), not a second reporting channel.
|
||||
let _ = load_font_registry(&script);
|
||||
}
|
||||
world.insert_non_send_resource(script);
|
||||
}
|
||||
|
||||
/// Does this run want the player UI at all? Captures stay pristine — their baselines regression-test
|
||||
/// the WORLD render — unless `WOW_CAPTURE_UI=1` opts the UI in.
|
||||
fn ui_wanted(world: &World) -> bool {
|
||||
!world.contains_resource::<crate::run_mode::CaptureMode>()
|
||||
|| std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
|
||||
}
|
||||
|
||||
/// `OnEnter(InWorld)`: materialize the in-game UI for **this** session.
|
||||
///
|
||||
/// **Once per world entry, not once per process** (decision 1290). The reference builds the whole
|
||||
/// in-game UI at `CGGameUI::Initialize 0x48fbf0` and destroys it again at `0x490bd0` on the way
|
||||
/// out, so every login runs every addon's file scope afresh — and that file scope is where the
|
||||
/// corpus reads the character it is looking at (`local currentPlayer = UnitName("player")`, the
|
||||
/// idiom [`seat_from_roster`] already documents). While this was latched per process, a second
|
||||
/// login kept the first login's captured name: the director's "the selected one is always
|
||||
/// Onewarrior no matter what char I log into", and, worse, its saved variables went to the first
|
||||
/// character's file. [`world_entry_tests`] holds both ends.
|
||||
///
|
||||
/// The load runs onto the fresh boot VM [`end_ui_session`] left behind (or, on the first login,
|
||||
/// `Startup`'s) — so nothing here has to unpick the previous session; there is nothing to unpick.
|
||||
///
|
||||
/// Safe on the state edge only because 1038 moved the initial transition after `PostStartup` — a
|
||||
/// capture boots straight into `InWorld`, so before that this would have run ahead of
|
||||
/// [`benilla_assets::AssetSet::Open`] and loaded against no patch chain.
|
||||
pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
if !ui_wanted(world) {
|
||||
return;
|
||||
}
|
||||
let Some(mut script) = world.remove_non_send_resource::<UiScript>() else {
|
||||
warn!("ui_script: entering the world with no VM — the in-game UI will not load");
|
||||
return;
|
||||
};
|
||||
// The character whose AddOn enable state applies. Resolved before the load because the
|
||||
// enable file gates which addons run at all.
|
||||
let identity = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(crate::ui_macro::identity);
|
||||
// The realm name goes in BEFORE the UI loads, because `GetRealmName()` is read at addon file
|
||||
// scope — `MyAddonDB[GetRealmName()] = …` is the corpus idiom, and 24 addons stop on it
|
||||
// (decision 1195). The roster carries the auth realm-list entry this session connected to.
|
||||
let realm = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(|r| r.realm.as_ref().map(|r| r.name.clone()))
|
||||
.unwrap_or_default();
|
||||
script.set_realm_name(&realm);
|
||||
// …and so does the PLAYER, for the same reason and with more riding on it — see
|
||||
// [`seat_from_roster`], which is where the why lives.
|
||||
if let Some(seat) = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(seat_from_roster)
|
||||
{
|
||||
script.set_unit("player", Some(seat));
|
||||
}
|
||||
// The addon version gate (decision 1292): the live table when this VM has one, else the
|
||||
// persisted value. On a first login the boot VM was seeded during the glue phase, so a
|
||||
// *Load out of date AddOns* click at character select reads back here even inside the
|
||||
// save debounce; on a reload the fresh boot VM has no table yet and the persisted value is
|
||||
// current by construction (1291's session-edge fold ran moments ago). Absent both (a bare
|
||||
// test world) is the registrar default: check ON.
|
||||
let version_check = script
|
||||
.cvar("checkAddonVersion")
|
||||
.map(|v| v != "0")
|
||||
.unwrap_or_else(|| {
|
||||
world
|
||||
.get_resource::<crate::cvars::CvarPersist>()
|
||||
.is_none_or(crate::cvars::CvarPersist::addon_version_check)
|
||||
});
|
||||
let _ = load_ingame_ui(&mut script, identity.as_ref(), version_check);
|
||||
// The Minimap widget was born a moment ago with `MinimapState::default()`; seed its two live
|
||||
// zoom indices from the persisted CVars now, before anything reads them — the reference's own
|
||||
// minimap reset path copying each CVar object's int into its live index (decision 1131). Once
|
||||
// only: from here the widget's index is the live truth and `Minimap:SetZoom` writes the CVar
|
||||
// back. Startup always precedes this state edge (1038), so the knob is already loaded.
|
||||
let zoom = world.resource::<crate::minimap::MinimapZoom>();
|
||||
script.set_minimap_zoom(zoom.outdoor, zoom.inside);
|
||||
// The saved-variables chunk runs HERE — after the XML assigned its file-scope defaults, before
|
||||
// any consumer reads them — then `VARIABLES_LOADED`. That is the reference's own load order
|
||||
// (`AddOn_Load 0x51f240` steps 2 → 4 → 6, decision 1128); reversing it means the defaults
|
||||
// always win and nothing can ever be remembered.
|
||||
finish_ui_load(&mut script);
|
||||
world.insert_non_send_resource(script);
|
||||
world.insert_resource(AddOnIdentity(identity));
|
||||
}
|
||||
|
||||
/// The `"player"` snapshot the UI loads **under**, built from the roster row of the pick in
|
||||
/// flight — `None` when there is no pick (a capture, a scenario, a test world).
|
||||
///
|
||||
/// **The reference's invariant is that addon file scope always sees a real character**:
|
||||
/// `AddOn_Load 0x51f240` runs from inside `UI_Init 0x48fbf0`, which is after the world is entered.
|
||||
/// benilla's does not. `Connected` flips us `InWorld` a whole server round-trip before the self
|
||||
/// descriptor streams in ([`crate::ui_unit`]'s own comment measures that gap in *seconds*), and
|
||||
/// `feed_units` — the only writer of the `"player"` token — is gated on that descriptor existing.
|
||||
/// So until this existed, every addon's file scope ran in a VM where `UnitName("player")` was
|
||||
/// **nil**, which is a state a real session cannot present. It is the same argument, at the same
|
||||
/// line, as the `set_realm_name` above it (decision 1195) — and this is the more load-bearing half.
|
||||
///
|
||||
/// **The failure it fixes is silent, which is why it survived every instrument.** The director
|
||||
/// installed Bagnon, opened their bags, and got a window with a title, a gold line and **no bag
|
||||
/// slots at all**. `Bagnon_Core/core/Utility.lua:5` opens `local currentPlayer =
|
||||
/// UnitName("player")`, and every one of Bagnon's "am I looking at a cached snapshot of some OTHER
|
||||
/// character?" predicates is `currentPlayer ~= frame.player`. With `currentPlayer` nil, Bagnon
|
||||
/// concluded the live player's own bags belonged to somebody else, took every bag size from
|
||||
/// Bagnon_Forever's (empty) offline cache instead of `GetContainerNumSlots`, created zero item
|
||||
/// buttons — and raised nothing, so `loaded`, `session` and the UI probe all scored it a pass.
|
||||
/// Reproduced both ways in [`bagnon_render_tests`].
|
||||
///
|
||||
/// **What is filled is what the roster actually knows**: name, race, class, gender and level, all
|
||||
/// a round-trip ahead of the descriptor (the same fact [`crate::char_select::Roster::pending_entry`]
|
||||
/// already exploits for the streamers). Health and power are deliberately left at zero — those are
|
||||
/// the descriptor's to say, they land within the second, and inventing them would be a different
|
||||
/// lie from the one being fixed.
|
||||
pub(crate) fn seat_from_roster(
|
||||
roster: &crate::char_select::Roster,
|
||||
) -> Option<benilla_ui::script::UnitState> {
|
||||
let row = roster.pending_row()?;
|
||||
let race = crate::ui_unit::race_names(row.race);
|
||||
let class = crate::ui_unit::class_names(row.class);
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some(row.name.clone()),
|
||||
level: u32::from(row.level),
|
||||
race: race.map(|(n, _)| n.to_string()),
|
||||
race_file: race.map(|(_, f)| f.to_string()),
|
||||
class: class.map(|(n, _)| n.to_string()),
|
||||
class_file: class.map(|(_, f)| f.to_string()),
|
||||
// The wire's 0/1 on `UnitSex`'s 2/3 scale — `ui_unit::snapshot`'s own mapping.
|
||||
sex: match row.gender {
|
||||
0 => 2,
|
||||
1 => 3,
|
||||
_ => 0,
|
||||
},
|
||||
is_player: true,
|
||||
// Nil here is not "no faction", it is a state a player character cannot be in, and
|
||||
// AceDB-2.0 concatenates it at file scope — see [`crate::ui_unit::race_faction_group`].
|
||||
faction_group: crate::ui_unit::race_faction_group(row.race).map(str::to_string),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// The character the loaded AddOn enable state belongs to, remembered so the shutdown write goes
|
||||
/// back to the file it came from — the roster's pick can be gone by then.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>);
|
||||
|
||||
/// **The UI shutdown, in the reference's own order** — `0x490bd0`, whose ordered tail wow-5875-re
|
||||
/// carves as (`system/ui/ui.md`):
|
||||
///
|
||||
/// > `PLAYER_LEAVING_WORLD` (273) → **`PLAYER_LOGOUT`** (271, `0x490c2a`) → `layout-cache.txt` →
|
||||
/// > **the flat saved file** (`0x490c7e`) → **the per-addon files** (`0x490c83`) → `AddOns.txt`
|
||||
/// > (`0x490c88`) → destroy the frame-script owner (`0x490c97`) → nil all 216 C bindings out of
|
||||
/// > `_G` (`0x490cba` → `0x490ce0`)
|
||||
///
|
||||
/// **The last step is not "destroy the Lua state"** — which is what three of wow-re's own notes
|
||||
/// said, until this client's teardown made the question load-bearing and a §5 cross-check settled
|
||||
/// it (`system/ui/scratch/lua-state-lifecycle.md`; 1291). `0x490c97` is the frame-script owner's
|
||||
/// scalar-deleting destructor — the widget tree and the native virtual-font registry — and the Lua
|
||||
/// state outlives it. The state is closed and re-opened at `0x703b80`, which reaches `InitLua` by a
|
||||
/// **tail-`jmp`** (`0x703b8e`) rather than a call, which is exactly why a call-census missed it.
|
||||
/// Its three callers are `UI_Init 0x48fbf0` (at its *head*), `ShutdownGame 0x491180`, and the glue
|
||||
/// builder `0x46a7b0` — so a logout/login cycle runs through four distinct states, and the glue
|
||||
/// screen gets its own.
|
||||
///
|
||||
/// **`PLAYER_LOGOUT` fires before any write, and that is the point**: it is an addon's last chance
|
||||
/// to mutate a saved global, so a handler that stores "where I left off" runs while the write is
|
||||
/// still ahead of it. Firing it after would make the event useless and the bug invisible.
|
||||
///
|
||||
/// One function, called from every root, because the steps are ordered *against each other* —
|
||||
/// three independent Bevy systems on one state edge cannot express that, and until this landed the
|
||||
/// flat write and the `AddOns.txt` write were exactly that.
|
||||
///
|
||||
/// **There is no autosave**, deliberately: the reference has none (decision 1128, and
|
||||
/// `ds:0xb4b3f4` has three references image-wide). These are a handful of scalars a player toggles
|
||||
/// a few times a session, and every file is written whole from the live globals.
|
||||
pub(crate) fn shutdown_ui_state(script: &mut UiScript, identity: Option<&(String, String)>) {
|
||||
script.fire_event("PLAYER_LEAVING_WORLD", vec![]);
|
||||
script.fire_event("PLAYER_LOGOUT", vec![]);
|
||||
crate::ui_saved::save(script);
|
||||
addons::save_addon_variables(script, identity);
|
||||
addons::save_enable_state(script, identity);
|
||||
}
|
||||
|
||||
/// `OnExit(InWorld)`: a `/logout` back to the glue, or a disconnect — two of the reference's five
|
||||
/// roots — and, with the writes done, **the end of this session's Lua state**.
|
||||
///
|
||||
/// The reference's own shutdown ends by destroying the state (`0x490bd0`'s tail, after
|
||||
/// `AddOns.txt`); ours ends by replacing it with a fresh boot VM ([`install_boot_vm`]), which is the
|
||||
/// same guarantee expressed the way our two-phase load wants it — the character screen still needs
|
||||
/// a font-object registry for the shared glyph atlas, and the next login needs somewhere to load
|
||||
/// onto. What matters is that **no frame, no global and no addon upvalue crosses this edge**: that
|
||||
/// is what makes the next login a real login (decision 1290) instead of a re-entry into the
|
||||
/// previous character's UI.
|
||||
///
|
||||
/// Exclusive rather than a `NonSendMut` system because it both drops and installs a `NonSend`, and
|
||||
/// because the shutdown writes must be ordered against each other — see [`shutdown_ui_state`].
|
||||
pub(crate) fn end_ui_session(world: &mut World) {
|
||||
let identity = world
|
||||
.get_resource::<AddOnIdentity>()
|
||||
.and_then(|id| id.0.clone());
|
||||
if let Some(mut script) = world.get_non_send_resource_mut::<UiScript>() {
|
||||
shutdown_ui_state(&mut script, identity.as_ref());
|
||||
}
|
||||
// The CVar bridge (decision 1291): the dying VM's table folds into the persist state — after
|
||||
// the shutdown events above (a `PLAYER_LOGOUT` handler may `SetCVar`, and in the reference
|
||||
// that lands in an engine-side store that survives), before the VM is replaced. The next
|
||||
// VM's registration seeds from what this writes ([`crate::cvars`]'s saved base).
|
||||
crate::cvars::fold_dying_vm_cvars(world);
|
||||
world.insert_resource(AddOnIdentity(None));
|
||||
|
||||
// **Everything the host is holding that came OUT of the dying VM goes with it.** A change memo
|
||||
// handles itself — it is keyed on [`benilla_ui::script::UiScript::session`] ([`VmMemo`]) — but
|
||||
// these are plain values other systems read through `Res<…>`, with no VM in hand to key
|
||||
// against, so the edge clears them. Each is a fact about a frame tree that is about to stop
|
||||
// existing: a hovered frame id, the minimap's extracted hole, a payload the cursor is carrying,
|
||||
// and the VM-relative clock the cooldown conversions run through.
|
||||
//
|
||||
// The two input latches were cleared by `char_select`'s logout and disconnect handlers, one
|
||||
// copy each. They belong here: the reason they need clearing is that `feed_ui_input` stops
|
||||
// running outside `InWorld`, which is this edge and nothing to do with *why* we left it.
|
||||
if let Some(mut hover) = world.get_resource_mut::<PlayerUiHover>() {
|
||||
hover.0 = None;
|
||||
}
|
||||
if let Some(mut keys) = world.get_resource_mut::<UiKeyboardCapture>() {
|
||||
keys.0 = false;
|
||||
}
|
||||
if let Some(mut held) = world.get_resource_mut::<CursorPayloadHeld>() {
|
||||
*held = CursorPayloadHeld::default();
|
||||
}
|
||||
if let Some(mut minimap) = world.get_resource_mut::<crate::minimap::MinimapWidget>() {
|
||||
minimap.0 = None;
|
||||
}
|
||||
if let Some(mut clock) = world.get_resource_mut::<UiClock>() {
|
||||
*clock = UiClock::default();
|
||||
}
|
||||
|
||||
install_boot_vm(world);
|
||||
}
|
||||
|
||||
/// A `ReloadUI()` waiting to run — set when [`crate::ui_logout`] drains
|
||||
/// [`benilla_ui::script::SessionRequest::ReloadUi`], consumed by [`run_pending_reload`] at the
|
||||
/// top of the next frame's `Update`.
|
||||
///
|
||||
/// A flag rather than an immediate call for the reference's own reason (`ds:0xb4b3f4`, its only
|
||||
/// writer `0x491380` and its only reader the per-frame callback `0x495590`): the VM that queued
|
||||
/// the request must not be mid-call when it is destroyed. Our drain already runs outside any VM
|
||||
/// dispatch, but the flag keeps the whole rebuild at one point in the frame — before the input
|
||||
/// pass — instead of wherever the drain happens to sit.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct ReloadUiPending(pub(crate) bool);
|
||||
|
||||
/// Run a pending `ReloadUI()`: the reference's teardown/rebuild pair (`0x495664 call 0x490bd0`,
|
||||
/// `0x495669 call 0x48fbf0`), which for us is [`end_ui_session`] then
|
||||
/// [`load_ingame_ui_on_world_entry`] — the same two functions the logout/login edges run, called
|
||||
/// back to back without leaving the world (decision 1291).
|
||||
///
|
||||
/// Everything that makes a login correct makes the reload correct **by construction**: the
|
||||
/// shutdown tail fires `PLAYER_LEAVING_WORLD`/`PLAYER_LOGOUT` and writes the four files (so a
|
||||
/// `DisableAddOn` staged in the dying VM reaches `AddOns.txt` before the rebuild reads it), the
|
||||
/// rebuild is a real login's load (fresh file scope, saved variables, `VARIABLES_LOADED`,
|
||||
/// `PLAYER_LOGIN`), and every host memory keyed on the VM's identity ([`VmMemo`], decision 1290)
|
||||
/// expires with the old session id. `PLAYER_ENTERING_WORLD` refires from [`crate::ui_unit`]'s
|
||||
/// feed once it notices the new VM, with the self descriptor already present — the reference's
|
||||
/// own ordering, where the event follows the rebuild.
|
||||
///
|
||||
/// **In-world only.** At the glue there is no in-game UI to rebuild and no identity to load
|
||||
/// addons under; the reference's own gate (`0x494a50(0xa)`) refuses there too. Dropped with a log
|
||||
/// line rather than deferred — a reload asked for at the character screen answers nothing.
|
||||
pub(crate) fn run_pending_reload(world: &mut World) {
|
||||
if !std::mem::take(&mut world.resource_mut::<ReloadUiPending>().0) {
|
||||
return;
|
||||
}
|
||||
let in_world = *world
|
||||
.resource::<State<crate::char_select::ClientState>>()
|
||||
.get()
|
||||
== crate::char_select::ClientState::InWorld;
|
||||
if !in_world {
|
||||
info!("ui_script: ReloadUI outside the world — dropped");
|
||||
return;
|
||||
}
|
||||
info!("ui_script: ReloadUI — ending the UI session and building a new one");
|
||||
end_ui_session(world);
|
||||
load_ingame_ui_on_world_entry(world);
|
||||
}
|
||||
|
||||
/// `AppExit`: quitting the client — the quit / application-exit roots. Reads the message rather
|
||||
/// than a state edge because a quit from in-world never leaves `InWorld`.
|
||||
pub(crate) fn shutdown_on_exit(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
id: Res<AddOnIdentity>,
|
||||
mut exits: MessageReader<AppExit>,
|
||||
) {
|
||||
if exits.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(mut script) = script {
|
||||
shutdown_ui_state(&mut script, id.0.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
/// The UI-init sequence's ordered tail, once every file — ours and every addon's — has loaded:
|
||||
/// the saved-variables chunk and `VARIABLES_LOADED`, then `PLAYER_LOGIN`.
|
||||
///
|
||||
/// **The order is the reference's**, byte-verified in wow-5875-re (`system/ui/ui.md`, and the
|
||||
/// cascade in `system/ui/scratch/mail-pending-countdown.md`). Inside `UI_Init 0x48fbf0`, in
|
||||
/// straight-line address order:
|
||||
///
|
||||
/// | | |
|
||||
/// |---|---|
|
||||
/// | `0x4900a3` → `0x51f600` | load every non-LoadOnDemand addon — each fires its own **`ADDON_LOADED`** (429, `0x51f5ad`) |
|
||||
/// | `0x4900b2` → `0x4913b0` | read the flat saved file, fire **`VARIABLES_LOADED`** (430) |
|
||||
/// | `0x490168` → `0x4908c0` | the world-enter cascade: **`PLAYER_LOGIN`** (`0x49094b`, `0x10e`) then **`PLAYER_ENTERING_WORLD`** (`0x490965`, `0x110`) |
|
||||
///
|
||||
/// So every non-LoD addon's `ADDON_LOADED` precedes `VARIABLES_LOADED`, which precedes
|
||||
/// `PLAYER_LOGIN`. It is one function rather than three inline calls because that sequence is the
|
||||
/// mechanism — an addon restores state on `ADDON_LOADED` and expects the saved chunk to have run,
|
||||
/// and a window that waits on `PLAYER_LOGIN` expects both — so it is worth being able to assert.
|
||||
///
|
||||
/// **`PLAYER_LOGIN` is the conditional one; `PLAYER_ENTERING_WORLD` is not.** The cascade fires
|
||||
/// the former only when `[0xb4e260]` is set, and only the FrameXML-loader path sets it, clearing
|
||||
/// it immediately after — so it means "the UI came up". That is once per **UI build**, which since
|
||||
/// 1290 is once per world entry here too: this runs from
|
||||
/// [`load_ingame_ui_on_world_entry`], on the same edge that built the tree.
|
||||
/// `PLAYER_ENTERING_WORLD` keeps its own per-entry latch in [`crate::ui_unit`] and still lands
|
||||
/// after this, since it waits on the self descriptor arriving over the wire.
|
||||
pub(crate) fn finish_ui_load(script: &mut UiScript) {
|
||||
crate::ui_saved::load_saved_variables(script);
|
||||
script.fire_event("PLAYER_LOGIN", vec![]);
|
||||
}
|
||||
|
||||
/// Execute the real `Interface\FrameXML\GlobalStrings.lua` off the patch chain into the VM —
|
||||
/// the reference boots FrameXML with exactly this file FIRST, and it is the source of every
|
||||
/// localized string global the UI reads (the cast-fail display's whole message set, 0427).
|
||||
/// Loaded before our own `assets/ui` files, matching the reference order. Failures are LOUD:
|
||||
/// a silently missing GlobalStrings once suppressed every red error line (the 0427 fold's
|
||||
/// absent-key face is faithful data suppression — but only when the file actually loaded).
|
||||
fn load_global_strings(world: &mut World, script: &UiScript) {
|
||||
let Some(assets) = world.get_resource::<benilla_assets::WorldAssets>() else {
|
||||
warn!("ui_script: no patch chain — GlobalStrings absent, error lines will be empty");
|
||||
return;
|
||||
};
|
||||
let bytes = {
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
chain.read_file("Interface\\FrameXML\\GlobalStrings.lua")
|
||||
};
|
||||
let src = match bytes {
|
||||
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
|
||||
Err(e) => {
|
||||
error!("ui_script: GlobalStrings.lua read failed — error lines will be empty: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = script.run(&src) {
|
||||
error!("ui_script: GlobalStrings.lua failed to run: {e}");
|
||||
return;
|
||||
}
|
||||
// The sentinel: the exact lookup the cast-fail drain performs. If this misses, every
|
||||
// message would silently vanish — turn that failure mode into a diagnosable line. Presence
|
||||
// only, not the enUS text: a non-enUS install is still a loaded GlobalStrings.
|
||||
let sentinel: Option<String> = script.lua().globals().get("SPELL_FAILED_NO_AMMO").ok();
|
||||
match sentinel {
|
||||
Some(s) if !s.is_empty() => info!("ui_script: GlobalStrings loaded"),
|
||||
other => {
|
||||
error!("ui_script: GlobalStrings sentinel missing ({other:?}) — error lines broken")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the reference's own **emote token table** into the VM (`EMOTE87_TOKEN = "SIT"`, …) —
|
||||
/// the second half of the emote slash grammar (decision 0881). The *aliases* are in
|
||||
/// `GlobalStrings.lua` above (`EMOTE87_CMD1 = "/sit"`), but the alias → `EmotesText.Name` mapping
|
||||
/// lives in `ChatFrame.lua`: the reference's chat **code**, which benilla replaces in Rust. So we
|
||||
/// take that file's **data** and none of its code — only whole lines matching
|
||||
/// `EMOTE<digits>_TOKEN = "<UPPER>";` ([`is_emote_token_line`]) are executed, and the file's ~2400
|
||||
/// lines of frame logic never run. Reading the shipped table beats transcribing 170 tokens into
|
||||
/// Rust: a transcription can be wrong, and a hand-kept alias list is exactly what left 61 real
|
||||
/// commands (`/lol`, `/hi`, `/ty`, …) unresolvable before 0881.
|
||||
fn load_emote_tokens(world: &mut World, script: &UiScript) {
|
||||
let Some(assets) = world.get_resource::<benilla_assets::WorldAssets>() else {
|
||||
return; // already WARNed by load_global_strings
|
||||
};
|
||||
let bytes = {
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
chain.read_file("Interface\\FrameXML\\ChatFrame.lua")
|
||||
};
|
||||
let src = match bytes {
|
||||
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
|
||||
Err(e) => {
|
||||
error!("ui_script: ChatFrame.lua read failed — emote commands will be dead: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let table: Vec<&str> = src
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| is_emote_token_line(l))
|
||||
.collect();
|
||||
let count = table.len();
|
||||
if let Err(e) = script.run(&table.join("\n")) {
|
||||
error!("ui_script: emote token table failed to run: {e}");
|
||||
return;
|
||||
}
|
||||
// The sentinel is the command this whole seam exists for: EMOTE87 is `/sit`.
|
||||
let sentinel: Option<String> = script.lua().globals().get("EMOTE87_TOKEN").ok();
|
||||
match sentinel.as_deref() {
|
||||
Some("SIT") => info!("ui_script: {count} emote tokens loaded"),
|
||||
other => error!(
|
||||
"ui_script: emote token sentinel is {other:?}, not \"SIT\" ({count} lines) — \
|
||||
emote slash commands are broken"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this line one of `ChatFrame.lua`'s emote-token assignments — `EMOTE<digits>_TOKEN = "<NAME>";`
|
||||
/// with `NAME` in `[A-Z0-9_]`? The whole-line shape is the filter that makes running the matched
|
||||
/// lines equivalent to reading data (no calls, no expressions, no side effects).
|
||||
pub(crate) fn is_emote_token_line(line: &str) -> bool {
|
||||
let Some(rest) = line.strip_prefix("EMOTE") else {
|
||||
return false;
|
||||
};
|
||||
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
|
||||
let Some(rest) = rest[digits..].strip_prefix("_TOKEN = \"") else {
|
||||
return false;
|
||||
};
|
||||
let Some(name) = rest.strip_suffix("\";") else {
|
||||
return false;
|
||||
};
|
||||
digits > 0
|
||||
&& !name.is_empty()
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
|
||||
}
|
||||
@@ -100,9 +100,16 @@ pub(crate) fn load_font_registry(script: &UiScript) -> Vec<String> {
|
||||
/// `identity` is `(realm, character)`, which names this character's AddOn enable-state file — the
|
||||
/// reference keys `AddOns.txt` per character too. `None` (no pick yet, a capture) means every
|
||||
/// discovered addon is enabled, the same answer an absent file gives.
|
||||
///
|
||||
/// `version_check` is the persisted `checkAddonVersion` — the *Load out of date AddOns* toggle,
|
||||
/// inverted — resolved by the caller because at load time this VM's own CVar table does not
|
||||
/// exist yet (registration is a per-VM `Update` seed, decision 1291); the persisted value is the
|
||||
/// truth the reference's live read would land on, since the session edge folds the dying VM's
|
||||
/// table into it before any rebuild reads it.
|
||||
pub(crate) fn load_ingame_ui(
|
||||
script: &mut UiScript,
|
||||
identity: Option<&(String, String)>,
|
||||
version_check: bool,
|
||||
) -> Vec<String> {
|
||||
// The reference FrameXML this client SOURCES off the patch chain rather than transcribing
|
||||
// ([`super::reference_ui`], whose header is the rule). It runs FIRST, before our own files,
|
||||
@@ -114,7 +121,11 @@ pub(crate) fn load_ingame_ui(
|
||||
failures.extend(bootstrap_positions(script));
|
||||
// `&mut` from here down: each addon's `ADDON_LOADED` fires as that addon finishes, which is
|
||||
// the reference's own interleaving (`0x51f5ad`, per addon) rather than a batch at the end.
|
||||
failures.extend(super::addons::load_third_party(script, identity));
|
||||
failures.extend(super::addons::load_third_party(
|
||||
script,
|
||||
identity,
|
||||
version_check,
|
||||
));
|
||||
failures
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ use bevy::prelude::*;
|
||||
use benilla_ui::script::{ActionSlot, ScriptValue, UiScript, UnitState};
|
||||
|
||||
use crate::ui_unit::UnitFeed;
|
||||
use benilla_assets::LockRecover;
|
||||
use benilla_world::schedule::WorldStage;
|
||||
|
||||
/// The addon folder: discovery, manifests, the enable file, and the load walk. `pub(crate)`
|
||||
@@ -351,175 +350,19 @@ fn arbitrate_pointer_over_ui(
|
||||
over.0 = egui.is_some_and(|e| e.0) || hover.0.is_some();
|
||||
}
|
||||
|
||||
fn setup_script(world: &mut World) {
|
||||
install_boot_vm(world);
|
||||
}
|
||||
|
||||
/// Build a **boot VM** and install it: a Lua state carrying the strings, the emote tokens and the
|
||||
/// font-object registry, and no frames at all.
|
||||
///
|
||||
/// This is the state the client sits at outside the world — the login and character screens — and
|
||||
/// the state a world entry loads the in-game UI *onto* ([`load_ingame_ui_on_world_entry`]). It is
|
||||
/// installed twice over a session: once at `Startup`, and again at every [`end_ui_session`], which
|
||||
/// is what makes a second login a genuine second load rather than a re-entry into the first
|
||||
/// login's Lua state.
|
||||
///
|
||||
/// ONLY the font-object registry at boot (1051). The glyph atlas bakes once, on the first `Update`,
|
||||
/// from `script.font_objects()` — and our native glue screens share that one atlas, so the registry
|
||||
/// has to exist before the login screen or in-game text loses its outlined variants and its
|
||||
/// registry-declared sizes for the whole session. The other 55 files are in-game UI and load at
|
||||
/// world entry ([`load_ingame_ui`]); the reference splits at exactly this seam, with GlueXML
|
||||
/// carrying its own `GlueFonts.xml`.
|
||||
///
|
||||
/// **Since 1290 this runs per login, and that is load-bearing rather than incidental.** The
|
||||
/// reference re-loads `Fonts.xml` on every rebuild and *must*: `0x48fbf0`'s toc loop has no
|
||||
/// already-loaded gate, and both halves of a font object die with the old session — the Lua handle
|
||||
/// with `_G`, and the native `CSimpleFont` registry with the frame-script owner (`0x7839c0`, torn
|
||||
/// down at `0x490c97`). Because [`end_ui_session`] routes every login through here, every session's
|
||||
/// VM gets the registry and every `inherits="GameFontNormal"` in FrameXML resolves. A design that
|
||||
/// dropped the VM and skipped index 0 on the in-game load would fail exactly there — verified in
|
||||
/// wow-re's §5 for this change (1291).
|
||||
fn install_boot_vm(world: &mut World) {
|
||||
let script = match UiScript::new() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("ui_script: VM init failed: {e}");
|
||||
world.remove_non_send_resource::<UiScript>();
|
||||
return;
|
||||
}
|
||||
};
|
||||
load_global_strings(world, &script);
|
||||
load_emote_tokens(world, &script);
|
||||
if ui_wanted(world) {
|
||||
// Errors are already logged per file as they happen; the returned list is the test-side
|
||||
// assertion (`shipped_xml_tests`), not a second reporting channel.
|
||||
let _ = load_font_registry(&script);
|
||||
}
|
||||
world.insert_non_send_resource(script);
|
||||
}
|
||||
|
||||
/// Does this run want the player UI at all? Captures stay pristine — their baselines regression-test
|
||||
/// the WORLD render — unless `WOW_CAPTURE_UI=1` opts the UI in.
|
||||
fn ui_wanted(world: &World) -> bool {
|
||||
!world.contains_resource::<crate::run_mode::CaptureMode>()
|
||||
|| std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
|
||||
}
|
||||
|
||||
/// `OnEnter(InWorld)`: materialize the in-game UI for **this** session.
|
||||
///
|
||||
/// **Once per world entry, not once per process** (decision 1290). The reference builds the whole
|
||||
/// in-game UI at `CGGameUI::Initialize 0x48fbf0` and destroys it again at `0x490bd0` on the way
|
||||
/// out, so every login runs every addon's file scope afresh — and that file scope is where the
|
||||
/// corpus reads the character it is looking at (`local currentPlayer = UnitName("player")`, the
|
||||
/// idiom [`seat_from_roster`] already documents). While this was latched per process, a second
|
||||
/// login kept the first login's captured name: the director's "the selected one is always
|
||||
/// Onewarrior no matter what char I log into", and, worse, its saved variables went to the first
|
||||
/// character's file. [`world_entry_tests`] holds both ends.
|
||||
///
|
||||
/// The load runs onto the fresh boot VM [`end_ui_session`] left behind (or, on the first login,
|
||||
/// `Startup`'s) — so nothing here has to unpick the previous session; there is nothing to unpick.
|
||||
///
|
||||
/// Safe on the state edge only because 1038 moved the initial transition after `PostStartup` — a
|
||||
/// capture boots straight into `InWorld`, so before that this would have run ahead of
|
||||
/// [`benilla_assets::AssetSet::Open`] and loaded against no patch chain.
|
||||
fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
if !ui_wanted(world) {
|
||||
return;
|
||||
}
|
||||
let Some(mut script) = world.remove_non_send_resource::<UiScript>() else {
|
||||
warn!("ui_script: entering the world with no VM — the in-game UI will not load");
|
||||
return;
|
||||
};
|
||||
// The character whose AddOn enable state applies. Resolved before the load because the
|
||||
// enable file gates which addons run at all.
|
||||
let identity = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(crate::ui_macro::identity);
|
||||
// The realm name goes in BEFORE the UI loads, because `GetRealmName()` is read at addon file
|
||||
// scope — `MyAddonDB[GetRealmName()] = …` is the corpus idiom, and 24 addons stop on it
|
||||
// (decision 1195). The roster carries the auth realm-list entry this session connected to.
|
||||
let realm = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(|r| r.realm.as_ref().map(|r| r.name.clone()))
|
||||
.unwrap_or_default();
|
||||
script.set_realm_name(&realm);
|
||||
// …and so does the PLAYER, for the same reason and with more riding on it — see
|
||||
// [`seat_from_roster`], which is where the why lives.
|
||||
if let Some(seat) = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(seat_from_roster)
|
||||
{
|
||||
script.set_unit("player", Some(seat));
|
||||
}
|
||||
let _ = load_ingame_ui(&mut script, identity.as_ref());
|
||||
// The Minimap widget was born a moment ago with `MinimapState::default()`; seed its two live
|
||||
// zoom indices from the persisted CVars now, before anything reads them — the reference's own
|
||||
// minimap reset path copying each CVar object's int into its live index (decision 1131). Once
|
||||
// only: from here the widget's index is the live truth and `Minimap:SetZoom` writes the CVar
|
||||
// back. Startup always precedes this state edge (1038), so the knob is already loaded.
|
||||
let zoom = world.resource::<crate::minimap::MinimapZoom>();
|
||||
script.set_minimap_zoom(zoom.outdoor, zoom.inside);
|
||||
// The saved-variables chunk runs HERE — after the XML assigned its file-scope defaults, before
|
||||
// any consumer reads them — then `VARIABLES_LOADED`. That is the reference's own load order
|
||||
// (`AddOn_Load 0x51f240` steps 2 → 4 → 6, decision 1128); reversing it means the defaults
|
||||
// always win and nothing can ever be remembered.
|
||||
finish_ui_load(&mut script);
|
||||
world.insert_non_send_resource(script);
|
||||
world.insert_resource(AddOnIdentity(identity));
|
||||
}
|
||||
|
||||
/// The `"player"` snapshot the UI loads **under**, built from the roster row of the pick in
|
||||
/// flight — `None` when there is no pick (a capture, a scenario, a test world).
|
||||
///
|
||||
/// **The reference's invariant is that addon file scope always sees a real character**:
|
||||
/// `AddOn_Load 0x51f240` runs from inside `UI_Init 0x48fbf0`, which is after the world is entered.
|
||||
/// benilla's does not. `Connected` flips us `InWorld` a whole server round-trip before the self
|
||||
/// descriptor streams in ([`crate::ui_unit`]'s own comment measures that gap in *seconds*), and
|
||||
/// `feed_units` — the only writer of the `"player"` token — is gated on that descriptor existing.
|
||||
/// So until this existed, every addon's file scope ran in a VM where `UnitName("player")` was
|
||||
/// **nil**, which is a state a real session cannot present. It is the same argument, at the same
|
||||
/// line, as the `set_realm_name` above it (decision 1195) — and this is the more load-bearing half.
|
||||
///
|
||||
/// **The failure it fixes is silent, which is why it survived every instrument.** The director
|
||||
/// installed Bagnon, opened their bags, and got a window with a title, a gold line and **no bag
|
||||
/// slots at all**. `Bagnon_Core/core/Utility.lua:5` opens `local currentPlayer =
|
||||
/// UnitName("player")`, and every one of Bagnon's "am I looking at a cached snapshot of some OTHER
|
||||
/// character?" predicates is `currentPlayer ~= frame.player`. With `currentPlayer` nil, Bagnon
|
||||
/// concluded the live player's own bags belonged to somebody else, took every bag size from
|
||||
/// Bagnon_Forever's (empty) offline cache instead of `GetContainerNumSlots`, created zero item
|
||||
/// buttons — and raised nothing, so `loaded`, `session` and the UI probe all scored it a pass.
|
||||
/// Reproduced both ways in [`bagnon_render_tests`].
|
||||
///
|
||||
/// **What is filled is what the roster actually knows**: name, race, class, gender and level, all
|
||||
/// a round-trip ahead of the descriptor (the same fact [`crate::char_select::Roster::pending_entry`]
|
||||
/// already exploits for the streamers). Health and power are deliberately left at zero — those are
|
||||
/// the descriptor's to say, they land within the second, and inventing them would be a different
|
||||
/// lie from the one being fixed.
|
||||
fn seat_from_roster(roster: &crate::char_select::Roster) -> Option<benilla_ui::script::UnitState> {
|
||||
let row = roster.pending_row()?;
|
||||
let race = crate::ui_unit::race_names(row.race);
|
||||
let class = crate::ui_unit::class_names(row.class);
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some(row.name.clone()),
|
||||
level: u32::from(row.level),
|
||||
race: race.map(|(n, _)| n.to_string()),
|
||||
race_file: race.map(|(_, f)| f.to_string()),
|
||||
class: class.map(|(n, _)| n.to_string()),
|
||||
class_file: class.map(|(_, f)| f.to_string()),
|
||||
// The wire's 0/1 on `UnitSex`'s 2/3 scale — `ui_unit::snapshot`'s own mapping.
|
||||
sex: match row.gender {
|
||||
0 => 2,
|
||||
1 => 3,
|
||||
_ => 0,
|
||||
},
|
||||
is_player: true,
|
||||
// Nil here is not "no faction", it is a state a player character cannot be in, and
|
||||
// AceDB-2.0 concatenates it at file scope — see [`crate::ui_unit::race_faction_group`].
|
||||
faction_group: crate::ui_unit::race_faction_group(row.race).map(str::to_string),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
/// The session lifecycle — the VM's birth, identity, death, and the reload. Every edge function
|
||||
/// lives there; this module keeps the per-frame bridge. See its header.
|
||||
mod lifecycle;
|
||||
pub(crate) use lifecycle::{
|
||||
end_ui_session, run_pending_reload, setup_script, AddOnIdentity, ReloadUiPending,
|
||||
};
|
||||
// Consumed only from other modules' test code (the emote-table checks, the harness's UI-init
|
||||
// tail, the quit-once pin) — a plain re-export would warn unused in a non-test build.
|
||||
#[cfg(test)]
|
||||
pub(crate) use lifecycle::{
|
||||
finish_ui_load, is_emote_token_line, seat_from_roster, shutdown_ui_state,
|
||||
};
|
||||
use lifecycle::{load_ingame_ui_on_world_entry, shutdown_on_exit};
|
||||
|
||||
/// `GetFramerate()`'s host half: push a **smoothed** frames-per-second into the VM each frame
|
||||
/// (decision 1195).
|
||||
@@ -554,299 +397,6 @@ fn feed_framerate(
|
||||
script.set_framerate(*smoothed);
|
||||
}
|
||||
|
||||
/// The character the loaded AddOn enable state belongs to, remembered so the shutdown write goes
|
||||
/// back to the file it came from — the roster's pick can be gone by then.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>);
|
||||
|
||||
/// **The UI shutdown, in the reference's own order** — `0x490bd0`, whose ordered tail wow-5875-re
|
||||
/// carves as (`system/ui/ui.md`):
|
||||
///
|
||||
/// > `PLAYER_LEAVING_WORLD` (273) → **`PLAYER_LOGOUT`** (271, `0x490c2a`) → `layout-cache.txt` →
|
||||
/// > **the flat saved file** (`0x490c7e`) → **the per-addon files** (`0x490c83`) → `AddOns.txt`
|
||||
/// > (`0x490c88`) → destroy the frame-script owner (`0x490c97`) → nil all 216 C bindings out of
|
||||
/// > `_G` (`0x490cba` → `0x490ce0`)
|
||||
///
|
||||
/// **The last step is not "destroy the Lua state"** — which is what three of wow-re's own notes
|
||||
/// said, until this client's teardown made the question load-bearing and a §5 cross-check settled
|
||||
/// it (`system/ui/scratch/lua-state-lifecycle.md`; 1291). `0x490c97` is the frame-script owner's
|
||||
/// scalar-deleting destructor — the widget tree and the native virtual-font registry — and the Lua
|
||||
/// state outlives it. The state is closed and re-opened at `0x703b80`, which reaches `InitLua` by a
|
||||
/// **tail-`jmp`** (`0x703b8e`) rather than a call, which is exactly why a call-census missed it.
|
||||
/// Its three callers are `UI_Init 0x48fbf0` (at its *head*), `ShutdownGame 0x491180`, and the glue
|
||||
/// builder `0x46a7b0` — so a logout/login cycle runs through four distinct states, and the glue
|
||||
/// screen gets its own.
|
||||
///
|
||||
/// **`PLAYER_LOGOUT` fires before any write, and that is the point**: it is an addon's last chance
|
||||
/// to mutate a saved global, so a handler that stores "where I left off" runs while the write is
|
||||
/// still ahead of it. Firing it after would make the event useless and the bug invisible.
|
||||
///
|
||||
/// One function, called from every root, because the steps are ordered *against each other* —
|
||||
/// three independent Bevy systems on one state edge cannot express that, and until this landed the
|
||||
/// flat write and the `AddOns.txt` write were exactly that.
|
||||
///
|
||||
/// **There is no autosave**, deliberately: the reference has none (decision 1128, and
|
||||
/// `ds:0xb4b3f4` has three references image-wide). These are a handful of scalars a player toggles
|
||||
/// a few times a session, and every file is written whole from the live globals.
|
||||
pub(crate) fn shutdown_ui_state(script: &mut UiScript, identity: Option<&(String, String)>) {
|
||||
script.fire_event("PLAYER_LEAVING_WORLD", vec![]);
|
||||
script.fire_event("PLAYER_LOGOUT", vec![]);
|
||||
crate::ui_saved::save(script);
|
||||
addons::save_addon_variables(script, identity);
|
||||
addons::save_enable_state(script, identity);
|
||||
}
|
||||
|
||||
/// `OnExit(InWorld)`: a `/logout` back to the glue, or a disconnect — two of the reference's five
|
||||
/// roots — and, with the writes done, **the end of this session's Lua state**.
|
||||
///
|
||||
/// The reference's own shutdown ends by destroying the state (`0x490bd0`'s tail, after
|
||||
/// `AddOns.txt`); ours ends by replacing it with a fresh boot VM ([`install_boot_vm`]), which is the
|
||||
/// same guarantee expressed the way our two-phase load wants it — the character screen still needs
|
||||
/// a font-object registry for the shared glyph atlas, and the next login needs somewhere to load
|
||||
/// onto. What matters is that **no frame, no global and no addon upvalue crosses this edge**: that
|
||||
/// is what makes the next login a real login (decision 1290) instead of a re-entry into the
|
||||
/// previous character's UI.
|
||||
///
|
||||
/// Exclusive rather than a `NonSendMut` system because it both drops and installs a `NonSend`, and
|
||||
/// because the shutdown writes must be ordered against each other — see [`shutdown_ui_state`].
|
||||
pub(crate) fn end_ui_session(world: &mut World) {
|
||||
let identity = world
|
||||
.get_resource::<AddOnIdentity>()
|
||||
.and_then(|id| id.0.clone());
|
||||
if let Some(mut script) = world.get_non_send_resource_mut::<UiScript>() {
|
||||
shutdown_ui_state(&mut script, identity.as_ref());
|
||||
}
|
||||
// The CVar bridge (decision 1291): the dying VM's table folds into the persist state — after
|
||||
// the shutdown events above (a `PLAYER_LOGOUT` handler may `SetCVar`, and in the reference
|
||||
// that lands in an engine-side store that survives), before the VM is replaced. The next
|
||||
// VM's registration seeds from what this writes ([`crate::cvars`]'s saved base).
|
||||
crate::cvars::fold_dying_vm_cvars(world);
|
||||
world.insert_resource(AddOnIdentity(None));
|
||||
|
||||
// **Everything the host is holding that came OUT of the dying VM goes with it.** A change memo
|
||||
// handles itself — it is keyed on [`benilla_ui::script::UiScript::session`] ([`VmMemo`]) — but
|
||||
// these are plain values other systems read through `Res<…>`, with no VM in hand to key
|
||||
// against, so the edge clears them. Each is a fact about a frame tree that is about to stop
|
||||
// existing: a hovered frame id, the minimap's extracted hole, a payload the cursor is carrying,
|
||||
// and the VM-relative clock the cooldown conversions run through.
|
||||
//
|
||||
// The two input latches were cleared by `char_select`'s logout and disconnect handlers, one
|
||||
// copy each. They belong here: the reason they need clearing is that `feed_ui_input` stops
|
||||
// running outside `InWorld`, which is this edge and nothing to do with *why* we left it.
|
||||
if let Some(mut hover) = world.get_resource_mut::<PlayerUiHover>() {
|
||||
hover.0 = None;
|
||||
}
|
||||
if let Some(mut keys) = world.get_resource_mut::<UiKeyboardCapture>() {
|
||||
keys.0 = false;
|
||||
}
|
||||
if let Some(mut held) = world.get_resource_mut::<CursorPayloadHeld>() {
|
||||
*held = CursorPayloadHeld::default();
|
||||
}
|
||||
if let Some(mut minimap) = world.get_resource_mut::<crate::minimap::MinimapWidget>() {
|
||||
minimap.0 = None;
|
||||
}
|
||||
if let Some(mut clock) = world.get_resource_mut::<UiClock>() {
|
||||
*clock = UiClock::default();
|
||||
}
|
||||
|
||||
install_boot_vm(world);
|
||||
}
|
||||
|
||||
/// A `ReloadUI()` waiting to run — set when [`crate::ui_logout`] drains
|
||||
/// [`benilla_ui::script::SessionRequest::ReloadUi`], consumed by [`run_pending_reload`] at the
|
||||
/// top of the next frame's `Update`.
|
||||
///
|
||||
/// A flag rather than an immediate call for the reference's own reason (`ds:0xb4b3f4`, its only
|
||||
/// writer `0x491380` and its only reader the per-frame callback `0x495590`): the VM that queued
|
||||
/// the request must not be mid-call when it is destroyed. Our drain already runs outside any VM
|
||||
/// dispatch, but the flag keeps the whole rebuild at one point in the frame — before the input
|
||||
/// pass — instead of wherever the drain happens to sit.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct ReloadUiPending(pub(crate) bool);
|
||||
|
||||
/// Run a pending `ReloadUI()`: the reference's teardown/rebuild pair (`0x495664 call 0x490bd0`,
|
||||
/// `0x495669 call 0x48fbf0`), which for us is [`end_ui_session`] then
|
||||
/// [`load_ingame_ui_on_world_entry`] — the same two functions the logout/login edges run, called
|
||||
/// back to back without leaving the world (decision 1291).
|
||||
///
|
||||
/// Everything that makes a login correct makes the reload correct **by construction**: the
|
||||
/// shutdown tail fires `PLAYER_LEAVING_WORLD`/`PLAYER_LOGOUT` and writes the four files (so a
|
||||
/// `DisableAddOn` staged in the dying VM reaches `AddOns.txt` before the rebuild reads it), the
|
||||
/// rebuild is a real login's load (fresh file scope, saved variables, `VARIABLES_LOADED`,
|
||||
/// `PLAYER_LOGIN`), and every host memory keyed on the VM's identity ([`VmMemo`], decision 1290)
|
||||
/// expires with the old session id. `PLAYER_ENTERING_WORLD` refires from [`crate::ui_unit`]'s
|
||||
/// feed once it notices the new VM, with the self descriptor already present — the reference's
|
||||
/// own ordering, where the event follows the rebuild.
|
||||
///
|
||||
/// **In-world only.** At the glue there is no in-game UI to rebuild and no identity to load
|
||||
/// addons under; the reference's own gate (`0x494a50(0xa)`) refuses there too. Dropped with a log
|
||||
/// line rather than deferred — a reload asked for at the character screen answers nothing.
|
||||
pub(crate) fn run_pending_reload(world: &mut World) {
|
||||
if !std::mem::take(&mut world.resource_mut::<ReloadUiPending>().0) {
|
||||
return;
|
||||
}
|
||||
let in_world = *world
|
||||
.resource::<State<crate::char_select::ClientState>>()
|
||||
.get()
|
||||
== crate::char_select::ClientState::InWorld;
|
||||
if !in_world {
|
||||
info!("ui_script: ReloadUI outside the world — dropped");
|
||||
return;
|
||||
}
|
||||
info!("ui_script: ReloadUI — ending the UI session and building a new one");
|
||||
end_ui_session(world);
|
||||
load_ingame_ui_on_world_entry(world);
|
||||
}
|
||||
|
||||
/// `AppExit`: quitting the client — the quit / application-exit roots. Reads the message rather
|
||||
/// than a state edge because a quit from in-world never leaves `InWorld`.
|
||||
fn shutdown_on_exit(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
id: Res<AddOnIdentity>,
|
||||
mut exits: MessageReader<AppExit>,
|
||||
) {
|
||||
if exits.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(mut script) = script {
|
||||
shutdown_ui_state(&mut script, id.0.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
/// The UI-init sequence's ordered tail, once every file — ours and every addon's — has loaded:
|
||||
/// the saved-variables chunk and `VARIABLES_LOADED`, then `PLAYER_LOGIN`.
|
||||
///
|
||||
/// **The order is the reference's**, byte-verified in wow-5875-re (`system/ui/ui.md`, and the
|
||||
/// cascade in `system/ui/scratch/mail-pending-countdown.md`). Inside `UI_Init 0x48fbf0`, in
|
||||
/// straight-line address order:
|
||||
///
|
||||
/// | | |
|
||||
/// |---|---|
|
||||
/// | `0x4900a3` → `0x51f600` | load every non-LoadOnDemand addon — each fires its own **`ADDON_LOADED`** (429, `0x51f5ad`) |
|
||||
/// | `0x4900b2` → `0x4913b0` | read the flat saved file, fire **`VARIABLES_LOADED`** (430) |
|
||||
/// | `0x490168` → `0x4908c0` | the world-enter cascade: **`PLAYER_LOGIN`** (`0x49094b`, `0x10e`) then **`PLAYER_ENTERING_WORLD`** (`0x490965`, `0x110`) |
|
||||
///
|
||||
/// So every non-LoD addon's `ADDON_LOADED` precedes `VARIABLES_LOADED`, which precedes
|
||||
/// `PLAYER_LOGIN`. It is one function rather than three inline calls because that sequence is the
|
||||
/// mechanism — an addon restores state on `ADDON_LOADED` and expects the saved chunk to have run,
|
||||
/// and a window that waits on `PLAYER_LOGIN` expects both — so it is worth being able to assert.
|
||||
///
|
||||
/// **`PLAYER_LOGIN` is the conditional one; `PLAYER_ENTERING_WORLD` is not.** The cascade fires
|
||||
/// the former only when `[0xb4e260]` is set, and only the FrameXML-loader path sets it, clearing
|
||||
/// it immediately after — so it means "the UI came up". That is once per **UI build**, which since
|
||||
/// 1290 is once per world entry here too: this runs from
|
||||
/// [`load_ingame_ui_on_world_entry`], on the same edge that built the tree.
|
||||
/// `PLAYER_ENTERING_WORLD` keeps its own per-entry latch in [`crate::ui_unit`] and still lands
|
||||
/// after this, since it waits on the self descriptor arriving over the wire.
|
||||
pub(crate) fn finish_ui_load(script: &mut UiScript) {
|
||||
crate::ui_saved::load_saved_variables(script);
|
||||
script.fire_event("PLAYER_LOGIN", vec![]);
|
||||
}
|
||||
|
||||
/// Execute the real `Interface\FrameXML\GlobalStrings.lua` off the patch chain into the VM —
|
||||
/// the reference boots FrameXML with exactly this file FIRST, and it is the source of every
|
||||
/// localized string global the UI reads (the cast-fail display's whole message set, 0427).
|
||||
/// Loaded before our own `assets/ui` files, matching the reference order. Failures are LOUD:
|
||||
/// a silently missing GlobalStrings once suppressed every red error line (the 0427 fold's
|
||||
/// absent-key face is faithful data suppression — but only when the file actually loaded).
|
||||
fn load_global_strings(world: &mut World, script: &UiScript) {
|
||||
let Some(assets) = world.get_resource::<benilla_assets::WorldAssets>() else {
|
||||
warn!("ui_script: no patch chain — GlobalStrings absent, error lines will be empty");
|
||||
return;
|
||||
};
|
||||
let bytes = {
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
chain.read_file("Interface\\FrameXML\\GlobalStrings.lua")
|
||||
};
|
||||
let src = match bytes {
|
||||
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
|
||||
Err(e) => {
|
||||
error!("ui_script: GlobalStrings.lua read failed — error lines will be empty: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = script.run(&src) {
|
||||
error!("ui_script: GlobalStrings.lua failed to run: {e}");
|
||||
return;
|
||||
}
|
||||
// The sentinel: the exact lookup the cast-fail drain performs. If this misses, every
|
||||
// message would silently vanish — turn that failure mode into a diagnosable line. Presence
|
||||
// only, not the enUS text: a non-enUS install is still a loaded GlobalStrings.
|
||||
let sentinel: Option<String> = script.lua().globals().get("SPELL_FAILED_NO_AMMO").ok();
|
||||
match sentinel {
|
||||
Some(s) if !s.is_empty() => info!("ui_script: GlobalStrings loaded"),
|
||||
other => {
|
||||
error!("ui_script: GlobalStrings sentinel missing ({other:?}) — error lines broken")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the reference's own **emote token table** into the VM (`EMOTE87_TOKEN = "SIT"`, …) —
|
||||
/// the second half of the emote slash grammar (decision 0881). The *aliases* are in
|
||||
/// `GlobalStrings.lua` above (`EMOTE87_CMD1 = "/sit"`), but the alias → `EmotesText.Name` mapping
|
||||
/// lives in `ChatFrame.lua`: the reference's chat **code**, which benilla replaces in Rust. So we
|
||||
/// take that file's **data** and none of its code — only whole lines matching
|
||||
/// `EMOTE<digits>_TOKEN = "<UPPER>";` ([`is_emote_token_line`]) are executed, and the file's ~2400
|
||||
/// lines of frame logic never run. Reading the shipped table beats transcribing 170 tokens into
|
||||
/// Rust: a transcription can be wrong, and a hand-kept alias list is exactly what left 61 real
|
||||
/// commands (`/lol`, `/hi`, `/ty`, …) unresolvable before 0881.
|
||||
fn load_emote_tokens(world: &mut World, script: &UiScript) {
|
||||
let Some(assets) = world.get_resource::<benilla_assets::WorldAssets>() else {
|
||||
return; // already WARNed by load_global_strings
|
||||
};
|
||||
let bytes = {
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
chain.read_file("Interface\\FrameXML\\ChatFrame.lua")
|
||||
};
|
||||
let src = match bytes {
|
||||
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
|
||||
Err(e) => {
|
||||
error!("ui_script: ChatFrame.lua read failed — emote commands will be dead: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let table: Vec<&str> = src
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| is_emote_token_line(l))
|
||||
.collect();
|
||||
let count = table.len();
|
||||
if let Err(e) = script.run(&table.join("\n")) {
|
||||
error!("ui_script: emote token table failed to run: {e}");
|
||||
return;
|
||||
}
|
||||
// The sentinel is the command this whole seam exists for: EMOTE87 is `/sit`.
|
||||
let sentinel: Option<String> = script.lua().globals().get("EMOTE87_TOKEN").ok();
|
||||
match sentinel.as_deref() {
|
||||
Some("SIT") => info!("ui_script: {count} emote tokens loaded"),
|
||||
other => error!(
|
||||
"ui_script: emote token sentinel is {other:?}, not \"SIT\" ({count} lines) — \
|
||||
emote slash commands are broken"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this line one of `ChatFrame.lua`'s emote-token assignments — `EMOTE<digits>_TOKEN = "<NAME>";`
|
||||
/// with `NAME` in `[A-Z0-9_]`? The whole-line shape is the filter that makes running the matched
|
||||
/// lines equivalent to reading data (no calls, no expressions, no side effects).
|
||||
pub(crate) fn is_emote_token_line(line: &str) -> bool {
|
||||
let Some(rest) = line.strip_prefix("EMOTE") else {
|
||||
return false;
|
||||
};
|
||||
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
|
||||
let Some(rest) = rest[digits..].strip_prefix("_TOKEN = \"") else {
|
||||
return false;
|
||||
};
|
||||
let Some(name) = rest.strip_suffix("\";") else {
|
||||
return false;
|
||||
};
|
||||
digits > 0
|
||||
&& !name.is_empty()
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
|
||||
}
|
||||
|
||||
/// Feed synthetic `"player"`/`"target"` snapshots each frame (overriding the real feed, which finds
|
||||
/// no avatar in a server-less capture) and fire the initial events once — proving the full chain
|
||||
/// end-to-end on a screenshot: snapshot → `Unit*` bindings → Lua `OnEvent` → bars + text, for both
|
||||
@@ -1163,9 +713,6 @@ mod tooltip_template_tests;
|
||||
#[cfg(test)]
|
||||
mod escape_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod addon_list_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod game_menu_tests;
|
||||
|
||||
|
||||
@@ -432,7 +432,7 @@ fn the_options_check_button_resolves_its_whole_inheritance_chain() {
|
||||
/// real 1.12.1 client has, read from `reference/1.12-globals.tsv`.
|
||||
///
|
||||
/// Scoped to these two files on purpose: the rest of `assets/ui` is full of deliberately
|
||||
/// benilla-shaped template names (`BenillaScrollBarTemplate`, `AddonListPanelButtonTemplate`,
|
||||
/// benilla-shaped template names (`BenillaScrollBarTemplate`, `BenillaMacroPanelButtonTemplate`,
|
||||
/// `OptionsRedButtonTemplate`), and a whole-tree sweep would be asserting something else. These
|
||||
/// two files make the opposite claim — *these are the reference's own names, which is why an addon
|
||||
/// can find them* — so that claim is the one worth gating. A template renamed to something
|
||||
|
||||
@@ -183,6 +183,14 @@ mod tests {
|
||||
// re-seats off `!script.has_text_measurer()` — it interrogates the VM instead of a memo,
|
||||
// which is the same guarantee arrived at the other way.
|
||||
("ui_script/extract/mod.rs", "last_seam"),
|
||||
// The atlas bake generation beside it (decision 1296) — a fact about the glyph atlas, a
|
||||
// Bevy resource that outlives any VM, not about the VM. It gates the same re-seat
|
||||
// `last_seam` does, and a fresh VM re-seats on `!has_text_measurer()` regardless.
|
||||
("ui_script/extract/mod.rs", "last_bake"),
|
||||
// How long the raster environment has differed from the baked one (decision 1296's
|
||||
// re-bake debounce): a stopwatch about the WINDOW, zeroed by its own equality test every
|
||||
// frame. A new VM neither reads it nor is described by it.
|
||||
("ui_text/atlas.rs", "drifted_for"),
|
||||
// Pushed unconditionally every frame; there is nothing remembered to go stale.
|
||||
("ui_script/mod.rs", "smoothed"),
|
||||
// The cursor systems, both platform arms: these track the OS cursor and an `NSCursor` raw
|
||||
|
||||
Reference in New Issue
Block a user