a slider snaps to its step, a pushed button releases, and warnings show
- a slider with a value step snaps to it: a stepped drag fires once per row instead of once per pixel, a scrollbar cannot rest between two rows, and one wheel notch on the trainer list reaches the bottom (2133) - setting a step on a live slider moves its value onto the new lattice and runs its handler, which an addon on the real client can count on (2143) - a button pushed from script releases on the next mouse-up, so Tablet-2.0's rows stop staying pressed forever; a release off the button's rect un-presses it, and a locked state holds (2134) - the mail body and the GM ticket box scroll as you type past their bottom, and the Use UI Scale checkbox reads a registered setting (2141) - an engine warning, a call that was accepted and did nothing, reaches the /errors window in grey instead of one terminal line; a text row trimmed mid-character no longer kills the window's repaint (2135, 2138) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -57,21 +57,38 @@
|
||||
|
||||
-- The first line of a message, trimmed to a row's width. A traceback's later lines are
|
||||
-- indented continuations; on one row they read as noise.
|
||||
-- `strsub` is BYTE-indexed (Lua 5.0 strings are byte strings, and benilla keeps them that
|
||||
-- way — decision 1193), so a cut that lands inside a multi-byte character leaves half of
|
||||
-- one. Every message here is UTF-8 and ours are full of em dashes, so this was not
|
||||
-- theoretical: the log window's own repaint raised on the first warning it was ever asked
|
||||
-- to show (2135/2138). Back the cut up over any continuation byte (`10xxxxxx`, i.e.
|
||||
-- 0x80..0xBF) so it lands on a character boundary.
|
||||
function BenillaScriptLog_RowText(message)
|
||||
local cut = strfind(message, "\n", 1, 1)
|
||||
local head = message
|
||||
if cut then head = strsub(message, 1, cut - 1) end
|
||||
if strlen(head) > BENILLA_SCRIPTLOG_ROW_CHARS then
|
||||
head = strsub(head, 1, BENILLA_SCRIPTLOG_ROW_CHARS - 3) .. "..."
|
||||
local last = BENILLA_SCRIPTLOG_ROW_CHARS - 3
|
||||
while last > 0 do
|
||||
local b = strbyte(head, last + 1)
|
||||
if not b or b < 128 or b >= 192 then break end
|
||||
last = last - 1
|
||||
end
|
||||
head = strsub(head, 1, last) .. "..."
|
||||
end
|
||||
return head
|
||||
end
|
||||
|
||||
-- `load` rows are red and `error` rows amber, and the split is by CONSEQUENCE: a `load` row
|
||||
-- means an addon is not running at all, which is the thing the player can act on. Colours
|
||||
-- come from the shipped tables so this window matches the rest of the UI.
|
||||
-- The three kinds, coloured by CONSEQUENCE — how much of the addon is broken:
|
||||
-- `load` red an addon is not running at all (the thing a player can act on)
|
||||
-- `error` amber code ran and raised; the addon is otherwise up
|
||||
-- `warn` grey a call was accepted and quietly did not do what it said (2135)
|
||||
-- Grey rather than a fourth bright colour because a warning is the least urgent of the
|
||||
-- three and there are more of them: colour has to keep ranking the list, not just label
|
||||
-- it. Colours come from the shipped tables so this window matches the rest of the UI.
|
||||
function BenillaScriptLog_RowColor(kind)
|
||||
if kind == "load" then return 1.0, 0.3, 0.3 end
|
||||
if kind == "warn" then return 0.65, 0.65, 0.65 end
|
||||
return 1.0, 0.82, 0.0
|
||||
end
|
||||
|
||||
@@ -81,15 +98,18 @@
|
||||
-- The summary line carries the one number a bug report needs and the window cannot
|
||||
-- otherwise show: how many were DROPPED. `total` counts distinct failures ever
|
||||
-- recorded; `shown` is what survived the cap.
|
||||
-- "problems", not "errors": the log carries three kinds now and two of them are not
|
||||
-- errors (2135). The window is still `/errors` and still opens on the same key —
|
||||
-- what changed is that a count of 7 stopped meaning "7 things raised".
|
||||
local summary
|
||||
if total == 0 then
|
||||
summary = "No errors this session."
|
||||
summary = "No problems this session."
|
||||
elseif total > shown then
|
||||
summary = format("%d errors (%d older not kept)", total, total - shown)
|
||||
summary = format("%d problems (%d older not kept)", total, total - shown)
|
||||
elseif total == 1 then
|
||||
summary = "1 error"
|
||||
summary = "1 problem"
|
||||
else
|
||||
summary = format("%d errors", total)
|
||||
summary = format("%d problems", total)
|
||||
end
|
||||
getglobal("BenillaScriptLogSummary"):SetText(summary)
|
||||
|
||||
@@ -152,7 +172,7 @@
|
||||
end
|
||||
end
|
||||
if text == "" then
|
||||
text = "Select an error above to read it in full."
|
||||
text = "Select a row above to read it in full."
|
||||
end
|
||||
getglobal("BenillaScriptLogDetailText"):SetText(text)
|
||||
end
|
||||
@@ -212,7 +232,7 @@
|
||||
if getglobal("BenillaScriptLogFrame"):IsVisible() then
|
||||
BenillaScriptLog_Update()
|
||||
end
|
||||
DEFAULT_CHAT_FRAME:AddMessage("Script error log cleared.", 1.0, 1.0, 0.0)
|
||||
DEFAULT_CHAT_FRAME:AddMessage("Script log cleared.", 1.0, 1.0, 0.0)
|
||||
end
|
||||
]]></Script>
|
||||
|
||||
|
||||
@@ -565,6 +565,36 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// **What was WARNED about, ranked** — the channel that reached nobody until 2135. It is
|
||||
// printed after the error rankings and before the distribution on purpose: these are not
|
||||
// blockers (nothing raised, the addon is running), so they must not outrank a row somebody is
|
||||
// stuck on — but they are the only column that can see an addon quietly getting the wrong
|
||||
// thing, which is the class `render` was added for and reaches later.
|
||||
//
|
||||
// Ranked by how many ADDONS hit each row, not by total occurrences: a warning one addon fires
|
||||
// in an OnUpdate would otherwise bury one that fifty addons hit once.
|
||||
let mut warned: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
for r in &reports {
|
||||
let mut seen: std::collections::BTreeSet<String> = Default::default();
|
||||
for w in &r.warnings {
|
||||
seen.insert(addon_harness::normalise(w));
|
||||
}
|
||||
for w in seen {
|
||||
*warned.entry(w).or_default() += 1;
|
||||
}
|
||||
}
|
||||
if !warned.is_empty() {
|
||||
let mut rows: Vec<(String, usize)> = warned.into_iter().collect();
|
||||
rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
let addons = reports.iter().filter(|r| !r.warnings.is_empty()).count();
|
||||
println!(
|
||||
"\n what was WARNED about ({addons} addons raised at least one, by addon count):"
|
||||
);
|
||||
for (w, count) in rows.into_iter().take(12) {
|
||||
println!(" {count:>4} {w}");
|
||||
}
|
||||
}
|
||||
|
||||
// The distribution, because a mean would hide the shape.
|
||||
let mut buckets = [0usize; 5];
|
||||
for r in &reports {
|
||||
|
||||
@@ -295,6 +295,28 @@ pub struct AddonReport {
|
||||
/// different question, and redefining an existing number would make every past run
|
||||
/// incomparable.
|
||||
pub probe_errors: Vec<String>,
|
||||
/// **Warnings raised while this addon loaded and ran** — the engine's own non-fatal channel
|
||||
/// (decision 2135), which nothing in this survey could see until it had one.
|
||||
///
|
||||
/// A warning is the class of failure that *does not announce itself*: an `inherits=` argument
|
||||
/// that was not a template name and got dropped, a `SetPoint` whose `relativeTo` did not
|
||||
/// resolve and silently re-anchored, a `SetCVar` on a name nothing registered, a saved
|
||||
/// variable that would not serialise, a loader note about a template of the wrong kind.
|
||||
/// Nothing raises, so `errors`, `session_errors` and `probe_errors` are all blind to it, and
|
||||
/// the addon scores a clean pass while doing the wrong thing. That is exactly the shape
|
||||
/// `render` was added for — and this reaches the cases `render` cannot, because a warning
|
||||
/// usually fires long before anything would have been drawn.
|
||||
///
|
||||
/// A NEW column, never folded into another (1213's rule, again).
|
||||
///
|
||||
/// **Read off the retained diagnostic log by `seq`, not off the per-frame drain**, so it
|
||||
/// carries the host's loader warnings (which are recorded with their `<file>` prefix and never
|
||||
/// touch the drain) as well as the engine's. One consequence of the log's dedupe is worth
|
||||
/// stating: a warning whose exact text the FrameXML underneath already produced collapses onto
|
||||
/// that older row and does not appear here. The corpus's warnings name the addon's own frames
|
||||
/// and files, so this is rare rather than theoretical — but it means the column, like every
|
||||
/// other one here, is a floor.
|
||||
pub warnings: Vec<String>,
|
||||
/// **What it actually PUT ON SCREEN** — see [`render`], whose header is the design.
|
||||
///
|
||||
/// The column every other one here was blind to. `loaded`, `session_errors` and `probe_errors`
|
||||
@@ -549,6 +571,11 @@ fn survey_one(
|
||||
// Taken here, after the dependency chain, so a library's frames are not charged to its
|
||||
// consumer — the same rule `load_dependencies` applies to errors.
|
||||
let baseline = RenderBaseline::of(&script);
|
||||
// THE WARNING MARK, taken at the same seam and for the same reason: everything the FrameXML
|
||||
// underneath and this addon's dependency chain warned about is already behind us, so what
|
||||
// follows is this addon's (decision 2135). `seq` is monotonic and never reused, which is what
|
||||
// makes a high-water mark a valid cut of a log that also evicts and dedupes.
|
||||
let warn_mark = script.diagnostics().last().map_or(0, |d| d.seq);
|
||||
|
||||
let (errors, absent) = load_addon_files(&script, root, name, &toc);
|
||||
// The registry has to agree with the VM about what has loaded: the live walk stamps each
|
||||
@@ -566,6 +593,22 @@ fn survey_one(
|
||||
let missing_inherits = missing_inherits(&script, root, name, &toc);
|
||||
let session_errors = drive_session_start(&mut script, name, &dep_order, installed);
|
||||
let probe_errors = drive_ui_probe(&mut script);
|
||||
// Everything filed since the mark, warnings only — the errors have their own columns and are
|
||||
// retained under their own kinds.
|
||||
let warnings: Vec<String> = script
|
||||
.diagnostics()
|
||||
.into_iter()
|
||||
.filter(|d| {
|
||||
d.seq > warn_mark && d.kind == benilla_ui::script::diagnostics::DiagnosticKind::Warning
|
||||
})
|
||||
.map(|d| {
|
||||
if d.count > 1 {
|
||||
format!("{} (x{})", d.message, d.count)
|
||||
} else {
|
||||
d.message
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// AFTER the UI probe and BEFORE the method oracle, and both halves of that are load-bearing.
|
||||
// After, because the probe leaves the addon fully driven — and this pass re-OPENS what the
|
||||
// probe's second toggle closed, which is why it is a separate probe rather than a read at the
|
||||
@@ -619,6 +662,7 @@ fn survey_one(
|
||||
ambiguous_methods,
|
||||
session_errors,
|
||||
probe_errors,
|
||||
warnings,
|
||||
render,
|
||||
used,
|
||||
}
|
||||
@@ -1868,6 +1912,12 @@ fn load_addon_files(
|
||||
match benilla_ui::framexml::parse(&benilla_ui::source::decode(&bytes)) {
|
||||
Ok(doc) => {
|
||||
let report = benilla_ui::loader::load_in(script, &doc, &path, &provider);
|
||||
// Retained with the file prefix so the survey's `warnings` column can see them
|
||||
// (2135); they were dropped with the report before that, which is why an
|
||||
// unresolved `inherits=` scored as a clean load.
|
||||
for w in report.warnings {
|
||||
script.report_warning(&format!("{file}: {w}"));
|
||||
}
|
||||
errors.extend(report.errors.into_iter().map(|e| format!("{file}: {e}")));
|
||||
}
|
||||
Err(e) => errors.push(format!("{file}: {e}")),
|
||||
|
||||
@@ -306,9 +306,23 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
(`0x48fce4`), and the OFF leg `0x492f70` computes clamp(768/height, 0.9, 1.0) instead — \
|
||||
0.9 at 854 px tall and up, which is every window we ship against. It is 1.0 at 768 and \
|
||||
below, where our flat 0.9 does diverge; `ui_script::DEFAULT_UI_SCALE` carries that. \
|
||||
`useUiScale` itself has no row here: nothing reads it, and registering it would only \
|
||||
offer a switch whose ON path we do not implement",
|
||||
See `useUiScale` below, whose row this one used to say did not exist",
|
||||
),
|
||||
// **`useUiScale` (`0x8430c0`, default `"0"`)** — the switch the row above gates on.
|
||||
//
|
||||
// Its absence used to be argued for here as *"nothing reads it, and registering it would only
|
||||
// offer a switch whose ON path we do not implement"*, and the first half of that has been
|
||||
// false since the interface went stock: `ContainerFrame.lua:483` and `UIDropDownMenu.lua:525`
|
||||
// both branch on `GetCVar("useUiScale") == "1"`, and `OptionsFrame.lua:13` gives it a
|
||||
// checkbox. Nobody noticed because the only thing that said so was a host warning with
|
||||
// nowhere to go (decision 2135, which is how this was found).
|
||||
//
|
||||
// Registering it changes no behaviour today — `nil ~= "1"` and `"0" ~= "1"` take the same
|
||||
// branch — and makes the read the reference's read rather than an accident. The ON path
|
||||
// lands where the reference's does, because our `uiScale` default *is* the reference's OFF-leg
|
||||
// result: at `useUiScale = 1` both clients scale the bag frames and the dropdown list by
|
||||
// `GetCVar("uiscale")`, and both read 0.9 there on every window we ship against.
|
||||
same("useUiScale", "0"),
|
||||
same("farclip", "350"),
|
||||
// The Controls-page trio (0961). `deselectOnClick`/`mouseInvertPitch` are 1.12's own
|
||||
// Interface Options CVars (UIOptionsFrame.lua indices 45/1); their defaults are the
|
||||
|
||||
@@ -279,6 +279,11 @@ impl Addon {
|
||||
let report = benilla_ui::loader::load_in(script, &doc, &path, &provider);
|
||||
for w in &report.warnings {
|
||||
warn!("ui_script({}/{file}): {w}", self.name);
|
||||
// …and retained where a player can read it (2135). The `warn!` above is the
|
||||
// terminal's copy and it is gone the moment the line scrolls; this is the one
|
||||
// that survives to `/errors`, and the prefix is why it is recorded here rather
|
||||
// than inside the loader — only this caller knows which file the warning is from.
|
||||
script.report_warning(&format!("{}/{file}: {w}", self.name));
|
||||
}
|
||||
for e in &report.errors {
|
||||
error!("ui_script({}/{file}): {e}", self.name);
|
||||
|
||||
@@ -242,8 +242,14 @@ fn the_background_row_opens_the_picker_and_its_opacity_slider_drives_the_window(
|
||||
let stored: f64 = s
|
||||
.eval("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
|
||||
.unwrap();
|
||||
// 204/255, and the last byte of that is 2133's doing. The slider carries
|
||||
// `valueStep="0.01"`, so `SetValue` snaps `1 - 0.8` onto the lattice and rebuilds it as
|
||||
// `n·step`; the reconstruction lands a hair BELOW the literal, `1 - value` a hair above 0.8,
|
||||
// and the alpha store's floor quantizer then keeps 204 where it used to shed one to 203.
|
||||
// The player asked for 0.8 and the store holds 0.8 — the client's own arithmetic, improved
|
||||
// by accident.
|
||||
assert!(
|
||||
(stored - 203.0 / 255.0).abs() < 1e-9,
|
||||
(stored - 204.0 / 255.0).abs() < 1e-9,
|
||||
"the drag reached the engine store, quantized to its byte: {stored}"
|
||||
);
|
||||
s.mouse_move(1500.0, 850.0);
|
||||
|
||||
@@ -302,7 +302,13 @@ fn cancel_restores_the_previous_colour_through_cancel_func() {
|
||||
.eval("local t = applied[table.getn(applied)] return t.r, t.a")
|
||||
.unwrap();
|
||||
assert_eq!(mid_r, 1.0, "the live preview really did go red");
|
||||
assert!((mid_a - 0.95).abs() < 1e-9);
|
||||
// 1e-6, like the accept test's alpha above and for one more reason on top of the f32 store:
|
||||
// `OpacitySliderFrame` carries `valueStep="0.01"`, so `SetValue` rebuilds the value as
|
||||
// `n·step + min` (2133) and the reconstruction costs a few more ulps than the literal.
|
||||
assert!(
|
||||
(mid_a - 0.95).abs() < 1e-6,
|
||||
"alpha is 1 - the slider's 0.05, got {mid_a}"
|
||||
);
|
||||
|
||||
// …then backs out.
|
||||
s.run("ColorPickerCancelButton:Click()").unwrap();
|
||||
|
||||
@@ -304,20 +304,38 @@ fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
|
||||
"the drag repainted the owner's list"
|
||||
);
|
||||
|
||||
// A sub-row nudge rounds to the NEAREST row (`floor(v/itemHeight + 0.5)`) and lands on the
|
||||
// same one, so the offset does not move.
|
||||
// A sub-row nudge on the BAR reaches nothing at all: `FauxScrollFrame_Update` set the bar's
|
||||
// step to one row, and `SetValue` quantises onto `min + n·step` before its change compare
|
||||
// (2133, `0x789930`), so 51px resolves to the 48 the bar already holds — no
|
||||
// `OnValueChanged`, no `SetVerticalScroll`, no repaint. This is the snap a 1.12 list
|
||||
// scrollbar has and ours did not: the bar cannot come to rest between two rows.
|
||||
let settled = s.eval::<i64>("return TestRepaints").unwrap();
|
||||
s.run("TestScrollScrollBar:SetValue(51)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return TestScrollScrollBar:GetValue()")
|
||||
.unwrap(),
|
||||
48.0,
|
||||
"51px is not a lattice point; the bar stays on row 3's"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return TestRepaints").unwrap(),
|
||||
settled,
|
||||
"and a value that did not move fires nothing"
|
||||
);
|
||||
|
||||
// The unconditional repaint is still there — it lives one level up, on the path a wheel or a
|
||||
// `SetVerticalScroll` takes. `FauxScrollFrame_OnVerticalScroll` ends in a bare
|
||||
// `updateFunction();` (UIPanelTemplates.lua:228-232) with no compare against the previous
|
||||
// offset, so a sub-row scroll through the FRAME repaints even though the bar does not move.
|
||||
// Our deleted kit repainted only when the row actually changed; the reference does not, and
|
||||
// that difference is the migration's, not a regression to chase (1860).
|
||||
s.run("TestScroll:SetVerticalScroll(51)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return FauxScrollFrame_GetOffset(TestScroll)")
|
||||
.unwrap(),
|
||||
3,
|
||||
"51px is still row 3 once rounded"
|
||||
"51px is still row 3 once rounded (`floor(v/itemHeight + 0.5)`)"
|
||||
);
|
||||
// …but it STILL repaints. `FauxScrollFrame_OnVerticalScroll` ends in a bare
|
||||
// `updateFunction();` (UIPanelTemplates.lua:228-233) — unconditional, with no compare against
|
||||
// the previous offset. Our deleted kit repainted only when the row actually changed; the
|
||||
// reference does not, and that difference is the migration's, not a regression to chase (1860).
|
||||
assert!(
|
||||
s.eval::<i64>("return TestRepaints").unwrap() > settled,
|
||||
"the reference repaints on every scroll, changed row or not"
|
||||
|
||||
@@ -1822,12 +1822,19 @@ fn defaults_resets_the_graphics_page_to_registered_defaults() {
|
||||
2,
|
||||
"only the default writes queue — never the dead pending: {changes:?}"
|
||||
);
|
||||
// **357, not 350, and that is the reference's ladder rather than a rounding slip.** The row
|
||||
// is the reference's own farclip slider — `minValue = OPTIONS_FARCLIP_MIN (177)`,
|
||||
// `maxValue = OPTIONS_FARCLIP_MAX (777)`, `valueStep = (max − min)/10` — so its ten stops are
|
||||
// 177, 237, 297, **357**, …, 777, and `SetValue` snaps onto them (2133). The registered
|
||||
// default is 350 (1804: the reference's own `CVar::Register` value), which is not a stop, so
|
||||
// seeding the slider from it lands on 357. 1.12 does exactly this and nobody can see it,
|
||||
// because its option sliders print no number; ours prints one, so the ladder shows.
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"return OptionsFrameContainerBodyGraphicsRowFarclipControlValue:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
"350"
|
||||
"357"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
|
||||
@@ -675,8 +675,21 @@ fn wheel_scroll_is_silent_but_the_arrows_click() {
|
||||
"the wheel scroll is silent"
|
||||
);
|
||||
|
||||
// The down arrow (enabled at the top), though — clicking it plays the ref's arrow click.
|
||||
s.run("ClassTrainerListScrollFrameScrollBarScrollDownButton:Click()")
|
||||
// That one notch reached the BOTTOM, and the arrow to click afterwards is therefore the UP
|
||||
// one. `ScrollFrameTemplate_OnMouseWheel` moves half the bar's height — 76px on this window's
|
||||
// 152-tall bar — and `SetValue` snaps that onto the row lattice (step 16), which rounds 76 up
|
||||
// to the range's own 80 (2133). `FauxScrollFrame_Update` then greys the DOWN arrow on its
|
||||
// `GetValue() - scrollFrameHeight == 0` test, so clicking it would be clicking a disabled
|
||||
// button. Pinned rather than worked around: this snap is the reference's.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return ClassTrainerListScrollFrameScrollBar:GetValue()")
|
||||
.unwrap(),
|
||||
80.0,
|
||||
"one wheel notch = 76px, snapped to the 5-row bottom of an 80px range"
|
||||
);
|
||||
|
||||
// The up arrow, now the enabled one — clicking it plays the ref's arrow click.
|
||||
s.run("ClassTrainerListScrollFrameScrollBarScrollUpButton:Click()")
|
||||
.unwrap();
|
||||
assert!(
|
||||
s.take_sounds().contains(&click),
|
||||
|
||||
@@ -880,20 +880,27 @@ fn an_addon_that_fails_to_load_without_raising_is_readable_in_the_error_log() {
|
||||
script
|
||||
.eval::<()>("BenillaScriptLog_Update() return nil")
|
||||
.expect("the window repaints over a real log without raising");
|
||||
let row_label: String = script
|
||||
.eval::<Option<String>>("return BenillaScriptLogRow1Label:GetText()")
|
||||
.expect("eval")
|
||||
.unwrap_or_default();
|
||||
// **A ROW shows it — not necessarily row 1.** The log is ordered by first occurrence and it
|
||||
// carries warnings now (2135), which a world entry produces before any addon is reached, so
|
||||
// asserting an index would be asserting how many warnings the stock UI happens to raise.
|
||||
// What this pins is the row TEXT, which is the window's own trim path over a real message.
|
||||
let row_labels: Vec<String> = (1..=13)
|
||||
.filter_map(|i| {
|
||||
script
|
||||
.eval::<Option<String>>(&format!("return BenillaScriptLogRow{i}Label:GetText()"))
|
||||
.expect("eval")
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
row_label.contains("AaMissing"),
|
||||
"row 1 shows the failure, trimmed to the row's width: {row_label:?}"
|
||||
row_labels.iter().any(|l| l.contains("AaMissing")),
|
||||
"a row shows the failure, trimmed to the row's width: {row_labels:?}"
|
||||
);
|
||||
let summary: String = script
|
||||
.eval::<Option<String>>("return BenillaScriptLogSummary:GetText()")
|
||||
.expect("eval")
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
summary.contains("error"),
|
||||
summary.contains("problem"),
|
||||
"the summary line counted them: {summary:?}"
|
||||
);
|
||||
|
||||
@@ -1159,3 +1166,48 @@ fn a_placed_window_comes_back_after_a_reload() {
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// **A clean world entry raises the KNOWN warnings and no others** — the tripwire that keeps
|
||||
/// decision 2135's channel worth having.
|
||||
///
|
||||
/// The stock FrameXML is loaded here with no addons at all, so every row is a gap of *ours*.
|
||||
/// Before 2135 these reached a terminal and nothing else, and four of them had been sitting in
|
||||
/// every session for as long as the interface has been stock: two `OnCursorChanged` refusals (the
|
||||
/// mail body and the GM ticket box, which therefore did not scroll as you typed — 2141), one
|
||||
/// `OnInputLanguageChanged`, and an unregistered `useUiScale` that `ContainerFrame.lua` and
|
||||
/// `UIDropDownMenu.lua` both read.
|
||||
///
|
||||
/// The allowlist is deliberately a **list of names, not a count**: a new silent gap in a stock
|
||||
/// file reddens this instead of scrolling past, and closing one means deleting a line here.
|
||||
#[test]
|
||||
fn a_clean_world_entry_raises_only_the_warnings_we_have_named() {
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (_tmp, _c, _h) = hermetic_probe("clean-entry-warnings");
|
||||
let mut world = booted_world();
|
||||
world.init_resource::<crate::ui_chat::ChatLog>();
|
||||
log_in_as(&mut world, "Onehunter", 1);
|
||||
let script = world
|
||||
.get_non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.expect("VM");
|
||||
|
||||
// **`OnInputLanguageChanged` stays out permanently**, and this is where that is said out loud.
|
||||
// It is a real 1.12 slot (`FloatingChatFrame.xml` wires it to the IME language indicator) with
|
||||
// zero corpus call sites, and benilla has no IME — so nothing here could ever fire it, and
|
||||
// `SCRIPT_KINDS`' rule is that a name we cannot fire stays out. The refusal is the honest
|
||||
// answer; the row is the price of saying it out loud.
|
||||
const KNOWN: [&str; 1] = ["OnInputLanguageChanged"];
|
||||
|
||||
let unexpected: Vec<String> = script
|
||||
.diagnostics()
|
||||
.into_iter()
|
||||
.filter(|d| d.kind == benilla_ui::script::diagnostics::DiagnosticKind::Warning)
|
||||
.map(|d| d.message)
|
||||
.filter(|m| !KNOWN.iter().any(|k| m.contains(k)))
|
||||
.collect();
|
||||
assert!(
|
||||
unexpected.is_empty(),
|
||||
"a stock world entry warned about something new — fix it or name it here: {unexpected:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -590,15 +590,18 @@ mod loader_tests {
|
||||
}
|
||||
|
||||
/// Unsupported handler names are warn-once gaps, not hard errors, and don't stop the frame
|
||||
/// from building. The example is `OnCursorChanged` — caret geometry is host-side here, so its
|
||||
/// four float args would all be zero, which is the silent-drop this warn exists to avoid.
|
||||
/// (It used to be `OnKeyDown`: that one is *fired* since decision 1319 and now belongs to
|
||||
/// `script::tests::keyboard`.)
|
||||
/// from building. The example is `OnAttributeChanged` — 2.0's secure-frame system, which no
|
||||
/// 1.12 resolver has a slot for, so it is the one name on that list that is out permanently.
|
||||
///
|
||||
/// It has been three names now, and each move is the rule working: `OnKeyDown` left when 1319
|
||||
/// built the delivery walk, `OnCursorChanged` when 2141 built the caret flush's fire. A name
|
||||
/// is accepted only once something fires it, so this test's subject is whatever is still
|
||||
/// waiting.
|
||||
#[test]
|
||||
fn unsupported_script_name_is_a_warning() {
|
||||
let s = UiScript::new().unwrap();
|
||||
let doc = parse(
|
||||
r#"<Ui><Frame name="Keyed"><Scripts><OnCursorChanged>x = 1</OnCursorChanged></Scripts></Frame></Ui>"#,
|
||||
r#"<Ui><Frame name="Keyed"><Scripts><OnAttributeChanged>x = 1</OnAttributeChanged></Scripts></Frame></Ui>"#,
|
||||
);
|
||||
let report = load(&s, &doc, &no_files);
|
||||
assert_eq!(report.frames, 1);
|
||||
@@ -606,7 +609,7 @@ mod loader_tests {
|
||||
assert!(report
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("OnCursorChanged")));
|
||||
.any(|w| w.contains("OnAttributeChanged")));
|
||||
}
|
||||
|
||||
/// An unknown frame type is an error that drops that subtree but not the rest of the load.
|
||||
|
||||
@@ -634,6 +634,13 @@ fn run_files(lua: &Lua, name: &str, read: &Reader, files: &[String]) {
|
||||
}
|
||||
};
|
||||
let report = crate::loader::load_into(lua, &doc, &path, &provider);
|
||||
// The warnings were dropped on the floor here until 2135 — an unresolved `inherits=`, a
|
||||
// template of the wrong kind, an attribute nobody reads: the addon loads "clean", paints
|
||||
// nothing, and the one thing that knew why was a `LoadReport` that went out of scope.
|
||||
// They carry the `<Addon>/<file>` prefix because a bare loader warning names no document.
|
||||
for w in report.warnings {
|
||||
crate::script::diagnostics::record_warning(lua, &format!("{name}/{file}: {w}"));
|
||||
}
|
||||
for e in report.errors {
|
||||
log_error(lua, &format!("{name}/{file}: {e}"));
|
||||
}
|
||||
@@ -659,10 +666,11 @@ fn load_miss(lua: &Lua, name: &str, file: &str) {
|
||||
fn load_failure(lua: &Lua, msg: &str) {
|
||||
let msg = format!("LoadAddOn: {msg}");
|
||||
crate::script::diagnostics::record_load_failure(lua, &msg);
|
||||
// `warn_host_only`, not `record_warning`: `record_load_failure` above has already retained
|
||||
// this exact sentence as a `Load` row, which is the truer kind (the addon is not running).
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model")
|
||||
.warnings
|
||||
.push(msg);
|
||||
.warn_host_only(msg);
|
||||
}
|
||||
|
||||
/// `root/rel`, refusing to escape `root` — the AddOns-root sandbox (1186), lexical and applied
|
||||
|
||||
@@ -90,7 +90,11 @@ pub(crate) fn coerced_number(lua: &Lua, v: Option<Value>) -> f64 {
|
||||
/// finds nothing, and answers `nil`. Everything else (nil, boolean, table, function) raises.
|
||||
pub(crate) fn string_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Result<String> {
|
||||
match lua.coerce_string(v)? {
|
||||
Some(s) => Ok(s.to_str()?.to_owned()),
|
||||
// Lossy, per decision 1193: a Lua 5.0 string is BYTES and the reference reads it as
|
||||
// bytes, so an argument that is not valid UTF-8 costs a glyph here — it does not cost
|
||||
// the call. `to_str()` used to raise, which turned one stray byte in one cp1252 addon's
|
||||
// literal into a dead handler (2138).
|
||||
Some(s) => Ok(s.to_string_lossy()),
|
||||
None => Err(mlua::Error::RuntimeError(usage.into())),
|
||||
}
|
||||
}
|
||||
@@ -118,15 +122,43 @@ pub(crate) fn string_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Resu
|
||||
/// argument, and only there) test the tag themselves before calling this.
|
||||
pub(crate) fn optional_string(lua: &Lua, v: &Value) -> Option<String> {
|
||||
match v {
|
||||
// Lossy, per decision 1193 and for the same reason as [`string_arg`] — with a sharper
|
||||
// symptom, because this one's failure was SILENT: `to_str().ok()` folded a string that
|
||||
// was not valid UTF-8 into `None`, which every caller here reads as *the argument was
|
||||
// not there*. `AddMessage` printed nothing, `CreateFrame` produced an unnamed frame, and
|
||||
// nothing anywhere said why (2138).
|
||||
Value::String(_) | Value::Number(_) | Value::Integer(_) => lua
|
||||
.coerce_string(v.clone())
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|s| s.to_str().ok().map(|t| t.to_owned())),
|
||||
.map(|s| s.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// **A free-text argument — the player-visible kind — as the reference takes it** (decision 2138).
|
||||
///
|
||||
/// The text sinks (`FontString`/`Button`/`EditBox`/`SimpleHTML` `SetText`, `SetFormattedText`,
|
||||
/// `EditBox:Insert`) took their argument as mlua's `Option<String>`, whose `FromLua` demands valid
|
||||
/// UTF-8 and **raises** otherwise. Decision 1193 settled that a UI source file is bytes and that
|
||||
/// "the right place to turn bytes into text is the boundary that actually requires text… A stray
|
||||
/// byte should cost one glyph, not the file" — and then these boundaries cost the whole call.
|
||||
/// `strsub` is byte-indexed (1193 keeps it that way deliberately), so *any* addon slicing
|
||||
/// non-ASCII text hands a sink a broken sequence, and a cp1252 literal is a broken sequence to
|
||||
/// begin with.
|
||||
///
|
||||
/// This is `Option<String>`'s conversion with exactly one arm changed: a **string** becomes its
|
||||
/// lossy text. Every other type behaves as before — absent and `nil` are `None`, a number
|
||||
/// coerces, a table or a function raises mlua's own conversion error — so the raise/absent split
|
||||
/// each binding already had (§ [`optional_string`]'s table) is untouched.
|
||||
pub(crate) fn text_arg(lua: &Lua, v: Option<Value>) -> mlua::Result<Option<String>> {
|
||||
match v {
|
||||
None | Some(Value::Nil) => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s.to_string_lossy())),
|
||||
Some(other) => mlua::FromLua::from_lua(other, lua).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GetBoolOrDefault` (`0x6f1c10`) — the reference's **boolean argument** coercion, which is *not*
|
||||
/// Lua truthiness and gets three arms backwards from the obvious reading. Byte-VERIFIED: wow-re
|
||||
/// `system/ui/scratch/action-bar-toggles.md` §2.1 re-derives the whole jump table at `0x6f1ce8`
|
||||
|
||||
@@ -231,11 +231,13 @@ pub(crate) fn set_label_font_justify_h_lua(
|
||||
}
|
||||
|
||||
/// Run `f` over a frame's Button state under one short write borrow.
|
||||
/// Run `f` over a frame's Button state under one short write borrow, then **settle the state
|
||||
/// machine** — `f` is every Lua write that can move an input (`Enable`/`Disable`,
|
||||
/// `SetButtonState`, `RegisterForClicks`), and the client's own writers call `SetState` on the
|
||||
/// spot rather than leaving a paint to notice. Settling a read is a no-op (the transition guard is
|
||||
/// `new == state`), so this stays on the one path instead of splitting into read/write halves.
|
||||
///
|
||||
/// **It no longer settles a state machine afterwards, because there is no longer one to settle**
|
||||
/// (decision 2134). The button's state is a latch the engine's own edges write; a Lua write moves
|
||||
/// it only when the write IS an edge (`Enable`/`Disable`, `SetButtonState`), and those call the
|
||||
/// edge themselves. `RegisterForClicks` and the texture setters no longer disturb the state at
|
||||
/// all — which is the reference's behaviour: re-registering a held button's clicks does not
|
||||
/// un-press it.
|
||||
fn with_button<T>(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
@@ -243,34 +245,25 @@ fn with_button<T>(
|
||||
) -> mlua::Result<T> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let (hovered, held) = press_inputs(&model, h);
|
||||
let frame = model
|
||||
.arena
|
||||
.frame_mut(h)
|
||||
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
|
||||
match &mut frame.kind_state {
|
||||
KindState::Button(bs) => {
|
||||
let out = f(bs);
|
||||
bs.settle(hovered, held);
|
||||
Ok(out)
|
||||
}
|
||||
KindState::Button(bs) => Ok(f(bs)),
|
||||
_ => Err(mlua::Error::runtime("not a Button")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two interaction inputs the model owns rather than the button: the cursor is over it, and a
|
||||
/// registered press is holding it.
|
||||
fn press_inputs(model: &Model, h: FrameHandle) -> (bool, bool) {
|
||||
(model.mouseover == Some(h), press_held(model, h))
|
||||
}
|
||||
|
||||
/// `Enable()` / `Disable()` — and the second thing they do, which is not the state texture.
|
||||
///
|
||||
/// `Disable 0x77ffd0` ends `0x78009a call [vtbl+0x90](0)`, reaching the shared helper
|
||||
/// `0x779160`; the constructor reaches the same helper with `1` (`0x778766`), which is what puts
|
||||
/// a fresh button in NORMAL. That helper does **two** things:
|
||||
///
|
||||
/// - `[vtbl+0x9c](state)` = `SetState 0x779790` — [`ButtonState::settle`]'s half; and
|
||||
/// - `[vtbl+0x9c](state, 0)` = `SetButtonState 0x779790` — [`ButtonState::set_enabled`]'s half,
|
||||
/// whose two early-outs (nothing happens if the button is already in the state being asked for)
|
||||
/// are the helper's own; and
|
||||
/// - `0x7791bb push 4; call 0x76a730` — the per-layer enable for layer **4 = HIGHLIGHT**, written
|
||||
/// into the very `[frame+0x198]` array that `Enable/DisableDrawLayer` writes and that
|
||||
/// `0x76b3a0` reads back at draw time.
|
||||
@@ -281,23 +274,49 @@ fn press_inputs(model: &Model, h: FrameHandle) -> (bool, bool) {
|
||||
/// the client keeps it, instead of a second rule that agreed with it on the common case and
|
||||
/// disagreed on `<Layer level="HIGHLIGHT">` art the button did not put there itself.
|
||||
///
|
||||
/// The reference has one array and one writer, so an addon's `DisableDrawLayer("HIGHLIGHT")` is
|
||||
/// undone by the next `Enable()`, exactly as here.
|
||||
/// **The restore is NOT symmetric, and this comment used to claim it was** — *"one array and one
|
||||
/// writer, so an addon's `DisableDrawLayer("HIGHLIGHT")` is undone by the next `Enable()`"*. Both
|
||||
/// halves are false (wow-re `scratch/button-disabled-state-texture-law.md` §7, sharpened
|
||||
/// 2026-09-09 off `0x779160` read contiguously for exactly this question). The indexed form
|
||||
/// `[reg + 4*reg + 0x198]` has **three** sites image-wide — `0x76a717` (=1), `0x76a737` (=0) and
|
||||
/// `DrawLayer 0x76b3a6`'s read — so there are **two** writers; and the enable arm calls
|
||||
/// *neither* helper. It reaches the ON one only one hop away and only when the button is the
|
||||
/// frame under the cursor:
|
||||
///
|
||||
/// ```text
|
||||
/// 779183 mov ecx,[esi+0xa0] ; the CSimpleTop singleton
|
||||
/// 779189 cmp esi,[ecx+0x7c] ; am I the hover target?
|
||||
/// 77918c jne 0x7791ce ; no -> return, layer untouched
|
||||
/// 779192 call [edx+0x4c] ; = 0x779490 -> 0x76b6a0 -> 0x76b6c9 push 4; call 0x76a710
|
||||
/// ```
|
||||
///
|
||||
/// — i.e. `Enable()` on a hovered button restores the layer **by re-running the frame's Lua
|
||||
/// `<OnEnter>`**, and on a non-hovered one restores nothing (the next hover-enter does it). Both
|
||||
/// restore paths are gated on the LockHighlight flag `[frame+0xf8]`, and the disable arm's
|
||||
/// `0x7791bb call 0x76a730(4)` is the one site in the image that is *not* — so
|
||||
/// `LockHighlight()` → `Disable()` → `Enable()` leaves the layer off in the reference, and only
|
||||
/// `UnlockHighlight()`+`LockHighlight()` or an explicit `EnableDrawLayer` brings it back.
|
||||
///
|
||||
/// **Ours is the symmetric bit-flip**, which draws the same in the ordinary case — a highlight is
|
||||
/// only visible while hovered, and every hover-enter sets it — and diverges in two script-visible
|
||||
/// ways: we do not fire the `<OnEnter>` the reference synthesises when you `Enable()` a hovered
|
||||
/// button, and we restore a locked-then-disabled highlight the reference leaves dark. Named here
|
||||
/// rather than fixed, because the faithful shape needs the layer restore moved onto the hover
|
||||
/// path where the reference keeps it.
|
||||
fn set_enabled(lua: &Lua, this: &Table, on: bool) -> mlua::Result<()> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let (hovered, held) = press_inputs(&model, h);
|
||||
let frame = model
|
||||
.arena
|
||||
.frame_mut(h)
|
||||
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
|
||||
// The state half is [`ButtonState::set_enabled`], whose two early-outs are the client's:
|
||||
// `Enable()` on a button that is not DISABLED does nothing at all, and neither does
|
||||
// `Disable()` on one that already is.
|
||||
match &mut frame.kind_state {
|
||||
KindState::Button(bs) => {
|
||||
bs.enabled = on;
|
||||
bs.settle(hovered, held);
|
||||
}
|
||||
KindState::Button(bs) => bs.set_enabled(on),
|
||||
_ => return Err(mlua::Error::runtime("not a Button")),
|
||||
}
|
||||
};
|
||||
let bit = 1u8 << DrawLayer::Highlight.index();
|
||||
if on {
|
||||
frame.disabled_layers &= !bit;
|
||||
@@ -307,18 +326,19 @@ fn set_enabled(lua: &Lua, this: &Table, on: bool) -> mlua::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-latch a button's state texture after something OUTSIDE the widget moved an input — the
|
||||
/// mouse crossing its boundary, a press landing or lifting, a capture dropped when the pointer
|
||||
/// left the window, or the hover cleared because the frame was hidden under the cursor.
|
||||
/// Run one of the engine's own state edges over frame `h`, if it is a live Button.
|
||||
///
|
||||
/// The reference's edge set is exactly seven `SetButtonState` call sites and **six of them are
|
||||
/// engine-side** (wow-re `scratch/button-state-edge-set.md`, VERIFIED): Enable, Disable, the hide
|
||||
/// notify, mouse-down, mouse-up and the drag-threshold crossing. There is **no enter or leave
|
||||
/// edge** — which is why the pointer path no longer calls anything here when the cursor crosses a
|
||||
/// button's boundary (decision 2134).
|
||||
///
|
||||
/// These are the client's own `SetState` call sites (`0x7791ed` enter, `0x7793f0` leave,
|
||||
/// `0x7792ad` down, `0x7793c2` up); ours reach the same transition through the derived inputs.
|
||||
/// Harmless on a non-Button handle, and on a stale one.
|
||||
pub(super) fn settle(model: &mut Model, h: FrameHandle) {
|
||||
let (hovered, held) = press_inputs(model, h);
|
||||
pub(super) fn edge(model: &mut Model, h: FrameHandle, f: impl FnOnce(&mut ButtonState)) {
|
||||
if let Some(frame) = model.arena.frame_mut(h) {
|
||||
if let KindState::Button(bs) = &mut frame.kind_state {
|
||||
bs.settle(hovered, held);
|
||||
f(bs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,7 +578,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// SetText/GetText target the ButtonText fontstring (RF-28: the `text` attr routes there).
|
||||
m.set(
|
||||
"SetText",
|
||||
lua.create_function(|lua, (this, text): (Table, Option<String>)| {
|
||||
lua.create_function(|lua, (this, text): (Table, Option<Value>)| {
|
||||
let text = super::binding_abi::text_arg(lua, text)?;
|
||||
// `CSimpleButton::SetText 0x778dc0` opens `if (!text) return;` (`0x778dcc`, wow-re
|
||||
// `button-label-build-and-anchor-order.md`) — a nil never reaches the label at all, so
|
||||
// it neither clears the text nor lazily creates the FontString. That guard is the
|
||||
@@ -991,7 +1012,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
m.set(
|
||||
"IsEnabled",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
with_button(lua, &this, |bs| i64::from(bs.enabled))
|
||||
with_button(lua, &this, |bs| i64::from(bs.enabled()))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -1012,49 +1033,53 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetButtonState("PUSHED"/"NORMAL") / GetButtonState — the scripted press state
|
||||
// (`0x780270`/`0x780180`; ref `ActionButtonDown/Up`, ActionButton.lua:15-28: DOWN pushes
|
||||
// only from NORMAL, UP fires only from PUSHED — the state doubles as the keybind debounce).
|
||||
// Case-insensitive like the API's other string enums; an unknown state is a runtime error.
|
||||
// DISABLED is Enable/Disable's to set, not this method's (the 1.12 FrameXML never passes it).
|
||||
// **`SetButtonState(state[, lock]) 0x780270` — TWO arguments**, and the second is not
|
||||
// cosmetic (wow-re `scratch/binding-shape-arity-law.md` §3, VERIFIED). It reads three stack
|
||||
// indices and returns none; the state maps case-insensitively over exactly
|
||||
// `{"DISABLED", "NORMAL", "PUSHED"}` (`0x780390`, `SStrCmpI`) and an unrecognised string
|
||||
// raises `Usage: %s:SetButtonState("state", lock)`; the flag is `GetBoolOrDefault` with
|
||||
// **default 0** (`0x78032c push 0`), whose number arm truncates through `_ftol`, so the
|
||||
// literal `1` that `MainMenuBarMicroButtons.lua` passes arms it.
|
||||
//
|
||||
// **The lock is the whole of decision 2134.** While it is set the button ignores the mouse
|
||||
// press/release transitions; while it is clear, the next release un-pushes a scripted push —
|
||||
// which is what Tablet-2.0's rows depend on, since the library pushes them and never calls
|
||||
// `SetButtonState("NORMAL")` anywhere.
|
||||
//
|
||||
// DISABLED really is accepted here — the mapper takes it — even though the 1.12 FrameXML
|
||||
// never passes it.
|
||||
m.set(
|
||||
"SetButtonState",
|
||||
lua.create_function(|lua, (this, state): (Table, String)| {
|
||||
let pushed = if state.eq_ignore_ascii_case("PUSHED") {
|
||||
true
|
||||
lua.create_function(|lua, (this, state, lock): (Table, String, MultiValue)| {
|
||||
let new = if state.eq_ignore_ascii_case("PUSHED") {
|
||||
ButtonVisualState::Pushed
|
||||
} else if state.eq_ignore_ascii_case("NORMAL") {
|
||||
false
|
||||
ButtonVisualState::Normal
|
||||
} else if state.eq_ignore_ascii_case("DISABLED") {
|
||||
ButtonVisualState::Disabled
|
||||
} else {
|
||||
return Err(mlua::Error::runtime(format!(
|
||||
"SetButtonState: unknown state '{state}'"
|
||||
"Usage: SetButtonState(\"state\", lock) — unknown state '{state}'"
|
||||
)));
|
||||
};
|
||||
with_button(lua, &this, |bs| bs.pushed_state = pushed)
|
||||
let first = lock.into_iter().next();
|
||||
let locked = super::binding_abi::bool_or_default(first.as_ref(), false);
|
||||
with_button(lua, &this, |bs| bs.set_button_state(new, locked))
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetButtonState",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
// The LIVE mouse-held press counts too (hovered + a registered button down on this
|
||||
// frame — the same predicate the extract pass renders the PushedTexture by), closing
|
||||
// the documented INTERIM where a mouse-held button answered "NORMAL": the chat scroll
|
||||
// buttons' hold-repeat (ref MessageFrameScrollButton_OnUpdate) polls exactly this
|
||||
// mid-press (decision 0288 P3). It reads the same in the reference for the same
|
||||
// reason — `0x7792ad` writes ONE state variable and `0x780180` reads it back, so a
|
||||
// right-held button answers "PUSHED" as surely as a left-held one.
|
||||
let held = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model.mouseover == Some(h) && press_held(&model, h)
|
||||
};
|
||||
with_button(lua, &this, move |bs| {
|
||||
if !bs.enabled {
|
||||
"DISABLED"
|
||||
} else if bs.pushed_state || held {
|
||||
"PUSHED"
|
||||
} else {
|
||||
"NORMAL"
|
||||
}
|
||||
// ONE variable, read back (`0x780180` reads `[obj+0x328]`). This used to OR in the
|
||||
// live mouse-held press, because the state was derived and the press lived outside
|
||||
// the widget; now the press *wrote* the state at its edge, so a mouse-held button
|
||||
// answers "PUSHED" for the same reason the reference does — and cannot disagree with
|
||||
// the art the way a re-derived answer could (decision 2134). The chat scroll buttons'
|
||||
// hold-repeat (ref `MessageFrameScrollButton_OnUpdate`) still reads what it needs.
|
||||
with_button(lua, &this, |bs| match bs.button_state() {
|
||||
ButtonVisualState::Disabled => "DISABLED",
|
||||
ButtonVisualState::Pushed => "PUSHED",
|
||||
ButtonVisualState::Normal => "NORMAL",
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
@@ -1176,18 +1201,6 @@ pub(super) fn wants_press_visual(model: &Model, h: FrameHandle, button: &str) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a press currently holding this button down — the `held` half of the PushedTexture rule.
|
||||
///
|
||||
/// Any captured mouse button counts, gated by [`wants_press_visual`]. Callers pair it with
|
||||
/// `hovered`, which is `0x779256`'s hit test at press time plus `0x7793f0`'s restore-to-NORMAL
|
||||
/// when the pointer leaves mid-press.
|
||||
pub(super) fn press_held(model: &Model, h: FrameHandle) -> bool {
|
||||
model
|
||||
.mouse_down_on
|
||||
.iter()
|
||||
.any(|(button, &pressed)| pressed == h && wants_press_visual(model, h, button))
|
||||
}
|
||||
|
||||
/// The click behavior shared by the input path and `Click()`: gated on `enabled`; a CheckButton
|
||||
/// **toggles before OnClick fires** (the documented widget contract — a handler reading
|
||||
/// `self:GetChecked()` sees the new state); then `OnClick(self, button, down)` — `down` is `true`
|
||||
@@ -1236,7 +1249,7 @@ pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripte
|
||||
match &mut frame.kind_state {
|
||||
// A Button/CheckButton click is gated on its enabled flag…
|
||||
KindState::Button(bs) => {
|
||||
if bs.enabled {
|
||||
if bs.enabled() {
|
||||
if is_check {
|
||||
bs.checked = !bs.checked;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,13 @@ pub(crate) fn maybe_start_drag(model: &mut Model, pos: (f32, f32)) -> Option<(u3
|
||||
(g.source, g.button.clone())
|
||||
};
|
||||
model.drag.as_mut().expect("checked Some above").started = true;
|
||||
// **The button's drag-start edge** — `CSimpleButton` overrides `+0x74` with `0x7793f0`, which
|
||||
// un-presses (`0x779410`, guarded on `locked == 0 && state != DISABLED`) and then forwards to
|
||||
// the base notify that fires `<OnDragStart>`. So a button you drag off releases its pushed art
|
||||
// at the THRESHOLD crossing, which is here — not when the cursor leaves its rect, and not for
|
||||
// a frame that never registered for drag (wow-re `scratch/button-state-edge-set.md`;
|
||||
// decision 2134).
|
||||
super::super::button::edge(model, source, crate::widget::ButtonState::on_drag_start);
|
||||
let id = model
|
||||
.arena
|
||||
.frame(source)
|
||||
|
||||
@@ -223,7 +223,7 @@ pub(super) fn write_cvar(
|
||||
fn warn_unknown(model: &mut Model, name: &str) {
|
||||
let key = name.to_ascii_lowercase();
|
||||
if model.cvars_warned.insert(key) {
|
||||
model.warnings.push(format!(
|
||||
model.record_warning(format!(
|
||||
"unknown CVar '{name}' (not host-registered) — ignored"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -88,6 +88,18 @@ pub enum DiagnosticKind {
|
||||
/// dependency that isn't there) land here — from the player's side they are one thing: *the
|
||||
/// addon isn't running*.
|
||||
Load,
|
||||
/// **The call was accepted and did not do what it said.** Nothing raised and nothing failed to
|
||||
/// load: an `inherits=` argument that was not a template name and got dropped, a `SetPoint`
|
||||
/// whose `relativeTo` did not resolve and re-anchored to the owner, a `SetCVar` on a name
|
||||
/// nothing registered, a saved variable that could not be serialised, a `CreateMacro` that
|
||||
/// was refused. The addon runs on, believing it got what it asked for.
|
||||
///
|
||||
/// **This kind exists because the channel was a dead end** (decision 2135). These messages had
|
||||
/// one consumer — a `warn!` line in the host's terminal, drained and discarded every frame —
|
||||
/// so they reached neither the player (who cannot read a terminal, which is the whole
|
||||
/// argument of 1495 above) nor the addon survey (whose columns read `errors`). A warning is
|
||||
/// exactly the class an instrument is *for*: it is the failure that does not announce itself.
|
||||
Warning,
|
||||
}
|
||||
|
||||
impl DiagnosticKind {
|
||||
@@ -96,6 +108,7 @@ impl DiagnosticKind {
|
||||
match self {
|
||||
Self::Error => "error",
|
||||
Self::Load => "load",
|
||||
Self::Warning => "warn",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,6 +225,17 @@ impl super::UiScript {
|
||||
pub fn report_load_failure(&self, msg: &str) {
|
||||
record_load_failure(&self.lua, msg);
|
||||
}
|
||||
|
||||
/// Retain one non-fatal warning the HOST caught and has already logged — a loader warning off
|
||||
/// an addon's XML, carrying the `<Addon>/<file>` prefix only the caller knows (decision 2135).
|
||||
///
|
||||
/// The retention half alone, exactly like [`Self::report_script_error`]: the caller writes its
|
||||
/// own `warn!` line, and putting the message on [`Model::warnings`] as well would double it at
|
||||
/// the app's next per-frame drain. For a warning the ENGINE raises, the door is
|
||||
/// [`Model::record_warning`], which owns both halves.
|
||||
pub fn report_warning(&self, msg: &str) {
|
||||
record_warning(&self.lua, msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// **THE rule for "an addon file did not load"** — one implementation, both loaders (decision
|
||||
@@ -237,6 +261,20 @@ pub(crate) fn record_load_failure(lua: &Lua, msg: &str) {
|
||||
.record(DiagnosticKind::Load, msg);
|
||||
}
|
||||
|
||||
/// **THE rule for "something was accepted and did not do what it said"** — the retention half of
|
||||
/// the warning channel, for a caller that holds `&Lua` (decision 2135).
|
||||
///
|
||||
/// Its `&UiScript` face is [`super::UiScript::report_warning`] and its engine-internal sibling is
|
||||
/// [`Model::record_warning`]; the difference is only which of the two channels the caller still
|
||||
/// owes. A host caller has already written its own log line and wants retention only; an engine
|
||||
/// caller wants both.
|
||||
pub(crate) fn record_warning(lua: &Lua, msg: &str) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data set")
|
||||
.diagnostics
|
||||
.record(DiagnosticKind::Warning, msg);
|
||||
}
|
||||
|
||||
/// Register the error-log reads the `BenillaScriptLogFrame` polls.
|
||||
///
|
||||
/// **`Benilla`-prefixed because they are ours**, not 1.12's — the prefix rule as
|
||||
@@ -304,6 +342,62 @@ mod tests {
|
||||
assert_eq!(log.total(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warning_reaches_the_retained_log_and_the_host_drain() {
|
||||
// 2135's whole subject. Before it, a warning existed for exactly as long as one terminal
|
||||
// line: `take_warnings` drained it and nothing kept a copy, so neither the player's
|
||||
// `/errors` window nor the addon survey could see one.
|
||||
let mut s = crate::script::UiScript::new().expect("vm");
|
||||
s.run(r#"SetCVar("thisCVarDoesNotExist", "1")"#).unwrap();
|
||||
|
||||
let host = s.take_warnings();
|
||||
assert!(
|
||||
host.iter().any(|w| w.contains("thisCVarDoesNotExist")),
|
||||
"the host's per-frame drain still gets it: {host:?}"
|
||||
);
|
||||
let kept = s.diagnostics();
|
||||
let row = kept
|
||||
.iter()
|
||||
.find(|d| d.message.contains("thisCVarDoesNotExist"))
|
||||
.unwrap_or_else(|| panic!("…and it is RETAINED: {kept:#?}"));
|
||||
assert_eq!(row.kind, DiagnosticKind::Warning, "under its own kind");
|
||||
|
||||
// The drain is a drain; the log is a memory. A second frame does not lose the row.
|
||||
assert!(s.take_warnings().is_empty());
|
||||
assert!(s
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|d| d.message.contains("thisCVarDoesNotExist")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_sink_takes_bytes_rather_than_raising_on_them() {
|
||||
// Decision 2138, and 1193's rule finally applied at the boundary 1193 named. `strsub` is
|
||||
// byte-indexed by design, so a slice through a multi-byte character is a string an addon
|
||||
// really does hand to `SetText` — and mlua's `String` conversion raised on it, killing the
|
||||
// handler. A stray byte costs a glyph.
|
||||
let s = crate::script::UiScript::new().expect("vm");
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "BytesFrame")
|
||||
t = f:CreateFontString("BytesText")
|
||||
-- "a—b" is 5 bytes; cutting at 2 leaves the em dash's lead byte alone.
|
||||
local sliced = strsub("a\226\128\148b", 1, 2)
|
||||
t:SetText(sliced)
|
||||
e = CreateFrame("EditBox", "BytesBox")
|
||||
e:SetText(sliced)
|
||||
"#,
|
||||
)
|
||||
.expect("a sliced multi-byte string must not raise");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
let got: Option<String> = s.eval("return BytesText:GetText()").unwrap();
|
||||
assert_eq!(
|
||||
got.as_deref(),
|
||||
Some("a\u{fffd}"),
|
||||
"the broken byte became one replacement glyph, and the call completed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_is_part_of_identity() {
|
||||
let mut log = DiagnosticLog::default();
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
//! Windows/Linux; decision 0301) and reuse the byte-verified word classes + selection-first
|
||||
//! delete law.
|
||||
|
||||
use mlua::{Lua, Table};
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use super::object::frame_handle_of;
|
||||
use super::types::{EditAction, EditUnit};
|
||||
@@ -611,6 +611,91 @@ fn fire_script(lua: &Lua, id: u32, name: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The caret flush's fire — `OnCursorChanged(x, y, w, h)`** (`0x77da80`).
|
||||
///
|
||||
/// The reference recomputes the caret's anchor inside `0x77da80` and, having done so, fires this
|
||||
/// with four floats *"each value scaled by `f`, so all four are in FrameXML UI units"* (wow-re
|
||||
/// `system/ui/ui.md`, the RF-0085 caret law, VERIFIED):
|
||||
///
|
||||
/// * `x` — the caret's advance from its line's start, the same number the paint anchors by
|
||||
/// ([`EditBoxState::caret_row_x`]).
|
||||
/// * `y` — `−lineIndex · (snap(H·scale) + snap(spacing·scale))/scale`, i.e. minus the row index
|
||||
/// times the row pitch. **Negative-downward**, which is what `ScrollingEdit_OnCursorChanged`'s
|
||||
/// `cursorOffset = y` then `-this.cursorOffset` reads as a positive distance down the page.
|
||||
/// * `w` — **`≡ 4.0`**, a constant in the reference (the caret texture's own width, read through
|
||||
/// its layout vtable `+0x1c`).
|
||||
/// * `h` — the font line height, read through `+0x20`.
|
||||
///
|
||||
/// Ours takes the row pitch [`EditBoxState::cell_h`] for both the `y` factor and `h`. The two are
|
||||
/// the same number whenever the font declares no extra `spacing`, which is every box in the
|
||||
/// shipped 1.12 UI; a box that *does* set spacing would report an `h` a little tall. Named rather
|
||||
/// than guessed at — the alternative is carrying a second measured number through the advance
|
||||
/// answer for a case nothing in the corpus produces.
|
||||
///
|
||||
/// **The edge is a CHANGE, not a frame.** `0x77da80` has exactly one caller, `0x77d475`, gated on
|
||||
/// dirty bit 2, so the handler runs when the caret has actually moved — which is what makes
|
||||
/// `ScrollingEdit_OnUpdate`'s `if (this.cursorOffset)` idiom terminate. [`EditBoxState::cursor_fired`]
|
||||
/// is that bit's memory here.
|
||||
///
|
||||
/// **Scoped to the focused box**, because the caret geometry rides the advance table and only the
|
||||
/// focused box asks the host for one ([`super::editbox::seam`]). The reference flushes any dirty
|
||||
/// box; the difference is invisible for the customer this exists for — you are typing in the box
|
||||
/// you are looking at — and an unfocused box's caret is never drawn either way.
|
||||
pub(in crate::script) fn drain_cursor_changed(lua: &Lua) {
|
||||
let fire = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let Some(h) = model.focused_editbox else {
|
||||
return;
|
||||
};
|
||||
if !model.arena.frame(h).is_some_and(|f| f.effective_visible) {
|
||||
return;
|
||||
}
|
||||
let id = model.frame_id(h);
|
||||
let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// No answered advance table yet ⇒ no caret geometry to report. The next tick after the
|
||||
// host answers carries it, exactly as the reference's next flush would.
|
||||
let display = eb.display();
|
||||
if eb.advances.len() != display.len() + 1 {
|
||||
return;
|
||||
}
|
||||
let cursor_d = eb.text_to_display(eb.cursor).min(display.len());
|
||||
let (row, x) = if eb.multi_line {
|
||||
eb.caret_row_x(cursor_d)
|
||||
} else {
|
||||
(0, eb.advances[cursor_d])
|
||||
};
|
||||
if eb.cursor_fired == Some((row, x)) {
|
||||
return;
|
||||
}
|
||||
eb.cursor_fired = Some((row, x));
|
||||
let pitch = eb.cell_h;
|
||||
(id, x, -(row as f32) * pitch, pitch)
|
||||
};
|
||||
let (id, x, y, h) = fire;
|
||||
if let Err(e) = event::fire_widget_handler(
|
||||
lua,
|
||||
id,
|
||||
"OnCursorChanged",
|
||||
vec![
|
||||
Value::Number(f64::from(x)),
|
||||
Value::Number(f64::from(y)),
|
||||
Value::Number(f64::from(CARET_WIDTH)),
|
||||
Value::Number(f64::from(h)),
|
||||
],
|
||||
) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// The caret's width as `OnCursorChanged` reports it — **a constant in the reference**, not a
|
||||
/// measurement (wow-re: `w ≡ 4.0`).
|
||||
const CARET_WIDTH: f32 = 4.0;
|
||||
|
||||
// The Lua method surface (SetText/GetText/HighlightText/SetFocus/…, consulted before the shared
|
||||
// frame table only for EditBox frames) — a child module over this file's focus/editing
|
||||
// primitives, like `interact`/`seam`.
|
||||
|
||||
@@ -30,7 +30,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
m.set(
|
||||
"SetText",
|
||||
lua.create_function(|lua, (this, s): (Table, Option<String>)| {
|
||||
lua.create_function(|lua, (this, s): (Table, Option<mlua::Value>)| {
|
||||
let s = crate::script::binding_abi::text_arg(lua, s)?;
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
// Programmatic SetText KEEPS a history browse in progress: the chat live parse
|
||||
// rewrites the box on every recalled slash line ("/s hi" → Say + "hi"), and ending
|
||||
@@ -100,7 +101,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// `Option<String>` coercion follows it.
|
||||
m.set(
|
||||
"Insert",
|
||||
lua.create_function(|lua, (this, s): (Table, Option<String>)| {
|
||||
lua.create_function(|lua, (this, s): (Table, Option<mlua::Value>)| {
|
||||
let s = crate::script::binding_abi::text_arg(lua, s)?;
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
if let Some(s) = s {
|
||||
insert(lua, h, &s, true);
|
||||
|
||||
@@ -1585,3 +1585,87 @@ fn set_max_bytes_caps_the_buffer_in_bytes_with_minus_one_unlimited() {
|
||||
assert!(s.run("E:SetMaxBytes()").is_err(), "too few raises");
|
||||
assert!(s.run("E:SetMaxBytes(1, 2)").is_err(), "too many raises");
|
||||
}
|
||||
|
||||
/// **`OnCursorChanged(x, y, w, h)` fires when the caret moves** — the edge the shipped
|
||||
/// `MailFrame.xml` and `HelpFrame.xml` wire `ScrollingEdit_OnCursorChanged` to, and the reason a
|
||||
/// multiline box follows its caret as you type past the bottom (decision 2141).
|
||||
///
|
||||
/// The four args are the reference's (wow-re's RF-0085 caret law, VERIFIED): `x` the caret's
|
||||
/// advance along its line, `y` **negative-downward** by row (which is what
|
||||
/// `ScrollingEdit_OnCursorChanged`'s `cursorOffset = y` then `-this.cursorOffset` reads back as a
|
||||
/// positive distance), `w` the constant `4.0`, `h` the line height.
|
||||
#[test]
|
||||
fn the_caret_flush_fires_on_cursor_changed_with_the_references_four_args() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
fires = {}
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetWidth(200); E:SetHeight(64)
|
||||
E:SetPoint("BOTTOMLEFT", 100, 50)
|
||||
E:SetMultiLine(true)
|
||||
E:SetScript("OnCursorChanged", function()
|
||||
table.insert(fires, { x = arg1, y = arg2, w = arg3, h = arg4 })
|
||||
end)
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
// Three bytes on row 0, then a wrap onto row 1 — the host answers the rows and the pitch.
|
||||
for ch in "abcdef".chars() {
|
||||
s.char_input(&ch.to_string());
|
||||
}
|
||||
s.resolve();
|
||||
if let Some(req) = s.editbox_advances_request() {
|
||||
// A plain monotonic 7 px/byte table (the rig's own), wrapped into two rows at byte 3 —
|
||||
// `caret_row_x` subtracts the row start's cumulative advance, which is the whole point.
|
||||
let cum: Vec<f32> = (0..=req.text.len()).map(|i| i as f32 * 7.0).collect();
|
||||
s.set_editbox_advances(req.id, req.key, cum, vec![0, 3], 12.0);
|
||||
}
|
||||
s.tick(0.016);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
|
||||
let n: i64 = s.eval("return table.getn(fires)").unwrap();
|
||||
assert!(n >= 1, "the flush fired at least once");
|
||||
let (x, y, w, h): (f64, f64, f64, f64) = s
|
||||
.eval("local f = fires[table.getn(fires)] return f.x, f.y, f.w, f.h")
|
||||
.unwrap();
|
||||
// The caret sits after 6 bytes: row 1 (rows start at 0 and 3), 3 bytes along it.
|
||||
assert_eq!(
|
||||
x, 21.0,
|
||||
"x is the advance from the ROW's start, not the text's"
|
||||
);
|
||||
assert_eq!(
|
||||
y, -12.0,
|
||||
"y is minus the row index times the pitch — downward is negative"
|
||||
);
|
||||
assert_eq!(w, 4.0, "w is the reference's constant, not a measurement");
|
||||
assert_eq!(h, 12.0, "h is the line height");
|
||||
|
||||
// **The edge is a CHANGE.** A tick that moves nothing fires nothing — which is what makes
|
||||
// `ScrollingEdit_OnUpdate`'s `if (this.cursorOffset)` loop terminate instead of re-scrolling
|
||||
// every frame.
|
||||
let before: i64 = s.eval("return table.getn(fires)").unwrap();
|
||||
s.tick(0.016);
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return table.getn(fires)").unwrap(),
|
||||
before,
|
||||
"two quiet ticks fire nothing"
|
||||
);
|
||||
|
||||
// …and a caret move fires again.
|
||||
s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Char,
|
||||
back: true,
|
||||
extend: false,
|
||||
});
|
||||
s.tick(0.016);
|
||||
assert!(
|
||||
s.eval::<i64>("return table.getn(fires)").unwrap() > before,
|
||||
"moving the caret one char fires the flush"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ use mlua::{Function, Lua, MultiValue, Table, Value};
|
||||
|
||||
use super::{Model, ScriptValue, REG_SCRIPTS};
|
||||
use crate::script::object::frame_wrapper;
|
||||
use crate::widget::FrameHandle;
|
||||
use crate::widget::{ButtonState, FrameHandle};
|
||||
|
||||
/// Fire `event` at every frame registered for it (the engine-internal twin of
|
||||
/// `UiScript::fire_event`, for engine code holding only the Lua context — the compare drive's
|
||||
@@ -169,10 +169,6 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec<FrameHandle>) {
|
||||
.filter(|&m| items.iter().any(|&(h, _, vis)| h == m && !vis));
|
||||
if let Some(m) = hidden_hover {
|
||||
model.mouseover = None;
|
||||
// The hover the hide just dropped is an input to the button state machine, so the
|
||||
// frame under it re-latches here as it would on a mouse-out (`0x7793f0`) — a button
|
||||
// hidden mid-press must not come back up still wearing its pushed art.
|
||||
super::button::settle(&mut model, m);
|
||||
if model.drag.as_ref().is_some_and(|d| d.source == m) {
|
||||
model.drag = None;
|
||||
}
|
||||
@@ -191,6 +187,18 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec<FrameHandle>) {
|
||||
if visible {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
super::object::toplevel::raise_on_show(&mut model, h);
|
||||
} else {
|
||||
// **The button's HIDE edge** — `CSimpleButton` overrides the hide notify (`+0x34`,
|
||||
// `0x7791e0`) to un-press itself before tail-jumping the base, so a button hidden
|
||||
// while held does not come back up wearing its pushed art (wow-re
|
||||
// `scratch/button-state-edge-set.md`; the guard is `state != DISABLED && locked == 0`).
|
||||
//
|
||||
// It hangs off the VISIBILITY transition, not off the hover, which is what it is in
|
||||
// the reference — a button hidden nowhere near the cursor un-presses too, and one
|
||||
// hidden under the cursor no longer needs the hover-drop above to notice
|
||||
// (decision 2134).
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
super::button::edge(&mut model, h, ButtonState::on_hide);
|
||||
}
|
||||
let name = if visible { "OnShow" } else { "OnHide" };
|
||||
if let Err(e) = fire(lua, id, name, None, Vec::new()) {
|
||||
|
||||
@@ -245,7 +245,7 @@ impl UiScript {
|
||||
let hovered = owner.is_some() && model.mouseover == owner;
|
||||
// The PRESS is not read here. Which state texture shows is latched on the
|
||||
// transition (`ButtonState::set_state`), so the press reaches the paint
|
||||
// through `button::settle` at the moment the mouse moves it — not by
|
||||
// through the press EDGE at the moment the button goes down — not by
|
||||
// being re-derived every frame. `hovered` survives because the Highlight
|
||||
// is not a state texture and carries no latch.
|
||||
if !bs.region_visible(rh, hovered) {
|
||||
@@ -286,26 +286,27 @@ impl UiScript {
|
||||
// shows its label. Every state-colour caller in our own UI ships the
|
||||
// matching font object, so the two readings agree on all of them.
|
||||
let highlighted = hovered || bs.locked_highlight;
|
||||
let (name, color, justify) =
|
||||
if !bs.enabled && bs.disabled_font.is_some() {
|
||||
(
|
||||
bs.disabled_font.as_ref(),
|
||||
bs.disabled_color,
|
||||
bs.disabled_justify_h,
|
||||
)
|
||||
} else if bs.enabled && highlighted && bs.highlight_font.is_some() {
|
||||
(
|
||||
bs.highlight_font.as_ref(),
|
||||
bs.highlight_color,
|
||||
bs.highlight_justify_h,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
bs.normal_font.as_ref(),
|
||||
bs.normal_color,
|
||||
bs.normal_justify_h,
|
||||
)
|
||||
};
|
||||
let (name, color, justify) = if !bs.enabled()
|
||||
&& bs.disabled_font.is_some()
|
||||
{
|
||||
(
|
||||
bs.disabled_font.as_ref(),
|
||||
bs.disabled_color,
|
||||
bs.disabled_justify_h,
|
||||
)
|
||||
} else if bs.enabled() && highlighted && bs.highlight_font.is_some() {
|
||||
(
|
||||
bs.highlight_font.as_ref(),
|
||||
bs.highlight_color,
|
||||
bs.highlight_justify_h,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
bs.normal_font.as_ref(),
|
||||
bs.normal_color,
|
||||
bs.normal_justify_h,
|
||||
)
|
||||
};
|
||||
state_font = name.and_then(|n| model.font_object(n));
|
||||
button_font = bs.font.as_ref();
|
||||
state_color = color;
|
||||
|
||||
@@ -827,6 +827,7 @@ impl UiScript {
|
||||
frame_to_id,
|
||||
screen,
|
||||
warnings,
|
||||
diagnostics,
|
||||
solver,
|
||||
layout_scope: scope,
|
||||
layout_fingerprint,
|
||||
@@ -1355,10 +1356,17 @@ impl UiScript {
|
||||
return;
|
||||
}
|
||||
if round + 1 == round_cap {
|
||||
warnings.push(format!(
|
||||
// `Model::record_warning`'s two halves, spelled out because this pass holds the
|
||||
// model destructured and cannot call it: the host drain, and the retained log
|
||||
// (2135). `WOW_LAYOUT_VERIFY`'s falsifier re-run truncates `warnings` and does
|
||||
// NOT roll the log back — the log dedupes, so a verify build reads `×2` on a row
|
||||
// that happened once, and that build is not a production path.
|
||||
let msg = format!(
|
||||
"layout: anchor graph did not converge in {round_cap} rounds — \
|
||||
an anchor cycle? (rects left at their last pass)"
|
||||
));
|
||||
);
|
||||
diagnostics.record(super::diagnostics::DiagnosticKind::Warning, &msg);
|
||||
warnings.push(msg);
|
||||
}
|
||||
}
|
||||
// The cycle bail (the loop ran out of rounds and warned above): the rects it leaves are
|
||||
|
||||
@@ -217,14 +217,12 @@ fn create_macro(
|
||||
) -> Option<usize> {
|
||||
let name = clamp_chars(name.trim(), MAX_MACRO_NAME);
|
||||
if name.is_empty() {
|
||||
model
|
||||
.warnings
|
||||
.push("CreateMacro() failed, no name specified".into());
|
||||
model.record_warning("CreateMacro() failed, no name specified");
|
||||
return None;
|
||||
}
|
||||
let list = model.macros.list_mut(per_character);
|
||||
if list.len() >= MAX_MACROS {
|
||||
model.warnings.push(format!(
|
||||
model.record_warning(format!(
|
||||
"CreateMacro() failed, already have {MAX_MACROS} macros"
|
||||
));
|
||||
return None;
|
||||
|
||||
@@ -451,7 +451,7 @@ pub const SCREEN: crate::layout::Handle = 0;
|
||||
/// corpus call sites across 91 addons); it removes working behaviour from the 23 sites that remain,
|
||||
/// so it is still a change to make deliberately rather than as a side effect of widening this list
|
||||
/// — but the FrameXML half of "with FrameXML fixed first" is most of the way there now.
|
||||
const SCRIPT_KINDS: [&str; 37] = [
|
||||
const SCRIPT_KINDS: [&str; 38] = [
|
||||
"OnLoad",
|
||||
"OnEvent",
|
||||
"OnUpdate",
|
||||
@@ -475,6 +475,11 @@ const SCRIPT_KINDS: [&str; 37] = [
|
||||
"OnTabPressed",
|
||||
"OnTextChanged",
|
||||
"OnTextSet",
|
||||
// The caret flush's own (`0x77da80`), fired by the tick's `drain_cursor_changed` when the
|
||||
// caret has moved — the edge `ScrollingEdit_OnCursorChanged` + `ScrollingEdit_OnUpdate` scroll
|
||||
// a multiline box by. It earns its row here the way this list's rule requires: together with
|
||||
// the code that fires it (decisions 2135/2141).
|
||||
"OnCursorChanged",
|
||||
"OnEditFocusGained",
|
||||
"OnEditFocusLost",
|
||||
"OnVerticalScroll",
|
||||
@@ -1255,11 +1260,11 @@ impl UiScript {
|
||||
let mut model = self.model_mut();
|
||||
let held: Vec<crate::widget::FrameHandle> = model.mouse_down_on.values().copied().collect();
|
||||
model.mouse_down_on.clear();
|
||||
// Every button that capture was holding down goes back to NORMAL — the release the OS
|
||||
// never fed us (`0x7793c2`'s transition), without which a button walked off the window
|
||||
// edge mid-press keeps its pushed art for the rest of the session.
|
||||
// Every button that capture was holding down goes back to NORMAL — the release edge the
|
||||
// OS never fed us (`0x7793de`), without which a button walked off the window edge
|
||||
// mid-press keeps its pushed art for the rest of the session.
|
||||
for h in held {
|
||||
button::settle(&mut model, h);
|
||||
button::edge(&mut model, h, crate::widget::ButtonState::on_mouse_up);
|
||||
}
|
||||
// …and its one-slot twin `root+0x80`, which the mouse-down raise reads: a capture left
|
||||
// behind would aim the next press's raise at whatever the pointer was last holding.
|
||||
|
||||
@@ -1738,6 +1738,35 @@ impl Model {
|
||||
self.errors.push(msg);
|
||||
}
|
||||
|
||||
/// **Record one non-fatal warning — the channel's one door** (decision 2135). Both halves:
|
||||
/// the host's per-frame `warnings` drain (a `warn!` line in the terminal, for whoever is
|
||||
/// running the client), and the retained diagnostic log, which is the only copy a player or an
|
||||
/// instrument can read after the frame that produced it.
|
||||
///
|
||||
/// Before this there was only the first half, and it was a dead end: `take_warnings` empties
|
||||
/// every frame, so a warning existed for the length of one terminal line and then nowhere at
|
||||
/// all. The messages it carries are the failures that never announce themselves — a dropped
|
||||
/// `inherits=`, an unresolved anchor, an unregistered CVar — which is precisely the class the
|
||||
/// diagnostic log was built for.
|
||||
///
|
||||
/// Its sibling is [`Self::warn_host_only`], for the one message already retained under a
|
||||
/// truer kind.
|
||||
pub(crate) fn record_warning(&mut self, msg: impl Into<String>) {
|
||||
let msg = msg.into();
|
||||
self.diagnostics
|
||||
.record(super::diagnostics::DiagnosticKind::Warning, &msg);
|
||||
self.warnings.push(msg);
|
||||
}
|
||||
|
||||
/// The host channel **alone** — for a message that is already retained under a kind that says
|
||||
/// more than `Warning` does. Exactly one caller: `LoadAddOn`'s failure line, which
|
||||
/// `record_load_failure` has already filed as a [`Load`](super::diagnostics::DiagnosticKind)
|
||||
/// row ("the addon isn't running", which is the fact worth keeping) and which still wants its
|
||||
/// terminal line. Retaining it twice would put the same sentence in the log under two kinds.
|
||||
pub(crate) fn warn_host_only(&mut self, msg: String) {
|
||||
self.warnings.push(msg);
|
||||
}
|
||||
|
||||
pub(crate) fn unit(&self, token: &str) -> Option<&UnitState> {
|
||||
if token.bytes().any(|b| b.is_ascii_uppercase()) {
|
||||
self.units_by_lower.get(&token.to_ascii_lowercase())
|
||||
|
||||
@@ -652,7 +652,7 @@ pub(super) fn create_frame(
|
||||
if template.is_none() {
|
||||
if let Some(v) = inherits.as_ref().filter(|v| !v.is_nil()) {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.warnings.push(format!(
|
||||
model.record_warning(format!(
|
||||
"CreateFrame: the 4th argument (inherits) must be a template-name string, got {}; \
|
||||
ignored for '{}'",
|
||||
v.type_name(),
|
||||
@@ -727,7 +727,9 @@ pub(super) fn create_frame(
|
||||
let messages = crate::loader::apply_template(lua, &wrapper, &kind, &template);
|
||||
if !messages.is_empty() {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.warnings.extend(messages);
|
||||
for m in messages {
|
||||
model.record_warning(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(wrapper)
|
||||
|
||||
@@ -309,14 +309,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
/// the mechanism: the kind buckets, the strata 8→0 order, the key-name table, and the
|
||||
/// existence-not-handling consumption gate). They were this list's standing exception; the rule
|
||||
/// above let them in the moment something fired them.
|
||||
/// * **`OnCursorChanged`** (4 sites over 3 addons — all of them the Era `ScrollingEdit_OnCursorChanged`
|
||||
/// auto-scroll idiom) — **raising**, by this list's own rule: nothing fires it. It is the
|
||||
/// EditBox's own slot (RF-28 `+0x428`), fired by the reference's caret flush `0x77da80` with
|
||||
/// **four float caret-POSITION args**. The geometry itself is no longer the obstacle — the paint
|
||||
/// seam computes `caret_row`/`caret_x` off the host-answered advance table
|
||||
/// ([`crate::script::editbox::seam`]) — but that is a per-extract recompute with no event edge,
|
||||
/// so there is no counterpart to the flush to fire from. Accepting the name before building one
|
||||
/// would mean a scroll box that silently never follows the caret.
|
||||
/// * **`OnCursorChanged`** (4 sites over 3 addons, plus two in the SHIPPED FrameXML — all of them
|
||||
/// the `ScrollingEdit_OnCursorChanged` auto-scroll idiom) — **accepted since decision 2141**,
|
||||
/// which built the edge this entry said was missing. The EditBox's own slot (RF-28 `+0x428`),
|
||||
/// fired by the caret flush `0x77da80` with four float caret-position args in UI units;
|
||||
/// `editbox::drain_cursor_changed` is our counterpart, on the tick, gated on the caret having
|
||||
/// actually moved. The stock `MailFrame.xml` and `HelpFrame.xml` declare it, so until 2141 the
|
||||
/// mail body and the GM ticket box did not scroll as you typed past their bottom — a bug nobody
|
||||
/// could see, because the load-time refusal only reached a terminal (decision 2135).
|
||||
/// * **`OnAttributeChanged`** (1 site, `Roid-Macros`) — **raising, permanently.** It is 2.0's secure
|
||||
/// frame/attribute system; there is no such slot in any 1.12 resolver. That addon is asking for a
|
||||
/// later client and should hear so.
|
||||
@@ -431,7 +431,7 @@ fn apply_region_inherits(lua: &Lua, wrapper: &Table, from: &str) -> mlua::Result
|
||||
};
|
||||
if is_template {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
model.warnings.push(format!(
|
||||
model.record_warning(format!(
|
||||
"CreateTexture/CreateFontString: '{from}' is a registered TEMPLATE, not a font object; the region is created but the template's content is not applied (no corpus caller does this)"
|
||||
));
|
||||
return Ok(());
|
||||
|
||||
@@ -439,7 +439,7 @@ fn set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> mlua::Re
|
||||
.frame(h)
|
||||
.and_then(|f| f.name.clone())
|
||||
.unwrap_or_else(|| "<anonymous>".into());
|
||||
model.warnings.push(format!(
|
||||
model.record_warning(format!(
|
||||
"SetPoint({who}): relativeTo '{}' does not resolve — anchored to the parent",
|
||||
nt.name
|
||||
));
|
||||
|
||||
@@ -48,7 +48,7 @@ use mlua::Value;
|
||||
|
||||
use crate::layout::Rect;
|
||||
use crate::order;
|
||||
use crate::widget::FrameHandle;
|
||||
use crate::widget::{ButtonState, FrameHandle};
|
||||
|
||||
use super::clip::{effective_clip, scroll_clip_sources};
|
||||
use super::{button, cursor, editbox, event, Model, UiScript};
|
||||
@@ -316,14 +316,17 @@ impl UiScript {
|
||||
});
|
||||
let old_handle = model.mouseover;
|
||||
model.mouseover = new_handle;
|
||||
// The client's `0x7793f0` (leave) and `0x7791ed` (enter) both call `SetState`:
|
||||
// a press held over a button and then walked off it drops back to NORMAL, and
|
||||
// walking back on picks PUSHED up again. Ours latches the same transition from
|
||||
// the moved hover — both sides of the boundary, since each one's `hovered`
|
||||
// changed.
|
||||
for h in [old_handle, new_handle].into_iter().flatten() {
|
||||
button::settle(&mut model, h);
|
||||
}
|
||||
// **Crossing a button's boundary moves NO button state**, and the comment that
|
||||
// used to sit here claimed the opposite off two mis-attributed addresses:
|
||||
// `0x7793f0` is the drag-start override, not a leave, and `0x7791e0` is the hide
|
||||
// override, not an enter. The real enter/leave notifies `0x779490`/`0x7794e0`
|
||||
// read `[+0x328]` only as a DISABLED guard and never write it (wow-re
|
||||
// `scratch/button-state-edge-set.md`, a §5 census of every store to the field).
|
||||
//
|
||||
// So a press held over a button and walked off it stays PUSHED in the reference —
|
||||
// it is the *drag threshold* (below) that un-presses one, and only for a frame
|
||||
// that registered for drag. Decision 2134.
|
||||
let _ = old_handle;
|
||||
(old_id, drag_start, true, slider_change, color_change)
|
||||
}
|
||||
};
|
||||
@@ -546,11 +549,25 @@ impl UiScript {
|
||||
Some(h) => model.mouse_down_on.insert(button.to_string(), h),
|
||||
None => model.mouse_down_on.remove(button),
|
||||
};
|
||||
// `0x7792ad`: the press SetState(PUSHED), unconditional past the registration
|
||||
// and hit gates. The displaced entry is settled too — a second press of the same
|
||||
// button elsewhere releases whatever it was holding.
|
||||
for h in [displaced, hit_handle].into_iter().flatten() {
|
||||
button::settle(&mut model, h);
|
||||
// `0x7792b1`: the press edge, `SetButtonState(PUSHED, 0)`.
|
||||
//
|
||||
// It is unconditional *past* `0x779210`'s own two gates, and the first of them is
|
||||
// the registration mask `[this+0x330]` tested as `m | m << 8` at `0x77924b` —
|
||||
// "registered for this mouse button EITHER WAY, up or down". That is
|
||||
// [`button::wants_press_visual`], and it is why a right-click lights up an action
|
||||
// slot (it registers `RightButtonUp`) and does nothing at all to a plain
|
||||
// `LeftButtonUp` button. Past it the edge is gated only on the button's own
|
||||
// `locked == 0 && state != DISABLED`.
|
||||
if let Some(h) = hit_handle.filter(|&h| button::wants_press_visual(&model, h, button))
|
||||
{
|
||||
button::edge(&mut model, h, ButtonState::on_mouse_down);
|
||||
}
|
||||
// A press arriving while this same mouse button was already holding another
|
||||
// frame is a release we never got (the OS ate it — a focus loss, a modal). Give
|
||||
// the displaced frame that release rather than leaving it pushed forever; the
|
||||
// reference never reaches this because its capture guarantees the release.
|
||||
if let Some(h) = displaced {
|
||||
button::edge(&mut model, h, ButtonState::on_mouse_up);
|
||||
}
|
||||
// `0x7663e6` writes the resolved target into `root+0x80` — capture-else-hover, so
|
||||
// this is an `or`, not an assignment: an existing capture is not displaced by a
|
||||
@@ -588,10 +605,6 @@ impl UiScript {
|
||||
(click, None, false, jump, None, abandoned, color_jump)
|
||||
} else {
|
||||
let pressed = model.mouse_down_on.remove(button);
|
||||
// `0x7793c2`: the release SetState(NORMAL) on the frame the press captured.
|
||||
if let Some(h) = pressed {
|
||||
button::settle(&mut model, h);
|
||||
}
|
||||
// `root+0x80` is cleared at `0x7664bb` **only when the post-event button mask is
|
||||
// zero** — a chorded release keeps the capture for the button still held. With the
|
||||
// per-button map already drained above, "mask is zero" is "the map is empty".
|
||||
@@ -609,6 +622,16 @@ impl UiScript {
|
||||
);
|
||||
let release = cursor::take_drag(&mut model, button);
|
||||
let started = release.as_ref().is_some_and(|r| r.started);
|
||||
// **The release edge** — `0x7793de`'s `SetButtonState(NORMAL, 0)`, on the frame
|
||||
// the PRESS captured and regardless of where the cursor now is (`0x7792d0` runs
|
||||
// no hit test of its own). It is guarded on the button's own `locked == 0` and on
|
||||
// the state actually being PUSHED, and it is **skipped when the base finalized a
|
||||
// drag** — `0x7792df`'s `0x76c040` test, which is this `started` (the button
|
||||
// already un-pressed at the drag-start edge below, and un-pressing again would
|
||||
// fire nothing but would be a second transition the reference does not make).
|
||||
if let (Some(h), false) = (pressed, started) {
|
||||
button::edge(&mut model, h, ButtonState::on_mouse_up);
|
||||
}
|
||||
// The world drop: press AND release both over THE WORLD, no drag started —
|
||||
// a completed left click on the game world (never a drag release, which just
|
||||
// keeps carrying; decision 0218's byte-verified trigger). What actually drops
|
||||
|
||||
@@ -776,7 +776,7 @@ pub(super) fn resolve_target(
|
||||
.and_then(|&h| model.arena.frame(h))
|
||||
.and_then(|f| f.name.clone())
|
||||
.unwrap_or_else(|| "<anonymous>".into());
|
||||
model.warnings.push(format!(
|
||||
model.record_warning(format!(
|
||||
"SetPoint(region of {who}): relativeTo '{}' does not resolve — anchored to the owner",
|
||||
nt.name
|
||||
));
|
||||
|
||||
@@ -27,7 +27,10 @@ use super::region_handle_of;
|
||||
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
m.set(
|
||||
"SetText",
|
||||
lua.create_function(|lua, (this, text): (Table, Option<String>)| {
|
||||
lua.create_function(|lua, (this, text): (Table, Option<mlua::Value>)| {
|
||||
// `text_arg`, not `Option<String>`: a Lua string is bytes and a sliced one need not be
|
||||
// valid UTF-8 (decision 2138 — this raise took the whole handler down).
|
||||
let text = crate::script::binding_abi::text_arg(lua, text)?;
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
@@ -50,7 +53,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
.globals()
|
||||
.get::<Table>("string")?
|
||||
.get::<mlua::Function>("format")?;
|
||||
let text: String = format.call(args)?;
|
||||
// The formatted result is bytes too — `%s` of a sliced string is a sliced string.
|
||||
let text = format.call::<mlua::String>(args)?.to_string_lossy();
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
|
||||
@@ -83,7 +83,7 @@ impl super::UiScript {
|
||||
}
|
||||
}
|
||||
if !unwritable.is_empty() {
|
||||
self.model_mut().warnings.push(format!(
|
||||
self.model_mut().record_warning(format!(
|
||||
"saved variables: not serializable, skipped: {}",
|
||||
unwritable.join(", ")
|
||||
));
|
||||
|
||||
@@ -244,7 +244,9 @@ pub(crate) fn set_text(model: &mut Model, fh: FrameHandle, raw: &str) -> bool {
|
||||
let parsed = parse::parse_markup(raw, &frame_name, &hyperlink_format);
|
||||
// The reference pushes these through the frame's error sink to the console. Ours are host
|
||||
// warnings: a malformed page is the player's content being wrong, not a script raising.
|
||||
model.warnings.extend(parsed.errors.iter().cloned());
|
||||
for e in &parsed.errors {
|
||||
model.record_warning(e.clone());
|
||||
}
|
||||
|
||||
build(model, fh, &parsed.blocks);
|
||||
parsed.used_markup
|
||||
@@ -836,7 +838,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"SetText",
|
||||
lua.create_function(|lua, (this, text): (Table, Value)| {
|
||||
let raw = match &text {
|
||||
Value::String(s) => s.to_str()?.to_string(),
|
||||
// Bytes, lossily — never a raise (2138).
|
||||
Value::String(s) => s.to_string_lossy(),
|
||||
Value::Number(_) | Value::Integer(_) => super::object::as_f32(&text).to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
@@ -136,8 +136,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
m.set(
|
||||
"SetValueStep",
|
||||
// Not a field write (2143): past the step store it re-pushes the range through
|
||||
// `SetMinMaxValues`, which re-clamps and re-quantises the held value onto the new lattice
|
||||
// and fires `OnValueChanged` if it moved — so this binding fires like the other two.
|
||||
lua.create_function(|lua, (this, step): (Table, f32)| {
|
||||
with_slider(lua, &this, |s| s.step = step)
|
||||
let changed = with_slider(lua, &this, |s| s.set_value_step(step))?;
|
||||
fire_value_changed(lua, &this, changed)
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
|
||||
@@ -1244,3 +1244,197 @@ fn a_state_texture_slot_takes_an_object_and_a_nil() {
|
||||
"the slot must read empty after nil"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The unlocked scripted push is released by the next mouse release — Tablet-2.0's rows**
|
||||
/// (decision 2134).
|
||||
///
|
||||
/// `SetButtonState(state[, lock])` writes `[+0x32c]` unconditionally, and the mouse-up edge
|
||||
/// `0x7793de` un-presses whenever `locked == 0`. Tablet-2.0 (`Tablet-2.0.lua:1645`) pushes a row it
|
||||
/// finds `clicked` and calls `SetButtonState("NORMAL")` *nowhere in the library* — it relies on
|
||||
/// exactly this. Ours kept a scripted push until Lua cleared it, so a Questie/FuBar/oRA2 row stayed
|
||||
/// depressed for the rest of the session.
|
||||
#[test]
|
||||
fn an_unlocked_scripted_push_is_released_by_the_next_mouse_release() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
row = CreateFrame("Button", "TabletRow")
|
||||
row:SetPoint("BOTTOMLEFT", 0, 0); row:SetSize(100, 100)
|
||||
row:SetNormalTexture("Interface\\RowN.blp")
|
||||
row:SetPushedTexture("Interface\\RowP.blp")
|
||||
row:SetButtonState("PUSHED") -- Tablet-2.0's call, verbatim: no lock argument
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve(); // a press only reaches a frame with a resolved rect
|
||||
assert_eq!(
|
||||
s.eval::<String>("return TabletRow:GetButtonState()")
|
||||
.unwrap(),
|
||||
"PUSHED"
|
||||
);
|
||||
|
||||
// A press and release over the row: the release edge finds it PUSHED and unlocked.
|
||||
s.mouse_move(50.0, 50.0);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return TabletRow:GetButtonState()")
|
||||
.unwrap(),
|
||||
"NORMAL",
|
||||
"the release un-pushes an unlocked scripted push — the row does not stay depressed"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **`lock` pins the state against the mouse — the micro buttons** (`0x780270`'s third index,
|
||||
/// `GetBoolOrDefault` with default 0; wow-re `binding-shape-arity-law.md` §3).
|
||||
///
|
||||
/// `MainMenuBarMicroButtons.lua` calls `SetButtonState("PUSHED", 1)` when its panel opens, and the
|
||||
/// button must stay depressed through every press and release until the panel closes. The same
|
||||
/// flag pins a NORMAL button *against* being pushed, which is the half a "sticky push" model
|
||||
/// cannot express at all.
|
||||
#[test]
|
||||
fn a_locked_state_ignores_the_mouse_and_enable_disable_clears_the_lock() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
micro = CreateFrame("Button", "MicroButton")
|
||||
micro:SetPoint("BOTTOMLEFT", 0, 0); micro:SetSize(100, 100)
|
||||
micro:SetButtonState("PUSHED", 1) -- MainMenuBarMicroButtons.lua, verbatim
|
||||
pin = CreateFrame("Button", "PinnedNormal")
|
||||
pin:SetPoint("BOTTOMLEFT", 200, 0); pin:SetSize(100, 100)
|
||||
pin:SetButtonState("NORMAL", 1)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
s.mouse_move(50.0, 50.0);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return MicroButton:GetButtonState()")
|
||||
.unwrap(),
|
||||
"PUSHED",
|
||||
"a locked push survives a whole click"
|
||||
);
|
||||
|
||||
s.mouse_move(250.0, 50.0);
|
||||
s.mouse_button(250.0, 50.0, "LeftButton", true);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return PinnedNormal:GetButtonState()")
|
||||
.unwrap(),
|
||||
"NORMAL",
|
||||
"…and a locked NORMAL cannot be pushed by the mouse at all"
|
||||
);
|
||||
s.mouse_button(250.0, 50.0, "LeftButton", false);
|
||||
|
||||
// `0x779160` passes `locked = 0` on both arms, so Enable/Disable UNLOCK. The panel closing
|
||||
// with a plain `SetButtonState("NORMAL")` (flag defaulting to 0) does the same.
|
||||
s.run("MicroButton:Disable() MicroButton:Enable()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return MicroButton:GetButtonState()")
|
||||
.unwrap(),
|
||||
"NORMAL",
|
||||
"Disable() wrote DISABLED, Enable() wrote NORMAL — and both cleared the lock"
|
||||
);
|
||||
s.mouse_move(50.0, 50.0);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return MicroButton:GetButtonState()")
|
||||
.unwrap(),
|
||||
"PUSHED",
|
||||
"so the mouse reaches it again"
|
||||
);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Walking off a held button does NOT un-press it, and walking back on does not re-press it.**
|
||||
///
|
||||
/// The correction decision 2134 is built on. Our old model derived the press as
|
||||
/// `(held && hovered) || pushed_state` and re-evaluated it whenever the hover moved; the
|
||||
/// reference's enter and leave notifies (`0x779490`/`0x7794e0`) read `[+0x328]` only as a DISABLED
|
||||
/// guard and **never write it** — proved by a §5 census of every store to the field image-wide.
|
||||
/// The only thing that un-presses a held button before its release is the drag-threshold crossing,
|
||||
/// and only for a frame that registered for drag.
|
||||
#[test]
|
||||
fn the_hover_is_not_an_input_to_the_press_state() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
b = CreateFrame("Button", "HeldButton")
|
||||
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(100, 100)
|
||||
b:SetNormalTexture("Interface\\HeldN.blp")
|
||||
b:SetPushedTexture("Interface\\HeldP.blp")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let shows = |s: &UiScript, path: &str| {
|
||||
s.extract()
|
||||
.iter()
|
||||
.any(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path))
|
||||
};
|
||||
|
||||
s.mouse_move(50.0, 50.0);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
assert!(shows(&s, "Interface\\HeldP.blp"), "the press pushed it");
|
||||
|
||||
// Walk the cursor right off it, still holding.
|
||||
s.mouse_move(500.0, 400.0);
|
||||
assert!(
|
||||
shows(&s, "Interface\\HeldP.blp"),
|
||||
"…and it stays pushed off the rect: the leave notify does not write the state"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return HeldButton:GetButtonState()")
|
||||
.unwrap(),
|
||||
"PUSHED"
|
||||
);
|
||||
|
||||
// The release, off the button, still un-presses it — `0x7792d0` runs no hit test of its own.
|
||||
s.mouse_button(500.0, 400.0, "LeftButton", false);
|
||||
assert!(
|
||||
shows(&s, "Interface\\HeldN.blp"),
|
||||
"the release is the edge, wherever the cursor is"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **A button hidden while held comes back NORMAL** — `CSimpleButton`'s hide override
|
||||
/// (`+0x34` = `0x7791e0`), which un-presses and then tail-jumps the base notify so `<OnHide>`
|
||||
/// still fires. It hangs off the visibility transition, not off the hover, so it reaches a button
|
||||
/// hidden nowhere near the cursor too.
|
||||
#[test]
|
||||
fn hiding_a_held_button_un_presses_it() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
h = CreateFrame("Button", "HidButton")
|
||||
h:SetPoint("BOTTOMLEFT", 0, 0); h:SetSize(100, 100)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.mouse_move(50.0, 50.0);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return HidButton:GetButtonState()")
|
||||
.unwrap(),
|
||||
"PUSHED"
|
||||
);
|
||||
s.run("HidButton:Hide()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return HidButton:GetButtonState()")
|
||||
.unwrap(),
|
||||
"NORMAL",
|
||||
"the hide edge un-pressed it"
|
||||
);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -617,8 +617,10 @@ fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
|
||||
// delivery walk, so they are fired and therefore accepted — the rule this test enforces,
|
||||
// applied in the other direction. Their law lives in `script::tests::keyboard`.
|
||||
//
|
||||
// Caret geometry is host-side; its four float args would all be zero.
|
||||
"OnCursorChanged",
|
||||
// `OnCursorChanged` left this list with decision 2141: the tick's caret flush fires it
|
||||
// (`editbox::drain_cursor_changed`), which is the rule applied in the other direction
|
||||
// again — its law lives in `script::editbox::tests`.
|
||||
//
|
||||
// 2.0's secure-frame system — no such slot exists in any 1.12 resolver.
|
||||
"OnAttributeChanged",
|
||||
// Real 1.12 slots we do not fire, and zero corpus call sites. (`OnUpdateModel` and
|
||||
|
||||
@@ -438,3 +438,214 @@ fn a_thumb_with_no_authored_size_takes_its_arts_texel_span_and_still_drags() {
|
||||
"travel is 128−32 = 96, so a 96-unit pull runs the value min→max (got {v})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slider_setvalue_quantises_onto_the_min_anchored_lattice() {
|
||||
// `SetValue 0x789930` rounds the clamped value onto `min + n·step` (round-half-away-from-zero,
|
||||
// truncated by `__ftol`) BEFORE it stores — so `GetValue` on a stepped slider can only ever
|
||||
// answer a lattice point, and the lattice is anchored at `min`, not at zero. Decision 2095
|
||||
// named this divergence and left it; this is it closed.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
q = CreateFrame("Slider", "SlQuant")
|
||||
q:SetMinMaxValues(0.2, 1.2)
|
||||
q:SetValueStep(0.5)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The lattice is 0.2 / 0.7 / 1.2 — 0.5 and 1.0 are NOT on it, which is the whole point of
|
||||
// anchoring at `min`.
|
||||
for (set, want) in [
|
||||
(0.6, 0.7),
|
||||
(0.95, 0.7),
|
||||
(0.4, 0.2),
|
||||
(1.2, 1.2),
|
||||
(0.2, 0.2),
|
||||
(0.0, 0.2), // clamped to min first, then quantised
|
||||
] {
|
||||
s.run(&format!("SlQuant:SetValue({set})")).unwrap();
|
||||
let v: f32 = s.eval("return SlQuant:GetValue()").unwrap();
|
||||
assert!(
|
||||
(v - want).abs() < 1e-5,
|
||||
"SetValue({set}) settles on {want}, got {v}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_step_leaves_the_value_continuous() {
|
||||
// The gate that keeps every scrollbar working: `UIPanelScrollBarTemplate` declares no
|
||||
// `valueStep`, so `[+0x324]` stays at the ctor's 0.0 and `0x78999c`'s `fcomp(step, 0.0)`
|
||||
// skips the quantiser outright. A stepless slider stores exactly what it was handed.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
c = CreateFrame("Slider", "SlCont")
|
||||
c:SetMinMaxValues(0, 1)
|
||||
c:SetValue(0.375)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let v: f32 = s.eval("return SlCont:GetValue()").unwrap();
|
||||
assert_eq!(v, 0.375, "no step means no lattice");
|
||||
let step: f32 = s.eval("return SlCont:GetValueStep()").unwrap();
|
||||
assert_eq!(step, 0.0, "the ctor's step is 0.0, and nothing set one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_quantised_value_is_not_re_clamped_and_may_pass_max() {
|
||||
// The clamp runs BEFORE the quantiser and there is no second clamp after it (`0x789a06`
|
||||
// stores whatever `fild n; fmul step; fadd min` produced). So a range that is not a whole
|
||||
// number of steps rounds *past* its own max at the top of the travel. This looks like a bug
|
||||
// and is the client's arithmetic; it is unreachable in the shipped UI because every stepped
|
||||
// slider the reference ships is an exact multiple of its step.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
o = CreateFrame("Slider", "SlOver")
|
||||
o:SetMinMaxValues(0, 2.5)
|
||||
o:SetValueStep(1)
|
||||
o:SetValue(2.5)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let v: f32 = s.eval("return SlOver:GetValue()").unwrap();
|
||||
assert_eq!(v, 3.0, "2.5 clamps to 2.5, then rounds half-away to 3·step");
|
||||
let (min, max): (f32, f32) = s.eval("return SlOver:GetMinMaxValues()").unwrap();
|
||||
assert_eq!(
|
||||
(min, max),
|
||||
(0.0, 2.5),
|
||||
"and the range itself is untouched — the overshoot is in the value alone"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_faux_scrollframes_rows_snap_and_its_bottom_is_exact() {
|
||||
// The falsifier decision 2095 named for this change, run on the shape it names.
|
||||
// `FauxScrollFrame_Update` builds its range as `(numItems − numToDisplay) · valueStep`
|
||||
// (`UIPanelTemplates.lua:180`) and then sets that same step, so the range IS a whole number
|
||||
// of steps: every drag lands on a row boundary and the bottom of the travel is exactly max.
|
||||
// 20 items, 10 on screen, 16 units a row ⇒ range 0..160, step 16.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Slider", "SlFaux")
|
||||
f:SetMinMaxValues(0, 160)
|
||||
f:SetValueStep(16)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
for (set, want) in [
|
||||
(23.9, 16.0), // just under the half-step boundary: the row below
|
||||
(24.1, 32.0), // just over it: the row above
|
||||
(37.0, 32.0),
|
||||
(160.0, 160.0), // the bottom is exact — no overshoot, the range is 10 steps
|
||||
(0.0, 0.0),
|
||||
] {
|
||||
s.run(&format!("SlFaux:SetValue({set})")).unwrap();
|
||||
let v: f32 = s.eval("return SlFaux:GetValue()").unwrap();
|
||||
assert_eq!(v, want, "SetValue({set}) snaps to the row at {want}");
|
||||
// FauxScrollFrame_GetOffset's own arithmetic, which is what the row list reads.
|
||||
let off: f32 = s.eval("return floor(SlFaux:GetValue() / 16)").unwrap();
|
||||
assert_eq!(off, (want / 16.0).floor(), "and the row offset follows it");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_inside_one_step_fires_nothing() {
|
||||
// The quantiser sits INSIDE the change-gate (`0x789a0b`'s `fcom` is against the stored value,
|
||||
// and what is stored is already quantised), so a drag that stays within one step's band
|
||||
// resolves to the same lattice point and never reaches `OnValueChanged`. Before this, every
|
||||
// pixel of a stepped drag fired.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
fires = 0
|
||||
g = CreateFrame("Slider", "SlGate")
|
||||
g:SetScript("OnValueChanged", function() fires = fires + 1 end)
|
||||
g:SetMinMaxValues(0, 160)
|
||||
g:SetValueStep(16)
|
||||
g:SetValue(32) -- first-ever value: always fires
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 1);
|
||||
for v in [30.0, 34.0, 39.9, 24.1] {
|
||||
s.run(&format!("SlGate:SetValue({v})")).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return fires").unwrap(),
|
||||
1,
|
||||
"four moves inside the 24..40 band are one lattice point: no further fire"
|
||||
);
|
||||
s.run("SlGate:SetValue(40.1)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return fires").unwrap(),
|
||||
2,
|
||||
"crossing into the next band fires once"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_value_step_re_quantises_the_held_value_and_can_fire() {
|
||||
// `SetValueStep 0x789a60` stores the step and then re-pushes the range through
|
||||
// `SetMinMaxValues`, which re-clamps the held value through `SetValue` — onto the NEW lattice
|
||||
// (decision 2143). A step handed to a slider that already holds a value moves it and fires.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
fires = 0
|
||||
v = CreateFrame("Slider", "SlStep")
|
||||
v:SetScript("OnValueChanged", function() fires = fires + 1 end)
|
||||
v:SetMinMaxValues(0, 100)
|
||||
v:SetValue(37) -- first-ever value: always fires, no step yet, so raw
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<f32>("return SlStep:GetValue()").unwrap(), 37.0);
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 1);
|
||||
|
||||
// A step arriving AFTER the value snaps it: 37 → 40 on a 10-lattice, and the move fires.
|
||||
s.run("SlStep:SetValueStep(10)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return SlStep:GetValue()").unwrap(),
|
||||
40.0,
|
||||
"the step re-quantised a value that was already stored"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return fires").unwrap(),
|
||||
2,
|
||||
"…and the move fired OnValueChanged, exactly as a SetValue would"
|
||||
);
|
||||
|
||||
// A step that moves nothing fires nothing — the same change gate, one level up.
|
||||
s.run("SlStep:SetValueStep(20)").unwrap();
|
||||
assert_eq!(s.eval::<f32>("return SlStep:GetValue()").unwrap(), 40.0);
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_before_any_range_is_the_whole_call() {
|
||||
// `0x789a6c test byte [+0x314],2` — bit1 clear ⇒ the step is stored and the function returns.
|
||||
// No range is pushed, nothing is clamped, nothing fires. It is what keeps an `<OnLoad>` that
|
||||
// sets a step before a range from running a handler the addon has not armed (2095's subject,
|
||||
// one verb over).
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
fires = 0
|
||||
n = CreateFrame("Slider", "SlNoRange")
|
||||
n:SetScript("OnValueChanged", function() fires = fires + 1 end)
|
||||
n:SetValueStep(0.25)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return SlNoRange:GetValueStep()").unwrap(),
|
||||
0.25
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 0);
|
||||
let (min, max): (f32, f32) = s.eval("return SlNoRange:GetMinMaxValues()").unwrap();
|
||||
assert_eq!((min, max), (0.0, 0.0), "no range was pushed");
|
||||
}
|
||||
|
||||
@@ -147,6 +147,11 @@ impl super::UiScript {
|
||||
// OnUpdate sweep, matching the reference's order: the box's own OnUpdate override drains
|
||||
// before the FrameXML handlers that read the box get their turn.
|
||||
editbox::drain_text_changed(&self.lua);
|
||||
// …and the caret flush's own fire, the second half of `0x77a790`'s update walk
|
||||
// (`0x77d3e0` → `0x77da80`): `OnCursorChanged` when the caret has moved. It is what
|
||||
// `ScrollingEdit_OnCursorChanged` records and `ScrollingEdit_OnUpdate` then scrolls by,
|
||||
// so this is the mail body and the GM ticket box following the caret as you type.
|
||||
editbox::drain_cursor_changed(&self.lua);
|
||||
// Events queued by Lua bindings last tick (`Model::pending_events` — e.g. `SetMapZoom` →
|
||||
// `WORLD_MAP_UPDATE`; the cursor arc's `CURSOR_UPDATE`/`ITEM_LOCK_CHANGED`/
|
||||
// `DELETE_ITEM_CONFIRM`, decision 0216) fire first, so handlers see them before this
|
||||
|
||||
@@ -55,7 +55,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"ANCHOR_BOTTOMLEFT" => Some((Point::TopRight, Point::BottomLeft)),
|
||||
"ANCHOR_NONE" | "ANCHOR_PRESERVE" => None,
|
||||
other => {
|
||||
model.warnings.push(format!(
|
||||
model.record_warning(format!(
|
||||
"SetOwner: unknown anchor '{other}' (kept ANCHOR_RIGHT)"
|
||||
));
|
||||
Some((Point::BottomLeft, Point::TopRight))
|
||||
|
||||
@@ -186,6 +186,11 @@ pub struct EditBoxState {
|
||||
/// The row pitch in px (the snapped font em — the same `N·S` block law the host's measure
|
||||
/// uses), answered with [`Self::advances`]. `0.0` until answered.
|
||||
pub cell_h: f32,
|
||||
/// The `(row, x)` the last `OnCursorChanged` was fired with — the caret-flush edge's memory
|
||||
/// (`0x77da80`, whose one caller `0x77d475` is gated on dirty bit 2, so the fire is per
|
||||
/// *change*, not per frame). `None` = never fired, which is what makes the first flush after
|
||||
/// a focus fire even though the caret is at the home position.
|
||||
pub cursor_fired: Option<(usize, f32)>,
|
||||
/// First visible byte of the display window (`E+0x348` display/scroll start index) — the
|
||||
/// char-granular h-scroll. Clamped each read so the cursor stays inside the window
|
||||
/// (`0x77da80`'s early-out hides the caret outside `[start, start+visible]`).
|
||||
@@ -310,6 +315,7 @@ impl Default for EditBoxState {
|
||||
advances_key: 0,
|
||||
rows: vec![0],
|
||||
cell_h: 0.0,
|
||||
cursor_fired: None,
|
||||
scroll_start: 0,
|
||||
drag_active: false,
|
||||
blink_period: 0.5,
|
||||
|
||||
@@ -1029,21 +1029,19 @@ pub enum ButtonVisualState {
|
||||
/// the pointer at extract time.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ButtonState {
|
||||
/// `Enable`/`Disable`. A disabled button shows its DisabledTexture and fires no clicks.
|
||||
pub enabled: bool,
|
||||
/// The scripted PUSHED state — `SetButtonState("PUSHED"/"NORMAL") 0x780270` /
|
||||
/// `GetButtonState 0x780180`, the keybind visual's engine half (ref `ActionButtonDown/Up`,
|
||||
/// `ActionButton.lua:15-28`). ORs with the mouse-derived held+hovered press in
|
||||
/// [`Self::input_state`]; the mouse press itself stays outside the widget (the app's capture),
|
||||
/// which is why the state machine takes it as an argument.
|
||||
/// **`[CSimpleButton+0x32c]` — the LOCK**, written unconditionally by every
|
||||
/// `SetButtonState(state, locked) 0x779790` and read by exactly four guards, all of them the
|
||||
/// engine's own edges. While it is set the button **ignores the mouse press/release
|
||||
/// transitions entirely**; while it is clear, any engine edge that fires re-writes it to
|
||||
/// `false`, because every engine call site passes `locked = 0` (wow-re
|
||||
/// `system/ui/scratch/button-state-edge-set.md`, VERIFIED — including `Enable`/`Disable`,
|
||||
/// which therefore CLEAR a lock a script set).
|
||||
///
|
||||
/// **The reference has one variable where we have two**, and the difference is stated rather
|
||||
/// than implied: `SetButtonState(state, locked)` writes `[+0x328]` *and* the lock at `+0x32c`,
|
||||
/// and an unlocked scripted push is therefore cleared by the next mouse press/release
|
||||
/// (`0x7793c2`'s `SetState(NORMAL)`, gated on `locked == 0`). Ours keeps the flag until Lua
|
||||
/// clears it. No 1.12 caller pushes without meaning it to stick: `ActionButtonDown` pairs
|
||||
/// every push with its own `ActionButtonUp`, and the micro buttons pass `locked = 1`.
|
||||
pub pushed_state: bool,
|
||||
/// `MainMenuBarMicroButtons.lua` is the reference's own customer: `SetButtonState("PUSHED", 1)`
|
||||
/// when a panel opens, `SetButtonState("NORMAL")` — the flag defaulting to 0 — when it closes.
|
||||
/// The ordinary action bars pass no flag at all, and Tablet-2.0 pushes its clicked rows
|
||||
/// unlocked and never un-pushes them, which is the whole reason this field exists (2134).
|
||||
pub locked: bool,
|
||||
/// [`FrameKind::LootButton`]'s own field — `CLootButton +0x4dc`, the **0-based** loot slot
|
||||
/// this row takes when clicked. `None` until `SetSlot` writes it (the ctor's zero is a slot
|
||||
/// id in the reference; ours is `None`, so a row that was never given one takes nothing
|
||||
@@ -1146,10 +1144,17 @@ pub struct ButtonState {
|
||||
/// [`Self::set_state`] and [`Self::set_state_slot`] alone, which is what makes the transition
|
||||
/// rule un-bypassable.
|
||||
shown: Option<RegionHandle>,
|
||||
/// The state [`Self::shown`] was last resolved for — the client's `[+0x328]`. Latched from the
|
||||
/// three inputs ([`Self::enabled`], [`Self::pushed_state`] and the mouse's held+hovered) by
|
||||
/// [`Self::settle`], so a *transition* can be detected at all; the client keeps the same one
|
||||
/// variable for the same reason.
|
||||
/// **`[CSimpleButton+0x328]` — THE state**, and the client really does have only this one
|
||||
/// variable: `SetButtonState 0x779790` is its only writer past the ctor, and `IsEnabled
|
||||
/// 0x7800b0`, `GetButtonState 0x780180` and every texture decision read it back.
|
||||
///
|
||||
/// Private, because it is a **latch driven by edges**, not a value derived from inputs. That
|
||||
/// distinction is the whole of decision 2134: benilla used to carry `enabled` and
|
||||
/// `pushed_state` beside it and re-derive `state` from them plus the live mouse
|
||||
/// (`(held && hovered) || pushed_state`), and the reference's press state **has no hover term
|
||||
/// at all** — the enter and leave notifies (`0x779490`/`0x7794e0`) read `[+0x328]` only as a
|
||||
/// DISABLED guard and never write it. A derived model un-presses when the cursor leaves a held
|
||||
/// button and re-presses when it comes back; the reference does neither.
|
||||
state: ButtonVisualState,
|
||||
}
|
||||
|
||||
@@ -1157,8 +1162,7 @@ impl Default for ButtonState {
|
||||
fn default() -> Self {
|
||||
ButtonState {
|
||||
loot_slot: None,
|
||||
enabled: true,
|
||||
pushed_state: false,
|
||||
locked: false,
|
||||
checked: false,
|
||||
normal: None,
|
||||
pushed: None,
|
||||
@@ -1195,18 +1199,6 @@ impl ButtonState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The state the three inputs put the button in — disabled wins, then the press (the mouse's
|
||||
/// held+hovered, or the scripted [`Self::pushed_state`]), else resting.
|
||||
fn input_state(&self, hovered: bool, held: bool) -> ButtonVisualState {
|
||||
if !self.enabled {
|
||||
ButtonVisualState::Disabled
|
||||
} else if (held && hovered) || self.pushed_state {
|
||||
ButtonVisualState::Pushed
|
||||
} else {
|
||||
ButtonVisualState::Normal
|
||||
}
|
||||
}
|
||||
|
||||
/// **`CSimpleButton::SetState 0x779790`** — the transition, and the whole of why the shown
|
||||
/// texture is state rather than a lookup.
|
||||
///
|
||||
@@ -1251,16 +1243,111 @@ impl ButtonState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the state machine over the current inputs — the caller's job at every point one of them
|
||||
/// can have moved (`script::button::settle`, which reads the mouse's two off the model).
|
||||
/// **`CSimpleButton::SetButtonState(state, locked) 0x779790` — the ONE writer**, and the door
|
||||
/// every edge below goes through.
|
||||
///
|
||||
/// The client has no such call because it has no derived inputs: its mouse handlers call
|
||||
/// `SetState` directly (`0x7791ed` enter, `0x7793f0` leave, `0x7792ad` down, `0x7793c2` up,
|
||||
/// each gated on `locked == 0`) and so do `Enable`/`Disable`. Ours keeps the inputs as fields
|
||||
/// and latches [`Self::state`] from them here; the transitions that result are the same ones,
|
||||
/// in the same order.
|
||||
pub fn settle(&mut self, hovered: bool, held: bool) {
|
||||
self.set_state(self.input_state(hovered, held));
|
||||
/// `0x77979d mov [this+0x32c], locked` is **unconditional**, before and independent of the
|
||||
/// state's own equality early-out at `0x7797a3` — so a `SetButtonState("NORMAL", 1)` on a
|
||||
/// button that is already NORMAL still arms the lock, and every engine edge (all of which pass
|
||||
/// `locked = 0`) disarms it even when the state does not move.
|
||||
pub fn set_button_state(&mut self, new: ButtonVisualState, locked: bool) {
|
||||
self.locked = locked;
|
||||
self.set_state(new);
|
||||
}
|
||||
|
||||
/// `IsEnabled 0x7800b0` — which reads `[+0x328]` and nothing else, because there is no
|
||||
/// separate enabled flag in the class.
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.state != ButtonVisualState::Disabled
|
||||
}
|
||||
|
||||
/// `GetButtonState 0x780180` — the same one variable, un-derived. A button held down by the
|
||||
/// mouse answers `"PUSHED"` because the press *wrote* it, not because a getter re-checks the
|
||||
/// cursor (which is what ours used to do, and why it could disagree with the art).
|
||||
pub fn button_state(&self) -> ButtonVisualState {
|
||||
self.state
|
||||
}
|
||||
|
||||
// ── The engine's edges (wow-re `scratch/button-state-edge-set.md`, VERIFIED) ─────────────
|
||||
//
|
||||
// Seven call sites of `0x779790` exist image-wide; six are below and the seventh is the Lua
|
||||
// binding ([`Self::set_button_state`]). Every one of the six passes `locked = 0`. There is
|
||||
// **no enter edge and no leave edge** — `0x779490`/`0x7794e0` re-apply the label style and
|
||||
// never touch `[+0x328]` — which is the correction 2134 is built on.
|
||||
|
||||
/// **`SetEnabled(bool) 0x779160`** (vtable `+0x90`) — `Enable 0x77fef0` / `Disable 0x77ffd0`.
|
||||
///
|
||||
/// Two asymmetries the byte read settles, both early-outs on the state it already has:
|
||||
/// `Enable()` on a button that is not DISABLED does **nothing at all** (`0x779175`) — so it
|
||||
/// does not un-push a PUSHED button and does not clear a lock; `Disable()` on an
|
||||
/// already-DISABLED one likewise (`0x77919c`). When it does run it passes `locked = 0`
|
||||
/// (`0x779179`/`0x7791c2`), so enabling or disabling a script-locked button **unlocks** it.
|
||||
///
|
||||
/// Returns whether the transition ran — the caller owes the HIGHLIGHT draw-layer half only
|
||||
/// then (`script::button::set_enabled`).
|
||||
pub fn set_enabled(&mut self, on: bool) -> bool {
|
||||
if on == self.enabled() {
|
||||
return false;
|
||||
}
|
||||
self.set_button_state(
|
||||
if on {
|
||||
ButtonVisualState::Normal
|
||||
} else {
|
||||
ButtonVisualState::Disabled
|
||||
},
|
||||
false,
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// **The hide edge** — `CSimpleButton`'s `+0x34` override `0x7791e0`, which un-presses and then
|
||||
/// tail-jumps the base notify so the frame's `<OnHide>` still fires. Guard at `0x7791ed`:
|
||||
/// `state != DISABLED && locked == 0`.
|
||||
pub fn on_hide(&mut self) {
|
||||
if self.state != ButtonVisualState::Disabled && !self.locked {
|
||||
self.set_button_state(ButtonVisualState::Normal, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The press edge** — the mouse-down dispatcher `0x779210`'s `0x7792b1`
|
||||
/// `SetButtonState(PUSHED, 0)`, guarded at `0x779295` on `locked == 0 && state != DISABLED`.
|
||||
/// Unconditional past the registration and hit gates above it, which is why a right-click on a
|
||||
/// slot that registered `RightButtonUp` lights up even when the click does nothing.
|
||||
pub fn on_mouse_down(&mut self) {
|
||||
if !self.locked && self.state != ButtonVisualState::Disabled {
|
||||
self.set_button_state(ButtonVisualState::Pushed, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The release edge** — the mouse-up dispatcher `0x7792d0`'s `0x7793de`
|
||||
/// `SetButtonState(NORMAL, 0)`, guarded at `0x7793c2` on `locked == 0` and at `0x779307` on the
|
||||
/// state actually being PUSHED.
|
||||
///
|
||||
/// **It does not ask where the cursor is.** A press held over a button and released anywhere
|
||||
/// on screen un-presses it; the release is the edge, the hover is not. (The caller skips this
|
||||
/// when the base finalized a drag — `0x7792df`'s `0x76c040` test — which is our
|
||||
/// `cursor::take_drag(..).started`.)
|
||||
///
|
||||
/// This is the edge that fixes Tablet-2.0: its rows call `SetButtonState("PUSHED")` with no
|
||||
/// lock argument and never call `SetButtonState("NORMAL")` at all, because in the reference
|
||||
/// this release does it for them.
|
||||
pub fn on_mouse_up(&mut self) {
|
||||
if !self.locked && self.state == ButtonVisualState::Pushed {
|
||||
self.set_button_state(ButtonVisualState::Normal, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The drag-start edge** — `+0x74`'s override `0x7793f0`, `0x779410`
|
||||
/// `SetButtonState(NORMAL, 0)` under the same `0x779400` guard, before forwarding to the base
|
||||
/// `0x76c1e0` that fires `<OnDragStart>`.
|
||||
///
|
||||
/// It fires on the drag THRESHOLD crossing (a link-time `0.01` frame units, `0x81c468`), not on
|
||||
/// the rect edge, and only for a frame that called `RegisterForDrag` — so a button you drag off
|
||||
/// un-presses well before the cursor leaves it, and one you merely slide off does not.
|
||||
pub fn on_drag_start(&mut self) {
|
||||
if !self.locked && self.state != ButtonVisualState::Disabled {
|
||||
self.set_button_state(ButtonVisualState::Normal, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// **`SetNormalTexture`/`SetPushedTexture`/`SetDisabledTexture 0x778fd0`** — the slot store,
|
||||
@@ -1308,10 +1395,10 @@ impl ButtonState {
|
||||
return hovered || self.locked_highlight;
|
||||
}
|
||||
if some == self.checked_tex {
|
||||
return self.checked && (self.enabled || self.disabled_checked.is_none());
|
||||
return self.checked && (self.enabled() || self.disabled_checked.is_none());
|
||||
}
|
||||
if some == self.disabled_checked {
|
||||
return self.checked && !self.enabled;
|
||||
return self.checked && !self.enabled();
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -1382,11 +1469,13 @@ pub struct SliderState {
|
||||
/// Atlas's option sliders raised `attempt to index global 'AtlasOptions'` from their own
|
||||
/// `<OnLoad>`, a handler the reference never runs at that point.
|
||||
pub has_value: bool,
|
||||
/// `SetValueStep` (XML `valueStep`) — the step the arrow keys / step buttons move by. Stored and
|
||||
/// returned. **Known divergence:** the client's `SetValue` (`0x789930`) round-half quantises the
|
||||
/// value by this step before the change compare (wow-re `item9-firing34-merge.md`, `ui.md`
|
||||
/// "clamped/step-quantized"); ours stores the raw value. Not yet reconciled — every scrollbar
|
||||
/// rides this path, so it is its own change with its own falsifier.
|
||||
/// `SetValueStep` (XML `valueStep`) — the client's `[+0x324]`, and **the quantiser every
|
||||
/// stored value passes through**, not merely the arrow keys' stride: `SetValue 0x789930`
|
||||
/// rounds the clamped value onto the `min + n·step` lattice before the change compare, so
|
||||
/// `GetValue` on a stepped slider can only ever answer a lattice point ([`slider_set_value`],
|
||||
/// which is the whole kernel transcribed). A `step` of exactly `0.0` — the ctor default, and
|
||||
/// what every scrollbar template leaves it at — skips the quantiser outright, which is the
|
||||
/// gate that keeps `UIPanelScrollBarTemplate` continuous.
|
||||
pub step: f32,
|
||||
/// `true` = VERTICAL (the ctor default; value maps along the track's height, min at the top),
|
||||
/// `false` = HORIZONTAL (`orientation`; shared enum `0x811b00` HORIZONTAL=0/VERTICAL=1).
|
||||
@@ -1473,6 +1562,80 @@ pub fn slider_fraction(
|
||||
(travel > 0.0).then(|| ((cursor - grab) / travel).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// **`CSimpleSlider::SetValue`'s arithmetic kernel — `0x789930`, clamp then quantise.**
|
||||
///
|
||||
/// The value a `SetValue(v)` actually stores, given the widget's range fields as the client holds
|
||||
/// them: `min` = `[+0x318]`, `span` = `[+0x31c]` (the client keeps the *span*, not the max — the
|
||||
/// range setter `0x7898f0` stores `end − start` and `0x789a60` re-derives `max = span + min`), and
|
||||
/// `step` = `[+0x324]`.
|
||||
///
|
||||
/// Transcribed op-for-op from wow-re's bit-exact primitive `glue_789930_set_value`
|
||||
/// (`crates/ui/src/glue_geom_789.rs`, difftest `glue_geom_789.rs::glue_789930_set_value`,
|
||||
/// ledger `PRIMITIVE:glue_789930_set_value`, VERIFIED), whose own doc reads the emitted jccs:
|
||||
///
|
||||
/// 1. **clamp-low** (`0x789947`..`0x78995f`) — `fld min; fcomp v; test ah,0x41; jne`: `max(min, v)`,
|
||||
/// with an unordered compare (NaN) taking the `v` arm.
|
||||
/// 2. **`bound = span + min`** (`0x789962`/`0x789968`), spilled to the f32 slot `[ebp-4]`.
|
||||
/// 3. **clamp-high** (`0x78996e`..`0x78999c`) — `hi = bound` *reloaded as f32* iff `bound < lo`
|
||||
/// ordered-strict; otherwise `hi` is the clamp-low again. Net: `clamp(v, min, bound)`, except
|
||||
/// that a **degenerate range pins to `bound`, not to `min`** — which is the one place this
|
||||
/// replaced an assumption, our old `v.clamp(min, max.max(min))` having pinned the other way.
|
||||
/// 4. **quantise** (`0x78999c`..`0x789a06`), skipped entirely when `step == 0.0`:
|
||||
/// `diff = hi − min`, `halfstep = step · 0.5`, then **round-half-away-from-zero** —
|
||||
/// `(diff + halfstep) / step` when `diff > 0` ordered, `(diff − halfstep) / step` otherwise
|
||||
/// (`≤ 0` and NaN both take the round-down arm) — truncated toward zero by `__ftol`, and
|
||||
/// `hi = n · step + min`.
|
||||
///
|
||||
/// Two consequences worth stating because they look like bugs and are not:
|
||||
///
|
||||
/// * **The quantised value is NOT re-clamped.** With a range that is not a whole number of steps,
|
||||
/// the top of the travel rounds *past* `max` by up to half a step. Every stepped slider the
|
||||
/// reference ships is an exact multiple (`FauxScrollFrame_Update` builds its range as
|
||||
/// `(numItems − numToDisplay) · valueStep`; the option sliders are all 10/20/100 steps), so the
|
||||
/// overshoot is unreachable there — but an addon's odd range really does produce it.
|
||||
/// * **The lattice is anchored at `min`, not at zero**, so a slider over `[0.25, 1.0]` stepping by
|
||||
/// `0.1` settles on `0.25, 0.35, …`, never on `0.3`.
|
||||
///
|
||||
/// The f64 intermediates are the x87 register stack at PC_53, and the two `as f32` narrowings are
|
||||
/// its two `fst`/`fstp` spills — not an approximation of them.
|
||||
pub fn slider_set_value(v: f32, min: f32, span: f32, step: f32) -> f32 {
|
||||
let minv = f64::from(min);
|
||||
let vv = f64::from(v);
|
||||
|
||||
// clamp-low: `fcomp(min, v)` falls through to `min` only on `min > v` ordered-strict.
|
||||
let clamp_low = |v: f64| {
|
||||
if matches!(minv.partial_cmp(&v), Some(core::cmp::Ordering::Greater)) {
|
||||
minv
|
||||
} else {
|
||||
v
|
||||
}
|
||||
};
|
||||
let lo = clamp_low(vv);
|
||||
|
||||
// `bound = span + min`, live, and spilled to f32 at `[ebp-4]`.
|
||||
let bound = f64::from(span) + minv;
|
||||
let mut hi = if matches!(bound.partial_cmp(&lo), Some(core::cmp::Ordering::Less)) {
|
||||
f64::from(bound as f32) // `fld [ebp-4]` — the f32 reload, not the live value
|
||||
} else {
|
||||
clamp_low(vv)
|
||||
};
|
||||
|
||||
// The step test is `fcomp(step, 0.0)`: exactly zero (either sign) skips the quantiser.
|
||||
if f64::from(step) != 0.0 {
|
||||
let stepv = f64::from(step);
|
||||
let diff = hi - minv;
|
||||
let halfstep = stepv * 0.5;
|
||||
let scaled = if diff > 0.0 {
|
||||
(halfstep + diff) / stepv
|
||||
} else {
|
||||
(diff - halfstep) / stepv
|
||||
};
|
||||
let n = scaled.trunc() as i32; // `__ftol`, truncate toward zero
|
||||
hi = (f64::from(n) * stepv) + minv;
|
||||
}
|
||||
hi as f32
|
||||
}
|
||||
|
||||
impl SliderState {
|
||||
/// The value fraction `(value − min) / (max − min)`, clamped to `[0, 1]`; a degenerate range
|
||||
/// (`max <= min`, an unscrollable slider) is `0.0` — the thumb sits at the track's start.
|
||||
@@ -1484,26 +1647,71 @@ impl SliderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SetValue` (`0x789930`): clamp `v` into the live range and store it; returns `Some(value)`
|
||||
/// when the caller must fire `OnValueChanged`. The fire law is the client's, emitted-jcc read
|
||||
/// in wow-re `slider-mouse-law.md` §6: **no range yet → a complete no-op**; **first-ever value →
|
||||
/// always store and fire**; after that, **fire iff the clamped value differs** from the stored
|
||||
/// one. The change-gate is load-bearing, not an optimisation: the reference scrollbar wires
|
||||
/// `OnValueChanged → SetVerticalScroll → scrollbar:SetValue` back on itself, and the gate is
|
||||
/// what stops that after one hop. A degenerate range (`max <= min`) pins to `min`.
|
||||
/// `SetValue` (`0x789930`): clamp **and quantise** `v` ([`slider_set_value`]) and store it;
|
||||
/// returns `Some(value)` when the caller must fire `OnValueChanged`. The fire law is the
|
||||
/// client's, emitted-jcc read in wow-re `slider-mouse-law.md` §6: **no range yet → a complete
|
||||
/// no-op**; **first-ever value → always store and fire**; after that, **fire iff the
|
||||
/// clamped/step-quantised value differs** from the stored one. The change-gate is load-bearing,
|
||||
/// not an optimisation: the reference scrollbar wires `OnValueChanged → SetVerticalScroll →
|
||||
/// scrollbar:SetValue` back on itself, and the gate is what stops that after one hop.
|
||||
///
|
||||
/// **The quantiser is inside the gate, not after it**, and that is what makes a stepped
|
||||
/// slider's drag cheap as well as faithful: a thumb moved a pixel within one step's band
|
||||
/// resolves to the same lattice point and fires nothing at all, exactly as the reference's
|
||||
/// `fcom` against `[+0x320]` decides.
|
||||
///
|
||||
/// The client holds the range as `min` + `span`; we hold `min` + `max`, so the span is derived
|
||||
/// here the way `0x7898f0` computes it (`end − start`, at f64 and narrowed). That keeps
|
||||
/// `GetMinMaxValues` answering the exact pair that was set — the reference's own `max` is
|
||||
/// re-derived as `span + min` and can differ from it in the last ulp — at the cost of the
|
||||
/// clamp bound being that same re-derivation. A degenerate range (`max < min`) pins to the
|
||||
/// bound, which is the client's arm, not to `min`.
|
||||
pub fn store_value(&mut self, v: f32) -> Option<f32> {
|
||||
if !self.range_valid {
|
||||
return None;
|
||||
}
|
||||
let clamped = v.clamp(self.min, self.max.max(self.min));
|
||||
let span = (f64::from(self.max) - f64::from(self.min)) as f32;
|
||||
let settled = slider_set_value(v, self.min, span, self.step);
|
||||
let first = !self.has_value;
|
||||
self.has_value = true;
|
||||
(first || clamped != self.value).then(|| {
|
||||
self.value = clamped;
|
||||
clamped
|
||||
(first || settled != self.value).then(|| {
|
||||
self.value = settled;
|
||||
settled
|
||||
})
|
||||
}
|
||||
|
||||
/// **`SetValueStep` (`0x789a60`) — which is not a field write** (decision 2143).
|
||||
///
|
||||
/// The byte read, VERIFIED (wow-re `slider-mouse-law.md` §10, a scoped round dispatched from
|
||||
/// this repo, cross-derived twice and pinned by an executed probe on the binary's own bytes):
|
||||
///
|
||||
/// 1. `0x789a66` stores the step — **unconditional**.
|
||||
/// 2. `0x789a6c test byte [+0x314],2` — **no range yet ⇒ return, and nothing else happens.**
|
||||
/// 3. Otherwise `0x789a91 call 0x7898f0(min, f32(span + min))` — [`Self::set_min_max`] with the
|
||||
/// max **re-derived**, which re-clamps the held value through `SetValue` when one exists,
|
||||
/// re-quantising it onto the NEW lattice and firing `OnValueChanged` if it moved.
|
||||
///
|
||||
/// So a step handed to a slider that already holds a value can move that value and fire — and,
|
||||
/// because `span′ = f32(f32(span + min) − min)` is **not the identity**, it can also change the
|
||||
/// range (`min = 1.0`, `span = 1e-8` collapses it to zero). Ours stored the step and stopped,
|
||||
/// which diverged on all three counts and on the next `GetMinMaxValues` besides.
|
||||
///
|
||||
/// Inert for every caller the reference ships, and **not for the reason decision 2133 gave**
|
||||
/// (that they set the range and the step before any value exists — true only of each site's
|
||||
/// first run; `FauxScrollFrame_Update` re-runs on a live bar from fourteen update functions and
|
||||
/// never calls `SetValue` at all). It is inert because the only writer of the value is
|
||||
/// `SetValue`, which quantises on the way in, so the value is always already on the lattice
|
||||
/// being re-imposed.
|
||||
pub fn set_value_step(&mut self, step: f32) -> Option<f32> {
|
||||
self.step = step;
|
||||
if !self.range_valid {
|
||||
return None;
|
||||
}
|
||||
let span = (f64::from(self.max) - f64::from(self.min)) as f32;
|
||||
let max = (f64::from(span) + f64::from(self.min)) as f32;
|
||||
self.set_min_max(self.min, max)
|
||||
}
|
||||
|
||||
/// `SetMinMaxValues` (`0x7898f0`): set the range, mark it valid, and re-clamp the held value
|
||||
/// **only if one exists** (bit2) — a fresh slider's zero-init value is not a value, so a range
|
||||
/// that excludes zero fires nothing. Returns what [`Self::store_value`] would for the re-clamp.
|
||||
|
||||
@@ -173,10 +173,10 @@ impl<T> Arena<T> {
|
||||
|
||||
mod kinds;
|
||||
pub use kinds::{
|
||||
model_key, slider_fraction, slider_grab, ArmedSequence, ButtonFont, ButtonState,
|
||||
ButtonVisualState, ColorSelectState, EditAction, EditBoxState, EditOutcome, EditUnit,
|
||||
FrameKind, InsertMode, KindState, MessageFrameState, MessageLine, MinimapState, ModelFileFacts,
|
||||
ModelFog, ModelLight, ModelPlayHead, ModelState, RegionKind, ScrollFrameState,
|
||||
model_key, slider_fraction, slider_grab, slider_set_value, ArmedSequence, ButtonFont,
|
||||
ButtonState, ButtonVisualState, ColorSelectState, EditAction, EditBoxState, EditOutcome,
|
||||
EditUnit, FrameKind, InsertMode, KindState, MessageFrameState, MessageLine, MinimapState,
|
||||
ModelFileFacts, ModelFog, ModelLight, ModelPlayHead, ModelState, RegionKind, ScrollFrameState,
|
||||
ScrollingMessageState, SequenceFacts, SliderState, StatusBarState, TooltipState,
|
||||
MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL,
|
||||
MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP,
|
||||
|
||||
Reference in New Issue
Block a user