Widget methods, chat-frame state and line fades follow 1.12.1 (#1949)

Widget methods 1.12.1 has were missing or differed.

- EditBox IsAutoFocus, IsMultiLine, IsNumeric and IsPassword; ColorSelect GetColorHSV and SetColorHSV (`0x7bbc80`, `0x7bbd60`).
- AddMessage with no colour takes the frame font's colour.
- ScrollingMessageFrame: the write slot and the four line-position methods; the cached lines-shown, at-top and at-bottom state its readers use (`0x788440`); a relayout on resize (`0x788310`); OnMessageScrollChanged (`0x788a9a`); SetTimeVisible and SetFadeDuration usage, unclamped; each line fading on its own countdowns (`0x7884c1`).
- GetFramerate averages the last 30 frames (`0x672700`).
- SetCursor takes the eight named cursors in any case, with its usage line; custom cursor files are still to do.
- SimpleHTML links get buttons, so OnHyperlinkClick fires.

A byte audit re-derived every address.

Fixes #1518
Fixes #1520
Fixes #1506
Fixes #1519
Fixes #1883
Fixes #1682
Fixes #1538
Refs #1186

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-18 23:43:30 +07:00
committed by GitHub
parent fdbb4bb070
commit 57384551de
25 changed files with 1710 additions and 489 deletions
+12
View File
@@ -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,
};
@@ -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,
@@ -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<FrameHandle>,
}
/// 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,
+29 -20
View File
@@ -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::<WorldFrameTimes>()
.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::<UiPassState>()
@@ -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<NonSendMut<UiScript>>, times: Res<WorldFrameTimes>) {
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<NonSendMut<UiScript>>,
step: Res<benilla_world::frame_pace::FrameStep>,
mut smoothed: Local<f64>,
mut times: ResMut<WorldFrameTimes>,
) {
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.
+102 -10
View File
@@ -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::<f64>("return GetFramerate()").unwrap(), 0.0);
assert_eq!(
s.eval::<String>("return format(\"%.1f\", GetFramerate())")
.unwrap(),
"0.0"
);
assert_eq!(s.arity("GetFramerate()").unwrap(), 1);
assert_eq!(s.eval::<f64>("return GetFramerate()").unwrap(), 1000.0);
s.set_framerate(59.94);
assert_eq!(
s.eval::<String>("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]
@@ -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::<usize>("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::<f64>("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();
+83 -16
View File
@@ -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<String>| {
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::<Model>().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::<Model>().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::<i64>("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::<i64>("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::<bool>("return SetCursor(\"Nope\") == nil").unwrap());
assert!(s.eval::<bool>("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(
@@ -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<Flag>` 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),
]
}
@@ -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::<i64>("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#"<Ui><EditBox name="Pw" password="true" autoFocus="false"/></Ui>"#,
)
.unwrap();
let report = crate::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert_eq!(s.eval::<i64>("return Pw:IsPassword()").unwrap(), 1);
assert_eq!(probe("Pw:IsAutoFocus()"), (1, "nil".into()));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+7
View File
@@ -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
+31 -20
View File
@@ -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<Span>)> = 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::<Model>().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
+1
View File
@@ -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;
+150 -44
View File
@@ -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::<Model>().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::<Model>().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::<Model>()
.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<crate::widget::RegionHandle> {
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<FrameHandle> = 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,
});
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -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,
+2 -2
View File
@@ -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,
@@ -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<Function>) -> mlua::Result<()> {
let kind = SCRIPT_KINDS
@@ -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<RegionHandle>,
/// 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
}
@@ -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 `<A href>`, 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('<html><body><p><a href="u">t</a></p></body></html>')
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::<i64>("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('<html><body><p><a href="u">t</a></p></body></html>')"#)
.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));
}
+1 -2
View File
@@ -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
@@ -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.
+4
View File
@@ -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<crate::widget::FrameHandle>,
},
}
+2 -1
View File
@@ -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),
+392 -225
View File
@@ -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<u64>,
/// 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<u64>,
/// 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<crate::layout::Rect>,
/// 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<usize> 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<usize> {
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<usize> {
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<Viewport>) {
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<Viewport>) {
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<Viewport>) {
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<Viewport>) -> 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<Viewport>,
) {
// 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<Viewport>,
) {
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<Viewport>) {
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<Viewport>) {
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<Viewport>) -> 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<Viewport>) {
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<Viewport>) {
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<Viewport>) {
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<usize>) {
@@ -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<Viewport>) -> std::ops::Range<usize> {
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<Viewport>) {
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<Viewport>) {
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<Viewport>) -> 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<Viewport>) -> 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<Viewport>) {
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<Viewport>) {
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<Viewport>) {
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<Viewport>) -> 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<Viewport>) {
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<Viewport>) {
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<Viewport>) {
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,
});
+11 -11
View File
@@ -421,7 +421,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- That file is renamed to `<Name>.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_<addon>` 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 `<FontString>` 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 `<FontString>` 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))