diff --git a/crates/benilla-app/src/cursor.rs b/crates/benilla-app/src/cursor.rs index ee4ab3786..1253ed0ce 100644 --- a/crates/benilla-app/src/cursor.rs +++ b/crates/benilla-app/src/cursor.rs @@ -250,6 +250,18 @@ fn drive_displayed_cursor( unable: true, }, Some(UiCursorMode::Point) => WorldCursor::default(), + Some(UiCursorMode::UnablePoint) => WorldCursor { + kind: CursorKind::Point, + unable: true, + }, + Some(UiCursorMode::Attack) => WorldCursor { + kind: CursorKind::Attack, + unable: false, + }, + Some(UiCursorMode::UnableAttack) => WorldCursor { + kind: CursorKind::Attack, + unable: true, + }, // `ResetCursor`: back to the base mode, Repair included. None => base, }; diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index b4fed8152..1657cae13 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -715,7 +715,8 @@ pub(super) fn paint_script( let mut out = Vec::new(); let mut assets = world_assets; let mut minimap_slot = None; - // Message lines' link spans for the click hit-test; region FontStrings' are not collected. + // The link spans of text with a link owner, scrolling message lines and SimpleHTML blocks, + // for the click hit-test; other text's are not collected. let mut link_spans: Vec<( benilla_ui::widget::FrameHandle, benilla_ui::layout::Rect, @@ -1507,6 +1508,7 @@ fn convert_entry( edit_line, non_space_wrap, max_lines, + link_owner, } => { // No atlas (no client data, or an unreadable font) draws no text. let (Some(atlas), Some(text)) = (font_atlas.as_deref_mut(), text) else { @@ -1531,6 +1533,7 @@ fn convert_entry( edit_line, non_space_wrap, max_lines, + link_owner, }, text::TextHost { z: eq.z, diff --git a/crates/benilla-app/src/ui_script/extract/text.rs b/crates/benilla-app/src/ui_script/extract/text.rs index feb21daac..a84fc52ce 100644 --- a/crates/benilla-app/src/ui_script/extract/text.rs +++ b/crates/benilla-app/src/ui_script/extract/text.rs @@ -37,6 +37,9 @@ pub(super) struct TextStyle { pub non_space_wrap: bool, /// `maxLines`: the ellipsis cuts the text to this many lines, 0 for the rect's height. pub max_lines: u32, + /// The text frame whose link buttons this text's spans take, a scrolling message line's or a + /// SimpleHTML block's. + pub link_owner: Option, } /// The extraction loop's context for one Text quad. @@ -60,8 +63,8 @@ pub(super) struct TextHost<'a> { pub caret_pinned: bool, } -/// Rasterize one Text quad into `out`, and a message-frame line's hyperlink spans into -/// `link_spans`. Glyph quads take the region's `z` ([`layout_text_quads`]). +/// Rasterize one Text quad into `out`, and the hyperlink spans of a text that has a link owner +/// into `link_spans`. Glyph quads take the region's `z` ([`layout_text_quads`]). pub(super) fn emit( atlas: &mut UiFontAtlas, text: &str, @@ -243,18 +246,15 @@ pub(super) fn emit( } out.extend(sq); } - let mut glyphs = if let ZTarget::Frame(fh) = host.target { - // A frame-targeted Text quad is a message line: collect its links for the click hit-test. + let mut glyphs = if let Some(fh) = style.link_owner { + // A text whose links take buttons: collect them for the click hit-test. let mut spans = Vec::new(); let g = layout_text_quads_links( &mut atlas.lock(), - text, - host.rect, + draw_text, + draw_rect, base_color, - crate::ui_text::Justify { - h: style.justify_h, - v: style.justify_v, - }, + draw_justify, host.z, spec, seat, diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index ca19ced16..c2a2b08cc 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -391,8 +391,19 @@ impl Plugin for UiScriptPlugin { ) .chain(), ) - // In the feed phase, so an `OnUpdate` reads this frame's `GetFramerate()`. + // `GetFramerate()`'s ring, a static of the client's that no reload or logout + // resets: the feed hands a new VM its rate before the tick, and each world frame's + // step joins the ring after its paint, standing for `CGWorldFrame::OnRender` + // (`0x482ea0`), so an `OnUpdate` reads the frames before its own. + .init_resource::() .add_systems(Update, feed_framerate.in_set(UiFeed)) + .add_systems( + Update, + record_world_frame_time + .after(UiPaint) + .after(WorldStage::Present) + .run_if(in_state(crate::client_state::ClientState::InWorld)), + ) // The input pass hit-tests the rects the tick just resolved, and runs in-world only or // on the stock glue screens. .init_resource::() @@ -503,29 +514,27 @@ pub(crate) use lifecycle::{ finish_ui_load, is_emote_token_line, seat_from_roster, shutdown_ui_state, }; -/// `GetFramerate()`'s host half: FPS on the frame step the UI ticks on, smoothed by a one-second -/// pole; how the reference averages it is untraced. -fn feed_framerate( +/// The 30 world-frame times `GetFramerate()` averages (`0xc631c8`). +#[derive(Resource, Default)] +struct WorldFrameTimes(benilla_ui::script::FrameTimeRing); + +/// `GetFramerate()`'s rate as the ring stands, for the tick about to run. +fn feed_framerate(script: Option>, times: Res) { + if let Some(mut script) = script { + script.set_framerate(times.0.rate()); + } +} + +/// One world frame's step into the ring (`0x672700`), the elapsed every `OnUpdate` was handed. +fn record_world_frame_time( script: Option>, step: Res, - mut smoothed: Local, + mut times: ResMut, ) { - let Some(mut script) = script else { - return; - }; - let dt = step.delta.as_secs_f64(); - if dt <= 0.0 { - return; // the first frame, or a paused one + times.0.push(step.delta.as_secs_f32()); + if let Some(mut script) = script { + script.set_framerate(times.0.rate()); } - let instant = 1.0 / dt; - // One-pole IIR with a 1 s time constant, frame-rate independent. - let alpha = (dt / 1.0).min(1.0); - *smoothed = if *smoothed <= 0.0 { - instant - } else { - *smoothed + alpha * (instant - *smoothed) - }; - script.set_framerate(*smoothed); } /// Synthetic player and target snapshots for a server-less UI capture, and its first events once. diff --git a/crates/benilla-ui/src/script/client.rs b/crates/benilla-ui/src/script/client.rs index d5ce41419..6f5603b48 100644 --- a/crates/benilla-ui/src/script/client.rs +++ b/crates/benilla-ui/src/script/client.rs @@ -223,8 +223,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // `GetFramerate()`: frames per second, a smoothed value the app pushes each tick; 0, a number, - // before the first push. + // `GetFramerate()` (`0x4893e0`): one number, the [`FrameTimeRing::rate`] the app pushes after + // each world frame's render, 1000 before the first. g.set( "GetFramerate", lua.create_function(|lua, ()| { @@ -285,12 +285,52 @@ impl super::UiScript { } } - /// Push the current framerate, from the app's frame clock each tick; this crate has no clock. + /// Push the current framerate, [`FrameTimeRing::rate`]; this crate has no clock. pub fn set_framerate(&mut self, fps: f64) { self.model_mut().framerate = fps; } } +/// The frame-time ring `GetFramerate` averages: 30 `f32` seconds at `0xc631c8` and the write index +/// at `0xc7b488`, a static that starts at zero and is never reset. `CGWorldFrame::OnRender` +/// (`0x482ea0`) records each world frame's step into it (`0x48323f` → `0x66fcc0` stores it, +/// `0x483297` → `0x66fd50` → `0x672700` writes it at the index and advances, wrapping at 30). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FrameTimeRing { + times: [f32; 30], + next: usize, +} + +impl FrameTimeRing { + /// The f32 1/30 the sum is scaled by (`0x807334`), not the exact third of a tenth. + const THIRTIETH: f32 = f32::from_bits(0x3d08_8889); + /// The mean below which the rate answers 1000 (`0x801360`). + const FLOOR: f32 = 0.001; + + /// `0x672700`: store one frame's seconds at the write index and advance it. + pub fn push(&mut self, seconds: f32) { + self.times[self.next] = seconds; + self.next = (self.next + 1) % self.times.len(); + } + + /// `0x6724c0`: the 30 times summed from the write index on, oldest first, in double precision, + /// scaled by [`Self::THIRTIETH`]; a mean under [`Self::FLOOR`] answers 1000 (`0x6724f7`), any + /// other `1 / mean` (`0x6724fe`), so the empty ring reads 1000 and a part-filled one reads + /// high. + pub fn rate(&self) -> f64 { + let n = self.times.len(); + let sum: f64 = (0..n) + .map(|k| f64::from(self.times[(self.next + k) % n])) + .sum(); + let mean = sum * f64::from(Self::THIRTIETH); + if mean < f64::from(Self::FLOOR) { + 1000.0 + } else { + 1.0 / mean + } + } +} + impl UiScript { /// `SetEuropeanNumbers`'s switch `[0xcf4d24]`, a client global no UI rebuild resets. pub fn european_numbers(&self) -> bool { @@ -391,15 +431,12 @@ mod tests { ); } + /// Before any world frame the ring is empty, whose rate is 1000 (`0x6724f7`). #[test] - fn get_framerate_is_a_number_before_the_first_push() { + fn get_framerate_is_1000_before_the_first_push() { let mut s = UiScript::new().unwrap(); - assert_eq!(s.eval::("return GetFramerate()").unwrap(), 0.0); - assert_eq!( - s.eval::("return format(\"%.1f\", GetFramerate())") - .unwrap(), - "0.0" - ); + assert_eq!(s.arity("GetFramerate()").unwrap(), 1); + assert_eq!(s.eval::("return GetFramerate()").unwrap(), 1000.0); s.set_framerate(59.94); assert_eq!( s.eval::("return format(\"%.1f\", GetFramerate())") @@ -408,6 +445,61 @@ mod tests { ); } + /// The ring's rate: an empty or barely filled ring answers 1000, a full one 1 over the mean of + /// its 30 times as `0x6724c0` scales it, and a hitch shows at once and is gone 30 frames on. + #[test] + fn the_frame_time_ring_averages_the_last_30_frames() { + use super::FrameTimeRing; + let mut ring = FrameTimeRing::default(); + assert_eq!(ring.rate(), 1000.0); + ring.push(1.0 / 60.0); + assert_eq!(ring.rate(), 1000.0, "a mean of 1/1800 s is under 0.001"); + for _ in 1..30 { + ring.push(1.0 / 60.0); + } + let frame = f64::from(1.0f32 / 60.0); + let thirtieth = f64::from(f32::from_bits(0x3d08_8889)); + let mut sum = 0.0; + for _ in 0..30 { + sum += frame; + } + assert_eq!(ring.rate(), 1.0 / (sum * thirtieth)); + assert!((ring.rate() - 60.0).abs() < 1e-4, "{}", ring.rate()); + ring.push(0.5); + let hitched = ring.rate(); + assert!( + (hitched - 30.5).abs() < 0.01, + "the hitch shows on the next read: {hitched}" + ); + for _ in 0..29 { + ring.push(1.0 / 60.0); + } + assert!( + (ring.rate() - hitched).abs() < 1e-9, + "29 frames on it is still in the ring" + ); + ring.push(1.0 / 60.0); + assert!((ring.rate() - 60.0).abs() < 1e-4, "30 frames on it is gone"); + } + + /// The sum runs oldest first from the write index (`0x6724c6`): with 2^54 there, each 1.0 + /// after it rounds away, where a walk from slot 0 would add the 29 ones first and keep 28. + #[test] + fn the_frame_time_ring_sums_from_the_write_index() { + use super::FrameTimeRing; + let big = 2f32.powi(54); + let mut ring = FrameTimeRing::default(); + for k in 0..30 { + ring.push(if k == 29 { big } else { 1.0 }); + } + for _ in 0..29 { + ring.push(1.0); // the write index is now 29, the slot holding 2^54 + } + let thirtieth = f64::from(f32::from_bits(0x3d08_8889)); + assert_eq!(ring.rate(), 1.0 / (f64::from(big) * thirtieth)); + assert_ne!(ring.rate(), 1.0 / ((f64::from(big) + 28.0) * thirtieth)); + } + /// A failure is recorded, not raised: `0x704ae0` runs the chunk under `lua_pcall(L, 0, 0, -2)` /// with the registry's error handler. #[test] diff --git a/crates/benilla-ui/src/script/colorselect.rs b/crates/benilla-ui/src/script/colorselect.rs index 7d6e3852a..734f26671 100644 --- a/crates/benilla-ui/src/script/colorselect.rs +++ b/crates/benilla-ui/src/script/colorselect.rs @@ -248,6 +248,29 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "GetColorRGB", lua.create_function(|lua, this: Table| with_colorselect(lua, &this, |s| s.rgb_f64()))?, )?; + m.set( + "SetColorHSV", + // A bare `lua_tonumber` on h, s, v (`0x78e9b2`-`0x78e9d0`), each stored as f32 with no + // clamp, then the same redraw and `OnColorSelect` tail as SetColorRGB (`0x78e9f1`). + lua.create_function(|lua, (this, h, s, v): (Table, Value, Value, Value)| { + let [h, s, v] = [h, s, v].map(|x| crate::script::object::tonumber_f64(lua, &x) as f32); + let (qr, qg, qb) = with_colorselect(lua, &this, |c| { + c.set_hsv(h, s, v); + c.rgb_f64() + })?; + fire_color_select(lua, &this, qr, qg, qb) + })?, + )?; + m.set( + "GetColorHSV", + // The three stored floats widened (`0x78ea9c`-`0x78eacd`): hue in degrees, unclamped. + lua.create_function(|lua, this: Table| { + with_colorselect(lua, &this, |c| { + let [h, s, v] = c.hsv.map(f64::from); + (h, s, v) + }) + })?, + )?; // The four texture accessors (`0x78de90`, `0x78e160`, `0x78e450`, `0x78e720`; getters // `0x78dd80`, `0x78e070`), through which the XML loader installs a ColorSelect's elements. @@ -579,6 +602,53 @@ mod tests { assert_eq!(from_handler, from_getter); } + /// `SetColorHSV` stores three raw f32s (`0x78e9b7`-`0x78e9d5`), coercing a non-number to 0 + /// with no clamp and no gate, and fires on every call; `GetColorHSV` answers them widened. + #[test] + fn the_hsv_pair_stores_raw_floats_and_fires_every_time() { + let s = UiScript::new().unwrap(); + s.run( + r#" + fired = {} + cs = CreateFrame("ColorSelect", "TestColorSelectHsv") + cs:SetScript("OnColorSelect", function() + table.insert(fired, arg1 .. "," .. arg2 .. "," .. arg3) + end) + "#, + ) + .unwrap(); + assert_eq!(s.arity("cs:GetColorHSV()").unwrap(), 3); + assert_eq!(s.arity("cs:SetColorHSV(1, 1, 1)").unwrap(), 0); + s.run("fired = {}").unwrap(); + + s.run("cs:SetColorHSV(0.1, 0.5, 0.25)").unwrap(); + let hsv: (f64, f64, f64) = s.eval("return cs:GetColorHSV()").unwrap(); + assert_eq!(hsv, (f64::from(0.1f32), 0.5, 0.25), "stored as f32"); + assert_ne!(hsv.0, 0.1); + + s.run("cs:SetColorHSV(400, 2, -1)").unwrap(); + let hsv: (f64, f64, f64) = s.eval("return cs:GetColorHSV()").unwrap(); + assert_eq!(hsv, (400.0, 2.0, -1.0), "no clamp"); + + s.run(r#"cs:SetColorHSV("x")"#).unwrap(); + let hsv: (f64, f64, f64) = s.eval("return cs:GetColorHSV()").unwrap(); + assert_eq!(hsv, (0.0, 0.0, 0.0), "a non-number reads 0"); + + s.run("fired = {} cs:SetColorHSV(120, 1, 1) cs:SetColorHSV(120, 1, 1)") + .unwrap(); + assert_eq!(s.eval::("return table.getn(fired)").unwrap(), 2); + let (from_handler, from_getter): (String, String) = s + .eval("local r, g, b = cs:GetColorRGB() return fired[2], r .. \",\" .. g .. \",\" .. b") + .unwrap(); + assert_eq!(from_handler, from_getter); + assert_eq!(from_handler, "0,1,0"); + + // An RGB grey stores hue -1 (`0x7bbccd`), readable through the HSV getter. + s.run("cs:SetColorRGB(0.5, 0.5, 0.5)").unwrap(); + assert_eq!(s.eval::("return (cs:GetColorHSV())").unwrap(), -1.0); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } + #[test] fn the_methods_do_not_leak_onto_other_kinds() { let s = UiScript::new().unwrap(); diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 4102da7ba..d28462cc6 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -413,6 +413,12 @@ pub enum UiCursorMode { /// UnableCast (22), `SetCursor("CAST_ERROR_CURSOR")`: a unit frame the armed spell cannot /// target, and leaving one while still targeting. CastError, + /// Attack (4), `SetCursor("ATTACK_CURSOR")`. + Attack, + /// UnablePoint (21), `SetCursor("POINT_ERROR_CURSOR")`. + UnablePoint, + /// UnableAttack (24), `SetCursor("ATTACK_ERROR_CURSOR")`. + UnableAttack, } /// `UseContainerItem` past its clear. An empty slot or a locked item ends it, repair mode @@ -997,27 +1003,44 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { }) })?, )?; - // SetCursor(name) (`0x489490`): POINT, CAST, BUY and ATTACK are modes 1-4, each `*_ERROR` 20 - // higher, and no argument is a `ResetCursor`. The stock UI calls it only from the unit-frame - // hover (`UnitFrame.lua:50`). + // SetCursor(name) (`0x489490`) returns nothing for nil, after the reset to the base mode + // (`0x489518`); anything `lua_tostring` cannot convert, no argument included, raises its usage + // line (`0x489537`). A number converts to its text. The name matches one of eight pairs + // ignoring case (`0x48955b`), POINT, CAST, BUY and ATTACK as modes 1-4 and each `*_ERROR` 20 + // higher, and answers 1 (`0x48958f` → `0x489575`). Any other name is a custom cursor + // (`0x48956c`), which is not built: it answers as one that fails to load, nil with the cursor + // unchanged (`0x48959c`). lua.globals().set( "SetCursor", - lua.create_function(|lua, name: Option| { - let mode = match name.as_deref() { - None => None, // no-arg == ResetCursor - Some("POINT_CURSOR") => Some(UiCursorMode::Point), - Some("CAST_CURSOR") => Some(UiCursorMode::Cast), - Some("CAST_ERROR_CURSOR") => Some(UiCursorMode::CastError), - Some("BUY_CURSOR") => Some(UiCursorMode::Buy), - Some("BUY_ERROR_CURSOR") => Some(UiCursorMode::UnableBuy), - // Ignored: the reference loads an unknown name as a custom cursor bitmap, which is - // not built. - Some(_) => return Ok(()), + lua.create_function(|lua, args: MultiValue| { + let first = args.into_iter().next(); + if matches!(first, Some(Value::Nil)) { + let mut model = lua.app_data_mut::().expect("model app_data"); + model.ui_cursor = None; + model.ui_cursor_dirty = true; + return Ok(MultiValue::new()); + } + let name = first + .as_ref() + .and_then(|v| super::binding_abi::optional_string(lua, v)) + .ok_or_else(|| mlua::Error::runtime("Usage: SetCursor(\"cursor\" or nil)"))?; + const NAMED: [(&str, UiCursorMode); 8] = [ + ("POINT_CURSOR", UiCursorMode::Point), + ("CAST_CURSOR", UiCursorMode::Cast), + ("BUY_CURSOR", UiCursorMode::Buy), + ("ATTACK_CURSOR", UiCursorMode::Attack), + ("POINT_ERROR_CURSOR", UiCursorMode::UnablePoint), + ("CAST_ERROR_CURSOR", UiCursorMode::CastError), + ("BUY_ERROR_CURSOR", UiCursorMode::UnableBuy), + ("ATTACK_ERROR_CURSOR", UiCursorMode::UnableAttack), + ]; + let Some(&(_, mode)) = NAMED.iter().find(|(n, _)| n.eq_ignore_ascii_case(&name)) else { + return Ok(MultiValue::from_vec(vec![Value::Nil])); }; let mut model = lua.app_data_mut::().expect("model app_data"); - model.ui_cursor = mode; + model.ui_cursor = Some(mode); model.ui_cursor_dirty = true; - Ok(()) + Ok(MultiValue::from_vec(vec![Value::Integer(1)])) })?, )?; // ResetCursor() (`0x48ac70` → `0x523d30`): back to the base mode, whatever set the override. @@ -1039,6 +1062,50 @@ mod tests { use crate::script::cursor::split_container_item; use crate::script::{MerchantItem, MerchantState, UiScript}; + /// `SetCursor` (`0x489490`): nil resets and returns nothing, no argument or a value with no + /// text raises the usage line, the eight names match without case and answer 1, and any other + /// name answers nil and leaves the cursor alone. + #[test] + fn set_cursor_takes_the_eight_names_and_raises_its_usage_line() { + let s = UiScript::new().unwrap(); + assert_eq!(s.arity("SetCursor(\"attack_cursor\")").unwrap(), 1); + assert_eq!( + s.eval::("return SetCursor(\"attack_cursor\")") + .unwrap(), + 1 + ); + assert_eq!(s.ui_cursor(), Some(UiCursorMode::Attack)); + for (name, mode) in [ + ("POINT_CURSOR", UiCursorMode::Point), + ("Point_Error_Cursor", UiCursorMode::UnablePoint), + ("CAST_CURSOR", UiCursorMode::Cast), + ("cast_error_cursor", UiCursorMode::CastError), + ("BUY_CURSOR", UiCursorMode::Buy), + ("BUY_ERROR_CURSOR", UiCursorMode::UnableBuy), + ("ATTACK_ERROR_CURSOR", UiCursorMode::UnableAttack), + ] { + s.run(&format!("R = SetCursor(\"{name}\")")).unwrap(); + assert_eq!(s.eval::("return R").unwrap(), 1, "{name}"); + assert_eq!(s.ui_cursor(), Some(mode), "{name}"); + } + // Another name is a custom cursor; this one does not load: nil, the cursor as it was. + assert_eq!(s.arity("SetCursor(\"Nope\")").unwrap(), 1); + assert!(s.eval::("return SetCursor(\"Nope\") == nil").unwrap()); + assert!(s.eval::("return SetCursor(5) == nil").unwrap()); + assert_eq!(s.ui_cursor(), Some(UiCursorMode::UnableAttack)); + // Nil is the reset, with no value back. + assert_eq!(s.arity("SetCursor(nil)").unwrap(), 0); + assert_eq!(s.ui_cursor(), None); + for bad in ["SetCursor()", "SetCursor({})", "SetCursor(true)"] { + let err = s.run(bad).unwrap_err().to_string(); + assert!( + err.contains("Usage: SetCursor(\"cursor\" or nil)"), + "{bad}: {err}" + ); + } + assert_eq!(s.ui_cursor(), None, "a raise changes nothing"); + } + fn backpack() -> ContainerState { let mut slots = std::collections::HashMap::new(); slots.insert( diff --git a/crates/benilla-ui/src/script/editbox/methods.rs b/crates/benilla-ui/src/script/editbox/methods.rs index ddb51f574..228f11b75 100644 --- a/crates/benilla-ui/src/script/editbox/methods.rs +++ b/crates/benilla-ui/src/script/editbox/methods.rs @@ -310,6 +310,17 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { )?; } + // The read-backs test one bit of the flags word `+0x318` each (`0x797dc5` & 1, `0x797f35` & 2, + // `0x7980a5` & 4, `0x798215` & 8) and push 1 or nil; no argument is read. + for (name, get) in flag_getters() { + m.set( + name, + lua.create_function(move |lua, this: Table| { + with_editbox(lua, &this, |eb| binding_abi::flag(get(eb))) + })?, + )?; + } + install_font_block(lua, &m)?; lua.set_named_registry_value(REG_EDITBOX_METHODS, m)?; @@ -387,3 +398,16 @@ fn flag_setters() -> [FlagSetter; 4] { ("SetMultiLine", |eb, on| eb.multi_line = on), ] } + +/// An `Is` method name and the field it reads. +type FlagGetter = (&'static str, fn(&EditBoxState) -> bool); + +/// The four config-flag read-backs, the setters' partners. +fn flag_getters() -> [FlagGetter; 4] { + [ + ("IsAutoFocus", |eb| eb.auto_focus), + ("IsNumeric", |eb| eb.numeric), + ("IsPassword", |eb| eb.password), + ("IsMultiLine", |eb| eb.multi_line), + ] +} diff --git a/crates/benilla-ui/src/script/editbox/tests.rs b/crates/benilla-ui/src/script/editbox/tests.rs index 519489628..cf67bfb27 100644 --- a/crates/benilla-ui/src/script/editbox/tests.rs +++ b/crates/benilla-ui/src/script/editbox/tests.rs @@ -1782,3 +1782,45 @@ fn the_caret_flush_fires_on_cursor_changed_with_the_references_four_args() { ); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +/// The four read-backs answer 1 or nil off the flags word, one value each: a new box is autoFocus +/// (the ctor stores 1, `0x779a2e`) and nothing else; a bare setter enables, nil disables; XML sets +/// them through the same setters. +#[test] +fn the_mode_read_backs_answer_one_or_nil() { + let s = script(); + s.run(r#"E = CreateFrame("EditBox", "E")"#).unwrap(); + let probe = |expr: &str| -> (usize, String) { + let n = s.arity(expr).unwrap(); + let t: String = s.eval(&format!("return type(({expr}))")).unwrap(); + (n, t) + }; + assert_eq!(probe("E:IsAutoFocus()"), (1, "number".into())); + assert_eq!(s.eval::("return E:IsAutoFocus()").unwrap(), 1); + for m in ["IsMultiLine", "IsNumeric", "IsPassword"] { + assert_eq!( + probe(&format!("E:{m}()")), + (1, "nil".into()), + "{m} on a new box" + ); + } + + s.run("E:SetNumeric(); E:SetAutoFocus(nil); E:SetMultiLine(1); E:SetPassword(true)") + .unwrap(); + assert_eq!(probe("E:IsNumeric()"), (1, "number".into())); + assert_eq!(probe("E:IsAutoFocus()"), (1, "nil".into())); + assert_eq!(probe("E:IsMultiLine()"), (1, "number".into())); + assert_eq!(probe("E:IsPassword()"), (1, "number".into())); + s.run("E:SetNumeric(nil)").unwrap(); + assert_eq!(probe("E:IsNumeric()"), (1, "nil".into())); + + let doc = crate::framexml::parse( + r#""#, + ) + .unwrap(); + let report = crate::loader::load(&s, &doc, &|_| None); + assert!(report.errors.is_empty(), "errors: {:?}", report.errors); + assert_eq!(s.eval::("return Pw:IsPassword()").unwrap(), 1); + assert_eq!(probe("Pw:IsAutoFocus()"), (1, "nil".into())); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index 76837800c..f7e2683a2 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -310,6 +310,13 @@ impl UiScript { ), non_space_wrap: model.non_space_wrap_of(rh), max_lines: data.max_lines, + // A block of the SimpleHTML's last `SetText` (`0x78b011`). + link_owner: owner.filter(|o| { + model + .simple_html + .get(o) + .is_some_and(|st| st.blocks.contains(&rh)) + }), } } else { // The draw gate is the texture slot, never the colour (`0x7706e0`: empty diff --git a/crates/benilla-ui/src/script/hyperlink.rs b/crates/benilla-ui/src/script/hyperlink.rs index c1ac1ea67..51da82766 100644 --- a/crates/benilla-ui/src/script/hyperlink.rs +++ b/crates/benilla-ui/src/script/hyperlink.rs @@ -14,10 +14,12 @@ //! hides, so a hovered one gets its leave with its old link and a held press loses its capture, and //! goes to the free list's tail. Then each span takes the free list's head (`0x7a3880`, a new //! button when it is empty), which is placed on its line and shown (`0x7a3350`); nothing is ever -//! freed. A ScrollingMessageFrame rebuilds from its update (`0x788595`) whenever its flag is up -//! ([`crate::widget::ScrollingMessageState::links_dirty`]), and a change of its on-screen width -//! or height relayouts it (`0x7882f0` → `0x788310` → `0x788750`), a rescale included, since the -//! rect it compares is in screen units (`0x788342` scales the insets by `+0x7c`). The buttons hang +//! freed. A SimpleHTML rebuilds from its update (`0x78a30b`) when `SetText` raised its flag, and +//! never on a resize. A ScrollingMessageFrame rebuilds from its update (`0x788595`) whenever its +//! flag is up ([`crate::widget::ScrollingMessageState::links_dirty`]), and a change of its +//! on-screen width or height relayouts it (`0x7882f0` → `0x788310` → `0x788750`), a rescale +//! included, since the rect it compares is in screen units (`0x788342` scales the insets by +//! `+0x7c`). The buttons hang //! off their line (`0x7a3441`), so a frame that only moves carries them along with no rebuild. //! The app lays the text out, so its spans arrive here as screen rects every frame, and a frame //! not rebuilt has its buttons re-seated in place. @@ -103,10 +105,10 @@ pub(crate) struct LinkPool { } /// Replace the spans on screen, `(text frame, y-up rect, link, text)`, a text frame's in the -/// order its rebuild places them. Only a ScrollingMessageFrame's take buttons; a MessageFrame's -/// stay text. A text frame due a rebuild ([`due`]) gets one: every live button hides, in order, -/// its leave reading the link it still carries, then each span takes a free button, is placed, -/// and shows. Any other has its live buttons re-seated on their spans with no hide and no show, +/// order its rebuild places them. Only a ScrollingMessageFrame's and a SimpleHTML's take buttons; +/// a MessageFrame's stay text. A text frame due a rebuild ([`due`]) gets one: every live button +/// hides, in order, its leave reading the link it still carries, then each span takes a free +/// button, is placed, and shows. Any other has its live buttons re-seated on their spans with no hide and no show, /// as buttons anchored to their lines follow a moved frame. pub(super) fn set_spans(lua: &Lua, spans: Vec<(FrameHandle, Rect, String, String)>) { let mut by_owner: Vec<(FrameHandle, Vec)> = Vec::new(); @@ -133,12 +135,13 @@ pub(super) fn set_spans(lua: &Lua, spans: Vec<(FrameHandle, Rect, String, String .map(|(owner, _)| *owner) .collect(); emptied.sort_by_key(|h| h.fingerprint_bits()); - // Only the SMF (`0x788ce7`) and SimpleHTML (`0x78b052`) take one; ours draws no links. + // Only the SMF (`0x788ce7`) and SimpleHTML (`0x78b052`) take one. by_owner.retain(|(owner, _)| { - model - .arena - .frame(*owner) - .is_some_and(|f| matches!(f.kind_state, KindState::ScrollingMessage(_))) + model.simple_html.contains_key(owner) + || model + .arena + .frame(*owner) + .is_some_and(|f| matches!(f.kind_state, KindState::ScrollingMessage(_))) }); by_owner.extend(emptied.into_iter().map(|owner| (owner, Vec::new()))); } @@ -156,23 +159,31 @@ pub(super) fn set_spans(lua: &Lua, spans: Vec<(FrameHandle, Rect, String, String } } -/// Whether `owner`, a ScrollingMessageFrame, rebuilds this pass: when its flag is up, which this -/// spends, or its on-screen size moved (`0x788394`, `0x7883b2`). +/// Whether `owner` rebuilds this pass: when its flag is up, which this spends, or, for a +/// ScrollingMessageFrame, its on-screen size moved (`0x788394`, `0x7883b2`); a SimpleHTML's resize +/// rebuilds nothing. /// Deviation: a span count that no longer matches the live set always rebuilds, so a state this -/// model does not flag (a ring wiped by `SetMaxLines`, a hidden frame) leaves no stale button. +/// model does not flag (a ring wiped by `SetMaxLines`, a hidden frame, a frame not yet laid out, +/// which the reference's rebuild skips at `0x78af7b`) leaves no stale button. fn due(lua: &Lua, owner: FrameHandle, list: &[Span]) -> bool { let mut model = lua.app_data_mut::().expect("model"); let model = &mut *model; let size = model .frame_rect(owner) .map(|r| (r.right - r.left, r.top - r.bottom)); - let flagged = match model.arena.frame_mut(owner).map(|f| &mut f.kind_state) { - Some(KindState::ScrollingMessage(smf)) => std::mem::take(&mut smf.links_dirty), - _ => false, + let (flagged, sized) = match model.arena.frame_mut(owner).map(|f| &mut f.kind_state) { + Some(KindState::ScrollingMessage(smf)) => (std::mem::take(&mut smf.links_dirty), true), + _ => ( + model + .simple_html + .get_mut(&owner) + .is_some_and(|st| std::mem::take(&mut st.links_dirty)), + false, + ), }; let pool = model.link_buttons.entry(owner).or_default(); let resized = std::mem::replace(&mut pool.size, size).is_some_and(|was| Some(was) != size); - flagged || resized || pool.live.len() != list.len() + flagged || (sized && resized) || pool.live.len() != list.len() } /// Re-seat `owner`'s live buttons on `list`, one each, with no hide and no show. True when a diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index 5f28343c9..6b63bc6a0 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -878,6 +878,7 @@ impl UiScript { // A solve may have resized a frame: its backdrop re-runs its texcoords (`0x76b580`). if model.layout_solves != solves { super::backdrop::after_resolve(model); + super::messageframe::after_resolve(model); } if !model.layout_verify_recheck { return; diff --git a/crates/benilla-ui/src/script/messageframe/mod.rs b/crates/benilla-ui/src/script/messageframe/mod.rs index 958e90bf8..6ce3a1374 100644 --- a/crates/benilla-ui/src/script/messageframe/mod.rs +++ b/crates/benilla-ui/src/script/messageframe/mod.rs @@ -34,35 +34,115 @@ pub(super) use scrolling::REG_SCROLLINGMESSAGEFRAME_METHODS; pub(super) fn update(model: &mut Model, h: FrameHandle, elapsed: f32) { let rows = UiScript::message_viewport_rows(model, h); let viewport = UiScript::message_viewport(model, h); - let layout_key = UiScript::message_layout_key(model, h); + let font_key = UiScript::message_font_key(model, h); match model.arena.frame_mut(h).map(|f| &mut f.kind_state) { Some(KindState::Message(mf)) => { mf.trim_to_viewport(rows); mf.tick(elapsed); } Some(KindState::ScrollingMessage(smf)) => { - // A resize or a font change relayouts at the current position (`0x788427`, - // `0x788086`), as a show does (`0x788451`, [`on_visibility`]). - if smf.laid.replace(layout_key) != Some(layout_key) { + // A font change relayouts at the anchor (`0x788086`); the resize is the resolve's. + if smf.laid_font.replace(font_key) != Some(font_key) { smf.relayout(viewport); } - smf.tick(elapsed, viewport) + smf.tick(elapsed) } _ => {} } } -/// A scrolling frame's show relayouts (`0x788451`): forget what it laid out against, so its next -/// update does. +/// `0x788310`'s size test (`0x788384`-`0x7883bd`): either dimension moved by the f32 `1e-5` +/// (`0x80c5c8`) or more, the rects in layout units as the reference's are, a NaN counting as +/// moved. +fn size_moved(old: Rect, new: Rect, eps: f64) -> bool { + let dw = + (f64::from(new.right) - f64::from(new.left)) - (f64::from(old.right) - f64::from(old.left)); + let dh = + (f64::from(new.top) - f64::from(new.bottom)) - (f64::from(old.top) - f64::from(old.bottom)); + let moved = |d: f64| d.is_nan() || d.abs() >= eps; + moved(dh) || moved(dw) +} + +/// After a resolve: a scrolling frame whose rect changed size relayouts at its anchor now, as its +/// rect application does (`0x7882f0` → `0x788310` → `0x788427`), so a read before its next update +/// sees the new layout. A hidden frame's layout is deferred until its show, here as in the +/// reference, so its rect, and this, wait for the show. +pub(super) fn after_resolve(model: &mut Model) { + let eps = f64::from(f32::from_bits(0x3727_c5ac)); + let frames: Vec<(FrameHandle, Rect)> = model + .frame_rects + .iter() + .filter(|(fh, _)| { + matches!( + model.arena.frame(**fh).map(|f| &f.kind_state), + Some(KindState::ScrollingMessage(_)) + ) + }) + .map(|(&fh, &r)| (fh, r)) + .collect(); + for (fh, rect) in frames { + let viewport = UiScript::message_viewport(model, fh); + if let Some(KindState::ScrollingMessage(smf)) = + model.arena.frame_mut(fh).map(|f| &mut f.kind_state) + { + if smf.laid_rect.is_none_or(|was| size_moved(was, rect, eps)) { + smf.laid_rect = Some(rect); + smf.relayout(viewport); + } + } + } +} + +/// A scrolling frame's show (`0x788440`) runs the OnShow script and then relayouts at the anchor +/// (`0x788451`), inside the show, with the frame already marked visible. pub(super) fn on_visibility(lua: &mlua::Lua, h: FrameHandle, visible: bool) { if !visible { return; } let mut model = lua.app_data_mut::().expect("model app_data"); + if !matches!( + model.arena.frame(h).map(|f| &f.kind_state), + Some(KindState::ScrollingMessage(_)) + ) { + return; + } + let viewport = UiScript::message_viewport(&model, h); + let font_key = UiScript::message_font_key(&model, h); + let rect = model.frame_rects.get(&h).copied(); if let Some(KindState::ScrollingMessage(smf)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) { - smf.laid = None; + smf.laid_font = Some(font_key); + smf.laid_rect = rect.or(smf.laid_rect); + smf.relayout(viewport); + } + drop(model); + fire_scroll_changes(lua, h); +} + +/// Fire `OnMessageScrollChanged` on the scrolling frame `h` once per relayout that moved its +/// anchor since the last call (`0x788a9a`-`0x788aad`), with no arguments; a handler error is +/// recorded. +pub(super) fn fire_scroll_changes(lua: &mlua::Lua, h: FrameHandle) { + let (n, id) = { + let mut model = lua.app_data_mut::().expect("model app_data"); + let n = match model.arena.frame_mut(h).map(|f| &mut f.kind_state) { + Some(KindState::ScrollingMessage(smf)) => std::mem::take(&mut smf.scroll_changes), + _ => return, + }; + if n == 0 { + return; + } + (n, model.frame_id(h)) + }; + for _ in 0..n { + if let Err(e) = + super::event::fire_widget_handler(lua, id, "OnMessageScrollChanged", Vec::new()) + { + lua.app_data_mut::() + .expect("model app_data") + .record_lua_error(&e); + } } } @@ -244,13 +324,14 @@ impl MessageFont { } /// What the scrolling class's layout fits messages into: a frame `height` tall, drawn at - /// `scale`. - fn viewport(&self, height: f32, scale: f32) -> Viewport { + /// `scale`, visible or not. + fn viewport(&self, height: f32, scale: f32, visible: bool) -> Viewport { Viewport { height, line_h: self.height_or_default() * scale, row_gap: self.row_gap(scale), message_gap: self.message_gap(scale), + visible, } } @@ -298,29 +379,16 @@ pub(super) fn ensure_font_region( impl UiScript { pub(super) fn message_frame_font(model: &Model, fh: FrameHandle) -> MessageFont { - model - .arena - .frame(fh) - .and_then(|frame| { - frame - .regions - .iter() - .find(|&&rh| { - matches!( - model.arena.region(rh).map(|r| r.kind), - Some(crate::widget::RegionKind::FontString) - ) - }) - .and_then(|rh| model.region_data.get(rh)) - .map(|d| MessageFont { - path: d.font_path.clone(), - height: d.font_height, - shadow: d.font_shadow, - outline: d.outline, - monochrome: d.monochrome, - justify_h: d.justify.paint_h(), - spacing: d.spacing, - }) + Self::message_font_region(model, fh) + .and_then(|rh| model.region_data.get(&rh)) + .map(|d| MessageFont { + path: d.font_path.clone(), + height: d.font_height, + shadow: d.font_shadow, + outline: d.outline, + monochrome: d.monochrome, + justify_h: d.justify.paint_h(), + spacing: d.spacing, }) .unwrap_or(MessageFont { path: None, @@ -333,6 +401,30 @@ impl UiScript { }) } + /// The frame font's packed text colour (`[font+0x58]`, which `SetTextColor` writes), opaque + /// white (the ctor's `-1`, `0x783a8e`) when the frame has no font region. + pub(super) fn message_frame_color(model: &Model, fh: FrameHandle) -> crate::color::Rgba8 { + Self::message_font_region(model, fh) + .and_then(|rh| model.region_data.get(&rh)) + .map_or(crate::color::Rgba8::WHITE, |d| d.vertex_color_or_white()) + } + + /// The FontString region a message frame's font lives on, if it has one. + fn message_font_region(model: &Model, fh: FrameHandle) -> Option { + model.arena.frame(fh).and_then(|frame| { + frame + .regions + .iter() + .find(|&&rh| { + matches!( + model.arena.region(rh).map(|r| r.kind), + Some(crate::widget::RegionKind::FontString) + ) + }) + .copied() + }) + } + /// How many rows the plain frame `fh`'s resolved rect holds at its font's pitch, its capacity /// (`+0x318`). Both the rect and the pitch are scaled (`0x785f0a`, `0x785f1f`); a pitch of 0 /// or less, which a negative spacing can make, holds nothing (`0x785f8f`). @@ -355,22 +447,20 @@ impl UiScript { /// quantity scaled; no rect yet is no height, which still shows the anchored message. pub(super) fn message_viewport(model: &Model, fh: FrameHandle) -> Viewport { let font = Self::message_frame_font(model, fh); - let scale = model.arena.frame(fh).map_or(1.0, |f| f.effective_scale); + let frame = model.arena.frame(fh); + let scale = frame.map_or(1.0, |f| f.effective_scale); + let visible = frame.is_some_and(|f| f.effective_visible); let height = model .frame_rect(fh) .map_or(0.0, |r| (r.top - r.bottom).max(0.0)); - font.viewport(height, scale) + font.viewport(height, scale, visible) } - /// What the scrolling frame `fh`'s relayout lays out against: its size and its font. - pub(super) fn message_layout_key(model: &Model, fh: FrameHandle) -> u64 { + /// The font the scrolling frame `fh`'s relayout lays out with, as a key. + pub(super) fn message_font_key(model: &Model, fh: FrameHandle) -> u64 { use std::hash::{Hash, Hasher}; let font = Self::message_frame_font(model, fh); let mut hasher = std::collections::hash_map::DefaultHasher::new(); - if let Some(r) = model.frame_rect(fh) { - (r.right - r.left).to_bits().hash(&mut hasher); - (r.top - r.bottom).to_bits().hash(&mut hasher); - } font.path.hash(&mut hasher); font.height.map(f32::to_bits).hash(&mut hasher); font.spacing.to_bits().hash(&mut hasher); @@ -484,6 +574,7 @@ impl UiScript { /// The key is stored beside the rows, so a line that changed since its request re-requests. pub fn set_message_line_rows(&mut self, rows: &[(u32, u32, u16, u64)]) { let mut model = self.model_mut(); + let mut moved: Vec = Vec::new(); for &(frame_id, index, n, key) in rows { let Some(&fh) = model.id_to_frame.get(&frame_id) else { continue; @@ -495,10 +586,23 @@ impl UiScript { continue; }; if let Some(line) = lines.get_mut(index as usize) { + if line.rows != n.max(1) && !moved.contains(&fh) { + moved.push(fh); + } line.rows = n.max(1); line.rows_key = key; } } + // The reference measures each line inside its relayout's walk; here the rows land after + // it, so a scrolling frame's cached count and at-top are re-counted from them. + for fh in moved { + let viewport = Self::message_viewport(&model, fh); + if let Some(KindState::ScrollingMessage(smf)) = + model.arena.frame_mut(fh).map(|f| &mut f.kind_state) + { + smf.recount(viewport); + } + } } /// Push one [`QuadContent::Text`] per visible message of either class, each `rows·S + @@ -529,7 +633,7 @@ impl UiScript { match &frame.kind_state { KindState::ScrollingMessage(smf) => ( &smf.lines, - smf.lines.len().saturating_sub(1 + smf.scroll_offset), + smf.lines.len().saturating_sub(1 + smf.scroll_offset()), false, ), KindState::Message(mf) => ( @@ -550,7 +654,7 @@ impl UiScript { }; // The resolved rect is scaled and the font is frame-local, so the height and gap scale. let line_h = font.height_or_default() * paint.scale; - let viewport = font.viewport(fr.top - fr.bottom, paint.scale); + let viewport = font.viewport(fr.top - fr.bottom, paint.scale, true); // A message's advance to the next: the scrolling class's block and snapped gap, the plain // class's own pitch per row. let advance = |rows: u16| match &frame.kind_state { @@ -587,7 +691,7 @@ impl UiScript { (fr.bottom + used, fr.bottom + used + band_h) }; used += advance(line.rows); - if line.alpha <= 0.0 { + if !line.shown || line.alpha <= 0.0 { continue; // faded: holds its rows, draws nothing } out.push(ExtractedQuad { @@ -629,6 +733,8 @@ impl UiScript { // A line is a FontString with the flag off and no `maxLines` (`0x7865f0`). non_space_wrap: false, max_lines: 0, + // Only the scrolling class gives its lines link buttons (`0x788ce7`). + link_owner: reverse.then_some(fh), }, scale: paint.scale, }); diff --git a/crates/benilla-ui/src/script/messageframe/scrolling.rs b/crates/benilla-ui/src/script/messageframe/scrolling.rs index 246c18499..d69367c5f 100644 --- a/crates/benilla-ui/src/script/messageframe/scrolling.rs +++ b/crates/benilla-ui/src/script/messageframe/scrolling.rs @@ -34,25 +34,48 @@ fn with_smf( } } -/// Run `f` over the state with the viewport's row count, which every scroll needs: each re-arms -/// the fade of the lines on display ([`ScrollingMessageState::reset_all_fade_times`]), and `AtTop` -/// reads the layout's flag. +/// Run `f` over the state with the viewport, which every entry that relayouts needs +/// ([`ScrollingMessageState::layout`]). fn with_viewport( lua: &Lua, this: &Table, f: impl FnOnce(&mut ScrollingMessageState, Viewport) -> T, ) -> mlua::Result { let h = frame_handle_of(lua, this)?; - let mut model = lua.app_data_mut::().expect("model app_data"); - let viewport_rows = UiScript::message_viewport(&model, h); - let frame = model - .arena - .frame_mut(h) - .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; - match &mut frame.kind_state { - KindState::ScrollingMessage(smf) => Ok(f(smf, viewport_rows)), - _ => Err(mlua::Error::runtime("not a ScrollingMessageFrame")), + let out = { + let mut model = lua.app_data_mut::().expect("model app_data"); + let viewport_rows = UiScript::message_viewport(&model, h); + let frame = model + .arena + .frame_mut(h) + .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; + match &mut frame.kind_state { + KindState::ScrollingMessage(smf) => f(smf, viewport_rows), + _ => return Err(mlua::Error::runtime("not a ScrollingMessageFrame")), + } + }; + super::fire_scroll_changes(lua, h); + Ok(out) +} + +/// `PageUp` (`up`) or `PageDown` as [`ScrollingMessageState::page_up`] runs it, one step per +/// call, so an `OnMessageScrollChanged` fires after each step that moved the anchor. +fn page(lua: &Lua, this: &Table, up: bool) -> mlua::Result<()> { + let n = with_smf(lua, this, |smf| smf.lines_shown)?; + let step = |smf: &mut ScrollingMessageState, v: Viewport, toward_top: bool| { + if toward_top { + smf.step_up(v) + } else { + smf.step_down(v) + } + }; + for _ in 0..n { + if !with_viewport(lua, this, |smf, v| step(smf, v, up))? { + return Ok(()); + } } + with_viewport(lua, this, |smf, v| step(smf, v, !up))?; + Ok(()) } /// Run a scroll call: [`with_viewport`] for an op with no answer. @@ -70,8 +93,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // The justify quartet, on both classes ([`super::install_justify`]). super::install_justify(lua, &m)?; - // AddMessage(text [, r, g, b [, id]]) (`0x792900`): white without rgb; the state quantizes - // the colour and makes the line opaque. The rgb test is three `lua_isnumber` checks; the id + // AddMessage(text [, r, g, b [, id]]) (`0x792900`): the state quantizes the colour and makes + // the line opaque; without rgb the line takes the frame font's packed colour, alpha included + // (`0x792b39`-`0x792b45`). The rgb test is three `lua_isnumber` checks; the id // is stack index 6 when they pass (`0x792b13`) and index 3, `r`'s, when they fail // (`0x792b48`), which makes `AddMessage(text, id)` work and gives // `AddMessage(text, nil, nil, nil, nil, 5)` id 0. A missing id is 0, which `UpdateColorByID` @@ -89,13 +113,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // `lua_isnumber`-gated `_ftol` (`0x792b1b`, `0x792b4f`); 0 when the gate fails. let id = is_number(lua, if has_rgb { &id } else { &r }) .map_or(0, |n| crate::script::binding_abi::ftol(n) as u32); - let (r, g, b) = if has_rgb { - (channel(lua, &r), channel(lua, &g), channel(lua, &b)) - } else { - (1.0, 1.0, 1.0) + if has_rgb { + let (r, g, b) = (channel(lua, &r), channel(lua, &g), channel(lua, &b)); + return with_viewport(lua, &this, |smf, rows| { + smf.add_with_id(text, r, g, b, id, rows); + }); + } + let color = { + let h = frame_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model app_data"); + UiScript::message_frame_color(&model, h) }; with_viewport(lua, &this, |smf, rows| { - smf.add_with_id(text, r, g, b, id, rows); + smf.add_packed(text, color, id, rows); }) }, )?, @@ -127,7 +157,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { m.set( "Clear", - lua.create_function(|lua, this: Table| with_smf(lua, &this, ScrollingMessageState::clear))?, + // The relayout's `OnMessageScrollChanged` fires before at top and the write position are + // reset (`0x7882c9`, then `0x7882ce`, `0x7882d8`). + lua.create_function(|lua, this: Table| { + scroll(lua, &this, ScrollingMessageState::clear_layout)?; + with_smf(lua, &this, ScrollingMessageState::clear_finish) + })?, )?; m.set( "ScrollUp", @@ -155,21 +190,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; m.set( "PageUp", - lua.create_function(|lua, this: Table| scroll(lua, &this, ScrollingMessageState::page_up))?, + lua.create_function(|lua, this: Table| page(lua, &this, true))?, )?; m.set( "PageDown", - lua.create_function(|lua, this: Table| { - scroll(lua, &this, ScrollingMessageState::page_down) - })?, + lua.create_function(|lua, this: Table| page(lua, &this, false))?, )?; - // AtTop (`0x793130`) pushes 1 (`0x7931c3`) or nil (`0x7931d6`); AtBottom (`0x7931f0`) pushes 1 - // (`0x793283`) or nil (`0x793296`). + // AtTop (`0x793130`) pushes 1 (`0x7931c3`) or nil (`0x7931d6`) off the cached `+0x350` + // (`0x7931b5`); AtBottom (`0x7931f0`) the same off `+0x354` (`0x793275`). m.set( "AtTop", lua.create_function(|lua, this: Table| { - with_viewport(lua, &this, |smf, rows| { - crate::script::binding_abi::flag(smf.at_top(rows)) + with_smf(lua, &this, |smf| { + crate::script::binding_abi::flag(smf.at_top) }) })?, )?; @@ -177,7 +210,45 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "AtBottom", lua.create_function(|lua, this: Table| { with_smf(lua, &this, |smf| { - crate::script::binding_abi::flag(smf.at_bottom()) + crate::script::binding_abi::flag(smf.at_bottom) + }) + })?, + )?; + // The line-position reads push one number each: GetCurrentLine the write position, −1 when + // empty (`0x7937bf`); GetCurrentScroll `0x788ac0` (`0x7936f3`); GetNumLinesDisplayed the + // cached lines shown (`0x79362f`). + m.set( + "GetCurrentLine", + lua.create_function(|lua, this: Table| { + with_smf(lua, &this, |smf| f64::from(smf.write_pos)) + })?, + )?; + m.set( + "GetCurrentScroll", + lua.create_function(|lua, this: Table| { + with_smf(lua, &this, |smf| f64::from(smf.current_scroll())) + })?, + )?; + m.set( + "GetNumLinesDisplayed", + lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.lines_shown as f64))?, + )?; + // SetScrollFromBottom(n) (`0x793070`) tests `lua_isnumber(2)` before the receiver check and + // returns silently without one (`0x79307f`); the number is `_ftol`ed (`0x793113`). + m.set( + "SetScrollFromBottom", + lua.create_function(|lua, (this, n): (Value, Value)| { + let Some(n) = is_number(lua, &n) else { + return Ok(()); + }; + let Value::Table(this) = this else { + return Err(mlua::Error::runtime( + "Attempt to find 'this' in non-table object (used '.' instead of ':' ?)", + )); + }; + let n = crate::script::binding_abi::ftol(n); + scroll(lua, &this, |smf, viewport| { + smf.set_scroll_from_bottom(n, viewport) }) })?, )?; @@ -215,11 +286,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { }) })?, )?; - // XML's `displayDuration` is the Lua `TimeVisible` (`0x788090`). + // XML's `displayDuration` is the Lua `TimeVisible`. SetTimeVisible (`0x793b00`) and + // SetFadeDuration (`0x793cb0`) take a `lua_isnumber` argument, else raise their usage line + // (`0x87b918`, `0x87b93c`), and store it as f32 with no clamp (`0x793ba6`, `0x793d56`). m.set( "SetTimeVisible", - lua.create_function(|lua, (this, s): (Table, f32)| { - with_smf(lua, &this, |smf| smf.time_visible = s.max(0.0)) + lua.create_function(|lua, (this, s): (Table, Value)| { + let s = is_number(lua, &s).ok_or_else(|| { + crate::script::binding_abi::usage(lua, &this, "SetTimeVisible(seconds)") + })?; + with_smf(lua, &this, |smf| smf.set_time_visible(s as f32)) })?, )?; m.set( @@ -228,8 +304,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; m.set( "SetFadeDuration", - lua.create_function(|lua, (this, s): (Table, f32)| { - with_smf(lua, &this, |smf| smf.fade_duration = s.max(0.0)) + lua.create_function(|lua, (this, s): (Table, Value)| { + let s = is_number(lua, &s).ok_or_else(|| { + crate::script::binding_abi::usage(lua, &this, "SetFadeDuration(seconds)") + })?; + with_smf(lua, &this, |smf| smf.set_fade_duration(s as f32)) })?, )?; m.set( @@ -291,7 +370,7 @@ mod tests { s.set_max_lines(128); assert!(s.lines.is_empty(), "SetMaxLines wipes the ring"); assert_eq!(s.max_lines, 128); - assert!(s.at_bottom()); + assert!(s.at_bottom); } #[test] @@ -314,14 +393,14 @@ mod tests { }; s.add("x".into(), 1.0, 1.0, 1.0); // Phase 1: full alpha while timeVisible remains. - s.tick(0.5, 8); + s.tick(0.5); assert_eq!(s.lines[0].alpha, 1.0); // 0.5 more spends timeVisible; the ticks after ramp over fadeDuration. - s.tick(0.5, 8); + s.tick(0.5); assert_eq!(s.lines[0].alpha, 1.0, "phase 1 just expired, ramp not yet"); - s.tick(1.0, 8); // fade_left 2.0 → 1.0 → alpha = trunc(1.0/2.0*255)/255 = 127/255 + s.tick(1.0); // fade_left 2.0 → 1.0 → alpha = trunc(1.0/2.0*255)/255 = 127/255 assert!((s.lines[0].alpha - 127.0 / 255.0).abs() < 1e-6); - s.tick(1.0, 8); // fade_left → 0 → retired + s.tick(1.0); // fade_left → 0 → retired assert_eq!(s.lines[0].alpha, 0.0); } @@ -333,9 +412,9 @@ mod tests { ..Default::default() }; s.add("x".into(), 1.0, 1.0, 1.0); - s.tick(0.6, 8); // spends timeVisible, still full this tick - assert_eq!(s.lines[0].alpha, 1.0); - s.tick(0.1, 8); // past phase 1 with no ramp: straight to 0 + // The tick that spends timeVisible, with no fade time, hides it (`0x7884f9`). + s.tick(0.6); + assert!(!s.lines[0].shown); assert_eq!(s.lines[0].alpha, 0.0); } @@ -352,16 +431,16 @@ mod tests { assert!(!s.links_dirty, "the ctor clears it"); s.add("x".into(), 1.0, 1.0, 1.0); assert!(std::mem::take(&mut s.links_dirty), "AddMessage relayouts"); - s.tick(0.6, 8); - s.tick(0.5, 8); + s.tick(0.6); + s.tick(0.5); assert!( s.lines[0].alpha > 0.0 && !s.links_dirty, "mid-ramp: no flag" ); - s.tick(0.6, 8); + s.tick(0.6); assert_eq!(s.lines[0].alpha, 0.0); assert!(std::mem::take(&mut s.links_dirty), "the hide raises it"); - s.tick(0.6, 8); + s.tick(0.6); assert!(!s.links_dirty, "a hidden line is not hidden again"); s.scroll_down(4); assert!( @@ -383,22 +462,24 @@ mod tests { for n in 0..3 { s.add(format!("l{n}"), 1.0, 1.0, 1.0); } - s.tick(2.0, 8); // half-way down the ramp: trunc(2.0/4.0*255) = 127 + s.tick(2.0); // half-way down the ramp: trunc(2.0/4.0*255) = 127 let half = 127.0 / 255.0; assert_eq!(s.lines[0].alpha, half); - // Scrolled up, ticks stop; the scroll re-arms l1, the line now in view, and l0, the one - // the relayout walked that did not fit (`0x7888be`). + // Shrunk to one row (the resize relayouts), then scrolled up, ticks stop; the scroll + // re-arms l1, the line now in view, and l0, the one the relayout walked that did not fit + // (`0x7888be`). + s.relayout(1); s.scroll_up(1); - assert!(!s.at_bottom()); + assert!(!s.at_bottom); assert_eq!(s.lines[1].alpha, 1.0, "the displayed line was re-armed"); assert_eq!(s.lines[0].alpha, 1.0, "so was the first that did not fit"); - s.tick(2.0, 8); + s.tick(2.0); assert_eq!(s.lines[0].alpha, 1.0, "frozen while scrolled up"); assert_eq!(s.lines[2].alpha, half); assert_eq!(s.lines[1].alpha, 1.0); // Back at the bottom, the fade resumes. s.scroll_to_bottom(1); - s.tick(2.0, 8); + s.tick(2.0); assert!(s.lines[2].alpha < 1.0); } @@ -416,8 +497,8 @@ mod tests { for n in 0..3 { s.add(format!("l{n}"), 1.0, 1.0, 1.0); } - s.tick(1.5, 8); // spend phase 1 - s.tick(1.5, 8); // spend phase 2: all gone + s.tick(1.5); // spend phase 1 + s.tick(1.5); // spend phase 2: all gone assert!(s.lines.iter().all(|l| l.alpha == 0.0), "faded out first"); op(&mut s, 8); s @@ -435,7 +516,7 @@ mod tests { ] { let s = armed(op); // The re-armed set is what the op left in view, not the whole ring. - let shown = s.displayed_range(8); + let shown = s.shown_range(); assert!(!shown.is_empty(), "{name} displayed nothing"); for i in shown { let line = &s.lines[i]; @@ -459,7 +540,8 @@ mod tests { for n in 0..5 { s.add(format!("l{n}"), 1.0, 1.0, 1.0); } - s.tick(2.0, 2); // l3 and l4 are in view + s.relayout(2); // shrunk to two rows: l3 and l4 are in view + s.tick(2.0); let half = 127.0 / 255.0; assert_eq!(s.lines[4].alpha, half); assert_eq!(s.lines[3].alpha, half); @@ -467,7 +549,8 @@ mod tests { assert_eq!(s.lines[hidden].alpha, 1.0, "l{hidden} is out of view"); assert_eq!(s.lines[hidden].fade_left, 4.0); } - s.tick(2.0, 8); // the window grew: all five tick from where each stopped + s.relayout(8); // the window grew: all five tick from where each stopped + s.tick(2.0); assert_eq!(s.lines[0].alpha, half); assert_eq!(s.lines[4].alpha, 0.0); } @@ -487,16 +570,18 @@ mod tests { for n in 0..5 { s.add(format!("l{n}"), 1.0, 1.0, 1.0); } - s.tick(2.0, 8); + s.tick(2.0); assert!(s.lines.iter().all(|l| l.alpha == 0.0)); s }; let alphas = |s: &ScrollingMessageState| s.lines.iter().map(|l| l.alpha).collect::>(); let mut s = faded(); + s.relayout(2); // two rows high, at the bottom: re-arms nothing s.scroll_up(2); // shows l2 l3 (anchor l3), walks l1 as well assert_eq!(alphas(&s), [0.0, 1.0, 1.0, 1.0, 0.0]); let mut s = faded(); + s.relayout(2); s.scroll_down(2); // refused at the bottom: l3 and l4 only assert_eq!(alphas(&s), [0.0, 0.0, 0.0, 1.0, 1.0]); } @@ -513,9 +598,10 @@ mod tests { for n in 0..3 { s.add(format!("l{n}"), 1.0, 1.0, 1.0); } - s.tick(1.5, 8); - s.tick(1.5, 8); - s.scroll_down(1); // a no-op scroll at the bottom, one row of viewport + s.tick(1.5); + s.tick(1.5); + s.relayout(1); // one row of viewport + s.scroll_down(1); // a no-op scroll at the bottom assert_eq!(s.lines[2].alpha, 1.0, "the one displayed line came back"); assert_eq!(s.lines[1].alpha, 0.0, "off-screen lines are untouched"); assert_eq!(s.lines[0].alpha, 0.0); @@ -530,21 +616,22 @@ mod tests { for n in 0..3 { s.add(format!("l{n}"), 1.0, 1.0, 1.0); } - assert!(s.at_bottom()); + assert!(s.at_bottom); s.scroll_down(8); // already at the bottom: no move - assert!(s.at_bottom()); + assert!(s.at_bottom); // A frame showing every line is at the top already (`+0x350`, `0x788a37`). - assert!(s.at_top(8)); + assert!(s.at_top); s.scroll_up(8); - assert_eq!(s.scroll_offset, 0, "refused: the oldest is on screen"); + assert_eq!(s.scroll_offset(), 0, "refused: the oldest is on screen"); // One row high, the oldest shows only at offset 2; scrolling past it is refused. + s.relayout(1); for _ in 0..10 { s.scroll_up(1); } - assert!(s.at_top(1)); - assert_eq!(s.scroll_offset, 2); + assert!(s.at_top); + assert_eq!(s.scroll_offset(), 2); s.scroll_to_bottom(8); - assert!(s.at_bottom()); + assert!(s.at_bottom); } #[test] @@ -556,11 +643,12 @@ mod tests { for n in 0..4 { s.add(format!("l{n}"), 1.0, 1.0, 1.0); } + s.relayout(1); s.scroll_up(1); // viewing one line back (bottom row = l2) - let off = s.scroll_offset; + let off = s.scroll_offset(); s.add("l4".into(), 1.0, 1.0, 1.0); assert_eq!( - s.scroll_offset, + s.scroll_offset(), off + 1, "offset tracks so the same lines stay in view" ); @@ -583,9 +671,10 @@ mod tests { s }; let anchor = - |s: &ScrollingMessageState| s.lines[s.lines.len() - 1 - s.scroll_offset].text.clone(); + |s: &ScrollingMessageState| s.lines[s.lines.len() - 1 - s.scroll_offset()].text.clone(); // Two rows, one line back: l2 l1 are in view, the oldest is not. let mut s = full(); + s.relayout(2); s.scroll_up(2); assert_eq!(anchor(&s), "l2"); s.add_with_id("l4".into(), 1.0, 1.0, 1.0, 0, 2); @@ -595,6 +684,7 @@ mod tests { assert_eq!(anchor(&s), "l3", "one line newer as the oldest went"); // The oldest shows (l1 l0), so the write lands on the topmost shown message. let mut s = full(); + s.relayout(2); s.scroll_up(2); s.scroll_up(2); assert_eq!(anchor(&s), "l1"); @@ -607,7 +697,7 @@ mod tests { // At the bottom the view is the newest. let mut s = full(); s.add_with_id("l4".into(), 1.0, 1.0, 1.0, 0, 2); - assert!(s.at_bottom()); + assert!(s.at_bottom); } #[test] @@ -632,15 +722,20 @@ mod tests { assert_eq!(s.displayed_count(0), 1, "the anchored message always shows"); // `PageUp` (`0x7885b0`) scrolls up once per displayed line and then down once, so a page // is the displayed count less one, and one displayed line nets nothing. + // The relayout counts what the layout placed (`+0x34c`), here after the rows changed. + s.relayout(4); + assert_eq!(s.lines_shown, 2); s.page_up(4); // 2 displayed: up moves, the second up is refused (the oldest shows) - assert_eq!(s.scroll_offset, 1); + assert_eq!(s.scroll_offset(), 1); + s.relayout(1); s.page_up(1); // 1 displayed: up, down - assert_eq!(s.scroll_offset, 1, "a page of one line nets 0"); + assert_eq!(s.scroll_offset(), 1, "a page of one line nets 0"); // `PageDown` (`0x7885e0`): a step refused at the bottom ends the page with no step back. + s.relayout(4); s.page_down(4); // 2 displayed: down moves, down is refused - assert!(s.at_bottom()); + assert!(s.at_bottom); s.page_down(4); // 2 displayed: both refused at the first - assert!(s.at_bottom()); + assert!(s.at_bottom); } /// A refused step ends the page where it stands (`0x7885ca`), with no step back; an empty @@ -659,16 +754,18 @@ mod tests { s.add(format!("l{n}"), 1.0, 1.0, 1.0); } // Two rows high: the oldest shows from offset 2, where every step up is refused. + s.relayout(2); s.scroll_up(2); s.scroll_up(2); - assert_eq!(s.scroll_offset, 2); - assert!(s.at_top(2)); + assert_eq!(s.scroll_offset(), 2); + assert!(s.at_top); // The first up is refused, so the page ends with no down after it. s.page_up(2); - assert_eq!(s.scroll_offset, 2); + assert_eq!(s.scroll_offset(), 2); // One row high there, `PageDown` is a down and an up: it nets 0. + s.relayout(1); s.page_down(1); - assert_eq!(s.scroll_offset, 2, "down then up nets 0"); + assert_eq!(s.scroll_offset(), 2, "down then up nets 0"); } /// With two or more lines displayed and room to move, the page runs its whole loop and then @@ -682,12 +779,13 @@ mod tests { for n in 0..8 { s.add(format!("l{n}"), 1.0, 1.0, 1.0); } + s.relayout(3); s.page_up(3); // 3 displayed: up, up, up, down - assert_eq!(s.scroll_offset, 2); + assert_eq!(s.scroll_offset(), 2); s.page_up(3); - assert_eq!(s.scroll_offset, 4); + assert_eq!(s.scroll_offset(), 4); s.page_down(3); // down, down, down, up - assert_eq!(s.scroll_offset, 2); + assert_eq!(s.scroll_offset(), 2); } /// `AtTop` answers nil on an empty frame: the constructor, `Clear` and `SetMaxLines` zero the @@ -720,7 +818,7 @@ mod tests { s.scroll_to_top(8); s.scroll_to_bottom(8); assert!(!s.links_dirty); - assert_eq!(s.scroll_offset, 0); + assert_eq!(s.scroll_offset(), 0); } /// `ScrollToTop` on a single message anchors the newest (`0x788799`-`0x7887aa`); a frame @@ -736,7 +834,7 @@ mod tests { for n in 0..count { s.add(format!("l{n}"), 1.0, 1.0, 1.0); } - s.tick(2.0, 8); + s.tick(2.0); s.links_dirty = false; s.scroll_to_top(8); s @@ -745,7 +843,7 @@ mod tests { assert_eq!(one.lines[0].alpha, 127.0 / 255.0, "left as it was"); assert!(one.links_dirty, "the links still rebuild"); let two = faded(2); - assert_eq!(two.scroll_offset, 1); + assert_eq!(two.scroll_offset(), 1); assert_eq!(two.lines[0].alpha, 1.0, "the oldest was re-armed"); } @@ -762,18 +860,20 @@ mod tests { s.add(format!("l{n}"), 1.0, 1.0, 1.0); } // Three rows show 3 of 5: l4 l3 l2, then l3 l2 l1, then l2 l1 l0. - assert!(!s.at_top(3)); + s.relayout(3); + assert!(!s.at_top); s.scroll_up(3); s.scroll_up(3); - assert_eq!(s.scroll_offset, 2); - assert!(s.at_top(3), "l0 is on screen"); + assert_eq!(s.scroll_offset(), 2); + assert!(s.at_top, "l0 is on screen"); s.scroll_up(3); - assert_eq!(s.scroll_offset, 2, "refused"); + assert_eq!(s.scroll_offset(), 2, "refused"); s.scroll_to_top(3); - assert_eq!(s.scroll_offset, 4, "the oldest as the anchor"); + assert_eq!(s.scroll_offset(), 4, "the oldest as the anchor"); } - /// The Lua `AtTop` follows the viewport the frame's rect gives. + /// The Lua `AtTop` reads the flag the last relayout cached against the frame's rect; a resize + /// relayouts when the resolve applies the new rect (`0x7882f0`). #[test] fn at_top_reads_the_layout_through_lua() { let mut s = UiScript::new().unwrap(); @@ -800,6 +900,351 @@ mod tests { ); } + /// `CF`, three rows of 14 high, holding `m1`..`m{n}`, laid out against its rect. + fn three_rows(n: usize) -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(800.0, 600.0); + s.run(&format!( + "CF = CreateFrame('ScrollingMessageFrame', 'CF')\n\ + CF:SetPoint('BOTTOMLEFT', 32, 90)\n\ + CF:SetWidth(430)\n\ + CF:SetHeight(44)\n\ + for n = 1, {n} do CF:AddMessage('m' .. n) end" + )) + .unwrap(); + s.resolve(); + s + } + + /// One number, as `select('#', ...)` and `type` see it. + fn one_number(s: &UiScript, call: &str) -> f64 { + assert_eq!(s.arity(call).unwrap(), 1, "{call}"); + assert_eq!( + s.eval::(&format!("return type(({call}))")).unwrap(), + "number", + "{call}" + ); + s.eval(&format!("return {call}")).unwrap() + } + + /// `GetCurrentLine` is the ring slot of the newest line, −1 when empty (`0x7937bf`); it wraps + /// at the limit (`0x788183`-`0x788198`). + #[test] + fn get_current_line_is_the_write_slot() { + let s = UiScript::new().unwrap(); + s.run("CF = CreateFrame('ScrollingMessageFrame', 'CF')") + .unwrap(); + assert_eq!(one_number(&s, "CF:GetCurrentLine()"), -1.0); + s.run("for n = 1, 3 do CF:AddMessage('m' .. n) end") + .unwrap(); + assert_eq!(one_number(&s, "CF:GetCurrentLine()"), 2.0); + s.run("CF:SetMaxLines(3)").unwrap(); + assert_eq!(one_number(&s, "CF:GetCurrentLine()"), -1.0); + s.run("for n = 1, 5 do CF:AddMessage('m' .. n) end") + .unwrap(); + assert_eq!( + one_number(&s, "CF:GetCurrentLine()"), + 1.0, + "the ring wrapped" + ); + s.run("CF:Clear()").unwrap(); + assert_eq!(one_number(&s, "CF:GetCurrentLine()"), -1.0); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } + + /// `GetCurrentScroll` is `(pos − anchor + count) % count` (`0x788ac0`); `SetScrollFromBottom(n)` + /// lands on the slot `(pos − n + count) % count` floored at 0 (`0x788700`), so `n` equal to the + /// count is the bottom, a negative `n` wraps toward the top and a large one gives slot 0. + #[test] + fn set_scroll_from_bottom_lands_on_the_references_slot() { + let s = three_rows(5); + assert_eq!(one_number(&s, "CF:GetCurrentScroll()"), 0.0); + assert_eq!(one_number(&s, "CF:GetNumLinesDisplayed()"), 3.0); + s.run("CF:ScrollUp()").unwrap(); + assert_eq!(one_number(&s, "CF:GetCurrentScroll()"), 1.0); + for (n, scroll) in [ + ("2", 2.0), + ("5", 0.0), + ("-1", 4.0), + // The remainder -4, which the floor makes slot 0 (`0x788723`-`0x788729`). + ("98", 4.0), + ("'3'", 3.0), + ] { + assert_eq!(s.arity(&format!("CF:SetScrollFromBottom({n})")).unwrap(), 0); + assert_eq!(one_number(&s, "CF:GetCurrentScroll()"), scroll, "n = {n}"); + } + // A non-number returns before the receiver check, with no error (`0x79307f`). + s.run("CF:SetScrollFromBottom('x') CF.SetScrollFromBottom(5)") + .unwrap(); + assert_eq!(one_number(&s, "CF:GetCurrentScroll()"), 3.0, "unchanged"); + let err = s + .run("CF.SetScrollFromBottom(5, 1)") + .unwrap_err() + .to_string(); + assert!( + err.contains("Attempt to find 'this' in non-table object"), + "{err}" + ); + + // A full ring of 8 written up to slot 3: slot 0 is 3 lines up, not the top. + s.run("CF:SetMaxLines(8) for n = 1, 12 do CF:AddMessage('w' .. n) end") + .unwrap(); + assert_eq!(one_number(&s, "CF:GetCurrentLine()"), 3.0); + s.run("CF:SetScrollFromBottom(12)").unwrap(); + assert_eq!(one_number(&s, "CF:GetCurrentScroll()"), 3.0); + assert!(s.eval::("return CF:AtTop() == nil").unwrap()); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } + + /// A `SetScrollFromBottom` onto the anchor re-arms the lines shown in place (`0x788739`). + #[test] + fn set_scroll_from_bottom_onto_the_anchor_re_arms() { + let mut s = three_rows(5); + s.run("CF:SetTimeVisible(0) CF:SetFadeDuration(1)").unwrap(); + s.run("CF:ScrollUp() CF:ScrollDown()").unwrap(); // re-arm with the new times + s.tick(2.0); + assert_eq!(alphas(&s)[4], 0.0, "faded at the bottom"); + s.run("CF:SetScrollFromBottom(0)").unwrap(); + assert_eq!(one_number(&s, "CF:GetCurrentScroll()"), 0.0); + assert_eq!( + alphas(&s)[2..], + [1.0, 1.0, 1.0], + "the lines shown came back" + ); + } + + /// Lines shown, at top and at bottom are cached by the relayout, which a hidden frame runs only + /// as far as zeroing lines shown and moving the anchor (`0x788782`-`0x78878e`); the show + /// relayouts at once, after the OnShow script (`0x788440`). + #[test] + fn a_hidden_frame_keeps_its_last_visible_flags() { + let s = three_rows(10); + assert!(s.eval::("return CF:AtTop() == nil").unwrap()); + s.run( + "CF:SetScript('OnShow', function() SEEN = this:AtBottom() end)\n\ + CF:Hide() CF:ScrollUp()", + ) + .unwrap(); + assert_eq!( + one_number(&s, "CF:GetCurrentScroll()"), + 1.0, + "the anchor moved" + ); + assert_eq!( + s.eval::("return CF:AtBottom()").unwrap(), + 1, + "still cached" + ); + assert_eq!(one_number(&s, "CF:GetNumLinesDisplayed()"), 0.0); + s.run("CF:ScrollDown()").unwrap(); + assert_eq!( + one_number(&s, "CF:GetCurrentScroll()"), + 1.0, + "refused at the cached bottom" + ); + s.run("CF:Show() AFTER = CF:AtBottom()").unwrap(); + assert_eq!( + s.eval::("return SEEN").unwrap(), + 1, + "OnShow reads the old cache" + ); + assert!( + s.eval::("return AFTER == nil").unwrap(), + "the show relayouts" + ); + assert_eq!(one_number(&s, "CF:GetNumLinesDisplayed()"), 3.0); + + // Hidden at the bottom, a new line relayouts hidden: no line shown, so a page is the one + // step down, refused at the cached bottom. + s.run("CF:ScrollToBottom() CF:Hide() CF:AddMessage('x') CF:PageUp()") + .unwrap(); + assert_eq!( + one_number(&s, "CF:GetCurrentScroll()"), + 0.0, + "PageUp moved nothing" + ); + s.run("CF:Show() CF:Clear()").unwrap(); + assert!(s.eval::("return CF:AtTop() == nil").unwrap()); + assert_eq!(s.eval::("return CF:AtBottom()").unwrap(), 1); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } + + /// The lines' `(time_left, fade_left)`, oldest first. + fn timers(s: &UiScript) -> Vec<(f32, f32)> { + let model = s.model_ref(); + let fh = model.arena.lookup("CF").expect("CF"); + match model.arena.frame(fh).map(|f| &f.kind_state) { + Some(crate::widget::KindState::ScrollingMessage(smf)) => smf + .lines + .iter() + .map(|l| (l.time_left, l.fade_left)) + .collect(), + _ => unreachable!("a scrolling frame"), + } + } + + /// `SetTimeVisible` and `SetFadeDuration` raise their usage line for a non-number (`0x87b918`, + /// `0x87b93c`), store the f32 unclamped, and rewrite the timer of each line shown that still + /// has its shown flag (`0x788090`, `0x7880f0`). + #[test] + fn the_time_setters_gate_their_number_and_rewrite_the_lines_shown() { + let mut s = three_rows(5); + for (call, line) in [ + ("CF:SetTimeVisible()", "Usage: CF:SetTimeVisible(seconds)"), + ( + "CF:SetFadeDuration('x')", + "Usage: CF:SetFadeDuration(seconds)", + ), + ( + "CreateFrame('ScrollingMessageFrame'):SetTimeVisible(nil)", + "Usage: :SetTimeVisible(seconds)", + ), + ] { + let err = s.run(call).unwrap_err().to_string(); + assert!(err.contains(line), "{call}: {err}"); + } + s.run("CF:SetTimeVisible(7) CF:SetFadeDuration('2')") + .unwrap(); + assert_eq!( + timers(&s), + [(10.0, 3.0), (10.0, 3.0), (7.0, 2.0), (7.0, 2.0), (7.0, 2.0)], + "the three lines shown, not the two above them" + ); + s.run("CF:SetTimeVisible(-2) CF:SetFadeDuration(0.1)") + .unwrap(); + assert_eq!(s.eval::("return CF:GetTimeVisible()").unwrap(), -2.0); + assert_eq!( + s.eval::("return CF:GetFadeDuration()").unwrap(), + f64::from(0.1f32) + ); + // At time 0 with no fade a line never hides (`0x78850e`), and is rewritten. + s.run("CF:SetTimeVisible(0) CF:SetFadeDuration(0)").unwrap(); + s.tick(0.1); + assert_eq!(alphas(&s)[2..], [1.0, 1.0, 1.0]); + s.run("CF:SetTimeVisible(5)").unwrap(); + assert_eq!(timers(&s)[4], (5.0, 0.0)); + // Hidden at the end of its visible time (`0x788525`), a line keeps its timers. + s.run("CF:SetTimeVisible(0.05)").unwrap(); + s.tick(0.1); + assert_eq!(alphas(&s)[2..], [0.0, 0.0, 0.0]); + s.run("CF:SetTimeVisible(5)").unwrap(); + let (time, _) = timers(&s)[4]; + assert!(time < 0.0, "a hidden line is not rewritten: {time}"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } + + /// A relayout that moves the anchor fires `OnMessageScrollChanged` at its tail, hidden or not + /// (`0x78877a`, `0x788a9a`-`0x788aad`); one at the same anchor, or a refused scroll, fires + /// nothing. A page fires once per step, inside it. + #[test] + fn a_moved_anchor_fires_on_message_scroll_changed() { + let s = three_rows(10); + s.run( + "SEEN = {}\n\ + CF:SetScript('OnMessageScrollChanged', function()\n\ + table.insert(SEEN, this:GetCurrentScroll())\n\ + end)", + ) + .unwrap(); + let seen = |s: &UiScript| -> String { + s.eval( + "local t = {} for i, v in ipairs(SEEN) do t[i] = tostring(v) end \ + return table.concat(t, ' ')", + ) + .unwrap() + }; + s.run("CF:ScrollDown() CF:UpdateColorByID(1, 1, 0, 0)") + .unwrap(); + assert_eq!(seen(&s), "", "refused, and a relayout in place"); + s.run("CF:ScrollUp() CF:ScrollDown() CF:AddMessage('new')") + .unwrap(); + assert_eq!(seen(&s), "1 0 0", "the add re-anchors on the new line"); + s.run("SEEN = {} CF:PageUp()").unwrap(); + assert_eq!(seen(&s), "1 2 3 2", "each step of the page, in place"); + s.run("SEEN = {} CF:Hide() CF:ScrollUp() CF:Show()") + .unwrap(); + assert_eq!( + seen(&s), + "3", + "a hidden relayout still fires; the show's does not move" + ); + // Clear's relayout fires inside it, before the write position goes back to -1. + s.run( + "SEEN = {} CF:SetScript('OnMessageScrollChanged', function()\n\ + table.insert(SEEN, this:GetCurrentLine())\n\ + end) CF:Clear()", + ) + .unwrap(); + assert_eq!(seen(&s), "0"); + assert_eq!(s.eval::("return CF:GetCurrentLine()").unwrap(), -1); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } + + /// The fade keys on each line's own countdowns (`0x7884c1`-`0x788558`): a line whose visible + /// time runs out with no fade time hides that same tick; one at time 0 with no fade time + /// never hides; a negative visible time is nonzero, so it counts down and hides or starts the + /// fade; a fade that lands exactly on 0 stays shown at alpha 0. + #[test] + fn the_fade_keys_on_each_lines_own_countdowns() { + let fresh = |time: f32, fade: f32| { + let mut s = ScrollingMessageState::default(); + s.add("x".into(), 1.0, 1.0, 1.0); + s.lines[0].time_left = time; + s.lines[0].fade_left = fade; + s.links_dirty = false; + s + }; + let mut s = fresh(0.5, 0.0); + s.tick(0.6); + assert!( + !s.lines[0].shown && s.links_dirty, + "hidden the tick the time ran out" + ); + let mut s = fresh(0.0, 0.0); + for _ in 0..5 { + s.tick(1.0); + } + assert!(s.lines[0].shown && s.lines[0].alpha == 1.0, "never hides"); + let mut s = fresh(-1.0, 2.0); + s.tick(0.25); + assert_eq!( + (s.lines[0].time_left, s.lines[0].alpha), + (0.0, 1.0), + "a negative time is below 0 at once: the fade starts next tick" + ); + s.tick(1.0); + assert_eq!(s.lines[0].fade_left, 1.0); + let mut s = fresh(-1.0, 0.0); + s.tick(0.25); + assert!(!s.lines[0].shown); + let mut s = fresh(0.0, 1.0); + s.tick(1.0); + assert!( + s.lines[0].shown && s.lines[0].alpha == 0.0, + "exactly 0 stays shown" + ); + s.tick(1.0); + assert!(s.lines[0].shown, "and then never moves"); + } + + /// The alpha byte is the low byte of `ftol(left / fadeDuration · 255)` with no clamp + /// (`0x788553`, `0x788558`): a line whose fade time outlasts the frame's wraps. + #[test] + fn the_fade_alpha_byte_wraps_unclamped() { + let mut s = ScrollingMessageState { + time_visible: 0.0, + fade_duration: 1.0, + ..Default::default() + }; + s.add("x".into(), 1.0, 1.0, 1.0); + s.lines[0].fade_left = 3.0; + s.tick(0.1); + let left = f64::from(3.0f32) - f64::from(0.1f32); + let byte = (left * 255.0) as i64 as u8; + assert_ne!(byte, 255, "wrapped, not clamped"); + assert_eq!(s.lines[0].alpha, f32::from(byte) / 255.0); + } + /// `CF` holds `l0`..`l5`, three rows high, scrolled up one line (shown `l4 l3 l2`, walked /// `l1`), with every line's alpha knocked to 0.5; `bottom` scrolls it back down first. fn faded_chat(bottom: bool) -> UiScript { @@ -871,6 +1316,29 @@ mod tests { assert_eq!(alphas(&s), [half; 6]); } + /// The resolve's resize test is `0x788310`'s: a size moved by under the `1e-5` epsilon + /// (`0x80c5c8`) relayouts nothing, one past it relayouts; a hidden frame's rect waits for its + /// show, whose relayout and then the resolve's lay it out. + #[test] + fn a_resize_relayouts_past_the_epsilon_and_a_hidden_one_at_its_show() { + let (half, full) = (0.5, 1.0); + let four = [half, full, full, full, full, half]; + let mut s = faded_chat(false); + s.run("CF:SetHeight(44.005)").unwrap(); + s.resolve(); + assert_eq!(alphas(&s), [half; 6], "under the epsilon"); + s.run("CF:SetHeight(44.05)").unwrap(); + s.resolve(); + assert_eq!(alphas(&s), four, "past it, at the anchor"); + let mut s = faded_chat(false); + s.run("CF:Hide() CF:SetHeight(86)").unwrap(); + s.resolve(); + assert_eq!(alphas(&s), [half; 6], "hidden: deferred"); + s.run("CF:Show()").unwrap(); + s.resolve(); + assert_eq!(alphas(&s), [full, full, full, full, full, half]); + } + /// A scrolling frame `CF` of the given height holding `messages`, bottom left at (32, 90), /// after the host answered each message's row count with `rows(index)`: what it draws as /// `(text, bottom, top)` in draw order, and the count its layout reports. @@ -916,9 +1384,9 @@ mod tests { .collect(); let model = s.model_mut(); let fh = fh.expect("a line drew"); - let viewport = UiScript::message_viewport(&model, fh); + // The cached lines shown, re-counted when the measured rows landed. let count = match &model.arena.frame(fh).expect("the frame").kind_state { - crate::widget::KindState::ScrollingMessage(smf) => smf.displayed_count(viewport), + crate::widget::KindState::ScrollingMessage(smf) => smf.lines_shown, _ => unreachable!("a scrolling frame"), }; (texts, count) @@ -1052,13 +1520,13 @@ mod tests { f:SetPoint('BOTTOMLEFT', 0, 0)\n\ f:SetWidth(430)\n\ f:SetHeight(120)\n\ - f:SetTimeVisible(0)\n\ + f:SetTimeVisible(0.05)\n\ f:SetFadeDuration(0)", ) .unwrap(); s.run("CF:AddMessage('doomed', 1, 1, 1)").unwrap(); - // Two ticks retire 'doomed' (phase 1, then the snap with no ramp); then fresh lines land. - s.tick(0.1); + // The tick that spends its visible time hides 'doomed', which has no fade time; then + // fresh lines land. s.tick(0.1); s.run("CF:SetTimeVisible(120)").unwrap(); s.run("CF:AddMessage('a', 1, 1, 1)").unwrap(); @@ -1174,6 +1642,54 @@ mod tests { ); } + /// Without rgb a line takes the frame font's packed colour, its alpha byte the line's starting + /// alpha (`0x792b39`-`0x792b45`); with rgb it is opaque. A re-arm makes it opaque (`0x788b09`). + #[test] + fn an_uncoloured_message_takes_the_frame_fonts_colour() { + let s = UiScript::new().unwrap(); + s.run( + "BenillaMF = CreateFrame('ScrollingMessageFrame') \ + BenillaMF:AddMessage('plain') \ + BenillaMF:SetTextColor(1, 0.82, 0) \ + BenillaMF:AddMessage('gold', 7) \ + BenillaMF:AddMessage('given', 0, 0, 1) \ + BenillaMF:SetTextColor(1, 1, 1, 0.5) \ + BenillaMF:AddMessage('half') \ + BenillaFont = CreateFont('BenillaFont') \ + BenillaFont:SetTextColor(0, 1, 0) \ + BenillaMF2 = CreateFrame('ScrollingMessageFrame') \ + BenillaMF2:SetFontObject(BenillaFont) \ + BenillaMF2:AddMessage('inherited')", + ) + .unwrap(); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + let frame: mlua::Table = s.eval("return BenillaMF").unwrap(); + let lines: Vec<([u8; 3], f32, u32)> = super::with_smf(s.lua(), &frame, |smf| { + smf.lines.iter().map(|l| (l.color, l.alpha, l.id)).collect() + }) + .unwrap(); + assert_eq!( + lines, + vec![ + ([255, 255, 255], 1.0, 0), + ([255, 209, 0], 1.0, 7), + ([0, 0, 255], 1.0, 0), + ([255, 255, 255], 128.0 / 255.0, 0), + ] + ); + let other: mlua::Table = s.eval("return BenillaMF2").unwrap(); + let inherited = super::with_smf(s.lua(), &other, |smf| smf.lines[0].color).unwrap(); + assert_eq!( + inherited, + [0, 255, 0], + "a font object's colour reaches the line" + ); + s.run("BenillaMF:ScrollUp() BenillaMF:ScrollDown()") + .unwrap(); + let alpha: f32 = super::with_smf(s.lua(), &frame, |smf| smf.lines[3].alpha).unwrap(); + assert_eq!(alpha, 1.0, "a scroll re-arms the line opaque"); + } + #[test] fn update_color_by_id_recolours_only_that_ids_lines() { let mut smf = ScrollingMessageState::default(); @@ -1234,7 +1750,7 @@ mod tests { s.run("CF:SetMaxLines(128)").unwrap(); assert_eq!(s.eval::("return CF:GetMaxLines()").unwrap(), 128); s.run("CF:AddMessage('one', 1, 1, 1)").unwrap(); - s.run("CF:AddMessage('two')").unwrap(); // rgb-less form defaults white + s.run("CF:AddMessage('two')").unwrap(); // rgb-less: the font's colour, here white assert_eq!(s.eval::("return CF:GetNumMessages()").unwrap(), 2); assert!(s.eval::("return CF:AtBottom()").unwrap()); s.run("CF:ScrollUp()").unwrap(); diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 3bd3386a5..fec790c19 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -58,6 +58,7 @@ mod addon_enable; pub mod addon_gate; mod char_name; mod client; +pub use client::FrameTimeRing; mod clock; mod cvar_checks; mod cvars; @@ -498,7 +499,7 @@ pub const SCREEN: crate::layout::Handle = 0; /// ScrollFrame's three scroll kinds are `+0x32c`/`+0x334`/`+0x33c` (script-name map `0x786c40`). /// The EditBox's vtable (`0x81c910`) replaces the key and char slots: an EditBox never fires /// `OnKeyDown`, and fires `OnChar` only from `Insert`, with the inserted text (`0x77c13c`). -const SCRIPT_KINDS: [&str; 44] = [ +const SCRIPT_KINDS: [&str; 45] = [ "OnLoad", "OnEvent", "OnUpdate", @@ -540,6 +541,9 @@ const SCRIPT_KINDS: [&str; 44] = [ "OnHyperlinkClick", "OnHyperlinkEnter", "OnHyperlinkLeave", + // The ScrollingMessageFrame's own (script map `0x787d90`, `+0x3bc`), fired by a relayout that + // moved its anchor (`0x788a9a`). + "OnMessageScrollChanged", // The GameTooltip's engine-fired scripts: money render, money clear and world-hover default // placement, all three wired by the stock template (`GameTooltipTemplate.xml:617-625`). "OnTooltipAddMoney", @@ -1084,6 +1088,8 @@ impl UiScript { match model.arena.frame_mut(h).map(|f| &mut f.kind_state) { Some(crate::widget::KindState::ScrollingMessage(smf)) => { smf.add_with_id(text.to_string(), r, g, b, 0, rows); + drop(model); + messageframe::fire_scroll_changes(&self.lua, h); true } _ => false, diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 0f8e405e1..227e0b77d 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -1270,7 +1270,7 @@ pub(crate) struct Model { /// `GetSummonConfirmSummoner()` calls that answered no name: each call's lookup passes the /// callback `0x48b710` (`0x48b6e1`), which the app makes. pub(crate) summon_summoner_lookups: u32, - /// `GetFramerate()`, pushed each tick. + /// `GetFramerate()`, pushed after each world frame; the empty ring's 1000 until then. pub(crate) framerate: f64, /// `(shift, ctrl, alt)` for `Is*KeyDown`, pushed before the frame's mouse events. pub(crate) modifiers: (bool, bool, bool), @@ -1819,7 +1819,7 @@ impl Model { summon_confirm: super::summon::SummonConfirmUiState::default(), summon_confirms: 0, summon_summoner_lookups: 0, - framerate: 0.0, + framerate: 1000.0, modifiers: (false, false, false), money: 0, net_latency_ms: 0, diff --git a/crates/benilla-ui/src/script/object/events_regions.rs b/crates/benilla-ui/src/script/object/events_regions.rs index eb5b650d9..6e14ae018 100644 --- a/crates/benilla-ui/src/script/object/events_regions.rs +++ b/crates/benilla-ui/src/script/object/events_regions.rs @@ -233,9 +233,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { } /// `SetScript(name, func)`: stores the closure under one of [`SCRIPT_KINDS`], else raises. -/// Deviation: real 1.12 kinds nothing fires yet raise too (`OnMessageScrollChanged`, -/// `OnInputLanguageChanged`), because an accepted handler that never runs fails silently. The movie -/// frame's three are accepted all the same: the stock XML sets them, and its player ([`super::super::movieframe`]) +/// Deviation: a real 1.12 kind nothing fires yet raises too (`OnInputLanguageChanged`), because +/// an accepted handler that never runs fails silently. The movie frame's three are accepted all the same: the stock XML sets them, and its player ([`super::super::movieframe`]) /// is the one thing that fires them. `OnAttributeChanged` is 2.0's, with no 1.12 slot. fn set_script(lua: &Lua, this: &Table, name: &str, func: Option) -> mlua::Result<()> { let kind = SCRIPT_KINDS diff --git a/crates/benilla-ui/src/script/simplehtml/mod.rs b/crates/benilla-ui/src/script/simplehtml/mod.rs index e010c42df..275a09627 100644 --- a/crates/benilla-ui/src/script/simplehtml/mod.rs +++ b/crates/benilla-ui/src/script/simplehtml/mod.rs @@ -78,6 +78,9 @@ pub(crate) struct SimpleHtmlState { pub(crate) hyperlink_format: String, /// The content list (`+0x340`): every region the last `SetText` built, freed by the next. pub(crate) blocks: Vec, + /// The link-button rebuild flag (`+0x370`, zeroed by the ctor at `0x789eb9`): set by `SetText` + /// alone (`0x78a50d`) and spent by the frame's update (`0x78a30b`-`0x78a310`). + pub(crate) links_dirty: bool, } impl Default for SimpleHtmlState { @@ -86,6 +89,7 @@ impl Default for SimpleHtmlState { fonts: Default::default(), hyperlink_format: parse::DEFAULT_HYPERLINK_FORMAT.to_string(), blocks: Vec::new(), + links_dirty: false, } } } @@ -186,6 +190,7 @@ pub(crate) fn set_text(model: &mut Model, fh: FrameHandle, raw: &str) -> bool { } build(model, fh, &parsed.blocks); + model.simple_html.entry(fh).or_default().links_dirty = true; parsed.used_markup } diff --git a/crates/benilla-ui/src/script/tests/hyperlink.rs b/crates/benilla-ui/src/script/tests/hyperlink.rs index 5b5589897..aef9dbe43 100644 --- a/crates/benilla-ui/src/script/tests/hyperlink.rs +++ b/crates/benilla-ui/src/script/tests/hyperlink.rs @@ -275,3 +275,108 @@ fn a_rebuild_under_a_held_press_drops_the_click() { assert_eq!(log_of(&s), "click:item:2589#|Hitem:2589|h[x]|h#LeftButton"); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +/// A SimpleHTML `SH` at (100, 100) 430×120 on 800×600 holding one ``, logging its link +/// events, with its one span fed over [110..160]×[120..134]. +fn html() -> UiScript { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r##" + log = {} + SH = CreateFrame("SimpleHTML", "SH") + SH:SetPoint("BOTTOMLEFT", 100, 100); SH:SetWidth(430); SH:SetHeight(120) + SH:SetFont("Fonts\FRIZQT__.TTF", 12) + SH:SetText('

t

') + SH:SetScript("OnHyperlinkClick", function() + table.insert(log, "click:"..arg1.."#"..arg2.."#"..arg3) + end) + SH:SetScript("OnHyperlinkEnter", function() table.insert(log, "enter:"..arg1) end) + SH:SetScript("OnHyperlinkLeave", function() table.insert(log, "leave:"..arg1) end) + "##, + ) + .unwrap(); + s.resolve(); + set_html_link(&mut s); + s +} + +fn set_html_link(s: &mut UiScript) { + let owner = s.model_ref().arena.lookup("SH").expect("SH"); + s.set_link_spans(vec![( + owner, + Rect::new(120.0, 110.0, 134.0, 160.0), + "u".to_string(), + "|Hu|ht|h".to_string(), + )]); +} + +/// A SimpleHTML's link takes a button from the pool (`0x78b052` → `0x7a3880`), whose click fires +/// the frame's `OnHyperlinkClick(link, text, button)`, as the stock `ServerAlertText` relies on +/// (GlueXML `AccountLogin.xml`). +#[test] +fn a_simple_html_link_fires_on_hyperlink_click() { + let mut s = html(); + assert_eq!(s.eval::("return SH:GetNumChildren()").unwrap(), 1); + click(&mut s, (130.0, 127.0), "LeftButton"); + assert_eq!(log_of(&s), "enter:u click:u#|Hu|ht|h#LeftButton"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// Only `SetText` raises a SimpleHTML's rebuild flag (`0x78a50d`): a resize re-seats the hovered +/// button with no leave, where a new `SetText` rebuilds and leaves it. +#[test] +fn a_simple_html_rebuilds_its_links_on_set_text_alone() { + let mut s = html(); + s.mouse_move(130.0, 127.0); + s.run("SH:SetWidth(400)").unwrap(); + s.resolve(); + set_html_link(&mut s); + s.mouse_move(131.0, 127.0); + assert_eq!(log_of(&s), "enter:u", "a resize rebuilds nothing"); + s.run(r#"SH:SetText('

t

')"#) + .unwrap(); + set_html_link(&mut s); + s.mouse_move(130.0, 127.0); + assert_eq!(log_of(&s), "enter:u leave:u enter:u"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The text the extract hands the app names its link owner for a SimpleHTML block and a scrolling +/// message line, and none for a plain FontString or a MessageFrame line, whose links stay text. +#[test] +fn only_simple_html_blocks_and_scrolling_lines_name_a_link_owner() { + let mut s = html(); + s.run( + r#" + CF = CreateFrame("ScrollingMessageFrame", "CF") + CF:SetPoint("TOPLEFT", 0, 0); CF:SetWidth(200); CF:SetHeight(60) + CF:AddMessage("|Hitem:1|h[a]|h", 1, 1, 1) + EF = CreateFrame("MessageFrame", "EF") + EF:SetPoint("TOPRIGHT", 0, 0); EF:SetWidth(200); EF:SetHeight(60) + EF:AddMessage("|Hitem:2|h[b]|h", 1, 1, 1) + local p = CreateFrame("Frame", "P") + p:SetPoint("CENTER", 0, 0); p:SetWidth(200); p:SetHeight(60) + local fs = p:CreateFontString("PFS", "ARTWORK") + fs:SetFont("Fonts\FRIZQT__.TTF", 12) + fs:SetAllPoints(p); fs:SetText("|Hitem:3|h[c]|h") + "#, + ) + .unwrap(); + s.resolve(); + let owner_of = |s: &UiScript, needle: &str| { + s.extract().into_iter().find_map(|q| match q.content { + crate::script::QuadContent::Text { + text: Some(t), + link_owner, + .. + } if t.contains(needle) => Some(link_owner), + _ => None, + }) + }; + let lookup = |s: &UiScript, name: &str| s.model_ref().arena.lookup(name); + assert_eq!(owner_of(&s, "|Hu|h"), Some(lookup(&s, "SH"))); + assert_eq!(owner_of(&s, "item:1"), Some(lookup(&s, "CF"))); + assert_eq!(owner_of(&s, "item:2"), Some(None)); + assert_eq!(owner_of(&s, "item:3"), Some(None)); +} diff --git a/crates/benilla-ui/src/script/tests/input.rs b/crates/benilla-ui/src/script/tests/input.rs index 8f9fa9dee..22a250862 100644 --- a/crates/benilla-ui/src/script/tests/input.rs +++ b/crates/benilla-ui/src/script/tests/input.rs @@ -589,8 +589,7 @@ fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() { for name in [ // A 2.0 secure-frame slot; no 1.12 resolver has it. "OnAttributeChanged", - // Real 1.12 slots that benilla does not fire. - "OnMessageScrollChanged", + // A real 1.12 slot that benilla does not fire. "OnInputLanguageChanged", ] { let err = s diff --git a/crates/benilla-ui/src/script/tests/widget_surface.rs b/crates/benilla-ui/src/script/tests/widget_surface.rs index 8f46d1531..e4cbc4807 100644 --- a/crates/benilla-ui/src/script/tests/widget_surface.rs +++ b/crates/benilla-ui/src/script/tests/widget_surface.rs @@ -67,31 +67,6 @@ const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[ "MovieFrame", "the same — `MovieFrame.lua:35` passes `GetMovieSubtitles()`", ), - // ── EditBox mode read-backs ── - ( - "IsAutoFocus", - "EditBox", - "the mode is set and not readable back; no caller anywhere measured", - ), - ("IsMultiLine", "EditBox", "the same"), - ("IsNumeric", "EditBox", "the same"), - ("IsPassword", "EditBox", "the same"), - // ── ScrollingMessageFrame's line-position surface ── - ( - "GetCurrentLine", - "ScrollingMessageFrame", - "the buffer's position is internal; no caller measured", - ), - ("GetCurrentScroll", "ScrollingMessageFrame", "the same"), - ("GetNumLinesDisplayed", "ScrollingMessageFrame", "the same"), - ("SetScrollFromBottom", "ScrollingMessageFrame", "the same"), - // ── The colour picker's HSV pair ── - ( - "GetColorHSV", - "ColorSelect", - "the RGB pair is published; nothing measured asks for HSV", - ), - ("SetColorHSV", "ColorSelect", "the same"), ]; /// Methods answered here that 1.12 does not register on that class, each with why it stays. diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index 04a1a31e7..2b1588ff9 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -179,6 +179,10 @@ pub enum QuadContent { non_space_wrap: bool, /// `maxLines` ([`RegionData::max_lines`]). max_lines: u32, + /// The text frame whose link buttons this text's `|H` spans take: a ScrollingMessageFrame + /// line's (`0x788ce7`) or a SimpleHTML block's (`0x78b052`); `None` for other text, whose + /// links are only text. + link_owner: Option, }, } diff --git a/crates/benilla-ui/src/script/update_walk.rs b/crates/benilla-ui/src/script/update_walk.rs index 13ea0cc6e..c04c1b098 100644 --- a/crates/benilla-ui/src/script/update_walk.rs +++ b/crates/benilla-ui/src/script/update_walk.rs @@ -146,7 +146,8 @@ impl UiScript { KindUpdate::Model => self.advance_pane(h, elapsed), // The line fades (`0x7862b0`, `0x788476`), counted only while the frame is shown. KindUpdate::MessageFrame | KindUpdate::ScrollingMessageFrame => { - messageframe::update(&mut self.model_mut(), h, elapsed) + messageframe::update(&mut self.model_mut(), h, elapsed); + messageframe::fire_scroll_changes(&self.lua, h); } // A marked range's recompute (`0x787152`), whose change fires `OnScrollRangeChanged`. KindUpdate::ScrollFrame => scrollframe::update(&self.lua, h), diff --git a/crates/benilla-ui/src/widget/kinds/messageframe.rs b/crates/benilla-ui/src/widget/kinds/messageframe.rs index 636784dac..000e6bbe0 100644 --- a/crates/benilla-ui/src/widget/kinds/messageframe.rs +++ b/crates/benilla-ui/src/widget/kinds/messageframe.rs @@ -14,11 +14,16 @@ pub struct MessageLine { pub time_left: f32, /// Seconds of fade left, from the `fadeDuration` snapshot; counts once `time_left` is spent. pub fade_left: f32, - /// The display alpha: `AddMessage`'s (opaque on a ScrollingMessageFrame, `0x792add`; the alpha - /// argument on a MessageFrame, `0x795752`) until the fade overwrites it with the byte ramp + /// The display alpha: `AddMessage`'s (on a ScrollingMessageFrame opaque with a colour, + /// `0x792add`, else the frame font's alpha, `0x792b39`; the alpha argument on a MessageFrame, + /// `0x795752`) until the fade overwrites it with the byte ramp /// `trunc(remaining / fadeDuration * 255)` (`0x788547`, `0x786364`). At 0 a scrolling line /// keeps its ring slot and rows; a MessageFrame frees it (`0x786570`). pub alpha: f32, + /// The record's shown flag (`+0x8`): set by the store and a re-arm (`0x788204`, `0x788b0d`), + /// cleared when the fade hides the line (`0x788525`); the fade skips a line without it + /// (`0x7884bb`), and the time setters rewrite only lines with it. + pub shown: bool, /// The rows the line wraps into at the frame's width and font, host-measured; 1 until then. pub rows: u16, /// Hash of the text, font, height and wrap width `rows` was measured at; 0 is unmeasured. @@ -26,7 +31,10 @@ pub struct MessageLine { } /// A `CSimpleMessageScrollFrame`'s runtime state (ctor `0x787670`): a drop-oldest ring of lines, -/// a scrollback cursor counted up from the newest line, and the fade config new lines copy. +/// the ring's write position and anchor slot, the fade config new lines copy, and the three fields +/// only its relayout ([`ScrollingMessageState::layout`], `0x788750`) writes: lines shown, at top +/// and at bottom. Every reader reads those cached fields, so a hidden frame, whose relayout +/// returns before it places anything (`0x78878e`), keeps its last visible answers. #[derive(Clone, Debug, PartialEq)] pub struct ScrollingMessageState { /// The frame's own `SetJustifyH`/`SetJustifyV` (method table `0x87b5c0`), `None` until called @@ -48,16 +56,33 @@ pub struct ScrollingMessageState { pub lines_gen: u64, /// `fadingEnabled` (ctor 1). pub fading_enabled: bool, - /// Lines scrolled back from the newest; 0 is AtBottom, the only state in which fades tick. - pub scroll_offset: usize, + /// The write position (`+0x344`): the ring slot of the newest line, from 0 and wrapping at + /// `max_lines` (`0x788183`-`0x788198`); −1 when empty (`0x7878d7`, `0x787f95`, `0x7882d8`). + pub write_pos: i32, + /// The anchor (`+0x348`): the ring slot on the bottom row, stored by the relayout alone + /// (`0x788788`) and reset to 0 by the ctor and `SetMaxLines` (`0x7878ca`, `0x787f9f`). + pub anchor: i32, + /// Lines shown (`+0x34c`): what the last relayout placed, 0 when it ran hidden (`0x788782`). + pub lines_shown: usize, + /// At top (`+0x350`): the last visible relayout placed every line from the anchor back + /// (`0x788a37`-`0x788a48`); `Clear` and `SetMaxLines` zero it (`0x7882ce`, `0x787eb1`). + pub at_top: bool, + /// At bottom (`+0x354`): the last visible relayout anchored the newest line (`0x7887aa`, + /// `0x7887b6`); the ctor and `SetMaxLines` set it (`0x78776b`, `0x787ea7`). + pub at_bottom: bool, /// The link-button rebuild flag (`+0x3b8`, cleared by the ctor at `0x7878d0`): set by a /// relayout (`0x788a8e`), a line fading out (`0x788538`) and a fade re-arm (`0x788bf1`), and /// spent by the per-frame rebuild (`0x788595` → `0x788c00`). pub links_dirty: bool, - /// A key of what the last relayout laid out against: the frame's size and font. A change is - /// a resize (`0x788427`) or a font change (`0x788086`), and `None` after a show (`0x788451`): - /// each relayouts at the current position, which re-arms what it walks unless at the bottom. - pub laid: Option, + /// The font the last relayout laid out with, as a key: a change is a font change + /// (`0x788086`), which relayouts at the anchor. + pub laid_font: Option, + /// The layout-unit rect the frame's size was last tested against (`0x788321`-`0x788339`): a + /// resize past the epsilon relayouts at the anchor (`0x788427`). + pub laid_rect: Option, + /// Relayouts that moved the anchor since the binding last fired `OnMessageScrollChanged` for + /// them (`0x78877a`, `0x788a89`-`0x788aad`). + pub scroll_changes: u32, } impl Default for ScrollingMessageState { @@ -71,13 +96,18 @@ impl Default for ScrollingMessageState { fade_duration: 3.0, fading_enabled: true, lines_gen: 0, - scroll_offset: 0, + write_pos: -1, + anchor: 0, + lines_shown: 0, + at_top: false, + at_bottom: true, links_dirty: false, - laid: None, + laid_font: None, + laid_rect: None, + scroll_changes: 0, } } } - /// A `0..1` colour component as a byte, rounded half up as `AddMessage` does (`0x788150`). pub fn quantize_u8(x: f32) -> u8 { (x.clamp(0.0, 1.0) * 255.0 + 0.5).trunc() as u8 @@ -98,6 +128,8 @@ pub struct Viewport { pub row_gap: f32, /// The gap between one message and the next. pub message_gap: f32, + /// Whether the frame is visible (`+0xd4`), without which the relayout places nothing. + pub visible: bool, } impl Viewport { @@ -109,6 +141,7 @@ impl Viewport { line_h: 1.0, row_gap: 0.0, message_gap: 0.0, + visible: true, } } @@ -133,6 +166,131 @@ impl From for Viewport { } impl ScrollingMessageState { + /// The ring's limit as the reference's `int` (`+0x340`). + fn limit(&self) -> i32 { + i32::try_from(self.max_lines).unwrap_or(i32::MAX) + } + + /// The line count (`+0x33c`). + fn count(&self) -> i32 { + self.lines.len() as i32 + } + + /// How many lines the anchor sits above the newest: `(pos − anchor) mod limit`, 0 when empty. + /// Past the oldest line (a hidden frame's `ScrollUp` with a stale at-top) it reaches the + /// count or beyond, and the relayout then places nothing. + pub fn scroll_offset(&self) -> usize { + if self.write_pos < 0 { + return 0; + } + self.write_pos + .wrapping_sub(self.anchor) + .rem_euclid(self.limit()) as usize + } + + /// The ring index of `slot`, if it holds a line. + fn index_of(&self, slot: i32) -> Option { + if self.write_pos < 0 { + return None; + } + let back = self.write_pos.wrapping_sub(slot).rem_euclid(self.limit()) as usize; + (back < self.lines.len()).then(|| self.lines.len() - 1 - back) + } + + /// The ring indices of the lines the last relayout placed, oldest first: its nodes, the + /// anchor slot and the `lines_shown - 1` before it (`0x788a03`-`0x788a0d`). + pub fn shown_range(&self) -> std::ops::Range { + match self.index_of(self.anchor) { + Some(top) if self.lines_shown > 0 => top + 1 - self.lines_shown.min(top + 1)..top + 1, + _ => 0..0, + } + } + + /// The relayout's walk from the anchor in `viewport`, read only: how many lines it places, + /// how many it may place (`count + anchor − pos`, less the limit past a wrap, + /// `0x7887dc`-`0x7887ea`), and whether it stopped on one that did not fit. From the anchor + /// back each line costs its block height plus the gap to the next (`0x7889a3`-`0x7889ad`); + /// one that does not fit whole in what is left ends the walk (`0x7889b3`-`0x7889be`, + /// `0x788a2f`) unless it is the anchored one (`0x7889c0`-`0x7889c5`), which always shows. A + /// faded line is placed all the same (`0x7889c7`-`0x7889ee` only hides it). + fn walk(&self, viewport: Viewport) -> (usize, i64, bool) { + let avail = i64::from(self.count()) - self.scroll_offset() as i64; + let Some(top) = self.index_of(self.anchor).filter(|_| avail > 0) else { + return (0, avail, false); + }; + let mut left = viewport.height; + let mut placed = 0usize; + for idx in (0..=top).rev().take(avail as usize) { + let cost = viewport.cost(self.lines[idx].rows); + if placed > 0 && left < cost { + return (placed, avail, true); + } + left -= cost; + placed += 1; + } + (placed, avail, false) + } + + /// `LayoutLines(anchor)` (`0x788750`). An empty ring returns at once (`0x78875f`). Otherwise + /// it zeroes lines shown and stores the anchor (`0x788782`, `0x788788`); a frame that is not + /// visible stops there (`0x78878e`) but for the link flag (`0x788a8e`). A visible one sets at + /// bottom to whether the anchor is the newest line, forcing a re-arm when it lands there from + /// off the bottom (`0x788799`-`0x7887b6`); walks and re-arms each line it reaches unless at the + /// bottom unforced (`0x788af0` at `0x7888be`, before the fit test, so the first line that does + /// not fit is re-armed too); then counts lines shown and sets at top to whether it placed every + /// line it could (`0x788a20`, `0x788a37`). + pub fn layout(&mut self, anchor: i32, viewport: impl Into) { + let viewport = viewport.into(); + if self.write_pos == -1 { + return; + } + // A moved anchor fires `OnMessageScrollChanged` at the tail, hidden or not (`0x788a89`). + if anchor != self.anchor { + self.scroll_changes += 1; + } + self.lines_shown = 0; + self.anchor = anchor; + self.links_dirty = true; + if !viewport.visible { + return; + } + let force = anchor == self.write_pos && !self.at_bottom; + self.at_bottom = anchor == self.write_pos; + let (placed, avail, broke) = self.walk(viewport); + if force || !self.at_bottom { + if let Some(top) = self.index_of(anchor) { + let walked = (placed + usize::from(broke)).min(top + 1); + self.rearm(top + 1 - walked..top + 1); + } + } + self.lines_shown = placed; + self.at_top = placed as i64 == avail; + } + + /// A relayout at the anchor: a resize (`0x788427`), a font change (`0x788086`), a show + /// (`0x788451`) and `UpdateColorByID` (`0x78829e`). + pub fn relayout(&mut self, viewport: impl Into) { + self.layout(self.anchor, viewport); + } + + /// Re-count lines shown and at top on a visible frame from rows the host measured after the + /// relayout ran, standing for the reference's measure inside the walk; nothing is re-armed. + pub fn recount(&mut self, viewport: impl Into) { + let viewport = viewport.into(); + if self.write_pos == -1 || !viewport.visible { + return; + } + let (placed, avail, _) = self.walk(viewport); + self.lines_shown = placed; + self.at_top = placed as i64 == avail; + } + + /// How many lines the view places in `viewport` from the anchor, live: what the draw + /// (`emit_message_lines`) walks, and the relayout's count while the frame is visible. + pub fn displayed_count(&self, viewport: impl Into) -> usize { + self.walk(viewport.into()).0 + } + /// `UpdateColorByID(id, r, g, b)` (`0x7932b0`): recolour every line tagged `id`, returning how /// many changed. `id == 0` matches nothing (the guard at `0x788250`): a line printed without /// an id stores 0 (`0x7929b7`), and `ChatFrame.lua:1357` recolours REPLY, whose id is 0. @@ -147,8 +305,6 @@ impl ScrollingMessageState { if id == 0 { return 0; } - // Past the guard the relayout runs whether or not a line matched (`0x78829e`). - self.relayout(viewport); let rgb = [quantize_u8(r), quantize_u8(g), quantize_u8(b)]; let mut moved = 0; for line in self.lines.iter_mut().filter(|l| l.id == id) { @@ -160,6 +316,8 @@ impl ScrollingMessageState { if moved > 0 { self.lines_gen = self.lines_gen.wrapping_add(1); } + // Past the guard the relayout runs whether or not a line matched (`0x78829e`). + self.relayout(viewport); moved } @@ -169,12 +327,8 @@ impl ScrollingMessageState { self.add_with_id(text, r, g, b, 0, 8); } - /// `AddMessage(text, r, g, b, id)` (`0x792900`, `0x788150`): push the line and drop the oldest - /// past `max_lines`. The store leaves the anchor slot where it is, so a scrolled-up view - /// stays on the same messages (`0x788220`) and relayouts nothing, except that a full ring - /// whose write lands on the topmost shown message, the oldest, scrolls one line newer - /// (`0x78819e`-`0x7881bb`, `0x78823f`-`0x78824a`). At the bottom it relayouts at the newest - /// (`0x788234`). + /// `AddMessage(text, r, g, b, id)` (`0x792900`, `0x788150`): store the line through + /// [`Self::add_packed`] with the colour quantized and opaque. pub fn add_with_id( &mut self, text: String, @@ -183,121 +337,103 @@ impl ScrollingMessageState { b: f32, id: u32, viewport: impl Into, + ) { + // The binding packs the given colour with the alpha byte 0xff (`0x792add`). + let color = crate::color::Rgba8::rgba(quantize_u8(r), quantize_u8(g), quantize_u8(b), 255); + self.add_packed(text, color, id, viewport); + } + + /// `StoreLine` (`0x788150`) with the record's colour dword as given, its alpha byte the line's + /// starting alpha: `AddMessage` without a colour copies the frame font's whole dword + /// (`0x792b39`-`0x792b45`) and the store keeps it (`0x7881f9`-`0x7881fe`). The count grows to + /// the limit and the write position advances, wrapping (`0x788177`-`0x788198`). At the bottom + /// it relayouts at the new line (`0x788234`); off it, when the write lands on the topmost + /// shown slot, `anchor − shown + 1` (`0x78819e`-`0x7881bb`), it scrolls down once + /// (`0x788245`); else nothing moves, so a scrolled-up view keeps its lines. + pub fn add_packed( + &mut self, + text: String, + color: crate::color::Rgba8, + id: u32, + viewport: impl Into, ) { let viewport = viewport.into(); self.lines_gen = self.lines_gen.wrapping_add(1); - let scrolled_up = self.scroll_offset > 0; - // A full ring writes over the oldest message, which is the topmost shown one when the - // view reaches the top. - let overwrites_topmost = - scrolled_up && self.lines.len() >= self.max_lines && self.at_top(viewport); - let line = MessageLine { + let limit = self.limit(); + self.write_pos += 1; + if self.write_pos >= limit { + self.write_pos -= limit; + } + let mut topmost = self.anchor - self.lines_shown as i32 + 1; + if topmost < 0 { + topmost += limit; + } + let overwrites_topmost = self.write_pos == topmost; + self.lines.push_back(MessageLine { text, id, - color: [quantize_u8(r), quantize_u8(g), quantize_u8(b)], + color: [color.r, color.g, color.b], time_left: self.time_visible, fade_left: self.fade_duration, - alpha: 1.0, + alpha: f32::from(color.a) / 255.0, + shown: true, rows: 1, rows_key: 0, - }; - self.lines.push_back(line); + }); while self.lines.len() > self.max_lines { self.lines.pop_front(); } - if !scrolled_up { - self.links_dirty = true; + if self.at_bottom { + self.layout(self.write_pos, viewport); } else if overwrites_topmost { - // The new message puts the anchor one further from the newest, and the scroll down - // (`0x788245`) takes it back one, so the offset holds. - self.rearm_walked(viewport); - } else { - // The same messages stay in view: the new one is one place newer than the anchor. - self.scroll_offset += 1; + self.step_down(viewport); } - self.clamp_scroll(); } - /// `SetMaxLines(n)` (`0x787dd0`): free every line and reset the scroll, then set the capacity. + /// `SetMaxLines(n)` (`0x787dd0`): free every line, then the count 0, the limit, lines shown 0, + /// at bottom, not at top, an empty write position and anchor 0 (`0x787e95`-`0x787eb1`, + /// `0x787f95`, `0x787f9f`). It does not relayout. pub fn set_max_lines(&mut self, n: usize) { self.lines_gen = self.lines_gen.wrapping_add(1); self.lines.clear(); - self.scroll_offset = 0; self.max_lines = n.max(1); + self.lines_shown = 0; + self.at_bottom = true; + self.at_top = false; + self.write_pos = -1; + self.anchor = 0; } - /// `Clear` (`0x7882b0`): retire every line immediately (no fade), reset to the bottom. - pub fn clear(&mut self) { + /// `Clear` (`0x7882b0`): the count and write position 0, a relayout at slot 0 (`0x7882c9`), + /// then at top 0 and the write position −1 (`0x7882ce`, `0x7882d8`). The relayout runs on a + /// ring it sees as holding a line at slot 0, so a visible frame lands at the bottom. + pub fn clear(&mut self, viewport: impl Into) { + self.clear_layout(viewport); + self.clear_finish(); + } + + /// [`Self::clear`] up to its relayout, inside which `OnMessageScrollChanged` fires + /// (`0x7882c9`), so a handler reads the count and write position 0. + pub fn clear_layout(&mut self, viewport: impl Into) { self.lines_gen = self.lines_gen.wrapping_add(1); - // Its relayout (`0x7882c9`). - self.links_dirty = true; self.lines.clear(); - self.scroll_offset = 0; + self.write_pos = 0; + self.layout(0, viewport); } - /// Whether the view is pinned to the newest line (`AtBottom`). - pub fn at_bottom(&self) -> bool { - self.scroll_offset == 0 + /// [`Self::clear`] after its relayout: at top 0 and the write position −1. + pub fn clear_finish(&mut self) { + self.at_top = false; + self.write_pos = -1; } - /// `AtTop` (`0x793130`), which reads the flag `+0x350`: the relayout sets it when every older - /// message was placed (`0x788a37`-`0x788a3c`), so the oldest is on screen. An empty frame - /// answers false: the constructor, `Clear` after its relayout and `SetMaxLines` all zero it - /// (`0x787775`, `0x7882ce`, `0x787eb1`). This reads the layout live, while the reference caches - /// the flag per relayout, so the two differ on a hidden frame, whose relayout does nothing - /// (`0x78878e`), and after an `AddMessage` while scrolled up (`0x788220`-`0x78824a`). - pub fn at_top(&self, viewport: impl Into) -> bool { - let viewport = viewport.into(); - if self.lines.is_empty() { - return false; - } - let available = self.lines.len().saturating_sub(self.scroll_offset); - self.displayed_count(viewport) == available - } - - /// The furthest the view can scroll: the oldest line as the anchor, where `ScrollToTop` - /// relayouts (`0x788690`). - fn max_scroll(&self) -> usize { - self.lines.len().saturating_sub(1) - } - - fn clamp_scroll(&mut self) { - self.scroll_offset = self.scroll_offset.min(self.max_scroll()); - } - - /// Re-arm the fade of the displayed lines only, on a frame with lines (`0x788b80`): full alpha - /// and the frame's current `timeVisible`/`fadeDuration`, so even a fully faded line comes back - /// (expiry frees nothing, `0x788525`). The refused scroll's re-arm; a scroll that moves - /// re-arms [`Self::rearm_walked`]. Engine-only, not a 1.12 Lua verb. - pub fn reset_all_fade_times(&mut self, viewport: impl Into) { - let viewport = viewport.into(); - // Both a refused scroll's re-arm (`0x788bf1`) and a moving one's relayout (`0x788a8e`) - // mark the links for rebuild. + /// Re-arm the fade of the lines shown (`0x788b80`): full alpha and the frame's current + /// `timeVisible`/`fadeDuration`, so even a fully faded line comes back (expiry frees nothing, + /// `0x788525`), then mark the links (`0x788bf1`). The refused scroll's re-arm; a scroll that + /// moves re-arms what its relayout walks. Engine-only, not a 1.12 Lua verb. + pub fn reset_all_fade_times(&mut self) { self.links_dirty = true; - self.rearm(self.displayed_range(viewport)); - } - - /// A relayout at the current position, with no scroll: it marks the links and, off the bottom, - /// re-arms what it walks (force 0 re-arms unless `+0x354` is set, `0x788aff`). `UpdateColorByID` - /// (`0x78829e`), a resize (`0x788427`), a font change (`0x788086`) and a show (`0x788451`) are - /// such relayouts. - pub fn relayout(&mut self, viewport: impl Into) { - if self.at_bottom() { - self.links_dirty = true; - } else { - self.rearm_walked(viewport); - } - } - - /// A moving scroll's re-arm: the relayout re-arms each message it walks (`0x788af0` at - /// `0x7888be`, before the fit test at `0x7889b3`). That is the displayed messages and the - /// first older one that did not fit; a walk that ran out of messages has no extra one. The - /// extra message is re-armed but not shown, so it ticks only once a larger view places it. - pub fn rearm_walked(&mut self, viewport: impl Into) { - let viewport = viewport.into(); - self.links_dirty = true; - let shown = self.displayed_range(viewport); - self.rearm(shown.start.saturating_sub(1)..shown.end); + self.rearm(self.shown_range()); } fn rearm(&mut self, range: std::ops::Range) { @@ -306,139 +442,155 @@ impl ScrollingMessageState { line.time_left = time_visible; line.fade_left = fade_duration; line.alpha = 1.0; + line.shown = true; } } - /// The ring indices the view shows, oldest first. - pub fn displayed_range(&self, viewport: impl Into) -> std::ops::Range { - let viewport = viewport.into(); - let count = self.displayed_count(viewport); - if count == 0 { - return 0..0; - } - let newest = self.lines.len().saturating_sub(1 + self.scroll_offset); - newest + 1 - count.min(newest + 1)..newest + 1 - } - // ── The scroll entries ────────────────────────────────────────────────────────────────── // - // Each re-arms the displayed lines' fade after moving the cursor. A scroll that cannot move - // calls `0x788b80` directly (`0x788626`, `0x788666`, `0x7886e5`); one that moves relayouts - // (`0x788750`), whose helper `0x788af0` re-arms on landing off the bottom or back onto it. + // Each tests the cached flags. A scroll that cannot move calls `0x788b80` directly + // (`0x788626`, `0x788666`, `0x7886e5`, `0x788739`); one that moves relayouts (`0x788750`). - /// `ScrollUp` (`0x788610`): one line older, then re-arm. + /// `ScrollUp` (`0x788610`). pub fn scroll_up(&mut self, viewport: impl Into) { - let viewport = viewport.into(); self.step_up(viewport); } - /// `ScrollDown` (`0x788650`): one line newer, then re-arm. + /// `ScrollDown` (`0x788650`). pub fn scroll_down(&mut self, viewport: impl Into) { - let viewport = viewport.into(); self.step_down(viewport); } /// `0x788610`'s answer: whether the view moved. An empty frame answers 0 and touches nothing - /// (`0x788619`); one whose oldest message is on screen re-arms and answers 0 (`0x78861c`- - /// `0x788626`). + /// (`0x788619`); one at the top re-arms and answers 0 (`0x78861c`-`0x788626`); else it + /// relayouts one slot older, wrapping at the limit (`0x78862e`-`0x78863e`). pub fn step_up(&mut self, viewport: impl Into) -> bool { - let viewport = viewport.into(); - if self.lines.is_empty() { + if self.write_pos == -1 { return false; } - let moved = !self.at_top(viewport); - if moved { - self.scroll_offset += 1; - self.rearm_walked(viewport); - } else { - self.reset_all_fade_times(viewport); + if self.at_top { + self.reset_all_fade_times(); + return false; } - moved + let mut slot = self.anchor - 1; + if slot < 0 { + slot += self.limit(); + } + self.layout(slot, viewport); + true } - /// `0x788650`'s answer: the same, toward the newest line (`0x788659`, `0x788666`). + /// `0x788650`'s answer: the same toward the newest line, refused at the bottom (`0x788659`, + /// `0x788666`), one slot newer wrapping at the limit (`0x78866e`-`0x788682`). pub fn step_down(&mut self, viewport: impl Into) -> bool { - let viewport = viewport.into(); - if self.lines.is_empty() { + if self.write_pos == -1 { return false; } - let moved = !self.at_bottom(); - if moved { - self.scroll_offset -= 1; - self.rearm_walked(viewport); - } else { - self.reset_all_fade_times(viewport); + if self.at_bottom { + self.reset_all_fade_times(); + return false; } - moved + let mut slot = self.anchor + 1; + if slot >= self.limit() { + slot -= self.limit(); + } + self.layout(slot, viewport); + true } - /// `ScrollToBottom` (`0x7886d0`): to the newest line, then re-arm. An empty frame returns - /// at once (`0x7886d6`-`0x7886d9`). + /// `ScrollToBottom` (`0x7886d0`): an empty frame returns at once (`0x7886d9`); at the bottom it + /// re-arms (`0x7886e5`), else it relayouts at the newest line (`0x7886eb`). pub fn scroll_to_bottom(&mut self, viewport: impl Into) { - let viewport = viewport.into(); - if self.lines.is_empty() { + if self.write_pos == -1 { return; } - // At the bottom it is the refused re-arm (`0x7886e5`); else the relayout's. - let moved = !self.at_bottom(); - self.scroll_offset = 0; - if moved { - self.rearm_walked(viewport); + if self.at_bottom { + self.reset_all_fade_times(); } else { - self.reset_all_fade_times(viewport); + self.layout(self.write_pos, viewport); } } - /// `ScrollToTop` (`0x788690`): the oldest line as the anchor, then re-arm, as its relayout - /// lands off the bottom. With exactly one message the anchor is the newest (`0x788799`- - /// `0x7887aa`), and a frame already at the bottom skips the re-arm (`0x788aff`); the links - /// still rebuild (`0x788a8e`). An empty frame returns at once (`0x788697`-`0x78869a`). + /// `ScrollToTop` (`0x788690`): an empty frame returns at once (`0x78869a`); else it relayouts + /// at the oldest slot, `pos + 1` on a full ring and 0 otherwise (`0x78869c`-`0x7886ba`). pub fn scroll_to_top(&mut self, viewport: impl Into) { - let viewport = viewport.into(); - if self.lines.is_empty() { + if self.write_pos == -1 { return; } - let stays_at_bottom = self.at_bottom() && self.lines.len() == 1; - self.scroll_offset = self.max_scroll(); - if stays_at_bottom { - self.links_dirty = true; + let limit = self.limit(); + let mut slot = 0; + if self.count() == limit { + slot = self.write_pos + 1; + if slot == limit { + slot = 0; + } + } + self.layout(slot, viewport); + } + + /// `SetScrollFromBottom(n)` (`0x788700`): nothing on an empty frame (`0x78870c`, `0x788717`); + /// else the slot `(pos − n + count) % count`, a truncated remainder floored at 0 + /// (`0x788719`-`0x788729`; the clamp to the count after it cannot bind), then a re-arm in place + /// when it is the anchor (`0x788739`) and a relayout there otherwise (`0x788744`). + pub fn set_scroll_from_bottom(&mut self, n: i32, viewport: impl Into) { + let count = self.count(); + if count == 0 || self.write_pos == -1 { + return; + } + let rem = self + .write_pos + .wrapping_sub(n) + .wrapping_add(count) + .wrapping_rem(count); + let slot = rem.max(0).min(count); + if slot == self.anchor { + self.reset_all_fade_times(); } else { - self.rearm_walked(viewport); + self.layout(slot, viewport); } } - /// How many messages the layout places in `viewport`, the client's lines shown (`+0x34c`, - /// which paging reads at `0x7885b4`). From the anchor outward each message costs its block - /// height plus the gap to the next (`0x7889a3`-`0x7889ad`); one that does not fit whole in - /// what is left ends the walk (`0x7889b3`-`0x7889be`, `0x788a2f`) unless it is the anchored - /// one (`0x7889c0`-`0x7889c5`), which always shows. A faded message is placed all the same - /// (`0x7889c7`-`0x7889ee` only hides it), so it holds its rows. The draw - /// (`emit_message_lines`) walks this many. - pub fn displayed_count(&self, viewport: impl Into) -> usize { - let viewport = viewport.into(); - if self.lines.is_empty() { + /// `GetCurrentScroll`'s `0x788ac0`: 0 when empty (`0x788ac9`, `0x788ad4`), else + /// `(pos − anchor + count) % count`, a truncated remainder (`0x788ad6`-`0x788ae2`). + pub fn current_scroll(&self) -> i32 { + let count = self.count(); + if count == 0 || self.write_pos == -1 { return 0; } - let top_index = self.lines.len().saturating_sub(1 + self.scroll_offset); - let mut left = viewport.height; - let mut count = 0usize; - for idx in (0..=top_index).rev() { - let cost = viewport.cost(self.lines[idx].rows); - if count > 0 && left < cost { - break; - } - left -= cost; - count += 1; - } - count + self.write_pos + .wrapping_sub(self.anchor) + .wrapping_add(count) + .wrapping_rem(count) } - /// `PageUp` (`0x7885b0`): `ScrollUp` once per displayed line, the count read once up front + /// `SetTimeVisible(s)`'s store (`0x788090`): each line shown with its shown flag takes `s` as + /// its visible time (`0x7880b4`), then the frame (`0x7880cc`). + pub fn set_time_visible(&mut self, s: f32) { + let range = self.shown_range(); + for line in self.lines.range_mut(range).filter(|l| l.shown) { + line.time_left = s; + } + self.time_visible = s; + } + + /// `SetFadeDuration(s)`'s store (`0x7880f0`): the same over the fade time (`0x788114`, + /// `0x78812c`). + pub fn set_fade_duration(&mut self, s: f32) { + let range = self.shown_range(); + for line in self.lines.range_mut(range).filter(|l| l.shown) { + line.fade_left = s; + } + self.fade_duration = s; + } + + /// `PageUp` (`0x7885b0`): `ScrollUp` once per line shown, the cached count read once up front /// (`0x7885b4`); a step that cannot move ends the page there (`0x7885ca`), else one `ScrollDown` - /// follows (`0x7885d4`). A page is the displayed count less one, and one displayed line nets 0. + /// follows (`0x7885d4`). With no line shown the page is that step back alone. The binding runs + /// this loop a step at a time, so each step's `OnMessageScrollChanged` fires in its place. + #[cfg(test)] pub fn page_up(&mut self, viewport: impl Into) { let viewport = viewport.into(); - for _ in 0..self.displayed_count(viewport) { + for _ in 0..self.lines_shown { if !self.step_up(viewport) { return; } @@ -446,11 +598,12 @@ impl ScrollingMessageState { self.step_down(viewport); } - /// `PageDown` (`0x7885e0`): the mirror, `ScrollDown` per displayed line (`0x7885f3`), a refusal + /// `PageDown` (`0x7885e0`): the mirror, `ScrollDown` per line shown (`0x7885f3`), a refusal /// ending the page (`0x7885fa`), else one `ScrollUp` (`0x788604`). + #[cfg(test)] pub fn page_down(&mut self, viewport: impl Into) { let viewport = viewport.into(); - for _ in 0..self.displayed_count(viewport) { + for _ in 0..self.lines_shown { if !self.step_down(viewport) { return; } @@ -458,43 +611,61 @@ impl ScrollingMessageState { self.step_up(viewport); } - /// Advance the fade by `dt` (OnUpdate, `0x788460`), only at the bottom with fading on; the - /// scroll entries, not this, bring faded lines back. Both countdowns store `f32` while the - /// ramp reads the unrounded x87 value (`fst` without pop, `0x7884d7`, `0x788544`), and phase - /// 1's overshoot is dropped, so the fade starts whole on the next tick. - pub fn tick(&mut self, dt: f32, viewport: impl Into) { - let viewport = viewport.into(); - if !self.fading_enabled || !self.at_bottom() { + /// Advance the fade by `dt` (OnUpdate, `0x788460`): only with fading on, at the bottom and + /// lines shown, as cached (`0x788480`, `0x78848c`, `0x78849b`), and over the lines shown alone, + /// so a line out of view keeps its fade where it stopped. The scroll entries, not this, bring + /// faded lines back. Each line keys on its own countdowns, each tested `!= 0` (`test ah,0x44`): + /// a nonzero visible time counts down and is stored (`0x7884d7`); once below 0 the line hides if + /// its fade time is 0 (`0x7884f9`) and otherwise starts the fade next tick at time 0 + /// (`0x7884fb`). At time 0 a fade time of 0 leaves the line as it is (`0x78850e`); any other + /// counts down, hiding below 0 (`0x788521`, so exactly 0 stays shown at alpha 0), else stored + /// with the alpha byte `ftol(left / fadeDuration · 255)`, unclamped (`0x788544`-`0x788558`), from + /// the unrounded x87 value. + pub fn tick(&mut self, dt: f32) { + if !self.fading_enabled || !self.at_bottom || self.lines_shown == 0 { return; } let dt = f64::from(dt); - // Only the shown messages: the loop is bounded by the shown count (`0x78849b`, - // `0x788582`), so a line out of view keeps its fade where it stopped. - for line in self.lines.range_mut(self.displayed_range(viewport)) { - if line.time_left > 0.0 { - line.time_left = ((f64::from(line.time_left) - dt).max(0.0)) as f32; + let duration = f64::from(self.fade_duration); + let range = self.shown_range(); + let mut hid = false; + for line in self.lines.range_mut(range) { + if !line.shown { continue; } - if self.fade_duration <= 0.0 { - // No ramp: the line vanishes when phase 1 ends, marking the links (`0x788538`). - self.links_dirty |= line.alpha > 0.0; + // The hide (`0x788525`): the flag cleared, the node hidden and the links marked + // (`0x788538`). The record's alpha byte is left; here it reads 0, which nothing can + // see while the flag is clear. + let mut hide = |line: &mut MessageLine| { + line.shown = false; line.alpha = 0.0; + hid = true; + }; + if line.time_left != 0.0 { + let left = f64::from(line.time_left) - dt; + line.time_left = left as f32; + if left < 0.0 { + if line.fade_left == 0.0 { + hide(line); + } else { + line.time_left = 0.0; + } + } continue; } - let remaining = f64::from(line.fade_left) - dt; - line.fade_left = remaining as f32; - if remaining <= 0.0 { - // The hide marks the links (`0x788538`), once: a hidden line is skipped after. - self.links_dirty |= line.alpha > 0.0; - line.fade_left = 0.0; - line.alpha = 0.0; - } else { - // Over the live fadeDuration (a mid-fade change rescales the ramp), from the - // unrounded `remaining`, not the stored f32. - let byte = quantize_fade_wide(remaining / f64::from(self.fade_duration)); - line.alpha = f32::from(byte) / 255.0; + if line.fade_left == 0.0 { + continue; } + let left = f64::from(line.fade_left) - dt; + if left < 0.0 { + hide(line); + continue; + } + line.fade_left = left as f32; + let byte = crate::script::binding_abi::ftol(left / duration * 255.0) as u8; + line.alpha = f32::from(byte) / 255.0; } + self.links_dirty |= hid; } } @@ -503,11 +674,6 @@ fn quantize_fade(x: f32) -> u8 { (x.clamp(0.0, 1.0) * 255.0).trunc() as u8 } -/// [`quantize_fade`] of the unrounded ratio (see [`ScrollingMessageState::tick`]). -fn quantize_fade_wide(x: f64) -> u8 { - (x.clamp(0.0, 1.0) * 255.0).trunc() as u8 -} - /// Where a MessageFrame's newest message enters: XML `insertMode` (`0x87a618`) and /// `SetInsertMode`/`GetInsertMode` (`0x794ed0`/`0x794ff0`); the scrolling class has neither. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -574,6 +740,7 @@ impl MessageFrameState { fade_left: self.fade_duration, // Quantized like the colour: all four channels pack into one ARGB dword. alpha: f32::from(quantize_u8(a)) / 255.0, + shown: true, rows: 1, rows_key: 0, }); diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 851799727..ecbc204a7 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -421,7 +421,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - That file is renamed to `.lua.bak`, where the reference deletes it, so an addon update that drops its toc line costs the player nothing they cannot get back. ([`src/ui_script/addons.rs:657`](../crates/benilla-app/src/ui_script/addons.rs#L657)) - The reference writes none (its file opens with a blank line); ours names the file, because players open this folder. ([`src/ui_script/addons.rs:715`](../crates/benilla-app/src/ui_script/addons.rs#L715)) - The reference then re-reads the aspect (`0x492f13`), and on a change of 0.001 or more calls `0x491380`, which sets the reload trigger (`[0xb4b3f4]`) when the script-context check `0x494a50(0xa)` allows it; here a resize re-lays out the live UI, the chat bubbles' window-derived `BubbleGeometry` with it, because the window resizes freely and a reload on each resize would tear down every addon's state mid-play. ([`src/ui_script/extract/mod.rs:523`](../crates/benilla-app/src/ui_script/extract/mod.rs#L523)) -- The V-plate border is resampled sharp to the quad's physical size rather than GPU-magnified, so its 1 px bevel stays crisp; keyed by that size, so only a resize re-rasterises it. ([`src/ui_script/extract/mod.rs:1403`](../crates/benilla-app/src/ui_script/extract/mod.rs#L1403)) +- The V-plate border is resampled sharp to the quad's physical size rather than GPU-magnified, so its 1 px bevel stays crisp; keyed by that size, so only a resize re-rasterises it. ([`src/ui_script/extract/mod.rs:1404`](../crates/benilla-app/src/ui_script/extract/mod.rs#L1404)) - No signature or digest check (`0x46a90b`, `0x46a9d3`, `0x401560(9)`), so the chain's toc always loads and `FRAMES_LOADED` follows, because the check verifies Blizzard's signature with a key carried in the client binary, which benilla does not ship, and a modified interface file is loaded rather than refused. ([`src/ui_script/glue.rs:14`](../crates/benilla-app/src/ui_script/glue.rs#L14)) - The reference's size callback only re-scales `GlueParent` and re-lays it out (`0x46ac51`, `0x46ac56`), as its root keeps `gxResolution`'s aspect until a rebuild (`0x41ad10` at `0x46a828`) and its window keeps that shape while dragged (`0x5989d7`); here the root is the window's, which takes any shape, so the box follows it on every resize, where a rebuild would reset the screen (the create screen's character and name, the typed password, the selection). ([`src/ui_script/glue.rs:296`](../crates/benilla-app/src/ui_script/glue.rs#L296)) - The reference's handler completes whichever operation is current; here only the character list's does, in state 3, and a reply of another cause (a roster nothing of the screen asked for) or of another link (before a realm change) completes nothing. ([`src/ui_script/glue_mgr.rs:1362`](../crates/benilla-app/src/ui_script/glue_mgr.rs#L1362)) @@ -678,10 +678,10 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - An id outside the seven built windows raises, where the reference serves ten records and silently ignores any other id (`0x4a0be1`), because benilla keeps only the seven and every caller loops `1, NUM_CHAT_WINDOWS`. ([`src/script/chat_window.rs:220`](../crates/benilla-ui/src/script/chat_window.rs#L220)) - One past `i32::MAX` clamps where the reference keeps the low 32 bits, for the colours' reason. ([`src/script/chat_window.rs:430`](../crates/benilla-ui/src/script/chat_window.rs#L430)) - The reference reads the clock live at each call (`0x42c010`); ours is the session clock the tick advances by the paced frame step, one value per frame, so `GetTime()` deltas and the `elapsed` handed to OnUpdate agree, as FrameXML (CastingBarFrame and kin) assumes. ([`src/script/clock.rs:53`](../crates/benilla-ui/src/script/clock.rs#L53)) -- With no wheel it scales by the strip's, because the reference reads the missing wheel unguarded. ([`src/script/colorselect.rs:356`](../crates/benilla-ui/src/script/colorselect.rs#L356)) +- With no wheel it scales by the strip's, because the reference reads the missing wheel unguarded. ([`src/script/colorselect.rs:379`](../crates/benilla-ui/src/script/colorselect.rs#L379)) - The reference's `set` registers an unknown name as a new category-5 record (`0x63d569`-`0x63d582`); benilla refuses it, because nothing would read the row and `config.toml` keeps an unknown key anyway. ([`src/script/console_exec.rs:120`](../crates/benilla-ui/src/script/console_exec.rs#L120)) - A row the session owns (an env lever) is passed, because the env chose its value for this run and the file never holds it, so a reset would undo the lever and save nothing. ([`src/script/console_exec.rs:199`](../crates/benilla-ui/src/script/console_exec.rs#L199)) -- A row that no longer resolves is refused, as `0x4c7300` does, because this path dereferences it unchecked (`0x4f9f35`), a latent null deref. ([`src/script/container.rs:666`](../crates/benilla-ui/src/script/container.rs#L666)) +- A row that no longer resolves is refused, as `0x4c7300` does, because this path dereferences it unchecked (`0x4f9f35`), a latent null deref. ([`src/script/container.rs:672`](../crates/benilla-ui/src/script/container.rs#L672)) - A last `spell_id` key breaks the remaining ties (Charge's six ranks all have `spellLevel` 0), because the reference leaves them to its qsort over an array benilla does not reproduce; ascending id is ascending rank in every 1.12 pet-ability family. ([`src/script/craft.rs:104`](../crates/benilla-ui/src/script/craft.rs#L104)) - On `Terrain` the reference keeps a spell, action, macro or pet action on the left button; we clear them, because otherwise a spell has no left-click way off the cursor. ([`src/script/cursor.rs:523`](../crates/benilla-ui/src/script/cursor.rs#L523)) - Every lock lifts here, bag contents included. The reference's walk of a bag's contents indexes the bag by its own player-array index (`0x495528`, `0x495535`), at least 19, past a bag's slots in every case but the biggest bags, so it normally unlocks none of them; a lock it strands lasts until the item's seat or stack moves, which this does not copy. ([`src/script/cursor.rs:694`](../crates/benilla-ui/src/script/cursor.rs#L694)) @@ -705,7 +705,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - Past `NAME_TRIES` names the answer is nil, where the reference keeps trying, forever when the generator has learned nothing. ([`src/script/glue_create.rs:968`](../crates/benilla-ui/src/script/glue_create.rs#L968)) - With no Region rows the reference reads a NULL category (`0x46e5b8`, `0x46f26a`) and faults; benilla lists every realm under one category, so a client whose `Wow.ini` names another Region still reaches a realm. ([`src/script/glue_realm.rs:195`](../crates/benilla-ui/src/script/glue_realm.rs#L195)) - Equal nonzero counts tie here, where the reference answers +1 both ways (its name tie-break at `0x46e841` is clobbered by `0x46e846`), because a comparator that is not a total order has no defined sort. ([`src/script/glue_realm.rs:260`](../crates/benilla-ui/src/script/glue_realm.rs#L260)) -- A span count that no longer matches the live set always rebuilds, so a state this model does not flag (a ring wiped by `SetMaxLines`, a hidden frame) leaves no stale button. ([`src/script/hyperlink.rs:161`](../crates/benilla-ui/src/script/hyperlink.rs#L161)) +- A span count that no longer matches the live set always rebuilds, so a state this model does not flag (a ring wiped by `SetMaxLines`, a hidden frame, a frame not yet laid out, which the reference's rebuild skips at `0x78af7b`) leaves no stale button. ([`src/script/hyperlink.rs:165`](../crates/benilla-ui/src/script/hyperlink.rs#L165)) - `IsInInstance()` then answers `nil, "none"`, because the reference returns two results without pushing either (`0x48a7a9`), a stack bug. ([`src/script/instance.rs:21`](../crates/benilla-ui/src/script/instance.rs#L21)) - Bounds-checked where the reference indexes unguarded, so none reads past it. ([`src/script/item_stats.rs:379`](../crates/benilla-ui/src/script/item_stats.rs#L379)) - Nil for a display row with no icon, where the reference answers the unloadable `Interface\Icons\`, because `SetTexture` shows no icon for either. ([`src/script/item_stats.rs:482`](../crates/benilla-ui/src/script/item_stats.rs#L482)) @@ -713,14 +713,14 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - An addon file whose first binding has no header opens a `HEADER_` row, where the reference files those rows under the previous file's last header, because under a foreign header the player could not find them. ([`src/script/keybind.rs:297`](../crates/benilla-ui/src/script/keybind.rs#L297)) - The handle's value is the address of a `CGxTex` (`SMemAlloc(0x54)`, `0x594a96`), which benilla cannot reproduce; each texture ranks instead by a creation stamp, one global sequence number taken when its cache key is first created and taken anew once its last region has let go of it (`super::texel::Texels`). ([`src/script/layer_batch.rs:9`](../crates/benilla-ui/src/script/layer_batch.rs#L9)) - A listing that finds no icon stays empty, to be listed again on the next ask, where the reference's dedup then reads below its null list (`0x4f0134`-`0x4f0145`) and faults. ([`src/script/macros.rs:286`](../crates/benilla-ui/src/script/macros.rs#L286)) -- The client's Lua has no such bound, as nothing sets a hook (`lua_sethook`, `0x6fba40`, has no caller and no pointer to it), so a load that never ends hangs it. ([`src/script/mod.rs:814`](../crates/benilla-ui/src/script/mod.rs#L814)) -- The reference sets the cells only when glue or the interface is built, `0x41ad10` from `0x46a828` (`0x46a7b0`) and `0x48fe2b` (`0x48fbf0`), and a resize reaches them only through the rebuild it asks for. ([`src/script/mod.rs:865`](../crates/benilla-ui/src/script/mod.rs#L865)) +- The client's Lua has no such bound, as nothing sets a hook (`lua_sethook`, `0x6fba40`, has no caller and no pointer to it), so a load that never ends hangs it. ([`src/script/mod.rs:818`](../crates/benilla-ui/src/script/mod.rs#L818)) +- The reference sets the cells only when glue or the interface is built, `0x41ad10` from `0x46a828` (`0x46a7b0`) and `0x48fe2b` (`0x48fbf0`), and a resize reaches them only through the rebuild it asks for. ([`src/script/mod.rs:869`](../crates/benilla-ui/src/script/mod.rs#L869)) - A path that does not resolve is stored, where the reference raises `Invalid model file: %s` (`0x878b44`), because the file loads asynchronously here and the call cannot know yet. ([`src/script/modelframe.rs:330`](../crates/benilla-ui/src/script/modelframe.rs#L330)) - Benilla plays no pre-rendered movie (#968), so `StartMovie` answers nil, the reference's "could not start" (`0x78d861`), which the stock `MovieFrame_PlayMovie` answers by hiding the frame and so by returning to the login screen. ([`src/script/movieframe.rs:4`](../crates/benilla-ui/src/script/movieframe.rs#L4)) - The reference's free list is FIFO; either keeps every index stable, all an addon can observe. ([`src/script/nameplate.rs:431`](../crates/benilla-ui/src/script/nameplate.rs#L431)) - The reference adds this rim over the bar; we brighten the bar (`LIT_BOOST`), because the rim reads as hard edge lines in our linear-blending pipeline. ([`src/script/nameplate.rs:889`](../crates/benilla-ui/src/script/nameplate.rs#L889)) - A wrapper that decodes to no live widget takes `default_parent`, because the reference, with no `IsA` check, reads a wild pointer. ([`src/script/object/anchor_args.rs:159`](../crates/benilla-ui/src/script/object/anchor_args.rs#L159)) -- Real 1.12 kinds nothing fires yet raise too (`OnMessageScrollChanged`, `OnInputLanguageChanged`), because an accepted handler that never runs fails silently. ([`src/script/object/events_regions.rs:236`](../crates/benilla-ui/src/script/object/events_regions.rs#L236)) +- A real 1.12 kind nothing fires yet raises too (`OnInputLanguageChanged`), because an accepted handler that never runs fails silently. ([`src/script/object/events_regions.rs:236`](../crates/benilla-ui/src/script/object/events_regions.rs#L236)) - An `OnSizeChanged` that resolve produces fires at the next `UiScript::resolve`, because a Lua handler must not run in the middle of a level compaction. ([`src/script/object/toplevel.rs:74`](../crates/benilla-ui/src/script/object/toplevel.rs#L74)) - A third `%s` gets an empty string, because the reference pushes two varargs (`0x6093f2 add esp,0x14`) and would read uninitialised stack. ([`src/script/pvp/mod.rs:261`](../crates/benilla-ui/src/script/pvp/mod.rs#L261)) - A token resolving to guid 0 answers nil too. The reference jumps from the guid test (`0x5172f6`) to `return 1` (`0x517343`) with nothing pushed, so the one value is its top argument, the token it read (`0x5172e6`); that is a stack quirk, not an answer. ([`src/script/pvp/mod.rs:508`](../crates/benilla-ui/src/script/pvp/mod.rs#L508)) @@ -734,7 +734,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - The writer's escapes are undone (`unescape`), where the reference sets the bytes between the quotes raw, so a string holding a quote or a backslash would grow every session. ([`src/script/saved.rs:197`](../crates/benilla-ui/src/script/saved.rs#L197)) - The block step is not pixel-snapped (`0x766750`), because layout here is in logical units; the two differ by under a pixel, and not at all at the default spacing of 0. ([`src/script/simplehtml/mod.rs:13`](../crates/benilla-ui/src/script/simplehtml/mod.rs#L13)) - A block of width 0, in a frame sized by anchors, gets its text's natural width where the reference keeps 0, because every width-less FontString here does. ([`src/script/simplehtml/mod.rs:15`](../crates/benilla-ui/src/script/simplehtml/mod.rs#L15)) -- The reference prints these to the console; here they are host warnings, because a malformed page is bad content, not a script error. ([`src/script/simplehtml/mod.rs:182`](../crates/benilla-ui/src/script/simplehtml/mod.rs#L182)) +- The reference prints these to the console; here they are host warnings, because a malformed page is bad content, not a script error. ([`src/script/simplehtml/mod.rs:186`](../crates/benilla-ui/src/script/simplehtml/mod.rs#L186)) - Only `%s` and `%%` are expanded and other specs copied, because the real `printf` would read the string pointers as other types. ([`src/script/simplehtml/parse.rs:244`](../crates/benilla-ui/src/script/simplehtml/parse.rs#L244)) - Skipped because it is no command; the reference raises. ([`src/script/slash.rs:64`](../crates/benilla-ui/src/script/slash.rs#L64)) - The app plays the queued file after the call, so the refusals (the bus-3 cap, a muted SFX channel, a missing file) come too late to answer, and a queued file answers 1. ([`src/script/sound.rs:68`](../crates/benilla-ui/src/script/sound.rs#L68)) @@ -752,7 +752,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - The selection clears to 0, because the header the reference re-selects (`0x4d7b42`, reading 1) has no spell id; the stock window's `> 1` test treats 0 and 1 alike. ([`src/script/trainer/mod.rs:198`](../crates/benilla-ui/src/script/trainer/mod.rs#L198)) - A header clears it too, because it has no spell id; the reference stores its `0xffffffff` (`0x4d7b05`) and reads back 1, which the stock window's `> 1` test treats the same as 0. ([`src/script/trainer/mod.rs:872`](../crates/benilla-ui/src/script/trainer/mod.rs#L872)) - Index 0 is a no-op, because one stray call would spend the purse; the reference buys every visible service for any index at or below 0 (`0x4da244`, `0x4d8a70`). ([`src/script/trainer/mod.rs:894`](../crates/benilla-ui/src/script/trainer/mod.rs#L894)) -- Kept apart from `Self::vertex_color`, which the reference's `Texture:SetAlpha` (`0x79b580`) rewrites, because the drawn product differs only on a region that sets both, or a gradient and an alpha, which no stock code does, and a FontString's `SetAlpha` then keeps its font colour. ([`src/script/types.rs:390`](../crates/benilla-ui/src/script/types.rs#L390)) +- Kept apart from `Self::vertex_color`, which the reference's `Texture:SetAlpha` (`0x79b580`) rewrites, because the drawn product differs only on a region that sets both, or a gradient and an alpha, which no stock code does, and a FontString's `SetAlpha` then keeps its font colour. ([`src/script/types.rs:394`](../crates/benilla-ui/src/script/types.rs#L394)) - UnitCanCooperate answers 1 for a friendly (5 and up) player, because the engine carries no faction templates; the reference resolves their cooperation masks. ([`src/script/unit/bindings.rs:612`](../crates/benilla-ui/src/script/unit/bindings.rs#L612)) - Among whole-name matches ours picks the nearest, because the reference's first-walked pick is order-dependent and reads as a bug. ([`src/script/unit/bindings.rs:1115`](../crates/benilla-ui/src/script/unit/bindings.rs#L1115)) - The reference ends the walk at the first whole-string hit (`0x493ca2`), so its pick follows the object list's order; we rank exact, then longer prefix, then nearest (`Rank::beats`), because the nearest namesake is the better pick, and a full tie goes to the lower guid (`Rank::beats_at`), so the VM's walk and the app's scan pick the same unit whatever order each walks in. ([`src/script/unit/by_name.rs:7`](../crates/benilla-ui/src/script/unit/by_name.rs#L7)) @@ -770,8 +770,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - On the last row a walk may end at the text's end, because the end starts no row; the reference steps back from it as from a row's start (its line table ends with the text's length, `0x77d6ef`), so DOWN onto a short last line stops before its last letter. ([`src/widget/kinds/editbox.rs:884`](../crates/benilla-ui/src/widget/kinds/editbox.rs#L884)) - The drag selects from its anchor, as every OS's own text fields do, where the reference's applies `0x77cd10` to each mouse move's whole delta (`0x77a8a1`). ([`src/widget/kinds/editbox.rs:997`](../crates/benilla-ui/src/widget/kinds/editbox.rs#L997)) - A code point past U+FFFF is no space, where the client tests its low 16 bits, so U+10020 counts as a space there. ([`src/widget/kinds/editbox.rs:1127`](../crates/benilla-ui/src/widget/kinds/editbox.rs#L1127)) -- The reference has one field, the font's, but our font is read off the first `` region (`message_frame_font`), which a `CreateFrame` frame lacks; so that FontString's own `GetJustifyH` does not see this. ([`src/widget/kinds/messageframe.rs:33`](../crates/benilla-ui/src/widget/kinds/messageframe.rs#L33)) -- Kept apart from the font's, as `ScrollingMessageState::justify` is. ([`src/widget/kinds/messageframe.rs:532`](../crates/benilla-ui/src/widget/kinds/messageframe.rs#L532)) +- The reference has one field, the font's, but our font is read off the first `` region (`message_frame_font`), which a `CreateFrame` frame lacks; so that FontString's own `GetJustifyH` does not see this. ([`src/widget/kinds/messageframe.rs:41`](../crates/benilla-ui/src/widget/kinds/messageframe.rs#L41)) +- Kept apart from the font's, as `ScrollingMessageState::justify` is. ([`src/widget/kinds/messageframe.rs:698`](../crates/benilla-ui/src/widget/kinds/messageframe.rs#L698)) - An index past 5 reads the last entry, where 1.12.1 reads past both six-entry tables (`0x6da9d5`, `0x6da9c4`), a client fault a hand-edited CVar reaches. ([`src/widget/kinds/mod.rs:897`](../crates/benilla-ui/src/widget/kinds/mod.rs#L897)) - `None` until then, not the ctor's 0, so that an unset row takes nothing rather than slot 0. ([`src/widget/kinds/mod.rs:999`](../crates/benilla-ui/src/widget/kinds/mod.rs#L999)) - On the thumb it is the grabbed point, where `CSimpleSlider` (`0x789ba0`/`0x789ca0`) always centers it, because that feels less surprising and a stock-sized thumb hides the difference. ([`src/widget/kinds/mod.rs:1357`](../crates/benilla-ui/src/widget/kinds/mod.rs#L1357))