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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user