brightness saves, the glue box holds still, and Questie's notes hover

- the Brightness slider in the options window changes the picture and keeps its value; it was writing to no setting at all, so every reopen read 50% again (2186)
- the login, realm and character screens share one letterboxed frame, so clicking down the character roster no longer slides the backdrop and every panel over it; at 4:3 nothing changes (2187)
- Questie's world-map notes show their tooltip on hover: the note hands the tooltip a frame where an anchor name goes, which benilla refused with a script error and the reference reads as the default anchor (2176)
- with OmniCC installed, a warrior in a stance sees the countdown on action bar 1 again: raising that bar's buttons by script carried their cooldown sweep up over the count (2189)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 21:06:04 +07:00
co-authored by Claude Fable 5.1
parent fcb159c151
commit 9caacad153
15 changed files with 698 additions and 313 deletions
+14 -1
View File
@@ -980,6 +980,19 @@
-- The third kind is what Show Helm / Show Cloak are: the preference is a bit of the
-- character's own server-side PLAYER_FLAGS, so there is nothing local to save and nothing
-- to restore at load — the getter IS the state (decision 1472).
-- **Does this row have somewhere to put a value?** The three stores of 1134, asked in the
-- order `OptionsRow_Set` tries them. A row with none is display-only and must not be
-- written.
--
-- It exists because the slider's OnValueChanged guarded on `row.cvar or row.uvar` — two of
-- the three — and so wrote nothing for a row whose store is an engine pair. The readout
-- still moved, which is the worst shape that bug can take: the control looks alive, the
-- setting never leaves the window, and nothing anywhere errors. Asking one predicate keeps
-- every caller in step with the setter by construction instead of by memory.
function OptionsRow_HasStore(row)
return row.uvar or row.setFunc or row.cvar
end
function OptionsRow_Get(row)
if row.uvar then return getglobal(row.uvar) end
if row.getFunc then
@@ -1441,7 +1454,7 @@
end
end
getglobal(control:GetName() .. "Value"):SetText(OptionsRow_FormatValue(row, value))
if (row.cvar or row.uvar) and not row.refreshing then
if OptionsRow_HasStore(row) and not row.refreshing then
-- Two decimals cover every page's grid (the 5% volumes, uiScale's 0.01, whole
-- yards); trailing zeros stripped so a value at the registered default
-- compares equal to it as a STRING and stays out of the config file ("0.40"
+16
View File
@@ -2915,6 +2915,22 @@ mod tests {
"weatherDensity {wrote}"
);
}
// Brightness (2182): the CVar's own unit is the ramp exponent, NOT the slider's offset —
// `SetGamma` does the `1 - v` on the way in, so what arrives here is already `gamma`.
// Both ends of the stock slider land whole, and the consumer's clamp holds the values the
// reference accepts without one (`SetGamma(5)` writes -4 there).
for (wrote, want) in [("1.000000", 1.0), ("0.500000", 0.5), ("1.500000", 1.5)] {
assert!(apply_to_knobs("gamma", wrote, &mut knobs));
assert_eq!(knobs.display_gamma.0, want, "gamma {wrote}");
}
assert!(apply_to_knobs("gamma", "-4.000000", &mut knobs));
assert_eq!(
knobs.display_gamma.0,
*crate::ui_gamma::GAMMA_RANGE.start(),
"a negative exponent clamps at the consumer, where it cannot blank the screen"
);
assert!(apply_to_knobs("gamma", "99", &mut knobs));
assert_eq!(knobs.display_gamma.0, *crate::ui_gamma::GAMMA_RANGE.end());
assert_eq!(knobs.clutter.frill_density(), 32.0);
// And the pair is NAMED as a pair, in the registered spelling and the lowercased one, so
// `$WOW_CLUTTER_DENSITY` cannot take one spelling of this knob for the session and leave
+7 -3
View File
@@ -251,15 +251,19 @@ pub(crate) fn glue_canvas() -> (GlueCanvas, FocusPolicy, Node) {
/// only on change keeps it off Bevy's `Changed<Node>` path on the ~every frame nothing moves.
pub(crate) fn fit_glue_canvas(
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
scene: Option<Res<crate::portrait::CreateScene>>,
mut canvases: Query<&mut Node, With<GlueCanvas>>,
) {
if canvases.is_empty() {
return;
}
// Off the **window**, not off `CreateScene` (decision 2187): the frame is one aspect for every
// scene now, so the canvas has no reason to wait on the booth — which is what used to move it
// when a stage swap cleared the box for a frame, and what made a screen spawned before its
// scene lay out against the window once and snap in.
let window = window.single().ok();
let (left, right) = crate::portrait::glue_canvas_bars(
window.single().ok(),
scene.and_then(|s| s.viewport_aspect()),
window,
window.and_then(|w| crate::portrait::glue_box_aspect(w.width() / w.height().max(1.0))),
);
let (left, right) = (Val::Px(left), Val::Px(right));
for mut node in &mut canvases {
+242 -196
View File
@@ -80,17 +80,14 @@ pub(super) use benilla_formats::{ArtExtent, GLUE_AUTHORED_ASPECT};
///
/// **…as far as the art goes** (decision 1619, B330). Every diorama is finite — a sky card of some
/// authored width, a ground that stops — and 1587's hor+ had no ceiling: `UI_MainMenu` runs out
/// of backdrop before 16:9 and the frame past its slanted edges was the clear colour; `UI_Tauren`
/// runs out at ~2.24:1. **Not one of the seven scenes is drawn wider than about 3:2**
/// ([`benilla_formats::shipped_glue_art_extent`], measured per texel off the chain: MainMenu 1.54,
/// Human 1.47, Orc 1.57, Dwarf 1.43, NightElf 1.45, Scourge 1.57, Tauren 1.46 — 1587's "no void at
/// 21:9 on `UI_Human`" held only because the create screen's side panels stand exactly over it).
/// So `art` bounds both legs: the wide leg grows the width only to `art.half_w` and past it
/// **holds the width and closes the vertical** — the zoom the reference does everywhere, done
/// only where the art forces it — and the narrow leg does the same with `art.half_h`. The
/// authored box itself is never given up (`max(h0)` / `max(t0)`): a scene whose art does not fill
/// 4:3 is the art's problem, not a reason to zoom past the composition. `None` is 1587's
/// unbounded law.
/// of backdrop before 16:9 and the frame past its slanted edges was the clear colour. **Not one of
/// the seven scenes is drawn wider than about 3:2** ([`benilla_formats::SHIPPED_GLUE_SCENES`],
/// measured per texel off the chain: MainMenu 1.54, Human 1.47, Orc 1.57, Dwarf 1.42, NightElf
/// 1.31, Scourge 1.57, Tauren 1.46). So the wide leg grows the width only to a ceiling and past it
/// **holds the width and closes the vertical** — the zoom the reference does everywhere, done only
/// where the art forces it. The narrow leg does the same with `art.half_h`, and the authored box
/// itself is never given up (`max(t0)`): a scene whose art does not fill 4:3 is the art's problem,
/// not a reason to zoom past the composition.
///
/// **…and never tighter than the reference at 16:9.** Holding the width zooms faster than the
/// reference's diagonal law, so left alone it re-crops the character at 21:9 (B242, the report
@@ -99,74 +96,82 @@ pub(super) use benilla_formats::{ArtExtent, GLUE_AUTHORED_ASPECT};
/// years, an empirical bound on "the character still fits", not a mechanism. (A floor of "what
/// 16:9 needs to reach this scene's art" was tried and clipped the night elf male's hair and
/// boots at 16:9 — 8% past the reference is past what fits.) Past the aspect where the floor's
/// opening cannot fill the art's width, the scene is **pillarboxed** — rendered at that aspect,
/// centred, black either side — which is what the modern client does past 16:9 and the only
/// honest answer once the art is narrower than the window: not void (B330), not a crop (B242),
/// a frame.
/// opening cannot fill the ceiling's width, the scene is **pillarboxed** — rendered at that
/// aspect, centred, black either side — which is what the modern client does past 16:9 and the
/// only honest answer once the art is narrower than the window: not void (B330), not a crop
/// (B242), a frame.
///
/// Where this lands: at 16:9 every scene but one zooms to within a few percent of the reference's
/// own `0.49·fov`, by construction rather than by a constant, and fills the window; the night
/// elves' stage — its sky card exactly 4:3 wide, so the box itself is its ceiling — is boxed at
/// 1.65:1 there, ~3.6% of the width each side, where the reference shows black corners past the
/// same card. At 21:9 the reference cropped
/// and 1587 showed void, and every scene now shows its 16:9 framing with bars (~9–13% of the
/// width, per scene). At 4:3 nothing changes: byte-identical.
/// **The ceiling is [`GLUE_BOX_ASPECT`]'s, not the scene's own** (decision 2187). 1619 read the
/// ceiling off each scene's measured art, so each of the seven boxed at its *own* aspect — 1.65:1
/// in the night elf grove, 1.93:1 at the gate — and the box is what the chrome's canvas insets by
/// (2091). Clicking a night elf on the character screen therefore moved the whole screen: at 16:9
/// the human, orc, scourge and tauren stages fill the window, the dwarves' boxes by a third of a
/// percent and the night elves' by 3.6% a side, so the backdrop and every panel over it jumped
/// inward and back as the selection moved down the roster. **One box for every scene** fixes that
/// at the cause: the frame is a property of the *window*, and nothing about it changes when the
/// scene does. The size is the narrowest box no scene has to give up its authored 4:3 composition
/// for ([`GLUE_BOX_ASPECT`]).
///
/// Where this lands: at 4:3 nothing changes, byte-identical. Between 4:3 and 1.672:1 every scene
/// fills the window, zooming as the width outruns the box (within 1.4% of each other — the only
/// spread left is `tan`'s, across the scenes' 60°–86° fovs). At or past 1.672:1 every scene sits
/// on its own 16:9 floor — the *reference's* framing — inside a box of exactly that aspect: 3.0%
/// of the width a side at 16:9, 15% at 3440×1440. What it costs: the wide-art scenes no longer
/// fill a 21:9 panel the way 1619 let them (the gate went from 9.7% bars a side to 15%). That is
/// the price of one frame, and one frame is what was asked for.
///
/// Scoped to the full-screen glue scene on purpose. A `<PlayerModel>` pane renders into a fixed
/// rect that is *not* the window, so its crop is its own `W/H` and stays verbatim ([`diag_to_vert`],
/// decisions 1089/1543).
pub(super) fn glue_scene_framing(
fov: f32,
window_aspect: f32,
art: Option<ArtExtent>,
) -> GlueFraming {
pub(super) fn glue_scene_framing(fov: f32, window_aspect: f32, art: Option<ArtExtent>) -> f32 {
let authored = diag_to_vert(fov, GLUE_AUTHORED_ASPECT);
let full = |vert_fov: f32| GlueFraming {
vert_fov,
viewport_aspect: None,
};
if window_aspect <= 0.0 {
return full(authored); // a degenerate (mid-resize) window: the authored opening, finite
return authored; // a degenerate (mid-resize) window: the authored opening, finite
}
let t0 = (authored * 0.5).tan(); // the authored vertical half-extent
let h0 = t0 * GLUE_AUTHORED_ASPECT; // …and horizontal
if window_aspect >= GLUE_AUTHORED_ASPECT {
// hor+: the authored vertical opening, until the width reaches the art's edge; past it
// hor+: the authored vertical opening, until the width reaches the box's edge; past it
// the width is held there and the vertical closes — down to the 16:9 floor, past which
// the window is wider than the framing can honestly fill: pillarbox.
let ceiling = art.map_or(f32::INFINITY, |a| a.half_w.max(h0));
let half_w = (t0 * window_aspect).min(ceiling);
// the window is wider than the framing can honestly fill and [`glue_box_aspect`] boxes it.
let floor = glue_zoom_floor(fov);
let half_h = half_w / window_aspect;
if half_h >= floor {
full(2.0 * half_h.atan())
} else {
GlueFraming {
vert_fov: 2.0 * floor.atan(),
viewport_aspect: Some(half_w / floor),
}
}
let half_w = (t0 * window_aspect).min(GLUE_BOX_ASPECT * floor);
2.0 * (half_w / window_aspect).max(floor).atan()
} else {
// The mirror: hold the authored horizontal half-extent and open upward, until the art's
// top/bottom edge; past it the height is held and the sides crop — the stage's edges,
// never the character, so no floor and no bars.
let ceiling = art.map_or(f32::INFINITY, |a| a.half_h.max(t0));
let half_h = (h0 / window_aspect).min(ceiling);
full(2.0 * half_h.atan())
2.0 * (h0 / window_aspect).min(ceiling).atan()
}
}
/// What [`glue_scene_framing`] decided for one window.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct GlueFraming {
/// The booth camera's vertical opening (radians).
pub(super) vert_fov: f32,
/// `Some(aspect)`: render into a centred viewport of this aspect instead of the whole window
/// (pillarbox — the window is wider than the art can fill at the zoom floor). `None`: the
/// whole window.
pub(super) viewport_aspect: Option<f32>,
/// The **one box** every glue scene is framed in, for a window of this aspect: `Some(aspect)` when
/// the window is wider than [`GLUE_BOX_ASPECT`] (pillarbox — render into a centred box of that
/// aspect, black either side), `None` when the scene fills the window.
///
/// A property of the **window alone** — no scene, no fov, no art (decision 2187). That is the whole
/// point: the booth camera's viewport and the chrome's canvas ([`crate::glue::GlueCanvas`]) both
/// come from here, so neither moves when the selected character's race changes the stage behind
/// them, and neither flickers in the frames a stage swap is in flight.
pub(crate) fn glue_box_aspect(window_aspect: f32) -> Option<f32> {
(window_aspect > GLUE_BOX_ASPECT).then_some(GLUE_BOX_ASPECT)
}
/// **The glue frame: `1.672:1`** — the aspect every glue scene is pillarboxed to once the window
/// is wider than it (decision 2187).
///
/// Derived, not chosen: it is `max` over the seven shipped scenes of `h0/floor` — each scene's
/// authored 4:3 **width** (`h0`) divided by the vertical half-extent the [`glue_zoom_floor`] holds
/// it at. Below that aspect a box would have to crop *inside* some scene's authored composition;
/// at it, the widest-fov scene (`UI_MainMenu`, 86°) shows exactly its authored 4:3 width at the
/// reference's own 16:9 opening, and the other six show a hair more than theirs. It is the
/// narrowest frame that costs no scene its composition, which is as close to "the smallest one" as
/// the seven allow. Pinned — with the two facts it rests on, that no scene's art is narrower than
/// the frame and that no scene's authored box is wider — by
/// [`tests::the_glue_frame_is_the_narrowest_box_no_scene_pays_for`].
pub(super) const GLUE_BOX_ASPECT: f32 = 1.672_042_7;
/// The panel the reference was played on without complaint for fifteen years — the aspect whose
/// reference framing is the zoom floor, and the one the modern client fills before it boxes.
pub(super) const REFERENCE_PANEL: f32 = 16.0 / 9.0;
@@ -179,8 +184,7 @@ pub(super) fn glue_zoom_floor(fov: f32) -> f32 {
}
/// **Where the pillarbox's box lands on the window**, in *physical* pixels: `(x, width)` of the
/// centred box, or `None` when the framing fills the window
/// ([`GlueFraming::viewport_aspect`] is `None`).
/// centred box, or `None` when the framing fills the window ([`glue_box_aspect`] is `None`).
///
/// One formula, two readers, and that is the point of it being a function. The booth camera's
/// `Viewport` ([`super::glue_booth::pillarbox_glue_scene`]) renders the scene into this rect; the
@@ -815,37 +819,44 @@ mod tests {
));
}
/// **B242 / decision 1587.** The full-screen glue scene holds the authored 4:3 view box on
/// every window shape, instead of spending height on width the way the reference's
/// `aspect = a_screen` does.
///
/// Unbounded (no art): the authored `0.6·fov` survives at 4:3, at 16:9 and at the 3440×1440
/// panel the report came from — where the un-pinned law gives `0.386·fov`, a 1.55× zoom, and
/// the character's head and feet leave the frame.
/// **B242 / decision 1587.** The full-screen glue scene holds the authored 4:3 view box
/// instead of spending height on width the way the reference's `aspect = a_screen` does — and
/// under 2187's one frame it is **never framed tighter than the reference's own 16:9 opening**,
/// on any window however wide. The 3440×1440 panel the report came from used to get
/// `0.386·fov`, a 1.55× zoom over the authored framing, with the character's head and feet out
/// of the frame; it gets `0.490·fov` — the 16:9 opening — inside the frame.
#[test]
fn the_glue_scene_holds_its_authored_vertical_on_every_wide_window() {
fn the_glue_scene_is_never_framed_tighter_than_the_reference_at_16_9() {
let close = |a: f32, b: f32| (a - b).abs() < 5e-4;
let authored = diag_to_vert(1.0, GLUE_AUTHORED_ASPECT);
assert!(
close(authored, 0.6),
"the authored 4:3 opening is the 0.6 legend"
);
for wide in [
GLUE_AUTHORED_ASPECT,
16.0 / 10.0,
16.0 / 9.0,
3440.0 / 1440.0,
32.0 / 9.0,
] {
let f = glue_scene_framing(1.0, wide, None);
assert!(
close(f.vert_fov, authored) && f.viewport_aspect.is_none(),
"a{wide} must keep the authored vertical opening, unboxed"
);
// 4:3 itself: byte-identical to 1587, and unboxed.
assert!(close(
glue_scene_framing(1.0, GLUE_AUTHORED_ASPECT, None),
authored
));
assert!(glue_box_aspect(GLUE_AUTHORED_ASPECT).is_none());
// Between 4:3 and the frame the opening closes, monotonically, and the window still fills.
let mut last = authored;
for a in [1.4, 16.0 / 10.0, GLUE_BOX_ASPECT] {
let vert = glue_scene_framing(1.0, a, None);
assert!(vert <= last + 1e-6, "a{a}: {vert} > {last}");
assert!(glue_box_aspect(a).is_none(), "a{a} fills the window");
last = vert;
}
// What we are NOT doing any more — the reference's own numbers, kept here so the size of
// the correction stays legible.
assert!(close(diag_to_vert(1.0, 16.0 / 9.0), 0.490_26));
// At and past the frame: the reference's own 16:9 opening, exactly, for ever.
let reference_16_9 = diag_to_vert(1.0, REFERENCE_PANEL);
assert!(close(reference_16_9, 0.490_26));
for wide in [REFERENCE_PANEL, 3440.0 / 1440.0, 32.0 / 9.0] {
let vert = glue_scene_framing(1.0, wide, None);
assert!(close(vert, reference_16_9), "a{wide}: {vert}");
assert_eq!(glue_box_aspect(wide), Some(GLUE_BOX_ASPECT));
}
// What we are NOT doing any more — the reference's own number on the reporter's panel,
// kept here so the size of the correction stays legible.
assert!(close(diag_to_vert(1.0, 3440.0 / 1440.0), 0.386_14));
}
@@ -860,7 +871,7 @@ mod tests {
GLUE_AUTHORED_ASPECT,
);
for narrow in [5.0 / 4.0, 1.0, 3.0 / 4.0] {
let got = half_width(glue_scene_framing(1.0, narrow, None).vert_fov, narrow);
let got = half_width(glue_scene_framing(1.0, narrow, None), narrow);
assert!(
(got - authored).abs() < 5e-4,
"a{narrow}: authored half-width {authored} vs {got}"
@@ -868,111 +879,152 @@ mod tests {
}
// …and it never *narrows* the view: a tall window sees more, never less.
assert!(
glue_scene_framing(1.0, 1.0, None).vert_fov
> glue_scene_framing(1.0, GLUE_AUTHORED_ASPECT, None).vert_fov
glue_scene_framing(1.0, 1.0, None)
> glue_scene_framing(1.0, GLUE_AUTHORED_ASPECT, None)
);
// A degenerate window (zero height, mid-resize) must not produce a NaN fov.
assert!(glue_scene_framing(1.0, 0.0, None).vert_fov.is_finite());
assert!(glue_scene_framing(1.0, 0.0, None).is_finite());
}
/// The art ceiling (decision 1619): with a measured extent the wide leg widens exactly to the
/// art's edge, past it holds that width and closes the vertical, and once the vertical would
/// close below the reference's 16:9 opening the window is pillarboxed at the aspect that
/// opening fills the art at. At and below the art's aspect nothing differs from 1587's law.
/// **Decision 2187 — one frame, whatever is standing in it.** The wide leg widens to
/// [`GLUE_BOX_ASPECT`]'s edge, past it holds that width and closes the vertical, and once the
/// vertical would close below the reference's 16:9 opening the window is pillarboxed at
/// exactly that aspect — *the same* aspect for every scene, whatever its fov or its art, which
/// is what stops the backdrop and the chrome over it jumping as the roster selection moves.
#[test]
fn a_wide_window_widens_to_the_arts_edge_then_zooms_then_pillarboxes() {
fn every_glue_scene_is_framed_in_the_same_box() {
let close = |a: f32, b: f32| (a - b).abs() < 5e-4;
let authored = diag_to_vert(1.0, GLUE_AUTHORED_ASPECT);
let t0 = (authored * 0.5).tan();
// Art that runs out of width at exactly 3:2 (the login gate's shape), tall enough never
// to bind.
let art = Some(ArtExtent {
half_w: t0 * 1.5,
half_h: 10.0,
});
let half_width =
|f: GlueFraming, a: f32| (f.vert_fov * 0.5).tan() * f.viewport_aspect.unwrap_or(a);
// Up to 3:2 the vertical opening is the authored one — identical to the unbounded law.
for a in [GLUE_AUTHORED_ASPECT, 1.4, 1.5] {
let f = glue_scene_framing(1.0, a, art);
assert!(
close(f.vert_fov, authored) && f.viewport_aspect.is_none(),
"a{a}"
);
let half_width = |vert: f32, a: f32| (vert * 0.5).tan() * a;
// The seven shipped fovs, 60°–86°, plus the unit fov the rest of this module reasons in.
let deg = std::f32::consts::PI / 180.0;
for fov in [1.0_f32, 60.0 * deg, 65.0 * deg, 80.0 * deg, 86.0 * deg] {
let authored = diag_to_vert(fov, GLUE_AUTHORED_ASPECT);
let t0 = (authored * 0.5).tan();
let floor = glue_zoom_floor(fov);
assert!(close(
floor,
(diag_to_vert(fov, REFERENCE_PANEL) * 0.5).tan()
));
// 4:3 is 1587's own answer, for every fov: the authored opening, filling the window.
assert!(close(
glue_scene_framing(fov, GLUE_AUTHORED_ASPECT, None),
authored
));
// The zoom, between 4:3 and the frame: the width is the box's and the vertical pays.
for a in [1.5, 1.6, GLUE_BOX_ASPECT] {
let vert = glue_scene_framing(fov, a, None);
assert!(
close(half_width(vert, a), GLUE_BOX_ASPECT * floor),
"fov {fov} a{a}: half-width {}",
half_width(vert, a)
);
assert!(vert < authored && vert >= 2.0 * floor.atan() - 1e-6);
}
// Past it: the floor holds the opening and the box is the frame — one number, no
// scene of its own in it.
for a in [REFERENCE_PANEL, 3440.0 / 1440.0, 32.0 / 9.0] {
let vert = glue_scene_framing(fov, a, None);
assert!(
close((vert * 0.5).tan(), floor),
"fov {fov} a{a} off the floor"
);
assert_eq!(glue_box_aspect(a), Some(GLUE_BOX_ASPECT));
assert!(
close(half_width(vert, GLUE_BOX_ASPECT), GLUE_BOX_ASPECT * floor),
"fov {fov} a{a}: the box ends where the width does"
);
}
// The art no longer reaches the wide leg at all: a scene measured narrow and a scene
// measured wide are framed identically. (`half_h` still binds the NARROW leg.)
for art in [
Some(ArtExtent {
half_w: t0 * 1.31,
half_h: 10.0,
}),
Some(ArtExtent {
half_w: t0 * 4.0,
half_h: 10.0,
}),
] {
for a in [1.5, REFERENCE_PANEL, 3440.0 / 1440.0] {
assert_eq!(
glue_scene_framing(fov, a, art),
glue_scene_framing(fov, a, None),
"fov {fov} a{a}: the wide leg read the art"
);
}
}
}
// Past it the width is pinned at the art's edge and the vertical closes: the zoom. At
// 16:9 that lands beside the reference's own diagonal-law number (0.490) — a 3:2 edge
// gives 2·atan(1.5·t0/(16/9)) — within a few percent, and no constant says so.
let f = glue_scene_framing(1.0, 16.0 / 9.0, art);
assert!(f.viewport_aspect.is_none(), "16:9 fills the window");
assert!(close(half_width(f, 16.0 / 9.0), t0 * 1.5));
}
/// **[`GLUE_BOX_ASPECT`] is derived, and the two facts it rests on hold** (decision 2187):
/// it is the narrowest box that costs no shipped scene its authored 4:3 composition, and at
/// that width no scene's art runs out inside the frame — except the night elves', whose sky
/// card ends a hair inside their own authored box and always did (1619's table doc).
#[test]
fn the_glue_frame_is_the_narrowest_box_no_scene_pays_for() {
use benilla_formats::{authored_half_height, SHIPPED_GLUE_SCENES};
let mut widest = 0.0_f32;
for scene in SHIPPED_GLUE_SCENES {
let t0 = authored_half_height(scene.fov);
let h0 = t0 * GLUE_AUTHORED_ASPECT;
let floor = glue_zoom_floor(scene.fov);
// No scene gives up its authored width: the frame reaches at least h0.
assert!(
GLUE_BOX_ASPECT * floor >= h0 - 1e-4,
"UI_{}: the frame stops at {} inside the authored {h0}",
scene.token,
GLUE_BOX_ASPECT * floor
);
// …and no scene's art stops inside the frame. The night elf grove is the exception
// 1619 measured and named — its 4:3 sky card is narrower than its own authored box,
// so the frame runs 1.3% past it into the black the bars are made of anyway, where
// 1619's own box ran 1.6% past the card for the same reason.
if scene.token == "NightElf" {
assert!(scene.art.half_w < h0, "the night elf exception closed?");
assert!(
GLUE_BOX_ASPECT * floor <= h0 * 1.015,
"UI_NightElf: the frame reaches {} past the authored {h0}",
GLUE_BOX_ASPECT * floor
);
} else {
assert!(
GLUE_BOX_ASPECT * floor <= scene.art.half_w,
"UI_{}: the frame reaches {} past the art's {}",
scene.token,
GLUE_BOX_ASPECT * floor,
scene.art.half_w
);
}
widest = widest.max(h0 / floor);
}
// …and it is the NARROWEST such box: the widest-fov scene sits exactly on it.
assert!(
f.vert_fov < authored && (f.vert_fov - 0.490_26).abs() < 0.03,
"{}",
f.vert_fov
(GLUE_BOX_ASPECT - widest).abs() < 5e-4,
"the frame is {GLUE_BOX_ASPECT}, the narrowest box no scene pays for is {widest}"
);
assert!(f.vert_fov >= 2.0 * glue_zoom_floor(1.0).atan() - 1e-6);
// Wider still, the zoom would pass the reference's 16:9 opening: the floor holds it
// there and the scene is boxed at the aspect the floor fills the art at (1.5·t0 / floor),
// the same box on any wider window.
let floor = glue_zoom_floor(1.0);
assert!(close(floor, (diag_to_vert(1.0, 16.0 / 9.0) * 0.5).tan()));
for a in [3440.0 / 1440.0, 32.0 / 9.0] {
let f = glue_scene_framing(1.0, a, art);
assert!(
close((f.vert_fov * 0.5).tan(), floor),
"a{a} sits on the floor"
);
let boxed = f.viewport_aspect.expect("pillarboxed");
assert!(close(boxed, t0 * 1.5 / floor), "a{a}: box {boxed}");
assert!(boxed < a, "the box is narrower than the window");
assert!(
close(half_width(f, a), t0 * 1.5),
"…and still ends at the art"
);
}
// Wide art (a scene drawn out to 21:9) never binds: 1587's answer, unchanged.
let wide_art = Some(ArtExtent {
half_w: t0 * 4.0,
half_h: 10.0,
});
let f = glue_scene_framing(1.0, 3440.0 / 1440.0, wide_art);
assert!(close(f.vert_fov, authored) && f.viewport_aspect.is_none());
// Art narrower than 16:9 can reach at the reference's opening (the night elves' sky, a
// 4:3 card — 1.31, inside the authored box, so the box's own width is the ceiling): the
// floor holds and 16:9 itself is boxed, at the aspect the floor fills the box at — the
// same box on any wider window.
let narrow_art = Some(ArtExtent {
half_w: t0 * 1.31,
half_h: 10.0,
});
let h0 = t0 * GLUE_AUTHORED_ASPECT;
for a in [16.0 / 9.0, 3440.0 / 1440.0] {
let f = glue_scene_framing(1.0, a, narrow_art);
assert!(
close((f.vert_fov * 0.5).tan(), floor),
"a{a} sits on the floor"
);
let boxed = f.viewport_aspect.expect("boxed");
assert!((boxed - h0 / floor).abs() < 2e-3, "a{a}: box {boxed}");
}
// …and 16:10 is not: the box's width still fills it above the floor.
assert!(glue_scene_framing(1.0, 1.6, narrow_art)
.viewport_aspect
.is_none());
}
/// **B377 / decision 2091 — the chrome's canvas IS the camera's box.**
///
/// At the reporter's own 3440×1440 the login gate boxes at 1.93:1 (1619 §3): `330 | 2779 |
/// 331` physical pixels, the bars he measured off the screenshot (332/331). The canvas insets
/// by exactly those two numbers — not by a symmetric half, which would hang a pixel of chrome
/// over the right bar — and it converts to Bevy UI's *logical* px on the way, which is the
/// half that a 2× display makes visible.
/// The arithmetic is pinned against pixels a reporter counted: at his own 3440×1440 the login
/// gate boxed at 1.93:1 under 1619's per-scene ceiling — `330 | 2779 | 331` physical pixels,
/// the bars he measured off the screenshot (332/331). The canvas insets by exactly those two
/// numbers — not by a symmetric half, which would hang a pixel of chrome over the right bar —
/// and it converts to Bevy UI's *logical* px on the way, which is the half that a 2× display
/// makes visible. (2187 framed every scene in one box instead, so that panel now boxes at
/// 1.672:1 — `516 | 2408 | 516`, asserted below. The odd-leftover case is what 1.93 is kept
/// for.)
#[test]
fn the_chrome_canvas_is_the_cameras_own_box() {
use bevy::window::WindowResolution;
// The frame the same panel gets today, off the one constant, with no scene in the call.
assert_eq!(
glue_box_physical(3440, 1440, glue_box_aspect(3440.0 / 1440.0)),
Some((516, 2408))
);
let (x, box_w) = glue_box_physical(3440, 1440, Some(1.93)).expect("21:9 boxes the gate");
assert_eq!((x, box_w), (330, 2779));
assert_eq!(
@@ -1016,17 +1068,9 @@ mod tests {
/// the frame the chrome lays out in are the same rect at every window the law boxes.
#[test]
fn the_box_the_camera_renders_is_the_box_the_chrome_lays_out_in() {
let t0 = (diag_to_vert(1.0, GLUE_AUTHORED_ASPECT) * 0.5).tan();
// The login gate's shape: art that runs out of width at 1.54·t0 (MainMenu, 1619 §2).
let art = Some(ArtExtent {
half_w: t0 * 1.54,
half_h: 10.0,
});
for (w, h) in [(3440, 1440), (2560, 1080), (5120, 1440), (3439, 1440)] {
let f = glue_scene_framing(1.0, w as f32 / h as f32, art);
let boxed = f
.viewport_aspect
.expect("every 21:9-and-wider window is boxed");
let boxed =
glue_box_aspect(w as f32 / h as f32).expect("every window past the frame is boxed");
let (x, box_w) = glue_box_physical(w, h, Some(boxed)).expect("boxed");
let (left, right) = glue_canvas_bars(
Some(&Window {
@@ -1049,8 +1093,12 @@ mod tests {
}
}
/// The authored box is never given up: art that does not even reach it (a `0.0` extent, or
/// one inside 4:3) leaves the 4:3 framing exactly where 1587 put it — on both legs.
/// The authored box is never given up — on either leg, and at the frame's own edge.
///
/// The wide leg: [`GLUE_BOX_ASPECT`] is defined so its width reaches at least the authored
/// `h0` at every shipped fov (its own pin test), so a scene whose art stops short of 4:3 is
/// still framed on its whole composition, never zoomed inside it. The narrow leg reads the
/// art, and clamps the same way: an extent inside the box leaves 1587's framing alone.
#[test]
fn art_inside_the_authored_box_never_zooms_past_the_composition() {
let close = |a: f32, b: f32| (a - b).abs() < 5e-4;
@@ -1060,36 +1108,34 @@ mod tests {
half_h: 0.0,
});
assert!(close(
glue_scene_framing(1.0, GLUE_AUTHORED_ASPECT, none).vert_fov,
glue_scene_framing(1.0, GLUE_AUTHORED_ASPECT, none),
authored
));
assert!(close(glue_scene_framing(1.0, 1.0, none).vert_fov, authored));
// At 16:9 with no art past the box, the box's own width is held — and holding 4:3's
// width in a 16:9 window closes the vertical past the reference's 16:9 opening, so the
// floor takes it: the authored box at that opening, boxed.
let f = glue_scene_framing(1.0, 16.0 / 9.0, none);
assert!(close(glue_scene_framing(1.0, 1.0, none), authored));
// At the frame's own aspect the vertical has closed to the floor and the width is the
// frame's — which is still at or outside the authored 4:3 width.
let t0 = (authored * 0.5).tan();
let floor = glue_zoom_floor(1.0);
assert!(close((f.vert_fov * 0.5).tan(), floor));
let boxed = f.viewport_aspect.expect("boxed");
let v = glue_scene_framing(1.0, GLUE_BOX_ASPECT, none);
assert!(close((v * 0.5).tan(), floor));
assert!(
(boxed - t0 * GLUE_AUTHORED_ASPECT / floor).abs() < 2e-3,
"box {boxed}"
(v * 0.5).tan() * GLUE_BOX_ASPECT >= t0 * GLUE_AUTHORED_ASPECT - 1e-4,
"the frame stopped inside the authored width"
);
// 16:10 still fills: 4:3's width there is above the floor.
assert!(glue_scene_framing(1.0, 1.6, none).viewport_aspect.is_none());
// 16:10 still fills the window: it is narrower than the frame.
assert!(glue_box_aspect(1.6).is_none());
// …and a narrow window with a bounded height holds that height, cropping the sides.
let short = Some(ArtExtent {
half_w: 10.0,
half_h: t0 * 1.1,
});
let v = glue_scene_framing(1.0, 0.75, short).vert_fov;
let v = glue_scene_framing(1.0, 0.75, short);
assert!(
close((v * 0.5).tan(), t0 * 1.1),
"height held at the art's edge"
);
assert!(
glue_scene_framing(1.0, 0.75, None).vert_fov > v,
glue_scene_framing(1.0, 0.75, None) > v,
"…below the unbounded law's"
);
}
+41 -23
View File
@@ -26,8 +26,8 @@ use benilla_assets::m2_url;
use benilla_assets::materials::WowModelMaterial;
use super::framing::{
attachment_point, diag_to_vert, glue_box_physical, glue_scene_framing, ArtExtent,
GLUE_AUTHORED_ASPECT, PORTRAIT_ASPECT,
attachment_point, diag_to_vert, glue_box_aspect, glue_box_physical, glue_scene_framing,
ArtExtent, GLUE_AUTHORED_ASPECT, PORTRAIT_ASPECT,
};
use super::{
aim, body_frame, new_target_image, spawn_booth_effects, spawn_booth_model, Booth,
@@ -198,11 +198,19 @@ pub(crate) struct CreateScene {
cam: Option<benilla_assets::PortraitCamera>,
/// How far the scene's art paints around that camera — the shipped scene's measured extent
/// ([`benilla_formats::shipped_glue_art_extent`], decision 1619) — the ceiling on the framing
/// law's widening ([`glue_scene_framing`]). `None` with no scene.
/// law's opening UPWARD on a narrow window ([`glue_scene_framing`]). `None` with no scene.
/// The wide leg stopped reading it under 2187: one frame for every scene, so its width is
/// [`super::framing::GLUE_BOX_ASPECT`]'s, not this stage's. It is still what says the frame
/// is safe there (the spawn log below, and the test that pins the constant).
art: Option<ArtExtent>,
/// `Some(aspect)` while the framing law pillarboxes the scene (the window is wider than the
/// art can fill at the zoom floor): the booth camera renders into a centred viewport of this
/// aspect, black either side ([`pillarbox_glue_scene`]). `None`: the whole window.
/// `Some(aspect)` while the frame pillarboxes the scene — the window is wider than
/// [`super::framing::GLUE_BOX_ASPECT`]: the booth camera renders into a centred viewport of
/// this aspect, black either side ([`pillarbox_glue_scene`]). `None`: the whole window.
///
/// A property of the **window**, not of the scene (decision 2187) — set from the window while
/// any glue screen is up, cleared only when the booth is torn down. It is what the chrome's
/// canvas insets by (2091), and 1619's per-scene box is why that canvas used to jump as the
/// roster selection moved between races.
viewport_aspect: Option<f32>,
/// The character's stage spot — scene attachment 0 (Bevy model space), `ZERO` with no scene.
/// (Verified for select too: the body seats on attachment **0**, `0x473039` — attachment 1 is
@@ -259,15 +267,6 @@ pub(crate) struct CreateScene {
pending: Option<PendingSwap>,
}
impl CreateScene {
/// The framing law's pillarbox for the window it last ran on: `Some(aspect)` while the scene
/// is boxed, `None` while it fills the window (decision 1619). Read by the glue screens'
/// chrome canvas, which lays out inside that box rather than over its bars (decision 2091).
pub(crate) fn viewport_aspect(&self) -> Option<f32> {
self.viewport_aspect
}
}
/// A requested stage swap, waiting for its half of the pair.
///
/// **The two halves of a glue booth swap independently, and that is the whole defect.** Clicking a
@@ -899,6 +898,13 @@ pub(super) fn sync_glue_scene(
// The booth target follows the window while the screen is up — the scene is a fullscreen
// render, so the bake wants window-native resolution (the fallback character-only render
// shares it; its projection is aspect-aware).
//
// …and so does the **frame** (decision 2187). One box for every scene means the box is a
// property of the window alone, so it is set here — from the window, before a token is even
// resolved — and not down in the camera block with the scene's own fov. Two consequences, and
// both are the point: the chrome's canvas ([`crate::glue::GlueCanvas`]) cannot move when the
// selected character's race changes the stage, and it cannot flicker out to the full window
// for the frames a stage swap is in flight.
if let Ok(w) = window.single() {
resize_target(
&mut images,
@@ -906,6 +912,10 @@ pub(super) fn sync_glue_scene(
w.physical_width().max(1),
w.physical_height().max(1),
);
let box_aspect = glue_box_aspect(w.width() / w.height().max(1.0));
if scene.viewport_aspect != box_aspect {
scene.viewport_aspect = box_aspect;
}
}
let token = scene_token(which);
@@ -970,7 +980,9 @@ pub(super) fn sync_glue_scene(
scene.spawned = false;
scene.cam = None;
scene.art = None;
scene.viewport_aspect = None;
// `viewport_aspect` is deliberately NOT cleared: the frame is the window's, not this
// stage's (2187), and clearing it here is what made the chrome jump out to the full
// window for the frames between two stages.
scene.char_spot = Vec3::ZERO;
clear_pet(&mut commands, &mut scene);
commands.entity(scene.root).despawn_related::<Children>();
@@ -1144,14 +1156,23 @@ pub(super) fn sync_glue_scene(
scene.art = benilla_formats::shipped_glue_art_extent(token);
if let (Some(art), Some(cam)) = (scene.art, model.camera0.as_ref()) {
let t0 = benilla_formats::authored_half_height(cam.fov);
// Where the frame's edge lands on THIS stage's art (2187): the constant is derived
// from the seven shipped scenes, so on the shipped chain this reads "inside" for six
// of them and a hair past the night elves' 4:3 sky card. A patched or replaced scene
// that paints narrower would say so here.
let frame = super::framing::GLUE_BOX_ASPECT * super::framing::glue_zoom_floor(cam.fov);
info!(
"create scene: UI_{token} art extent — half_w {:.4} (widens to {:.2}:1), half_h {:.4} \
(opens to 1:{:.2}); boxes past {:.2}:1",
(opens to 1:{:.2}); the frame ends at {frame:.4} — {}",
art.half_w,
art.half_w / t0,
art.half_h,
art.half_h / (t0 * GLUE_AUTHORED_ASPECT),
art.half_w.max(t0 * GLUE_AUTHORED_ASPECT) / super::framing::glue_zoom_floor(cam.fov),
if frame <= art.half_w.max(t0 * GLUE_AUTHORED_ASPECT) {
"inside the art"
} else {
"PAST the art (void at the frame's edges)"
},
);
}
scene.char_spot = stage;
@@ -1207,14 +1228,11 @@ pub(super) fn sync_glue_scene(
// a 1.55× zoom at 21:9 that crops head and feet (B242). [`glue_scene_framing`] pins the
// authored 4:3 view box instead; its doc carries the law. Far kept generous: fog is not
// rendered yet, so the authored far (27.8 on Orc) would slice unfogged geometry.
let framing = glue_scene_framing(cam.fov, aspect, scene.art);
if scene.viewport_aspect != framing.viewport_aspect {
scene.viewport_aspect = framing.viewport_aspect;
}
let vert_fov = glue_scene_framing(cam.fov, aspect, scene.art);
let rig = (
Transform::from_translation(cam.eye).looking_at(cam.target, up),
Projection::from(PerspectiveProjection {
fov: framing.vert_fov,
fov: vert_fov,
near: cam.near,
far: cam.far.max(1000.0),
..default()
+4 -5
View File
@@ -79,8 +79,8 @@ use benilla_assets::materials::WowModelMaterial;
mod framing;
pub(crate) use framing::{
attachment_point, glue_canvas_bars, head_anchor, pane_projection, PortraitAnchors,
WowPortraitProjection,
attachment_point, glue_box_aspect, glue_canvas_bars, head_anchor, pane_projection,
PortraitAnchors, WowPortraitProjection,
};
use framing::{body_frame, frame, PORTRAIT_FOV};
mod booth;
@@ -96,9 +96,8 @@ mod dressup;
pub(crate) use dressup::{DressUpBake, DressUpLook, DressUpPreview};
mod glue_booth;
pub(crate) use glue_booth::{
CreateLook, CreateScene, GhostKit, GlueLook, GluePetBake, GluePreview, GluePreviewBake,
GlueScene, PetLook, PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SelectLook,
GLUE_SLOT,
CreateLook, GhostKit, GlueLook, GluePetBake, GluePreview, GluePreviewBake, GlueScene, PetLook,
PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SelectLook, GLUE_SLOT,
};
mod light;
pub(crate) use light::{material_variant, VariantLane};
@@ -469,41 +469,8 @@ fn a_cooldown_count_addons_hook_leaves_the_sweep_running() {
load_action_bar(&s);
// The addon loads after FrameXML (`!` sorts it first among addons, all of which run after the
// interface), so the global it captures is the stock one.
s.run(
r#"
local original = CooldownFrame_SetTimer
seen = 0
CooldownFrame_SetTimer = function(cd, start, duration, enable)
seen = seen + 1
original(cd, start, duration, enable)
if start > 0 and duration > 3 and enable > 0 then
local count = cd.textFrame
if not count then
local icon = getglobal(cd:GetParent():GetName() .. "Icon")
if icon then
count = CreateFrame("Frame", nil, cd:GetParent())
count:SetAllPoints(cd:GetParent())
count:SetFrameLevel(count:GetFrameLevel() + 1)
count.text = count:CreateFontString(nil, "OVERLAY")
count.text:SetFontObject(GameFontNormal)
count.text:SetPoint("CENTER", count, "CENTER", 0, 1)
count.icon = icon
count:SetScript("OnUpdate", function() end)
cd.textFrame = count
end
end
if count then
count.start = start
count.duration = duration
count:Show()
end
elseif cd.textFrame then
cd.textFrame:Hide()
end
end
"#,
)
.expect("the addon's hook installs");
s.run(COOLDOWN_COUNT_HOOK)
.expect("the addon's hook installs");
s.set_action(
1,
@@ -563,6 +530,120 @@ fn a_cooldown_count_addons_hook_leaves_the_sweep_running() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `!OmniCC` 6.8.30's hook, as shape: it captures `CooldownFrame_SetTimer` in an upvalue, replaces
/// the global with a function that calls through, and hangs its countdown off the BUTTON — a
/// `CreateFrame` parented there lands at `button + 1`, then its own `+ 1` puts it at `button + 2`,
/// one over where it trusts the cooldown to sit. The handle rides a field of the cooldown widget
/// itself (`cd.textFrame`).
const COOLDOWN_COUNT_HOOK: &str = r#"
local original = CooldownFrame_SetTimer
seen = 0
CooldownFrame_SetTimer = function(cd, start, duration, enable)
seen = seen + 1
original(cd, start, duration, enable)
if start > 0 and duration > 3 and enable > 0 then
local count = cd.textFrame
if not count then
local icon = getglobal(cd:GetParent():GetName() .. "Icon")
if icon then
count = CreateFrame("Frame", nil, cd:GetParent())
count:SetAllPoints(cd:GetParent())
count:SetFrameLevel(count:GetFrameLevel() + 1)
count.text = count:CreateFontString(nil, "OVERLAY")
count.text:SetFontObject(GameFontNormal)
count.text:SetPoint("CENTER", count, "CENTER", 0, 1)
count.icon = icon
count:SetScript("OnUpdate", function() end)
cd.textFrame = count
end
end
if count then
count.start = start
count.duration = duration
count:Show()
end
elseif cd.textFrame then
cd.textFrame:Hide()
end
end
"#;
/// **On the bonus bar the countdown draws over the sweep, as it does on every other bar**
/// (decision 2189). It came in as "the cooldown counter on action bar 1 is hidden behind the pie";
/// a warrior in a stance — a druid in a form, a rogue in stealth — sees the bonus bar there.
///
/// Stock `BonusActionButtonTemplate`'s `OnLoad` raises the button `+2` and then its cooldown `+2`
/// **by hand**, which is only one level of separation because a script level change carries no
/// children (`0x774560` → `set_frame_level(…, propagate=0)`). Our binding carried them, so the
/// cooldown came out at `button + 3` — over the count text the hook hangs at `button + 2`.
#[test]
fn a_cooldown_count_draws_over_the_bonus_bars_sweep() {
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.run(COOLDOWN_COUNT_HOOK)
.expect("the addon's hook installs");
let level = |s: &UiScript, frame: &str| {
s.eval::<i64>(&format!("return {frame}:GetFrameLevel()"))
.unwrap()
};
assert_eq!(
level(&s, "BonusActionButton1Cooldown"),
level(&s, "BonusActionButton1") + 1,
"the template's two hand raises leave the sweep ONE level over its button"
);
// Bonus page 1 (a warrior's Battle Stance): button 1 is action 73.
s.set_action(
73,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_Racial_BloodRage".into()),
kind: 0x00,
action: 2687,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.run("BonusActionBarFrame:Show()").unwrap();
s.tick(10.0);
s.set_action_state(
73,
Some(ActionState {
usable: true,
cooldown: Some((6_000, 60_000, true)),
..Default::default()
}),
);
s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]);
super::test_ui::cooldown_facts(&mut s);
// The addon writes its digits from its OnUpdate; the transcription's is a no-op.
s.run(r#"BonusActionButton1Cooldown.textFrame.text:SetText("27")"#)
.expect("the hook hung its countdown off the bonus button");
s.tick(0.0);
s.resolve();
let quads = s.extract();
let sweep = quads
.iter()
.position(|q| {
matches!(q.content, QuadContent::ModelPane { .. })
&& s.quad_owner_name(q.target).as_deref() == Some("BonusActionButton1Cooldown")
})
.expect("the bonus button's sweep is on the paint list");
let count = quads
.iter()
.position(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "27"))
.expect("the countdown is on the paint list");
assert!(
sweep < count,
"the countdown (index {count}) must paint over the bonus button's sweep (index {sweep})"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// An action button is a TWO-button button (decision 0908; director's report B200: "I can't right
/// click food on my bar to eat it or right click spells"). The ref's `ActionButton_OnLoad`
/// registers `("LeftButtonUp", "RightButtonUp")` (ActionButton.lua:109) and its OnClick body reads
@@ -1328,6 +1328,82 @@ fn the_vertical_sync_row_reads_and_writes_the_present_mode_cvar() {
/// Environment Detail is the reference's own slider (1649): 0..2 step 1 over `WorldDetail`, with
/// 0992's Low/Medium/High names kept in the readout seat — a groove whose readout says "1" tells a
/// player nothing. Dragging writes the CVar; a value from outside the range shows the nearest stop
/// **The Brightness slider actually writes** (decision 2182, and the bug that shipped with it).
///
/// This row is the kit's first slider whose store is an engine PAIR rather than a CVar or a
/// saved-variable global — 1.12's own arrangement for its slider 6 — and
/// `OptionsSlider_OnValueChanged` guarded its write on `row.cvar or row.uvar`, two of the kit's
/// three stores. So dragging it moved the READOUT and wrote nothing: the control looked alive, the
/// picture never changed, and reopening the window re-read an untouched CVar and snapped back to
/// the centre. No error anywhere, on any path.
///
/// The shipped test missed it by calling `SetGamma` directly and checking the row's *wiring* — the
/// bounds, the numeric flag, the readout at the default. Everything it asserted was true. What it
/// never did was move the slider, which is the only thing a player does. So this one drags.
#[test]
fn the_brightness_slider_writes_through_its_engine_pair_and_survives_a_reopen() {
const ROW: &str = "BenillaOptionsFrameContainerBodyGraphicsRowBrightness";
let s = audio_harness();
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}Label:GetText()"))
.unwrap(),
"Brightness"
);
// The registered `gamma = "1.0"` is `GetGamma() == 0`, the centre of the stock slider's
// travel — and selecting the page must not write it back.
assert_eq!(
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
.unwrap(),
"50%"
);
assert!(
s.take_cvar_changes().is_empty(),
"reading the pair on select must not write it back"
);
// **A drag to each end WRITES.** The value is the reference's own `1 - slider`, six decimals,
// because `SetGamma` owns the formatting — this is the assertion the shipped test lacked.
for (slider, cvar, readout) in [
(0.5, "0.500000", "100%"),
(-0.5, "1.500000", "0%"),
(0.2, "0.800000", "70%"),
] {
s.run(&format!("{ROW}ControlSlider:SetValue({slider})"))
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("gamma".to_string(), cvar.to_string())],
"dragging to {slider} must reach the store"
);
assert_eq!(
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
.unwrap(),
readout
);
}
// **And it comes back.** Leaving the page and returning re-reads the pair rather than the
// control, which is the half the player sees as "it didn't save": the row was left at 0.2.
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
.unwrap(),
"70%",
"the reopened page reads the store, not the registered default"
);
assert!(
s.take_cvar_changes().is_empty(),
"…and the refresh itself writes nothing"
);
}
/// and writes nothing back (0959's out-of-range law).
#[test]
fn the_world_detail_slider_writes_the_cvar_and_the_readout_names_its_stop() {
@@ -23,7 +23,7 @@ const SCENES: [&str; 7] = [
pub fn glueextent(chain: &mut Chain, batches: bool) -> Result<()> {
println!(
"{:<9} {:>6} {:>7} {:>7} | {:>7} {:>7} | {:>9} {:>9} | {:>7} {:>7}",
"{:<9} {:>10} {:>7} {:>7} | {:>7} {:>7} | {:>9} {:>9} | {:>7} {:>7}",
"scene", "fov", "t0", "h0", "half_w", "half_h", "wide@", "narrow@", "opaque", "+all"
);
for token in SCENES {
@@ -65,7 +65,7 @@ pub fn glueextent(chain: &mut Chain, batches: bool) -> Result<()> {
/ t0;
let with_all = glue_art_extent(&subs, &cam, |_| Some(Coverage::Full)).half_w / t0;
println!(
"{token:<9} {:>6.3} {:>7.4} {:>7.4} | {:>7.4} {:>7.4} | {:>9.3} {:>9.3} | {:>7.3} {:>7.3}",
"{token:<9} {:>10.7} {:>7.4} {:>7.4} | {:>7.4} {:>7.4} | {:>9.3} {:>9.3} | {:>7.3} {:>7.3}",
cam.fov,
t0,
h0,
+3 -3
View File
@@ -254,11 +254,11 @@ pub use models::{
CoverageReader, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone,
GlobalSeqChannel, GroundQuad, KeyAnim, M2AnimSummary, M2Attachment, M2Bounds, M2CameraTracks,
M2Light, M2PaneCamera, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis,
PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, Skeleton, SkeletonBone,
StringAnchors, UvAnim, UvRotAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog,
PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, ShippedGlueScene, Skeleton,
SkeletonBone, StringAnchors, UvAnim, UvRotAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog,
WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
ALPHA_KEY_REF, DEGENERATE_RING_FOOTPRINT, GLUE_AUTHORED_ASPECT, NO_GROUP_LIQUID,
OWNER_RUNG_BUCKETS,
OWNER_RUNG_BUCKETS, SHIPPED_GLUE_SCENES,
};
mod terrain;
pub use terrain::{
+75 -36
View File
@@ -79,8 +79,9 @@ pub const GLUE_AUTHORED_ASPECT: f32 = 4.0 / 3.0;
/// (wow-re `models.md` — `ALPHAREF` 224, `GEQUAL`). The one threshold this module paints by.
pub const ALPHA_KEY_REF: u8 = 224;
/// The **shipped** scenes' measured extents — `benilla-extract glueextent` on the 1.12.1 (5875)
/// chain, transcribed. Keyed by the scene token (`UI_<token>.m2`). The test re-measures them.
/// The **shipped** scenes' measured extents and authored fovs — `benilla-extract glueextent` on
/// the 1.12.1 (5875) chain, transcribed. Keyed by the scene token (`UI_<token>.m2`). The test
/// re-measures them.
///
/// Read as aspects (`half_w / t0`, the window aspect past which the art runs out of width under
/// the authored vertical): MainMenu 1.54 · Human 1.47 · Orc 1.57 · Dwarf 1.42 · NightElf 1.31 ·
@@ -94,62 +95,92 @@ pub const ALPHA_KEY_REF: u8 = 224;
/// what shows through is the page behind the scene, near-black, read as sky. The framing law never
/// opens past the authored box on an axis the art does not reach, so the number costs nothing
/// there.
const SHIPPED: [(&str, ArtExtent); 7] = [
(
"MainMenu",
ArtExtent {
/// One degree in radians — the unit the `UI_*` cameras were dialled in. Every one of the seven
/// authored fovs is a whole number of degrees, to the last bit of the `f32` the M2 carries
/// (`benilla-extract glueextent`: 86°, 80°, 65°, 60°), so the table says so rather than
/// transcribing decimals.
const DEG: f32 = std::f32::consts::PI / 180.0;
/// One shipped glue scene, as the framing law needs it: the scene token, its authored camera-0
/// **fov** ([`DEG`] — 86° on the gate, 80° on the human street, 65° on the four orc-family
/// stages, 60° in the night elf grove), and the measured
/// [`ArtExtent`]. The fov rides the table because the framing constant is derived from it
/// ([`benilla`]'s `GLUE_BOX_ASPECT`) and both halves are re-measured by the same chain test.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShippedGlueScene {
pub token: &'static str,
pub fov: f32,
pub art: ArtExtent,
}
/// The seven shipped scenes: the login gate plus the six race stages (Gnome shares Dwarf's,
/// Troll shares Orc's). See [`ShippedGlueScene`].
pub const SHIPPED_GLUE_SCENES: [ShippedGlueScene; 7] = [
ShippedGlueScene {
token: "MainMenu",
fov: 86.0 * DEG,
art: ArtExtent {
half_w: 0.7431,
half_h: 0.5067,
},
),
(
"Human",
ArtExtent {
},
ShippedGlueScene {
token: "Human",
fov: 80.0 * DEG,
art: ArtExtent {
half_w: 0.6548,
half_h: 0.4866,
},
),
(
"Orc",
ArtExtent {
},
ShippedGlueScene {
token: "Orc",
fov: 65.0 * DEG,
art: ArtExtent {
half_w: 0.5573,
half_h: 0.3954,
},
),
(
"Dwarf",
ArtExtent {
},
ShippedGlueScene {
token: "Dwarf",
fov: 65.0 * DEG,
art: ArtExtent {
half_w: 0.5040,
half_h: 0.4272,
},
),
(
"NightElf",
ArtExtent {
},
ShippedGlueScene {
token: "NightElf",
fov: 60.0 * DEG,
art: ArtExtent {
half_w: 0.4262,
half_h: 0.1221,
},
),
(
"Scourge",
ArtExtent {
},
ShippedGlueScene {
token: "Scourge",
fov: 65.0 * DEG,
art: ArtExtent {
half_w: 0.5542,
half_h: 0.4776,
},
),
(
"Tauren",
ArtExtent {
},
ShippedGlueScene {
token: "Tauren",
fov: 65.0 * DEG,
art: ArtExtent {
half_w: 0.5177,
half_h: 0.4066,
},
),
},
];
/// The shipped scene's measured [`ArtExtent`] by token, `None` for a token that is not one of
/// the seven `UI_*` dioramas (the consumer then keeps 1587's unbounded law).
pub fn shipped_glue_art_extent(token: &str) -> Option<ArtExtent> {
SHIPPED.iter().find(|(t, _)| *t == token).map(|(_, e)| *e)
SHIPPED_GLUE_SCENES
.iter()
.find(|s| s.token == token)
.map(|s| s.art)
}
/// The authored **vertical** half-extent (tan units) of a glue camera's `fov` at 4:3 — the number
@@ -821,14 +852,14 @@ mod tests {
}
/// Measure one shipped scene off the chain, the way the tool does.
fn measure(chain: &mut Chain, token: &str) -> (ArtExtent, f32) {
fn measure(chain: &mut Chain, token: &str) -> (ArtExtent, f32, f32) {
let name = format!("Interface\\Glues\\Models\\UI_{token}\\UI_{token}.m2");
let bytes = chain.read_file(&name).expect("read scene");
let subs = crate::parse_m2_render_submeshes(&bytes, "", &[]).expect("parse");
let cam = crate::parse_m2_camera(&bytes, 0).expect("camera 0");
let mut reader = CoverageReader::new(chain);
let ext = glue_art_extent(&subs, &cam, |s| reader.coverage(s).expect("texture"));
(ext, authored_half_height(cam.fov))
(ext, cam.fov, authored_half_height(cam.fov))
}
/// The transcription is the measurement: every shipped scene re-measures to the table's
@@ -838,13 +869,21 @@ mod tests {
fn the_shipped_table_matches_the_measurement() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
for (token, shipped) in SHIPPED {
let (ext, t0) = measure(&mut chain, token);
for scene in SHIPPED_GLUE_SCENES {
let token = scene.token;
let shipped = scene.art;
let (ext, fov, t0) = measure(&mut chain, token);
assert!(
(ext.half_w - shipped.half_w).abs() < 2e-3
&& (ext.half_h - shipped.half_h).abs() < 2e-3,
"UI_{token}: measured {ext:?}, table {shipped:?}"
);
// The fov rides the table because the glue framing constant is derived from it.
assert!(
(fov - scene.fov).abs() < 1e-6,
"UI_{token}: camera 0 fov {fov}, table {}",
scene.fov
);
let runs_out_at = ext.half_w / t0;
assert!(
(GLUE_AUTHORED_ASPECT - 0.03..16.0 / 9.0).contains(&runs_out_at),
@@ -511,10 +511,16 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, (this, level): (Table, i64)| {
let h = frame_handle_of(lua, &this)?;
let lvl = level.clamp(0, i64::from(u16::MAX)) as u16;
// **A script level change carries no children** — the binding `0x774560` calls
// `set_frame_level 0x76a4f0` with `propagate=0` (wow-re `ui/ui.md`, default levels).
// Only the toplevel raise shifts a subtree. Stock `BonusActionButtonTemplate` is written
// for this: it raises the button +2 and then its cooldown +2 by hand, landing the sweep
// one level over the button — carrying the children made it three, over the
// cooldown-count text an addon hangs at button + 2 (decision 2189).
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_frame_level(h, lvl, true);
.set_frame_level(h, lvl, false);
Ok(())
})?,
)?;
@@ -670,6 +670,63 @@ fn set_owner_defaults_to_anchor_left_and_only_preserve_keeps_the_placement() {
);
}
/// **A NON-STRING anchor argument must not raise** — and for a year it did, because the binding
/// took arg 3 as `Option<String>` and let mlua's converter be the gate.
///
/// The reference's gate is `lua_isstring 0x6f3510`, whose entire body is `lua_type` then
/// `cmp eax,4 / cmp eax,3`: LUA_TSTRING or LUA_TNUMBER pass and are read by coercion, and every
/// other tag takes `0x53121b je 0x53133a` over the whole compare chain to the join, leaving the
/// zeroed local — mode 0, `ANCHOR_LEFT`, silently, with no `luaL_error` anywhere in
/// `[0x531221, 0x53133a)` (wow-re `system/ui/scratch/tooltip-cursor-anchor-law.md` §0.2, the same
/// section decision 2176 already quotes in this file's doc comments). So a boolean, a table, a
/// function and absent are *indistinguishable* at this position.
///
/// The symptom that found it: `Questie` hovers a world-map note with
/// `Tooltip:SetOwner(this, this)` (`QuestieNotes.lua` `Questie_Tooltip_OnEnter`) — a frame where
/// the anchor goes — and got `bad argument #3: error converting Lua table to String` where the
/// reference draws the tooltip.
#[test]
fn a_non_string_anchor_is_silently_mode_zero_and_never_raises() {
let s = script();
s.run(
r#"
Plate = CreateFrame("GameTooltip", "Plate")
Owner = CreateFrame("Frame", "Owner")
"#,
)
.unwrap();
// A TABLE — Questie's own call shape, a frame passed where the anchor string goes.
s.run("Plate:SetOwner(Owner, Owner)")
.expect("a table anchor takes the isstring gate's jump, it does not raise");
assert_eq!(
s.eval::<String>("return Plate:GetAnchorType()").unwrap(),
"ANCHOR_LEFT"
);
// A BOOLEAN and an explicit nil are the same jump.
for arg in ["true", "false", "nil"] {
s.run(&format!(
r#"Plate:SetOwner(Owner, "ANCHOR_RIGHT") Plate:SetOwner(Owner, {arg})"#
))
.unwrap_or_else(|e| panic!("SetOwner(Owner, {arg}) raised: {e}"));
assert_eq!(
s.eval::<String>("return Plate:GetAnchorType()").unwrap(),
"ANCHOR_LEFT",
"{arg} is indistinguishable from absent at this position"
);
}
// A NUMBER is the one non-string tag the gate DOES admit: it is stringified and run through
// the chain, which no number can match — the same mode 0, by the other route.
s.run(r#"Plate:SetOwner(Owner, "ANCHOR_RIGHT") Plate:SetOwner(Owner, 5)"#)
.unwrap();
assert_eq!(
s.eval::<String>("return Plate:GetAnchorType()").unwrap(),
"ANCHOR_LEFT"
);
}
/// **`ANCHOR_CURSOR` is a real ninth mode**, not a string to warn about (2142's open thread 2, and
/// nine corpus files ask for it): mode 6 clears at `SetOwner` time like mode 7, and then the
/// per-frame update `0x530b20` pins the plate's **BOTTOM** to the screen's **BOTTOMLEFT** at the
@@ -801,3 +801,29 @@ fn a_raise_with_a_hidden_child_keeps_the_windows_own_siblings_level() {
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **A script level change carries no children** (decision 2189): the Lua binding `0x774560`
/// calls `set_frame_level 0x76a4f0` with `propagate=0` — only the raise shifts a subtree. Stock
/// FrameXML is written against it (`BonusActionButtonTemplate` raises the button and then its
/// cooldown by hand), and carrying the children put the bonus bar's sweep over an addon's
/// cooldown count.
#[test]
fn a_script_level_change_leaves_the_children_where_they_were() {
let mut s = script();
s.run(
r#"
Parent = CreateFrame("Frame", "Parent")
Child = CreateFrame("Frame", "Child", Parent)
Grandchild = CreateFrame("Frame", "Grandchild", Child)
"#,
)
.unwrap();
let (child, grandchild) = (level(&mut s, "Child"), level(&mut s, "Grandchild"));
s.run("Parent:SetFrameLevel(7)").unwrap();
assert_eq!(level(&mut s, "Parent"), 7);
assert_eq!(
(level(&mut s, "Child"), level(&mut s, "Grandchild")),
(child, grandchild),
"the children keep their absolute levels"
);
}
+12 -8
View File
@@ -6,6 +6,7 @@
use mlua::{Lua, Table, Value, Variadic};
use crate::layout::{Anchor, Point};
use crate::script::binding_abi::optional_string;
use crate::script::object::{frame_handle_of, frame_wrapper};
use crate::script::region::region_handle_of;
use crate::script::Model;
@@ -52,16 +53,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"SetOwner",
lua.create_function(
|lua,
(this, owner, anchor, x, y): (
Table,
Table,
Option<String>,
Option<f32>,
Option<f32>,
)| {
|lua, (this, owner, anchor, x, y): (Table, Table, Value, Option<f32>, Option<f32>)| {
let h = frame_handle_of(lua, &this)?;
let owner_h = frame_handle_of(lua, &owner)?;
// The gate is `lua_isstring 0x6f3510`, whose whole body is `lua_type` then
// `cmp eax,4 / cmp eax,3` — LUA_TSTRING or LUA_TNUMBER and nothing else — so the
// anchor argument is read the reference's way, by COERCION, and a boolean, table,
// function or userdata is indistinguishable from absent. Taking it as
// `Option<String>` made mlua's converter the gate instead, and mlua raises on a
// table: `Questie`'s `Tooltip:SetOwner(this, this)` (QuestieNotes.lua, the map-note
// hover) died with "bad argument #3: error converting Lua table to String" on a
// call the reference completes silently at mode 0. This function's doc comment has
// stated the law since 2176 — only the signature disagreed.
let anchor = optional_string(lua, &anchor);
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);