the cooldown pie survives a /reload, and Bagnon's bags keep their order
- the cooldown pie, the GCD sweep, minimap pings and the map arrow draw again after a logout, login or /reload; they went blank until a restart, which read as OmniCC killing the cooldowns (2113) - an addon's own font on a button label keeps its face, size and outline instead of being undone every frame, MikScrollingBattleText's reported look (2112) - an empty label reads as nothing rather than an empty string, so Cartographer's map label keeps its colour; a slider thumb with no authored size takes its art's own size, so Dewdrop's Look'n'Feel slider moves (2109, 2110) - Bagnon's saved bag list comes back in the order it was written, so the keyring stops jumping to the front after a restart; hovering a Bagnon bag costs a third of what it did (2111, 2114) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -476,14 +476,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
m.set(
|
||||
"SetText",
|
||||
lua.create_function(|lua, (this, text): (Table, Option<String>)| {
|
||||
// `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
|
||||
// BUTTON's own: a `FontString:SetText(nil)` is not a no-op, it truncates the cell
|
||||
// (`0x771d80`, decision 2110). Below the guard the button is a pass-through to
|
||||
// `[button+0x338]->0x771d80`.
|
||||
let Some(text) = text else { return Ok(()) };
|
||||
let id = ensure_slot(lua, &this, Slot::Text)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let rh = *model.id_to_region.get(&id).expect("text region id");
|
||||
model.region_data.entry(rh).or_default().text = text;
|
||||
model.region_data.entry(rh).or_default().text = Some(text);
|
||||
model.touch_measure(rh);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
// GetText — like the FontString's own (`region::text`, decision 2110), an **empty label reads
|
||||
// back nil**: `Button:GetText 0x780e10` carries the same first-byte substitution at `0x780ec5`,
|
||||
// over three nil conditions rather than two (no label region, a NULL cell, an empty cell).
|
||||
// Its EditBox neighbour deliberately does not — the law is per binding.
|
||||
m.set(
|
||||
"GetText",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
@@ -492,6 +503,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
rh.and_then(|rh| model.region_data.get(&rh))
|
||||
.and_then(|d| d.text.clone())
|
||||
.filter(|t| !t.is_empty())
|
||||
};
|
||||
match text {
|
||||
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
|
||||
|
||||
@@ -178,7 +178,11 @@ impl UiScript {
|
||||
owner_frame.map(|f| &f.kind_state)
|
||||
{
|
||||
if sl.thumb == Some(rh) {
|
||||
let tsize = model.region_data.get(&rh).and_then(|d| d.size);
|
||||
// The thumb's OWN size getters, not its authored `<Size>` — the same
|
||||
// `CSimpleTexture::GetWidth`/`GetHeight` fallback the drag law reads
|
||||
// (`slider::thumb_extent`), so a Lua-built `SetThumbTexture(path)` knob
|
||||
// draws at its art's texel span instead of smeared over the whole track.
|
||||
let tsize = super::region::virtual_span(&model, rh);
|
||||
rect = rect
|
||||
.map(|r| slider::thumb_rect(r, tsize, sl.vertical, sl.fraction()));
|
||||
thumb_fill = true;
|
||||
@@ -333,18 +337,34 @@ impl UiScript {
|
||||
let alpha = owner_frame.map(|f| f.effective_alpha).unwrap_or(1.0)
|
||||
* data.alpha.unwrap_or(1.0);
|
||||
if let Some(fo) = state_font {
|
||||
// The font object's paint wholesale, except a color the Lua explicitly
|
||||
// SetTextColor'd (the client's explicitly-set mask keeps it — ui ledger
|
||||
// FONTINSTANCE+0x38, color bit 0x404).
|
||||
data.font_path = fo.font.clone().or(data.font_path);
|
||||
data.font_height = fo.height.or(data.font_height);
|
||||
// The font object's paint, **behind the severance mask on every axis** —
|
||||
// the same `font_explicit` gate `font::repaint` applies and the
|
||||
// `button_font` block below names as the law ("it loses to a face the
|
||||
// label FontString set for *itself*, which severs one level further
|
||||
// down"). Three of these six axes did not have it: face and height were
|
||||
// `fo.x.or(data.x)`, which makes the OBJECT outrank an explicit
|
||||
// `SetFont`, and the outline was written unconditionally — so a label
|
||||
// that called `SetFont(path, h, "OUTLINE")` for itself had all three put
|
||||
// back from the object on the very next extract, every frame, forever.
|
||||
// That is the *shape* of the report this was found under (decision 2112:
|
||||
// an addon's `SetFont` silently not taking); MSBT's own strings are not
|
||||
// a button's label and never met it, but any addon that restyles a
|
||||
// `<ButtonText>` did.
|
||||
if !data.font_explicit.face {
|
||||
data.font_path = fo.font.clone().or(data.font_path);
|
||||
}
|
||||
if !data.font_explicit.height {
|
||||
data.font_height = fo.height.or(data.font_height);
|
||||
}
|
||||
// Same severance as the colour below: a region that called
|
||||
// `SetShadowColor`/`SetShadowOffset` keeps its own, or the font object it
|
||||
// inherits would silently overwrite the value the addon just set.
|
||||
if !data.font_explicit.shadow {
|
||||
data.font_shadow = fo.shadow.or(data.font_shadow);
|
||||
}
|
||||
data.outline = fo.outline;
|
||||
if !data.font_explicit.outline {
|
||||
data.outline = fo.outline;
|
||||
}
|
||||
// Test the severance MASK, which is what the sentence above claims and what
|
||||
// wow-re pinned, not `vertex_color.is_none()`. The nil-check was an
|
||||
// equivalent proxy for exactly as long as an explicit `SetTextColor` was the
|
||||
|
||||
@@ -597,4 +597,68 @@ mod tests {
|
||||
"5.0's insert writes index getn+1, which after setn(t, 0) is index 1"
|
||||
);
|
||||
}
|
||||
|
||||
/// **`{ [1] = a, [2] = b, … }` is a LIST, and `pairs` walks it 1, 2, 3** — decision 2111, the
|
||||
/// fourth "restore what 5.1 changed" hunk in `third_party/lua-src`.
|
||||
///
|
||||
/// 1.12's `recfield` keeps `cc->nh++` **inside** its `TK_NAME` arm (`0x6fd5a4`'s
|
||||
/// `cmp [edi+0x10],0x116` / `0x6fd5ad jne 0x6fd5d4` — 5.0's own placement), so a
|
||||
/// `[expr] = value` field credits **neither** `OP_NEWTABLE` size hint. The table is born with
|
||||
/// `B = C = 0`, i.e. on the shared dummy node, so the first store finds no free node, takes
|
||||
/// `luaH_newkey`'s rehash tail, and `rehash` sizes an **array part** for the dense integer
|
||||
/// keys; `luaH_next` walks the array part first, ascending by index, and the node part after.
|
||||
/// Stock 5.1 moved that `cc->nh++` out of the arm, pre-sized a node vector big enough that no
|
||||
/// rehash ever fires, and left all n keys in the hash — where `next` is slot order.
|
||||
///
|
||||
/// wow-5875-re `system/ui/scratch/lua-table-storage-and-next-order.md` — **executed** on
|
||||
/// `WoW.exe`'s own bytes (`lua_open` → `luaL_loadbuffer` → `lua_pcall` → `lua_next`), not
|
||||
/// derived, including the exact `Bagnon_Core.lua` below; ascending holds for n = 1..24 and
|
||||
/// regardless of the order the fields are written in.
|
||||
///
|
||||
/// It matters because every saved-variables file we write is that constructor (decision 1128's
|
||||
/// grammar, and the reference's own writer's — it never emits a bare positional entry), so
|
||||
/// before this hunk every list an addon saved came back in hash order. Bagnon 6.10.22 walks
|
||||
/// its saved bag order with `pairs` and drew the keyring first, ahead of the backpack.
|
||||
#[test]
|
||||
fn a_bracketed_key_constructor_is_an_array_and_walks_in_index_order() {
|
||||
let s = UiScript::new().unwrap();
|
||||
// Bagnon's own table, exactly as its saved-variables file spells it.
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"local t = { [1] = 0, [2] = 1, [3] = 2, [4] = 3, [5] = 4, [6] = -2 }
|
||||
local out = '' for _, v in pairs(t) do out = out .. v .. ',' end return out"
|
||||
)
|
||||
.unwrap(),
|
||||
"0,1,2,3,4,-2,",
|
||||
"the keyring (-2) is LAST — this is the director's Bagnon grid"
|
||||
);
|
||||
// …and the order the fields are written in does not change it.
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"local t = { [3] = 'c', [1] = 'a', [2] = 'b' }
|
||||
local out = '' for k in pairs(t) do out = out .. k .. ',' end return out"
|
||||
)
|
||||
.unwrap(),
|
||||
"1,2,3,"
|
||||
);
|
||||
// A key the array part cannot hold comes after the run, in hash order.
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"local t = { ['a'] = 1, [1] = 10, [2] = 20, [3] = 30 }
|
||||
local out = '' for k in pairs(t) do out = out .. tostring(k) .. ',' end return out"
|
||||
)
|
||||
.unwrap(),
|
||||
"1,2,3,a,"
|
||||
);
|
||||
// The positional spelling was always an array and still is — the control that keeps the
|
||||
// first assertion from reading as vacuous.
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"local t = { 0, 1, 2, 3, 4, -2 }
|
||||
local out = '' for _, v in pairs(t) do out = out .. v .. ',' end return out"
|
||||
)
|
||||
.unwrap(),
|
||||
"0,1,2,3,4,-2,"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,15 +60,24 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// keeps an idempotent per-frame caller (the classic OnUpdate re-SetPoint idiom) from
|
||||
// pinning the gate open — the same absorption the fingerprint gives, paid once at
|
||||
// the write instead of per-frame over the whole model.
|
||||
let changed = match model.layout_inputs.get_mut(&h) {
|
||||
//
|
||||
// **And it NAMES its node** (decision 2114, completing 1625's migration). Dropping
|
||||
// every anchor is a retarget whose NEW target list is empty, and both lists are right
|
||||
// here — so the cached graph's edges get unlinked instead of thrown away. Left on the
|
||||
// conservative touch, this was the one recurring `[layout-derive]` site in a live
|
||||
// hover sweep: `Bagnon_AnchorTooltip` opens with `GameTooltip:ClearAllPoints()` and
|
||||
// then asks `frame:GetLeft()`, so the whole graph was re-derived INSIDE the handler,
|
||||
// once per hovered item, and billed to `[ui-handlers]`' `OnEnter`.
|
||||
let old: Option<Vec<u32>> = match model.layout_inputs.get_mut(&h) {
|
||||
Some(input) if !input.anchors.is_empty() => {
|
||||
let old = input.anchors.iter().map(|a| a.relative_to).collect();
|
||||
input.anchors.clear();
|
||||
true
|
||||
Some(old)
|
||||
}
|
||||
_ => false,
|
||||
_ => None,
|
||||
};
|
||||
if changed {
|
||||
model.touch_layout();
|
||||
if let Some(old) = old {
|
||||
model.touch_layout_retarget_frame(h, &old, &[]);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
|
||||
@@ -136,10 +136,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
let changed = !d.anchors.is_empty();
|
||||
// Names its node, the frame twin's rule and for the frame twin's reason (decision
|
||||
// 2114): clearing every anchor is a retarget onto an EMPTY target list, so the cached
|
||||
// graph's edges are unlinked rather than the whole graph re-derived.
|
||||
let old: Option<Vec<u32>> =
|
||||
(!d.anchors.is_empty()).then(|| d.anchors.iter().map(|a| a.relative_to).collect());
|
||||
d.anchors.clear();
|
||||
if changed {
|
||||
model.touch_layout();
|
||||
if let Some(old) = old {
|
||||
model.touch_layout_retarget_region(rh, &old, &[]);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
|
||||
@@ -61,13 +61,47 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetText — **an EMPTY string comes back as `nil`, and that substitution is the getter's own**
|
||||
// (`FontString:GetText 0x79d690`, wow-re
|
||||
// `system/ui/scratch/fontstring-text-cell-and-gettext-contract.md`, §5 five-worker round):
|
||||
//
|
||||
// ```text
|
||||
// 79d72f mov eax,[eax+0xf0] ; the text cell
|
||||
// 79d735 test eax,eax
|
||||
// 79d739 je 0x79d740 ; NULL -> substitute
|
||||
// 79d73b cmp byte ptr [eax],0x0 ; the FIRST-BYTE test
|
||||
// 79d73e jne 0x79d742 ; non-empty -> keep
|
||||
// 79d740 xor eax,eax ; EMPTY -> NULL
|
||||
// 79d746 call 0x6f3890 ; pushstring; NULL -> pushnil
|
||||
// ```
|
||||
//
|
||||
// So `FontString:GetText` **cannot return `""`**. Not the setter's doing: `SetText 0x771d80`
|
||||
// never writes NULL to `+0xf0` on any leg — NULL and `""` share one leg that truncates the
|
||||
// buffer in place (`0x771e7e mov byte ptr [eax],0x0`, the pointer surviving) — so the cell
|
||||
// really does hold a non-NULL empty string and this getter is what collapses it.
|
||||
//
|
||||
// The law is **per binding, not per family**, and the two neighbours differ:
|
||||
// `Button:GetText 0x780e10` carries the same substitution (`0x780ec5`, three nil conditions —
|
||||
// see `button.rs`), while `EditBox:GetText 0x7985c0` carries **none** (`0x79867b` reads
|
||||
// `[edit+0x32c]` straight through), which is what stock `MailFrame.lua`'s
|
||||
// `GetText() == ""`/`strlen(GetText())` is written against. Do not hoist this.
|
||||
//
|
||||
// What it costs to get wrong (decision 2110): the stock world map blanks
|
||||
// `WorldMapFrameAreaDescription` with `SetText("")` on every POI hover, and Cartographer 2.02
|
||||
// reads `if WorldMapFrameAreaDescription:GetText() then` as "this POI has a status line" —
|
||||
// answering `""` there put the zone's level range on the description's own line under a
|
||||
// whitened label and left it on screen forever.
|
||||
m.set(
|
||||
"GetText",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let text = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
model.region_data.get(&rh).and_then(|d| d.text.clone())
|
||||
model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.and_then(|d| d.text.clone())
|
||||
.filter(|t| !t.is_empty())
|
||||
};
|
||||
match text {
|
||||
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
|
||||
|
||||
@@ -146,9 +146,18 @@ fn quote(s: &str) -> String {
|
||||
}
|
||||
|
||||
/// A table constructor: `{\n<tabs>[key] = value,\n<tabs-1>}`. Keys are always bracketed (the
|
||||
/// reference's shape, and it sidesteps every reserved-word and non-identifier question); entries
|
||||
/// are **sorted** — integer keys ascending, then strings alphabetically — so the file is stable
|
||||
/// across runs instead of following Lua's hash order.
|
||||
/// reference's shape — its writer emits `[key] = value` for **every** table shape and never a bare
|
||||
/// positional entry, byte-verified in wow-re `system/ui/scratch/lua-table-storage-and-next-order.md`
|
||||
/// §Q5 — and it sidesteps every reserved-word and non-identifier question); entries are **sorted**
|
||||
/// — integer keys ascending, then strings alphabetically — so the file is stable across runs
|
||||
/// instead of following Lua's hash order.
|
||||
///
|
||||
/// **A list written this way still reloads as a list**, and that is a property of the *parser*, not
|
||||
/// of this emitter: 1.12's `recfield` gives a `[expr] = v` field neither size hint, so the table is
|
||||
/// born on the dummy node, the first store rehashes, and the dense integer keys land in an array
|
||||
/// part that `next` walks ascending. Our vendored parser was restored to that placement in decision
|
||||
/// 2111 — before it, this exact file shape came back keyed 1..n in the *hash* part and Bagnon's
|
||||
/// keyring drew first.
|
||||
fn table(t: &Table, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<String> {
|
||||
if depth > MAX_DEPTH || !seen.insert(t.to_pointer()) {
|
||||
return None;
|
||||
@@ -211,6 +220,50 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
mod tests {
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// **A saved LIST comes back a list** — Bagnon's inventory grid, at 1:1 scale, and the
|
||||
/// round-trip half of decision 2111 (the dialect half is `lua50`'s own tests).
|
||||
///
|
||||
/// `Bagnon_Core` keeps its frame's bag order in a saved table, sorts it so the keyring
|
||||
/// (`KEYRING_CONTAINER` = -2) lands last, and then lays the grid out by walking it with
|
||||
/// **`pairs`** (`BagnonFrame_Generate`'s `for _, bagID in pairs(BagnonSets[frameName].bags)`).
|
||||
/// Session one has no file: the table is the literal `{-2,0,1,2,3,4}`, an array table, and
|
||||
/// `pairs` walks it 1..6 — keyring last, as the reference shows it. Session two reads the
|
||||
/// serializer's `[k] = v` form back, and before 2111 that came back keyed 1..6 in the **hash**
|
||||
/// part, walked in slot order, with key 6 — the keyring — **first**, ahead of the backpack.
|
||||
/// That is the director's report; the writer is unchanged and the parser is what was wrong.
|
||||
#[test]
|
||||
fn a_saved_list_still_walks_in_index_order_after_a_restart() {
|
||||
let s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
BAGS = { -2, 0, 1, 2, 3, 4 }
|
||||
table.sort(BAGS, function(a, b)
|
||||
if a == -2 then return false
|
||||
elseif b == -2 then return true
|
||||
else return a < b end
|
||||
end)
|
||||
RegisterForSave("BAGS")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let walk = |vm: &UiScript| {
|
||||
vm.eval::<String>(
|
||||
"local out = '' for _, v in pairs(BAGS) do out = out .. v .. ',' end return out",
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(walk(&s), "0,1,2,3,4,-2,", "the live table, before any save");
|
||||
|
||||
let text = s.saved_variables_text();
|
||||
let fresh = UiScript::new().unwrap();
|
||||
fresh.run(&text).unwrap();
|
||||
assert_eq!(
|
||||
walk(&fresh),
|
||||
"0,1,2,3,4,-2,",
|
||||
"the restart must walk the same order the live session did; wrote:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The round trip that is the whole point: what the writer emits, the loader's own chunk
|
||||
/// restores — through a fresh VM, exactly as a restart does.
|
||||
#[test]
|
||||
|
||||
@@ -282,16 +282,14 @@ fn num_f32(v: &Value) -> f32 {
|
||||
/// track and centered on the cross-axis. The thumb stays **inset** within the track (travel =
|
||||
/// trackLen − thumbLen), the way WoW scrollbar knobs visibly do. VERTICAL runs y-up-inverted —
|
||||
/// `fraction` 0 (value=min) sits the thumb flush at the **top** (a scrollbar at 0 is scrolled to the
|
||||
/// top), `fraction` 1 flush at the bottom. A thumb with no explicit size fills the slider (zero
|
||||
/// travel). This is the single geometry source shared by `extract` (render) and [`begin_drag`]
|
||||
/// (hit-test), so render and input never disagree. Returns a [`Rect`] as `(bottom, left, top, right)`.
|
||||
pub(super) fn thumb_rect(
|
||||
r: Rect,
|
||||
thumb_size: Option<(f32, f32)>,
|
||||
vertical: bool,
|
||||
fraction: f32,
|
||||
) -> Rect {
|
||||
let (tw, th) = thumb_size.unwrap_or((r.width(), r.height()));
|
||||
/// top), `fraction` 1 flush at the bottom. This is the single geometry source shared by `extract`
|
||||
/// (render) and [`begin_drag`] (hit-test), so render and input never disagree. Returns a [`Rect`] as
|
||||
/// `(bottom, left, top, right)`.
|
||||
///
|
||||
/// `thumb_size` is [`thumb_extent`]'s — the thumb region's OWN size getters, never its authored
|
||||
/// `<Size>` alone.
|
||||
pub(super) fn thumb_rect(r: Rect, thumb_size: (f32, f32), vertical: bool, fraction: f32) -> Rect {
|
||||
let (tw, th) = thumb_size;
|
||||
if vertical {
|
||||
let travel = (r.height() - th).max(0.0);
|
||||
let top = r.top - fraction * travel; // f=0 → track top, f=1 → track bottom
|
||||
@@ -305,6 +303,26 @@ pub(super) fn thumb_rect(
|
||||
}
|
||||
}
|
||||
|
||||
/// The thumb's extent along both axes — **the thumb region's own `GetWidth`/`GetHeight`**, which
|
||||
/// is what the client asks for and is not the same thing as its authored `<Size>`.
|
||||
///
|
||||
/// `CSimpleSlider`'s pixel→value law reads the thumb through its geometry vtable
|
||||
/// (`0x789ba0`: `ff 50 1c call [eax+0x1c]` for the horizontal branch, `+0x20` for the vertical), and
|
||||
/// on a `CSimpleTexture` those slots are `0x770720`/`0x770790` — the **native-texel fallback**:
|
||||
/// authored span when it is non-zero on that axis, else the art's own texel span through the same
|
||||
/// `<AbsDimension>` converter, else `0.0` when there is no art at all (wow-re
|
||||
/// `region-size-fallback.md` §2, VERIFIED; ours is [`super::region::virtual_span`], decision 1349).
|
||||
///
|
||||
/// Reading `RegionData::size` instead is what broke every Lua-built slider: `SetThumbTexture(path)`
|
||||
/// authors no size, all four stock `<ThumbTexture>`s declare one, and the old fallback — *the thumb
|
||||
/// fills the track* — is not a client behaviour at all. It smeared the knob over the whole bar and
|
||||
/// left `trackLen − thumbLen == 0`, i.e. a slider that cannot move (Dewdrop-2.0's popout, B-report).
|
||||
/// A thumb with **no region at all** is `None`: `0x789ba0` gates the value math on `+0x328` being
|
||||
/// non-null, so a thumbless slider takes the press and the capture but never warps.
|
||||
fn thumb_extent(model: &Model, thumb: Option<crate::widget::RegionHandle>) -> Option<(f32, f32)> {
|
||||
Some(super::region::virtual_span(model, thumb?))
|
||||
}
|
||||
|
||||
/// The in-flight thumb drag (decision 0250 §5): the slider being dragged + the grab offset
|
||||
/// [`slider_grab`] returned, so the thumb tracks the cursor without jumping. Engine C++-equivalent
|
||||
/// — no Lua, like the real client's scrollbar.
|
||||
@@ -340,22 +358,28 @@ pub(super) fn begin_drag(
|
||||
if !enabled {
|
||||
return None;
|
||||
}
|
||||
let size = thumb
|
||||
.and_then(|rh| model.region_data.get(&rh))
|
||||
.and_then(|d| d.size);
|
||||
// No thumb region → `0x789ba0`'s `+0x328` gate: the press still captures (the dispatcher's
|
||||
// `mov [ebx+0x80],esi` is unconditional), the value never moves.
|
||||
let Some(size) = thumb_extent(model, thumb) else {
|
||||
model.slider_drag = Some(SliderDrag {
|
||||
slider: h,
|
||||
grab_offset: 0.0,
|
||||
});
|
||||
return None;
|
||||
};
|
||||
let trect = thumb_rect(r, size, vertical, fraction);
|
||||
let on_thumb = point_in_rect(trect, x, y);
|
||||
// Everything below is stated as distance from the TRACK'S LEADING EDGE — the end the thumb
|
||||
// sits at when the value is `min`. Vertical tracks run downward from `r.top` in this y-up
|
||||
// arena, so that distance is `top − y`; horizontal ones run rightward from `r.left`.
|
||||
let (cursor, thumb_lead, thumb_extent) = if vertical {
|
||||
let (cursor, thumb_lead, thumb_len) = if vertical {
|
||||
(r.top - y, r.top - trect.top, trect.top - trect.bottom)
|
||||
} else {
|
||||
(x - r.left, trect.left - r.left, trect.right - trect.left)
|
||||
};
|
||||
model.slider_drag = Some(SliderDrag {
|
||||
slider: h,
|
||||
grab_offset: slider_grab(cursor, thumb_lead, thumb_extent),
|
||||
grab_offset: slider_grab(cursor, thumb_lead, thumb_len),
|
||||
});
|
||||
if on_thumb {
|
||||
return None; // no value change from the grab itself
|
||||
@@ -378,18 +402,15 @@ pub(super) fn drag_move(model: &mut Model, x: f32, y: f32) -> Option<(u32, f32)>
|
||||
Some(KindState::Slider(s)) => (s.min, s.max, s.vertical, s.thumb),
|
||||
_ => return None, // slider destroyed mid-drag
|
||||
};
|
||||
let (tw, th) = thumb
|
||||
.and_then(|rh| model.region_data.get(&rh))
|
||||
.and_then(|d| d.size)
|
||||
.unwrap_or((r.width(), r.height()));
|
||||
let (tw, th) = thumb_extent(model, thumb)?;
|
||||
// The same leading-edge frame [`begin_drag`] stored the grab in.
|
||||
let (cursor, track_extent, thumb_extent) = if vertical {
|
||||
let (cursor, track_extent, thumb_len) = if vertical {
|
||||
(r.top - y, r.height(), th)
|
||||
} else {
|
||||
(x - r.left, r.width(), tw)
|
||||
};
|
||||
// `None` = nothing to scroll (a thumb as long as its track).
|
||||
let fraction = slider_fraction(cursor, grab_offset, track_extent, thumb_extent)?;
|
||||
let fraction = slider_fraction(cursor, grab_offset, track_extent, thumb_len)?;
|
||||
let value = min + fraction * (max - min);
|
||||
let changed = match model.arena.frame_mut(slider).map(|f| &mut f.kind_state) {
|
||||
Some(KindState::Slider(s)) => s.store_value(value),
|
||||
|
||||
@@ -1073,3 +1073,96 @@ fn a_lazily_made_label_is_anchored_by_the_normal_fonts_justify() {
|
||||
s.run("left:SetTextFontObject(ProbeFontRight)").unwrap();
|
||||
assert_eq!(point(&s, "left"), ("LEFT".into(), "LEFT".into(), 0.0, 0.0));
|
||||
}
|
||||
|
||||
/// **A label that set its own face keeps it — the severance mask covers every axis, not three of
|
||||
/// six** (decision 2112).
|
||||
///
|
||||
/// `font_explicit` is the client's explicitly-set mask (`FONTINSTANCE+0x38`): an axis a widget
|
||||
/// writes for *itself* severs inheritance from the font instance it reads, and is never restored
|
||||
/// (wow-re `font-object-lua-surface.md`; the `button_font` block in `script::extract` cites it by
|
||||
/// name). `font::repaint` honours it on all seven axes. The extract's per-state re-point honoured
|
||||
/// it on shadow, colour and both justifies — and not on **face, height or outline**: the face and
|
||||
/// height read `fo.x.or(data.x)`, which makes the object outrank an explicit `SetFont`, and the
|
||||
/// outline was written unconditionally. A `<ButtonText>` that called
|
||||
/// `SetFont(path, h, "OUTLINE")` for itself therefore had all three silently put back from the
|
||||
/// button's font object on the very next extract — every frame, so no Lua could win the race.
|
||||
#[test]
|
||||
fn a_button_labels_own_setfont_survives_the_state_font_repoint() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.register_font_object(
|
||||
"TemplateFont",
|
||||
FontObject {
|
||||
font: Some("Fonts\\FRIZQT__.TTF".into()),
|
||||
height: Some(12.0),
|
||||
color: Some([1.0, 0.82, 0.0, 1.0]),
|
||||
outline: Outline::None,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
b = CreateFrame("Button", "SkinnedBtn")
|
||||
b:SetPoint("CENTER", 0, 0); b:SetSize(100, 20)
|
||||
b:SetText("Label")
|
||||
b:SetTextFontObject("TemplateFont")
|
||||
b:GetFontString():SetFont("Interface\\Addons\\Skin\\Fonts\\porky.ttf", 18, "OUTLINE")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let painted = |s: &mut crate::script::UiScript| {
|
||||
s.resolve();
|
||||
s.extract()
|
||||
.into_iter()
|
||||
.find_map(|q| match q.content {
|
||||
QuadContent::Text {
|
||||
text: Some(t),
|
||||
ref font,
|
||||
font_height,
|
||||
outline,
|
||||
..
|
||||
} if t == "Label" => Some((font.clone(), font_height, outline)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("label text quad")
|
||||
};
|
||||
assert_eq!(
|
||||
painted(&mut s),
|
||||
(
|
||||
Some("Interface\\Addons\\Skin\\Fonts\\porky.ttf".to_string()),
|
||||
Some(18.0),
|
||||
Outline::Normal
|
||||
),
|
||||
"the label's own SetFont severs face, height AND outline from the state font object"
|
||||
);
|
||||
// …and it survives a state change, which is what re-runs the re-point.
|
||||
s.run("b:Disable()").unwrap();
|
||||
assert_eq!(
|
||||
painted(&mut s),
|
||||
(
|
||||
Some("Interface\\Addons\\Skin\\Fonts\\porky.ttf".to_string()),
|
||||
Some(18.0),
|
||||
Outline::Normal
|
||||
),
|
||||
"…and a disable re-points the instance without restoring what the label severed"
|
||||
);
|
||||
// The axes the label did NOT set still follow the object: the colour is the template's gold.
|
||||
s.resolve();
|
||||
let color = s
|
||||
.extract()
|
||||
.into_iter()
|
||||
.find_map(|q| match q.content {
|
||||
QuadContent::Text {
|
||||
text: Some(t),
|
||||
color,
|
||||
..
|
||||
} if t == "Label" => Some(color),
|
||||
_ => None,
|
||||
})
|
||||
.expect("label text quad");
|
||||
assert_eq!(
|
||||
color,
|
||||
Some([1.0, 0.82, 0.0, 1.0]),
|
||||
"an axis the label never set still inherits — severance is per-axis"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1081,3 +1081,96 @@ fn set_font_answers_the_hosts_load_verdict_when_there_is_a_host() {
|
||||
);
|
||||
assert_eq!(height, 18.0, "…while the height, which never fails, is set");
|
||||
}
|
||||
|
||||
/// **Mik's Scrolling Battle Text, end to end** — the exact sequence the addon runs per event,
|
||||
/// pinned because it is the shape a whole class of "the addon's font did not take" reports wears
|
||||
/// (decisions 2103, 2112).
|
||||
///
|
||||
/// MSBT's twenty scroll-area FontStrings are declared `inherits="MasterFont"` — the reference's
|
||||
/// root font object, which carries a `<Shadow>` and **nothing else**: no face, no height, no
|
||||
/// outline. Each animation then does, in this order, on the string it recycles:
|
||||
/// `ClearAllPoints · SetFont(<its own TTF>, 18, "OUTLINE") · SetTextColor · SetText · SetAlpha ·
|
||||
/// SetPoint`. Every one of those five paint axes has to reach the extracted quad *unmodified* by
|
||||
/// the object the string inherits — the addon's face over the object's absent one, the addon's
|
||||
/// height over the renderer default, the addon's outline over the object's `NONE` (which is
|
||||
/// indistinguishable from "unset", see [`Outline`]), its colour over the object's absent one, and
|
||||
/// the animation's fade as the quad's own alpha. The `<Shadow>` is the one thing that IS inherited,
|
||||
/// and it must still be there.
|
||||
#[test]
|
||||
fn msbt_paints_its_own_face_size_outline_and_fade_over_the_font_object_it_inherits() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
load(
|
||||
&s,
|
||||
r#"<Ui>
|
||||
<Font name="MasterFont" virtual="true">
|
||||
<Shadow><Offset><AbsDimension x="1" y="-1"/></Offset><Color r="0" g="0" b="0"/></Shadow>
|
||||
</Font>
|
||||
<Frame name="MSBTFrameIncoming" parent="UIParent">
|
||||
<Size><AbsDimension x="24" y="24"/></Size>
|
||||
<Anchors><Anchor point="BOTTOM" relativePoint="CENTER" relativeTo="UIParent"/></Anchors>
|
||||
<Layers><Layer level="ARTWORK">
|
||||
<FontString name="$parentText1" inherits="MasterFont">
|
||||
<Anchors><Anchor point="BOTTOMRIGHT"/></Anchors>
|
||||
</FontString>
|
||||
</Layer></Layers>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
local fs = getglobal("MSBTFrameIncomingText1")
|
||||
fs:ClearAllPoints()
|
||||
fs:SetFont("Interface\\Addons\\MikScrollingBattleText\\Fonts\\porky.ttf", 18, "OUTLINE")
|
||||
fs:SetTextColor(1, 1, 1)
|
||||
fs:SetText("-64")
|
||||
fs:SetAlpha(0.5)
|
||||
fs:SetPoint("BOTTOMRIGHT", 0, 0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let q = s
|
||||
.extract()
|
||||
.into_iter()
|
||||
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "-64"))
|
||||
.expect("MSBT's text quad");
|
||||
assert_eq!(
|
||||
q.alpha, 0.5,
|
||||
"the scroll animation's fade is the quad's alpha"
|
||||
);
|
||||
match q.content {
|
||||
QuadContent::Text {
|
||||
ref font,
|
||||
font_height,
|
||||
outline,
|
||||
color,
|
||||
shadow,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(
|
||||
font.as_deref(),
|
||||
Some("Interface\\Addons\\MikScrollingBattleText\\Fonts\\porky.ttf"),
|
||||
"the addon's own face, not the fallback"
|
||||
);
|
||||
assert_eq!(font_height, Some(18.0), "the profile's master size");
|
||||
assert_eq!(outline, Outline::Normal, "its OUTLINE flag");
|
||||
assert_eq!(color, Some([1.0, 1.0, 1.0, 1.0]));
|
||||
assert!(
|
||||
shadow.is_some(),
|
||||
"MasterFont's <Shadow> is the one axis the string never set, so it inherits"
|
||||
);
|
||||
}
|
||||
ref other => panic!("expected a Text quad, got {other:?}"),
|
||||
}
|
||||
// The Lua-visible echo agrees with the quad — the readback an addon branches on.
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32, String)>("return MSBTFrameIncomingText1:GetFont()")
|
||||
.unwrap(),
|
||||
(
|
||||
"Interface\\Addons\\MikScrollingBattleText\\Fonts\\porky.ttf".to_string(),
|
||||
18.0,
|
||||
"OUTLINE".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1233,3 +1233,103 @@ fn set_portrait_texture_folds_the_token_to_lowercase() {
|
||||
assert_eq!(bound.as_deref(), Some("npc"));
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **`FontString:GetText` substitutes nil for an EMPTY string, and that substitution is the
|
||||
/// getter's own** — `0x79d690` reads the cell, and eleven bytes before the pushstring it does a
|
||||
/// FIRST-BYTE test and zeroes the pointer on `'\0'`:
|
||||
///
|
||||
/// ```text
|
||||
/// 79d735 test eax,eax / je 0x79d740 ; NULL -> substitute
|
||||
/// 79d73b cmp byte ptr [eax],0x0 ; the first-byte test
|
||||
/// 79d73e jne 0x79d742 ; non-empty -> keep
|
||||
/// 79d740 xor eax,eax ; EMPTY -> NULL
|
||||
/// 79d746 call 0x6f3890 ; NULL -> pushnil
|
||||
/// ```
|
||||
///
|
||||
/// So it cannot return `""`, whatever the cell holds — and the cell really does hold non-NULL
|
||||
/// empty strings, because `SetText 0x771d80` never writes NULL to `+0xf0` on any leg: NULL and
|
||||
/// `""` share one leg that truncates the buffer in place. Per binding, not per family:
|
||||
/// `Button:GetText 0x780e10` carries the same substitution (`0x780ec5`), `EditBox:GetText
|
||||
/// 0x7985c0` carries none (wow-re `fontstring-text-cell-and-gettext-contract.md`, a §5 round;
|
||||
/// decision 2110).
|
||||
///
|
||||
/// The director's shape (Cartographer 2.02, the world map's hover label): the stock
|
||||
/// `WorldMapFrameAreaDescription` is blanked with `SetText("")` by `WorldMapPOI_OnEnter`/`_OnLeave`
|
||||
/// on **every** POI hover, and Cartographer's ZoneInfo reads
|
||||
/// `if WorldMapFrameAreaDescription:GetText() then` as "this POI has a status line, so put the
|
||||
/// zone's level range there instead of on the label". Answering `""` made that true forever after
|
||||
/// the first POI touch — the name lost its faction colour, the range moved to the description's own
|
||||
/// line beneath it, and nothing ever cleared it again.
|
||||
#[test]
|
||||
fn an_empty_fontstring_reads_back_nil_and_an_edit_box_does_not() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "TextCell")
|
||||
fresh = f:CreateFontString(nil, "OVERLAY")
|
||||
held = f:CreateFontString(nil, "OVERLAY")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let text_of = |s: &UiScript, which: &str| {
|
||||
s.eval::<Option<String>>(&format!("return {which}:GetText()"))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Never written, and every shape of an empty write, all nil.
|
||||
assert_eq!(text_of(&s, "fresh"), None);
|
||||
for write in [
|
||||
r#"fresh:SetText("")"#,
|
||||
r#"fresh:SetText(nil)"#,
|
||||
r#"fresh:SetFormattedText("%s", "")"#,
|
||||
] {
|
||||
s.run(write).unwrap();
|
||||
assert_eq!(text_of(&s, "fresh"), None, "after `{write}`");
|
||||
}
|
||||
|
||||
// A string that HAS held text still reads nil once blanked — the substitution is the getter's,
|
||||
// so it does not matter that the cell keeps a (truncated) buffer.
|
||||
s.run(r#"held:SetText("In Conflict")"#).unwrap();
|
||||
assert_eq!(text_of(&s, "held").as_deref(), Some("In Conflict"));
|
||||
s.run(r#"held:SetText("")"#).unwrap();
|
||||
assert_eq!(
|
||||
text_of(&s, "held"),
|
||||
None,
|
||||
"a blanked FontString reads back nil, not an empty string"
|
||||
);
|
||||
s.run(r#"held:SetText("back"); held:SetText(nil)"#).unwrap();
|
||||
assert_eq!(text_of(&s, "held"), None);
|
||||
|
||||
// Button:GetText carries the SAME substitution; Button:SetText(nil) is its own no-op guard.
|
||||
s.run(
|
||||
r#"
|
||||
local b = CreateFrame("Button", "TextCellButton")
|
||||
b:SetText("Accept")
|
||||
b:SetText(nil)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
text_of(&s, "TextCellButton").as_deref(),
|
||||
Some("Accept"),
|
||||
"a nil never reaches the button's label (`0x778dcc`)"
|
||||
);
|
||||
s.run(r#"TextCellButton:SetText("")"#).unwrap();
|
||||
assert_eq!(text_of(&s, "TextCellButton"), None, "an empty label is nil");
|
||||
|
||||
// And the EditBox is the counter-example that keeps this from being hoisted: stock
|
||||
// `MailFrame.lua` compares `GetText() == ""` and calls `strlen(GetText())` on one.
|
||||
s.run(
|
||||
r#"
|
||||
local e = CreateFrame("EditBox", "TextCellEdit")
|
||||
e:SetText("")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
text_of(&s, "TextCellEdit").as_deref(),
|
||||
Some(""),
|
||||
"EditBox:GetText 0x7985c0 reads [edit+0x32c] straight through — no substitution"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -375,3 +375,66 @@ fn slider_onload_range_does_not_run_an_unarmed_onvaluechanged() {
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thumb_with_no_authored_size_takes_its_arts_texel_span_and_still_drags() {
|
||||
// Dewdrop-2.0's `OpenSlider` (Cartographer 2.02 → Look 'n' Feel → Overlay transparency), in
|
||||
// its own construction order — the shape that has no `<ThumbTexture><Size>` anywhere:
|
||||
// `SetThumbTexture(path)` from Lua and nothing else. The reference asks the thumb for its own
|
||||
// `GetWidth`/`GetHeight` (`0x789ba0` → `[thumb_vt+0x1c]/[+0x20]` = `CSimpleTexture::0x770720`
|
||||
// /`0x770790`), which falls back to the art's native texel span — 32×32 for
|
||||
// `UI-SliderBar-Button-Vertical` — so the thumb is a 32-unit knob on a 128-unit track with
|
||||
// 96 units of travel. Ours read the *authored* size only and, finding none, sized the thumb to
|
||||
// the whole track: zero travel, a thumb smeared over the bar, and a drag that cannot move.
|
||||
let mut s = script();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.set_texture_size_probe(Box::new(|p| {
|
||||
p.contains("UI-SliderBar-Button-Vertical")
|
||||
.then_some((32, 32))
|
||||
}));
|
||||
s.run(
|
||||
r#"
|
||||
-- The popout is a parentless FULLSCREEN_DIALOG frame that takes the mouse itself, with
|
||||
-- the slider one frame level above it — so this also pins that the press resolves to the
|
||||
-- Slider and not to the mouse-enabled host sitting under it.
|
||||
host = CreateFrame("Frame", "DdHost", nil)
|
||||
host:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
|
||||
host:SetSize(80, 170)
|
||||
host:SetFrameStrata("FULLSCREEN_DIALOG")
|
||||
host:EnableMouse(true)
|
||||
bar = CreateFrame("Slider", "DdSlider", host)
|
||||
bar:SetFrameLevel(host:GetFrameLevel() + 1)
|
||||
bar:SetOrientation("VERTICAL")
|
||||
bar:SetMinMaxValues(0, 1)
|
||||
bar:SetValueStep(0.01)
|
||||
bar:SetValue(0.5)
|
||||
bar:SetWidth(16)
|
||||
bar:SetHeight(128)
|
||||
bar:SetPoint("LEFT", host, "LEFT", 15, 0)
|
||||
bar:SetThumbTexture("Interface\\Buttons\\UI-SliderBar-Button-Vertical")
|
||||
-- Dewdrop then seats the open value: overlayAlpha is 100 % of a 25 %..100 % range, and
|
||||
-- the popout inverts it (`1 - (value-min)/(max-min)`), so the engine value is 0 = the
|
||||
-- TOP of a vertical track.
|
||||
bar:SetValue(0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// host y ∈ [100, 270] ⇒ the bar is centred on y=185, 128 tall ⇒ track y ∈ [121, 249];
|
||||
// x: host left 100 + 15 ⇒ [115, 131], so the thumb centres on x=123.
|
||||
assert_eq!(
|
||||
thumb_rect(&s.extract(), "UI-SliderBar-Button-Vertical"),
|
||||
(107.0, 139.0, 217.0, 249.0),
|
||||
"a sizeless thumb is its art's 32×32, flush at the track top — not the whole 16×128 track"
|
||||
);
|
||||
|
||||
// And it drags: grab the thumb at its centre and pull to the bottom of the track.
|
||||
s.mouse_button(123.0, 233.0, "LeftButton", true);
|
||||
s.mouse_move(123.0, 137.0);
|
||||
let v: f32 = s.eval("return DdSlider:GetValue()").unwrap();
|
||||
assert!(
|
||||
(v - 1.0).abs() < 1e-3,
|
||||
"travel is 128−32 = 96, so a 96-unit pull runs the value min→max (got {v})"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user