held weapons pick, book images size to their art, the panel opens again

- a unit's pick geometry is every model chained to it — body, held items, mount — one candidate per root resolving to the same unit, which is how the reference registers its pick (it walks the attachment tree and registers each model); Naxxramas' floating weapons could only be selected by their name plate before (no body, no candidate), and every armed unit gains its gear as clickable surface (our own body 12 → 21 tested parts); the broad phase stays the body's sphere, so a very long polearm on a small body is the named residual
- the selection ring's writer has two branches and we had transcribed one: a model whose stand box is degenerate (zero extent in x and y) rings at the literal 1.2 × scale — 2.7 yd under a trigger creature — instead of `sqrt(0.5·sqrt(dx²+dy²))` = 0 clamped to an invented 0.05 floor; both invented fallbacks are gone
- an HTML book image with no width/height is its art's texel square (one texel = one FrameXML unit, bit-for-bit the `<AbsDimension>` converter), not the rest of the page — *A Treatise on Military Ranks* drew its crest twice too big and stretched to the page's aspect; a texture-size oracle answers off the same decoder the renderer draws with, and an unfloated height-less `<IMG>` reserves its art's height in the flow; 797 shipped textures swept, exactly two zero-size axes move and neither is visible
- the dev debug panel killed the client on open with "Attachments have differing sample counts": Bevy's 2-D depth texture is keyed on the render target alone, so two Camera2ds on one window at different MSAA counts cannot both be valid, and the egui overlay camera had silently kept the default 4× after the player-UI camera went to Off; it now says `Msaa::Off`, and a preflight check reports any two 2-D cameras that disagree on a shared target, every run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-29 17:16:04 +07:00
co-authored by Claude Fable 5
parent 80551cab5e
commit 18858046f9
21 changed files with 862 additions and 118 deletions
+85
View File
@@ -160,9 +160,70 @@ fn region_node_hash(data: &RegionData) -> u64 {
}
None => node.feed(u64::MAX),
}
// **The ART, on a region whose rect is derived from it** (decision 1349): an axis authored `0`
// takes its span from the content ([`content_span`]), so swapping the texture on such a region
// MOVES it and the node must re-hash. Fed only on that shape — a sized region's art cannot
// change its rect, and hashing every icon path would put a string walk in the derive's hot loop
// for a fact with no reader.
if !data.anchors.is_empty() && data.size.is_none_or(|(w, h)| w == 0.0 || h == 0.0) {
node.feed(data.fill.is_some() as u64);
match &data.texture {
Some(path) => {
node.feed(path.len() as u64);
for b in path.as_bytes() {
node.feed(u64::from(*b));
}
}
None => node.feed(u64::MAX),
}
}
node.finish()
}
/// A region's **content-derived span**, in FrameXML units — the client's virtual size getters,
/// which the resolver calls instead of reading a stored width/height (wow-re
/// `region-size-fallback.md` §1/§2, decision 1349).
///
/// `CSimpleTexture::GetWidth 0x770720` returns the authored value only when it is **not exactly
/// `0.0`**; on `0.0` with a texture loaded it returns the texture's own texel extent, through
/// bit-for-bit the same converter the `<AbsDimension>` attribute uses — so **one texel is one
/// FrameXML unit, at every aspect ratio**. `0x770790` is the identical body for height. The
/// colour form generates an **8×8** surface (`0x770360` → `0x44a900` stamps
/// `[tex+0x144] = [tex+0x148] = 8`), so a `<Color>`-only texture spans 8 units.
///
/// `None` = nothing to measure (no art, or no host oracle to measure it with), and the caller
/// leaves the axis at zero — which is the client's answer too when a texture has no `CGxTex*`
/// at all.
///
/// **This is the texture half of 1349 §4 only.** A `CSimpleFontString`'s span is likewise derived
/// (its measured extent, floored at one unit — `0x772930`), and our floor is still the zero-span
/// collapse the sweep applies below; that half, and deleting the owner-edge fallback the derived
/// spans make redundant, stay 1349's own arc.
fn content_span(
data: &RegionData,
probe: Option<&crate::script::TextureSizeProbe>,
) -> Option<(f32, f32)> {
if data.fill.is_some() {
return Some((SOLID_TEXTURE_TEXELS, SOLID_TEXTURE_TEXELS));
}
texel_span(probe, data.texture.as_deref()?)
}
/// [`content_span`]'s texture arm, for a caller holding a path rather than a region — the
/// SimpleHTML flow reservation, which asks the reference's `GetHeight` override the same way
/// (`simplehtml-markup-engine.md` §7 step 6).
pub(super) fn texel_span(
probe: Option<&crate::script::TextureSizeProbe>,
path: &str,
) -> Option<(f32, f32)> {
let (w, h) = probe?(path)?;
Some((w as f32, h as f32))
}
/// The edge of the surface `CSimpleTexture::SetTexture(const CImVector*)` generates for the colour
/// form — 64 texels, `0x44a900` stamping `[tex+0x144] = [tex+0x148] = 8` (decision 1349 §1).
const SOLID_TEXTURE_TEXELS: f32 = 8.0;
/// [`LayoutScope::node_of`] holds one roster index per id, and the two rosters are separate
/// (`plan` for frames, `regions` for regions) — this bit says which one. Node counts run in the
/// thousands, so the top bit is free forever.
@@ -768,6 +829,7 @@ impl UiScript {
layout_last_scope,
layout_rounds,
pending_size_changed,
texture_size_probe,
..
} = model;
// ── The incremental seed (decision 1388) ─────────────────────────────────────────────
@@ -1120,6 +1182,29 @@ impl UiScript {
width = m.w;
}
}
// A TEXTURE's unset axis is the client's *other* content-derived span: the art's
// own texel extent ([`content_span`]). It is the same shape as the FontString
// measure above — the reference's size getter is virtual, and both region classes
// override it — and it has to be applied HERE, before the resolve, because
// `combine_edge` reads a zero span as "no size at all" and leaves the opposite edge
// unresolved.
//
// That vacuum is what B342 fell into. `page_text` 2654 (*A Treatise on Military
// Ranks*) carries `<IMG src="…\PvPRankAlliance" align="left"/>` — **no width=, no
// height=** — so SimpleHTML sized the block `0 × 0`, one TOPLEFT anchor pinned the
// corner, and the owner-edge fallback below stretched a 128×128 crest across the
// rest of the 270×304 page with the text over it. The reference draws it at 128×128
// units, because a texel is a FrameXML unit.
if !is_fontstring && (width == 0.0 || height == 0.0) {
if let Some((cw, ch)) = content_span(data, texture_size_probe.as_ref()) {
if width == 0.0 {
width = cw;
}
if height == 0.0 {
height = ch;
}
}
}
scratch.anchors.clear();
scratch.anchors.extend_from_slice(&data.anchors);
scratch.width = width;
+1 -1
View File
@@ -183,7 +183,7 @@ pub use mail::{MailInboxRow, MailInvoice, MailSendRequest, MailState};
pub use measure::TextMeasure;
pub use merchant::{ItemStatsHead, MerchantItem, MerchantState};
pub(crate) use model::Model;
pub use model::TextureProbe;
pub use model::{TextureProbe, TextureSizeProbe};
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
pub use pet::{PetActionView, PetStats};
pub use pvp::{HonorState, InspectHonorData};
+18
View File
@@ -18,6 +18,9 @@ use super::{
/// The host's texture-path resolvability oracle — see [`Model::texture_probe`].
pub type TextureProbe = Box<dyn Fn(&str) -> bool>;
/// The host's texture **texel-size** oracle — see [`Model::texture_size_probe`].
pub type TextureSizeProbe = Box<dyn Fn(&str) -> Option<(u32, u32)>>;
/// The Rust-side model behind the Lua VM — the arena, the layout inputs + resolved rects, the
/// id↔handle bijection, region visuals, and the event/script registrations. Held in `lua.app_data`
/// (interior-mutable) so callbacks reach it; contains **no** mlua handles (the MAXCSTACK discipline).
@@ -38,6 +41,20 @@ pub(crate) struct Model {
/// engine-less VM (tests, the addon harness), where the path form keeps answering nil: no
/// backend, nothing loads — which is also exactly what those tests always saw.
pub(crate) texture_probe: Option<TextureProbe>,
/// The host's texture **texel-size** oracle ([`super::UiScript::set_texture_size_probe`]): how
/// many texels wide and tall is the art at this path?
///
/// What lets a region whose authored size on an axis is exactly `0` take its span from its
/// CONTENT, the way the real client's size getters do — `CSimpleTexture::GetWidth 0x770720` /
/// `GetHeight 0x770790` return the authored value only when it is not `0.0`, and otherwise read
/// the loaded texture's `[tex+0x144]`/`[tex+0x148]` through the same converter `<AbsDimension>`
/// uses, so **one texel is one FrameXML unit** (wow-re `region-size-fallback.md` §2, decision
/// 1349). The host answers off the same candidate walk the renderer decodes with and memoises,
/// so the size layout resolves with is the size the screen shows.
///
/// `None` in an engine-less VM (tests, the addon harness), where a zero-size texture keeps the
/// rect it always had — there is no art to measure without a backend.
pub(crate) texture_size_probe: Option<TextureSizeProbe>,
/// Where per-addon saved variables live: the account-scoped folder, then this character's.
/// Both are directories holding one `<Addon>.lua` per declaring addon.
pub(crate) addons_saved_account: Option<std::path::PathBuf>,
@@ -1360,6 +1377,7 @@ impl Model {
addons_root: None,
measurer: None,
texture_probe: None,
texture_size_probe: None,
addons_saved_account: None,
addons_saved_character: None,
framexml_templates: Default::default(),
+111 -74
View File
@@ -148,83 +148,112 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(
|lua, (this, arg, g, b, a): (Table, Value, Value, Value, Value)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// A plain SetTexture makes the region ordinary again — drop any portrait circular mask
// and any live-unit-portrait binding.
let data = model.region_data.entry(rh).or_default();
data.circular = false;
data.portrait_unit = None;
// **`SetTexture` CLEARS the desaturation** (wow-re `texture-desaturate-law.md` §2.3,
// VERIFIED): `+0x128` is a `CGxShader*`, and `CSimpleTexture::SetTexture`
// (`0x770200`) writes it from its 4th stack arg, for which the Lua binding
// (`0x79bb40`) pushes slot 0 — permanently NULL — on both of its legs. A
// re-implementation that keeps a desaturate boolean independent of the texture
// handle diverges on every `icon:SetDesaturated(1)` followed by `icon:SetTexture(t)`.
//
// Scoped exactly as the binary scopes it:
// · the **same path** is inert — `0x770225` returns before ever reaching the write,
// so a repaint that re-sets the icon it already shows keeps its grey;
// · `nil`/`""` DO clear — the `test esi,esi` leg falls through to the write;
// · the **colour form** does NOT — that is `0x770360`, which is not among the four
// writers of the field.
let same_path = matches!((&arg, &data.texture),
// Scoped, so the model borrow is provably gone before the layout touch below —
// the path arm hands it back mid-way (to call the host probe without holding a
// mutable borrow across a host callback) and the others do not.
let (loaded, derived) = {
let mut model = lua.app_data_mut::<Model>().expect("model");
// A plain SetTexture makes the region ordinary again — drop any portrait circular mask
// and any live-unit-portrait binding.
let data = model.region_data.entry(rh).or_default();
data.circular = false;
data.portrait_unit = None;
// **`SetTexture` CLEARS the desaturation** (wow-re `texture-desaturate-law.md` §2.3,
// VERIFIED): `+0x128` is a `CGxShader*`, and `CSimpleTexture::SetTexture`
// (`0x770200`) writes it from its 4th stack arg, for which the Lua binding
// (`0x79bb40`) pushes slot 0 — permanently NULL — on both of its legs. A
// re-implementation that keeps a desaturate boolean independent of the texture
// handle diverges on every `icon:SetDesaturated(1)` followed by `icon:SetTexture(t)`.
//
// Scoped exactly as the binary scopes it:
// · the **same path** is inert — `0x770225` returns before ever reaching the write,
// so a repaint that re-sets the icon it already shows keeps its grey;
// · `nil`/`""` DO clear — the `test esi,esi` leg falls through to the write;
// · the **colour form** does NOT — that is `0x770360`, which is not among the four
// writers of the field.
let same_path = matches!((&arg, &data.texture),
(Value::String(s), Some(cur)) if s.to_str().is_ok_and(|s| *s == **cur));
let colour_form = matches!(&arg, Value::Number(_) | Value::Integer(_));
if !same_path && !colour_form {
data.desaturated = false;
}
// Both forms write the SAME `+0xcc` texture slot — the path form loads a file
// there (`0x770200`), the colour form generates an 8×8 solid into it
// (`0x770360`) — so each clears the other. NEITHER touches the vertex colour at
// `+0xb8`: a tint outlives the art it was tinting.
let loaded = match &arg {
// SetTexture("") clears, same as SetTexture(nil) — the ref lua blanks state
// art with the empty string (QuestLogFrame.lua:165-166). The return for ""
// is read as the path form's failure (nothing loads from an empty name);
// INFERRED — no corpus caller reads it.
Value::String(s) if s.to_str()?.is_empty() => {
data.texture = None;
data.fill = None;
false
let colour_form = matches!(&arg, Value::Number(_) | Value::Integer(_));
if !same_path && !colour_form {
data.desaturated = false;
}
Value::String(s) => {
let path = s.to_str()?.to_string();
data.texture = Some(path.clone());
data.fill = None;
drop(model);
let model = lua.app_data_ref::<Model>().expect("model");
model
.texture_probe
.as_ref()
.is_some_and(|probe| probe(&path))
}
// The colour form, and the ONLY branch that looks at the trailing three. A
// non-numeric there takes the same default a missing one does, which is what
// reading off a C stack does: `lua_tonumber` on a non-number yields 0.
Value::Number(_) | Value::Integer(_) => {
let chan = |v: &Value, dflt: f32| match v {
Value::Number(_) | Value::Integer(_) => as_f32(v),
Value::String(s) => s
.to_str()
.ok()
.and_then(|s| s.parse::<f32>().ok())
.unwrap_or(dflt),
_ => dflt,
};
data.fill =
Some([as_f32(&arg), chan(&g, 0.0), chan(&b, 0.0), chan(&a, 1.0)]);
data.texture = None;
true
}
// SetTexture(nil) clears (the live API's blank-the-region form); a cleared
// texture region draws nothing. Returns 1 (Q1's `SetTexture(nil) / ()` row).
Value::Nil => {
data.texture = None;
data.fill = None;
true
}
_ => false,
// Does this region's rect come from its ART? An axis authored `0` takes its span
// from the content (decision 1349, `script::layout::content_span`), so on that
// shape — and only there — swapping the texture MOVES the region and the resolve
// has to hear about it. Read before the match, which writes the art and never the
// size.
//
// **An ANCHOR-LESS region is not that shape**, whatever its size: with no
// pinned edge and no center there is nothing for a span to be added to
// (`combine_edge` needs one), so every edge stays unset — which is why the
// resolve sweep skips such regions outright. Painting one is a paint, not a
// layout change, and saying otherwise would re-open the change gate on every
// `CreateTexture(…):SetTexture(…)` in the UI (decisions 0740/1385/1388).
let derived = !data.anchors.is_empty()
&& data.size.is_none_or(|(w, h)| w == 0.0 || h == 0.0);
// Both forms write the SAME `+0xcc` texture slot — the path form loads a file
// there (`0x770200`), the colour form generates an 8×8 solid into it
// (`0x770360`) — so each clears the other. NEITHER touches the vertex colour at
// `+0xb8`: a tint outlives the art it was tinting.
let loaded = match &arg {
// SetTexture("") clears, same as SetTexture(nil) — the ref lua blanks state
// art with the empty string (QuestLogFrame.lua:165-166). The return for ""
// is read as the path form's failure (nothing loads from an empty name);
// INFERRED — no corpus caller reads it.
Value::String(s) if s.to_str()?.is_empty() => {
data.texture = None;
data.fill = None;
false
}
Value::String(s) => {
let path = s.to_str()?.to_string();
data.texture = Some(path.clone());
data.fill = None;
drop(model);
let model = lua.app_data_ref::<Model>().expect("model");
model
.texture_probe
.as_ref()
.is_some_and(|probe| probe(&path))
}
// The colour form, and the ONLY branch that looks at the trailing three. A
// non-numeric there takes the same default a missing one does, which is what
// reading off a C stack does: `lua_tonumber` on a non-number yields 0.
Value::Number(_) | Value::Integer(_) => {
let chan = |v: &Value, dflt: f32| match v {
Value::Number(_) | Value::Integer(_) => as_f32(v),
Value::String(s) => s
.to_str()
.ok()
.and_then(|s| s.parse::<f32>().ok())
.unwrap_or(dflt),
_ => dflt,
};
data.fill =
Some([as_f32(&arg), chan(&g, 0.0), chan(&b, 0.0), chan(&a, 1.0)]);
data.texture = None;
true
}
// SetTexture(nil) clears (the live API's blank-the-region form); a cleared
// texture region draws nothing. Returns 1 (Q1's `SetTexture(nil) / ()` row).
Value::Nil => {
data.texture = None;
data.fill = None;
true
}
_ => false,
};
(loaded, derived)
};
// Named precisely, and only on the content-derived shape: an ordinary sized icon's
// rect cannot move here, and touching the layout on every icon repaint would
// re-open the resolve's change gate every frame for a rect nobody moved
// (decisions 0740/1385/1388).
if derived {
lua.app_data_mut::<Model>()
.expect("model")
.touch_layout_region(rh);
}
Ok(if loaded {
Value::Number(1.0)
} else {
@@ -454,4 +483,12 @@ impl crate::script::UiScript {
pub fn set_texture_probe(&mut self, probe: crate::script::TextureProbe) {
self.model_mut().texture_probe = Some(probe);
}
/// Install the host's texture **texel-size** oracle — what lets a region with an authored size
/// of `0` on an axis take that span from its art, as the client's virtual size getters do
/// ([`Model::texture_size_probe`], decision 1349 / wow-re `region-size-fallback.md` §2). A VM
/// that never gets one leaves such a region exactly where it was.
pub fn set_texture_size_probe(&mut self, probe: crate::script::TextureSizeProbe) {
self.model_mut().texture_size_probe = Some(probe);
}
}
+16 -4
View File
@@ -43,9 +43,6 @@
//! `SimpleHTML:SetSpacing` *does* honour here is the half SimpleHTML itself owns and computes:
//! the inter-block step. That is a real, drawn effect rather than a stored-and-ignored number,
//! which is why this pair ships where the FontString/Font ones do not.
//! - **An `<IMG>` with no declared height reserves nothing in the flow.** The reference
//! substitutes the texture's natural pixel height (`0x770790`), which needs the BLP loaded —
//! knowledge the engine core does not have at `SetText` time.
//! - **A block's wrap width of 0** (a SimpleHTML sized only by anchors) lets the measure
//! round-trip supply the natural text width, where the reference leaves the block at width 0.
//! That is this engine's standing law for every height-less/width-less FontString, and inverting
@@ -375,8 +372,23 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
// `prevBlock` is **never** written by the image path (three reads of `+0x348` in
// the whole function, zero writes) — the next block still hangs off the last TEXT
// block, only lower.
//
// The height it reserves is `texture.GetHeight()`, which is `CSimpleTexture`'s
// **override**: a `<IMG>` with no `height=` reserves its art's own texel height,
// not nothing (decision 1349 — one texel is one FrameXML unit). Asked of the same
// host oracle the resolve derives the region's rect from, so the space the flow
// leaves and the space the image fills are one number.
if !*floated {
next_y -= *h;
let reserved = if *h == 0.0 {
src.as_deref()
.and_then(|p| {
super::layout::texel_span(model.texture_size_probe.as_ref(), p)
})
.map_or(0.0, |(_, th)| th)
} else {
*h
};
next_y -= reserved;
}
made.push(rh);
}
@@ -542,6 +542,130 @@ fn an_unfloated_image_reserves_height_without_becoming_the_anchor() {
);
}
/// **B342, at the engine.** Goudy, 2026-08-27 (`#bugs` `1542371921486811236`): *"html images in
/// books are not scaled correctly"* — the Alliance crest on *A Treatise on Military Ranks* drawn
/// several times the reference's size, with the page's text over the top of it.
///
/// The body is `page_text` 2654 and its `<IMG>` carries **no `width=` and no `height=`**, so the
/// block is sized `0 × 0`. In the reference that is not "no size": the resolver's size call is
/// VIRTUAL, and `CSimpleTexture::GetWidth 0x770720` / `GetHeight 0x770790` answer an authored `0.0`
/// with the loaded texture's own texel extent, through bit-for-bit the converter `<AbsDimension>`
/// uses — **one texel is one FrameXML unit** (wow-re `region-size-fallback.md` §2, decision 1349).
/// `PvPRankAlliance.blp` is 128×128, so the crest is a 128-unit square.
///
/// The falsification is the reported shape itself, asserted below: a zero span leaves the opposite
/// edge unresolved (`combine_edge`'s `span != 0.0` leg), and the region sweep's owner-edge fallback
/// then hands the image the rest of the **270-wide** page.
#[test]
fn the_books_unsized_crest_is_its_arts_texel_square_not_the_whole_page() {
const CREST: &str = "Interface\\PvPRankBadges\\PvPRankAlliance";
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_texture_size_probe(Box::new(|path| (path == CREST).then_some((128, 128))));
page(&s);
s.run(&format!(
"Page:SetText({})",
lua_str(
"<HTML><BODY>\n\
<H1 align=\"center\">A TREATISE ON MILITARY RANKS</H1>\n\
<BR/>\n<BR/>\n\
<IMG src=\"Interface\\PvPRankBadges\\PvPRankAlliance\" align=\"left\"/>\n\
<BR/>\n\
<P align=\"right\">What follows are</P>\n\
</BODY></HTML>"
)
))
.unwrap();
measure_at_16px(&mut s);
let b = blocks(&s, "Page");
let img = b
.iter()
.position(|x| x.texture.is_some())
.expect("an IMG block");
assert_eq!(b[img].texture.as_deref(), Some(CREST));
assert_eq!(
b[img].size,
Some((0.0, 0.0)),
"the block still stores what the markup authored — the span is DERIVED at resolve, as the \
reference's virtual getter derives it"
);
let rect = resolved_rect(&s, "Page", img);
assert_eq!(
(rect.right - rect.left, rect.top - rect.bottom),
(128.0, 128.0),
"a texel is a FrameXML unit: the 128x128 crest is a 128-unit square, not the page"
);
// The reported symptom, stated as the thing that must not come back: the page is 270 wide and
// 304 tall, and the crest used to take all of the first and everything left of the second.
assert!(
rect.right - rect.left < 270.0,
"the crest is stretched across the page width again"
);
}
/// The flow half of the same law (`simplehtml-markup-engine.md` §7 step 6): an **unfloated** image
/// reserves `texture.GetHeight()` — `CSimpleTexture`'s override — so one with no `height=` reserves
/// its art's texel height, and the next text block hangs that far lower. A floated one (the book's
/// `align="left"`) still reserves nothing, which is why its text overlaps it in both clients.
#[test]
fn an_unsized_image_reserves_its_arts_texel_height_unless_it_is_floated() {
let mut s = script();
s.set_texture_size_probe(Box::new(|_| Some((64, 48))));
page(&s);
s.run(
r#"Page:SetText("<HTML><BODY><P>a</P>" ..
"<IMG src=\"Interface\\Pic\"/>" ..
"<P>b</P></BODY></HTML>")"#,
)
.unwrap();
assert_eq!(
blocks(&s, "Page")[2].anchor.unwrap(),
(Point::TopLeft, Point::BottomLeft, 0.0, -48.0, Rel::Block(0)),
"no height= reserves the ART's height, not nothing"
);
let mut s = script();
s.set_texture_size_probe(Box::new(|_| Some((64, 48))));
page(&s);
s.run(
r#"Page:SetText("<HTML><BODY><P>a</P>" ..
"<IMG src=\"Interface\\Pic\" align=\"left\"/>" ..
"<P>b</P></BODY></HTML>")"#,
)
.unwrap();
assert_eq!(
blocks(&s, "Page")[2].anchor.unwrap(),
(Point::TopLeft, Point::BottomLeft, 0.0, 0.0, Rel::Block(0)),
"a FLOATED image reserves nothing — the text runs over it, in both clients"
);
}
/// A VM with no host oracle has no art to measure, so a zero-size image stays exactly where it
/// always was. The engine-less truth, stated so the fix cannot be read as "the engine now knows
/// texel sizes".
#[test]
fn without_a_size_oracle_an_unsized_image_is_unchanged() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(r#"Page:SetText("<HTML><BODY><P>a</P><IMG src=\"Interface\\Pic\"/></BODY></HTML>")"#)
.unwrap();
measure_at_16px(&mut s);
let b = blocks(&s, "Page");
let img = b
.iter()
.position(|x| x.texture.is_some())
.expect("an IMG block");
let rect = resolved_rect(&s, "Page", img);
assert_eq!(
rect.right - rect.left,
270.0,
"the owner-edge fallback, unchanged"
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The Lua table
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -825,6 +949,18 @@ fn resolved_tops(s: &UiScript, name: &str) -> Vec<(f32, f32)> {
.collect()
}
/// One block's resolved rect, by index in the block list.
fn resolved_rect(s: &UiScript, name: &str, block: usize) -> crate::layout::Rect {
let lua = s.lua();
let model = lua.app_data_ref::<Model>().expect("model");
let fh = model.arena.lookup(name).expect("frame");
let st = model.simple_html.get(&fh).expect("state");
*model
.region_resolved
.get(&st.blocks[block])
.expect("block rect")
}
/// The text of every quad the extract emits for the page — what is actually on screen.
fn page_texts_on_screen(s: &UiScript) -> Vec<String> {
s.extract()