the cooldown goes stock, early-Z comes back, and lists reach the end

- a size-less `<Model>` pane's rect is its file's box in layout units: the reference's geometry overrides (`0x76d080`/`0x76d0d0`) answer the bbox extent whenever no size is authored, `bboxExtent · 768·√(a²+1) · layoutScale` FrameXML units (`1280 · extent` at 4:3), written into the pane's layout input when the file's facts land, re-derived on an aspect change, and ended for good by any authored size; `SetPosition` is the same unit, which is how `0x4a7b20` centres the arrow at `½·GetWidth, ½·GetHeight` through the override — so the map arrow's sprite arm and its hand-sized 33.6 px footprint go (its rect is `0.0262 × 0.0263` layout units, 33.5 × 33.7 FrameXML units at 4:3, 41.0 × 41.2 at 16:9), and the stock `ItemAnim` pane plays the item push unadapted (`ReplaceIconTexture` on the type-14 batch, the 1000 ms clamp, the stock `OnAnimFinished`), `ContainerFrameAdapters.xml` losing 0887's Lua card and its hand-measured 42.41 × 124.72 rect — the implicit one at 16:9 — and keeping the keyring fork and the three bag verbs (2015); and the cooldown is the reference's own model: the texture-transform law `uv' = R_q((uv + t − p) ⊙ s) + p`, `p = (½, ½)`, the raw quaternion component-lerped and never normalised, is one function (`uv_transform`) with the shader's UV fold as its twin — rotation and scale tracks bake per file sequence slot beside the translation, the mat-anim table gains an affine row `[cos − 1, sin, sx − 1, sy − 1]` on `anim_slots.z` whose identity is the zero row so every static batch reads exactly what it did, and a UI tile clones the twin material per animated batch and writes both rows every frame off the pane's play head, never the world clock; the stock `Cooldown.xml` runs off the chain on 2007's two handlers, the file's four quadrant quads turned by their own rotation tracks (`0 → −90°` over each quadrant's 250 ms window, clockwise from 12 o'clock) and its alpha tracks revealing the star for the flash; retired: the native `Cooldown` widget and `SetCooldown` (an Era API 1.12 never had — `CreateFrame("Cooldown")` is the reference's unknown-type refusal now), the loader's `Model → Cooldown` substitution, the app's hand-built pie and star, and our `Cooldown.xml`; `assets/ui` is eight files (2019)
- `UpdateMapHighlight` gets its zone-level exits (B360): at zone zoom ours returned the nil tail for every cursor position, so hovering Deadwind Pass from Duskwood showed no label where 1.12 shows "Deadwind Pass"; the reference's `0x4a7fa0` runs two lookups at zone level and answers a NAME only — first the current zone's exploration-gated overlay list (the builder at `0x4a6ad9` gating on the explored byte), each row's HitRect scaled by 1/1002 and 1/668 against the normalized cursor, edges inclusive, the first hit naming its Area1, then the continent grid re-windowed through the displayed zone's own rect, naming the neighbouring zone or city and nothing for the displayed zone itself — and only the name-only tail is reachable from there, fileName nil and the six coords zero, so the stock frame hides the highlight quad, as the director's own 1.12 shot shows; and `SetVerticalScroll` stores the offset as given (B370, B363): the reference's `0x786db0` compares the new offset against the old and writes it to `[+0x328]` verbatim, `0x787100` re-anchors the scroll child off it, and the range at `[+0x320]` is never read on that path — every clamp the reference exhibits is FrameXML's, through the scroll bar's `[min, max]`; ours clamped into `[0, GetVerticalScrollRange()]`, the child's overflow past the FRAME, which is 40 px short of where `FauxScrollFrame_Update` sends the bar on the 220-tall skills list (three rows lost) and 15 px short on the 287-tall who list (one); the setter fires `OnVerticalScroll` only on a change, as the bytes do, `BenillaScroll_Step` bounds its wheel in Lua where the reference's handler does, and the ScrollFrame kind's prose stops calling its offset unpinned (2017)
- overdraw's cheap half, the sky first: since 0588 every sky element wrote `frag_depth = 0.0`, which costs a pipeline its early-Z, so on an outdoor frame every full-screen dome fragment under the ground and the buildings was shaded in full (the gradient's `asin`, `atan2` and eight table lookups at dawn and dusk) and thrown away; one shared `sky_vertex.wgsl` — bevy's mesh vertex verbatim plus `out.position.z = 0.0` — is now the vertex stage of the gradient dome, the cloud dome, the star patches and the celestial discs and glares, and the model lane's `WOW_SKY_DEPTH` branch moves from its fragment to its vertex, so no sky fragment declares `frag_depth` and the rasterizer rejects the covered screen before the shader runs; every sky `specialize` zeroes the rasterizer bias constant the `sky_order` rung had been leaking into `DepthBiasState::constant`, harmless only while the fragment overwrote its depth, and the prose beside the pin follows it; then the retained `static_gx` pass draws BEFORE bevy's opaque pass (`StartMainPass → static_gx → MainOpaquePass`) — buildings and doodads are the frame's best occluders and a terrain fragment its dearest, six samples a pixel — so walls reject the terrain behind them instead of the reverse, the pass taking the first-use clear with it and the dev depth probe ordered after it; measured on 16 capture scenarios: 14 bit-identical, `northshire-dusk` 1 px and `water-night` 11 px, attributed to exact depth ties on static/terrain silhouettes and last-bit interpolation noise; a depth prepass and near-to-far bins are declined until a journal says where the time goes (2016)
- the grass cut goes per pixel because of a quarter-mip: `0x6813d0` sets `D3DSAMP_MIPMAPLODBIAS = +0.25` on stage 0 (`0x6813f4`) once per frame before the detail-doodad pass — capture-confirmed on all 29 doodad batches, and outside the `[0x6b1000, 0x6b3200)` scan 2004 had called complete; it decides the whole look because the `clutter_cut` probe finds 6 of 31 shipped detail atlases carry a BINARY alpha pyramid (mip 0 has DXT3's 16 levels, every level below exactly two, both starting zones among them), so `a × ramp` degenerates to a global on/off and every texel of a leaf crosses at the same depth — Elwynn's survivor coverage is flat at 47.6 % across the whole 52.5–61.1 yd band, then 0.0 %, while the probe's box-filtered control erodes 47.7 → 20.9 over the same band, so the art has the gradient the shipped pyramid threw away, and `clutter_size` puts the median tuft at 0.92 yd tall, 1.03 yd deep, a full yard for the boundary plane to sweep; the escape is bilinear: full alpha needs a 2×2 all-opaque texel neighbourhood, and those run out between mip 2 and 3 (179/56/10/0/0/0 on the dominant Elwynn cell), so the reference's λ ≈ 2.9–3.3 sits past the cliff and the tuft erodes continuously — 2.2 % of a tuft's pixels leave in the last half-yard with the bias, 11.6 % without, 24.2 % one mip sharper; wgpu has no sampler LOD bias, so the clutter lane re-samples base colour at `view.mip_bias + 0.25`, keeping the sampler every other model batch shares at the reference's 0; ruled out on the way: our BLP decoder (all nine uploaded levels byte-identical against the archive's 52 793 BLP2s), mip regeneration, the `has_mipmaps` byte's value 2 (a plain boolean, an authoring marker), anisotropy and geometry; what it does not fix is resolution-dependent, and so is the reference's own look — λ with the bias is 3.22 at the capture's 648p but 2.48 at 1080p and 1.48 at 2160p, so at a modern resolution the reference itself pops leaf by leaf, and scaling the bias with the viewport is one reversible line left for a look call (2021)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-06 19:35:31 +07:00
co-authored by Claude Fable 5.1
parent c92ca1b8ec
commit c3555b4eea
93 changed files with 2423 additions and 1643 deletions
-71
View File
@@ -1,71 +0,0 @@
//! The `Cooldown` widget's method surface (decision 0137 phase 4) — the Era API's
//! `Cooldown:SetCooldown(start, duration)` over the engine-modeled sweep/flash machine
//! ([`crate::widget::CooldownState`]).
//!
//! The 1.12 reference has no such method: its cooldown is a `Model` playing
//! `UI-Cooldown-Indicator.mdx`, driven by `Cooldown.lua`'s authored machine
//! (`CooldownFrame_SetTimer` → `SetSequence(0)` + `Show`; `OnUpdateModel` scrubs sequence 0 to
//! `(GetTime()-start)/duration`, flips to sequence 1 realtime at 1.0, `OnAnimFinished` → `Hide`).
//! benilla keeps `CooldownFrame_SetTimer`'s show/hide gate ref-verbatim in the authored XML and
//! models the sequence machine engine-side: `SetCooldown` stores the pair, the extraction derives
//! the sweep fraction / flash phase from the engine clock, and `tick` hides a finished widget
//! (the `OnAnimFinished` edge). The pixels — the byte-pinned 60 %-black pie and the `star4`
//! flash — are the app renderer's ([`super::types::QuadContent::Cooldown`]).
//!
//! The methods live in their own registry table, consulted by the frame `__index` dispatcher only
//! for Cooldown frames — the same duck-typing posture as StatusBar's.
use mlua::{Lua, Table};
use super::object::frame_handle_of;
use super::Model;
use crate::widget::{CooldownState, KindState};
/// Registry key of the Cooldown method table (the MAXCSTACK discipline: Lua-side root, named key).
pub(super) const REG_COOLDOWN_METHODS: &str = "__benilla_cooldown_methods";
/// Run `f` over a frame's Cooldown state under one short write borrow.
fn with_cooldown<T>(
lua: &Lua,
this: &Table,
f: impl FnOnce(&mut CooldownState) -> T,
) -> mlua::Result<T> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
match &mut frame.kind_state {
KindState::Cooldown(c) => Ok(f(c)),
_ => Err(mlua::Error::runtime("not a Cooldown")),
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
m.set(
"SetCooldown",
// `SetCooldown(start, duration)` — GetTime-clock seconds, the pair
// `CooldownFrame_SetTimer` stores (`this.start`/`this.duration`). Show/hide stays the
// authored helper's job (ref-verbatim), so the method only (re)arms the timer.
lua.create_function(|lua, (this, start, duration): (Table, f64, f64)| {
with_cooldown(lua, &this, |c| {
c.start = start;
c.duration = duration;
})
})?,
)?;
m.set(
"GetCooldownTimes",
// The Era read-back (milliseconds, like the live API) — no reference consumer; kept for
// parity with SetCooldown so a transcription can round-trip.
lua.create_function(|lua, this: Table| {
with_cooldown(lua, &this, |c| (c.start * 1000.0, c.duration * 1000.0))
})?,
)?;
lua.set_named_registry_value(REG_COOLDOWN_METHODS, m)?;
Ok(())
}
-13
View File
@@ -91,7 +91,6 @@ impl UiScript {
/// [`crate::order::traversal`] zipped with the resolved rects and region visuals. Already sorted
/// ascending by `ZKey`. Call [`UiScript::resolve`] first for populated rects.
pub fn extract(&self) -> Vec<ExtractedQuad> {
let now = self.now();
let model = self.model_ref();
let list = order::traversal(&model.arena);
let mut out = Vec::with_capacity(list.len());
@@ -114,18 +113,6 @@ impl UiScript {
zoom: m.zoom,
inside_zoom: m.inside_zoom,
},
// A shown Cooldown's phase (the reference machine's derived state): the
// sweep scrub while `now < start+duration`, else the 1 s flash's own
// progress. `tick` hides the widget at flash end, so a stale slot never
// draws (decision 0137 phase 4).
Some(crate::widget::KindState::Cooldown(cd)) if cd.duration > 0.0 => {
let fraction = ((now - cd.start) / cd.duration) as f32;
let flash = (fraction >= 1.0).then(|| {
((now - cd.sweep_end()) / crate::widget::COOLDOWN_FLASH_SECS)
.clamp(0.0, 1.0) as f32
});
QuadContent::Cooldown { fraction, flash }
}
// A `<Model>`/`<PlayerModel>` pane's content hole, carrying the pane's own
// name so the app can join it to the bake that window keeps (see
// [`QuadContent::ModelPane`]). Both widget kinds share `KindState::Model`
+4 -4
View File
@@ -64,7 +64,6 @@ mod chat_window;
mod clip;
mod colorselect;
mod container;
mod cooldown;
mod craft;
mod cursor;
mod death;
@@ -211,7 +210,7 @@ pub use tabard::{
emblem_mask_path, TabardHost, TabardIntent, EMBLEM_MASK_TOKEN, TABARD_COUNTS,
TABARD_CREATION_COST,
};
pub use worldmap_arrow::{ARROW_FOOTPRINT_PX, ARROW_MODEL};
pub use worldmap_arrow::ARROW_MODEL;
pub(crate) use button::{set_label_font_justify_h_lua, LabelFont};
pub use inspect::{InspectView, UnitReach};
@@ -667,7 +666,6 @@ impl UiScript {
colorselect::install(&lua)?;
minimap::install(&lua)?;
modelframe::install(&lua)?;
cooldown::install(&lua)?;
tooltip::install(&lua)?;
worldmap::install(&lua)?;
worldstate::install(&lua)?;
@@ -790,6 +788,9 @@ impl UiScript {
}
model.screen = new;
model.touch_layout();
drop(model);
// The implicit rects are measured in layout units, which follow the aspect (2015).
self.reapply_implicit_rects();
true
}
@@ -1474,7 +1475,6 @@ impl UiScript {
crate::widget::FrameKind::MovieFrame => "MovieFrame",
crate::widget::FrameKind::GameTooltip => "GameTooltip",
crate::widget::FrameKind::Minimap => "Minimap",
crate::widget::FrameKind::Cooldown => "Cooldown",
});
}
// The region leaves publish into their own name table (`region_names`), not the arena's —
+90 -2
View File
@@ -94,9 +94,70 @@ use mlua::{Lua, MultiValue, Table, Value};
use super::object::frame_handle_of;
use super::Model;
use crate::widget::{model_key, KindState, ModelFileFacts, ModelState};
use crate::widget::{model_key, FrameHandle, KindState, ModelFileFacts, ModelState};
impl Model {
/// FrameXML units per **layout unit** — `768 · √(a²+1)` for the screen's aspect `a`
/// (render law §3; the `G48 = 1/√(a²+1)` root scale against the `768`-tall FrameXML
/// space). `SetPosition`'s space, and the unit a size-less pane's implicit rect is measured
/// in. `4/3` before a screen exists.
pub(crate) fn layout_unit(&self) -> f32 {
let (w, h) = (self.screen.width(), self.screen.height());
let a = if h > 0.0 && w > 0.0 { w / h } else { 4.0 / 3.0 };
768.0 * (a * a + 1.0).sqrt()
}
/// The **implicit rect** (decision 2015): a model pane that authored no size takes its
/// file's bounding-box extent, in layout units, as its size — the reference's geometry
/// overrides (`0x76d080`/`0x76d0d0`) answer the bbox whenever no size is authored, and its
/// layout consumes them like any size. Written into the layout input when the facts are
/// known; a pane with an authored size, or no file, or no facts yet, is left alone.
pub(crate) fn apply_implicit_rect(&mut self, h: FrameHandle) {
let unit = self.layout_unit();
let extent = {
let Some(f) = self.arena.frame(h) else { return };
let KindState::Model(m) = &f.kind_state else {
return;
};
let Some(path) = m.path.as_deref() else {
return;
};
let Some(facts) = self.model_facts.get(&model_key(path)) else {
return;
};
let authored = !m.implicit_size
&& self
.layout_inputs
.get(&h)
.is_some_and(|i| i.width != 0.0 || i.height != 0.0);
if authored {
return;
}
facts.extent()
};
let (w, ht) = (extent.0 * unit, extent.1 * unit);
let input = self.layout_inputs.entry(h).or_default();
let changed =
input.width.to_bits() != w.to_bits() || input.height.to_bits() != ht.to_bits();
input.width = w;
input.height = ht;
if changed {
self.touch_layout_frame(h);
}
if let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) {
m.implicit_size = true;
}
}
/// A size was AUTHORED on `h` (`SetWidth`/`SetHeight`/`SetSize`, the XML `<Size>` through
/// them): an implicit rect no longer applies to it, for good — the reference's override
/// yields to any authored size.
pub(crate) fn note_authored_size(&mut self, h: FrameHandle) {
if let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) {
m.implicit_size = false;
}
}
/// The facts the host has handed over for `path`, or `None` — in which case the path is
/// queued for the host to load ([`crate::script::UiScript::model_facts_wanted`]), once.
pub(crate) fn model_facts_for(&mut self, path: &str) -> Option<Arc<ModelFileFacts>> {
@@ -236,7 +297,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.app_data_mut::<Model>()
.expect("model app_data")
.model_facts_for(&path);
with_model(lua, &this, |m| m.set_file(path, facts.as_deref()))
with_model(lua, &this, |m| m.set_file(path, facts.as_deref()))?;
// A size-less pane takes the file's rect the moment the file is known (2015).
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model app_data")
.apply_implicit_rect(h);
Ok(())
})?,
)?;
m.set(
@@ -559,6 +626,27 @@ impl crate::script::UiScript {
if let Some(KindState::Model(m)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
m.seed_from_facts(&facts);
}
model.apply_implicit_rect(h);
}
}
/// The screen's aspect moved: every implicit rect is measured in layout units, which scale
/// with `√(a²+1)`, so each one is re-derived (decision 2015). An authored size is untouched.
pub(crate) fn reapply_implicit_rects(&mut self) {
let mut model = self.model_mut();
let panes: Vec<FrameHandle> = model
.arena
.ticked_kinds()
.iter()
.copied()
.filter(|&h| {
model.arena.frame(h).is_some_and(
|f| matches!(&f.kind_state, KindState::Model(m) if m.implicit_size),
)
})
.collect();
for h in panes {
model.apply_implicit_rect(h);
}
}
-2
View File
@@ -249,7 +249,6 @@ fn frame_kind_from_str(s: &str) -> Option<FrameKind> {
"MOVIEFRAME" => FrameKind::MovieFrame,
"GAMETOOLTIP" => FrameKind::GameTooltip,
"MINIMAP" => FrameKind::Minimap,
"COOLDOWN" => FrameKind::Cooldown,
_ => return None,
})
}
@@ -458,7 +457,6 @@ fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
super::modelframe::REG_MODEL_METHODS,
],
Some(FrameKind::Minimap) => &[super::minimap::REG_MINIMAP_METHODS],
Some(FrameKind::Cooldown) => &[super::cooldown::REG_COOLDOWN_METHODS],
Some(FrameKind::GameTooltip) => &[super::tooltip::REG_TOOLTIP_METHODS],
_ => &[],
}
@@ -109,13 +109,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// anything derived from the variant name — `format!("{:?}")` and friends — would hand
// addons `"SimpleHtml"`, and `GetObjectType` is compared with `==` (`IsObjectType`'s
// case-folding would hide it; the getter's would not).
// · **`Cooldown` answers `"Model"`.** 1.12.1 has no `Cooldown` type — the census finds 23
// type-name globals and none is that. The reference builds its cooldown as a `Model` playing
// `UI-Cooldown-Indicator.mdx` (`CooldownFrameTemplate`), and OUR `FrameKind::Cooldown` is a
// deliberate Era-shaped divergence that models the mechanism first-class (0137 phase 4). So
// the faithful answer is what the reference's own cooldown widget IS. Answering `"Cooldown"`
// would announce an Era type to a Lua ecosystem that branches on presence — precisely the
// superset 1189 had to take back out.
// · **There is no `Cooldown` type.** 1.12.1's census finds 23 type-name globals and none is
// that: the reference builds its cooldown as a `Model` playing `UI-Cooldown-Indicator.mdx`
// (`CooldownFrameTemplate`), and since decision 2019 so does this engine — the Era-shaped
// `FrameKind::Cooldown` that modelled the mechanism first-class (0137 phase 4) is gone, so
// `CreateFrame("Cooldown")` is the reference's own unknown-type refusal, never an announced
// Era type to a Lua ecosystem that branches on presence (the superset 1189 took back out).
fn type_chain(kind: FrameKind) -> &'static [&'static str] {
match kind {
FrameKind::Frame => &["Frame", "Region"],
@@ -144,7 +143,6 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
FrameKind::MovieFrame => &["MovieFrame", "Frame", "Region"],
FrameKind::GameTooltip => &["GameTooltip", "Frame", "Region"],
FrameKind::Minimap => &["Minimap", "Frame", "Region"],
FrameKind::Cooldown => &["Model", "Frame", "Region"],
}
}
fn chain_of(lua: &Lua, this: &Table) -> mlua::Result<&'static [&'static str]> {
@@ -155,6 +155,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// A size write moves no edge and no roster membership (decision 1388).
model.touch_layout_frame(h);
}
model.note_authored_size(h);
Ok(())
})?,
)?;
@@ -169,6 +170,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
if changed {
model.touch_layout_frame(h);
}
model.note_authored_size(h);
Ok(())
})?,
)?;
@@ -185,6 +187,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
if changed {
model.touch_layout_frame(h);
}
model.note_authored_size(h);
Ok(())
})?,
)?;
+26 -17
View File
@@ -1,10 +1,12 @@
//! The `ScrollFrame` method surface (`CSimpleScrollFrame`) — the ScrollFrame mechanism (decision
//! 0112, the engine's last structural gap): `SetScrollChild`/`GetScrollChild`, the vertical scroll
//! offset (`SetVerticalScroll`/`GetVerticalScroll`), and the live range
//! (`GetVerticalScrollRange`/`UpdateScrollChildRect`). Spec-faithful to the documented widget
//! contract (the Era `ScrollFrameTemplate` Lua drives its scrollbar off exactly these), not
//! byte-pinned — same posture as StatusBar's fill. Horizontal scroll (`SetHorizontalScroll`/…) is
//! out of scope: no 1.12 template drives it.
//! (`GetVerticalScrollRange`/`UpdateScrollChildRect`). The offset setter and the range are
//! byte-pinned (decisions 1338, 2017): `0x786db0` stores the offset VERBATIM — the engine never
//! clamps it — and `0x786e30` measures the range off the scroll child's subtree. The rest is
//! spec-faithful to the documented widget contract (the Era `ScrollFrameTemplate` Lua drives its
//! scrollbar off exactly these), same posture as StatusBar's fill. Horizontal scroll
//! (`SetHorizontalScroll`/…) is out of scope: no 1.12 template drives it.
//!
//! The actual geometry — the scroll child's anchors pinned to the frame + the scroll offset, and
//! the clip every descendant of the child draws/hits within — lives in [`super::mod@super`]'s
@@ -203,26 +205,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SetVerticalScroll(px) — clamps into [0, GetVerticalScrollRange()] (computed live), stores, and
// fires OnVerticalScroll(self, offset) — the scrollbar's OnValueChanged wiring's other half.
// SetVerticalScroll(px) — stores the offset VERBATIM and, only when it actually changed,
// re-lays the child out and fires OnVerticalScroll(self, offset) — the scrollbar's
// OnValueChanged wiring's other half.
//
// **No clamp** (decision 2017). The reference's `0x786db0` compares the new value against the
// OLD one alone (an epsilon change-gate: unchanged → nothing happens, not even the script),
// writes it to `[+0x328]`, re-anchors the scroll child off it (`0x787100` →
// `SetPoint(child, self, hScroll, vScroll)`) and fires the handler. `[+0x320]` — the range —
// is never read on this path. Every clamp the reference exhibits is FrameXML's own, through
// the scroll bar's `[min, max]` (`ScrollFrameTemplate_OnMouseWheel`,
// `ScrollFrame_OnScrollRangeChanged`). The clamp this carried from 07-05 (a3fce6a76) to 2017
// cost every faux list whose frame is taller than `rows × step` its last rows (B370): the
// reference's `FauxScrollFrame_Update` drives the bar to `(n − rows) × step`, which is past
// `n × step − frameHeight` whenever the frame has slack under its last row.
m.set(
"SetVerticalScroll",
lua.create_function(|lua, (this, px): (Table, f32)| {
let h = frame_handle_of(lua, &this)?;
let range = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
scroll_range(&model, h)
};
let clamped = px.clamp(0.0, range);
let changed = with_scroll(lua, &this, |s| {
let changed = s.vertical.to_bits() != clamped.to_bits();
s.vertical = clamped;
let changed = s.vertical.to_bits() != px.to_bits();
s.vertical = px;
changed
})?;
if changed {
lua.app_data_mut::<Model>().expect("model").touch_layout();
if !changed {
return Ok(());
}
fire_vertical_scroll(lua, &this, clamped)
lua.app_data_mut::<Model>().expect("model").touch_layout();
fire_vertical_scroll(lua, &this, px)
})?,
)?;
m.set(
+3 -57
View File
@@ -1,64 +1,10 @@
//! The Cooldown widget kind (decision 0137 phase 4): the `SetCooldown` API, the reference
//! machine's derived phases through extraction, and the tick auto-hide (`Cooldown.lua`'s
//! `OnAnimFinished` → `Hide` edge), plus the per-action dynamic-state read API.
//! The cooldown **bindings**: the per-action dynamic-state read API and the `GetTime`-space
//! cooldown triples the stock `Cooldown.lua` machine consumes (the widget itself is the
//! reference's own `<Model>` since decision 2019 — `tests::model_clock` runs that file verbatim).
use super::common::script;
use crate::script::*;
fn cooldown_content(s: &UiScript) -> Option<(f32, Option<f32>)> {
s.extract().into_iter().find_map(|q| match q.content {
QuadContent::Cooldown { fraction, flash } => Some((fraction, flash)),
_ => None,
})
}
/// The machine, end to end on the engine clock: hidden → sweep (fraction scrubs with GetTime) →
/// the 1 s finish flash → auto-hidden by tick.
#[test]
fn cooldown_widget_sweeps_flashes_and_hides() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
cd = CreateFrame("Cooldown", "TestCooldown")
cd:SetWidth(36); cd:SetHeight(36); cd:SetPoint("CENTER")
cd:Hide()
"#,
)
.unwrap();
s.resolve();
assert_eq!(cooldown_content(&s), None, "hidden — no slot emitted");
// Arm a 10 s cooldown that started 2.5 s ago (the reference's start-in-the-past shape from
// GetActionCooldown) and show — the sweep reads 25 %.
s.run("cd:SetCooldown(GetTime() - 2.5, 10); cd:Show()")
.unwrap();
s.resolve();
let (fraction, flash) = cooldown_content(&s).expect("shown — the slot is emitted");
assert!(
(fraction - 0.25).abs() < 1e-3,
"sweep at 25%, got {fraction}"
);
assert_eq!(flash, None, "no flash while the sweep runs");
// Advance past the sweep end into the flash's middle: fraction ≥ 1, flash ≈ 0.5.
s.tick(8.0); // now 0.5 s into the flash window
s.resolve();
let (fraction, flash) = cooldown_content(&s).expect("flash phase still emits");
assert!(fraction >= 1.0);
let f = flash.expect("the finish flash runs for 1 s after the sweep");
assert!((f - 0.5).abs() < 0.05, "flash midway, got {f}");
// Past the flash: tick hides the widget (the OnAnimFinished → Hide edge).
s.tick(1.0);
s.resolve();
assert_eq!(cooldown_content(&s), None, "finished — tick hid the widget");
assert!(
!s.eval::<bool>("return cd:IsVisible() == 1").unwrap(),
"the frame itself is hidden, not just skipped"
);
}
/// The per-action dynamic-state API: the 1/nil conventions, IsActionInRange's tri-state, and
/// GetActionCooldown's GetTime-space triple that goes cold at expiry.
#[test]
@@ -409,6 +409,73 @@ fn a_loop_completes_once_and_a_fileless_pane_paints_nothing() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The implicit rect** (decision 2015): a model pane that authored no size takes its file's
/// bounding-box extent in LAYOUT units — `768·√(a²+1)` FrameXML units per unit, `1280` at 4:3
/// — the moment the facts are known; it follows the screen's aspect; an authored size wins.
#[test]
fn a_size_less_pane_takes_its_files_rect_in_layout_units() {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
// The map arrow's own box (`MinimapArrow.m2`, render law §2): 0.0262 × 0.0263 units.
let arrow = |s: &mut UiScript| {
s.set_model_facts(
r"Interface\Minimap\MinimapArrow.mdx",
ModelFileFacts {
sequences: vec![SequenceFacts {
anim_id: 0,
duration_ms: 3333,
looping: true,
}],
bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]),
},
);
};
s.run(
r#"
a = CreateFrame("Model", "Sized", UIParent)
a:SetPoint("CENTER")
b = CreateFrame("Model", "Authored", UIParent)
b:SetPoint("CENTER") b:SetWidth(50) b:SetHeight(20)
a:SetModel("Interface\\Minimap\\MinimapArrow.mdx")
b:SetModel("Interface\\Minimap\\MinimapArrow.mdx")
"#,
)
.unwrap();
s.resolve();
assert_eq!(
s.eval::<f32>("return Sized:GetWidth()").unwrap(),
0.0,
"no facts yet: no rect"
);
arrow(&mut s);
s.resolve();
let (w, h): (f32, f32) = s
.eval("return Sized:GetWidth(), Sized:GetHeight()")
.unwrap();
assert!(
(w - 0.0262 * 1280.0).abs() < 0.05 && (h - 0.0263 * 1280.0).abs() < 0.05,
"{w}×{h}"
);
let (bw, bh): (f32, f32) = s
.eval("return Authored:GetWidth(), Authored:GetHeight()")
.unwrap();
assert_eq!((bw, bh), (50.0, 20.0), "an authored size is untouched");
assert!(pane(&s, "Sized").implicit_size && !pane(&s, "Authored").implicit_size);
// 16:9 — a layout unit is 768·√((16/9)²+1) = 1566.4 FrameXML units.
s.set_screen_size(1600.0, 900.0);
s.resolve();
let w: f32 = s.eval("return Sized:GetWidth()").unwrap();
assert!((w - 0.0262 * 1566.4).abs() < 0.1, "{w}");
// Authoring a size later ends the implicit rect for good.
s.run("Sized:SetWidth(10)").unwrap();
assert!(!pane(&s, "Sized").implicit_size);
s.set_screen_size(1024.0, 768.0);
s.resolve();
assert_eq!(s.eval::<f32>("return Sized:GetWidth()").unwrap(), 10.0);
}
/// `ReplaceIconTexture` is the type-14 texture override on the INSTANCE: stored over a file
/// (queued and replayed while it streams), dropped with no file, released by `SetModel` and
/// `ClearModel`.
@@ -1464,9 +1464,6 @@ fn a_frames_type_chain_matches_the_roster() {
"DressUpModel",
&["DressUpModel", "PlayerModel", "Model", "Frame", "Region"],
),
// **Our Era-shaped divergence reports what 1.12's cooldown IS**: a Model. 1.12.1 has no
// `Cooldown` type name at all (the roster is 23 and none is that).
("Cooldown", "Model", &["Model", "Frame", "Region"]),
];
for (kind, leaf, chain) in cases {
s.run(&format!(r#"TC = CreateFrame("{kind}")"#))
@@ -68,10 +68,12 @@ fn get_scroll_child_roundtrips_wrapper_and_name_and_clears_on_nil() {
}
/// The sign convention (verified against the design's own worked example): frame top 500, vertical
/// 40 ⇒ child top 540. Also covers the 0 / clamped-to-range cases and that `SetScrollChild(nil)`
/// restores the child's own authored anchor (never mutated — the override is a local map).
/// 40 ⇒ child top 540. Also covers 0, an offset PAST the range and one below zero — both stored and
/// applied verbatim, since the reference's `0x786db0` never reads the range (decision 2017) — and
/// that `SetScrollChild(nil)` restores the child's own authored anchor (never mutated — the
/// override is a local map).
#[test]
fn scroll_child_top_tracks_vertical_scroll_and_clamps_then_restores_on_clear() {
fn scroll_child_top_tracks_vertical_scroll_unclamped_then_restores_on_clear() {
let mut s = script();
s.set_screen_size(800.0, 600.0); // screen rect: bottom 0, left 0, top 600, right 800
@@ -112,18 +114,34 @@ fn scroll_child_top_tracks_vertical_scroll_and_clamps_then_restores_on_clear() {
"vertical=40 -> child top = frame top + 40 (a positive offset lifts the child)"
);
// range = child_h(600) - frame_h(200) = 400; asking for 9999 clamps to it.
s.run("SF:SetVerticalScroll(9999)").unwrap();
// range = child_h(600) - frame_h(200) = 400, and the offset is NOT clamped to it: 450 is
// stored and applied as 450. (Keeping the bar inside the range is FrameXML's job, through the
// Slider's [min, max] — the engine has no opinion.)
s.run("SF:SetVerticalScroll(450)").unwrap();
s.resolve();
assert_eq!(
s.eval::<f32>("return SF:GetVerticalScroll()").unwrap(),
400.0
450.0,
"stored verbatim, the range of 400 notwithstanding"
);
let quads = s.extract();
assert_eq!(
marker_rect(&quads, "marker:child").map(|r| r.top),
Some(900.0),
"clamped: frame top 500 + range 400"
Some(950.0),
"applied verbatim: frame top 500 + 450"
);
// Nor at zero.
s.run("SF:SetVerticalScroll(-30)").unwrap();
s.resolve();
assert_eq!(
s.eval::<f32>("return SF:GetVerticalScroll()").unwrap(),
-30.0
);
let quads = s.extract();
assert_eq!(
marker_rect(&quads, "marker:child").map(|r| r.top),
Some(470.0),
"a negative offset lowers the child: frame top 500 - 30"
);
// SetScrollChild(nil): the override stops being computed — the child's own authored anchor
@@ -138,6 +156,41 @@ fn scroll_child_top_tracks_vertical_scroll_and_clamps_then_restores_on_clear() {
);
}
/// The reference's `0x786db0` is gated on the offset actually CHANGING (a compare against the
/// stored value, nothing else): a `SetVerticalScroll` to the value already held re-lays nothing out
/// and fires no `OnVerticalScroll`. The same shape as the Slider's `SetValue` change-gate (decision
/// 0250) — the other half of what keeps the bar ↔ frame wiring from ringing — and a value past the
/// range is a change like any other.
#[test]
fn set_vertical_scroll_fires_on_vertical_scroll_only_on_a_change() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Fires = {}
local frame = CreateFrame("ScrollFrame", "SF")
frame:SetPoint("TOPLEFT", 0, -100)
frame:SetSize(300, 200)
frame:SetScript("OnVerticalScroll", function() table.insert(Fires, arg1) end)
local child = CreateFrame("Frame", "Child")
child:SetSize(300, 600)
frame:SetScrollChild(child)
frame:SetVerticalScroll(0) -- already 0: nothing
frame:SetVerticalScroll(40) -- a change
frame:SetVerticalScroll(40) -- the same again: nothing
frame:SetVerticalScroll(700) -- past the range (400): a change, stored as given
frame:SetVerticalScroll(0) -- back
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return table.concat(Fires, \",\")")
.unwrap(),
"40,700,0"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
#[test]
fn vertical_scroll_range_is_live_and_zero_when_unresolved_or_childless_or_shorter() {
let mut s = script();
@@ -235,8 +288,11 @@ fn vertical_scroll_range_is_local_units_on_a_scaled_frame() {
);
}
/// `OnVerticalScroll` carries the value AS STORED — past the range too (decision 2017: the
/// reference's `0x786db0` fires with `[+0x328]`, which it never clamps) — under the RF-0025
/// conventions, and `UpdateScrollChildRect` fires `OnScrollRangeChanged` with the live range.
#[test]
fn vertical_scroll_fires_clamped_and_update_rect_fires_range_changed() {
fn vertical_scroll_fires_as_given_and_update_rect_fires_range_changed() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
@@ -265,8 +321,8 @@ fn vertical_scroll_fires_clamped_and_update_rect_fires_range_changed() {
s.run("SF:SetVerticalScroll(9999)").unwrap();
assert_eq!(
s.eval::<f32>("return seen_v").unwrap(),
400.0,
"fires with the CLAMPED value"
9999.0,
"fires with the value as given — the range (400) is not consulted"
);
s.run("SF:UpdateScrollChildRect()").unwrap();
+90 -2
View File
@@ -14,6 +14,9 @@ fn push_catalog(s: &mut UiScript) {
// Cell 8801 = the child (0.5, 0.5) resolves to through Elwynn's OWN loc rect — a "Stormwind
// City" peer child. Clicking it from the Elwynn zone map drills into the city (the §3 path).
ek_grid[8801] = 2;
// Cell 9060 = the child (0.82, 0.82) resolves to through Elwynn's own rect — Elwynn itself,
// the case the zone-level hover must stay silent on (the displayed zone never names itself).
ek_grid[9060] = 1;
s.set_world_map_catalog(vec![
WorldMapContinentView {
name: "Kalimdor".into(),
@@ -52,9 +55,23 @@ fn push_catalog(s: &mut UiScript) {
// A clean synthetic loc rect inside the EK continent rect: w=2000, h=1500 → the
// continent-highlight formula lands on round-ish expected coords (asserted below).
loc_rect: (-8000.0, -10000.0, -400.0, -1900.0),
// Two overlays shaped like real Elwynn rows: Northshire reveals on explore bit
// 125, Goldshire on bit 124 (the real AreaTable exploreFlags).
// Three overlays. Two are shaped like real Elwynn rows: Northshire reveals
// on explore bit 125, Goldshire on bit 124 (the real AreaTable exploreFlags);
// their hit rects (top, left, bottom, right; px of 1002×668) sit apart so a
// point is in at most one. The first is a row whose first area slot resolves
// to nothing (bit 126; its rect overlaps Northshire's) — the zone-level hover
// must walk past it, not stop at it.
overlays: vec![
WorldMapOverlayView {
texture: "Interface\\WorldMap\\Elwynn\\STONECAIRNLAKE".into(),
width: 128,
height: 128,
offset_x: 450,
offset_y: 300,
explore_bits: vec![126],
hit_rect: (300, 450, 400, 560),
area_name: None,
},
WorldMapOverlayView {
texture: "Interface\\WorldMap\\Elwynn\\NORTHSHIREVALLEY".into(),
width: 200,
@@ -62,6 +79,8 @@ fn push_catalog(s: &mut UiScript) {
offset_x: 490,
offset_y: 200,
explore_bits: vec![125],
hit_rect: (200, 490, 376, 690),
area_name: Some("Northshire Valley".into()),
},
WorldMapOverlayView {
texture: "Interface\\WorldMap\\Elwynn\\GOLDSHIRE".into(),
@@ -70,6 +89,8 @@ fn push_catalog(s: &mut UiScript) {
offset_x: 380,
offset_y: 320,
explore_bits: vec![124],
hit_rect: (400, 300, 520, 460),
area_name: Some("Goldshire".into()),
},
],
},
@@ -292,6 +313,73 @@ fn worldmap_click_containment_and_zone_grid() {
assert_eq!(s.eval::<i64>("return GetCurrentMapContinent()").unwrap(), 0);
}
/// At ZONE level `UpdateMapHighlight` answers a NAME only (report B360; wow-re 15b2a8ea §1a/1b/1d):
/// a revealed overlay's sub-area when the cursor is inside its hit rect, else the neighbouring
/// zone or city whose grid cell the cursor is in through the DISPLAYED zone's rect window — never
/// the displayed zone itself — and always a nil fileName + six zeros, so the stock frame hides the
/// highlight quad. Fogged sub-areas have no name; an overlay with no resolvable first area is
/// walked past. The reference's own Duskwood shot: "Deadwind Pass" in the label, no highlight.
#[test]
fn worldmap_zone_level_hover_names_without_highlight() {
let mut s = script();
push_catalog(&mut s);
s.run("SetMapZoom(2, 1)").unwrap(); // the Elwynn zone map
type Answer = (Option<String>, Option<String>, f64, f64, f64, f64, f64, f64);
let hover = |s: &mut UiScript, x: f32, y: f32| {
s.eval::<Answer>(&format!("return UpdateMapHighlight({x}, {y})"))
.unwrap()
};
let name_only =
|name: &str| -> Answer { (Some(name.into()), None, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) };
let miss: Answer = (None, None, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
// Nothing explored. (0.5, 0.5) through Elwynn's rect is cell 8801 = the Stormwind City peer:
// the neighbouring child is NAMED, and nothing else — the B360 answer, Deadwind Pass from
// Duskwood in the report.
assert_eq!(hover(&mut s, 0.5, 0.5), name_only("Stormwind City"));
// Cell 9060 = Elwynn itself: the displayed zone never names itself.
assert_eq!(hover(&mut s, 0.82, 0.82), miss);
// An empty cell, no overlay: the tail.
assert_eq!(hover(&mut s, 0.2, 0.2), miss);
// Inside Goldshire's hit rect but fogged (bit 124 clear): no name — the pre-search walks the
// REVEALED list, and cell 8929 under it is empty.
assert_eq!(hover(&mut s, 0.42, 0.7), miss);
// Reveal Northshire (bit 125 = word 3, bit 29). Its rect contains (0.5, 0.5): the overlay
// pre-search runs BEFORE the grid, so the sub-area wins over the city under the same point.
let mut explored = vec![0u32; 64];
explored[3] = 1 << 29;
s.set_world_map_explored(explored.clone());
assert_eq!(hover(&mut s, 0.5, 0.5), name_only("Northshire Valley"));
assert_eq!(hover(&mut s, 0.42, 0.7), miss, "Goldshire is still fogged");
// Reveal Goldshire too (bit 124).
explored[3] |= 1 << 28;
s.set_world_map_explored(explored.clone());
assert_eq!(hover(&mut s, 0.42, 0.7), name_only("Goldshire"));
// Only the nameless overlay revealed (bit 126 = word 3, bit 30): its rect contains (0.5, 0.5)
// but its first area resolves to nothing, so the walk passes it and the grid answers.
let mut explored = vec![0u32; 64];
explored[3] = 1 << 30;
s.set_world_map_explored(explored.clone());
assert_eq!(hover(&mut s, 0.5, 0.5), name_only("Stormwind City"));
// Nameless first, Northshire second, both revealed: the walk continues to Northshire.
explored[3] |= 1 << 29;
s.set_world_map_explored(explored);
assert_eq!(hover(&mut s, 0.5, 0.5), name_only("Northshire Valley"));
// Control: back at the continent level the hovered zone still lights up — fileName is the art
// folder and the quad geometry is non-zero.
s.run("SetMapZoom(2)").unwrap();
let (name, file, tpx, _, tx, ty, _, _) = hover(&mut s, 0.452843, 0.720880);
assert_eq!(
(name.as_deref(), file.as_deref(), tpx),
(Some("Elwynn Forest"), Some("Elwynn"), 1.0)
);
assert!(tx > 0.0 && ty > 0.0);
}
/// SetMapZoom queues WORLD_MAP_UPDATE through the pending-event queue: nothing fires inside the
/// call (a binding can't re-enter dispatch), the registered frame hears it on the next tick.
#[test]
+1 -14
View File
@@ -1,6 +1,6 @@
//! The host runtime loop — an `impl UiScript` block beside its concern (the `layout.rs` pattern):
//! the event fan-out to registered frames ([`UiScript::fire_event`]), the per-frame advance
//! ([`UiScript::tick`]: OnUpdate + the engine-side fades/cooldowns), and the FrameXML session clock
//! ([`UiScript::tick`]: OnUpdate + the engine-side fades and the model panes' clocks), and the FrameXML session clock
//! ([`UiScript::now`]). The low-level handler-firing these drive lives in [`super::event`].
use mlua::Lua;
@@ -188,7 +188,6 @@ impl super::UiScript {
// Advance every ScrollingMessageFrame's per-line fade (the client's OnUpdate `0x788460`).
// Independent of the frame's own OnUpdate script — the fade is C++ behavior, not Lua. The
// AtBottom freeze gate lives inside `ScrollingMessageState::tick`.
let now = self.now();
let mut model = self.model_mut();
// The sibling class's OnUpdate (`0x786200`): the same two-phase fade with no scroll gate,
// plus the capacity law that is this class's stand-in for `maxLines` — the cap is what fits
@@ -216,7 +215,6 @@ impl super::UiScript {
mf.trim_to_viewport(viewport_rows);
}
}
let mut finished_cooldowns: Vec<FrameHandle> = Vec::new();
for &h in &ticked {
let Some(frame) = model.arena.frame_mut(h) else {
continue;
@@ -224,17 +222,6 @@ impl super::UiScript {
if let crate::widget::KindState::ScrollingMessage(smf) = &mut frame.kind_state {
smf.tick(elapsed);
}
// A Cooldown whose flash has finished hides itself — the reference machine's
// `OnAnimFinished` → `Hide()` edge (`Cooldown.lua`), modeled engine-side like the
// message fade above (C++-equivalent behavior, not Lua).
if let crate::widget::KindState::Cooldown(cd) = &frame.kind_state {
if frame.shown && cd.duration > 0.0 && now >= cd.finished_at() {
finished_cooldowns.push(h);
}
}
}
for h in finished_cooldowns {
model.arena.set_shown(h, false);
}
drop(model);
// Advance fading tooltips (FadeOut's ramp + end-of-ramp hide) — engine behavior like the
+5 -20
View File
@@ -85,24 +85,10 @@ pub enum QuadContent {
/// `inside` flag it pushed down.
inside_zoom: u8,
},
/// A `Cooldown` widget's draw slot (decision 0137 phase 4): the engine-derived phase of the
/// reference machine (`Cooldown.lua`), for the app renderer's pie-wipe/flash draw. Emitted
/// only while the widget is shown; `tick` hides it once the flash ends.
Cooldown {
/// Sweep progress `0..1` (`(now-start)/duration`, the `Cooldown.lua` scrub); `>= 1` =
/// the sweep is over (the flash below runs) — no dark pie draws.
fraction: f32,
/// The finish flash's progress `0..1` over its authored 1.000 s (the model's sequence 1),
/// `None` while the sweep still runs. The alpha ramp is the model's own texture-weight
/// track — byte-read off `UI-Cooldown-Indicator.m2` (linear 0→1 over the first third,
/// hold to the half, 1→0 over the back half) — applied app-side.
flash: Option<f32>,
},
/// A `Model` / `PlayerModel` widget's own draw slot: the 3D pane's content hole. The engine
/// core carries the resolved rect and the pane's identity; the app renderer puts pixels in it
/// — the same division of labour [`QuadContent::Minimap`] and [`QuadContent::Cooldown`] use,
/// and for the same reason (the scene state is [`crate::widget::ModelState`]; the render is
/// not this crate's).
/// — the same division of labour [`QuadContent::Minimap`] uses, and for the same reason (the
/// scene state is [`crate::widget::ModelState`]; the render is not this crate's).
///
/// **The clock is deliberately NOT here.** A pane's play head moves every tick, and the
/// host memoizes its whole quad conversion on the extracted list being unchanged — a
@@ -125,10 +111,9 @@ pub enum QuadContent {
/// The pane's global frame name (`$parent`-expanded), or `None` for an anonymous
/// `CreateFrame("Model")` — pfUI's autocast shine is the corpus example of the latter.
name: Option<String>,
/// The `SetModel` path the pane holds (`None` for a unit pane or an empty one). Decision
/// 1980: the world-map arrow is an anonymous `Model` child whose file is the minimap arrow
/// (`crate::script::ARROW_MODEL`) — the one path the app draws as a sprite, so the seam
/// carries it with the facing and the pane's `SetModelScale`.
/// The `SetModel` path the pane holds (`None` for a unit pane or an empty one) — what the
/// host's tile renderer draws (decision 2013), the map arrow's `crate::script::ARROW_MODEL`
/// among them since 2015.
model: Option<String>,
/// `SetFacing`'s radians (0 default).
facing: f32,
+155 -72
View File
@@ -21,9 +21,13 @@
//! bitmap (`Interface\WorldMap\<Continent>.zmp`, remapped app-side to 1-based zone indices; the
//! source + cell law are wow-re-verified, `0x4a6ec0` / the Q1 §5 verdict 2026-07-07). The cell
//! law itself is transcribed here ([`area_grid_cell`]) rather than in the app's `map_proj`
//! because its only callers are these bindings. `UpdateMapHighlight` returns the hovered zone's
//! NAME only for now — the highlight-texture halves (fileName + the `0x4a7fa0` POT extents) land
//! with phase 3's overlay machinery, which shares them.
//! because its only callers are these bindings. `UpdateMapHighlight` answers per level exactly
//! as `0x4a7fa0` does (wow-re 15b2a8ea): at continent level the hovered zone's name **and** its
//! highlight quad; at zone level a **name only** — a revealed overlay's sub-area when the cursor
//! is inside its `WorldMapOverlay` hit rect, else the neighbouring zone or city whose grid cell
//! the cursor is in through the displayed zone's rect window, never the displayed zone itself —
//! with a nil fileName, so the frame hides the quad (report B360). The world sheet's continent
//! highlight is not built: hovering a continent there answers the nil/zero tail.
//!
//! Zone maps carry **exploration fog**: the base tiles are the unexplored parchment, and each
//! discovered sub-area's overlay art (`GetNumMapOverlays`/`GetMapOverlayInfo`, filtered by the
@@ -95,6 +99,15 @@ pub struct WorldMapOverlayView {
/// The AreaTable `exploreFlag` bit indices that reveal this overlay (any set bit shows it;
/// empty = never shown — an overlay whose areas are unknown).
pub explore_bits: Vec<u32>,
/// The hover hit rect `(top, left, bottom, right)`, px of the 1002×668 detail frame — the
/// DBC's own `HitRect*` fields. The zone-level `UpdateMapHighlight` scales it by 1/1002 and
/// 1/668 and tests the normalized cursor against it, both edges inclusive (wow-re 15b2a8ea
/// §1d, `0x4a7ffc..0x4a80ea`).
pub hit_rect: (u32, u32, u32, u32),
/// The name that hover shows inside the rect: the localized AreaTable name of the overlay's
/// FIRST area slot (`0x4a7fa0` reads `+0x8` only). `None` when that slot resolves to no row —
/// the client then walks past the overlay exactly as if the cursor were outside it.
pub area_name: Option<String>,
}
/// One continent. Its 1-based position IS the continent index Lua sees.
@@ -249,6 +262,121 @@ fn explored_bit(explored: &[u32], n: u32) -> bool {
.is_some_and(|w| w & (1 << (n % 32)) != 0)
}
/// The reciprocals `0x4a7fa0` scales a `WorldMapOverlay` hit rect by: the rect is authored in
/// pixels of the 1002×668 detail frame, the cursor arrives normalized, and the client brings the
/// rect to the cursor (`fild` → `fmul` a f32 constant → `fstp dword`), so the edges are f32.
const OVERLAY_RECIP_X: f32 = 1.0 / 1002.0;
const OVERLAY_RECIP_Y: f32 = 1.0 / 668.0;
/// The zone-level **overlay pre-search** (`0x4a7ffc..0x4a80ea`, wow-re 15b2a8ea §1d): the first
/// REVEALED overlay whose hit rect contains the cursor names its first area. Both edges are
/// inclusive; a rect with a zero-width edge never hits; a NaN cursor never hits; an overlay whose
/// first area resolves to no AreaTable row is walked past, not stopped at. The list is the one
/// `GetNumMapOverlays` counts — the exploration-gated one (`[0xb6e630]`, built by `0x4a6ad9`
/// under the explored-byte read at `0x4a6bfa`), so a fogged sub-area has no name.
fn overlay_hover(state: &WorldMapState, x: f32, y: f32) -> Option<String> {
revealed_overlays(state).into_iter().find_map(|o| {
let (top, left, bottom, right) = o.hit_rect;
let (xlo, xhi) = (
left as f32 * OVERLAY_RECIP_X,
right as f32 * OVERLAY_RECIP_X,
);
let (ylo, yhi) = (
top as f32 * OVERLAY_RECIP_Y,
bottom as f32 * OVERLAY_RECIP_Y,
);
let inside = xhi - xlo != 0.0
&& yhi - ylo != 0.0
&& (xlo..=xhi).contains(&x)
&& (ylo..=yhi).contains(&y);
if inside {
o.area_name.clone()
} else {
None
}
})
}
/// What `UpdateMapHighlight` answers for a cursor at map UV `(x, y)` under the current selection.
enum Hover {
/// Continent level, over a zone: its name and the highlight quad — fileName, texPercentageY,
/// textureX/Y, scrollChildX/Y (texPercentageX is that branch's constant 1.0).
Highlight {
name: String,
file: String,
tex_pct_y: f64,
texture: (f64, f64),
scroll: (f64, f64),
},
/// Zone level: a name only — fileName nil, six zeros, the frame hides the quad.
Name(String),
/// Off every area: the nil/zero tail.
Miss,
}
/// The continent-level answer for the hovered zone (the `0x4a81de → 0x4a822a` highlight-compute
/// path, zone-search branch): fileName = its art folder (the frame draws `<file>\<file>Highlight`),
/// the six coords seat/size/crop that texture over the zone's rect within the continent, keeping
/// the client's f32/f64 asymmetric narrowing.
fn continent_hover(cont: &WorldMapContinentView, zone: &WorldMapZoneView) -> Option<Hover> {
let (cl, cr, ct, cb) = cont.loc_rect;
let (zl, zr, zt, zb) = zone.loc_rect;
let (w, h) = (zl - zr, zt - zb);
let (cont_w, cont_h) = (cl - cr, ct - cb);
if w == 0.0 || h == 0.0 || cont_w == 0.0 || cont_h == 0.0 {
return None;
}
let recip_x = 1.0f32 / cont_w;
let recip_y_f32 = 1.0f32 / cont_h;
let texture_x = f64::from(recip_x * w);
let texture_y = (1.0f64 / f64::from(cont_h)) * f64::from(h);
let scroll_x = f64::from(recip_x * (cl - zl));
let scroll_y = f64::from(recip_y_f32 * (ct - zt));
// texPctY = dim/potdim, dim = __ftol((h·128)/w) (rect-form K = 128).
let dim = ((h * 128.0) / w) as i64;
let potdim = next_pow2(dim);
let tex_pct_y = if potdim > 0 {
dim as f64 / potdim as f64
} else {
0.0
};
Some(Hover::Highlight {
name: zone.name.clone(),
file: zone.map_file.clone(),
tex_pct_y,
texture: (texture_x, texture_y),
scroll: (scroll_x, scroll_y),
})
}
/// `0x4a7fa0`'s level dispatch (wow-re 15b2a8ea §1a). Continent level: the grid cell's zone lights
/// up. Zone level: the overlay pre-search first, then the same grid re-windowed by the displayed
/// zone's own rect (`0x4a7620`), which returns nothing for the displayed zone itself — and only the
/// name-only tail is reachable from there (`0x4a812e`). World level: the continent highlight is
/// not built, so the tail.
fn hover(wm: &WorldMapState, x: f32, y: f32) -> Hover {
let (c, z) = wm.selection;
let Some(cont) = c.checked_sub(1).and_then(|i| wm.continents.get(i as usize)) else {
return Hover::Miss;
};
if z != 0 {
if let Some(name) = overlay_hover(wm, x, y) {
return Hover::Name(name);
}
return match grid_area(wm, x, y) {
Some(zi) if u32::from(zi) != z => cont
.zones
.get(zi as usize - 1)
.map_or(Hover::Miss, |zone| Hover::Name(zone.name.clone())),
_ => Hover::Miss,
};
}
grid_area(wm, x, y)
.and_then(|zi| cont.zones.get(zi as usize - 1))
.and_then(|zone| continent_hover(cont, zone))
.unwrap_or(Hover::Miss)
}
/// The current selection's zone view, if a zone map is displayed.
fn current_zone(state: &WorldMapState) -> Option<&WorldMapZoneView> {
let (c, z) = state.selection;
@@ -570,82 +698,37 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// UpdateMapHighlight(x, y) → name, fileName, texPctX, texPctY, textureX, textureY,
// scrollChildX, scrollChildY (wow-re 15b2a8ea, 0x4a7fa0). Continent level: the hovered zone
// lights up — fileName = its art folder (the frame draws `<file>\<file>Highlight`), the six
// coords seat/size/crop that texture over the zone's rect within the continent, keeping the
// client's f32/f64 asymmetric narrowing. World-level continent highlight and zone-level
// overlay-name are deferred: those exits return the nil/zero tail (the frame hides the quad).
// scrollChildX, scrollChildY (wow-re 15b2a8ea, 0x4a7fa0) — the per-level law is [`hover`].
// Eight values always: the frame reads `fileName` to decide whether a quad is drawn at all,
// so a name-only answer carries a nil there and six zeros behind it.
g.set(
"UpdateMapHighlight",
lua.create_function(|lua, (x, y): (f32, f32)| {
// (name, fileName, texPctY, textureX, textureY, scrollChildX, scrollChildY); texPctX
// is the constant 1.0 the continent branch always returns.
let hit: Option<(String, String, f64, f64, f64, f64, f64)> = {
let hit = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let wm = &model.worldmap;
let (c, z) = wm.selection;
if c == 0 || z != 0 {
None
} else {
grid_area(wm, x, y)
.and_then(|zi| wm.continents.get(c as usize - 1).zip(Some(zi)))
.and_then(|(cont, zi)| cont.zones.get(zi as usize - 1).zip(Some(cont)))
.and_then(|(zone, cont)| {
let (cl, cr, ct, cb) = cont.loc_rect;
let (zl, zr, zt, zb) = zone.loc_rect;
let (w, h) = (zl - zr, zt - zb);
let (cont_w, cont_h) = (cl - cr, ct - cb);
if w == 0.0 || h == 0.0 || cont_w == 0.0 || cont_h == 0.0 {
return None;
}
let recip_x = 1.0f32 / cont_w;
let recip_y_f32 = 1.0f32 / cont_h;
let texture_x = f64::from(recip_x * w);
let texture_y = (1.0f64 / f64::from(cont_h)) * f64::from(h);
let scroll_x = f64::from(recip_x * (cl - zl));
let scroll_y = f64::from(recip_y_f32 * (ct - zt));
// texPctY = dim/potdim, dim = __ftol((h·128)/w) (rect-form K = 128).
let dim = ((h * 128.0) / w) as i64;
let potdim = next_pow2(dim);
let tex_pct_y = if potdim > 0 {
dim as f64 / potdim as f64
} else {
0.0
};
Some((
zone.name.clone(),
zone.map_file.clone(),
tex_pct_y,
texture_x,
texture_y,
scroll_x,
scroll_y,
))
})
}
hover(&model.worldmap, x, y)
};
match hit {
Some((name, file, tex_pct_y, tx, ty, sx, sy)) => Ok((
Value::String(lua.create_string(&name)?),
Value::String(lua.create_string(&file)?),
let string = |s: &str| Ok::<_, mlua::Error>(Value::String(lua.create_string(s)?));
Ok(match hit {
Hover::Highlight {
name,
file,
tex_pct_y,
texture,
scroll,
} => (
string(&name)?,
string(&file)?,
1.0f64,
tex_pct_y,
tx,
ty,
sx,
sy,
)),
None => Ok((
Value::Nil,
Value::Nil,
0.0f64,
0.0f64,
0.0f64,
0.0f64,
0.0f64,
0.0f64,
)),
}
texture.0,
texture.1,
scroll.0,
scroll.1,
),
Hover::Name(name) => (string(&name)?, Value::Nil, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
Hover::Miss => (Value::Nil, Value::Nil, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
})
})?,
)?;
+65 -22
View File
@@ -27,12 +27,18 @@ use super::Model;
/// The arrow's model — `0x8453c0`, and NOT `Rotating-MinimapArrow`.
pub const ARROW_MODEL: &str = "Interface\\Minimap\\MinimapArrow.mdx";
/// The arrow's footprint at the reference basis: `MinimapArrow.mdx`'s single quad at 1280 px per
/// model unit — 33.6 px on the 1024×768 sheet, the same number the minimap's player arrow is
/// drawn at (`minimap::blips::PLAYER_ARROW_QUAD_PX`). The world-map arrow's model scale is
/// `G48 · 5/3` (`= 1.0` at 4:3, `G48 = 1/√(aspect² + 1)`) and the mini's `G48 · 10/9`, so both
/// hold a constant apparent size as the window's shape changes.
pub const ARROW_FOOTPRINT_PX: f32 = 33.6;
/// The arrow's rect is the file's own bounding box — the implicit rect of a size-less pane
/// (decision 2015; `MinimapArrow.mdx`'s `0.0262 × 0.0263` model units read as layout units,
/// `1280·extent = 33.5 × 33.7` FrameXML units at 4:3). The world-map arrow's model scale is
/// `G48 · 5/3` (`= 1.0` at 4:3, `G48 = 1/√(aspect² + 1)`) and the mini's `G48 · 10/9`, so the
/// quad holds a constant apparent size as the window's shape changes while its rect grows with
/// `√(a²+1)` (render law §2, the worked arrow).
fn centre_of(facts: Option<&crate::widget::ModelFileFacts>) -> (f32, f32, f32) {
facts.map_or((0.0, 0.0, 0.0), |f| {
let (x, y) = f.extent();
(x * 0.5, y * 0.5, 0.0)
})
}
/// Which of the two singletons a binding addresses.
#[derive(Clone, Copy)]
@@ -144,18 +150,17 @@ fn create(lua: &Lua, which: Arrow, parent: Value) -> mlua::Result<()> {
("Model".to_string(), None, Some(Value::Table(t)), None),
)?;
let id = decode_id(&wrapper)?;
let (scale, side) = {
let scale = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let scale = arrow_scale(&model, which);
(scale, ARROW_FOOTPRINT_PX * scale)
arrow_scale(&model, which)
};
// The widget's rect is the resident model's bounding box (§2.2); the footprint at this scale.
wrapper.call_method::<()>("SetWidth", side)?;
wrapper.call_method::<()>("SetHeight", side)?;
// `SetModel("Interface\Minimap\MinimapArrow.mdx")` in C++ (`0x4a7a80` → `0x76c8e0`): the
// same file set every pane takes, seeded with the file's facts when the host has them
// (decision 2007 — the arrow's Stand loops its 3.333 s with no bone keyed, so nothing
// moves; the arm is the reference's, not a look).
// moves; the arm is the reference's, not a look). No authored size: the widget's rect is
// the file's bounding box (§2.2, decision 2015), and `0x4a7b20`'s `SetPosition(½·GetWidth,
// ½·GetHeight, 0)` — the geometry override's bbox extent, in layout units — centres the
// model on it.
let facts = lua
.app_data_mut::<Model>()
.expect("model app_data")
@@ -163,9 +168,11 @@ fn create(lua: &Lua, which: Arrow, parent: Value) -> mlua::Result<()> {
super::modelframe::with_model(lua, &wrapper, |m| {
m.set_file(ARROW_MODEL.to_string(), facts.as_deref());
m.scale = scale;
m.position = (side * 0.5, side * 0.5, 0.0);
m.position = centre_of(facts.as_deref());
})?;
let h = frame_handle_of(lua, &wrapper)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.apply_implicit_rect(h);
which.set_slot(&mut model, id);
Ok(())
}
@@ -187,17 +194,16 @@ fn update(lua: &Lua) -> mlua::Result<()> {
let Some(wrapper) = arrow_wrapper(lua, which)? else {
continue;
};
let (facing, tracked) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let (facing, tracked, facts) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
(
model.worldmap.player_facing,
model.worldmap.player_uv.is_some(),
model.model_facts_for(ARROW_MODEL),
)
};
let w: f32 = wrapper.call_method("GetWidth", ())?;
let h: f32 = wrapper.call_method("GetHeight", ())?;
super::modelframe::with_model(lua, &wrapper, |m| {
m.position = (w * 0.5, h * 0.5, 0.0);
m.position = centre_of(facts.as_deref());
if tracked {
m.facing = facing;
}
@@ -334,6 +340,22 @@ mod tests {
use super::*;
use crate::script::UiScript;
/// The arrow file's facts, as the host hands them over (2007/2015): the header box the
/// implicit rect and the re-centring read.
fn arrow_facts(s: &mut UiScript) {
s.set_model_facts(
ARROW_MODEL,
crate::widget::ModelFileFacts {
sequences: vec![crate::widget::SequenceFacts {
anim_id: 0,
duration_ms: 3333,
looping: true,
}],
bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]),
},
);
}
fn vm() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -350,7 +372,7 @@ mod tests {
/// loaded with the arrow, born shown, sized to its footprint; a second create a no-op.
#[test]
fn create_validates_its_parent_and_makes_one_arrow_per_session() {
let s = vm();
let mut s = vm();
for (call, want) in [
(
"CreateWorldMapArrowFrame(5)",
@@ -388,9 +410,28 @@ mod tests {
.unwrap();
assert_eq!(kind, "Model");
assert!(shown, "born shown");
assert_eq!(w, 0.0, "no authored size, no facts yet: no rect");
// The file lands: the rect is its box in layout units — 0.0262 × 1280 at 4:3 — and the
// model is centred on it (`0x4a7b20`, half the box, in layout units).
arrow_facts(&mut s);
// The C++ re-centres on create and on every `UpdateWorldMapArrowFrames` (`0x4a7b20`);
// a file that lands after the create is picked up by the next update.
s.run("UpdateWorldMapArrowFrames()").unwrap();
s.resolve();
let w: f32 = s.eval("return ({WM:GetChildren()})[2]:GetWidth()").unwrap();
assert!((w - 0.0262 * 1280.0).abs() < 0.05, "the box at 4:3: {w}");
let pos = {
let model = s.lua().app_data_ref::<Model>().expect("model");
let id = model.worldmap.arrow_world.expect("the singleton");
let fh = model.id_to_frame.get(&id).copied().expect("live");
match &model.arena.frame(fh).expect("frame").kind_state {
crate::widget::KindState::Model(m) => m.position,
_ => panic!("not a Model"),
}
};
assert!(
(w - ARROW_FOOTPRINT_PX).abs() < 1e-3,
"the footprint at 4:3: {w}"
(pos.0 - 0.0131).abs() < 1e-5 && (pos.1 - 0.01315).abs() < 1e-5 && pos.2 == 0.0,
"centred on the box: {pos:?}"
);
assert!(
s.eval::<bool>("return select('#', CreateWorldMapArrowFrame(WM)) == 0")
@@ -408,6 +449,8 @@ mod tests {
s.run("PositionWorldMapArrowFrame(1, 2) ShowWorldMapArrowFrame('x') UpdateWorldMapArrowFrames()")
.unwrap();
s.run("CreateWorldMapArrowFrame(WM)").unwrap();
// The file's facts: a size-less pane has no rect to anchor until its box is known.
arrow_facts(&mut s);
s.run("arrow = ({WM:GetChildren()})[2]").unwrap();
for (call, want) in [
(