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:
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 ` `, 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, ` `, 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 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 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}]==]")
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user