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:
Sam
2026-09-23 12:19:22 +07:00
co-authored by Claude Opus 5.5
parent 9c49f10882
commit 293b8e57fb
60 changed files with 1876 additions and 425 deletions
+12 -2
View File
@@ -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());
}
+117 -4
View File
@@ -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");
}
}
+46 -34
View File
@@ -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.
+98 -14
View File
@@ -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
+1
View File
@@ -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());
}