the same-continent teleport fall is found and fixed

- a teleport between cities on one continent could release you before the destination had streamed in and drop you under the map — the residency publish now waits for the snap and names the tile its facts are about, so the loading screen provably holds until the ground is real
- nameplates stop jittering: plates of units you cannot see no longer shove the visible ones around, and the snap rides the correct pixel grid
- unit-name text takes its first fix — a string fits the width its own measure reported, and a re-bake reaches the screen
- books and plaques render through a real SimpleHTML widget inside a real ScrollFrame — strict parse, per-element fonts, block layout, and a scroll range measured from the whole subtree

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-16 17:43:01 +07:00
co-authored by Claude Fable 5
parent 8d3d805ec8
commit 4585a12259
27 changed files with 3714 additions and 102 deletions
+42 -21
View File
@@ -9,15 +9,11 @@
ITEM_TEXT_BEGIN → ITEM_TEXT_READY → ITEM_TEXT_CLOSED.
ADAPTATIONS (the MailFrame.xml precedents):
· The body scroll (ItemTextScrollFrame, UIPanelScrollFrameTemplate 280×355 at -66,-76) is
a plain Frame at that exact rect with the page FontString INSIDE it (no live scrollbar);
a very long page clips. The ref's own flanking scrollbar art (UI-Character-ScrollBar
top/bottom + the black middle strip) is quoted verbatim as inert layers, so the right
edge reads exactly like the reference at rest.
· ItemTextPageText is SimpleHTML in the ref — drawn here as a FontString at the same
rect (our letters/pages are plain text; an HTML page renders as its markup, accepted).
· The ScrollFrame-only verbs the reference's OnLoad/READY use (UpdateScrollChildRect,
ScrollBar SetValue/Hide, scrollBarHideable) are dropped with the live scrollbar.
· The body scroll and the page are the REFERENCE's own structure since decisions
1337/1338 (B240): a real ScrollFrame over a 10x10 scroll child holding a real
SimpleHTML. Before those, the scroll was a plain Frame with an inert bar and the page a
FontString — so an HTML body drew as its own markup and a long one was cut off with
"...", both of which Goudy reported.
· The two material colour TABLES are the reference's data (its Fonts.xml), quoted
verbatim — the numbers ARE the look. They live here Benilla-scoped (the MoneyInput-shim
precedent) because our own Fonts.xml deliberately doesn't ship the reference's two
@@ -166,6 +162,10 @@
end
ItemTextPageText:SetText(body)
ItemTextScrollFrame:Show()
-- A page is content the reader has never seen: re-measure what there is to scroll and
-- start at the top, or page two opens halfway down page one.
ItemTextScrollFrame:UpdateScrollChildRect()
ItemTextScrollFrameScrollBar:SetValue(0)
local pageNumber = ItemTextGetPage()
local hasNext = ItemTextHasNextPage()
@@ -275,9 +275,11 @@
</Layer>
</Layers>
<Frames>
<!-- ref ItemTextScrollFrame l.145-219: the plain-Frame stand-in (see the header) with
the ref's own flanking scrollbar art kept verbatim. -->
<Frame name="ItemTextScrollFrame" hidden="true">
<!-- ref ItemTextScrollFrame l.145-219 — the REAL one now (decisions 1337/1338): an
engine ScrollFrame that pans and clips its scroll child, with the ref's own
flanking art quoted verbatim as the bar's track. `scrollBarHideable` keeps the bar
off a page that fits, which is most of them. -->
<ScrollFrame name="ItemTextScrollFrame" inherits="UIPanelScrollFrameTemplate" hidden="true">
<Size><AbsDimension x="280" y="355"/></Size>
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-66" y="-76"/></Offset></Anchor></Anchors>
<Layers>
@@ -304,16 +306,35 @@
<Color r="0" g="0" b="0" a="0.8"/>
</Texture>
</Layer>
<Layer level="OVERLAY">
<!-- ref ItemTextPageText (SimpleHTML 270×304 at 0,-15 in the scroll child)
as a FontString at the same rect. -->
<FontString name="ItemTextPageText" inherits="ItemTextFontNormal" justifyH="LEFT" justifyV="TOP">
<Size><AbsDimension x="270" y="304"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-15"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
<Scripts>
<!-- The template's own OnLoad still has to run — a handler declared on an
instance REPLACES the template's, it does not chain. -->
<OnLoad>
ScrollFrame_OnLoad()
this.scrollBarHideable = 1
</OnLoad>
</Scripts>
<ScrollChild>
<!-- ref ItemTextPageScrollChild l.198-217, 10x10 and deliberately so: the
scroll range is the child's whole SUBTREE box (decision 1338), so the page
inside it is what gives the reader something to scroll. -->
<Frame name="ItemTextPageScrollChild">
<Size><AbsDimension x="10" y="10"/></Size>
<Frames>
<!-- ref ItemTextPageText l.203-215. A SimpleHTML for real now (1337):
an HTML body renders as blocks, and a plain one as a single block
through the engine's raw-text fallback. Its height is its content,
so a long page SCROLLS where it used to be cut off with "...". -->
<SimpleHTML name="ItemTextPageText">
<Size><AbsDimension x="270" y="304"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-15"/></Offset></Anchor></Anchors>
<FontString inherits="ItemTextFontNormal"/>
</SimpleHTML>
</Frames>
</Frame>
</ScrollChild>
</ScrollFrame>
<!-- ref ItemTextStatusBar l.220-251 — the ITEM_TEXT_TRANSLATION progress bar; never
fires on our letters, kept for fidelity. -->
<StatusBar name="ItemTextStatusBar" hidden="true">
+84 -5
View File
@@ -79,7 +79,8 @@ struct BookProbe {
plaque: Option<u64>,
closed: Vec<Sample>,
open: Vec<Sample>,
/// The page body the reader actually painted, in chars — the `n` the cost is quadratic in.
/// Characters the whole UI draws once the reader is up — the `n` the seam's cost scales in,
/// and the proof the page's blocks were built at all.
page_chars: i64,
fails: u32,
exited: bool,
@@ -104,17 +105,94 @@ enum Phase {
Done,
}
/// The reader's painted state, straight out of the live VM: `(shown, page body length)`.
/// The reader's painted state: `(shown, characters the page actually draws)`.
///
/// The body is read off the RENDER LIST, not out of the VM. `ItemTextPageText` is a `SimpleHTML`
/// since decisions 1337/1338 and 5875's SimpleHTML has no `GetText` (its Lua table is 19 entries
/// and none of them is a text getter) — so the honest question is what the reader draws, which is
/// also the stronger one: a parsed page is many blocks, a plain one is a single raw block, and a
/// page that failed to build is zero.
fn reader_state(script: &UiScript) -> (bool, i64) {
use benilla_ui::script::QuadContent;
let shown = script
.eval::<bool>("return ItemTextFrame:IsShown() and 1 or nil")
.eval::<bool>("return ItemTextScrollFrame:IsShown() and 1 or nil")
.unwrap_or(false);
let chars = script
.eval::<i64>("return strlen(ItemTextPageText:GetText() or \"\")")
.unwrap_or(0);
.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Text { text, .. } => text,
_ => None,
})
.map(|t| t.chars().count() as i64)
.sum();
(shown, chars)
}
/// Every string the UI currently draws — the render list is the honest place to ask what the page
/// looks like, and the only place since `ItemTextPageText` became a `SimpleHTML` (5875's has no
/// `GetText`; wow-re `simplehtml-markup-engine.md` §5.1).
fn drawn_strings(script: &UiScript) -> Vec<String> {
use benilla_ui::script::QuadContent;
script
.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Text { text, .. } => text,
_ => None,
})
.filter(|t| !t.is_empty())
.collect()
}
/// **B240's render half, checked where it was reported.** The page body is HTML; if the parse ever
/// falls back, the reader draws the markup itself — which is what Goudy photographed. So: no drawn
/// string may contain a tag, and the page's own lines must each be there as their own block.
fn report_render(script: &UiScript) -> u32 {
let drawn = drawn_strings(script);
let mut fails = 0;
let markup: Vec<&String> = drawn
.iter()
.filter(|t| t.contains("<HTML>") || t.contains("<P align") || t.contains("</P>"))
.collect();
if markup.is_empty() {
info!("PROBE_BOOK: (render) PASS — nothing on screen draws its own markup");
} else {
fails += 1;
error!(
"PROBE_BOOK: (render) FAIL — the page is drawing markup: {:?}",
markup.iter().take(2).collect::<Vec<_>>()
);
}
// The blocks the plaque's own body must produce, each its own centred line.
let want = [
"ALLIANCE MILITARY RANKS",
"OFFICERS",
"Grand Marshal",
"Private",
];
let missing: Vec<&str> = want
.iter()
.copied()
.filter(|w| !drawn.iter().any(|t| t == w))
.collect();
if missing.is_empty() {
info!("PROBE_BOOK: (render) PASS — the page's blocks are on screen, one per tag");
} else {
fails += 1;
error!("PROBE_BOOK: (render) FAIL — blocks missing from the page: {missing:?}");
}
// The other half of the reported look: a truncated block means the body is height-pinned
// again and decision 1332's ellipsis seam has it back.
if let Some(cut) = drawn.iter().find(|t| t.ends_with("...")) {
fails += 1;
error!("PROBE_BOOK: (render) FAIL — a drawn block is ellipsized: {cut:?}");
} else {
info!("PROBE_BOOK: (render) PASS — no block is cut off with \"...\"");
}
fails
}
/// Mean, median and max of a sample column, in µs.
fn stats(v: &[u128]) -> (f64, u128, u128) {
if v.is_empty() {
@@ -218,6 +296,7 @@ fn book_probe(
let (shown, chars) = reader_state(&script);
if shown && chars > 0 {
probe.page_chars = chars;
probe.fails += report_render(&script);
info!("PROBE_BOOK: reader painted a {chars}-char body — sampling {SAMPLE_FRAMES} frames with it OPEN");
probe.phase = Phase::Open;
} else if now - since > READY_TIMEOUT_SECS {
@@ -167,6 +167,10 @@ fn probe_key_by_name(name: &str) -> Option<KeyCode> {
"X" => KeyCode::KeyX,
"Z" => KeyCode::KeyZ,
"Tab" => KeyCode::Tab,
// The nameplate toggles (`vplates`): bare `V` is enemy plates, `Shift`-held `V` is
// friendly. A probe that wants plates over a town's *friendly* NPCs — the only way to
// measure plate behaviour without standing in aggro range of something — presses this.
"V" => KeyCode::KeyV,
// The detached free-fly toggle (`player.rs`) and its Ctrl speed boost (`camera.rs`, ×5).
// Added because the harness could not reach the leg that broke decision 0793: the director's
// first real run was a boosted free-fly, whose camera crosses the art radius in ~5 s, and
@@ -234,6 +238,7 @@ fn fire_probe_key(
self_player: Query<(), With<crate::net::SelfPlayer>>,
mut keys: ResMut<ButtonInput<KeyCode>>,
mut events: MessageWriter<bevy::input::keyboard::KeyboardInput>,
mut hold: ResMut<benilla_world::modkeys::SyntheticHold>,
) {
probe.armed |= !self_player.is_empty();
if probe.taps.is_empty() || !probe.armed {
@@ -267,6 +272,21 @@ fn fire_probe_key(
tap.released = true;
}
}
// Publish what we are holding, so the macOS stuck-modifier reconciler
// ([`benilla_world::modkeys`]) doesn't undo it: it polls the *hardware* flags, which read
// "up" for a key no hand is on, so before this every synthesized chord — `SHIFT-V`,
// `Ctrl`+`Shift`+`F` — was released the frame after it was pressed, and the release was
// logged as a stuck-key correction. Rewritten only when the held set changes (a plain
// `ResMut` write every frame would mark the resource changed forever).
let held: Vec<KeyCode> = probe
.taps
.iter()
.filter(|t| t.pressed && !t.released)
.map(|t| t.key)
.collect();
if hold.0 != held {
hold.0 = held;
}
}
/// The PROBE LUA one-shot (`WOW_PROBE_LUA="<chunk>"`, delay via `WOW_PROBE_LUA_AT` seconds,
+145 -1
View File
@@ -162,12 +162,22 @@ impl NamePaint {
/// The nameplate render caches: one unlit depth-tested material per palette color (built once the
/// atlas exists), one mesh per distinct line stack (every "Young Wolf" shares one mesh), and the
/// live per-unit plate entities.
///
/// **Every field here is atlas-derived and dies with the bake it was built from** ([`baked_from`]).
/// The meshes carry glyph-cell UVs in their vertex data ([`build_name_mesh`]) and the materials
/// bind the atlas image by handle — both are readings of ONE `UiFontAtlas`, and a re-bake
/// (`ui_text::atlas`'s `BakeEnv` edge, decision 1296) replaces that atlas with a new image and a
/// new cell layout. Keeping either across the edge draws new-bake UVs through the old-bake
/// texture: the garbage-glyph unit names the maximised window produced (B272, decision 1339).
#[derive(Resource, Default)]
pub(crate) struct Nameplates {
materials: HashMap<NamePaint, Handle<StandardMaterial>>,
meshes: HashMap<Vec<String>, Handle<Mesh>>,
/// unit → (plate entity, the (lines, color) it was built with).
live: bevy::ecs::entity::EntityHashMap<(Entity, Vec<String>, NamePaint)>,
/// The `UiFontAtlas::generation` every cache above was built from — `None` before the first
/// build. [`drop_stale_atlas_caches`] empties all three when it moves.
baked_from: Option<u64>,
}
impl Nameplates {
@@ -338,6 +348,8 @@ pub(crate) fn drive_nameplates(
let (Ok(cam_tf), Some(atlas)) = (camera.single(), atlas.as_mut()) else {
return;
};
// A re-baked atlas invalidates every cache below it (see [`Nameplates`]).
drop_stale_atlas_caches(&mut plates, atlas.generation, &mut commands);
// The palette materials, once (they need the atlas image).
if plates.materials.is_empty() {
let image = atlas.image();
@@ -657,9 +669,45 @@ impl Plugin for NameplatesPlugin {
}
}
/// Empty every atlas-derived cache when the bake under it moves — the staleness edge [`Nameplates`]
/// describes.
///
/// A re-bake hands us a different image and a different cell layout, so the meshes' baked UVs and
/// the materials' bound texture both stop meaning what they meant. Dropping the maps alone is not
/// enough: the live plate entities still hold their old `Mesh3d`/`MeshMaterial3d` handles, so they
/// are despawned too and rebuilt from the new atlas on this same frame's walk below (which
/// re-enters every visible unit). That one-frame rebuild is the whole cost of a re-bake here.
///
/// The **first** build is not an edge — `baked_from: None` seeds to the live generation with
/// nothing to drop.
fn drop_stale_atlas_caches(plates: &mut Nameplates, generation: u64, commands: &mut Commands) {
if plates.baked_from == Some(generation) {
return;
}
if plates.baked_from.is_some() {
debug!(
"nameplates: atlas re-baked (generation {generation}) — dropping {} meshes, {} \
materials and {} live plates",
plates.meshes.len(),
plates.materials.len(),
plates.live.len(),
);
for (plate, _, _) in plates.live.values() {
if let Ok(mut e) = commands.get_entity(*plate) {
e.despawn();
}
}
plates.live.clear();
plates.meshes.clear();
plates.materials.clear();
}
plates.baked_from = Some(generation);
}
/// Drop the per-line-stack mesh dedup on a cross-map transition (`world_map::MapChange` — see its
/// doc): it grows with every distinct name ever seen and the old map's population never comes
/// back. The palette `materials` stay — a small fixed set, rebuilt only if the atlas changes.
/// back. The palette `materials` stay — they are keyed by the atlas bake, not the map
/// ([`drop_stale_atlas_caches`] owns that edge).
fn evict_name_meshes(
mut changes: MessageReader<benilla_world::world_map::MapChange>,
mut plates: ResMut<Nameplates>,
@@ -753,4 +801,100 @@ mod tests {
}
const RED: Color = Color::linear_rgb(1.0, 0.0, 0.0);
/// A populated cache, as a re-bake finds it: one live plate, its mesh, its material.
fn primed(generation: Option<u64>, unit: Entity, plate: Entity) -> Nameplates {
let lines = vec!["Young Wolf".to_string()];
let mut plates = Nameplates {
baked_from: generation,
..Default::default()
};
plates.meshes.insert(lines.clone(), Handle::default());
plates.materials.insert(NamePaint::Flash, Handle::default());
plates.live.insert(unit, (plate, lines, NamePaint::Flash));
plates
}
/// **A re-bake empties every atlas-derived cache, live plates included** (B272, decision 1339).
///
/// The meshes carry glyph-cell UVs in vertex data and the materials bind the atlas image by
/// handle. A re-bake replaces the atlas with a new image whose HEIGHT is a function of the
/// seam-dependent size ladder, so every normalized V from the old bake addresses a different
/// row of the new one. Keeping the material while rebuilding meshes is what drew new-bake UVs
/// through the old-bake texture — unit names as fragments of other letters.
///
/// Dropping the maps is not enough on its own: the live plate entities still hold the old
/// `Mesh3d`/`MeshMaterial3d`, so they must be despawned to be rebuilt. That is the assertion
/// the map-clear alone would pass and the screen would not.
#[test]
fn a_rebake_drops_the_meshes_the_materials_and_the_live_plates() {
let mut world = World::new();
let unit = world.spawn_empty().id();
let plate = world.spawn_empty().id();
let mut plates = primed(Some(7), unit, plate);
let mut queue = bevy::ecs::world::CommandQueue::default();
{
let mut commands = Commands::new(&mut queue, &world);
drop_stale_atlas_caches(&mut plates, 8, &mut commands);
}
queue.apply(&mut world);
assert!(plates.meshes.is_empty(), "stale UVs must not survive");
assert!(
plates.materials.is_empty(),
"the material binds the OLD atlas image — the half that made it garbage rather than \
merely soft"
);
assert!(plates.live.is_empty());
assert!(
world.get_entity(plate).is_err(),
"a live plate holds the old mesh+material handles: clearing the maps around it leaves \
the wrong cells on screen"
);
assert_eq!(plates.baked_from, Some(8));
}
/// The FIRST build is not an edge — there is nothing baked from a previous atlas to drop, and
/// treating it as one would despawn the plates the same frame they were spawned.
#[test]
fn the_first_bake_seeds_without_dropping_anything() {
let mut world = World::new();
let unit = world.spawn_empty().id();
let plate = world.spawn_empty().id();
let mut plates = primed(None, unit, plate);
let mut queue = bevy::ecs::world::CommandQueue::default();
{
let mut commands = Commands::new(&mut queue, &world);
drop_stale_atlas_caches(&mut plates, 0, &mut commands);
}
queue.apply(&mut world);
assert_eq!(plates.meshes.len(), 1);
assert_eq!(plates.live.len(), 1);
assert!(world.get_entity(plate).is_ok());
assert_eq!(plates.baked_from, Some(0));
}
/// The steady frame — every frame that is not a re-bake — costs nothing and keeps everything.
#[test]
fn an_unmoved_bake_is_a_no_op() {
let mut world = World::new();
let unit = world.spawn_empty().id();
let plate = world.spawn_empty().id();
let mut plates = primed(Some(3), unit, plate);
let mut queue = bevy::ecs::world::CommandQueue::default();
{
let mut commands = Commands::new(&mut queue, &world);
drop_stale_atlas_caches(&mut plates, 3, &mut commands);
}
queue.apply(&mut world);
assert_eq!(plates.meshes.len(), 1);
assert_eq!(plates.materials.len(), 1);
assert_eq!(plates.live.len(), 1);
assert!(world.get_entity(plate).is_ok());
}
}
+10
View File
@@ -165,9 +165,19 @@ impl Plugin for PlayerPlugin {
// 1160's wire (a), both directions (see `world_focus`): the game answers the world's
// "where do I stream from" before the stream stage, and reads the residency the world
// publishes after it to end its own post-snap hold.
//
// **Both ordering edges are load-bearing** (B263 round 3, decision 1336). `.before(Stream)`
// alone left the publish free to run before `control`'s teleport snap (conflicting access,
// no edge — the executor picks either order), and on the snap frame the streamer then
// streamed around the DEPARTURE position: residency read "world resident", the settle hold
// released on frame 0, and the body free-fell at the destination under the loading screen —
// every same-map `.tele` on the probe machine. `.after(Input)` pins the publish to the
// post-snap position; the schedule contract (snap → focus → stream → release → present)
// is only a contract if every arrow is an explicit edge.
app.add_systems(
Update,
(world_focus::publish_viewer, world_focus::publish_view_focus)
.after(benilla_world::schedule::WorldStage::Input)
.before(benilla_world::schedule::WorldStage::Stream),
)
.add_systems(
+62 -3
View File
@@ -108,10 +108,21 @@ pub(super) fn release_post_snap_hold(
mut last_counters: Local<Option<[usize; 4]>>,
) {
let Some(p) = progress else { return };
// **The facts must be about the ground under our own feet** (decision 1336, B263 round 3).
// `WorldLoadProgress` names the tile it describes; a mismatch means the streamer's focus and
// the avatar diverged — the stale-focus snap frame this guard exists for, or a detached
// free-fly eye — and residency published for another tile must never unfreeze this body. On a
// mismatch the resident release and the stale-clear below are both refused; the stall backstop
// still runs, so a genuinely wedged mismatch costs a logged 6 s timeout, never a silent fall.
let focus_matches = p.focus_tile.is_some_and(|t| {
let wow = benilla_assets::coords::bevy_to_wow(player.pos);
let (tx, ty) = benilla_formats::world_to_tile(wow[0], wow[1]);
t == (tx as i32, ty as i32)
});
// The streamer is the only authority on *which map* the colliders under the avatar belong to.
// Residency here means this map's own tile (or, on a WMO-only map, its one building) is
// spawned — reachable only after a swap has drained every tile of the map we left.
if p.focus_resident && p.total > 0 {
if p.focus_resident && p.total > 0 && focus_matches {
player.world_stale = false;
}
// Did the stream move since last frame? Any counter changing — a tile spawned, a placement
@@ -133,7 +144,7 @@ pub(super) fn release_post_snap_hold(
// still up. Only a stream that has made NO progress for the whole budget — missing data, dead
// IO — can time out now, which is the case the backstop was always for.
let now = time.elapsed_secs();
if p.scene_ready && p.colliders_pending == 0 && !player.world_stale {
if p.scene_ready && p.colliders_pending == 0 && !player.world_stale && focus_matches {
player.end_settle(true, now);
} else if player.world_stale || progressed {
player.settle_deadline = now + SETTLE_TIMEOUT;
@@ -148,12 +159,25 @@ mod tests {
use super::*;
/// The tile under `Player::default()`'s position — what the streamer would publish as
/// `focus_tile` when its focus and the avatar agree (the ordinary, correctly-ordered frame).
fn own_tile() -> (i32, i32) {
let wow = benilla_assets::coords::bevy_to_wow(Player::default().pos);
let (tx, ty) = benilla_formats::world_to_tile(wow[0], wow[1]);
(tx as i32, ty as i32)
}
/// A test app with the release system registered (a registered system keeps its `Local`
/// baseline across frames, which `run_system_once` would reset) and a hand-driven clock.
/// The published progress names the avatar's own tile — each test then describes residency
/// facts that are at least *about* the right place (the mismatch test overrides it).
fn app() -> App {
let mut app = App::new();
app.insert_resource(Time::<()>::default())
.insert_resource(WorldLoadProgress::default())
.insert_resource(WorldLoadProgress {
focus_tile: Some(own_tile()),
..WorldLoadProgress::default()
})
.insert_resource(Player {
settling: true,
settle_deadline: SETTLE_TIMEOUT,
@@ -275,4 +299,39 @@ mod tests {
});
assert!(!settling(&mut app), "presentable world, hold still on");
}
/// B263 round 3 (decision 1336): residency published for ANOTHER tile never releases the hold
/// and never clears the stale flag — the live defect was the focus publish racing the teleport
/// snap, so on the snap frame the streamer described the DEPARTURE city as fully resident and
/// the hold released into free fall at the destination. The facts now name their tile; facts
/// about somewhere else are not facts about the ground under this body.
#[test]
fn residency_about_another_tile_neither_releases_nor_clears_stale() {
let mut app = app();
app.world_mut().resource_mut::<Player>().world_stale = true;
// A fully-resident, quiet world — but described for a tile the avatar is not standing on
// (the departure side of a same-map teleport, one frame stale).
let elsewhere = {
let (tx, ty) = own_tile();
Some((tx + 8, ty))
};
for _ in 0..3 {
step(&mut app, 0.05, |p| {
p.focus_tile = elsewhere;
p.total = 2000;
p.ready = 2000;
p.colliders_pending = 0;
p.scene_ready = true;
p.focus_resident = true;
});
assert!(settling(&mut app), "released on another tile's residency");
assert!(
app.world().resource::<Player>().world_stale,
"the stale flag cleared on another tile's residency"
);
}
// The same facts, now about the right tile: stale clears and the hold ends at once.
step(&mut app, 0.05, |p| p.focus_tile = Some(own_tile()));
assert!(!settling(&mut app), "matching residency must still release");
}
}
@@ -44,6 +44,7 @@ fn report_gate_miss(
now: &[benilla_ui::script::ExtractedQuad],
prev: &[benilla_ui::script::ExtractedQuad],
dims_eq: bool,
bake_eq: bool,
text_ui_eq: bool,
portraits_eq: bool,
) {
@@ -51,6 +52,13 @@ fn report_gate_miss(
eprintln!("[ui-gate] miss: window/scale changed");
return;
}
if !bake_eq {
// Named, because it is the one miss whose cause is a QUARTER SECOND behind the input that
// provoked it (`REBAKE_SETTLE`): a resize that has already settled, re-converting again
// when the atlas it was waiting for finally lands (decision 1339).
eprintln!("[ui-gate] miss: glyph atlas re-baked");
return;
}
if !text_ui_eq {
eprintln!("[ui-gate] miss: focused editbox text-UI changed");
return;
@@ -89,13 +97,16 @@ fn report_gate_miss(
}
/// Last frame's extract-gate inputs (decision 0740), held together as one `Local` — the gate
/// compares all four or none, and a Bevy system has a hard param budget this was eating.
/// compares all of them or none, and a Bevy system has a hard param budget this was eating.
#[derive(Default)]
pub(super) struct GateInputs {
extracted: Vec<benilla_ui::script::ExtractedQuad>,
text_ui: Option<benilla_ui::script::EditBoxTextUi>,
dims: Option<(u32, u32, u32)>,
portraits: std::collections::HashMap<String, crate::portrait::PortraitSource>,
/// The `UiFontAtlas::generation` the held quads' glyph cells were rasterized from — the term
/// that stopped being constant at decision 1296 (see the gate's own note below, decision 1339).
bake: Option<u64>,
}
/// Per frame: screen size → `tick` (OnUpdate) → `resolve` → `extract` → [`UiQuads`]. Script errors
@@ -149,10 +160,20 @@ pub(super) fn drive_script(
ui_scale: Res<super::UiScaleCvar>,
// ── The extract gate's memory (decision 0740): last frame's conversion inputs ─────────────
// The conversion loop below is a pure function of (extracted, text_ui, window dims × seam
// scale, the portrait token map) — the glyph atlas bakes once at Startup and the sprite
// caches are monotone path→handle, so equal inputs reproduce the same `UiQuads` the diff
// would then discard. Capture mode never skips (the harness wants exact per-frame output,
// including the cursor-icon quad's live mouse position).
// scale, the portrait token map, THE ATLAS BAKE) — the sprite caches are monotone
// path→handle, so equal inputs reproduce the same `UiQuads` the diff would then discard.
// Capture mode never skips (the harness wants exact per-frame output, including the
// cursor-icon quad's live mouse position).
//
// The bake term is decision 1339's correction: this list read "the glyph atlas bakes once at
// Startup", which decision 1296 falsified — the atlas now re-bakes whenever the raster
// environment moves, and every glyph quad the conversion emits carries that bake's cell UVs
// and image handle. The two are DEBOUNCED apart by design (`REBAKE_SETTLE` = 0.25 s), which is
// exactly what made the omission bite: the seam moves the instant the window resizes, the UI
// re-converts and settles well inside a quarter second, and *then* the new atlas arrives to a
// gate whose other four inputs all match — so the conversion is skipped and the screen keeps
// the old bake's resampled cells indefinitely. 1296's re-bake landed and nothing on screen
// used it, which is the shape of the reports that survived it.
//
// A [`crate::ui_script::VmMemo`] because a skip is not only a quad-conversion skip: it also skips
// `set_link_spans` and the minimap-slot / booth-pane refills, which are pushes INTO the VM. The
@@ -401,6 +422,7 @@ pub(super) fn drive_script(
let dims = (w.to_bits(), h.to_bits(), s.to_bits());
let settled = capture.is_none()
&& prev.dims == Some(dims)
&& prev.bake == generation
&& text_ui == prev.text_ui
&& booths.images.0 == prev.portraits
&& extracted == prev.extracted;
@@ -443,11 +465,13 @@ pub(super) fn drive_script(
&extracted,
&prev.extracted,
prev.dims == Some(dims),
prev.bake == generation,
text_ui == prev.text_ui,
booths.images.0 == prev.portraits,
);
}
prev.dims = Some(dims);
prev.bake = generation;
prev.text_ui = text_ui.clone();
prev.portraits = booths.images.0.clone();
prev.extracted = extracted.clone();
+138 -20
View File
@@ -241,6 +241,24 @@ fn drawn_k(font_height: Option<f32>, font_size: f32) -> f32 {
}
}
/// A DRAWN-space box, in the SNAPPED space every fit decision is made in — `(width, height)`, both
/// divided by [`drawn_k`].
///
/// The three seams that measure a box against glyphs — [`measure_text`]'s wrap, [`ellipsize_to_fit`]
/// and the emit pass's wrap + line stack — all shape at the nearest baked size and rescale by `k`
/// at the end, while the rect they are handed is in drawn px. They must convert it the same way or
/// they decide different breaks over the same string: the emit pass keeping the raw rect while
/// measure moved into drawn space (1296) is what made an auto-sized FontString wrap inside its own
/// measured width and lose its tail to the line stack, with no ellipsis (decision 1339). One body,
/// so the conversion cannot drift back apart, and so the test that pins it binds this code and not
/// a copy of it (`measure_fits_render`).
///
/// Identity at every baked size — all on-ladder UI text takes an unchanged path.
fn fit_box(rect: Rect, font_height: Option<f32>, font_size: f32) -> (f32, f32) {
let k = drawn_k(font_height, font_size);
(rect.width() / k, rect.height() / k)
}
/// The multiline-EditBox row answer (the 2-D half of
/// [`benilla_ui::script::UiScript::set_editbox_advances`]'s payload): the byte offset where each
/// wrapped display row of `text` begins at `wrap_width` — the exact [`wrap_line`] pass the render
@@ -549,13 +567,12 @@ pub(crate) fn ellipsize_to_fit(
.metrics
.snap_for(&family, font.height.unwrap_or(DEFAULT_FONT_SIZE));
let step_extra = step_extra_of(font.outline);
// The whole fit test runs in SNAPPED space, so the box enters divided by the draw's exact-
// height rescale ([`drawn_k`]) — the rect is drawn px, the pitch and step table below are the
// snapped size, and the draw multiplies its quads by `k`. Measuring the box against unscaled
// glyphs is what let an off-ladder request truncate text that fits (B209; decision 0989's
// named residual). Identity at every baked size.
let k = drawn_k(font.height, font_size);
let (box_w, box_h) = (rect.width() / k, rect.height() / k);
// The whole fit test runs in SNAPPED space, so the box enters through [`fit_box`] — the rect
// is drawn px, the pitch and step table below are the snapped size, and the draw multiplies
// its quads by `k`. Measuring the box against unscaled glyphs is what let an off-ladder
// request truncate text that fits (B209; decision 0989's named residual). The emit pass reads
// its box through the same body, so this seam and the wrap it feeds cannot disagree.
let (box_w, box_h) = fit_box(rect, font.height, font_size);
// The line pitch — identical to the emit pass's ([`layout_text_quads`]: the font em, NO
// outline pad — the byte law, `fontstring-vertical-placement.md`), so the fit test and the
// drawn stack agree line for line.
@@ -797,23 +814,34 @@ fn layout_text_quads_inner(
0.0
};
// The box, in the space this pass lays out in. Everything below — the wrap walk, the line
// stack, the pitch — is computed at the SNAPPED size and rescaled to the true height by `k`
// in one pass at the end ([`drawn_k`]); `rect` arrives in DRAWN px. So a box the wrap or the
// stack is measured against enters divided by `k`, exactly as [`measure_text`] and
// [`ellipsize_to_fit`] take it — this pass and those two must decide the same breaks over the
// same box or a string does not fit the width its own measure reported.
//
// Leaving it raw is what put the Main Menu's labels one character short (B272's sibling,
// decision 1339): 1296 moved measure into drawn space and left this pass in snapped space, so
// an auto-sized FontString — whose rect IS `measure_text`'s answer — wrapped inside its own
// box at `k < 1`, and the stack below then dropped the overflow line with no ellipsis to show
// for it ("Options" → "Option", "Return to Game" → "Return to"). The error is `w·(1−k)`, so it
// grows with the string and the +1 px of measure headroom stops covering it.
//
// The justify offsets below deliberately keep the raw `rect`: they position the block by an
// ANCHOR the final rescale pivots about, so the drawn-space rect term cancels exactly (Left
// and Top are the anchor; Center/Middle and Right/Bottom cancel to ±half the block). Only the
// two *fit* decisions here are measurements, and only they convert.
let (fit_w, fit_h) = fit_box(rect, font.height, font_size);
// Word-wrap within the rect when it carries a pinned width; an unsized single-point FontString
// resolves to a ~zero-width rect (no constraint) and keeps its single-line behavior. Wrapping
// runs first, fully into `render_lines`, so its `&mut atlas.font_system` borrow is released
// before the emit pass takes it again.
let mut render_lines: Vec<Vec<ColorRun>> = if rect.width() > WRAP_MIN_WIDTH {
let mut render_lines: Vec<Vec<ColorRun>> = if fit_w > WRAP_MIN_WIDTH {
lines
.iter()
.flat_map(|line| {
wrap_line(
&atlas.metrics,
&family,
font_size,
step_extra,
line,
rect.width(),
)
})
.flat_map(|line| wrap_line(&atlas.metrics, &family, font_size, step_extra, line, fit_w))
.collect()
} else {
lines
@@ -824,8 +852,8 @@ fn layout_text_quads_inner(
// truncated fits by construction, so this is the belt under any caller that skips that seam —
// in the client the same split: the ui truncate feeds the gx string, and the gx line stack
// clamps regardless. The pitch is the font em (the emit's `pitch` below — same law).
if rect.height() > HEIGHT_LIMIT_MIN {
render_lines.truncate(overflow::lines_allowed(rect.height(), font_size));
if fit_h > HEIGHT_LIMIT_MIN {
render_lines.truncate(overflow::lines_allowed(fit_h, font_size));
}
let mut quads = Vec::new();
@@ -1245,6 +1273,96 @@ mod row_start_tests {
///
/// Run with `cargo test --release -p benilla-app -- --ignored --nocapture ellipsis_cost`. Skips
/// without an install.
/// **A string fits the width its own measure reported** — the invariant that binds
/// [`measure_text`] to the emit pass's wrap, over the real client fonts.
///
/// An auto-sized FontString has no width of its own: the engine's measure round-trip
/// (`ui_script::extract`) sizes its rect *from* [`measure_text`], and [`layout_text_quads`] then
/// re-wraps inside that rect. So the two must decide the same breaks over the same box, or a
/// string wraps inside itself — and the emit pass's line stack drops the overflow row with no
/// ellipsis to show for it, which is text silently losing its tail.
///
/// They stopped agreeing at 1296, which moved measure into DRAWN space (`×k`) and left the emit
/// pass measuring the drawn-space rect against the SNAPPED step table. The gap is `w·(1−k)`, so it
/// grows with the string while the measure's headroom stays one pixel: short labels survived and
/// long ones lost a character. The Main Menu — every label auto-sized, the whole window wearing
/// `SetScale(ERA_WINDOW_SCALE)` = 0.78 so nothing lands on the ladder — showed it whole:
/// `Options` → `Option`, `Return to Game` → `Return to` (B272's sibling, decision 1339).
#[cfg(test)]
mod measure_fits_render {
use super::*;
/// Friz Quadrata on a deliberately sparse ladder, so a request lands between bakes the way
/// every request does under a scaled frame. 16 snaps UP to 20: `k = 0.8`, the sign that
/// truncates (the emit pass's snapped glyphs are 25 % wider than what it draws).
const FACE: &str = "Fonts\\FRIZQT__.TTF";
const LADDER: &[f32] = &[10.0, 20.0];
const REQ: f32 = 16.0;
/// Every label the Main Menu draws, plus the two that survived — the shipped strings, so a
/// regression reads as the screenshot did.
const LABELS: &[&str] = &[
"Options",
"Support",
"Macros",
"Logout",
"Return to Game",
"Exit Game",
"Key Bindings",
"Edit",
"Main Menu",
];
#[test]
fn a_string_fits_the_width_its_own_measure_reported() {
let Some(metrics) = super::super::atlas::test_metrics(&[(FACE, LADDER)], 1.0) else {
eprintln!("skipping: no install / font chain");
return;
};
let family = metrics.family_for(Some(FACE));
let font_size = metrics.snap_for(&family, REQ);
let k = drawn_k(Some(REQ), font_size);
assert!(k < 1.0, "the truncating sign: {REQ} must snap UP (k = {k})");
let step_extra = step_extra_of(Outline::None);
let spec = FontSpec {
path: Some(FACE),
height: Some(REQ),
outline: Outline::None,
paint_halo: true,
alpha_gradient: None,
};
let mut raw_wrapped = 0usize;
for label in LABELS {
// The rect an auto-sized FontString gets: measure's own answer, in drawn px.
let measured = measure_text(&metrics, label, None, spec).0;
let runs = parse_markup(label, [1.0, 1.0, 1.0, 1.0]);
let line = runs.first().expect("one line");
// The emit pass's wrap, over the box read through the SAME body it reads it through
// ([`fit_box`]) — so putting the raw rect back fails here, not just a copy of it.
let rect = Rect::new(0.0, 0.0, measured, REQ);
let (fit_w, _) = fit_box(rect, spec.height, font_size);
let rows = wrap_line(&metrics, &family, font_size, step_extra, line, fit_w);
assert_eq!(
rows.len(),
1,
"{label:?} wrapped inside its own measured width ({measured} drawn px)"
);
// The mutation check, welded in: measuring that same box RAW — what shipped — wraps
// the longer labels, and the emit pass's stack then eats the row it made.
let raw = wrap_line(&metrics, &family, font_size, step_extra, line, measured);
raw_wrapped += usize::from(raw.len() > 1);
}
assert!(
raw_wrapped > 0,
"the raw-box mutation must still break at least one label — otherwise this test \
cannot fail on the bug it pins"
);
}
}
#[cfg(test)]
mod ellipsis_cost {
use super::*;
+143 -12
View File
@@ -9,8 +9,11 @@
//! bound to **V / Shift-V** by FrameXML `Bindings.xml` (asset-sourced default, like TAB).
//! - **Gate**: never the own unit; never `NOT_SELECTABLE` (UNIT_FIELD_FLAGS bit 25); enemy bit
//! covers reaction ≤ neutral, friendly bit ≥ friendly; **max 20 yd**, hardcoded; **no
//! occlusion** (a 2-D overlay — plates draw through walls); snap, no smoothing; no
//! anti-overlap. (The friendly-totem exclusion waits on totems existing.)
//! occlusion** (a 2-D overlay — plates draw through walls); snap, no smoothing. Anti-overlap
//! there *is*, since 0367 found the shared solver ([`crate::smart_rect`]) — this file's older
//! "no anti-overlap" was the census that missed it. And the sphere is bounded by the frustum:
//! the unit must be **in view** ([`in_view`]), or its plate is not seated at all.
//! (The friendly-totem exclusion waits on totems existing.)
//! - **Anchor**: the same overhead head point (`0x608640`, [`overhead_anchor`]) **+ 2/3 yd**
//! (`[0x80abfc]`), projected per frame (`0x483ee0`); the plate's **TOP-CENTER** lands on the
//! point (it hangs below — §8 Q5) and is edge-clamped half a plate inside the screen;
@@ -230,6 +233,50 @@ fn con_color(pl_level: u32, unit_level: u32) -> [f32; 4] {
}
}
/// Snap a logical-pixel coordinate onto the **device** pixel grid — the plate's texel alignment
/// (a benilla divergence: the reference draws at fractional device coords).
///
/// The plate rect is snapped so the sharp-resampled border art blits 1:1 and reads crisp instead
/// of bilinear-smeared (0188). Alignment is a property of the **framebuffer**, though, and the
/// quad lane is *logical* px ([`crate::ui_pass`]: 1 world unit = 1 logical px) — so the plain
/// `round()` this shipped with quantized the plate to `scale_factor` PHYSICAL pixels: two pixels
/// of stepping on the 2× display we develop and play on, against a world that slides continuously
/// underneath. At a fractional scale (1.25/1.5, the Windows norm) it did not land on a texel
/// boundary at all — the crispness it was bought for was an accident of an integer scale factor.
///
/// Snapping on the device grid keeps the 1:1 blit, is correct at any scale factor, and halves the
/// stepping at 2×: measured over a Goldshire walk at 1440×810 (the `vpl` trace), the extra
/// displacement the snap adds to a plate's glide fell from a median 0.33 / max 0.99 logical px per
/// frame to 0.21 / 0.49. What is left is the ±½ device pixel every crisp UI pays.
fn device_snap(v: f32, scale: f32) -> f32 {
(v * scale).round() / scale
}
/// **Is the unit in view?** — the gate between the 20-yd sphere and the seat.
///
/// The plate gate is a 20-yard *sphere*; the view is a 45° frustum, so most of what the sphere
/// admits is beside or behind you. That would be harmless if an off-screen desire simply drew
/// off-screen — but the seat's on-screen clamp ([`crate::smart_rect`]'s `normalize`, the ref's
/// `0x509e20`) does not merely tidy a plate straddling an edge: it **translates a rect from
/// anywhere bodily onto the screen**. Without this gate a unit two screens off to the side got a
/// phantom plate pinned to the border — measured walking Goldshire at 1440×810 (the `vpl` trace,
/// [`drive_vplates`]): **30% of every drawn plate while moving, two thirds while standing still**.
/// They are not passive either: each claims bucket 0, so the anti-overlap solve pushed the plates
/// of units you *can* see off phantoms you cannot, and they were live hover targets through
/// [`PlateRects`].
///
/// That was the whole of the reported jitter — 26% of frame-steps moved a plate more than 4 px
/// past its own projected motion, up to 300 px in a single frame, and 90% of those involved a
/// phantom (after: 3.5% and 51 px, which is the solver's own documented snap-back). The projection
/// is smooth throughout — median frame-to-frame acceleration 0.05 px — so none of it was ever a
/// camera or an anchor problem.
///
/// The clamp keeps its job: a plate whose unit *is* in view still gets pulled fully inside the
/// border, which is what it is for.
fn in_view(screen: Vec2, viewport: Vec2) -> bool {
(0.0..=viewport.x).contains(&screen.x) && (0.0..=viewport.y).contains(&screen.y)
}
/// The knee of the plate's gx basis — **a director-pinned DEVIATION from the byte law**
/// (0185/0186). The real client's plates grow diagonal-linear without limit (wow-re §9,
/// byte-closed: ~294 px plate + em-29 name at 2560×1440, bilinear-softened) — the director
@@ -531,8 +578,11 @@ fn drive_vplates(
if !friendly && store.is_some_and(|s| s.0.unit_reads_dead()) {
continue;
}
// Project the plate point: the overhead anchor + 2/3 yd, per frame, no smoothing. A
// point behind the camera draws nothing (the plate has no clamp — offscreen is gone).
// Project the plate point: the overhead anchor + 2/3 yd, per frame, no smoothing. Behind
// the camera the projection itself fails and the plate is gone. (This comment used to
// claim "the plate has no clamp — offscreen is gone", which had not been true since the
// seat gained the solver's on-screen normalize: offscreen was dragged to the border. The
// gate below is what makes the sentence true again.)
let anchor = overhead_anchor(
entity,
tf,
@@ -544,9 +594,14 @@ fn drive_vplates(
let Ok(screen) = cam.world_to_viewport(&cam_tf, anchor + Vec3::Y * PLATE_LIFT) else {
continue;
};
// …and the unit has to be in view ([`in_view`]).
if !in_view(screen, viewport) {
continue;
}
cands.push((
screen.distance_squared(sort_pt),
screen,
anchor,
rank,
is_player,
entity,
@@ -555,7 +610,7 @@ fn drive_vplates(
));
}
cands.sort_by(|a, b| a.0.total_cmp(&b.0));
for (_, screen, rank, is_player, entity, guid, store) in cands {
for (_, screen, anchor, rank, is_player, entity, guid, store) in cands {
plates.0.insert(entity);
// The target-highlight relative alpha: target (or nobody targeted) opaque, others 0x7F.
@@ -591,14 +646,48 @@ fn drive_vplates(
screen.y + ph,
);
let solved = bucket.resolve(desired, viewport);
// The rect SNAPS to whole logical pixels (benilla divergence, texel alignment): the
// solved corner is fractional and a half-pixel offset would bilinear-smear the fill
// bitmap (and blur text). Snap the LEFT edge (not the center): an odd width would put a
// snapped center's edges back on half-pixels. The CLAIM takes the snapped rect, so
// later plates dodge exactly what is drawn.
let top = solved.min.y.round();
let left = solved.min.x.round();
// The rect snaps to the device pixel grid ([`device_snap`]) — the LEFT edge, not the
// centre: an odd width would put a snapped centre's edges back between texels. The CLAIM
// takes the snapped rect, so later plates dodge exactly what is drawn.
let top = device_snap(solved.min.y, scale);
let left = device_snap(solved.min.x, scale);
let plate = Rect::new(left, top, left + pw, top + ph);
// **The jitter decomposition** (`WOW_MOVE_TRACE` tag `vpl`, one line per plate per frame):
// the world anchor, the camera pose that projected it, the raw projected point, the
// solver's answer, and the snapped rect — so "the plates are jittery when moving" is
// attributed to one of the four (a moving anchor, a noisy camera, a flipping solve, the
// pixel snap) from numbers rather than from the eye. It rides the shared trace — tag-
// filtered, one line per plate — and not the `WOW_VPLATE_TRACE` eprintlns beside it, whose
// four unbuffered writes per plate per frame would distort the frame pacing the question
// is about (the 0880 lesson).
if benilla_assets::trace::enabled_for("vpl") {
let (cp, cf) = (cam_pose.translation, cam_pose.forward());
benilla_assets::trace::line(
"vpl",
&format!(
"e={} vp=({:.0},{:.0}) anchor=[{:.4},{:.4},{:.4}] cam=[{:.4},{:.4},{:.4}] \
fwd=[{:.4},{:.4},{:.4}] scr=({:.3},{:.3}) solved=({:.3},{:.3}) plate=({:.1},{:.1})",
entity.index(),
viewport.x,
viewport.y,
anchor.x,
anchor.y,
anchor.z,
cp.x,
cp.y,
cp.z,
cf.x,
cf.y,
cf.z,
screen.x,
screen.y,
solved.min.x,
solved.min.y,
plate.min.x,
plate.min.y,
),
);
}
bucket.claim(plate);
rects.0.push((plate, entity));
let hover = cursor.is_some_and(|c| plate.contains(c));
@@ -935,6 +1024,48 @@ mod tests {
assert_eq!(CON_GREEN[1], 178.0 / 255.0);
}
/// The in-view gate: the 20-yd sphere reaches far outside the frustum, and everything it
/// admits from out there used to be dragged onto a screen border by the seat's clamp. The
/// bounds are inclusive — a unit exactly on the edge is still in view, and the clamp (not this)
/// is what keeps its plate fully inside.
#[test]
fn only_a_unit_in_view_gets_a_plate() {
let vp = Vec2::new(1440.0, 810.0);
assert!(in_view(Vec2::new(720.0, 405.0), vp));
assert!(in_view(Vec2::ZERO, vp), "the corner is in view");
assert!(in_view(vp, vp), "so is the far corner");
for out in [
Vec2::new(-0.5, 405.0), // off left
Vec2::new(1440.5, 405.0), // off right
Vec2::new(720.0, -0.5), // above
Vec2::new(720.0, 810.5), // below
Vec2::new(1409.0, 1332.0), // the measured phantom: two thirds of a screen below
] {
assert!(!in_view(out, vp), "{out:?} is not on screen");
}
}
/// The snap is on the DEVICE grid, not the logical one. At the 2× scale we develop on, a
/// logical `round()` moved the plate two physical pixels at a time; this moves it one, which
/// is the smallest step that still lands the border blit on a texel boundary. Pinned so the
/// `scale` argument can't be "simplified" away back into `round()`.
#[test]
fn the_plate_snaps_on_the_device_grid_not_the_logical_one() {
// 2× display: the grid is every half logical pixel, and every snapped value is a whole
// number of PHYSICAL pixels.
for (v, want) in [(10.0, 10.0), (10.2, 10.0), (10.3, 10.5), (10.6, 10.5)] {
let got = device_snap(v, 2.0);
assert_eq!(got, want, "{v} at 2×");
assert_eq!((got * 2.0).fract(), 0.0, "{got} is a whole physical pixel");
}
// 1×: unchanged from the old law.
assert_eq!(device_snap(10.4, 1.0), 10.0);
assert_eq!(device_snap(10.6, 1.0), 11.0);
// 1.5× (the Windows norm), where a logical round() was never texel-aligned at all.
assert_eq!((device_snap(10.4, 1.5) * 1.5).fract(), 0.0);
assert_eq!((device_snap(10.9, 1.5) * 1.5).fract(), 0.0);
}
/// Plate text ems ride the gx DIAGONAL like the frame geometry, growth-damped past the
/// director's [`PLATE_DIAG_KNEE`] — name em 13 at the reference's 1152×648 window (measured),
/// level em 11 (director-pinned one under the byte 0.009); at larger windows the em lands midway
+100 -10
View File
@@ -8,7 +8,16 @@ use benilla_ui::script::{ItemTextState, UiScript};
const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
const FILES: [&str; 3] = ["Fonts.xml", "UiPanels.xml", "ItemTextFrame.xml"];
/// The reader's dependency prefix, in the manifest's own order. `ScrollTemplates.xml` and
/// `UIPanelTemplates.xml` joined it with decisions 1337/1338: the page sits in a real ScrollFrame
/// now, whose template is in the second and whose `ScrollFrame_OnLoad` is in the first.
const FILES: [&str; 5] = [
"Fonts.xml",
"UiPanels.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"ItemTextFrame.xml",
];
fn load_ui(script: &UiScript) {
let dir = std::path::Path::new(UI_DIR);
@@ -35,6 +44,24 @@ fn load_ui(script: &UiScript) {
}
}
/// The page body as the reader actually DRAWS it, one string per block.
///
/// `ItemTextPageText` is a `SimpleHTML` since decisions 1337/1338, and 5875's SimpleHTML has no
/// `GetText` — its Lua table is 19 entries and none of them is a text getter (wow-re
/// `simplehtml-markup-engine.md` §5.1; later clients grew one, this one has not). So the page is
/// read the way it is seen: off the render list. A plain body is one block through the engine's
/// raw-text fallback, which is what every letter here is.
fn page_blocks(s: &UiScript) -> Vec<String> {
use benilla_ui::script::QuadContent;
s.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Text { text, .. } => text,
_ => None,
})
.collect()
}
fn letter() -> ItemTextState {
ItemTextState {
item: "Plain Letter".into(),
@@ -63,11 +90,10 @@ fn a_letter_reads_with_the_creator_tail() {
);
s.fire_event("ITEM_TEXT_READY", vec![]);
assert!(s.eval::<bool>("return ItemTextFrame:IsShown()").unwrap());
assert_eq!(
s.eval::<String>("return ItemTextPageText:GetText()")
.unwrap(),
"\nasd\n\nFrom,\nOne\n\n",
"the reference creator tail (ITEM_TEXT_FROM really is comma'd)"
assert!(
page_blocks(&s).contains(&"\nasd\n\nFrom,\nOne\n\n".to_string()),
"the reference creator tail (ITEM_TEXT_FROM really is comma'd), drawn as one raw block: {:?}",
page_blocks(&s)
);
for hidden in [
"ItemTextCurrentPage",
@@ -134,10 +160,10 @@ fn a_book_page_shows_the_paging_chrome() {
}));
s.fire_event("ITEM_TEXT_BEGIN", vec![]);
s.fire_event("ITEM_TEXT_READY", vec![]);
assert_eq!(
s.eval::<String>("return ItemTextPageText:GetText()")
.unwrap(),
"\npage one\n"
assert!(
page_blocks(&s).contains(&"\npage one\n".to_string()),
"the page body drawn: {:?}",
page_blocks(&s)
);
assert_eq!(
s.eval::<String>("return ItemTextCurrentPage:GetText()")
@@ -176,3 +202,67 @@ fn closing_queues_the_close_intent() {
assert!(!s.eval::<bool>("return ItemTextFrame:IsShown()").unwrap());
assert!(s.take_errors().is_empty());
}
/// **B240's render half, on the reported page.** Goudy's plaque body (`page_text` 2676, the
/// *Alliance Military Ranks* wall plaque in Stormwind's Old Town) went through the reader and came
/// out as its own source — `<HTML><BODY><H1 align="center">…` drawn literally, and cut off with
/// "..." partway down. Both were the page being a plain FontString where the reference has a
/// `SimpleHTML` (decisions 1337/1338).
///
/// What this pins is the whole path the report exercises: the app's page feed → the reader's
/// `"\n" .. body .. "\n"` padding → the markup parse → the drawn blocks. The falsification is
/// stated at the bottom: if the parse ever regresses to the raw-text fallback, the page draws as
/// one block that still has angle brackets in it, which is exactly what was reported.
#[test]
fn the_reported_html_page_draws_as_blocks_not_as_its_own_markup() {
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_item_text(Some(ItemTextState {
item: "Alliance Military Ranks".into(),
creator: None,
text: "<HTML>\n<BODY>\n\
<H1 align=\"center\">ALLIANCE MILITARY RANKS</H1><BR/>\n\
<P align=\"center\">OFFICERS</P><BR/>\n\
<P align=\"center\">Grand Marshal</P>\n\
<P align=\"center\">Knight</P><BR/>\n\
<P align=\"center\">ENLISTED</P><BR/>\n\
<P align=\"center\">Private</P>\n\
</BODY>\n</HTML>"
.into(),
page: 1,
has_next: false,
material: None,
}));
s.fire_event("ITEM_TEXT_BEGIN", vec![]);
s.fire_event("ITEM_TEXT_READY", vec![]);
assert!(s.eval::<bool>("return ItemTextFrame:IsShown()").unwrap());
let drawn = page_blocks(&s);
for line in [
"ALLIANCE MILITARY RANKS",
"OFFICERS",
"Grand Marshal",
"Knight",
"ENLISTED",
"Private",
] {
assert!(
drawn.contains(&line.to_string()),
"{line:?} should draw as its own block; drawn: {drawn:?}"
);
}
// The reported symptom, stated as the thing that must not come back.
assert!(
!drawn
.iter()
.any(|b| b.contains("<HTML>") || b.contains("<P align")),
"the markup itself must never reach the page — that is what was photographed: {drawn:?}"
);
// And the second half of the same symptom: the body is no longer a height-pinned FontString,
// so decision 1332's ellipsis seam cannot cut a long page short. No block ends in the
// truncation marker.
assert!(
!drawn.iter().any(|b| b.ends_with("...")),
"a block was truncated — the page is height-pinned again: {drawn:?}"
);
}
+12 -1
View File
@@ -208,7 +208,18 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// it stopped two engine facts hiding inside instrument modules this test deliberately does not
/// count. The items were always crossing; the measurement improved. That is the only kind of raise
/// that is not a retreat — a raise for a NEW leak is the failure this number exists to catch.
const CEILING: usize = 154;
///
/// And 154 → 155: `modkeys::SyntheticHold`, a PUBLISH by 1164's test rather than a leak. The
/// engine owns the macOS stuck-modifier reconciler, and that reconciler decides by polling the
/// **hardware** flag state — which by construction reads "up" for a key no hand is on. So a
/// synthesized press (the probe harness's `WOW_PROBE_KEY`) was released the frame after it was
/// made, and logged as a stuck-key correction: every chord binding was silently unreachable
/// headlessly on the platform we develop on. "Something is deliberately holding this key" has
/// exactly one reader — the reconciler, engine-side — and its writers are whoever synthesizes
/// input, which is game-side; a one-field resource is the smallest honest expression of that. The
/// alternative was ordering a game system against an engine system, which crosses this same line
/// *and* publishes a schedule point to do it.
const CEILING: usize = 155;
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
+1
View File
@@ -800,6 +800,7 @@ impl Loader<'_> {
self.apply_button(el, wrapper, self_name, dbg_name);
self.apply_editbox(el, wrapper, dbg_name);
self.apply_messageframe(el, wrapper, dbg_name);
self.apply_simplehtml(el, wrapper, dbg_name);
// 6 · <Scripts> handlers (rf24 `0x769ef0`); OnLoad is captured to fire bottom-up below.
let onload = self.apply_scripts(el, wrapper, dbg_name);
+10 -2
View File
@@ -98,6 +98,14 @@ impl Loader<'_> {
parent_name: &str,
dbg: &str,
) {
// **A `<SimpleHTML>`'s direct-child `<FontString>` is not a region at all** — it is the
// declaration of that widget's `P` element font (`0x78a1fe` → `CSimpleFont::LoadXML`), and
// `Loader::apply_simplehtml` owns it. Creating a real FontString here instead would leave
// an unanchored, textless string on the frame AND leave `elementFont[0]` empty, which is
// the one thing that makes every block fall back to the renderer's default face.
if el.tag.eq_ignore_ascii_case("SimpleHTML") {
return;
}
for region in &el.children {
if !region.tag.eq_ignore_ascii_case("FontString") {
continue; // <Layers>/<Frames>/<Scripts>/... are handled by their own passes
@@ -317,7 +325,7 @@ impl Loader<'_> {
/// the single-font reality (a region cannot wear two faces) without pretending the list cannot
/// exist. Depth is bounded: a template registry can contain a cycle, and a loader that hangs on
/// a malformed addon is worse than one that gives up on it.
fn font_object_through_templates(&self, inherits: &str) -> Option<String> {
pub(super) fn font_object_through_templates(&self, inherits: &str) -> Option<String> {
// **The probes here stay case-EXACT, and the outcome is still folded.** Font names match
// case-insensitively in 1.12 (`0x783870`, `SStrCmpI`), and that fold lives in
// `Model::font_object` — so a miss here falls through to the caller's
@@ -359,7 +367,7 @@ impl Loader<'_> {
/// `inherits=`'s affordance alone.
///
/// Case-folded, because 1.12 matches font names with `SStrCmpI` (`0x783870`).
fn is_font_object(&self, name: &str) -> bool {
pub(super) fn is_font_object(&self, name: &str) -> bool {
let model = self.model();
let fonts = model.framexml_fonts.borrow();
fonts.contains_key(name) || fonts.keys().any(|k| k.eq_ignore_ascii_case(name))
+104
View File
@@ -1294,6 +1294,110 @@ mod loader_tests {
assert!(!is_global_string_key(no), "{no} is not key-shaped");
}
}
// ── <SimpleHTML> (the markup widget's XML element) ───────────────────────────────────────
/// **`<SimpleHTML>`'s `<FontString>` child is a font DECLARATION, not a region** — the shape
/// stock `ItemTextFrame.xml` uses, and the whole reason its `<H1>` renders at the `<P>` size.
///
/// Four claims in one document: the element materializes as a real `SimpleHTML` frame with its
/// `<Size>`/`<Anchors>`/`<Scripts>` applied like any frame; the direct-child `<FontString
/// inherits=>` lands on `elementFont[0]` rather than creating a FontString region; a
/// `<FontStringHeader1>` lands on `elementFont[1]`; and `hyperlinkFormat=` reaches `+0x360`.
#[test]
fn simplehtml_xml_declares_element_fonts_not_regions() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Font name="ItemTextFontNormal" font="Fonts\MORPHEUS.TTF" justifyH="LEFT">
<FontHeight val="15"/>
<Color r="0.18" g="0.12" b="0.06"/>
</Font>
<Font name="BookHeader" font="Fonts\SKURRI.TTF">
<FontHeight val="24"/>
</Font>
<SimpleHTML name="PageText" hyperlinkFormat="|H%s|h[%s]|h">
<Size><AbsDimension x="270" y="304"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<FontString inherits="ItemTextFontNormal"/>
<FontStringHeader1 inherits="BookHeader"/>
<Scripts><OnLoad>LOADED = this:GetName()</OnLoad></Scripts>
</SimpleHTML>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert_eq!(s.eval::<String>("return LOADED").unwrap(), "PageText");
assert_eq!(
s.eval::<String>("return PageText:GetObjectType()").unwrap(),
"SimpleHTML"
);
assert_eq!(
s.eval::<String>("return PageText:GetHyperlinkFormat()")
.unwrap(),
"|H%s|h[%s]|h"
);
// The two declarations landed on their own elements…
assert_eq!(
s.eval::<(String, f32)>("local p, h = PageText:GetFont(); return p, h")
.unwrap(),
("Fonts\\MORPHEUS.TTF".to_string(), 15.0)
);
assert_eq!(
s.eval::<(String, f32)>("local p, h = PageText:GetFont(\"H1\"); return p, h")
.unwrap(),
("Fonts\\SKURRI.TTF".to_string(), 24.0)
);
// …and NEITHER became a region. A `<FontString>` handled by the generic special-fontstring
// pass would leave an unanchored, textless string on the frame here.
let regions = {
let lua = s.lua();
let model = lua
.app_data_ref::<crate::script::Model>()
.expect("model app_data");
let fh = model.arena.lookup("PageText").expect("frame");
model.arena.frame(fh).expect("live").regions.len()
};
assert_eq!(regions, 0, "the font declarations created no regions");
// Now drive it: three blocks, the H1 in its OWN declared face, at the frame's width.
s.run(
r#"PageText:SetText("<HTML><BODY><H1>Title</H1><P align=\"center\">Body</P></BODY></HTML>")"#,
)
.unwrap();
let (kinds, fonts, widths) = {
let lua = s.lua();
let model = lua
.app_data_ref::<crate::script::Model>()
.expect("model app_data");
let fh = model.arena.lookup("PageText").expect("frame");
let blocks = &model.simple_html.get(&fh).expect("state").blocks;
(
blocks.len(),
blocks
.iter()
.map(|rh| model.region_data[rh].font_path.clone())
.collect::<Vec<_>>(),
blocks
.iter()
.map(|rh| model.region_data[rh].size)
.collect::<Vec<_>>(),
)
};
assert_eq!(kinds, 2);
assert_eq!(
fonts,
[
Some("Fonts\\SKURRI.TTF".to_string()),
Some("Fonts\\MORPHEUS.TTF".to_string())
],
"a DECLARED header font wins; the P falls through to its own"
);
assert_eq!(widths, [Some((270.0, 0.0)); 2]);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
}
mod layer_blend_tests {
+127 -1
View File
@@ -2,7 +2,7 @@ use mlua::{ObjectLike, Table};
use crate::framexml::{self, Element};
use super::{children_named, color_of, tex_coords_of, Loader};
use super::{abs_dim, abs_value, children_named, color_of, tex_coords_of, Loader};
impl Loader<'_> {
/// `<StatusBar>` LoadXML extras (RF-28, byte-verified table `0x782ef0`): `minValue`/`maxValue`
@@ -435,4 +435,130 @@ impl Loader<'_> {
}
}
}
/// `<SimpleHTML>` LoadXML extras — `CSimpleHTML::LoadXML 0x78a130`, whose whole job past the
/// base `CSimpleFrame::LoadXML 0x769820` is to fill the four **element fonts** and the
/// hyperlink format (wow-re `simplehtml-markup-engine.md` §5.5):
///
/// - attribute **`font="NAME"`** (`0x78a152`) — looked up as a font object and `SetFontObject`ed
/// onto **all four** elements at once (the `edi = 4` loop at `0x78a17a`), with
/// `"Couldn't find font object named %s"` on a miss.
/// - child **`<FontString>`** → `elementFont[0]` (`P`), **`<FontStringHeader1|2|3>`** →
/// `[1]`/`[2]`/`[3]` (`0x78a1fe`…`0x78a26e`), each through `CSimpleFont::LoadXML 0x783c30`.
/// - attribute **`hyperlinkFormat`** (`0x87a87c` → `0x78a540`) and attribute **`file`**
/// (`0x8710b8`), the latter a localized-string lookup fed straight to `SetText`.
///
/// **These `<FontString>` children are font DECLARATIONS, not regions**, which is why
/// [`Self::apply_special_fontstrings`] skips a `<SimpleHTML>`: creating a real FontString for
/// one would put an extra, unanchored string on the frame and leave the element font empty.
///
/// Stock `ItemTextFrame.xml` declares exactly one `<FontString inherits="ItemTextFontNormal"/>`,
/// which is the whole reason an `<H1>` in a `page_text` body renders at the `<P>` size: nothing
/// declares a header font, so `0x78ae30`'s empty-path test sends every element back to `P`'s.
pub(super) fn apply_simplehtml(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
if !el.tag.eq_ignore_ascii_case("SimpleHTML") {
return;
}
if let Some(fmt) = el.attr("hyperlinkFormat") {
self.call(wrapper, "SetHyperlinkFormat", fmt.to_string(), dbg);
}
// `font=` on the widget itself paints all four elements; a per-element child below can
// still override any of them, exactly as the reference's ordering allows.
if let Some(name) = el.attr("font").filter(|n| !n.is_empty()) {
if self.is_font_object(name) {
for elem in ["P", "H1", "H2", "H3"] {
self.call(wrapper, "SetFontObject", (elem, name.to_string()), dbg);
}
} else {
self.warn_once(
&format!("shtmlfont:{name}"),
format!("{dbg}: <SimpleHTML font=\"{name}\">: couldn't find font object"),
);
}
}
for child in &el.children {
let Some(elem) = crate::script::simplehtml_element_of_xml_tag(&child.tag) else {
continue; // <Size>/<Anchors>/<Scripts>/<Layers>/... have their own passes
};
let child = &self.expand_region(child);
self.apply_element_font(child, wrapper, elem, dbg);
}
if let Some(raw) = el.attr("file") {
let text = self.resolve_text(raw, dbg);
self.call(wrapper, "SetText", text, dbg);
}
}
/// One `<FontString>`/`<FontStringHeaderN>` child of a `<SimpleHTML>` → one element font.
///
/// The `inherits=` → `font=` gate is the same three-outcome law a `<Layers>` `<FontString>`
/// takes ([`Self::apply_fontstring_font`], `0x7710e1`-`0x771254`): a `font=` naming a registered
/// object is a `SetFontObject` and **skips** `<FontHeight>`/`outline=` entirely; a `font=`
/// naming anything else is a file with those two as its companions; no `font=` at all means
/// neither is ever parsed. Only `<Color>`/`<Shadow>`/`justifyH`/`justifyV`/`spacing` sit past
/// that join and are genuine post-link overrides.
fn apply_element_font(&mut self, el: &Element, wrapper: &Table, elem: usize, dbg: &str) {
let name = ["P", "H1", "H2", "H3"][elem];
if let Some(inherits) = el.attr("inherits").filter(|n| !n.is_empty()) {
let resolved = self
.font_object_through_templates(inherits)
.unwrap_or_else(|| inherits.to_string());
self.call(wrapper, "SetFontObject", (name, resolved), dbg);
}
match el.attr("font") {
Some(f) if self.is_font_object(f) => {
self.call(wrapper, "SetFontObject", (name, f.to_string()), dbg);
}
Some(path) => {
let height = children_named(el, "FontHeight").last().and_then(abs_value);
let outline = el.attr("outline").map(str::to_string);
if let Err(e) = crate::script::apply_simplehtml_font_parts(
self.lua,
wrapper,
elem,
Some(path.to_string()),
height,
outline,
) {
self.report
.errors
.push(format!("{dbg}: <SimpleHTML> {name} font attrs: {e}"));
}
}
None => {}
}
if let Some(c) = children_named(el, "Color").last().map(color_of) {
self.call(wrapper, "SetTextColor", (name, c[0], c[1], c[2], c[3]), dbg);
}
if let Some(sh) = children_named(el, "Shadow").last() {
if let Some(c) = children_named(sh, "Color").next().map(color_of) {
self.call(
wrapper,
"SetShadowColor",
(name, c[0], c[1], c[2], c[3]),
dbg,
);
}
if let Some((x, y)) = children_named(sh, "Offset").next().map(abs_dim) {
self.call(
wrapper,
"SetShadowOffset",
(name, x.unwrap_or(0.0), y.unwrap_or(0.0)),
dbg,
);
}
}
if let Some(j) = el.attr("justifyH") {
self.call(wrapper, "SetJustifyH", (name, j.to_string()), dbg);
}
if let Some(j) = el.attr("justifyV") {
self.call(wrapper, "SetJustifyV", (name, j.to_string()), dbg);
}
if let Some(sp) = el
.attr("spacing")
.and_then(|v| v.trim().parse::<f32>().ok())
{
self.call(wrapper, "SetSpacing", (name, sp), dbg);
}
}
}
+7 -1
View File
@@ -100,6 +100,7 @@ mod saved;
mod scrollframe;
mod session;
mod shapeshift;
mod simplehtml;
mod skills;
mod slash;
mod slider;
@@ -169,6 +170,10 @@ pub(crate) use region::{apply_font_parts, implicit_creation_anchor_lua};
pub use reputation::{FactionEntry, ReputationSend, ReputationState};
pub use session::SessionRequest;
pub use shapeshift::ShapeshiftFormView;
pub(crate) use simplehtml::{
apply_element_font_parts as apply_simplehtml_font_parts,
element_of_xml_tag as simplehtml_element_of_xml_tag,
};
pub use skills::{SkillEntry, SkillsState};
pub use social::{FriendInfo, SocialRequest, SocialState, WhoInfo};
pub use sound::SoundRequest;
@@ -189,7 +194,7 @@ pub use types::{
Gradient, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent,
ScriptValue, TexCoords,
};
pub(crate) use types::{MeasuredText, RegionData};
pub(crate) use types::{FontExplicit, MeasuredText, RegionData};
pub use unit::{grey_band, level_reads_unknown, power_token, unit_is_grey, UnitState};
pub use weapon_enchant::WeaponEnchant;
pub use worldmap::{WorldMapContinentView, WorldMapOverlayView, WorldMapState, WorldMapZoneView};
@@ -546,6 +551,7 @@ impl UiScript {
quest_log::install(&lua)?;
messageframe::install(&lua)?;
scrollframe::install(&lua)?;
simplehtml::install(&lua)?;
slider::install(&lua)?;
colorselect::install(&lua)?;
minimap::install(&lua)?;
+8 -2
View File
@@ -6,8 +6,8 @@ use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
use super::{
backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, guild,
inspect, item_text, loot, loot_roll, macros, mail, merchant, party, quest, quest_log,
reputation, session, skills, slider, social, spellbook, taxi, trade, tradeskill, trainer,
weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView, PlayerReqState,
reputation, session, simplehtml, skills, slider, social, spellbook, taxi, trade, tradeskill,
trainer, weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView, PlayerReqState,
RegionData, ScriptValue, SoundRequest, UnitState,
};
@@ -115,6 +115,11 @@ pub(crate) struct Model {
/// `SetBackdrop`). Absent ⇒ the frame draws no plate. Stored beside the arena like `region_data`
/// (the arena models structure, not paint). The client's `frame+0x1ac` pointer.
pub(crate) backdrops: HashMap<FrameHandle, backdrop::Backdrop>,
/// Per-`SimpleHTML` widget state (decision-free transcription of `CSimpleHTML`'s own members):
/// the four element fonts `+0x350`, the hyperlink format `+0x360`, and the CONTENTNODE list
/// `+0x340` of blocks the last `SetText` built. Stored beside the arena, like `backdrops`,
/// because its contents are script-layer types and `widget::kinds` is the layer below.
pub(crate) simple_html: simplehtml::SimpleHtmlStates,
/// The named virtual **Font object** registry (`<Font name=…>` → resolved paint), keyed by name.
/// Populated by the loader as it walks top-level `<Font>` nodes; read by `SetFontObject` and by
/// FontString `inherits=` resolution. Data only — no Lua handles (MAXCSTACK discipline).
@@ -1090,6 +1095,7 @@ impl Model {
chat_tab: false,
region_data: HashMap::new(),
backdrops: HashMap::new(),
simple_html: simplehtml::SimpleHtmlStates::new(),
font_objects_by_lower: HashMap::new(),
region_resolved: HashMap::new(),
next_id: 1,
+1
View File
@@ -326,6 +326,7 @@ fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
// corpus's six method-class blockers) and made `SetInsertMode` unreachable.
Some(FrameKind::MessageFrame) => &[super::messageframe::REG_MESSAGEFRAME_METHODS],
Some(FrameKind::ScrollFrame) => &[super::scrollframe::REG_SCROLLFRAME_METHODS],
Some(FrameKind::SimpleHtml) => &[super::simplehtml::REG_SIMPLEHTML_METHODS],
Some(FrameKind::Slider) => &[super::slider::REG_SLIDER_METHODS],
Some(FrameKind::ColorSelect) => &[super::colorselect::REG_COLORSELECT_METHODS],
Some(FrameKind::Button) => &[super::button::REG_BUTTON_METHODS],
+63 -12
View File
@@ -45,11 +45,26 @@ fn with_scroll<T>(
}
}
/// `GetVerticalScrollRange()`'s live computation: `max(0, child_height − frame_height)` from the
/// **resolved** rects — `0.0` when either is unresolved or there is no child. Never cached: the
/// `GetVerticalScrollRange()`'s live computation: `max(0, contentExtent − frameHeight)` from the
/// **resolved** rects — `0.0` when the frame is unresolved or there is no child. Never cached: the
/// scroll offset always clamps against the current layout, not a stale snapshot.
///
/// In **LOCAL units**, each height divided by its own frame's effective scale — i.e. exactly
/// **The content extent is the scroll child's whole SUBTREE, not the child frame's own height**
/// (wow-re `system/ui/scratch/simplehtml-markup-engine.md` §4.5, byte-identified during the
/// SimpleHTML RE; decision 1338 corrects what 0112 shipped). `0x786e30` seeds a bounding box
/// `{FLT_MAX, FLT_MAX, 0, 0}` and calls the **recursive** `0x786f80(scrollChild, &bbox)`, which
/// unions the frame's REGION list (head `+0x1b4`, link `+0x1b8`, guard `[entry+8]+0xc4`) and
/// re-enters itself for every CHILD FRAME (head `+0x2fc`, link `+0x300`, guard `[entry+8]+0xd0`);
/// the range stored at `[+0x31c]` is `max(0, bboxExtent − viewportExtent)`.
///
/// The distinction is not academic: stock `ItemTextPageScrollChild` is declared **10×10**
/// (`ItemTextFrame.xml` l.198) around a 270×304 SimpleHTML whose blocks extend well past it.
/// Measured as the child's own height the range is zero and a book cannot be scrolled at all;
/// measured as the subtree box it tracks the rendered text. Every ScrollFrame whose child is sized
/// directly — the guild-info EditBox, the options panes, every faux list — is unchanged, because
/// there its own rect already IS its subtree.
///
/// In **LOCAL units**, each height divided by the owning frame's effective scale — i.e. exactly
/// `child:GetHeight() - self:GetHeight()`, the widget contract this method states. The resolved
/// rects are screen px, and the scroll offset is NOT: `SetVerticalScroll` becomes the scroll
/// child's anchor y-offset, which the solver multiplies by that child's scale (`resolve`'s
@@ -64,19 +79,55 @@ fn scroll_range(model: &Model, h: FrameHandle) -> f32 {
_ => None,
};
let Some(child) = child else { return 0.0 };
let local_height = |h: FrameHandle| {
let scale = model
let scale_of = |f: FrameHandle| {
model
.arena
.frame(h)
.map(|f| f.effective_scale)
.frame(f)
.map(|x| x.effective_scale)
.filter(|s| s.abs() >= 1e-6)
.unwrap_or(1.0);
model.resolved.get(&h).map(|r| r.height() / scale)
.unwrap_or(1.0)
};
match (local_height(h), local_height(child)) {
(Some(fh), Some(ch)) => (ch - fh).max(0.0),
_ => 0.0,
let Some(frame_h) = model.resolved.get(&h).map(|r| r.height() / scale_of(h)) else {
return 0.0;
};
let Some((top, bottom)) = subtree_span(model, child) else {
return 0.0;
};
((top - bottom) / scale_of(child) - frame_h).max(0.0)
}
/// The vertical span `(top, bottom)` of `f`'s subtree in SCREEN px — its own resolved rect unioned
/// with every VISIBLE descendant region and child frame, recursively (`0x786f80`). `None` when
/// nothing in the subtree resolved.
///
/// Visibility is the client's own guard on both lists: a hidden block contributes no scroll range,
/// which is what stops a window's parked art from inventing travel nobody can use.
fn subtree_span(model: &Model, f: FrameHandle) -> Option<(f32, f32)> {
let frame = model.arena.frame(f)?;
let mut span: Option<(f32, f32)> = model.resolved.get(&f).map(|r| (r.top, r.bottom));
let union = |s: &mut Option<(f32, f32)>, t: f32, b: f32| {
*s = Some(match *s {
Some((ot, ob)) => (ot.max(t), ob.min(b)),
None => (t, b),
});
};
for &rh in &frame.regions {
if model.region_data.get(&rh).is_some_and(|d| d.hidden) {
continue;
}
if let Some(r) = model.region_resolved.get(&rh) {
union(&mut span, r.top, r.bottom);
}
}
for &ch in &frame.children {
if !model.arena.frame(ch).is_some_and(|c| c.effective_visible) {
continue;
}
if let Some((t, b)) = subtree_span(model, ch) {
union(&mut span, t, b);
}
}
span
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
@@ -0,0 +1,889 @@
//! The **`SimpleHTML` widget** (`CSimpleHTML`, class size `0x374`, ctor `0x789dd0`) — its markup
//! engine, its block layout, its four element fonts, and its 19-entry Lua method table.
//!
//! Ground truth: wow-5875-re `system/ui/scratch/simplehtml-markup-engine.md` (a §5 trio plus the
//! orchestrator's own byte read and arbitration). The parse itself is [`parse`]; this module is
//! the half that touches the model — turning a block list into real FontString/Texture regions
//! anchored bottom-to-top, and exposing the widget's Lua surface.
//!
//! ## What the widget is, in one paragraph
//!
//! `SetText(s)` throws away every block of the previous parse, runs `s` through a **strict XML**
//! parser, and — if and only if the document is well-formed, rooted at `HTML`, and has a `BODY`
//! child — walks that BODY emitting one block per `<H1>`/`<H2>`/`<H3>`/`<P>`/`<BR/>`/`<IMG>`.
//! Anything else falls to the plain-text path: the **raw** string as one `P` block, left-aligned,
//! whitespace intact. Each text block is one `CSimpleFontString` at the frame's declared width
//! with **no height set**, so its rect is the intrinsic height of the wrapped text; block 0 hangs
//! from the frame's TOPLEFT and block *N* from block *N−1*'s BOTTOMLEFT at `−spacing`. Spacing
//! defaults to 0, so blocks are flush.
//!
//! ## Three defaults a re-implementation gets wrong, and this one does not
//!
//! - **Block justifyH is LEFT**, never the `CSimpleFontString` ctor's CENTER: `align` is
//! pre-loaded with `1` before the attribute is read (`0x78a7c8`) and `0x78ae78` writes it into
//! the block after `SetFontObject`, so the element font's own `justifyH` can never reappear.
//! - **Element fonts start with no font at all**, and `H1`/`H2`/`H3` fall back to `P`'s whenever
//! their own resolved path is empty (`0x78ae30` measures `SStr::Length(&font->path)`). **Nothing
//! in this TU scales a header** — no height multiply, no size table — so an `<H1>` under a
//! single `<FontString>` declaration renders at exactly the `<P>` size. That is the reference
//! `ItemTextFrame` render, not an approximation of it.
//! - **`GetContentHeight` does not exist in 1.12.1.** The method table has no height getter and
//! nothing in the TU aggregates one; the hosting ScrollFrame measures the subtree generically
//! instead (§4.5), which is what ours does too — the blocks are real regions of the SimpleHTML,
//! so `GetVerticalScrollRange` sees them without SimpleHTML publishing anything.
//!
//! ## Where this diverges from the note, deliberately
//!
//! - **`nextYOffset` is not pixel-snapped.** The reference stores
//! `−pixelSnap(spacing)` (`0x766750`, quantising to whole *device* pixels). Our layout is in
//! logical units and the solver has no device-pixel quantiser to reach for; at the default
//! spacing of 0 the two agree exactly, and at any other value they differ by under a pixel.
//! - **Intra-block line spacing is not modelled**, because nothing in this engine models line
//! spacing at all (see `script::font_block`'s own note on the withheld `Set/GetSpacing`). What
//! `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
//! it here would make a SimpleHTML the one widget whose text vanishes when it is anchor-sized.
//!
//! ## The Lua table, and the one name that is NOT on it
//!
//! §5.1 dumps `.data 0x87ba80` as **19 `{name, fn}` pairs**, and all 19 are installed here. The
//! first sixteen take an **optional leading element-name string** resolved by `0x795d80`; the last
//! three (`SetText`, `SetHyperlinkFormat`, `GetHyperlinkFormat`) do not.
//!
//! **There is no `GetText`.** Later clients grew one; build 5875's table does not have it, and
//! adding a name a table does not carry is exactly as wrong as missing one (`script::font_block`'s
//! module doc states the same law for the font block). A caller that wants the source string keeps
//! it — the reference's own `ItemTextFrame` calls `ItemTextGetText()`.
use std::collections::HashMap;
use mlua::{FromLua, Lua, MultiValue, Table, Value};
use super::object::frame_handle_of;
use super::{FontObject, FontShadow, Model, Outline, RegionData};
use crate::justify::{self, Justify};
use crate::layout::{Anchor, Point};
use crate::order::DrawLayer;
use crate::widget::{FrameHandle, FrameKind, RegionHandle, RegionKind};
mod parse;
pub(crate) use parse::Block;
use parse::{ELEMENT_NAMES, ELEM_P};
/// Registry key of the SimpleHTML method table (the MAXCSTACK discipline: Lua-side root, named key).
pub(super) const REG_SIMPLEHTML_METHODS: &str = "__benilla_simplehtml_methods";
// ─────────────────────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// One of the four **element fonts** (`CSimpleHTML+0x350`…`+0x35c`, index `0 = P, 1 = H1, 2 = H2,
/// 3 = H3`), each a `0x80`-byte subclass of `CSimpleFont` the ctor loop `0x789e61`–`0x789ea3`
/// allocates.
///
/// Modelled as "the named object it points at, plus the properties it set for itself" rather than
/// as a flattened snapshot, so a later `GameFontNormal:SetFont(…)` reaches the *next* parse — the
/// live `parentFontObject` link the real `CFontInstance` carries. The explicit mask is the same
/// `FONTINSTANCE+0x038` severance record a region keeps ([`super::FontExplicit`]); reusing it is
/// what lets a block inherit the mask and stay correct under `script::font::propagate`.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct ElementFont {
/// `inherits=` / `SetFontObject(elem, obj)` — the object this element resolves through.
pub(crate) font_object: Option<String>,
/// Which properties this element set for **itself** (and so must survive a re-read of the
/// object above).
pub(crate) explicit: super::FontExplicit,
/// `FieldBlock.path +0x3c`, ctor `""` — the field `0x78ae30` measures for the H1→P fallback.
pub(crate) font_path: Option<String>,
/// `FieldBlock` height.
pub(crate) font_height: Option<f32>,
/// `SetFont`'s flags / XML `outline=`.
pub(crate) outline: Outline,
/// `SetTextColor` / `<Color>`.
pub(crate) color: Option<[f32; 4]>,
/// `SetShadowColor`/`SetShadowOffset` / `<Shadow>`.
pub(crate) shadow: Option<FontShadow>,
/// `CSimpleFont+0x54`, ctor `0x212` (`0x783a98`) — CENTER | MIDDLE | the one-to-one bit.
pub(crate) justify: Justify,
/// `FieldBlock.spacing +0x50`, ctor **0** (`0x783a81`). Both the inter-block step
/// (`nextYOffset = −pixelSnap(spacing)`) and, in the reference, the intra-block line gap.
pub(crate) spacing: f32,
}
/// A `SimpleHTML` frame's runtime state — the members `CSimpleHTML` adds over `CSimpleFrame`.
///
/// Held in a [`Model`]-side map rather than in [`crate::widget::KindState`] for the reason
/// `Model::backdrops` is: its contents are script-layer types ([`FontObject`], [`FontShadow`],
/// [`Outline`]), and `widget::kinds` is the layer *below* `script` and does not depend on it.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct SimpleHtmlState {
/// `+0x350`…`+0x35c`.
pub(crate) fonts: [ElementFont; 4],
/// `+0x360`, ctor `"|H%s|h%s|h"` (`0x87a838`).
pub(crate) hyperlink_format: String,
/// The CONTENTNODE list `+0x340` — every region the last `SetText` built, in emission order,
/// text blocks and images alike. This is the free list the next `SetText` walks.
pub(crate) blocks: Vec<RegionHandle>,
}
impl Default for SimpleHtmlState {
fn default() -> Self {
SimpleHtmlState {
fonts: Default::default(),
hyperlink_format: parse::DEFAULT_HYPERLINK_FORMAT.to_string(),
blocks: Vec::new(),
}
}
}
/// The paint one block copies out of its element font — the result of `CFontInstance::
/// SetFontObject 0x770c60`, which forces all five property groups changed (`0x770d06 or …,0x1F`)
/// and pulls immediately, with the element's own explicit sets layered back on top.
#[derive(Clone, Debug, Default)]
struct BlockPaint {
font_object: Option<String>,
explicit: super::FontExplicit,
font_path: Option<String>,
font_height: Option<f32>,
outline: Outline,
color: Option<[f32; 4]>,
shadow: Option<FontShadow>,
justify: Justify,
spacing: f32,
}
/// Flatten one element font against the (live) font object it inherits.
fn resolve_font(model: &Model, ef: &ElementFont) -> BlockPaint {
let fo: Option<FontObject> = ef
.font_object
.as_deref()
.and_then(|n| model.font_object(n))
.cloned();
let mut p = BlockPaint {
font_object: ef.font_object.clone(),
explicit: ef.explicit,
justify: Justify::default(),
spacing: ef.spacing,
..BlockPaint::default()
};
if let Some(fo) = &fo {
p.font_path = fo.font.clone();
p.font_height = fo.height;
p.outline = fo.outline;
p.color = fo.color;
p.shadow = fo.shadow;
if let Some(j) = fo.justify_h {
p.justify.set_h(j);
}
if let Some(j) = fo.justify_v {
p.justify.set_v(j);
}
}
if ef.explicit.face {
p.font_path = ef.font_path.clone();
}
if ef.explicit.height {
p.font_height = ef.font_height;
}
if ef.explicit.outline {
p.outline = ef.outline;
}
if ef.explicit.color {
p.color = ef.color;
}
if ef.explicit.shadow {
p.shadow = ef.shadow;
}
if ef.explicit.justify_h {
p.justify.0 = justify::set_axis(p.justify.0, justify::H_MASK, ef.justify.0);
}
if ef.explicit.justify_v {
p.justify.0 = justify::set_axis(p.justify.0, justify::V_MASK, ef.justify.0);
}
p
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// SetText — the rebuild
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// `CSimpleHTML::SetText 0x78a3a0`, whole: free the old blocks, parse, and build the new ones.
///
/// Returns `usedMarkup` (`0x78a519`'s `al`) — the Lua shim discards it, our tests do not.
pub(crate) fn set_text(model: &mut Model, fh: FrameHandle, raw: &str) -> bool {
// §10 step 1. The reference pool-frees the previous parse's widgets; ours must too, or a
// second `SetText` leaves the first one's FontStrings standing behind the new ones — the
// failure a book with a "next page" button hits on its very first turn.
let old = model
.simple_html
.get_mut(&fh)
.map(|s| std::mem::take(&mut s.blocks))
.unwrap_or_default();
for rh in old {
free_block(model, rh);
}
let (frame_name, hyperlink_format) = {
let name = model
.arena
.frame(fh)
.and_then(|f| f.name.clone())
.unwrap_or_default();
let fmt = model
.simple_html
.get(&fh)
.map(|s| s.hyperlink_format.clone())
.unwrap_or_else(|| parse::DEFAULT_HYPERLINK_FORMAT.to_string());
(name, fmt)
};
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());
build(model, fh, &parsed.blocks);
parsed.used_markup
}
/// §10's `ADD_BLOCK`/`ADD_IMAGE` loop — the anchor chain, the fonts, and the `nextY` bookkeeping.
fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
let frame_id = model.frame_id(fh);
// `0x78ae19 call [CLayoutFrame vtbl + 0x1c]` = `0x768420`, `fld [ecx+0x50]` — the frame's
// **declared** width, not its resolved rect. Every block is exactly that wide, which is what
// makes each one word-wrap at the frame width; §4.6's corollary is that a later resize does
// NOT re-wrap, because each block snapshotted the width at creation.
let width = model
.layout_inputs
.get(&fh)
.map_or(0.0, |input| input.width);
// `+0x348 prevBlock` — the last **text** block, and `+0x34c nextYOffset` (always <= 0).
let mut prev_block: Option<u32> = None;
let mut next_y = 0.0f32;
let mut made: Vec<RegionHandle> = Vec::with_capacity(blocks.len());
for block in blocks {
match block {
Block::Text { text, elem, align } => {
// §5.3, the empty-path fallback: `elementFont[elem]`, unless its resolved path is
// empty, in which case `elementFont[0]` — so a declaration that supplies only
// `<FontString>` supplies the font for every element, headers included.
let paint = {
let st = model.simple_html.entry(fh).or_default().clone();
let own = resolve_font(model, &st.fonts[*elem]);
if own.font_path.as_deref().unwrap_or("").is_empty() {
resolve_font(model, &st.fonts[ELEM_P])
} else {
own
}
};
let Some(rh) =
model
.arena
.create_region(fh, RegionKind::FontString, DrawLayer::Artwork, 0)
else {
// The frame died under us — unreachable (we hold its handle and just made
// regions on it), but stop rather than orphan what is already built: the
// blocks so far are still recorded below, so the next `SetText` frees them.
break;
};
let id = model.region_id(rh);
let mut d = RegionData {
anchors: vec![anchor_for(prev_block, frame_id, Point::TopLeft, next_y)],
// `SetWidth(frame.GetWidth())` and **no height** — the block's rect height is
// the FontString's intrinsic wrapped height, which the engine's measure
// round-trip supplies exactly as the client's font engine supplies
// `0x7729b0` → `0x5c2070`.
size: Some((width, 0.0)),
text: Some(text.clone()),
font_object: paint.font_object.clone(),
font_explicit: paint.explicit,
font_path: paint.font_path.clone(),
font_height: paint.font_height,
outline: paint.outline,
vertex_color: paint.color,
font_shadow: paint.shadow,
..RegionData::default()
};
// §5.4: `block->+0x120 = (block->+0x120 & ~7) | (align & 7)` (`0x78ae78`), written
// AFTER `SetFontObject`, so the tag's `align` always wins over the element font's
// own justifyH. justifyV is left alone and keeps whatever the element font
// supplied (ctor MIDDLE) — visually inert here, because the block's rect height IS
// its text height.
d.justify = Justify(justify::set_axis(paint.justify.0, justify::H_MASK, *align));
// …and it can never be RE-inherited: `0x78ae68` clears the per-bit justifyH
// inherit mask (`+0x124 &= ~7`) and `0x78ae95` the whole CFontInstance justify
// group bit (`+0xd4 &= ~2`). Marking both axes explicit is what that pair means
// here — it is what stops `script::font::propagate` from re-centering a built
// block the moment somebody mutates the font object it drew from.
d.font_explicit.justify_h = true;
d.font_explicit.justify_v = true;
model.region_data.insert(rh, d);
prev_block = Some(id);
next_y = -paint.spacing;
made.push(rh);
}
Block::Image {
src,
width: w,
height: h,
align,
floated,
} => {
let Some(rh) =
model
.arena
.create_region(fh, RegionKind::Texture, DrawLayer::Artwork, 0)
else {
break; // as above
};
// Minted even though nothing anchors TO an image (`prevBlock` is never written by
// the image path): the id is what the resolve seats the region's rect under, and
// what `free_block` unmaps on the next `SetText`.
model.region_id(rh);
// §7 step 4 — the anchor corner is selected by `align`, and 8/16/32 get **no
// `SetPoint` at all** (`0x78ac61 jne 0x78ace8`). An anchorless region never
// resolves here either, so it draws nothing, which is the same outcome.
let point = match *align {
parse::ALIGN_LEFT => Some(Point::TopLeft),
parse::ALIGN_CENTER => Some(Point::Top),
parse::ALIGN_RIGHT => Some(Point::TopRight),
_ => None,
};
let anchors = point
.map(|p| vec![anchor_for(prev_block, frame_id, p, next_y)])
.unwrap_or_default();
model.region_data.insert(
rh,
RegionData {
anchors,
size: Some((*w, *h)),
texture: src.clone(),
..RegionData::default()
},
);
// §7 step 6/7: an unfloated image reserves its own height in the flow, and
// `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.
if !*floated {
next_y -= *h;
}
made.push(rh);
}
}
}
model.simple_html.entry(fh).or_default().blocks = made;
model.touch_layout();
}
/// The two anchor forms of §4.2 step 2: block 0 pins to the frame, block *N* to its predecessor's
/// bottom edge at `nextYOffset` (negative, i.e. downward).
fn anchor_for(prev_block: Option<u32>, frame_id: u32, point: Point, next_y: f32) -> Anchor {
match prev_block {
None => Anchor::new(point, frame_id, point, 0.0, 0.0),
Some(prev) => {
let rel = match point {
Point::TopLeft => Point::BottomLeft,
Point::Top => Point::Bottom,
Point::TopRight => Point::BottomRight,
other => other,
};
Anchor::new(point, prev, rel, 0.0, next_y)
}
}
}
/// Free one block region and every trace of its identity — the arena slot, the paint, the resolved
/// rect, and the stable id both directions. A block is anonymous, so nothing in `region_names`
/// points at it; anything that had fetched its wrapper now resolves to a stale-handle error, which
/// is the honest answer for an object the widget destroyed.
fn free_block(model: &mut Model, rh: RegionHandle) {
model.region_data.remove(&rh);
model.region_resolved.remove(&rh);
if let Some(id) = model.region_to_id.remove(&rh) {
model.id_to_region.remove(&id);
}
model.arena.destroy_region(rh);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The loader's seam
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// Apply the font parts an **XML** `<FontString>`/`<FontStringHeaderN>` child of a `<SimpleHTML>`
/// supplies — `font=`, `<FontHeight>`, `outline=` — any subset of which may be absent.
///
/// The region-side twin of this is [`super::apply_font_parts`], and it exists for the same reason:
/// the reference applies XML font attributes in C++ (`CSimpleFont::LoadXML 0x783c30`, reached from
/// `0x78a1fe`…`0x78a26e`), never through the Lua `SetFont`, whose usage string *requires* both a
/// path and a height. Routing the loader through the binding is what forces the binding to be
/// lenient; keeping them separate lets each be right.
pub(crate) fn apply_element_font_parts(
lua: &Lua,
this: &Table,
elem: usize,
path: Option<String>,
height: Option<f32>,
flags: Option<String>,
) -> mlua::Result<()> {
let fh = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let ef = &mut model.simple_html.entry(fh).or_default().fonts[elem];
if let Some(p) = path.filter(|p| !p.is_empty()) {
ef.font_path = Some(p);
ef.explicit.face = true;
}
if let Some(h) = height {
ef.font_height = Some(h);
ef.explicit.height = true;
}
if let Some(f) = flags {
ef.outline = Outline::parse(&f);
ef.explicit.outline = true;
}
Ok(())
}
/// The element index an XML child tag names: `<FontString>` → `P`, `<FontStringHeader1|2|3>` →
/// `H1`/`H2`/`H3` (`0x8786cc`, `0x87a8b4`/`0x87a8a0`/`0x87a88c`; assigned at `0x78a1fe`…`0x78a26e`).
pub(crate) fn element_of_xml_tag(tag: &str) -> Option<usize> {
if tag.eq_ignore_ascii_case("FontString") {
Some(0)
} else if tag.eq_ignore_ascii_case("FontStringHeader1") {
Some(1)
} else if tag.eq_ignore_ascii_case("FontStringHeader2") {
Some(2)
} else if tag.eq_ignore_ascii_case("FontStringHeader3") {
Some(3)
} else {
None
}
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The Lua surface
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// Resolve `this` to a live `SimpleHTML` frame. Reached through the kind dispatcher, so the guard
/// only fires when a caller fishes the method table out and misapplies it — the same posture
/// `script::scrollframe`'s `with_scroll` takes.
fn html_handle(lua: &Lua, this: &Table) -> mlua::Result<FrameHandle> {
let h = frame_handle_of(lua, this)?;
let model = lua.app_data_ref::<Model>().expect("model");
match model.arena.frame(h).map(|f| f.kind) {
Some(FrameKind::SimpleHtml) => Ok(h),
_ => Err(mlua::Error::runtime("not a SimpleHTML")),
}
}
/// `0x795d80` — the **optional leading element-name string**, consumed by the first sixteen
/// methods.
///
/// ```text
/// if (lua_type(L, 2) != LUA_TSTRING) return 0; ; 0x795d91
/// if (SStrCmpI(s,"P" )==0) { lua_remove(L,2); return 0; } ; 0x795db3
/// if (SStrCmpI(s,"H1")==0) { lua_remove(L,2); return 1; } ; 0x795ddb
/// if (SStrCmpI(s,"H2")==0) { lua_remove(L,2); return 2; } ; 0x795e06
/// if (SStrCmpI(s,"H3")==0) { lua_remove(L,2); return 3; } ; 0x795e2e
/// return 0; ; 0x795e50 (NO lua_remove)
/// ```
///
/// Two silent behaviours fall out and are reproduced exactly: omitting the name addresses **`P`**,
/// and a *string* first argument that is not one of the four is **not** an error and is **not**
/// removed — it stays and is consumed as the shared implementation's first real argument, so
/// `SetFont("h4", path, 15)` targets `P` and hands `"h4"` to `SetFont` as the path.
fn take_element(args: &mut MultiValue) -> usize {
let matched = match args.front() {
Some(Value::String(s)) => s.to_str().ok().and_then(|s| {
ELEMENT_NAMES
.iter()
.position(|e| s.as_ref().eq_ignore_ascii_case(e))
}),
_ => None,
};
match matched {
Some(i) => {
args.pop_front();
i
}
None => ELEM_P,
}
}
/// Read argument `i` of the post-element list, or `nil`.
fn arg(args: &MultiValue, i: usize) -> Value {
args.get(i).cloned().unwrap_or(Value::Nil)
}
/// Run `f` over one element font under a short write borrow, then dirty the layout — every one of
/// these can change what the *next* `SetText` draws with, and several change nothing until then.
fn edit_font<T>(
lua: &Lua,
this: &Table,
elem: usize,
f: impl FnOnce(&mut ElementFont) -> T,
) -> mlua::Result<T> {
let h = html_handle(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
Ok(f(&mut model.simple_html.entry(h).or_default().fonts[elem]))
}
/// Read one element font's **resolved** paint (the object it inherits, with its own sets on top) —
/// what the getters answer and what a block would be built with.
fn read_font(lua: &Lua, this: &Table, elem: usize) -> mlua::Result<BlockPaint> {
let h = html_handle(lua, this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let st = model.simple_html.get(&h);
Ok(match st {
Some(st) => resolve_font(&model, &st.fonts[elem]),
None => resolve_font(&model, &ElementFont::default()),
})
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// ── 0/1 · the font object, and the live link to it ──────────────────────────────────────
// `SetFontObject([element,] font | "font" | nil)` → 0 values. Unlike a region's, this cannot
// repaint anything already on screen: the element fonts are prototypes, and only the next
// `SetText` copies them into blocks (§4.6 — a built block is never re-fonted).
m.set(
"SetFontObject",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let name = super::font::resolve("SetFontObject", &arg(&rest, 0))?;
if let Some(n) = &name {
let model = lua.app_data_ref::<Model>().expect("model");
if model.font_object(n).is_none() {
return Err(mlua::Error::runtime(format!(
"SetFontObject: no font object named '{n}' is registered"
)));
}
}
// The **nil form severs the link and leaves the paint standing** — the reference
// stores a null parent and nothing re-reads or clears the resolved values. A region
// gets that for free (its paint was copied into `RegionData`); an element font
// resolves its object lazily, so the standing paint has to be pinned down here or the
// sever would blank the element instead.
let standing = name
.is_none()
.then(|| read_font(lua, &this, elem))
.transpose()?;
edit_font(lua, &this, elem, |ef| {
if let Some(p) = standing {
ef.font_path = p.font_path;
ef.font_height = p.font_height;
ef.outline = p.outline;
ef.color = p.color;
ef.shadow = p.shadow;
ef.justify = p.justify;
ef.explicit = super::FontExplicit {
face: ef.font_path.is_some(),
height: ef.font_height.is_some(),
outline: true,
color: ef.color.is_some(),
shadow: ef.shadow.is_some(),
justify_h: true,
justify_v: true,
};
}
// The severance mask is deliberately **not** reset on a re-point. §5-verified for
// the region side (`script::font_block`'s `SetFontObject`): the real "stop
// inheriting this property" signal is a CLEARED bit in the inheritMask
// (`FONTINSTANCE+0x2c`), cleared by each local setter and never restored — so a
// property this element set for itself stays severed across a later
// `SetFontObject`. The two sides of one law must not disagree.
ef.font_object = name;
})
})?,
)?;
m.set(
"GetFontObject",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let h = html_handle(lua, &this)?;
let name = {
let model = lua.app_data_ref::<Model>().expect("model");
model
.simple_html
.get(&h)
.and_then(|s| s.fonts[elem].font_object.clone())
.filter(|n| model.font_object(n).is_some())
};
match name {
Some(n) => Ok(Value::Table(super::font::wrapper(lua, &n)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// ── 2/3 · the face ──────────────────────────────────────────────────────────────────────
// `SetFont([element,] file, height [, flags])` → the NUMBER 1, or nil on an empty path (the
// load-failure probe shape); the argument gate is the shared `0x79f210` entry, so a numeric
// string is accepted for either and anything else raises `0x87c69c`'s usage string.
m.set(
"SetFont",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let (path, height) =
super::font_block::set_font_args(&arg(&rest, 0), &arg(&rest, 1), "SimpleHTML")?;
let flags = match arg(&rest, 2) {
Value::String(s) => Some(s.to_str()?.to_string()),
_ => None,
};
let ok = !path.is_empty();
edit_font(lua, &this, elem, |ef| {
if ok {
ef.font_path = Some(path);
ef.explicit.face = true;
}
ef.font_height = Some(height);
ef.explicit.height = true;
if let Some(f) = flags {
// The **Lua** flags spelling ("OUTLINE"/"THICKOUTLINE"), a different
// vocabulary from the XML `outline=` attribute — see [`Outline::flags`].
ef.outline = Outline::flags(&f);
ef.explicit.outline = true;
}
})?;
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
})?,
)?;
// `GetFont([element])` → 3 values: path, height, flags string (`""`, never nil).
m.set(
"GetFont",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let p = read_font(lua, &this, elem)?;
let path = match p.font_path {
Some(path) => Value::String(lua.create_string(&path)?),
None => Value::Nil,
};
Ok((path, p.font_height, p.outline.as_str()))
})?,
)?;
// ── 4/5 · the text colour ───────────────────────────────────────────────────────────────
m.set(
"SetTextColor",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let c = rgba(lua, &rest)?;
edit_font(lua, &this, elem, |ef| {
ef.color = Some(c);
ef.explicit.color = true;
})
})?,
)?;
m.set(
"GetTextColor",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let c = read_font(lua, &this, elem)?
.color
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
// ── 6..9 · the shadow ───────────────────────────────────────────────────────────────────
// `GetShadowColor` returns **four** values (`0x79f9b3 mov eax,0x4`), `GetShadowOffset` two.
// Either half may be set before the other, so each starts from whatever is already there.
m.set(
"SetShadowColor",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let c = rgba(lua, &rest)?;
edit_font(lua, &this, elem, |ef| {
let offset = ef.shadow.map_or([0.0, 0.0], |s| s.offset);
ef.shadow = Some(FontShadow { offset, color: c });
ef.explicit.shadow = true;
})
})?,
)?;
m.set(
"GetShadowColor",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let c = read_font(lua, &this, elem)?
.shadow
.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
// Both arguments are required — the shared impl raises `Usage: %s:SetShadowOffset(x, y)`
// (`0x87c6e8`) rather than defaulting the missing one.
m.set(
"SetShadowOffset",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let x = f32::from_lua(arg(&rest, 0), lua)?;
let y = f32::from_lua(arg(&rest, 1), lua)?;
edit_font(lua, &this, elem, |ef| {
let color = ef.shadow.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
ef.shadow = Some(FontShadow {
offset: [x, y],
color,
});
ef.explicit.shadow = true;
})
})?,
)?;
m.set(
"GetShadowOffset",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let o = read_font(lua, &this, elem)?
.shadow
.map_or([0.0, 0.0], |s| s.offset);
Ok((o[0], o[1]))
})?,
)?;
// ── 10/11 · spacing ─────────────────────────────────────────────────────────────────────
// `SetSpacing([element,] n)` — `0x772240` clamps a negative argument to 0 before storing
// (`0x772246 fcomp [0x7ffd74 = 0.0]`). This is the ONE way to open a gap between blocks: the
// ctor default is 0, so out of the box they stack perfectly flush, with no per-element
// constant, no half-line and no margin anywhere.
m.set(
"SetSpacing",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let n = f32::from_lua(arg(&rest, 0), lua)?.max(0.0);
edit_font(lua, &this, elem, |ef| ef.spacing = n)
})?,
)?;
m.set(
"GetSpacing",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
Ok(read_font(lua, &this, elem)?.spacing)
})?,
)?;
// ── 12..15 · justification ──────────────────────────────────────────────────────────────
// Both resolve through the same `0x811ad0` table as `align`, masked `&7` / `&0x38`, so a
// cross-axis token CLEARS its axis and a non-token raises (see [`crate::justify`]).
//
// **`SetJustifyH` is inert for rendered text**, and that is the byte law rather than a gap:
// it writes the element font's justify, which `0x78adb0` overwrites on every block it builds
// with the tag's own `align`. It is installed because the table has it and `GetJustifyH` must
// answer what was stored.
m.set(
"SetJustifyH",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let s =
String::from_lua(arg(&rest, 0), lua).map_err(|_| justify::usage_h("SimpleHTML"))?;
let bits = justify::parse_bits(&s).ok_or_else(|| justify::usage_h("SimpleHTML"))?;
edit_font(lua, &this, elem, |ef| {
ef.justify.0 = justify::set_axis(ef.justify.0, justify::H_MASK, bits);
ef.explicit.justify_h = true;
})
})?,
)?;
m.set(
"GetJustifyH",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
Ok(read_font(lua, &this, elem)?.justify.name_h())
})?,
)?;
m.set(
"SetJustifyV",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let s =
String::from_lua(arg(&rest, 0), lua).map_err(|_| justify::usage_v("SimpleHTML"))?;
let bits = justify::parse_bits(&s).ok_or_else(|| justify::usage_v("SimpleHTML"))?;
edit_font(lua, &this, elem, |ef| {
ef.justify.0 = justify::set_axis(ef.justify.0, justify::V_MASK, bits);
ef.explicit.justify_v = true;
})
})?,
)?;
m.set(
"GetJustifyV",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
Ok(read_font(lua, &this, elem)?.justify.name_v())
})?,
)?;
// ── 16..18 · the three with NO element argument ─────────────────────────────────────────
// `SetText(s)` → 0 values (`0x796a90` discards the shared implementation's `usedMarkup`).
// A nil argument reaches `lua_tostring` as NULL; an empty string is the closest thing to that
// this engine can hand a parser, and it takes the same fallback route a NULL would.
m.set(
"SetText",
lua.create_function(|lua, (this, text): (Table, Value)| {
let raw = match &text {
Value::String(s) => s.to_str()?.to_string(),
Value::Number(_) | Value::Integer(_) => super::object::as_f32(&text).to_string(),
_ => String::new(),
};
let h = html_handle(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
set_text(&mut model, h, &raw);
Ok(())
})?,
)?;
// `SetHyperlinkFormat(s)` raises `Usage: %s:SetHyperlinkFormat("format")` (`0x87bb40`) if
// argument 2 is not a string (`0x796bcc`–`0x796c29`). It takes effect on the NEXT parse.
m.set(
"SetHyperlinkFormat",
lua.create_function(|lua, (this, fmt): (Table, Value)| {
let Value::String(s) = &fmt else {
return Err(mlua::Error::runtime(
"Usage: <SimpleHTML>:SetHyperlinkFormat(\"format\")",
));
};
let fmt = s.to_str()?.to_string();
let h = html_handle(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.simple_html.entry(h).or_default().hyperlink_format = fmt;
Ok(())
})?,
)?;
m.set(
"GetHyperlinkFormat",
lua.create_function(|lua, this: Table| {
let h = html_handle(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model
.simple_html
.get(&h)
.map(|s| s.hyperlink_format.clone())
.unwrap_or_else(|| parse::DEFAULT_HYPERLINK_FORMAT.to_string()))
})?,
)?;
lua.set_named_registry_value(REG_SIMPLEHTML_METHODS, m)?;
Ok(())
}
/// `(r, g, b [, a])` off the post-element argument list — alpha `lua_isnumber`-gated with the
/// default `1.0`, r/g/b required, exactly as the shared colour setters take them.
fn rgba(lua: &Lua, rest: &MultiValue) -> mlua::Result<[f32; 4]> {
let r = f32::from_lua(arg(rest, 0), lua)?;
let g = f32::from_lua(arg(rest, 1), lua)?;
let b = f32::from_lua(arg(rest, 2), lua)?;
let a = match arg(rest, 3) {
v @ (Value::Number(_) | Value::Integer(_)) => f32::from_lua(v, lua)?,
_ => 1.0,
};
Ok([r, g, b, a])
}
/// The per-frame state store — `Model`'s side of this module (see [`SimpleHtmlState`]).
pub(crate) type SimpleHtmlStates = HashMap<FrameHandle, SimpleHtmlState>;
@@ -0,0 +1,716 @@
//! The **markup parse** half of `CSimpleHTML::SetText` — `&str` in, a block list out.
//!
//! Pure: no Lua, no model, no arena. Everything here is one transcription of
//! wow-5875-re `system/ui/scratch/simplehtml-markup-engine.md` §10's algorithm and the §0–§7
//! derivations behind it; [`super`] does the materialization (regions, anchors, fonts).
//!
//! ## The one fact everything else follows from
//!
//! **The markup is parsed as strict XML, not as lenient HTML.** `0x78a422` calls
//! `XMLTree::Parse 0x6f2a30`, which is embedded **expat 1.95.5** (version string `0x882094`,
//! wrapper `__FILE__` `…\FrameXML\XMLTree.cpp` at `0x8715ec`). So an unclosed `<BR>`, a `</p>`
//! closing a `<P>` (XML open/close matching is case-SENSITIVE, even though *SimpleHTML's own*
//! name compares are not — §1.3, `SStrCmpI 0x414310`), a bare `&`, a non-predefined entity such
//! as `&nbsp;`, a duplicate attribute, or any non-whitespace after `</HTML>` all fail the parse
//! outright, and the widget renders the **raw** string instead (§3).
//!
//! [`roxmltree`] stands in for expat here, and it is the right stand-in rather than a convenient
//! one: it is a well-formedness-checking XML parser with exactly the five XML predefined entities
//! plus numeric character references, and it was checked case by case against the note's error
//! table before this module was written — plain prose, a bare `&`, a bare `<`, an unclosed `<BR>`,
//! a case-mismatched close tag, `&nbsp;`, junk after the root and a duplicate attribute are each
//! an `Err` (expat codes 4/4/4/7/7/11/9/8), while trailing whitespace after `</HTML>`, a leading
//! newline, numeric refs, comments and CDATA are each an `Ok`. Its interleaved text/element child
//! order also hands us the `<A>`/`<BR>` splice positions directly, so none of the note's
//! `offsetInParentText` arithmetic (`XMLTree` node `+0x18`) is needed.
//!
//! **The one behavioural difference found**, stated rather than hidden: roxmltree refuses a
//! document carrying an internal DTD subset (`XML with DTD detected`) where expat 1.95.5 may
//! accept one and honour its `<!ENTITY>` declarations. §9 of the note leaves expat's build options
//! unread for exactly this case and observes that no `page_text` body carries a DOCTYPE; for us a
//! DOCTYPE simply takes the plain-text fallback, which is the same place a rejected parse lands.
use crate::justify;
/// The four block elements, indexed as the client indexes `elementFont[4]` at `+0x350`:
/// `0 = P, 1 = H1, 2 = H2, 3 = H3` (`0x789e3e` creates them in that order, `0x78ae29` reads them,
/// and the Lua element-name resolver `0x795d80` answers the same four numbers).
pub(crate) const ELEMENT_NAMES: [&str; 4] = ["P", "H1", "H2", "H3"];
/// `P` — the element every unqualified path addresses: the plain-text fallback's block (`0x78a503
/// push 0`), the `<BR/>` block, and the element a missing or unrecognised Lua element-name
/// argument resolves to (`0x795e50`).
pub(crate) const ELEM_P: usize = 0;
/// The `align` default, pre-loaded into the local before the attribute is even looked at
/// (`0x78a7c8 mov [ebp-0xc],1` for a block, `0x78ab59 mov [ebp-8],1` for an `<IMG>`): **LEFT**,
/// and emphatically *not* the `CSimpleFontString` ctor's CENTER, which `0x78ae78` overwrites on
/// every block it builds.
pub(crate) const ALIGN_LEFT: u32 = 0x01;
/// `align="center"` — the value `0x6f1990` writes from the shared enum table `.rdata 0x811ad0`.
pub(crate) const ALIGN_CENTER: u32 = 0x02;
/// `align="right"`.
pub(crate) const ALIGN_RIGHT: u32 = 0x04;
/// The ctor default of `hyperlinkFormat` (`+0x360`, string `0x87a838`, installed by
/// `0x789ea7`→`0x78a540`): `<A href="X">Y</A>` becomes `|HX|hY|h`, which the font engine then
/// parses as an ordinary hyperlink (§6.5 — nothing disables `|H` on a `CSimpleFontString`).
pub(crate) const DEFAULT_HYPERLINK_FORMAT: &str = "|H%s|h%s|h";
/// One block the walk produced — the arguments of `AddTextBlock 0x78adb0` / `AddImage 0x78ab40`,
/// in the order the BODY walker emitted them.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Block {
/// A text block: one `CSimpleFontString`, `SetWidth(frame width)`, no height.
Text {
/// The already-spliced, already-collapsed string handed to `SetText` — except on the
/// plain-text fallback path, which hands over the **raw** input (§3).
text: String,
/// The `elementFont[]` index this block draws with, before the empty-path fallback.
elem: usize,
/// The `align` bits (`0x811ad0`'s values); masked `& 7` into the block's justifyH.
align: u32,
},
/// An `<IMG>`: one `CSimpleTexture`, sized by `width`/`height` and anchored by `align`.
Image {
/// `src=`, used **verbatim** as a texture path — no prefix, no extension fix-up, no
/// validation in this TU (`0x78ad02`).
src: Option<String>,
/// `width=`/`height=` in logical UI pixels (§7 step 2 — the same units as
/// `<AbsDimension>`); `0` when the attribute is absent.
width: f32,
height: f32,
/// The `align` bits; selects which corner anchors to the previous block.
align: u32,
/// The **separate** float byte `[ebp-1]` (§7 step 1): a floated image reserves no height
/// in the flow, so the following text overlaps it. Set only inside the
/// attribute-present branch, which is why a bare `<IMG src=…/>` and
/// `<IMG align="left" src=…/>` anchor identically and flow differently.
floated: bool,
},
}
/// The outcome of one `SetText` parse.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Parse {
/// The blocks to build, in order.
pub(crate) blocks: Vec<Block>,
/// `SetText`'s own return value in `al` (`0x78a519`): whether the markup path ran. The Lua
/// shim `0x796a90` discards it; we keep it because it is exactly "did this render as markup
/// or as raw text", which is the thing a reader of a `page_text` body wants to know.
pub(crate) used_markup: bool,
/// The console lines the error sink received (`"Frame %s: Unknown element type: %s"` and its
/// two `(expected …)` variants), already formatted with the frame's name.
pub(crate) errors: Vec<String>,
}
/// `CSimpleHTML::SetText 0x78a3a0`'s parse half — §10's `SetText`/`WALK_BODY`/`ADD_PARAGRAPH`.
///
/// `frame` is the widget's name, the `%s` of the error strings. `hyperlink_format` is
/// `+0x360`'s current value, used by the `<A>` splice.
pub(crate) fn parse_markup(raw: &str, frame: &str, hyperlink_format: &str) -> Parse {
let mut out = Parse {
blocks: Vec::new(),
used_markup: false,
errors: Vec::new(),
};
// `usedMarkup` is `[ebp+0xf]`, zeroed at `0x78a415` and raised at exactly ONE site,
// `0x78a4b6` — immediately BEFORE the BODY walker runs. That ordering is the whole reason an
// empty `<BODY/>` renders nothing at all rather than falling back to the raw string.
if let Ok(doc) = roxmltree::Document::parse(raw) {
let root = doc.root_element();
if root.tag_name().name().eq_ignore_ascii_case("HTML") {
// Only the FIRST `BODY` is walked: `0x78a4ba call 0x78a660` is followed by
// `0x78a4bf jmp 0x78a4ef`, which leaves the sibling loop — a second `<BODY>` is never
// visited and never even errors.
for child in root.children().filter(roxmltree::Node::is_element) {
if child.tag_name().name().eq_ignore_ascii_case("BODY") {
out.used_markup = true;
walk_body(child, frame, hyperlink_format, &mut out);
break;
}
out.errors.push(format!(
"Frame {frame}: Unknown element type: {} (expected BODY)",
child.tag_name().name()
));
}
} else {
out.errors.push(format!(
"Frame {frame}: Unknown element type: {} (expected HTML)",
root.tag_name().name()
));
}
}
if !out.used_markup {
// The three routes here — a failed parse, a non-`HTML` root, an `HTML` with no `BODY`
// child — all land on `0x78a501`: the ORIGINAL, unmodified string as ONE block, element
// `P`, justifyH LEFT. **Without the whitespace collapse**, because the collapse lives in
// the paragraph builder `0x78a7b0` which this path never enters — so embedded `\n` stay
// real line breaks. Stock `ItemTextFrame.lua` depends on exactly that, padding its page
// with `SetText("\n" .. ItemTextGetText() .. "\n")`.
out.blocks.push(Block::Text {
text: raw.to_string(),
elem: ELEM_P,
align: ALIGN_LEFT,
});
}
out
}
/// The BODY walker `0x78a660` — dispatch by child element name, case-insensitively.
///
/// **`BODY`'s own character data is DROPPED**: the walker reads only `firstChild`/`nextSibling`
/// and nothing ever reads `body->+0x0c`. The inter-tag newlines of a typical `page_text` body are
/// exactly that, which is why they contribute no blank lines.
fn walk_body(body: roxmltree::Node, frame: &str, hyperlink_format: &str, out: &mut Parse) {
for node in body.children().filter(roxmltree::Node::is_element) {
let tag = node.tag_name().name();
if let Some(elem) = ELEMENT_NAMES
.iter()
.position(|e| tag.eq_ignore_ascii_case(e))
{
let block = paragraph(node, elem, frame, hyperlink_format, &mut out.errors);
out.blocks.push(block);
} else if tag.eq_ignore_ascii_case("BR") {
// `0x78a726` → `0x78adb0("\n" @0x835144, elem 0, align 1)`: a block of its own whose
// text is the single byte `\n`. The height kernel `0x5c2070` takes the class-2 arm
// once and exits with `lines == 1`, so it is **exactly one blank line** — not two,
// not a paragraph gap.
out.blocks.push(Block::Text {
text: "\n".to_string(),
elem: ELEM_P,
align: ALIGN_LEFT,
});
} else if tag.eq_ignore_ascii_case("IMG") {
out.blocks.push(image(node));
} else {
// `0x78a762`–`0x78a793`: the name is formatted into `"Frame %s: Unknown element type:
// %s"` (`0x87a95c`), pushed through the sink, and the node contributes NOTHING; the
// sibling walk continues at `0x78a796`.
out.errors
.push(format!("Frame {frame}: Unknown element type: {tag}"));
}
}
}
/// `0x78a7b0(node, elem, sink)` — one `<P>`/`<H1>`/`<H2>`/`<H3>` into one block.
fn paragraph(
node: roxmltree::Node,
elem: usize,
frame: &str,
hyperlink_format: &str,
errors: &mut Vec<String>,
) -> Block {
let align = align_of(node);
// §6.1/§6.2 in one pass. The reference seeds the buffer with the node's whole accumulated
// character data (expat concatenates the text either side of an inline child into the SAME
// `+0x0c`) and then splices each child in at its recorded `+0x18` offset plus the running
// `extra`. roxmltree hands us text and elements interleaved in document order instead, so
// appending as we walk lands every splice exactly where the tag was, with no offset
// arithmetic to get wrong.
let mut buf = String::new();
for child in node.children() {
if child.is_text() {
buf.push_str(child.text().unwrap_or(""));
continue;
}
if !child.is_element() {
// A comment or PI: expat never delivers it as character data either.
continue;
}
let tag = child.tag_name().name();
if tag.eq_ignore_ascii_case("BR") {
// `0x78a86b` appends the two literal bytes `|n` (`0x87a9a0`) — a real line break
// WITHIN this block, not a block of its own, and `extra += 2`.
buf.push_str("|n");
} else if tag.eq_ignore_ascii_case("A") {
// `0x78a8f0` requires **both** a non-empty `href` and non-empty inner text
// (`0x78a914`–`0x78a922`); otherwise the whole `<A>` contributes nothing at all.
let href = attr_ci(child, "href").unwrap_or("");
let inner = direct_text(child);
if !href.is_empty() && !inner.is_empty() {
buf.push_str(&sprintf_two(hyperlink_format, href, &inner));
}
} else {
// `0x78a9f9`–`0x78aa2c`: the same message and the same outcome as at BODY level. The
// child's own character data went into the CHILD's `+0x0c`, never the parent's, so
// the text of an unknown inline element is silently lost while the text either side
// of it is kept — which is what dropping the node here reproduces.
errors.push(format!("Frame {frame}: Unknown element type: {tag}"));
}
}
Block::Text {
text: collapse(&buf),
elem,
align,
}
}
/// `0x78ab40(node, sink)` — one `<IMG>`.
fn image(node: roxmltree::Node) -> Block {
let mut align = ALIGN_LEFT;
let mut floated = false;
// §7 step 1, the round's arbitration point. The float byte `[ebp-1]` is zeroed at `0x78ab55`
// and set only INSIDE the attribute-present branch (`0x78ab67`/`0x78ab6c` both skip past it),
// so it is a property of *the attribute being written*, not of the value:
//
// absent / empty → align 1, floated 0 `left`/`right` → 1 / 4, floated 1
// `center` → 2, floated 0 `top`/`middle`/`bottom` → 8/16/32, floated 0
// anything else → align stays 1, floated **1** (`0x6f1990` left the local at its default)
if let Some(v) = attr_ci(node, "align").filter(|v| !v.is_empty()) {
if let Some(bits) = justify::parse_bits(v) {
align = bits;
}
floated = align == ALIGN_LEFT || align == ALIGN_RIGHT;
}
Block::Image {
src: attr_ci(node, "src").map(str::to_string),
width: atof(attr_ci(node, "width").unwrap_or("")),
height: atof(attr_ci(node, "height").unwrap_or("")),
align,
floated,
}
}
/// `align` on a `<P>`/`<H1>`/`<H2>`/`<H3>` (`0x78a7cf`): the shared 6-entry enum at
/// `.rdata 0x811ad0`, which [`justify::parse_bits`] already owns one transcription of.
///
/// `0x6f1990` writes `*out` only on a hit, and the local was pre-loaded with `1`, so an **absent,
/// empty or unrecognised** `align` leaves the block at LEFT. `top`/`middle`/`bottom` are accepted
/// and yield 8/16/32, which mask to `& 7 == 0` — a block with no justifyH bit set at all. That is
/// a degenerate state the binary really reaches, not a rejection.
fn align_of(node: roxmltree::Node) -> u32 {
attr_ci(node, "align")
.filter(|v| !v.is_empty())
.and_then(justify::parse_bits)
.unwrap_or(ALIGN_LEFT)
}
/// Attribute lookup, **case-insensitively** — every attribute-name compare in this engine goes
/// through `0x64a4c0` → `SStrCmpI 0x414310` (§1.3), so `ALIGN`/`Align`/`align` are one attribute.
/// roxmltree's own `Node::attribute` is case-sensitive, which is why this scan exists.
fn attr_ci<'a>(node: roxmltree::Node<'a, '_>, name: &str) -> Option<&'a str> {
node.attributes()
.find(|a| a.name().eq_ignore_ascii_case(name))
.map(|a| a.value())
}
/// An element's own direct character data, concatenated in document order — the `<A>`'s inner
/// text. roxmltree's `Node::text` answers only the FIRST text child, which a comment or an entity
/// boundary is enough to split.
fn direct_text(node: roxmltree::Node) -> String {
node.children()
.filter(roxmltree::Node::is_text)
.filter_map(|n| n.text())
.collect()
}
/// `SStrPrintf(tmp, len, hyperlinkFormat, href, innerText)` (`0x78a97b` → `0x64a7f0`) — a C
/// `printf` with exactly two arguments, both strings.
///
/// Only `%s` and `%%` are honoured; any other spec is copied through verbatim rather than
/// consuming an argument. A real `printf` given `%d` and a `char*` would read the pointer as an
/// integer, which is not behaviour worth reproducing, and `SetHyperlinkFormat` is a
/// player-reachable string — the ctor default `"|H%s|h%s|h"` and every format an addon writes are
/// two-`%s` templates.
fn sprintf_two(fmt: &str, a: &str, b: &str) -> String {
let mut out = String::with_capacity(fmt.len() + a.len() + b.len());
let mut used = 0usize;
let mut it = fmt.chars().peekable();
while let Some(c) = it.next() {
if c != '%' {
out.push(c);
continue;
}
match it.peek() {
Some('%') => {
it.next();
out.push('%');
}
Some('s') => {
it.next();
out.push_str(match used {
0 => a,
1 => b,
_ => "",
});
used += 1;
}
_ => out.push('%'),
}
}
out
}
/// C's `atof` (`0x64aaa0`), as `<IMG width=>`/`height=` reach it: leading whitespace skipped, the
/// longest valid numeric prefix parsed, **0 on no parse at all** — `"12px"` is 12, `"px"` is 0.
/// Rust's `str::parse` rejects a trailing suffix outright, which would turn a sloppy but working
/// `width="100 "` into a zero-width image.
fn atof(s: &str) -> f32 {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() && (b[i] as char).is_ascii_whitespace() {
i += 1;
}
let start = i;
if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
i += 1;
}
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
if i < b.len() && b[i] == b'.' {
i += 1;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
}
// An exponent counts only when it is complete — `"1e"` is 1, matching strtod's own backtrack.
if i < b.len() && (b[i] | 0x20) == b'e' {
let mut j = i + 1;
if j < b.len() && (b[j] == b'+' || b[j] == b'-') {
j += 1;
}
if j < b.len() && b[j].is_ascii_digit() {
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
i = j;
}
}
s[start..i].parse::<f32>().unwrap_or(0.0)
}
/// The HTML-style whitespace collapse, §6.3 — `0x78a8af`–`0x78aa87`, a three-arm dispatch through
/// the jump table at `.text 0x78aab8` with the byte remap at `.text 0x78aac4`.
///
/// | arm | characters | behaviour |
/// |---|---|---|
/// | 0 `0x78aa34` | `\t` `\n` `\r` ` ` | suppress-leading → drop; a space already pending → drop; else emit one `0x20` and set pending |
/// | 1 `0x78aa46` | `\|` followed by `n` | un-emit a pending space (`0x78aa54 dec eax`), clear pending, emit `\|n`, set suppress-leading, skip both bytes |
/// | 2 `0x78aa66` | everything else, `\|` **not** followed by `n` included | clear suppress-leading and pending, emit the byte verbatim |
///
/// Tail `0x78aa7b` removes a trailing pending space. Net: **leading and trailing whitespace
/// trimmed, every internal run of tabs/newlines/CRs/spaces collapsed to one space, and a space
/// immediately before a `|n` removed** — exactly HTML's rule.
///
/// It is byte-wise and can still never split a UTF-8 sequence: the index is computed with the
/// **signed** widening `0x78a8c5 movsx edi,bl`, so every byte `>= 0x80` goes negative and the
/// unsigned `cmp edi,0x73; ja` sends it to arm 2 — lead and continuation bytes alike are copied
/// verbatim. Operating on `&[u8]` here reproduces that exactly, and the result is valid UTF-8 by
/// construction because only ASCII whitespace is ever dropped.
///
/// **This applies to markup blocks only.** The §3 plain-text path bypasses it entirely.
fn collapse(buf: &str) -> String {
let src = buf.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(src.len());
// `dl` — "a space is already pending" — and `[ebp+0xb]`, "suppress leading whitespace",
// initialised to **1** so a block never starts with a space.
let mut pending_space = false;
let mut suppress_leading = true;
let mut i = 0;
while i < src.len() {
let ch = src[i];
if ch == b'|' && src.get(i + 1) == Some(&b'n') {
if pending_space {
debug_assert_eq!(out.last(), Some(&b' '));
out.pop();
}
pending_space = false;
out.extend_from_slice(b"|n");
suppress_leading = true;
i += 2;
} else if matches!(ch, b'\t' | b'\n' | b'\r' | b' ') {
if !suppress_leading && !pending_space {
out.push(b' ');
pending_space = true;
}
i += 1;
} else {
suppress_leading = false;
pending_space = false;
out.push(ch);
i += 1;
}
}
if pending_space {
debug_assert_eq!(out.last(), Some(&b' '));
out.pop();
}
String::from_utf8(out).expect("collapse drops only ASCII whitespace")
}
#[cfg(test)]
mod tests {
use super::*;
fn texts(p: &Parse) -> Vec<(&str, usize, u32)> {
p.blocks
.iter()
.map(|b| match b {
Block::Text { text, elem, align } => (text.as_str(), *elem, *align),
Block::Image { .. } => ("<IMG>", usize::MAX, 0),
})
.collect()
}
#[test]
fn collapse_is_htmls_rule() {
assert_eq!(collapse(" a \t\n b "), "a b");
assert_eq!(collapse("a \n\n b"), "a b");
// A space immediately before a `|n` is removed; whitespace right after one is suppressed.
assert_eq!(collapse("a |n b"), "a|nb");
// A `|` not followed by `n` is verbatim — colour spans and hyperlinks survive.
assert_eq!(
collapse(" |cffff0000red|r text "),
"|cffff0000red|r text"
);
// Multi-byte sequences are never split (the signed-widening arm-2 fall-through).
assert_eq!(collapse(" héllo wörld "), "héllo wörld");
assert_eq!(collapse(" "), "");
}
#[test]
fn atof_takes_the_numeric_prefix() {
assert_eq!(atof("64"), 64.0);
assert_eq!(atof(" 12.5px"), 12.5);
assert_eq!(atof("-3"), -3.0);
assert_eq!(atof("px"), 0.0);
assert_eq!(atof(""), 0.0);
assert_eq!(atof("1e2"), 100.0);
assert_eq!(atof("1e"), 1.0);
}
#[test]
fn hyperlink_format_takes_exactly_two_strings() {
assert_eq!(
sprintf_two(DEFAULT_HYPERLINK_FORMAT, "item:1234", "Thunderfury"),
"|Hitem:1234|hThunderfury|h"
);
assert_eq!(sprintf_two("%s%%%s", "a", "b"), "a%b");
}
#[test]
fn a_well_formed_body_is_one_block_per_tag() {
let p = parse_markup(
"<HTML><BODY><H1 align=\"center\">Title</H1><BR/><P>Body text</P></BODY></HTML>",
"F",
DEFAULT_HYPERLINK_FORMAT,
);
assert!(p.used_markup);
assert_eq!(
texts(&p),
[
("Title", 1, ALIGN_CENTER),
("\n", ELEM_P, ALIGN_LEFT),
("Body text", ELEM_P, ALIGN_LEFT),
]
);
assert!(p.errors.is_empty());
}
#[test]
fn the_three_fallback_routes_each_render_the_raw_string() {
for raw in [
"Just some prose with an & in it.", // parse failure
"<FOO><BAR/></FOO>", // wrong root
"<HTML><HEAD>no body here</HEAD></HTML>", // no BODY child
] {
let p = parse_markup(raw, "F", DEFAULT_HYPERLINK_FORMAT);
assert!(!p.used_markup, "{raw}");
assert_eq!(texts(&p), [(raw, ELEM_P, ALIGN_LEFT)], "{raw}");
}
}
#[test]
fn the_fallback_keeps_raw_newlines_because_it_skips_the_collapse() {
let p = parse_markup("\nline one\nline two\n", "F", DEFAULT_HYPERLINK_FORMAT);
assert_eq!(texts(&p), [("\nline one\nline two\n", ELEM_P, ALIGN_LEFT)]);
}
#[test]
fn an_empty_body_renders_nothing_at_all() {
// `usedMarkup` is raised BEFORE the walker runs (`0x78a4b6`), so this does NOT fall back.
let p = parse_markup("<HTML><BODY/></HTML>", "F", DEFAULT_HYPERLINK_FORMAT);
assert!(p.used_markup);
assert!(p.blocks.is_empty());
}
#[test]
fn body_level_character_data_is_dropped() {
let p = parse_markup(
"<HTML><BODY>\nloose\n<P>kept</P>\nmore loose\n</BODY></HTML>",
"F",
DEFAULT_HYPERLINK_FORMAT,
);
assert_eq!(texts(&p), [("kept", ELEM_P, ALIGN_LEFT)]);
}
#[test]
fn malformed_markup_falls_back_rather_than_erroring() {
for raw in [
"<HTML><BODY><P>a&nbsp;b</P></BODY></HTML>", // undefined entity (expat 11)
"<HTML><BODY><P>hi</p></BODY></HTML>", // case-mismatched close (expat 7)
"<HTML><BODY><P>a<BR>b</P></BODY></HTML>", // unclosed BR (expat 7)
"<HTML><BODY><P>hi</P></BODY></HTML>\nFrom: Bob", // junk after root (expat 9)
"<HTML><BODY><P align=\"l\" align=\"r\">x</P></BODY></HTML>", // dup attr (expat 8)
] {
let p = parse_markup(raw, "F", DEFAULT_HYPERLINK_FORMAT);
assert!(!p.used_markup, "{raw}");
assert_eq!(texts(&p), [(raw, ELEM_P, ALIGN_LEFT)], "{raw}");
}
}
#[test]
fn tag_and_attribute_matching_is_case_insensitive() {
let p = parse_markup(
"<html><body><p ALIGN=\"Right\">x</p></body></html>",
"F",
DEFAULT_HYPERLINK_FORMAT,
);
assert_eq!(texts(&p), [("x", ELEM_P, ALIGN_RIGHT)]);
}
#[test]
fn an_unrecognised_tag_is_dropped_and_the_walk_continues() {
let p = parse_markup(
"<HTML><BODY><P>one</P><TABLE/><P>two</P></BODY></HTML>",
"Book",
DEFAULT_HYPERLINK_FORMAT,
);
assert_eq!(
texts(&p),
[("one", ELEM_P, ALIGN_LEFT), ("two", ELEM_P, ALIGN_LEFT)]
);
assert_eq!(p.errors, ["Frame Book: Unknown element type: TABLE"]);
}
#[test]
fn an_unknown_inline_loses_its_own_text_and_keeps_the_text_either_side() {
let p = parse_markup(
"<HTML><BODY><P>a<B>bold</B>b</P></BODY></HTML>",
"Book",
DEFAULT_HYPERLINK_FORMAT,
);
assert_eq!(texts(&p), [("ab", ELEM_P, ALIGN_LEFT)]);
assert_eq!(p.errors, ["Frame Book: Unknown element type: B"]);
}
#[test]
fn inline_br_and_a_splice_where_the_tag_was() {
let p = parse_markup(
"<HTML><BODY><P>see <A href=\"item:1\">this</A> now<BR/>and more</P></BODY></HTML>",
"F",
DEFAULT_HYPERLINK_FORMAT,
);
assert_eq!(
texts(&p),
[("see |Hitem:1|hthis|h now|nand more", ELEM_P, ALIGN_LEFT)]
);
}
#[test]
fn an_a_without_href_or_without_text_contributes_nothing() {
let p = parse_markup(
"<HTML><BODY><P>x<A>t</A>y<A href=\"h\"></A>z</P></BODY></HTML>",
"F",
DEFAULT_HYPERLINK_FORMAT,
);
assert_eq!(texts(&p), [("xyz", ELEM_P, ALIGN_LEFT)]);
}
#[test]
fn only_the_first_body_is_walked_and_the_second_never_errors() {
let p = parse_markup(
"<HTML><BODY><P>one</P></BODY><BODY><P>two</P></BODY></HTML>",
"F",
DEFAULT_HYPERLINK_FORMAT,
);
assert_eq!(texts(&p), [("one", ELEM_P, ALIGN_LEFT)]);
assert!(p.errors.is_empty());
}
#[test]
fn a_non_body_child_of_html_errors_before_the_body_that_follows() {
let p = parse_markup(
"<HTML><HEAD/><BODY><P>x</P></BODY></HTML>",
"F",
DEFAULT_HYPERLINK_FORMAT,
);
assert!(p.used_markup);
assert_eq!(texts(&p), [("x", ELEM_P, ALIGN_LEFT)]);
assert_eq!(
p.errors,
["Frame F: Unknown element type: HEAD (expected BODY)"]
);
}
#[test]
fn img_float_is_a_property_of_the_attribute_not_the_value() {
let cases: [(&str, u32, bool); 6] = [
("<IMG src=\"a\"/>", ALIGN_LEFT, false),
("<IMG align=\"left\" src=\"a\"/>", ALIGN_LEFT, true),
("<IMG align=\"right\" src=\"a\"/>", ALIGN_RIGHT, true),
("<IMG align=\"center\" src=\"a\"/>", ALIGN_CENTER, false),
("<IMG align=\"top\" src=\"a\"/>", 0x08, false),
("<IMG align=\"foo\" src=\"a\"/>", ALIGN_LEFT, true),
];
for (tag, want_align, want_float) in cases {
let p = parse_markup(
&format!("<HTML><BODY>{tag}</BODY></HTML>"),
"F",
DEFAULT_HYPERLINK_FORMAT,
);
match &p.blocks[..] {
[Block::Image { align, floated, .. }] => {
assert_eq!((*align, *floated), (want_align, want_float), "{tag}")
}
other => panic!("{tag}: {other:?}"),
}
}
}
/// The `page_text` shape §8 quotes, end to end: an `<H1>` title, a `<BR/>`, and centred
/// paragraphs, with the body's own inter-tag newlines contributing nothing.
#[test]
fn the_page_text_shape_walks_to_the_block_list_a_reader_would_draw() {
let p = parse_markup(
"<HTML>\n<BODY>\n<H1 align=\"center\">The Green Hills of Stranglethorn</H1>\n<BR/>\n\
<P align=\"center\">Chapter One:\nThe Mysteries of the Jungle</P>\n<BR/>\n\
<P align=\"center\">Deep in the jungle, all is not as it seems.</P>\n</BODY>\n</HTML>",
"ItemTextPageText",
DEFAULT_HYPERLINK_FORMAT,
);
assert!(p.used_markup);
assert_eq!(
texts(&p),
[
("The Green Hills of Stranglethorn", 1, ALIGN_CENTER),
("\n", ELEM_P, ALIGN_LEFT),
(
"Chapter One: The Mysteries of the Jungle",
ELEM_P,
ALIGN_CENTER
),
("\n", ELEM_P, ALIGN_LEFT),
(
"Deep in the jungle, all is not as it seems.",
ELEM_P,
ALIGN_CENTER
),
]
);
assert!(p.errors.is_empty());
}
/// The §8 signed-page consequence: `ItemTextFrame.lua` appends `From: <creator>` **after**
/// `</HTML>`, which is expat error 9 — so a signed HTML page renders as raw markup on the
/// reference client too. Our fallback must reach the same place rather than "fixing" it.
#[test]
fn a_signed_html_page_falls_back_exactly_as_the_reference_does() {
let raw = "\n<HTML><BODY><P>The letter body.</P></BODY></HTML>\n\nFrom:\nMankrik\n\n";
let p = parse_markup(raw, "ItemTextPageText", DEFAULT_HYPERLINK_FORMAT);
assert!(!p.used_markup);
assert_eq!(texts(&p), [(raw, ELEM_P, ALIGN_LEFT)]);
}
}
@@ -420,6 +420,66 @@ fn a_scroll_child_element_gives_the_frame_a_real_scroll_range() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The scroll range is the child's SUBTREE, not the child frame's own height** (decision 1338,
/// wow-re `simplehtml-markup-engine.md` §4.5: `0x786e30` seeds a bbox and walks `0x786f80`
/// recursively over the child's region and child-frame lists).
///
/// The geometry is the reference reader's own: `ItemTextPageScrollChild` is declared **10×10**
/// around a 270×304 page. Measured as the child's height that is range 0 — a book that cannot be
/// scrolled — which is exactly what shipped before this. The second assertion is the mutation
/// check welded in: it is the number the old law returned.
#[test]
fn the_scroll_range_is_the_childs_whole_subtree() {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
register(
&s,
r#"<Ui>
<ScrollFrame name="Reader">
<Size><AbsDimension x="280" y="100"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<ScrollChild>
<Frame name="$parentChild">
<Size><AbsDimension x="10" y="10"/></Size>
<Frames>
<Frame name="Page">
<Size><AbsDimension x="270" y="304"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Frame>
</Frames>
</Frame>
</ScrollChild>
</ScrollFrame>
</Ui>"#,
);
s.resolve();
assert_eq!(
s.eval::<f64>("return Reader:GetVerticalScrollRange()")
.unwrap(),
204.0,
"the 304-tall page inside the 10-tall child, less the 100-tall window"
);
// The mutation check: under the old law the child's own 10 never reaches the window's 100, so
// the range clamps to zero and the reader's scrollbar has nowhere to go.
assert_eq!(
s.eval::<f64>("return ReaderChild:GetHeight()").unwrap(),
10.0,
"and the child itself really is the reference's 10 — the range does not come from it"
);
// A hidden branch contributes nothing: the client guards both list walks on visibility, which
// is what stops a window's parked art from inventing travel nobody can use.
s.run("Page:Hide()").unwrap();
s.resolve();
assert_eq!(
s.eval::<f64>("return Reader:GetVerticalScrollRange()")
.unwrap(),
0.0,
"hidden subtree, no range"
);
}
/// An empty `<ScrollChild>` is an error — the reference's own behaviour (`rf28`), and the honest
/// one: a ScrollFrame that declares a child and has none is a typo, not a design.
#[test]
@@ -27,6 +27,7 @@ mod reference_surface;
mod regions;
mod scrollframe;
mod setparent;
mod simplehtml;
mod size_changed;
mod slider;
mod statusbar;
@@ -0,0 +1,843 @@
//! The `SimpleHTML` widget end to end — the markup engine's *model* half.
//!
//! The parse itself (strict XML, the three fallback routes, the collapse, the splices) is unit
//! tested in `script::simplehtml::parse`, against the byte law directly. What is asserted here is
//! everything that only exists once the blocks are real regions: the anchor chain, the width
//! snapshot, the element-font resolution and its H1→P fallback, the spacing step, the free on
//! rebuild, and the 19-name Lua table.
use super::common::script;
use crate::layout::Point;
use crate::script::{Model, UiScript};
use crate::widget::RegionKind;
/// One block as a reader would describe it: what it says, how it is justified, what face it is in,
/// how wide it was told to be, and where it hangs from.
#[derive(Clone, Debug, PartialEq)]
struct Blk {
text: Option<String>,
texture: Option<String>,
justify_h: &'static str,
font: Option<String>,
font_height: Option<f32>,
/// The `size` the widget wrote: `(frame width, 0)` for a text block — a zero height is how the
/// measure round-trip is asked for the intrinsic one.
size: Option<(f32, f32)>,
/// `(own point, relative point, x, y, target)`, `None` when the block carries no anchor at all.
anchor: Option<(Point, Point, f32, f32, Rel)>,
}
/// What a block's single anchor points at.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Rel {
/// The SimpleHTML frame itself — only block 0 ever does this.
Frame,
/// A previous block, by index in the block list.
Block(usize),
/// Something else entirely (never expected; a failure would print it).
Other,
}
/// Read every block of the named SimpleHTML off the model, in emission order.
fn blocks(s: &UiScript, name: &str) -> Vec<Blk> {
let lua = s.lua();
let model = lua.app_data_ref::<Model>().expect("model");
let fh = model.arena.lookup(name).expect("SimpleHTML frame");
let frame_id = model.frame_to_id.get(&fh).copied();
let st = model.simple_html.get(&fh).expect("SimpleHTML state");
let ids: Vec<Option<u32>> = st
.blocks
.iter()
.map(|rh| model.region_to_id.get(rh).copied())
.collect();
st.blocks
.iter()
.map(|&rh| {
let d = model.region_data.get(&rh).expect("block region data");
let kind = model.arena.region(rh).expect("live block").kind;
let anchor = d.anchors.first().map(|a| {
let rel = if Some(a.relative_to) == frame_id {
Rel::Frame
} else {
ids.iter()
.position(|id| *id == Some(a.relative_to))
.map_or(Rel::Other, Rel::Block)
};
(a.point, a.relative_point, a.x_off, a.y_off, rel)
});
Blk {
text: d.text.clone(),
texture: d.texture.clone(),
justify_h: d.justify.name_h(),
font: d.font_path.clone(),
font_height: d.font_height,
size: d.size,
anchor,
// A texture block has no text; keeping the kind out of the struct and asserting it
// here keeps the expected-value literals readable.
}
.tap_kind(kind)
})
.collect()
}
impl Blk {
/// A tiny consistency check folded into the read: a text block is a FontString, an image block
/// is a Texture. A regression that made blocks the wrong region kind would otherwise pass
/// every text assertion below.
fn tap_kind(self, kind: RegionKind) -> Blk {
match (&self.text, &self.texture) {
(Some(_), None) => assert_eq!(kind, RegionKind::FontString),
(None, Some(_)) => assert_eq!(kind, RegionKind::Texture),
_ => {}
}
self
}
}
/// Just the strings, for the tests that only care about the walk.
fn texts(s: &UiScript, name: &str) -> Vec<String> {
blocks(s, name)
.into_iter()
.map(|b| b.text.unwrap_or_default())
.collect()
}
/// How many regions the frame owns in total — the free-on-rebuild check's instrument. A block that
/// was orphaned rather than destroyed still sits in its owner's region list and still draws.
fn region_count(s: &UiScript, name: &str) -> usize {
let lua = s.lua();
let model = lua.app_data_ref::<Model>().expect("model");
let fh = model.arena.lookup(name).expect("frame");
model.arena.frame(fh).expect("live frame").regions.len()
}
/// A `SimpleHTML` named `Page`, 270×304 like the reference's `ItemTextPageText`, with one font
/// object behind it (the `<FontString inherits="ItemTextFontNormal"/>` shape).
fn page(s: &UiScript) {
s.run(
r#"
local f = CreateFont("ItemTextFontNormal")
f:SetFont("Fonts\\MORPHEUS.TTF", 15)
f:SetTextColor(0.18, 0.12, 0.06)
Page = CreateFrame("SimpleHTML", "Page")
Page:SetPoint("TOPLEFT", 0, 0)
Page:SetSize(270, 304)
Page:SetFontObject("P", ItemTextFontNormal)
"#,
)
.unwrap();
}
/// Answer every pending measure at 16px a line and 100 wide, so the resolved rects below are
/// arithmetic a reader can check.
///
/// The line count is the client's own kernel `0x5c2070`, not "breaks + 1": it counts a line per
/// class-2 token consumed and exits at the terminator, so a **trailing** break opens no new line
/// and the one-byte `"\n"` a `<BR/>` block carries is **one** line, not two (§4.4). An empty string
/// never enters the loop body at all and measures 0 — the empty-`<P>` edge.
fn measure_at_16px(s: &mut UiScript) {
s.resolve();
let reqs = s.fontstrings_needing_measure();
let answers: Vec<(u32, f32, f32, u64)> = reqs
.iter()
.map(|r| (r.id, 100.0, 16.0 * client_lines(&r.text) as f32, r.key))
.collect();
s.set_measured_text_unwrapped(&answers);
s.resolve();
}
/// `0x5c2070`'s line count for a string, at the fidelity these tests need.
fn client_lines(text: &str) -> usize {
if text.is_empty() {
return 0;
}
let breaks = text.matches('\n').count() + text.matches("|n").count();
if text.ends_with('\n') || text.ends_with("|n") {
breaks
} else {
breaks + 1
}
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The walk, as blocks on the frame
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// A well-formed body: one block per tag, each carrying the tag's own `align` and — because only
/// `<FontString>` was declared — every one of them in `P`'s face, the `<H1>` included.
#[test]
fn a_well_formed_body_is_one_block_per_tag() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(
r#"Page:SetText("<HTML><BODY>" ..
"<H1 align=\"center\">Title</H1>" ..
"<P>Left body.</P>" ..
"<P align=\"right\">Right body.</P>" ..
"</BODY></HTML>")"#,
)
.unwrap();
let b = blocks(&s, "Page");
assert_eq!(b.len(), 3);
assert_eq!(
b.iter()
.map(|x| (x.text.clone().unwrap(), x.justify_h))
.collect::<Vec<_>>(),
[
("Title".to_string(), "CENTER"),
("Left body.".to_string(), "LEFT"),
("Right body.".to_string(), "RIGHT"),
]
);
// §5.3 — `elementFont[1]`'s path is empty, so `0x78ae30` substitutes `elementFont[0]`. There
// is NO header scaling anywhere in the TU: the H1 is the same 15px as the paragraphs.
for blk in &b {
assert_eq!(blk.font.as_deref(), Some("Fonts\\MORPHEUS.TTF"));
assert_eq!(blk.font_height, Some(15.0));
// `SetWidth(frame.GetWidth())`, and NO height — the intrinsic one comes from the measure.
assert_eq!(blk.size, Some((270.0, 0.0)));
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The anchor chain of §4.2 step 2, and the flush default of §4.3: block 0 pins TOPLEFT→frame
/// TOPLEFT, every later block TOPLEFT→**the previous block's** BOTTOMLEFT at `−spacing` — which
/// is `0` out of the box, so the blocks touch.
#[test]
fn blocks_chain_bottom_to_top_and_are_flush_at_spacing_zero() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(r#"Page:SetText("<HTML><BODY><P>one</P><P>two</P><P>three</P></BODY></HTML>")"#)
.unwrap();
let b = blocks(&s, "Page");
assert_eq!(
b.iter().map(|x| x.anchor.unwrap()).collect::<Vec<_>>(),
[
(Point::TopLeft, Point::TopLeft, 0.0, 0.0, Rel::Frame),
(Point::TopLeft, Point::BottomLeft, 0.0, 0.0, Rel::Block(0)),
(Point::TopLeft, Point::BottomLeft, 0.0, 0.0, Rel::Block(1)),
]
);
// …and the resolved rects really do stack with no gap. Frame top is 600 (screen top, TOPLEFT
// at 0,0); three 16px lines run 600→584→568→552.
measure_at_16px(&mut s);
let tops = resolved_tops(&s, "Page");
assert_eq!(tops, [(600.0, 584.0), (584.0, 568.0), (568.0, 552.0)]);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `SetSpacing` is the only thing that opens a gap, and it opens it by exactly its own value.
#[test]
fn spacing_steps_the_blocks_apart() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(
r#"
Page:SetSpacing(4)
Page:SetText("<HTML><BODY><P>one</P><P>two</P></BODY></HTML>")
"#,
)
.unwrap();
let b = blocks(&s, "Page");
assert_eq!(
b[1].anchor.unwrap(),
(Point::TopLeft, Point::BottomLeft, 0.0, -4.0, Rel::Block(0)),
"block N+1 sits at block N's BOTTOMLEFT displaced by -spacing"
);
assert_eq!(s.eval::<f32>("return Page:GetSpacing()").unwrap(), 4.0);
// A negative spacing is clamped to 0 before it is stored (`0x772246`).
s.run("Page:SetSpacing(-9)").unwrap();
assert_eq!(s.eval::<f32>("return Page:GetSpacing()").unwrap(), 0.0);
measure_at_16px(&mut s);
assert_eq!(
resolved_tops(&s, "Page"),
[(600.0, 584.0), (580.0, 564.0)],
"the second block starts 4px below the first's bottom"
);
}
/// `<BR/>` at BODY level is its own `"\n"` block, and `0x5c2070` counts **one** line for it — one
/// blank line, not two and not a paragraph gap.
#[test]
fn a_body_level_br_is_exactly_one_blank_line() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(r#"Page:SetText("<HTML><BODY><P>a</P><BR/><P>b</P></BODY></HTML>")"#)
.unwrap();
assert_eq!(texts(&s, "Page"), ["a", "\n", "b"]);
measure_at_16px(&mut s);
assert_eq!(
resolved_tops(&s, "Page"),
[(600.0, 584.0), (584.0, 568.0), (568.0, 552.0)],
"the BR block is one line tall and pushes `b` down by exactly that"
);
}
/// Inline `<BR/>` is a different animal: it splices the two bytes `|n` into the SAME block, which
/// the font engine turns into a line break within it.
#[test]
fn an_inline_br_stays_inside_its_block() {
let s = script();
page(&s);
s.run(r#"Page:SetText("<HTML><BODY><P>a<BR/>b</P></BODY></HTML>")"#)
.unwrap();
assert_eq!(texts(&s, "Page"), ["a|nb"]);
}
/// All three fallback routes land on ONE block holding the RAW string — no collapse, so embedded
/// newlines survive as real line breaks. This is the common case for a vmangos `page_text`.
#[test]
fn the_three_fallback_routes_each_land_on_one_raw_block() {
let s = script();
page(&s);
for raw in [
"Plain prose, with an & and a < in it.\nSecond line.",
"<FOO><BAR/></FOO>",
"<HTML><HEAD>nothing</HEAD></HTML>",
] {
s.run(&format!("Page:SetText({})", lua_str(raw))).unwrap();
let b = blocks(&s, "Page");
assert_eq!(b.len(), 1, "{raw}");
assert_eq!(b[0].text.as_deref(), Some(raw), "{raw}");
assert_eq!(b[0].justify_h, "LEFT", "{raw}");
assert_eq!(
b[0].anchor.unwrap(),
(Point::TopLeft, Point::TopLeft, 0.0, 0.0, Rel::Frame),
"{raw}"
);
}
}
/// Malformed markup **falls back**; it never raises and never renders half a document.
#[test]
fn malformed_markup_falls_back_rather_than_erroring() {
let s = script();
page(&s);
for raw in [
"<HTML><BODY><P>a&nbsp;b</P></BODY></HTML>",
"<HTML><BODY><P>hi</p></BODY></HTML>",
"<HTML><BODY><P>hi</P></BODY></HTML>\nFrom: Bob",
] {
s.run(&format!("Page:SetText({})", lua_str(raw))).unwrap();
assert_eq!(texts(&s, "Page"), [raw], "{raw}");
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **A second `SetText` destroys the first parse's blocks.** Not hides, not orphans — the regions
/// are gone from the frame, so nothing of the old page can draw behind the new one.
#[test]
fn a_second_set_text_replaces_the_blocks() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(r#"Page:SetText("<HTML><BODY><P>one</P><P>two</P><P>three</P></BODY></HTML>")"#)
.unwrap();
assert_eq!(region_count(&s, "Page"), 3);
measure_at_16px(&mut s);
assert_eq!(page_texts_on_screen(&s).len(), 3);
s.run(r#"Page:SetText("<HTML><BODY><P>only</P></BODY></HTML>")"#)
.unwrap();
assert_eq!(texts(&s, "Page"), ["only"]);
assert_eq!(
region_count(&s, "Page"),
1,
"the previous parse's FontStrings are freed, not left on the frame"
);
measure_at_16px(&mut s);
assert_eq!(
page_texts_on_screen(&s),
["only"],
"and nothing of the old page still draws"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The real `page_text` shape §8 quotes, driven through the widget: the `<H1>` at the `<P>` size,
/// every block centred, each `<BR/>` one blank line, and the body's own inter-tag newlines adding
/// nothing at all.
#[test]
fn the_page_text_body_renders_the_block_list_a_reader_would_draw() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(
"Page:SetText(\"<HTML>\\n<BODY>\\n\
<H1 align=\\\"center\\\">The Green Hills of Stranglethorn</H1>\\n<BR/>\\n\
<P align=\\\"center\\\">Chapter One:\\nThe Mysteries of the Jungle</P>\\n<BR/>\\n\
<P align=\\\"center\\\">Deep in the jungle, all is not as it seems.</P>\\n\
</BODY>\\n</HTML>\")",
)
.unwrap();
let b = blocks(&s, "Page");
assert_eq!(
b.iter()
.map(|x| (x.text.clone().unwrap(), x.justify_h, x.font_height))
.collect::<Vec<_>>(),
[
(
"The Green Hills of Stranglethorn".to_string(),
"CENTER",
Some(15.0)
),
("\n".to_string(), "LEFT", Some(15.0)),
(
"Chapter One: The Mysteries of the Jungle".to_string(),
"CENTER",
Some(15.0)
),
("\n".to_string(), "LEFT", Some(15.0)),
(
"Deep in the jungle, all is not as it seems.".to_string(),
"CENTER",
Some(15.0)
),
],
"the H1 renders at the P size — nothing in the TU scales a header"
);
// Five blocks flush at 16px each: 600 → 520.
measure_at_16px(&mut s);
let tops = resolved_tops(&s, "Page");
assert_eq!(tops.first(), Some(&(600.0, 584.0)));
assert_eq!(tops.last(), Some(&(536.0, 520.0)));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The exact string the reader builds**, on the exact body B240 reports — `ItemTextFrame.xml`'s
/// READY handler pads an authorless page as `"\n" .. ItemTextGetText() .. "\n"`, and that padding
/// is the one thing standing between a formatted page and the raw markup Goudy photographed.
/// Whitespace before and after the root element is legal XML, so this must take the MARKUP path;
/// if it ever takes the fallback, every book in the world silently renders as its own source.
///
/// The body is vmangos `page_text` 2676 verbatim — the *Alliance Military Ranks* plaque in
/// Stormwind's Old Town, `GameObject` 3011, the object in the report's screenshots.
#[test]
fn the_readers_own_newline_padding_still_parses_as_markup() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(
"Page:SetText(\"\\n\" .. \
\"<HTML>\\n<BODY>\\n\
<H1 align=\\\"center\\\">ALLIANCE MILITARY RANKS</H1><BR/>\\n\
<P align=\\\"center\\\">OFFICERS</P><BR/>\\n\
<P align=\\\"center\\\">Grand Marshal</P>\\n\
<P align=\\\"center\\\">Field Marshal</P>\\n\
<P align=\\\"center\\\">Knight</P><BR/>\\n\
<P align=\\\"center\\\">ENLISTED</P><BR/>\\n\
<P align=\\\"center\\\">Private</P>\\n\
</BODY>\\n</HTML>\" .. \"\\n\")",
)
.unwrap();
let texts = texts(&s, "Page");
assert_eq!(
texts,
[
"ALLIANCE MILITARY RANKS",
"\n",
"OFFICERS",
"\n",
"Grand Marshal",
"Field Marshal",
"Knight",
"\n",
"ENLISTED",
"\n",
"Private",
],
"the padded page took the markup path — one block per tag, one blank line per <BR/>, and \
BODY's own inter-tag newlines adding nothing"
);
// The falsification, stated: the fallback would put the WHOLE source in one block.
assert!(
!texts[0].contains('<'),
"a fallback would render the markup itself — the reported symptom"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The other half of the same guard: `page_text` **1510** (the Crystal Pylon manual) ends
/// `</HTML.` — a typo in Blizzard's own shipped data. Strict XML rejects it, so it falls back to
/// raw markup **on the reference client too**, and ours must do the same rather than paper over
/// it. This is the one of the world's 62 HTML bodies that is not well-formed.
#[test]
fn blizzards_own_malformed_page_falls_back_exactly_as_the_reference_does() {
let s = script();
page(&s);
s.run(
"Page:SetText(\"<HTML> <BODY> <H1 align=\\\"center\\\"> CRYSTAL PYLON USER'S MANUAL </H1> \
<BR/> <P align=\\\"left\\\">Chapter 1: The Northern Pylon </P> </BODY> </HTML.\")",
)
.unwrap();
let texts = texts(&s, "Page");
assert_eq!(texts.len(), 1, "one raw block, not a parsed page");
assert!(
texts[0].starts_with("<HTML>") && texts[0].ends_with("</HTML."),
"the fallback ships the ORIGINAL string, unmodified and uncollapsed"
);
}
/// A declared header font **is** honoured — the fallback fires only on an element with no font
/// from any source, so this is the control for the test above.
#[test]
fn a_declared_header_font_wins_over_the_p_fallback() {
let s = script();
page(&s);
s.run(
r#"
local h = CreateFont("BookHeader")
h:SetFont("Fonts\\SKURRI.TTF", 24)
Page:SetFontObject("H1", BookHeader)
Page:SetText("<HTML><BODY><H1>Title</H1><P>Body</P></BODY></HTML>")
"#,
)
.unwrap();
let b = blocks(&s, "Page");
assert_eq!(
(b[0].font.as_deref(), b[0].font_height),
(Some("Fonts\\SKURRI.TTF"), Some(24.0))
);
assert_eq!(
(b[1].font.as_deref(), b[1].font_height),
(Some("Fonts\\MORPHEUS.TTF"), Some(15.0))
);
}
/// An `<IMG>` sizes itself, anchors by `align`, and — when it is NOT floated — reserves its own
/// height in the flow while leaving `prevBlock` pointing at the last **text** block.
#[test]
fn an_unfloated_image_reserves_height_without_becoming_the_anchor() {
let s = script();
page(&s);
s.run(
r#"Page:SetText("<HTML><BODY><P>a</P>" ..
"<IMG src=\"Interface\\Pic\" width=\"64\" height=\"32\"/>" ..
"<P>b</P></BODY></HTML>")"#,
)
.unwrap();
let b = blocks(&s, "Page");
assert_eq!(b[1].texture.as_deref(), Some("Interface\\Pic"));
assert_eq!(b[1].size, Some((64.0, 32.0)));
assert_eq!(
b[1].anchor.unwrap(),
(Point::TopLeft, Point::BottomLeft, 0.0, 0.0, Rel::Block(0))
);
assert_eq!(
b[2].anchor.unwrap(),
(Point::TopLeft, Point::BottomLeft, 0.0, -32.0, Rel::Block(0)),
"the next text block still hangs off the last TEXT block, only 32px lower"
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The Lua table
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The 19 names of `.data 0x87ba80`, and only those 19 — `GetText` is **not** one of them in
/// build 5875, and a name a table does not carry is as wrong as a missing one.
#[test]
fn the_method_table_is_the_nineteen_names() {
let s = script();
s.run(r#"H = CreateFrame("SimpleHTML")"#).unwrap();
for name in [
"SetFontObject",
"GetFontObject",
"SetFont",
"GetFont",
"SetTextColor",
"GetTextColor",
"SetShadowColor",
"GetShadowColor",
"SetShadowOffset",
"GetShadowOffset",
"SetSpacing",
"GetSpacing",
"SetJustifyH",
"GetJustifyH",
"SetJustifyV",
"GetJustifyV",
"SetText",
"SetHyperlinkFormat",
"GetHyperlinkFormat",
] {
assert!(
s.eval::<bool>(&format!("return H.{name} ~= nil")).unwrap(),
"SimpleHTML:{name} is missing"
);
}
assert!(
!s.eval::<bool>("return H.GetText ~= nil").unwrap(),
"1.12.1's SimpleHTML table has no GetText"
);
// The names are the SimpleHTML's own — no other widget kind answers them.
s.run(r#"F = CreateFrame("Frame")"#).unwrap();
assert!(!s
.eval::<bool>("return F.SetHyperlinkFormat ~= nil")
.unwrap());
}
/// The optional element-name argument (`0x795d80`): omitting it addresses `P`, the four names are
/// case-insensitive, and a **string that is not one of the four is not removed** — it becomes the
/// shared implementation's first real argument.
#[test]
fn the_element_name_argument_is_optional_case_insensitive_and_non_matching_falls_through() {
let s = script();
s.run(
r#"
H = CreateFrame("SimpleHTML")
H:SetFont("Fonts\\A.TTF", 10) -- no element name: targets P
H:SetFont("h2", "Fonts\\B.TTF", 20) -- "h2" matches, case-insensitively
"#,
)
.unwrap();
assert_eq!(
s.eval::<(String, f32)>("local p, h = H:GetFont(); return p, h")
.unwrap(),
("Fonts\\A.TTF".to_string(), 10.0)
);
assert_eq!(
s.eval::<(String, f32)>("local p, h = H:GetFont(\"H2\"); return p, h")
.unwrap(),
("Fonts\\B.TTF".to_string(), 20.0)
);
// "h4" is not an element: it stays on the stack and is consumed as SetFont's PATH, so this
// targets P and sets the face to the literal "h4".
s.run(r#"H:SetFont("h4", 12)"#).unwrap();
assert_eq!(
s.eval::<(String, f32)>("local p, h = H:GetFont(); return p, h")
.unwrap(),
("h4".to_string(), 12.0)
);
}
/// `SetHyperlinkFormat` governs how an `<A>` is spliced, and takes effect on the next parse.
#[test]
fn the_hyperlink_format_shapes_the_a_splice() {
let s = script();
page(&s);
assert_eq!(
s.eval::<String>("return Page:GetHyperlinkFormat()")
.unwrap(),
"|H%s|h%s|h"
);
s.run(r#"Page:SetText("<HTML><BODY><P>see <A href=\"item:1\">this</A></P></BODY></HTML>")"#)
.unwrap();
assert_eq!(texts(&s, "Page"), ["see |Hitem:1|hthis|h"]);
s.run(r#"Page:SetHyperlinkFormat("|cff33ff99|H%s|h[%s]|h|r")"#)
.unwrap();
s.run(r#"Page:SetText("<HTML><BODY><P>see <A href=\"item:1\">this</A></P></BODY></HTML>")"#)
.unwrap();
assert_eq!(texts(&s, "Page"), ["see |cff33ff99|Hitem:1|h[this]|h|r"]);
// A non-string argument raises the reference's own usage string rather than no-opping.
assert!(s.run("Page:SetHyperlinkFormat(7)").is_err());
}
/// The colour/shadow/justify getters answer per element, and `SetTextColor` reaches the blocks the
/// next parse builds.
#[test]
fn the_paint_setters_land_on_the_element_and_reach_the_next_parse() {
let s = script();
page(&s);
s.run(
r#"
Page:SetTextColor("H1", 1, 0, 0)
Page:SetShadowColor(0, 0, 0, 0.5)
Page:SetShadowOffset(1, -1)
Page:SetText("<HTML><BODY><H1>t</H1><P>b</P></BODY></HTML>")
"#,
)
.unwrap();
assert_eq!(
s.eval::<(f32, f32, f32, f32)>("return Page:GetTextColor(\"H1\")")
.unwrap(),
(1.0, 0.0, 0.0, 1.0)
);
// P keeps the font object's own colour — the element arg really did scope the write.
let (r, g, b, _) = s
.eval::<(f32, f32, f32, f32)>("return Page:GetTextColor(\"P\")")
.unwrap();
assert_eq!((r, g, b), (0.18, 0.12, 0.06));
assert_eq!(
s.eval::<(f32, f32)>("return Page:GetShadowOffset()")
.unwrap(),
(1.0, -1.0)
);
assert_eq!(
s.eval::<(f32, f32, f32, f32)>("return Page:GetShadowColor()")
.unwrap(),
(0.0, 0.0, 0.0, 0.5)
);
}
/// **The empty-path fallback substitutes the WHOLE element font, colour included** — so an `H1`
/// given a colour and nothing else renders in `P`'s colour, and its own red is never drawn.
///
/// That is the shape of `0x78ae29`–`0x78ae54`: the register holding `elementFont[elem]` is
/// *replaced* by `elementFont[0]` before the single `SetFontObject` call, so the test is on the
/// path and the consequence is on everything. Surprising, faithful, and exactly the sort of thing
/// a re-implementation gets wrong by merging property-by-property instead.
#[test]
fn an_element_with_no_font_of_its_own_loses_its_own_colour_too() {
let s = script();
page(&s);
s.run(
r#"
Page:SetTextColor("H1", 1, 0, 0)
Page:SetText("<HTML><BODY><H1>t</H1></BODY></HTML>")
"#,
)
.unwrap();
// The getter still answers the red — the element font really does hold it (`0x795d3e` reads
// `[this + idx*4 + 0x350]`, never a block).
assert_eq!(
s.eval::<(f32, f32, f32, f32)>("return Page:GetTextColor(\"H1\")")
.unwrap(),
(1.0, 0.0, 0.0, 1.0)
);
assert_eq!(
block_color(&s, "Page", 0),
Some([0.18, 0.12, 0.06, 1.0]),
"the block took elementFont[0] wholesale, so H1's red never reaches it"
);
// Give H1 a path of its own and the fallback stops firing — now the red draws.
s.run(
r#"
Page:SetFont("H1", "Fonts\\SKURRI.TTF", 24)
Page:SetText("<HTML><BODY><H1>t</H1></BODY></HTML>")
"#,
)
.unwrap();
assert_eq!(block_color(&s, "Page", 0), Some([1.0, 0.0, 0.0, 1.0]));
}
/// The `vertex_color` of block `i` — a FontString has no texel, so its vertex colour IS the colour
/// it draws.
fn block_color(s: &UiScript, name: &str, i: usize) -> Option<[f32; 4]> {
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_data.get(&st.blocks[i])?.vertex_color
}
/// The two halves of `SetFontObject`'s severance law, which the element font has to reproduce by
/// hand because it resolves its object lazily rather than copying the paint at the call:
///
/// - a **re-point does not reset** the explicit mask (§5-verified for the region side: the
/// inheritMask bit a local setter clears is never restored), so a face set on the element
/// survives being pointed at a different object;
/// - the **nil form severs the link and leaves the paint standing** — it does not blank the
/// element.
#[test]
fn set_font_object_keeps_local_overrides_and_the_nil_form_leaves_the_paint_standing() {
let s = script();
s.run(
r#"
local a = CreateFont("FaceA"); a:SetFont("Fonts\\A.TTF", 10); a:SetTextColor(1, 0, 0)
local b = CreateFont("FaceB"); b:SetFont("Fonts\\B.TTF", 20); b:SetTextColor(0, 1, 0)
H = CreateFrame("SimpleHTML")
H:SetFontObject(FaceA)
H:SetFont("Fonts\\OWN.TTF", 33) -- an explicit face + height
H:SetFontObject(FaceB) -- re-point: the colour follows, the face does not
"#,
)
.unwrap();
assert_eq!(
s.eval::<(String, f32)>("local p, h = H:GetFont(); return p, h")
.unwrap(),
("Fonts\\OWN.TTF".to_string(), 33.0)
);
assert_eq!(
s.eval::<(f32, f32, f32)>("local r, g, b = H:GetTextColor(); return r, g, b")
.unwrap(),
(0.0, 1.0, 0.0),
"the colour was never set locally, so it re-reads from the new object"
);
s.run("H:SetFontObject(nil)").unwrap();
assert!(s.eval::<bool>("return H:GetFontObject() == nil").unwrap());
assert_eq!(
s.eval::<(f32, f32, f32)>("local r, g, b = H:GetTextColor(); return r, g, b")
.unwrap(),
(0.0, 1.0, 0.0),
"severing the link leaves the resolved paint standing, it does not blank the element"
);
}
/// `SetJustifyH` is **inert for rendered text** — the byte law, not a gap: `0x78adb0` overwrites
/// every block's justifyH with the tag's `align`. The getter still answers what was stored.
#[test]
fn set_justify_h_is_stored_and_answered_but_never_drawn() {
let s = script();
page(&s);
s.run(
r#"
Page:SetJustifyH("RIGHT")
Page:SetText("<HTML><BODY><P>a</P></BODY></HTML>")
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return Page:GetJustifyH()").unwrap(),
"RIGHT"
);
assert_eq!(
blocks(&s, "Page")[0].justify_h,
"LEFT",
"the tag's absent align (LEFT) wins over the element font's justify"
);
// A non-token raises, exactly as `0x87c77c`'s usage arm does.
assert!(s.run(r#"Page:SetJustifyH("sideways")"#).is_err());
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Helpers that need the resolve
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// Every block's resolved `(top, bottom)`, in block order.
fn resolved_tops(s: &UiScript, name: &str) -> Vec<(f32, f32)> {
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");
st.blocks
.iter()
.map(|rh| {
let r = model.region_resolved.get(rh).expect("block rect");
(r.top, r.bottom)
})
.collect()
}
/// 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()
.into_iter()
.filter_map(|q| match q.content {
crate::script::QuadContent::Text { text: Some(t), .. } => Some(t),
_ => None,
})
.collect()
}
/// A Rust string as a Lua long-bracket literal — the bodies here are full of quotes and
/// backslashes, and escaping them twice is how a test asserts on the wrong string.
fn lua_str(s: &str) -> String {
format!("[==[{s}]==]")
}
+31
View File
@@ -659,6 +659,37 @@ impl WidgetArena {
.push(handle);
Some(handle)
}
/// **Free one region leaf** — unlink it from its owner's region list and release the slab slot,
/// so the handle stops resolving and the region stops drawing. Returns whether it was live.
///
/// There is no `Region:Destroy` in the widget API and none is wanted: this exists for the one
/// engine-owned lifetime in the client, `CSimpleHTML::SetText`, which pool-frees the blocks of
/// the previous parse before building the new ones (`simplehtml-markup-engine.md` §10 step 1 —
/// the pool allocate at `0x78adc6` has a matching free, and the CONTENTNODE list at `+0x340` is
/// what names the objects to free). Without it a second `SetText` leaves the first parse's
/// FontStrings on screen, stacked behind the new ones.
///
/// **The caller owns the rest of the region's identity.** The arena holds only structure;
/// paint (`RegionData`), the stable id maps, the resolved-rect cache and any name publish live
/// on the script model, and a caller that frees a region without dropping those leaves a
/// resolvable id pointing at a dead handle. [`crate::script`]'s SimpleHTML block teardown is
/// the one caller and does the whole set. For the same reason this must never be pointed at a
/// region some *state* still holds by handle (a button's texture slot, a tooltip line, an
/// EditBox's text region): freeing those would leave that state dangling.
pub fn destroy_region(&mut self, h: RegionHandle) -> bool {
let Some(region) = self.region(h) else {
return false;
};
let owner = region.owner;
if let Some(f) = self.frame_mut(owner) {
f.regions.retain(|&r| r != h);
if f.title_region == Some(h) {
f.title_region = None;
}
}
self.regions.remove(h.index, h.generation).is_some()
}
}
// The propagation mutators (visibility/strata/level/scale/alpha/reparenting) — split out for size;
+50 -5
View File
@@ -91,17 +91,37 @@ fn dev_plane(keys: &ButtonInput<KeyCode>) -> bool {
ctrl && shift && !blocked
}
/// Keys a **synthetic** input source is holding this frame — the probe harness's `WOW_PROBE_KEY`
/// taps are the only writer today.
///
/// The reconciliation above polls the *hardware* flag state, which by construction reads "up" for
/// a key no hand is on. Without this list a synthesized `Shift`/`Ctrl` is released the frame after
/// it is pressed — and logged as a stuck-key correction, so the instrument reads as working while
/// **no probe can reach a chord binding on macOS at all** (`SHIFT-V`'s friendly nameplates, the
/// `Ctrl`+`Shift`+`F` free-fly the `"Shift"` tap name was added for). The engine owns the type
/// because the reconciler does; whoever synthesizes input writes it, on every platform.
#[derive(Resource, Default)]
pub struct SyntheticHold(pub Vec<KeyCode>);
/// Is `code` a modifier Bevy holds down that nothing is legitimately holding — i.e. stuck, and the
/// reconciler's to release? Pure, so the rule is testable off-platform (the reconciler itself
/// needs AppKit and the main thread) — which is also why it is compiled for the test build of
/// every platform, and only there off macOS.
#[cfg(any(target_os = "macos", test))]
fn is_stuck(code: KeyCode, pressed: &ButtonInput<KeyCode>, hold: &SyntheticHold) -> bool {
pressed.pressed(code) && !hold.0.contains(&code)
}
pub struct ModKeysPlugin;
impl Plugin for ModKeysPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SyntheticHold>();
#[cfg(target_os = "macos")]
app.add_systems(
PreUpdate,
mac::reconcile_modifiers.after(bevy::input::InputSystems),
);
#[cfg(not(target_os = "macos"))]
let _ = app;
}
}
@@ -118,6 +138,7 @@ mod mac {
_main_thread: NonSendMarker,
mut codes: ResMut<ButtonInput<KeyCode>>,
mut logical: ResMut<ButtonInput<Key>>,
hold: Res<super::SyntheticHold>,
) {
let flags = unsafe { NSEvent::modifierFlags_class() };
let families = [
@@ -147,14 +168,17 @@ mod mac {
continue;
}
// Guarded so an in-sync frame (the overwhelmingly common case) never marks the
// resources changed.
// resources changed — and a key a synthetic source is holding
// ([`super::SyntheticHold`]) is never "stuck": the hardware poll cannot see it.
let mut synthetic = false;
for code in keys {
if codes.pressed(code) {
if super::is_stuck(code, &codes, &hold) {
warn!("modkeys: releasing stuck {code:?} (OS reports it up)");
codes.release(code);
}
synthetic |= hold.0.contains(&code);
}
if logical.pressed(key.clone()) {
if !synthetic && logical.pressed(key.clone()) {
logical.release(key);
}
}
@@ -204,6 +228,27 @@ mod tests {
}
}
/// A modifier a **synthetic** source is holding is never "stuck". The macOS reconciler polls
/// the hardware flags, which read up for a key no hand is on — so without the exemption a
/// synthesized chord dies the frame after it is pressed, and every `WOW_PROBE_KEY` chord
/// (`SHIFT-V`'s friendly nameplates, `Ctrl`+`Shift`+`F`'s free-fly) is unreachable on the
/// platform we develop on, while the log calls it a stuck-key fix.
#[test]
fn a_synthesized_modifier_is_not_stuck() {
let held = keys(&[KeyCode::ShiftLeft]);
assert!(is_stuck(
KeyCode::ShiftLeft,
&held,
&SyntheticHold::default()
));
let hold = SyntheticHold(vec![KeyCode::ShiftLeft]);
assert!(!is_stuck(KeyCode::ShiftLeft, &held, &hold));
assert!(
!is_stuck(KeyCode::ControlLeft, &held, &hold),
"an unheld key is nothing to release either way"
);
}
/// The label a hint prints is the plane actually listened for — one const, one predicate, so a
/// player is never told to press a key we stopped reading.
#[test]
+18 -1
View File
@@ -207,6 +207,13 @@ pub struct WorldLoadProgress {
pub colliders_pending: usize,
/// Focus-neighbourhood placements not yet spawned (the wait instrument's term).
pub placements_pending: usize,
/// **Which tile these facts are about** — the focus tile the streamer computed this frame.
/// `None` until the streamer first runs (and again after `release_world`). The settle release
/// compares it against the tile under the avatar's own feet and refuses the resident release on
/// a mismatch (decision 1336): residency published for one tile must never unfreeze a body
/// standing on another — the fail-closed guard that makes any future focus/snap ordering
/// regression cost a logged 6 s timeout instead of a silent fall through the world.
pub focus_tile: Option<(i32, i32)>,
}
impl WorldLoadProgress {
@@ -863,7 +870,17 @@ fn stream_terrain(
let spawned = |c: &(i32, i32)| state.tiles.get(c).is_some_and(|t| t.furnished);
p.total = desired.len();
p.ready = desired.iter().filter(|c| spawned(c)).count();
p.focus_resident = state.tiles.get(&(cx, cy)).is_none_or(|t| t.furnished);
p.focus_tile = Some((cx, cy));
// Resident = the focus tile is furnished — judged from the DESIRED set, not from the
// presence of a `state.tiles` entry: a desired tile whose request hasn't landed yet is
// absent from the map, and "no entry" must read as *not there*, never as "nothing to wait
// for". The vacuous arm is only for ground the WDT never authors (map edge, open ocean,
// a WMO-only map) — waiting there would hang forever on terrain that cannot exist.
p.focus_resident = if desired.contains(&(cx, cy)) {
state.tiles.get(&(cx, cy)).is_some_and(|t| t.furnished)
} else {
true
};
// The focus neighbourhood's placements join the accounting (bar + scene term). A placement
// is *up* once its own model spawned AND its WMO props (each an M2 arriving on its own
// schedule) have — the furniture the reveal would otherwise pop in. Placements of tiles not