creatures keep their first corner, and characters never share files
- a creature walking a path no longer cuts its first corner: the path's first waypoint was dropped at decode (2342) - two characters whose names differ only in accented or non-Latin letters no longer share macros, bindings and SavedVariables; a file that failed to load is not overwritten at logout (2342) - custom chat channels survive a logout, a collapsed quest header keeps its quests watched, and a trackpad swipe scrolls as far as it adds up to (2342) - a hearth off a zeppelin no longer lands back on its deck, and an opened lockbox with loot left in it unlocks (2342) - every snapshot runs on Vulkan, in a container, before it ships (2344) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1051,9 +1051,19 @@ pub(super) fn save_addon_variables(script: &mut UiScript, identity: Option<&(Str
|
||||
continue;
|
||||
}
|
||||
let Some(dir) = dir else { continue };
|
||||
let body = script.saved_variables_text_for(names);
|
||||
let path = dir.join(format!("{name}.lua"));
|
||||
match crate::local_state::write_atomic(&path, &format!("{SAVED_HEADER}{body}")) {
|
||||
if script.saved_file_held(&path) {
|
||||
// It failed to load this session: its globals are the addon's defaults, and
|
||||
// writing them would replace the player's file with them.
|
||||
warn!(
|
||||
"ui_script: {} did not load this session — left as it is",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let mut body = SAVED_HEADER.as_bytes().to_vec();
|
||||
body.extend(script.saved_variables_bytes_for(names));
|
||||
match crate::local_state::write_atomic_bytes(&path, &body) {
|
||||
Ok(()) => info!("ui_script: wrote {}", path.display()),
|
||||
Err(e) => warn!("ui_script: cannot write {}: {e}", path.display()),
|
||||
}
|
||||
|
||||
@@ -650,16 +650,10 @@ 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 resize re-runs the bottom-stack manage pass (decision 1499). Anchors follow the new
|
||||
// screen rect by themselves; what does not is a seat somebody COMPUTED from the old
|
||||
// height — the open-bag stack starts a fresh column when the current one would run off
|
||||
// the top, and that decision is made from `GetScreenHeight()` at layout time. Without
|
||||
// this, dragging the window smaller leaves the bag columns wrapped for the old height
|
||||
// until the next bag opens. Existence-guarded: the pass is defined by `UIParent.xml`,
|
||||
// which is in-game UI, and this system also runs on the glue screens.
|
||||
// A resize re-runs everything the interface COMPUTED from the old screen size — see
|
||||
// [`super::manifest::on_screen_resized`].
|
||||
if resized {
|
||||
let _ = script
|
||||
.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end");
|
||||
super::manifest::on_screen_resized(&script);
|
||||
}
|
||||
script.tick(time.delta_secs());
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use bevy::window::PrimaryWindow;
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use super::{CursorPayloadHeld, PlayerUiClickConsumed, PlayerUiHover, UiKeyboardCapture};
|
||||
use crate::bindings::WheelNotches;
|
||||
use crate::textinput::{self, keymap, HostClipboard};
|
||||
|
||||
/// The pointer-side state [`feed_ui_input`] reads and writes, as one
|
||||
@@ -58,6 +59,28 @@ impl PointerFeed<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame's wheel travel into the pane under the cursor's `OnMouseWheel` — **one call per whole
|
||||
/// notch**, `arg1 = ±1`.
|
||||
///
|
||||
/// The travel is normalised to lines first ([`crate::bindings::wheel_lines`]) and the fraction
|
||||
/// carried in `notches`: a trackpad reports a gesture as a `Pixel` trickle across many frames, and
|
||||
/// passing each frame's raw delta fired the handler once per frame — the stock handlers act on
|
||||
/// the sign alone (`ScrollFrameTemplate_OnMouseWheel` scrolls half a pane per call), so a gentle
|
||||
/// swipe slammed the pane to its end. A mouse wheel's `Line` notch still fires once, as before.
|
||||
fn feed_wheel(
|
||||
script: &mut UiScript,
|
||||
notches: &mut WheelNotches,
|
||||
x: f32,
|
||||
y: f32,
|
||||
scroll: &AccumulatedMouseScroll,
|
||||
) {
|
||||
let whole = notches.feed(crate::bindings::wheel_lines(scroll.unit, scroll.delta.y));
|
||||
let step = whole.signum() as f32;
|
||||
for _ in 0..whole.unsigned_abs() {
|
||||
script.mouse_wheel(x, y, step);
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed the window's cursor + buttons + wheel + keyboard into the UI engine (after
|
||||
/// [`super::extract::tick_script`] has resolved this frame's rects), firing
|
||||
/// OnEnter/OnLeave/OnClick/OnMouseWheel and the EditBox
|
||||
@@ -73,7 +96,9 @@ pub(super) fn feed_ui_input(
|
||||
// winit has actually created the surface.
|
||||
window: Query<(&Window, Option<&bevy::window::RawHandleWrapper>), With<PrimaryWindow>>,
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
scroll: Res<AccumulatedMouseScroll>,
|
||||
// The frame's wheel travel, and the fraction of a notch carried between frames
|
||||
// ([`feed_wheel`]) — one param for clippy's argument ceiling.
|
||||
(scroll, mut notches): (Res<AccumulatedMouseScroll>, ResMut<WheelNotches>),
|
||||
// One [`PointerFeed`] (clippy's argument ceiling): the hover + click-consumed outputs this
|
||||
// pass writes, the world pick that routes the world-click payload legs (decision 0571), and
|
||||
// the payload-held mirror written for the Send-side world-click consumers.
|
||||
@@ -219,9 +244,7 @@ pub(super) fn feed_ui_input(
|
||||
script.mouse_button(x, y, name, false);
|
||||
}
|
||||
}
|
||||
if scroll.delta.y != 0.0 {
|
||||
script.mouse_wheel(x, y, scroll.delta.y);
|
||||
}
|
||||
feed_wheel(&mut script, &mut notches, x, y, &scroll);
|
||||
} else if !synthetic {
|
||||
// The OS pointer left the window: leave whatever frame was hovered (once, on the
|
||||
// Some→None transition) and — every frame it stays outside — clear any armed press/drag
|
||||
@@ -417,3 +440,93 @@ pub(super) fn feed_ui_input(
|
||||
// bare-key window toggles — moved into the command registry (`crate::bindings::commands`,
|
||||
// decision 0997): one chord→command table, rebindable, with 0585's modifier law enforced once
|
||||
// in the dispatch instead of per branch here.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::input::mouse::MouseScrollUnit;
|
||||
|
||||
/// A bare VM with one wheel-enabled pane at the screen's centre whose `OnMouseWheel` counts
|
||||
/// its calls and sums its `arg1`s.
|
||||
fn wheel_pane() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "WheelProbe")
|
||||
f:SetWidth(200) f:SetHeight(200) f:SetPoint("CENTER", nil, "CENTER", 0, 0)
|
||||
f:EnableMouseWheel(true)
|
||||
f:SetScript("OnMouseWheel", function()
|
||||
WheelCalls = (WheelCalls or 0) + 1
|
||||
WheelSum = (WheelSum or 0) + arg1
|
||||
end)
|
||||
f:Show()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s
|
||||
}
|
||||
|
||||
fn calls(s: &UiScript) -> (i64, f64) {
|
||||
s.eval::<(Option<i64>, Option<f64>)>("return WheelCalls, WheelSum")
|
||||
.map(|(c, v)| (c.unwrap_or(0), v.unwrap_or(0.0)))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn frame_of(unit: MouseScrollUnit, dy: f32) -> AccumulatedMouseScroll {
|
||||
AccumulatedMouseScroll {
|
||||
unit,
|
||||
delta: Vec2::new(0.0, dy),
|
||||
}
|
||||
}
|
||||
|
||||
/// **A gentle trackpad swipe is not a notch a frame.** Ten frames of a small `Pixel` delta —
|
||||
/// a tenth of a line apiece at Bevy's factor, one line in total — must fire the pane's
|
||||
/// `OnMouseWheel` at most the ONE notch they add up to. Fed raw, it fired ten times, and a
|
||||
/// stock `ScrollFrameTemplate_OnMouseWheel` moves half a pane per call off the sign alone.
|
||||
#[test]
|
||||
fn a_trackpad_trickle_fires_only_the_notches_it_adds_up_to() {
|
||||
let mut s = wheel_pane();
|
||||
let mut notches = WheelNotches::default();
|
||||
let step = MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR / 10.0;
|
||||
for _ in 0..10 {
|
||||
feed_wheel(
|
||||
&mut s,
|
||||
&mut notches,
|
||||
512.0,
|
||||
384.0,
|
||||
&frame_of(MouseScrollUnit::Pixel, step),
|
||||
);
|
||||
}
|
||||
let (n, sum) = calls(&s);
|
||||
assert!(
|
||||
n <= 1,
|
||||
"ten frames adding up to one line fired {n} wheel calls (sum {sum})"
|
||||
);
|
||||
assert!(sum <= 1.0, "…and they may move at most one notch: {sum}");
|
||||
}
|
||||
|
||||
/// A mouse wheel's notch arrives as one `Line` delta and fires exactly once, `arg1 = 1`.
|
||||
#[test]
|
||||
fn a_line_notch_fires_once() {
|
||||
let mut s = wheel_pane();
|
||||
let mut notches = WheelNotches::default();
|
||||
feed_wheel(
|
||||
&mut s,
|
||||
&mut notches,
|
||||
512.0,
|
||||
384.0,
|
||||
&frame_of(MouseScrollUnit::Line, 1.0),
|
||||
);
|
||||
assert_eq!(calls(&s), (1, 1.0));
|
||||
feed_wheel(
|
||||
&mut s,
|
||||
&mut notches,
|
||||
512.0,
|
||||
384.0,
|
||||
&frame_of(MouseScrollUnit::Line, -1.0),
|
||||
);
|
||||
assert_eq!(calls(&s), (2, 0.0), "and the other way is a notch down");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use bevy::prelude::*;
|
||||
use benilla_assets::LockRecover;
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use super::manifest::silenced_ui_load;
|
||||
use super::{
|
||||
load_font_registry, load_ingame_ui, CursorPayloadHeld, PlayerUiHover, UiClock,
|
||||
UiKeyboardCapture,
|
||||
@@ -584,43 +585,54 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
// through its whole load edge and only converged a frame later off whatever poll the caller
|
||||
// had written to survive it.
|
||||
seat_raster_seam_for_load(world, &mut script);
|
||||
let _ = load_ingame_ui(&mut script, identity.as_ref(), &roster, 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.
|
||||
//
|
||||
// **And the chat cache restores inside it, between `VARIABLES_LOADED` and `PLAYER_LOGIN`** —
|
||||
// the reference's own slot for the reader's `UPDATE_CHAT_WINDOWS` + `UPDATE_CHAT_COLOR` burst
|
||||
// (`0x4900d6`, after `0x4900b2` and before `0x490959`; decisions 2119 and 2125). It is the
|
||||
// sole firer of `UPDATE_CHAT_WINDOWS`, which is the only thing that registers a chat frame for
|
||||
// any `CHAT_MSG_*` (ref `ChatFrame.lua` l.1261-1273) — as an `Update` system it landed after
|
||||
// the session's first chat had already been routed, and the login MOTD went to a window
|
||||
// registered for nothing.
|
||||
// The plate pair is read out of the world FIRST: the second closure borrows `world` for the
|
||||
// chat-cache restore, and a `Copy` of two bools costs nothing next to fighting that borrow.
|
||||
// Absent in a bare test world, where "both off" is also the resource's own default.
|
||||
// The minimap zoom pair and the plate pair are read out of the world FIRST: the bracket's body
|
||||
// borrows `world` for the chat-cache restore, and two `Copy` pairs cost nothing next to
|
||||
// fighting that borrow. The plates are absent in a bare test world, where "both off" is also
|
||||
// the resource's own default.
|
||||
let zoom = {
|
||||
let z = world.resource::<crate::minimap::MinimapZoom>();
|
||||
(z.outdoor, z.inside)
|
||||
};
|
||||
let plates = world
|
||||
.get_resource::<crate::vplates::VPlateMode>()
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
finish_ui_load_with(
|
||||
&mut script,
|
||||
// `NAMEPLATES_ON` / `FRIENDNAMEPLATES_ON` (2132). This seat, and not the `Update` feed
|
||||
// beside it, is what fixes the bug: `UIParent_OnEvent`'s first `UpdateNameplates()` runs
|
||||
// inside the `VARIABLES_LOADED` fired at the end of this very call.
|
||||
|script| crate::vplates::push_plate_globals(script, plates),
|
||||
|script| {
|
||||
crate::ui_chat::restore_chat_looks(world, script);
|
||||
},
|
||||
);
|
||||
// **The whole load edge is silent** — the TOC walk, every addon, the saved variables and
|
||||
// `PLAYER_LOGIN`, the span `0x48fbf0` brackets itself across (`0x48fbfa` → `0x49016d`). Both
|
||||
// login and `ReloadUI` come through here; see [`silenced_ui_load`].
|
||||
silenced_ui_load(&mut script, |script| {
|
||||
let _ = load_ingame_ui(script, identity.as_ref(), &roster, 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.
|
||||
script.set_minimap_zoom(zoom.0, zoom.1);
|
||||
// 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.
|
||||
//
|
||||
// **And the chat cache restores inside it, between `VARIABLES_LOADED` and `PLAYER_LOGIN`**
|
||||
// — the reference's own slot for the reader's `UPDATE_CHAT_WINDOWS` + `UPDATE_CHAT_COLOR`
|
||||
// burst (`0x4900d6`, after `0x4900b2` and before `0x490959`; decisions 2119 and 2125). It
|
||||
// is the sole firer of `UPDATE_CHAT_WINDOWS`, which is the only thing that registers a chat
|
||||
// frame for any `CHAT_MSG_*` (ref `ChatFrame.lua` l.1261-1273) — as an `Update` system it
|
||||
// landed after the session's first chat had already been routed, and the login MOTD went to
|
||||
// a window registered for nothing.
|
||||
finish_ui_load_with(
|
||||
script,
|
||||
// `NAMEPLATES_ON` / `FRIENDNAMEPLATES_ON` (2132). This seat, and not the `Update`
|
||||
// feed beside it, is what fixes the bug: `UIParent_OnEvent`'s first
|
||||
// `UpdateNameplates()` runs inside the `VARIABLES_LOADED` fired at the end of this
|
||||
// very call.
|
||||
|script| crate::vplates::push_plate_globals(script, plates),
|
||||
|script| {
|
||||
crate::ui_chat::restore_chat_looks(world, script);
|
||||
},
|
||||
);
|
||||
});
|
||||
// **Say it out loud when an addon didn't load** (decision 1495). Every failure the walk found
|
||||
// is retained now, but a log nobody knows to open does not fix silence — and silence is the
|
||||
// actual defect B293 reports: *"there are a lot of addons that still doesn't work"*, with
|
||||
@@ -961,7 +973,7 @@ pub(crate) fn end_ui_session(world: &mut World) {
|
||||
// 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);
|
||||
// The chat cache, on the same terms and for the same reason (decision NNNN): it composes the
|
||||
// The chat cache, on the same terms and for the same reason (decision 2184): it composes the
|
||||
// player's file out of the DYING VM, and `/reload` never crosses the `OnExit(InWorld)` edge
|
||||
// its flush used to hang on — so a window moved in the last second before a reload was
|
||||
// written nowhere and re-read stale from disk.
|
||||
|
||||
@@ -150,6 +150,69 @@ pub(super) fn apply_buff_durations(script: &UiScript) -> Result<(), String> {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// **What a screen-size change re-runs** — called from `extract::tick_script`'s resize arm, the
|
||||
/// frame [`UiScript::set_screen_size`] reports a change on. Anchors follow the new screen rect by
|
||||
/// themselves; what does not is a size or seat somebody COMPUTED from the old one.
|
||||
///
|
||||
/// The bottom-stack manage pass (decision 1499): the open-bag stack starts a fresh column when
|
||||
/// the current one would run off the top, and that decision is made from `GetScreenHeight()` at
|
||||
/// layout time. Without this, dragging the window smaller leaves the bag columns wrapped for the
|
||||
/// old height until the next bag opens. Existence-guarded: the pass is defined by `UIParent.xml`,
|
||||
/// which is in-game UI, and the resize arm also runs on the glue screens.
|
||||
///
|
||||
/// **And the three full-screen quads the stock files size once** — a stated repair of a reference
|
||||
/// gap, in the same posture as [`install_durability_reseat`]: nothing of Blizzard's is edited.
|
||||
/// `WorldMapFrame_OnLoad` sizes `BlackoutWorld` and `CinematicFrame_OnLoad` sizes
|
||||
/// `UpperBlackBar`/`LowerBlackBar` from `GetScreenWidth()`/`GetScreenHeight()` at load, and no
|
||||
/// stock handler touches them again (no `DISPLAY_SIZE_CHANGED` listener) — the reference could
|
||||
/// afford that because its screen changed only across a restart. 2242 fixed the load edge; this
|
||||
/// is the other edge: after a window resize or a `uiScale` change the blackout stopped short of
|
||||
/// the new screen and the world showed through beside the map. [`FULLSCREEN_QUADS_RESEAT`] is the
|
||||
/// two OnLoads' own arithmetic re-run, so a quad after a resize is exactly what a fresh load at
|
||||
/// the new size would have made. A stateless chunk rather than an installed hook, so a `ReloadUI`
|
||||
/// has nothing to re-install and nothing can stack.
|
||||
pub(super) fn on_screen_resized(script: &UiScript) {
|
||||
let _ = script.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end");
|
||||
if let Err(e) = script.run(FULLSCREEN_QUADS_RESEAT) {
|
||||
error!("ui_script: full-screen quad re-seat: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `WorldMapFrame.lua` `WorldMapFrame_OnLoad` l.19-28 and `CinematicFrame.lua`
|
||||
/// `CinematicFrame_OnLoad` l.5-19, verbatim in their arithmetic, each existence-guarded (the
|
||||
/// glue screens have neither). One addition to the letterbox: the stock OnLoad does nothing below
|
||||
/// 4:3, which leaves the bars at `CinematicFrame.xml`'s declared `1024 x 128` — so a resize to
|
||||
/// below 4:3 puts that declared size back, rather than keeping a wide screen's recompute.
|
||||
const FULLSCREEN_QUADS_RESEAT: &str = r#"
|
||||
if BlackoutWorld then
|
||||
local width = GetScreenWidth()
|
||||
local height = GetScreenHeight()
|
||||
if ( width / height < 4 / 3 ) then
|
||||
width = width * 1.25
|
||||
height = height * 1.25
|
||||
end
|
||||
BlackoutWorld:SetWidth( width )
|
||||
BlackoutWorld:SetHeight( height )
|
||||
end
|
||||
if UpperBlackBar and LowerBlackBar then
|
||||
local width = GetScreenWidth()
|
||||
local height = GetScreenHeight()
|
||||
local barWidth, blackBarHeight = 1024, 128
|
||||
if ( width / height > 4 / 3 ) then
|
||||
local desiredHeight = width / 2
|
||||
if ( desiredHeight > height ) then
|
||||
desiredHeight = height
|
||||
end
|
||||
barWidth = width
|
||||
blackBarHeight = ( height - desiredHeight ) / 2
|
||||
end
|
||||
UpperBlackBar:SetHeight( blackBarHeight )
|
||||
UpperBlackBar:SetWidth( barWidth )
|
||||
LowerBlackBar:SetHeight( blackBarHeight )
|
||||
LowerBlackBar:SetWidth( barWidth )
|
||||
end
|
||||
"#;
|
||||
|
||||
/// **A stated divergence from the reference, installed rather than edited in** (1751 window 4).
|
||||
///
|
||||
/// The reference seats `DurabilityFrame` 20 further in when one of its three side glyphs is up
|
||||
@@ -186,6 +249,35 @@ if DurabilityFrame and not DurabilityFrame.benillaReseat then
|
||||
end
|
||||
"#;
|
||||
|
||||
/// **The UI load's sound-suppression bracket — the one site both load paths go through.**
|
||||
///
|
||||
/// `CGGameUI::Initialize 0x48fbf0` brackets ITSELF in the counted suppression scope —
|
||||
/// `0x48fbfa call 0x458f50` on entry, `0x49016d call 0x458f60` on exit — across the TOC walk,
|
||||
/// Bindings.xml, the AddOns, the saved variables and the world-enter cascade it calls at
|
||||
/// `0x490168` (`PLAYER_LOGIN`), so both of its callers (login `0x48f681` and `/reloadui`
|
||||
/// `0x495669`) load without a sound. The only reader of that depth is `PlaySoundByName
|
||||
/// 0x458030`, which drops the call outright (wow-re
|
||||
/// `system/ui/scratch/framexml-load-sound-suppression.md`).
|
||||
///
|
||||
/// This is the mechanism, not a workaround for one noisy handler: stock `TargetFrame_OnHide`
|
||||
/// really does fire at load (the frame ships shown and its OnLoad hides it) and really does call
|
||||
/// `PlaySound("INTERFACESOUND_LOSTTARGETUNIT")`. The engine throws it away. Decision 1033 reached
|
||||
/// the right rule from the director's ear; this is the binary agreeing.
|
||||
///
|
||||
/// One function rather than a push/pop pair at each caller because the production edge
|
||||
/// ([`super::load_ingame_ui_on_world_entry`]) spent its life unbracketed while the tests' whole-
|
||||
/// manifest load was — every `/reload` played the lost-target click. Generic over the borrow so
|
||||
/// the `&UiScript` test loader and the owning production edge share it.
|
||||
pub(super) fn silenced_ui_load<S: std::borrow::Borrow<UiScript>, R>(
|
||||
script: &mut S,
|
||||
body: impl FnOnce(&mut S) -> R,
|
||||
) -> R {
|
||||
script.borrow().push_sound_suppression();
|
||||
let out = body(script);
|
||||
script.borrow().pop_sound_suppression();
|
||||
out
|
||||
}
|
||||
|
||||
/// Load benilla's own default UI — every file [`MANIFEST`] names — through the engine-free loader.
|
||||
/// This is our content (MIT/Apache), committed and **compiled into the binary**
|
||||
/// ([`super::content`], decision 1175); a dev build still prefers the copy on disk, so editing a
|
||||
@@ -219,20 +311,12 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
|
||||
// have been registered here with real consumers since 1493/1502; the probes simply never had
|
||||
// them, and 218 tests found that out the hour this row went on the manifest.
|
||||
script.register_cvars(crate::cvars::registered_pairs());
|
||||
// **Silent, the way the client's own load is.** `0x48fbf0` brackets ITSELF in the counted
|
||||
// sound-suppression scope — `0x48fbfa call 0x458f50` on entry, `0x49016d call 0x458f60` on
|
||||
// exit — across the TOC walk, Bindings.xml and the AddOns, so both of its callers (login
|
||||
// `0x48f681` and `/reloadui` `0x495669`) load without a sound. The only reader of that depth
|
||||
// is `PlaySoundByName 0x458030`, which drops the call outright.
|
||||
//
|
||||
// This is the mechanism, not a workaround for one noisy handler: stock `TargetFrame_OnHide`
|
||||
// really does fire at load (the frame ships shown and its OnLoad hides it) and really does
|
||||
// call `PlaySound("INTERFACESOUND_LOSTTARGETUNIT")`. The engine throws it away. Decision 1033
|
||||
// reached the right rule from the director's ear; this is the binary agreeing.
|
||||
script.push_sound_suppression();
|
||||
let mut failures = load_manifest(script, &Addon::builtin().toc.files);
|
||||
failures.extend(bootstrap_positions(script));
|
||||
script.pop_sound_suppression();
|
||||
// Silent, the way the client's own load is — see [`silenced_ui_load`].
|
||||
let mut failures = silenced_ui_load(&mut &*script, |script| {
|
||||
let mut failures = load_manifest(script, &Addon::builtin().toc.files);
|
||||
failures.extend(bootstrap_positions(script));
|
||||
failures
|
||||
});
|
||||
// **A handler that raised DURING the walk is a load failure.** The loader's own report holds
|
||||
// the raises it dispatched itself (a `<Script file=>` chunk, an OnLoad); a raise one call
|
||||
// deeper — an OnLoad that `Show()`s a frame whose OnShow indexes a global its file has not
|
||||
|
||||
@@ -457,6 +457,7 @@ impl Plugin for UiScriptPlugin {
|
||||
// the hover recorder is armed; the producer owns the resource so any minimal app that
|
||||
// runs the UI pass (the extract tests) has it.
|
||||
.init_resource::<UiFrameCost>()
|
||||
.init_resource::<crate::bindings::WheelNotches>()
|
||||
.init_resource::<UiCostWanted>()
|
||||
.init_resource::<PointerOverUi>()
|
||||
.init_resource::<SyntheticPointer>()
|
||||
|
||||
@@ -2981,7 +2981,7 @@ fn what_the_combat_page_writes_survives_a_restart() {
|
||||
s.run("BenillaOptionsFrameContainerBodyCombatRowHonorGainedCheck:Click()")
|
||||
.unwrap();
|
||||
|
||||
let saved = s.saved_variables_text();
|
||||
let saved = String::from_utf8(s.saved_variables_bytes()).unwrap();
|
||||
assert!(
|
||||
saved.contains("COMBAT_TEXT_SHOW_HONOR_GAINED = \"0\""),
|
||||
"the toggle is in the saved text:\n{saved}"
|
||||
@@ -3531,7 +3531,7 @@ fn what_the_interface_page_writes_survives_a_restart() {
|
||||
s.run("BenillaOptionsFrameContainerBodyInterfaceRowAutoQuestWatchCheck:Click()")
|
||||
.unwrap();
|
||||
|
||||
let saved = s.saved_variables_text();
|
||||
let saved = String::from_utf8(s.saved_variables_bytes()).unwrap();
|
||||
assert!(
|
||||
saved.contains("AUTO_QUEST_WATCH = \"0\""),
|
||||
"the toggle is in the saved text under the reference's name:\n{saved}"
|
||||
@@ -3653,7 +3653,7 @@ fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
|
||||
|
||||
// Restart: the fresh tree comes up on the shipped geometry, the chunk replaces the value, and
|
||||
// VARIABLES_LOADED is what puts the bar where the value says.
|
||||
let saved = s.saved_variables_text();
|
||||
let saved = String::from_utf8(s.saved_variables_bytes()).unwrap();
|
||||
assert!(
|
||||
saved.contains("SHOW_BUFF_DURATIONS = \"1\""),
|
||||
"the switch is in the saved text:\n{saved}"
|
||||
|
||||
@@ -71,6 +71,7 @@ fn log(entries: Vec<QuestLogEntryView>) -> QuestLogState {
|
||||
QuestLogState {
|
||||
num_quests: entries.len() as u32,
|
||||
entries,
|
||||
hidden_quest_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ fn eight_entries() -> QuestLogState {
|
||||
QuestLogState {
|
||||
num_quests: 8,
|
||||
entries,
|
||||
hidden_quest_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1065,6 +1066,7 @@ fn overflowing_entry() -> QuestLogState {
|
||||
.collect();
|
||||
QuestLogState {
|
||||
num_quests: 1,
|
||||
hidden_quest_ids: Vec::new(),
|
||||
entries: vec![QuestLogEntryView {
|
||||
quest_id: 1,
|
||||
title: "A Very Long Quest".into(),
|
||||
|
||||
@@ -810,7 +810,7 @@ fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
|
||||
0,
|
||||
"the click must write the SAVED global, not just the engine mask"
|
||||
);
|
||||
let text = s.saved_variables_text_for(&info().saved_variables);
|
||||
let text = String::from_utf8(s.saved_variables_bytes_for(&info().saved_variables)).unwrap();
|
||||
assert!(
|
||||
text.contains("TRAINER_FILTER_UNAVAILABLE = 0"),
|
||||
"the file carries the toggle: {text}"
|
||||
|
||||
@@ -1722,3 +1722,63 @@ fn the_entry_load_seeds_a_record_the_feed_cannot_take_away() {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
// ─────────────────── The production load is silent (the UI-load sound bracket) ───────────────────
|
||||
|
||||
/// Every kit name the live VM has queued since the last drain.
|
||||
fn taken_kit_names(world: &mut World) -> Vec<String> {
|
||||
world
|
||||
.get_non_send_resource_mut::<benilla_ui::script::UiScript>()
|
||||
.expect("in-world VM")
|
||||
.take_sounds()
|
||||
.into_iter()
|
||||
.filter_map(|r| match r {
|
||||
benilla_ui::script::SoundRequest::KitName(n) => Some(n),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// **A login and a `/reload` load without a sound** — `0x48fbf0` brackets itself in the counted
|
||||
/// suppression scope (`0x48fbfa` → `0x49016d`) across the TOC walk, the addons, the saved
|
||||
/// variables and the login cascade, and both of its callers (login `0x48f681`, `/reloadui`
|
||||
/// `0x495669`) go through it (wow-re `system/ui/scratch/framexml-load-sound-suppression.md`).
|
||||
///
|
||||
/// Stock `TargetFrame_OnLoad` → `TargetFrame_Update` → `Hide()` → `TargetFrame_OnHide` really does
|
||||
/// call `PlaySound("INTERFACESOUND_LOSTTARGETUNIT")` at load; the engine drops it. Before the
|
||||
/// production edge carried the bracket only the tests' whole-manifest load did, and every
|
||||
/// `/reload` played the lost-target click.
|
||||
#[test]
|
||||
fn a_login_and_a_reload_load_without_the_lost_target_sound() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_probe("load-silent");
|
||||
let mut world = booted_world();
|
||||
|
||||
log_in_as(&mut world, "Onehunter", 1);
|
||||
assert!(
|
||||
frame_exists(&world, "TargetFrame"),
|
||||
"the stock target frame loaded"
|
||||
);
|
||||
let at_login = taken_kit_names(&mut world);
|
||||
assert!(
|
||||
!at_login
|
||||
.iter()
|
||||
.any(|n| n == "INTERFACESOUND_LOSTTARGETUNIT"),
|
||||
"the login load must be silent — the lost-target click was queued: {at_login:?}"
|
||||
);
|
||||
|
||||
reload(&mut world, crate::char_select::ClientState::InWorld);
|
||||
let at_reload = taken_kit_names(&mut world);
|
||||
assert!(
|
||||
!at_reload
|
||||
.iter()
|
||||
.any(|n| n == "INTERFACESOUND_LOSTTARGETUNIT"),
|
||||
"the /reload load must be silent — the lost-target click was queued: {at_reload:?}"
|
||||
);
|
||||
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
@@ -915,3 +915,52 @@ fn a_stale_layout_row_cannot_seat_a_stock_frame() {
|
||||
assert_eq!(saved[0], row(), "unchanged through the round trip");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The full-screen quads follow a resize** — `BlackoutWorld` behind the map, and the cinematic
|
||||
/// letterbox bars. Stock `WorldMapFrame_OnLoad` and `CinematicFrame_OnLoad` size them ONCE from
|
||||
/// `GetScreenWidth()`/`GetScreenHeight()` and nothing in FrameXML touches them again (no
|
||||
/// `DISPLAY_SIZE_CHANGED` listener); 2242 fixed the load edge, and this is the other edge. A window
|
||||
/// grown after the load left the quad at the old size and the world showed through beside it.
|
||||
#[test]
|
||||
fn the_fullscreen_quads_follow_a_resize() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
// A player exists by the time the manifest loads (decision 1848; see the test above).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.resolve();
|
||||
|
||||
let width = |s: &UiScript, f: &str| s.eval::<f64>(&format!("return {f}:GetWidth()")).unwrap();
|
||||
assert_eq!(width(&s, "BlackoutWorld"), 1600.0, "the load edge sized it");
|
||||
|
||||
// Grown, still wider than 4:3 so every one of the three takes its recompute branch.
|
||||
assert!(s.set_screen_size(1920.0, 1080.0), "the size changed");
|
||||
super::manifest::on_screen_resized(&s);
|
||||
s.resolve();
|
||||
|
||||
let screen = s.eval::<f64>("return GetScreenWidth()").unwrap();
|
||||
assert_eq!(screen, 1920.0);
|
||||
for quad in ["BlackoutWorld", "UpperBlackBar", "LowerBlackBar"] {
|
||||
assert_eq!(
|
||||
width(&s, quad),
|
||||
screen,
|
||||
"{quad} still spans the OLD screen after a resize — the world shows through beside it"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return BlackoutWorld:GetHeight()").unwrap(),
|
||||
1080.0,
|
||||
"…and the blackout is the new height too"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user