WOW_GLUE_STOCK=1 loads and draws the stock GlueXML (#969)

With `WOW_GLUE_STOCK=1` (dev builds), benilla now loads the stock GlueXML off the player's own patch chain into the glue state and draws it; with the switch off nothing changes.

In 1.12.1 the glue builder (`0x46a7b0`) opens `Logs\GlueXML.log` (`0x46a8ce`), runs `GlueXML.toc` through the shared toc runner (`0x6edb90` at `0x46a9b3`), fires `FRAMES_LOADED` (`0x46aa34`) and opens on "login" or "movie" (`0x402b3b`), or "charselect" back from the world (`0x402084`). Glue `GetScreenWidth`/`GetScreenHeight` answer the root unscaled (`0x46dd30`, `0x46dd80`).

Under the switch the glue state replaces the boot state, takes input, and fires `SET_GLUE_SCREEN` on those edges; the native screens are hidden but still drive the network. A data-gated test loads the stock files with only seven unbuilt verbs raising, a list that may only shrink.

Part of #856

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 08:40:34 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 51a2ce5586
commit 133414f5c1
24 changed files with 717 additions and 79 deletions
+2 -2
View File
@@ -48,11 +48,11 @@ impl Plugin for CharCreatePlugin {
Update,
(
screen::rescale_screen,
create_input,
create_input.run_if(crate::ui_script::glue::native_glue),
debug_auto_create,
debug_pick,
debug_shot,
rotate_model,
rotate_model.run_if(crate::ui_script::glue::native_glue),
refresh::refresh_dynamic,
refresh_name_box,
refresh::refresh_hover,
@@ -106,6 +106,7 @@ fn spawn_screen(
let root = commands
.spawn((
crate::glue::NativeGlue,
CharCreateUi { s },
DynTint::Backdrop,
GlobalZIndex(SCREEN_Z),
@@ -720,6 +720,7 @@ fn spawn_panel(
commands
.spawn((
AddonsUi,
crate::glue::NativeGlue,
GlobalZIndex(1200), // over the select screen's 1100, like the delete dialog
// The reference's full-screen BACKGROUND layer, black at 0.75; it also keeps a stray
// click off the screen behind.
@@ -223,6 +223,7 @@ fn spawn_dialog(
commands
.spawn((
DialogUi,
crate::glue::NativeGlue,
GlobalZIndex(1200), // over the select screen's 1100
Node {
width: Val::Percent(100.0),
+6 -5
View File
@@ -101,17 +101,18 @@ impl Plugin for CharSelectPlugin {
.chain(),
(
screen::materialize_screen,
input::select_input,
input::rotate_model,
input::select_input.run_if(crate::ui_script::glue::native_glue),
input::rotate_model.run_if(crate::ui_script::glue::native_glue),
debug_select_dialog,
dialog::drive_delete_dialog,
dialog::drive_delete_dialog.run_if(crate::ui_script::glue::native_glue),
// Ahead of the dialog driver, so a refused delete shows the frame it lands.
delete_result,
// The one `GlueDialog`: here, the refused login and the refused delete.
crate::glue::dialog::drive_glue_dialog,
crate::glue::dialog::drive_glue_dialog
.run_if(crate::ui_script::glue::native_glue),
debug_select_addons,
debug_select_walk,
addons::drive_addons_panel,
addons::drive_addons_panel.run_if(crate::ui_script::glue::native_glue),
refresh::refresh_list,
refresh::refresh_banner_and_buttons,
refresh::feed_glue_preview,
@@ -145,6 +145,7 @@ fn spawn_screen(
let root = commands
.spawn((
crate::glue::NativeGlue,
CharSelectUi {
with_art: art.button_up.is_some(),
s,
+4 -2
View File
@@ -637,8 +637,10 @@ pub(crate) mod schedule_tests {
}
/// `PostUpdate`'s undeclared-order pairs on the declared graph; `GlobalTransform` and the
/// particle `EffectQuads` are most of it.
const POST_UPDATE_CEILING: usize = 371;
/// particle `EffectQuads` are most of it. Six are `glue::hide_native_glue`'s `Visibility`
/// against the world's and the fade cover's writers, populations it never shares an entity
/// with (the native glue roots alone).
const POST_UPDATE_CEILING: usize = 377;
const POST_UPDATE_SLACK: usize = 20;
/// The actionable pairs in `Update`: conflicting access, no declared order, and nothing
/// [`Classes`] explains, so the executor orders them however the graph falls. The count may
+1
View File
@@ -196,6 +196,7 @@ pub(crate) fn spawn_dialog(
let (caption, caption2) = kind.buttons(strings);
commands
.spawn((
super::NativeGlue,
GlobalZIndex(1200), // over the screen's 1100
Node {
width: Val::Percent(100.0),
+22
View File
@@ -15,6 +15,22 @@ use bevy::ui::FocusPolicy;
use art::{tc_rect, GlueArt, BTN_BG, BTN_HOVER, BUTTON_TC, GOLD};
use widgets::{ArtSwap, GlueBtn, GlueCaption, GlueDisabled, OutlineCopy};
/// The root of a native glue screen or dialog, which [`hide_native_glue`] keeps hidden while the
/// stock glue screens stand in its place.
#[derive(Component)]
pub(crate) struct NativeGlue;
/// Under `WOW_GLUE_STOCK=1`, keep every native glue tree hidden, after any screen system shows one
/// and before visibility propagates: their state machine still drives the network, unseen, and a
/// hidden node takes no click.
pub(crate) fn hide_native_glue(mut roots: Query<&mut Visibility, With<NativeGlue>>) {
for mut v in &mut roots {
if *v != Visibility::Hidden {
*v = Visibility::Hidden;
}
}
}
/// The glue widgets' look passes, registered once for every glue screen.
///
/// A screen orders its own refresh `.before(GlueVisuals)` so the flags and captions it writes
@@ -50,6 +66,12 @@ impl Plugin for GluePlugin {
PostUpdate,
fit_glue_canvas.before(bevy::ui::UiSystems::Layout),
)
.add_systems(
PostUpdate,
hide_native_glue
.before(bevy::camera::visibility::VisibilitySystems::VisibilityPropagate)
.run_if(not(crate::ui_script::glue::native_glue)),
)
.add_systems(
Update,
(art_swaps, glue_button_visuals, glue_hilights, sync_outlines).in_set(GlueVisuals),
+3 -2
View File
@@ -63,14 +63,15 @@ impl Plugin for LoginPlugin {
(drive_policy, to_select_on_roster, drive_quit).chain(),
(
screen::materialize_screen,
login_input,
login_input.run_if(crate::ui_script::glue::native_glue),
tick_login_caret,
screen::refresh_boxes,
screen::refresh_checkbox,
// The shared glue dialog stays inside this chain: ordering it from outside
// would order a shared painter against its own `SystemTypeSet`, which
// panics at schedule build, at boot, where no unit test sees it.
crate::glue::dialog::drive_glue_dialog,
crate::glue::dialog::drive_glue_dialog
.run_if(crate::ui_script::glue::native_glue),
answer_dialog,
// After the driver: it spawns the dialog's edit box, and a realmlist Okay
// changes the address repainted here.
+1
View File
@@ -141,6 +141,7 @@ fn spawn_screen(
let root = commands
.spawn((
crate::glue::NativeGlue,
LoginUi {
with_art: art.button_up.is_some(),
s,
+2 -1
View File
@@ -295,7 +295,8 @@ impl Plugin for RealmSelectPlugin {
(
(input::clicks, input::keys)
.chain()
.run_if(|realms: Res<Realms>| realms.owns_input()),
.run_if(|realms: Res<Realms>| realms.owns_input())
.run_if(crate::ui_script::glue::native_glue),
(tick_refresh, screen::refresh_list)
.chain()
.run_if(|realms: Res<Realms>| realms.shown),
@@ -209,6 +209,7 @@ fn spawn_screen(
commands
.spawn((
crate::glue::NativeGlue,
RealmListUi { with_art, s },
GlobalZIndex(REALM_Z),
// `enableMouse="true"` on a full-screen frame: clicks never reach the screen below.
+19 -8
View File
@@ -280,15 +280,26 @@ pub(crate) fn register_dev_commands(script: &benilla_ui::script::UiScript, dev:
}
}
/// Build the table in `PostStartup`, after `Startup` has inserted the VM and the emote catalog.
pub(crate) fn build_slash_commands(
mut commands: Commands,
script: Option<NonSend<benilla_ui::script::UiScript>>,
emotes: Option<Res<crate::sound::EmoteSounds>>,
) {
/// Build the table in `PostStartup`, after `Startup` has inserted the VM and the emote catalog,
/// off the boot VM's `GlobalStrings.lua` and emote tokens. Where the stock glue screens hold the
/// world instead, their VM has neither, so a boot VM's contents are built for this read alone
/// ([`crate::ui_script::strings_vm`]).
pub(crate) fn build_slash_commands(world: &mut World) {
let glue_side = world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.is_some_and(|s| s.vm_kind() == benilla_ui::script::VmKind::Glue);
let own = if glue_side {
crate::ui_script::strings_vm(world)
} else {
None
};
let script = own
.as_ref()
.or_else(|| world.get_non_send_resource::<benilla_ui::script::UiScript>());
let emotes = world.get_resource::<crate::sound::EmoteSounds>();
let (Some(script), Some(emotes)) = (script, emotes) else {
warn!("chat: no UI VM or emote catalog — the slash-command table is EMPTY");
commands.insert_resource(SlashCommands::default());
world.insert_resource(SlashCommands::default());
return;
};
let globals = script.lua().globals();
@@ -305,5 +316,5 @@ pub(crate) fn build_slash_commands(
if emote == 0 {
error!("chat: NO emote aliases — every /wave-style command is dead (GlobalStrings?)");
}
commands.insert_resource(table);
world.insert_resource(table);
}
+24 -14
View File
@@ -489,18 +489,23 @@ pub(super) fn tick_script(
{
let _span = bevy::log::info_span!("ui_script: tick").entered();
let resized = script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 });
// A display change re-applies the UI scale (`0x492e90`), whose narrow-aspect cap follows
// the window; a moved dial applies once, as its CVar callback does (`0x490770`).
if resized {
let display = (w.round() as u32, h.round() as u32);
let mut none = None;
let cache = auto_cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
script.apply_ui_scale(display, cache);
}
let rescaled = script.set_ui_scale(ui_scale.0);
// Either re-runs what the interface computed from the old `GetScreenHeight()`.
if resized || rescaled {
super::manifest::on_screen_resized(&script);
// The UI scale is `UIParent`'s, which only the in-game state has; the glue lays out on
// the root alone (`0x46dd30`).
if script.vm_kind() == benilla_ui::script::VmKind::InGame {
// A display change re-applies the UI scale (`0x492e90`), whose narrow-aspect cap
// follows the window; a moved dial applies once, as its CVar callback does
// (`0x490770`).
if resized {
let display = (w.round() as u32, h.round() as u32);
let mut none = None;
let cache = auto_cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
script.apply_ui_scale(display, cache);
}
let rescaled = script.set_ui_scale(ui_scale.0);
// Either re-runs what the interface computed from the old `GetScreenHeight()`.
if resized || rescaled {
super::manifest::on_screen_resized(&script);
}
}
script.tick(time.delta_secs());
}
@@ -573,8 +578,13 @@ pub(super) fn tick_script(
for w in script.take_warnings() {
warn!("ui_script: {w}");
}
// A runtime `LoadAddOn`'s record, appended to `Logs\FrameXML.log`.
super::load_log::write(script.take_load_log_writes());
// A runtime `LoadAddOn`'s record, appended to `Logs\FrameXML.log`; the glue has none, but
// whatever its VM drains is the glue log's.
let writes = script.take_load_log_writes();
match script.vm_kind() {
benilla_ui::script::VmKind::InGame => super::load_log::write(writes),
benilla_ui::script::VmKind::Glue => super::load_log::write_glue(writes),
}
// ── The handover ────────────────────────────────────────────────────────────────────────
*pass = super::UiPassState {
+354
View File
@@ -0,0 +1,354 @@
//! The stock glue screens, behind `WOW_GLUE_STOCK=1` in a dev build: the player's own
//! `GlueXML.toc` off the patch chain, run into a glue VM ([`UiScript::new_glue`]) that stands in
//! the world while no character is in it, where the boot VM otherwise stands. The native screens
//! stay the default and, under the switch, keep driving the network unseen.
//!
//! The reference's glue builder (`0x46a7b0`) resets the Lua state on a rebuild (`0x46a87b`; the
//! cold first build, `0x46a505`, passes 0 and skips it), registers the glue functions
//! (`0x46abb0`) and the 26 glue events (`0x46a88f`), opens `Logs\GlueXML.log` (`0x46a8ce`), then
//! checks `GlueXML.toc`'s signature (`0x46a90b`). Only a valid one (`0x6f10f0` answering 3) loads
//! the toc through the shared runner (`0x6edb90`, at `0x46a9b3`), and only when the loaded files'
//! MD5 matches the signature's (`0x46a9d3`) does `FRAMES_LOADED` fire (`0x46aa34`). Signature
//! results 0–2 (the table at `0x46aaac`) log their line, missing, corrupt or modified (`0x836fb8`,
//! `0x836f98`, `0x836f78`), and load nothing; an MD5 mismatch logs no line, its frames already
//! built, and skips `FRAMES_LOADED`. Either way the client quits with
//! error 9 (`0x401560`: the exit code, then the quit post `ForceQuit` makes with code 0). benilla
//! checks no signature, as for `FrameXML.toc` (`0x48ff4d`): the chain's toc always loads and
//! `FRAMES_LOADED` follows.
//!
//! The screen comes after the build: at startup the reference fires `SET_GLUE_SCREEN` with
//! `"movie"` while the `movie` CVar is 1 (clearing it), else `"login"` (`0x402b3b`–`0x402b65`);
//! back from the world `0x402090` fires it with `"charselect"` or `"login"`. benilla plays no movie
//! yet (#968), so its boot screen is `"login"`.
//!
//! The screens' `ModelFFX` panes (P4) must drive the glue booth's scene rig, the scene M2's own
//! point lights (`portrait::glue_booth::scene_rig`) and the `FfxGlow::GLUE_SCENE` pass, never the
//! `ui_models` tile path, where every lit batch draws black.
use bevy::prelude::*;
use benilla_ui::script::{ScriptValue, UiScript};
use super::reference_ui;
/// The switch's variable.
const SWITCH: &str = "WOW_GLUE_STOCK";
/// Whether this run puts the stock glue screens up, read once at plugin build: a dev build with
/// `WOW_GLUE_STOCK=1`. A player build never does.
#[derive(Resource, Default, Clone, Copy)]
pub(crate) struct GlueStock(pub(crate) bool);
impl GlueStock {
/// The switch as this process has it.
pub(crate) fn from_env() -> Self {
Self(
crate::run_mode::dev_affordances()
&& std::env::var(SWITCH).ok().as_deref() == Some("1"),
)
}
}
/// Run condition: the stock glue screens are up, the switch on and no character in the world.
pub(crate) fn glue_ui_up(
stock: Option<Res<GlueStock>>,
state: Option<Res<State<crate::char_select::ClientState>>>,
) -> bool {
stock.is_some_and(|s| s.0)
&& state.is_none_or(|s| *s.get() != crate::char_select::ClientState::InWorld)
}
/// Run condition: the native glue screens draw and take input, the switch off.
pub(crate) fn native_glue(stock: Option<Res<GlueStock>>) -> bool {
!stock.is_some_and(|s| s.0)
}
/// The glue screen `SET_GLUE_SCREEN` names, `GlueParent.lua`'s `GlueScreenInfo` key.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum GlueScreen {
Login,
CharSelect,
}
impl GlueScreen {
/// The key, as the reference's literals spell it (`0x82e3e0`, `0x82e3e8`).
pub(crate) fn key(self) -> &'static str {
match self {
GlueScreen::Login => "login",
GlueScreen::CharSelect => "charselect",
}
}
}
/// Load the chain's `GlueXML.toc` into `script`, a glue VM, and fire `FRAMES_LOADED`: the glue
/// builder's steps from the toc on (`0x46a9b3`, `0x46aa34`). Returns every failure; a loader error
/// is tagged `"GlueXML/<file>: <error>"`, a handler that raised during the walk is one too. The
/// load's record is left for [`finish_glue_load_log`].
pub(super) fn load_glue(script: &mut UiScript) -> Vec<String> {
let mut failures = match reference_ui::glue() {
Some(glue) => {
let mut toc = benilla_ui::status::Status::default();
let failures = glue.load_files_into(script, &glue.toc.files, &mut toc);
let mut log = benilla_ui::status::Status::default();
let banner = benilla_ui::status::toc_banner(reference_ui::GLUE_TOC);
toc.close_into(&mut log, script.framexml_debug(), banner);
script.report_load_status(log);
failures
}
None => {
// The runner's `"Couldn't open %s"` (`0x846ff4`) at severity 2; no glue frame exists.
let e = format!(
"{}: not found — the stock glue screens are not built",
reference_ui::GLUE_TOC
);
error!("ui_script: {e}");
script.report_load_failure(&e);
let mut log = benilla_ui::status::Status::default();
log.report(
benilla_ui::status::FAILURE,
benilla_ui::status::missing(reference_ui::GLUE_TOC, false),
);
script.report_load_status(log);
vec![e]
}
};
script.fire_event("FRAMES_LOADED", vec![]);
failures.extend(
script
.errors()
.into_iter()
.map(|e| format!("a handler raised during the glue load: {e}")),
);
failures
}
/// Drain the glue load's record into `Logs\GlueXML.log`, as the builder does after
/// `FRAMES_LOADED` (`0x46aa61`-`0x46aa94`).
pub(super) fn finish_glue_load_log(script: &mut UiScript) {
script.finish_ui_load_log();
super::load_log::write_glue(script.take_load_log_writes());
}
/// Put `screen` up: `SET_GLUE_SCREEN` with its key as `arg1` (`0x46b840`, event 0).
pub(super) fn set_glue_screen(script: &mut UiScript, screen: GlueScreen) {
script.fire_event(
"SET_GLUE_SCREEN",
vec![ScriptValue::Str(screen.key().to_string())],
);
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::*;
/// The glue functions the stock GlueXML calls that benilla has not built yet: each one's call
/// raises "attempt to call global `X' (a nil value)", at the load or on the login screen. The
/// list only shrinks: a name built, or no longer reached, comes off it.
const UNBUILT_CALLED: &[&str] = &[
"EULAAccepted",
"GetBuildInfo",
"GetNumCharacters",
"GetSavedAccountName",
"SetCharCustomizeFrame",
"SetCharSelectModelFrame",
"SetCurrentScreen",
];
/// The movie frame's three handlers, which benilla's `MovieFrame` does not take: it plays no
/// movie. Each is a loader warning, `<frame>: SetScript("<handler>") unsupported`; the list only
/// shrinks.
const UNBUILT_HANDLERS: &[(&str, &str)] = &[
("MovieFrame", "OnMovieFinished"),
("MovieFrame", "OnMovieHideSubtitle"),
("MovieFrame", "OnMovieShowSubtitle"),
];
/// The frames each stock glue screen and dialog is, which the load must build.
const SCREENS: [&str; 8] = [
"GlueParent",
"AccountLogin",
"CharacterSelect",
"CharacterCreate",
"RealmList",
"GlueDialog",
"RealmWizard",
"AddonList",
];
/// The name a "nil value" call error names, in either of Lua's two spellings of it.
fn nil_call(error: &str) -> Option<&str> {
let (_, rest) = error.split_once("attempt to call ")?;
let (kind, rest) = rest.split_once(' ')?;
if !matches!(kind, "global" | "field" | "method") {
return None;
}
let rest = rest.strip_prefix('`').or_else(|| rest.strip_prefix('\''))?;
let (name, rest) = rest.split_once('\'')?;
rest.starts_with(" (a nil value)").then_some(name)
}
/// The glue state's functions, `reference/1.12-glue-surface.tsv`'s names.
fn glue_surface() -> BTreeSet<String> {
let path = format!(
"{}/../../reference/1.12-glue-surface.tsv",
env!("CARGO_MANIFEST_DIR")
);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("reading {path}: {e}"))
.lines()
.filter(|l| !l.starts_with('#') && !l.starts_with("scope\t"))
.filter_map(|l| l.split('\t').nth(2).map(str::to_string))
.collect()
}
#[test]
fn a_nil_call_names_its_function() {
assert_eq!(
nil_call("x.lua:1: attempt to call global `GetBuildInfo' (a nil value)"),
Some("GetBuildInfo")
);
assert_eq!(
nil_call("x.lua:1: attempt to call field 'Foo' (a nil value)"),
Some("Foo")
);
assert_eq!(
nil_call("x.lua:1: attempt to index global `X' (a nil value)"),
None
);
assert_eq!(nil_call("x.lua:1: bad argument #1"), None);
}
/// The stock GlueXML loads into the glue VM, `FRAMES_LOADED` fires and the login screen goes
/// up, and a few frames run: every screen's frame exists, and the only Lua errors are calls
/// of the glue functions [`UNBUILT_CALLED`] names, each of which is still unbuilt and still
/// called. Any other error, an XML problem or a missing widget method, fails here.
#[test]
fn the_stock_glue_loads_with_only_unbuilt_verbs_raising() {
let _ = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new_glue().unwrap();
s.set_screen_size(1024.0, 768.0);
let mut errors = load_glue(&mut s);
// `load_glue` reports the walk's handler errors; the rest are the screen's and the ticks'.
let _ = s.take_errors();
set_glue_screen(&mut s, GlueScreen::Login);
for _ in 0..3 {
s.tick(0.1);
}
s.resolve();
errors.extend(s.take_errors());
// Every row the session retained: the load's failures and warnings, and what raised.
errors.extend(s.diagnostics().into_iter().map(|d| d.message));
let surface = glue_surface();
let mut called = BTreeSet::new();
let mut handlers = BTreeSet::new();
let other: Vec<&String> = errors
.iter()
.filter(|e| {
if let Some(name) = nil_call(e).filter(|n| UNBUILT_CALLED.contains(n)) {
called.insert(name);
return false;
}
if let Some(h) = UNBUILT_HANDLERS.iter().find(|(frame, handler)| {
e.contains(&format!("{frame}: SetScript(\"{handler}\") unsupported"))
}) {
handlers.insert(*h);
return false;
}
true
})
.collect();
assert!(
other.is_empty(),
"the glue load raised {} error(s) or warning(s) that are not a call of an unbuilt \
glue function on UNBUILT_CALLED or a handler on UNBUILT_HANDLERS:\n{}",
other.len(),
other
.iter()
.map(|e| e.as_str())
.collect::<Vec<_>>()
.join("\n")
);
let listed: BTreeSet<&str> = UNBUILT_CALLED.iter().copied().collect();
let stale: Vec<&&str> = listed.difference(&called).collect();
assert!(
stale.is_empty(),
"UNBUILT_CALLED names no longer called unbuilt; take them off: {stale:?}"
);
let stale: Vec<&(&str, &str)> = UNBUILT_HANDLERS
.iter()
.filter(|h| !handlers.contains(*h))
.collect();
assert!(
stale.is_empty(),
"UNBUILT_HANDLERS entries the load no longer warns about; take them off: {stale:?}"
);
for name in UNBUILT_CALLED {
assert!(
surface.contains(*name),
"{name} is not a glue-state function"
);
assert!(
s.eval::<bool>(&format!("return {name} == nil")).unwrap(),
"{name} is built; take it off UNBUILT_CALLED"
);
}
for name in SCREENS {
assert!(
s.eval::<bool>(&format!(
"return type({name}) == 'table' and {name}.GetObjectType ~= nil"
))
.unwrap(),
"the glue load built no frame {name}"
);
}
assert_eq!(
s.eval::<(bool, bool)>(
"return AccountLogin:IsShown() == 1, CharacterSelect:IsShown() == 1"
)
.unwrap(),
(true, false),
"SET_GLUE_SCREEN \"login\" shows the login screen alone"
);
}
/// `GlueParent` fills the screen root, 768 tall and `768·a` wide (`0x41ad10`, from `0x46ab40`),
/// up to 16:9; past that `GlueParent_OnLoad` centres it 16:9 wide on the root's height.
#[test]
fn glue_parent_fills_the_root_up_to_sixteen_by_nine() {
let _ = benilla_formats::wow_data_or_skip!();
let rect = |aspect: f32| {
let mut s = UiScript::new_glue().unwrap();
s.set_screen_size(768.0 * aspect, 768.0);
let _ = load_glue(&mut s);
s.resolve();
s.eval::<(f32, f32, f32, f32)>(
"return GlueParent:GetLeft(), GlueParent:GetRight(), GlueParent:GetBottom(), \
GlueParent:GetTop()",
)
.unwrap()
};
let near = |a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)| {
[(a.0, b.0), (a.1, b.1), (a.2, b.2), (a.3, b.3)]
.iter()
.all(|(x, y)| (x - y).abs() < 0.01)
};
for aspect in [4.0 / 3.0, 16.0 / 10.0, 16.0 / 9.0] {
let got = rect(aspect);
assert!(
near(got, (0.0, 768.0 * aspect, 0.0, 768.0)),
"{aspect}: {got:?}"
);
}
let wide = 21.0 / 9.0;
let bar = (768.0 * wide - 768.0 * 16.0 / 9.0) / 2.0;
let got = rect(wide);
assert!(
near(got, (bar, 768.0 * wide - bar, 0.0, 768.0)),
"21:9: {got:?}"
);
}
}
+127 -17
View File
@@ -1,7 +1,8 @@
//! The UI session's lifecycle, all edges and nothing per-frame: `Startup` installs a boot VM
//! (strings and emote tokens); world entry builds a fresh VM and loads the in-game UI and every
//! addon onto it; leaving the world runs the reference's shutdown tail and installs a boot
//! VM again; `ReloadUI()` is the two edges back to back without leaving the world.
//! (strings and emote tokens), or the stock glue screens' VM under `WOW_GLUE_STOCK=1`; world entry
//! builds a fresh VM and loads the in-game UI and every addon onto it; leaving the world runs the
//! reference's shutdown tail and installs the glue-side VM again; `ReloadUI()` is the two edges
//! back to back without leaving the world.
use bevy::prelude::*;
@@ -13,26 +14,71 @@ use super::{load_ingame_ui, CursorPayloadHeld, PlayerUiHover, UiClock, UiKeyboar
use crate::ui_script::addons;
pub(crate) fn setup_script(world: &mut World) {
install_boot_vm(world);
install_glue_side_vm(world, super::glue::GlueScreen::Login);
}
/// Install the VM the world holds outside the world: the stock glue screens' under
/// [`super::glue::GlueStock`], put up on `screen`, else a boot VM.
fn install_glue_side_vm(world: &mut World, screen: super::glue::GlueScreen) {
if world
.get_resource::<super::glue::GlueStock>()
.is_some_and(|s| s.0)
{
install_glue_vm(world, screen);
} else {
install_boot_vm(world);
}
}
/// Build and install a boot VM, the one the world holds outside an in-game UI session: the
/// strings the feeds resolve lines from and the emote tokens `/`-commands are built from
/// (`ui_chat::commands`), no frames and no font objects. Installed at `Startup` and at every
/// [`end_ui_session`].
/// [`end_ui_session`], unless the stock glue screens stand there instead ([`install_glue_vm`]).
fn install_boot_vm(world: &mut World) {
let Some(script) = new_vm(world) else {
return;
};
if let Some(script) = strings_vm(world) {
world.insert_non_send_resource(script);
}
}
/// A boot VM's contents on a VM of their own: `GlobalStrings.lua` and the emote tokens, for a
/// reader that needs them where the world holds the glue VM. `None` (the world's VM removed) when
/// the VM cannot start.
pub(crate) fn strings_vm(world: &mut World) -> Option<UiScript> {
let script = new_vm(world, UiScript::new)?;
load_global_strings(world, &script);
load_emote_tokens(world, &script);
Some(script)
}
/// Build the glue VM, load the stock GlueXML onto it, install it and put `screen` up: the glue
/// builder `0x46a7b0` then `SET_GLUE_SCREEN` (`0x402b65` at startup, `0x402090` back from the
/// world). It holds what the glue state holds and nothing of the boot VM's: no `GlobalStrings.lua`,
/// no emote tokens.
fn install_glue_vm(world: &mut World, screen: super::glue::GlueScreen) {
let Some(mut script) = new_vm(world, UiScript::new_glue) else {
return;
};
seat_raster_seam_for_load(world, &mut script);
let start = std::time::Instant::now();
let failures = super::glue::load_glue(&mut script);
super::glue::finish_glue_load_log(&mut script);
super::glue::set_glue_screen(&mut script, screen);
info!(
"ui_script: stock glue up in {:.0} ms on \"{}\" ({} load failure(s))",
start.elapsed().as_secs_f32() * 1000.0,
screen.key(),
failures.len()
);
world.insert_non_send_resource(script);
}
/// A fresh VM on the process clock with the addon asset probes wired, or `None` (the world's VM
/// removed) when the VM cannot start.
fn new_vm(world: &mut World) -> Option<UiScript> {
let mut script = match UiScript::new() {
/// A fresh VM from `make` on the process clock with the addon asset probes wired, or `None` (the
/// world's VM removed) when the VM cannot start.
fn new_vm<E: std::fmt::Display>(
world: &mut World,
make: fn() -> Result<UiScript, E>,
) -> Option<UiScript> {
let mut script = match make() {
Ok(s) => s,
Err(e) => {
error!("ui_script: VM init failed: {e}");
@@ -155,7 +201,7 @@ pub(crate) fn arm_entry_ui_load(world: &mut World) {
/// `GlobalStrings.lua` and `Fonts.xml` as its first rows, and `ChatFrame.lua` the emote tokens.
fn mint_entry_vm(world: &mut World) {
crate::cvars::fold_dying_vm_cvars(world);
if let Some(script) = new_vm(world) {
if let Some(script) = new_vm(world, UiScript::new) {
world.insert_non_send_resource(script);
}
}
@@ -648,8 +694,24 @@ pub(crate) fn shutdown_ui_state(
/// shutdown tail, then end this session's Lua state, so no frame, global or addon upvalue reaches
/// the next login. The reference's state outlives `0x490bd0` and is replaced twice through
/// `0x703b80`, in `ShutdownGame` (at `0x491231`) and then in the glue build (`0x46a7b0`), so the
/// character screen runs on a state of its own. Ours is a fresh boot VM.
/// character screen runs on a state of its own. Ours is a fresh boot VM, or the stock glue's
/// ([`install_glue_side_vm`]).
pub(crate) fn end_ui_session(world: &mut World) {
close_ui_session(world);
// `0x401ee0`'s glue leg: `"charselect"` after a logout, `"login"` after a disconnect
// (`0x402084`); the state this exit enters says which.
let screen = match world
.get_resource::<State<crate::char_select::ClientState>>()
.map(|s| *s.get())
{
Some(crate::char_select::ClientState::CharSelect) => super::glue::GlueScreen::CharSelect,
_ => super::glue::GlueScreen::Login,
};
install_glue_side_vm(world, screen);
}
/// [`end_ui_session`] up to the new VM: the shutdown tail, the folds and the host values reset.
fn close_ui_session(world: &mut World) {
// A VM still parked (the load never ran) goes back into the world first.
unpark_boot_vm(world);
// An entry load still armed means no in-game UI was built, and the tail would overwrite the
@@ -690,8 +752,6 @@ pub(crate) fn end_ui_session(world: &mut World) {
if let Some(mut minimap) = world.get_resource_mut::<crate::minimap::MinimapWidget>() {
minimap.0 = None;
}
install_boot_vm(world);
}
/// A `ReloadUI()` waiting to run: set when [`crate::ui_logout`] drains
@@ -722,7 +782,10 @@ pub(crate) fn run_pending_reload(world: &mut World) {
return;
}
info!("ui_script: ReloadUI — ending the UI session and building a new one");
end_ui_session(world);
// A boot VM, never the glue's: the reload builds no glue (`0x495664` then `0x495669`), and the
// load replaces this VM at once.
close_ui_session(world);
install_boot_vm(world);
load_ingame_ui_seated(world, LoadSeat::Live);
// `UI_Init` ends by re-entering the world-enter cascade (`0x490168`), its sends included, but
// only with the active player set (`0x490166 je`): our own player's entity exists. Not
@@ -985,6 +1048,53 @@ mod tests {
);
}
/// Under `WOW_GLUE_STOCK=1` the world holds the glue VM where it holds a boot VM, at startup
/// on the login screen (`0x402b65`) and back from the world on the screen the exit enters
/// (`0x402090`); a `ReloadUI()` never builds the glue.
#[test]
fn the_stock_glue_stands_where_the_boot_vm_does() {
use crate::char_select::ClientState;
use benilla_ui::script::VmKind;
let shown = |world: &World, frame: &str| {
benilla_formats::wow_data().is_none()
|| world
.non_send_resource::<UiScript>()
.eval::<bool>(&format!("return {frame}:IsShown() == 1"))
.unwrap()
};
let mut world = World::new();
world.insert_resource(super::super::glue::GlueStock(true));
world.insert_resource(State::new(ClientState::Login));
setup_script(&mut world);
assert_eq!(
world.non_send_resource::<UiScript>().vm_kind(),
VmKind::Glue
);
assert!(shown(&world, "AccountLogin"), "the boot screen is login");
world.insert_resource(State::new(ClientState::CharSelect));
world.init_resource::<PendingEntryUiLoad>();
end_ui_session(&mut world);
assert_eq!(
world.non_send_resource::<UiScript>().vm_kind(),
VmKind::Glue
);
assert!(
shown(&world, "CharacterSelect"),
"a logout puts the character screen up"
);
world.insert_resource(State::new(ClientState::InWorld));
world.insert_resource(crate::run_mode::CaptureMode);
world.insert_resource(ReloadUiPending(true));
run_pending_reload(&mut world);
assert_eq!(
world.non_send_resource::<UiScript>().vm_kind(),
VmKind::InGame,
"the reload's VM is a boot VM, never the glue's"
);
}
/// The reference re-makes its Lua state inside `UI_Init` (`0x48fe97`).
#[test]
fn the_world_entry_builds_its_own_vm_rather_than_adopting_the_glue_phases() {
+35 -14
View File
@@ -1,7 +1,8 @@
//! `Logs\FrameXML.log` (`0x842ecc`): the UI loads' records ([`benilla_ui::status`]), written a
//! line at a time as the reference's log layer writes one (`0x65ac20`): the stamp
//! `"%u/%u %02u:%02u:%02u.%03u "` (`0x866aa0`), the text, then `"\r\n"` (`0x82edfc`). Deviation:
//! the file is `benilla-config/Logs/FrameXML.log`, since the install is read-only.
//! `"%u/%u %02u:%02u:%02u.%03u "` (`0x866aa0`), the text, then `"\r\n"` (`0x82edfc`). The glue
//! build writes its own `Logs\GlueXML.log` (`0x837024`, opened at `0x46a8ce`) the same way.
//! Deviation: the files are under `benilla-config/Logs/`, since the install is read-only.
use std::io::Write as _;
@@ -9,20 +10,32 @@ use bevy::prelude::*;
use benilla_ui::status::LogWrite;
/// The log's name, the reference's.
/// The in-game log's name, the reference's.
const FILE: &str = "FrameXML.log";
/// Write the VM's drains in the order they were made. A drain with no lines opens nothing, so a
/// clean UI load leaves the last load's file in place, as the reference's lazy open does
/// (`0x65a930`).
/// The glue log's name, the reference's.
const GLUE_FILE: &str = "GlueXML.log";
/// Write the in-game VM's drains in the order they were made. A drain with no lines opens
/// nothing, so a clean UI load leaves the last load's file in place, as the reference's lazy open
/// does (`0x65a930`).
pub(super) fn write(writes: Vec<LogWrite>) {
write_named(FILE, writes);
}
/// [`write`] for the glue VM's drains, into the glue's own log.
pub(super) fn write_glue(writes: Vec<LogWrite>) {
write_named(GLUE_FILE, writes);
}
fn write_named(file: &str, writes: Vec<LogWrite>) {
if writes.iter().all(|w| lines(w).is_empty()) {
return;
}
let Some(dir) = crate::local_state::logs_dir() else {
return; // hermetic capture, or no state folder
};
write_under(&dir, writes);
write_under(&dir, file, writes);
}
fn lines(write: &LogWrite) -> &[String] {
@@ -31,9 +44,9 @@ fn lines(write: &LogWrite) -> &[String] {
}
}
/// [`write`] into `dir`.
fn write_under(dir: &std::path::Path, writes: Vec<LogWrite>) {
let path = dir.join(FILE);
/// [`write_named`] into `dir`.
fn write_under(dir: &std::path::Path, file: &str, writes: Vec<LogWrite>) {
let path = dir.join(file);
for w in writes {
let (append, lines) = match w {
LogWrite::Rewrite(lines) => (false, lines),
@@ -84,11 +97,19 @@ mod tests {
fn a_load_rewrites_a_load_addon_appends_and_a_clean_load_touches_nothing() {
let dir = std::env::temp_dir().join(format!("benilla-framexml-log-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
write_under(&dir, vec![LogWrite::Rewrite(vec!["first load".into()])]);
write_under(&dir, vec![LogWrite::Rewrite(vec!["second load".into()])]);
write_under(&dir, vec![LogWrite::Append(vec!["on demand".into()])]);
write_under(
&dir,
FILE,
vec![LogWrite::Rewrite(vec!["first load".into()])],
);
write_under(
&dir,
FILE,
vec![LogWrite::Rewrite(vec!["second load".into()])],
);
write_under(&dir, FILE, vec![LogWrite::Append(vec!["on demand".into()])]);
assert_eq!(read(&dir), ["second load", "on demand"]);
write_under(&dir, vec![LogWrite::Rewrite(vec![])]);
write_under(&dir, FILE, vec![LogWrite::Rewrite(vec![])]);
assert_eq!(read(&dir), ["second load", "on demand"]);
let raw = std::fs::read_to_string(dir.join(FILE)).unwrap();
let first = raw.split("\r\n").next().unwrap();
+11 -4
View File
@@ -16,6 +16,8 @@ use benilla_world::schedule::WorldStage;
pub(crate) mod addons;
mod content;
pub(crate) mod extract;
/// The stock glue screens behind `WOW_GLUE_STOCK=1`.
pub(crate) mod glue;
mod input;
#[cfg(test)]
mod layer_tests;
@@ -289,6 +291,7 @@ impl Plugin for UiScriptPlugin {
// The quit root runs in `Last`: the close button's `AppExit` is written in `PostUpdate`.
crate::shutdown::on_app_exit(app, shutdown_on_exit.into_configs());
app.insert_resource(UiScaleCvar(default_ui_scale()))
.insert_resource(glue::GlueStock::from_env())
.init_resource::<UiAutoScaleCache>()
.init_resource::<UiFrameCost>()
.init_resource::<crate::bindings::WheelNotches>()
@@ -345,14 +348,17 @@ impl Plugin for UiScriptPlugin {
)
// In the feed phase, so an `OnUpdate` reads this frame's `GetFramerate()`.
.add_systems(Update, feed_framerate.in_set(UiFeed))
// The input pass hit-tests the rects the tick just resolved, and runs in-world only.
// The input pass hit-tests the rects the tick just resolved, and runs in-world only or
// on the stock glue screens.
.init_resource::<UiPassState>()
.init_resource::<PointerOverUiPanel>()
.add_systems(
Update,
(
extract::tick_script,
input::feed_ui_input.in_set(crate::char_select::InWorldGated),
input::feed_ui_input.run_if(
in_state(crate::char_select::ClientState::InWorld).or(glue::glue_ui_up),
),
// Off the walk that just ran, for the pick and the arbiter after the pass.
crate::vplates::seed_plate_hover,
)
@@ -412,8 +418,9 @@ fn arbitrate_pointer_over_ui(
/// The session lifecycle: the VM's birth, identity, death and reload.
mod lifecycle;
pub(crate) use lifecycle::{
end_ui_session, ingame_ui_up, run_pending_reload, setup_script, AddOnIdentity, LeaveWorldSweep,
LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending, WorldLeaveSweepMessage,
end_ui_session, ingame_ui_up, run_pending_reload, setup_script, strings_vm, AddOnIdentity,
LeaveWorldSweep, LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending,
WorldLeaveSweepMessage,
};
// Test-only: other modules' tests consume these, and a plain re-export would warn unused.
#[cfg(test)]
@@ -47,9 +47,29 @@ pub(super) fn core() -> Option<Addon> {
/// order, as the runner builds its paths (`0x6edbe5`, `0x6edcf4`, `0x6edd0c`) and dispatches each
/// in turn (`0x6edd51`).
pub(super) fn core_from(toc: &str) -> Addon {
let dir = TOC.rsplit_once('\\').map_or("", |(d, _)| d);
addon(
Toc::parse(toc)
toc_addon(NAME, TOC, toc)
}
/// The glue screens' table of contents, which the glue builder (`0x46a7b0`) hands to the same
/// `.toc` runner (`0x6edb90`) at `0x46a9b3`, once per glue build.
pub(super) const GLUE_TOC: &str = r"Interface\GlueXML\GlueXML.toc";
/// The name the stock glue files load under, as [`NAME`] is the in-game core's.
pub(super) const GLUE_NAME: &str = "GlueXML";
/// The glue screens' interface: the chain's own [`GLUE_TOC`] as an [`Addon`] over the chain, or
/// `None` when the chain has no such file.
pub(super) fn glue() -> Option<Addon> {
read(GLUE_TOC).map(|bytes| toc_addon(GLUE_NAME, GLUE_TOC, &benilla_ui::source::decode(&bytes)))
}
/// The interface `toc_text` lists, the `.toc` at chain path `toc_path`: its rows under the toc's
/// own directory, in its order.
fn toc_addon(name: &str, toc_path: &str, toc_text: &str) -> Addon {
let dir = toc_path.rsplit_once('\\').map_or("", |(d, _)| d);
named_addon(
name,
Toc::parse(toc_text)
.files
.iter()
.map(|f| format!("{dir}\\{f}"))
@@ -58,9 +78,15 @@ pub(super) fn core_from(toc: &str) -> Addon {
}
/// The stock interface as an [`Addon`] over the chain; `files` are full chain paths.
#[cfg(test)]
pub(super) fn addon(files: Vec<String>) -> Addon {
named_addon(NAME, files)
}
/// [`addon`] under `name`.
fn named_addon(name: &str, files: Vec<String>) -> Addon {
Addon::new(
NAME.to_string(),
name.to_string(),
Toc {
directives: Vec::new(),
files,
+37
View File
@@ -11,6 +11,10 @@
//!
//! [`UiScript::new_glue`]: super::UiScript::new_glue
use mlua::Lua;
use super::Model;
/// Which of the reference's two FrameScript states a VM is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VmKind {
@@ -80,3 +84,36 @@ impl VmKind {
}
}
}
/// The glue core's screen verbs (registrar `0x46ddf0`, table `0x8373b8`): the screen root's size
/// and the cursor in it. The glue and in-game states share the root, `0x41ad10`'s, 768 units tall
/// and `768·a` wide for the aspect `a` (the glue builder sets it at `0x46a828`, from `0x46ab40`:
/// `gxResolution`'s width over height with `widescreen` on, its default, else 4:3). The glue has
/// no `UIParent` and so no UI scale: `GetScreenWidth` (`0x46dd30`) and `GetScreenHeight`
/// (`0x46dd80`) are the in-game pair (`0x48b480`, `0x48b4d0`) without its `0x494590` division,
/// and `GetCursorPosition` (`0x46dad0`) is byte for byte the in-game `0x48b820`.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"GetScreenWidth",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.screen.width())
})?,
)?;
g.set(
"GetScreenHeight",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.screen.height())
})?,
)?;
g.set(
"GetCursorPosition",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.cursor_pos)
})?,
)?;
Ok(())
}
+6 -3
View File
@@ -646,10 +646,13 @@ impl UiScript {
/// The glue screens' VM, as the glue builder (`0x46a7b0`) leaves its state before GlueXML
/// loads: the base set and the shared registrars, with the glue event table and the glue frame
/// types ([`glue`]), and none of the in-game surface. The glue registrar's own four tables
/// (`0x46ddf0`, `0x4736c0`, `0x471a20`, `0x46f480`) are not built yet.
/// types ([`glue`]), and none of the in-game surface. Of the glue registrar's own four tables
/// (`0x46ddf0`, `0x4736c0`, `0x471a20`, `0x46f480`) only the screen verbs are built
/// ([`glue::install`]).
pub fn new_glue() -> mlua::Result<UiScript> {
Ok(Self::host(install_framescript(VmKind::Glue)?))
let lua = install_framescript(VmKind::Glue)?;
glue::install(&lua)?;
Ok(Self::host(lua))
}
fn host(lua: Lua) -> UiScript {
@@ -162,14 +162,11 @@ const NOT_YET: &[(&str, &[&str])] = &[
"GetBillingTimeRemaining",
"GetBillingTimeRested",
"GetBuildInfo",
"GetCursorPosition",
"GetLocale",
"GetMovieResolution",
"GetMovieSubtitles",
"GetNumAddOns",
"GetSavedAccountName",
"GetScreenHeight",
"GetScreenWidth",
"GetScriptMemory",
"GetServerName",
"HideCursor",
@@ -522,3 +519,30 @@ fn model_ffx_is_a_model_flagged_ffx() {
("Test/Xml.mdx".to_string(), 2.0)
);
}
/// The glue's screen verbs answer the screen root with no UI scale (`0x46dd30`, `0x46dd80`): the
/// in-game pair divides by `UIParent`'s scale (`0x494590`), the glue pair has none to divide by,
/// even when a frame is named `UIParent`. The cursor is the root's (`0x46dad0`).
#[test]
fn the_glue_screen_verbs_answer_the_root_unscaled() {
let root = (768.0_f32 * 16.0 / 9.0, 768.0_f32);
let read = |mut s: UiScript| {
s.set_screen_size(root.0, root.1);
s.run(r#"local p = CreateFrame("Frame", "UIParent") p:SetScale(0.5)"#)
.unwrap();
s.mouse_move(100.0, 200.0);
s.eval::<(f32, f32, f32, f32)>(
"local x, y = GetCursorPosition() return GetScreenWidth(), GetScreenHeight(), x, y",
)
.unwrap()
};
assert_eq!(
read(UiScript::new_glue().unwrap()),
(root.0, root.1, 100.0, 200.0)
);
assert_eq!(
read(UiScript::new().unwrap()),
(root.0 * 2.0, root.1 * 2.0, 100.0, 200.0),
"the in-game pair answers in UIParent's units"
);
}
+1
View File
@@ -262,6 +262,7 @@
- `WOW_FX_YAW` — benilla-app/src/capture/mod.rs
- `WOW_GHOST_PROBE` — benilla-app/src/death/mod.rs
- `WOW_GLUE_ROUNDTRIP` — benilla-app/src/char_select/mod.rs, benilla-world/src/bgwin.rs
- `WOW_GLUE_STOCK` — benilla-app/src/ui_script/glue.rs
- `WOW_GLYPH_CACHE` — benilla-app/src/ui_text/engine/gpu.rs
- `WOW_GM` — benilla-app/src/probe_shield.rs
- `WOW_GOD` — benilla-app/src/preflight.rs, benilla-app/src/probe_shield.rs