ui: the Era map canvas over 1.12's own world map, and the Line region
What 1.12 withholds here is a framework, not a verb. Its world map is a fixed 1002x668 frame showing one zone — twelve tiles, a hit button, POI buttons, two dropdowns — with no canvas, no pan, no zoom, no pins and no notion of where one map sits on another. Every map addon written after 4.0 drives the canvas framework instead, and there is no stock file to migrate from because there is no stock feature. The constraint that shaped every decision: `WorldMapFrame` already has eight 1.12 consumers on this install, all anchoring icons to `WorldMapDetailFrame` and `WorldMapButton`. So nothing is replaced. The canvas is added, and those two frames are reparented INTO it — at rest the rect they had, at scale 1 with no pan, so the stock window is pixel-identical, and the payoff is that the 1.12 art and every icon on it now pan and scale WITH the canvas for free. Clipping comes from a real ScrollFrame: this client clips a scroll child and every descendant of it from one source, in the draw pass and the hit-test, so drawing and hitting agree. The base-art layer a canvas addon hides is a proxy that switches draw layers rather than the frame, because hiding the frame would take the POI buttons and every addon's icons with it. Three regressions, each found by measurement, each pinned: the anchor cycle (container to detail frame to canvas to container — a cycle resolves to nothing, and the symptom was `GetCenter()` nil inside the stock hover pass); the base scale (a minScale below 1 opens the map zoomed out of its own window); and `dataProviders` being a set rather than an array, because callers iterate it with `pairs` and bind the key — an array handed that loop the number 1. `CreateLine` is a Texture carrying two endpoints, drawn through the explicit-corner quad path the cooldown wedge already uses. Nothing stands in for it: a line between two moving points is a rotated quad whose angle and length both change. `C_Texture.GetAtlasInfo` answers nil for every name, and the nil is the answer — but the getter's presence is what makes the common atlas guard take its fallback branch instead of raising. The harness grew a second surface, and the finding is worth keeping: the full screen map hides `UIParent`, so opening it in the ordinary render probe dropped the corpus's draw column from 37/86 to 18/86 — nineteen unrelated addons. As its own pass: no addon lost a quad and nineteen gained, pfQuest 374 -> 911, pfUI 1063 -> 2259. OpenMapClassic now reads 1/1 on all five columns, stitching 74 of the realm's own zones. Corpus roster unchanged at 85 ok / 1 fail; the 12 data-half reds byte-identical to HEAD's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -150,6 +150,14 @@ Interface\FrameXML\FadingFrame.xml
|
||||
Interface\FrameXML\ZoneText.xml
|
||||
Interface\FrameXML\ColorPickerFrame.xml
|
||||
Interface\FrameXML\WorldMapFrame.xml
|
||||
# Ours: the Era map-canvas framework (`MapCanvasMixin` and friends), bolted onto the stock window
|
||||
# above — which is why it sits immediately after it, and why the file reads `WorldMapFrame`,
|
||||
# `WorldMapDetailFrame` and `WorldMapButton` by name. 1.12's map is a fixed 1002x668 frame showing
|
||||
# one zone with no canvas, no pan, no zoom and no pins; every map addon written after 4.0 drives
|
||||
# the canvas framework instead, and there is no stock file to migrate from because 1.12 has no
|
||||
# such thing. Nothing here replaces anything: the canvas is added, and the stock art and its mouse
|
||||
# surface are reparented INTO it, so a 1.12 addon's icons pan and zoom with the map for free.
|
||||
MapCanvas.lua
|
||||
Interface\FrameXML\CastingBarFrame.xml
|
||||
Interface\FrameXML\MirrorTimer.xml
|
||||
Interface\FrameXML\DurabilityFrame.xml
|
||||
|
||||
@@ -288,6 +288,17 @@ fn survey_one(
|
||||
.flat_map(|d| methods_defined_by(root, d, defined_methods).clone())
|
||||
.collect();
|
||||
|
||||
// The stock world map, opened and closed once BEFORE the baseline is taken, so the regions
|
||||
// and buttons its own first update creates are the *client's* and not the addon's.
|
||||
//
|
||||
// It has to be here and not later: `WorldMapFrame_Update` builds its exploration overlay
|
||||
// textures lazily (`WorldMapDetailFrame:CreateTexture("WorldMapOverlay"..j)`) and its POI
|
||||
// buttons likewise (`CreateFrame("Button", "WorldMapFramePOI"..i, WorldMapButton)`), so a
|
||||
// first open after the snapshot would charge every one of them to whichever addon happened to
|
||||
// be surveyed — the exact mis-attribution the probe must never make. Closed again, because
|
||||
// the 1.12 client loads addons with the map shut and an addon's own startup may read it.
|
||||
prime_world_map(&mut script);
|
||||
|
||||
// The render baseline: every widget that exists before this addon runs, taken after the
|
||||
// dependency chain so a library's frames are not charged to its consumer.
|
||||
let baseline = RenderBaseline::of(&script);
|
||||
@@ -323,10 +334,22 @@ fn survey_one(
|
||||
.collect();
|
||||
// After the UI probe, because it reopens what the probe's second toggle closed; before the
|
||||
// method oracle, whose probe widgets would otherwise count as this addon's drawing.
|
||||
let (render, painted) = measure_render(&mut script, &baseline);
|
||||
let (mut render, painted) = measure_render(&mut script, &baseline);
|
||||
// Then use what it drew: after every other column so the input it invents perturbs none, before
|
||||
// the oracle so its widgets never become targets. It reuses `render`'s attribution.
|
||||
let used = measure_use(&mut script, &baseline, &painted);
|
||||
let mut used = measure_use(&mut script, &baseline, &painted);
|
||||
// The world map is its own surface and its own pass, because 1.12's full-screen map hides
|
||||
// `UIParent` while it is up — `measure_render_on_world_map` has the measurement. Both columns
|
||||
// are read there and unioned in: an addon that draws only on the map, and survives being
|
||||
// clicked there, must read the same as one that does it on the action bar.
|
||||
let (map_render, map_painted) = render::measure_render_on_world_map(&mut script, &baseline);
|
||||
render.merge(map_render);
|
||||
if !map_painted.is_empty() {
|
||||
// Driven while the map is still up: a point on a hidden frame hits nothing, which would
|
||||
// read as "nothing a pointer can reach" for an addon whose whole UI is the map.
|
||||
used.merge(measure_use(&mut script, &baseline, &map_painted));
|
||||
}
|
||||
render::close_world_map(&mut script);
|
||||
// Last: the oracle writes probe frames and a global into the VM, and no addon code runs after
|
||||
// this, so every number above is unperturbed by it. One oracle call serves both question sets
|
||||
// and the per-kind pass.
|
||||
@@ -668,6 +691,29 @@ fn raised_inside_a_demand_load(err: &str, mine: &str) -> bool {
|
||||
///
|
||||
/// Each call is guarded on the global existing and wrapped so a raise is recorded. It proves the
|
||||
/// body ran, not that its result is right.
|
||||
/// Open the stock world map, let its first update run, and close it again — see the call site for
|
||||
/// why the timing is the whole point. Errors are swallowed: a raise here is the client's or a
|
||||
/// dependency's, never the surveyed addon's, and the columns after this must not be zeroed by it.
|
||||
fn prime_world_map(script: &mut UiScript) {
|
||||
let _ = script.run(
|
||||
r#"
|
||||
if type(ToggleWorldMap) == "function" and WorldMapFrame then
|
||||
if not WorldMapFrame:IsShown() then pcall(ToggleWorldMap) end
|
||||
end
|
||||
"#,
|
||||
);
|
||||
script.tick(0.1);
|
||||
script.resolve();
|
||||
let _ = script.run(
|
||||
r#"
|
||||
if type(ToggleWorldMap) == "function" and WorldMapFrame and WorldMapFrame:IsShown() then
|
||||
pcall(ToggleWorldMap)
|
||||
end
|
||||
"#,
|
||||
);
|
||||
script.tick(0.1);
|
||||
}
|
||||
|
||||
fn drive_ui_probe(script: &mut UiScript) -> Vec<String> {
|
||||
let before = script.errors().len();
|
||||
// `this` is set for the `this`-shaped entry points, which read it.
|
||||
@@ -690,6 +736,13 @@ fn drive_ui_probe(script: &mut UiScript) -> Vec<String> {
|
||||
-- Open, then close: a toggle left open would leak state into nothing here, but the second
|
||||
-- call is what exercises an override's close path.
|
||||
try(ToggleBackpack); try(ToggleBackpack)
|
||||
-- The world map, open then close, for the same reason the backpack is here: a whole class
|
||||
-- of addon lives only on that surface — the eight in this install that anchor icons to
|
||||
-- `WorldMapDetailFrame` and `WorldMapButton`, and every canvas-map addon — and a raise in
|
||||
-- its open or close path is invisible to every other call in this probe.
|
||||
if WorldMapFrame then
|
||||
try(ToggleWorldMap); try(ToggleWorldMap)
|
||||
end
|
||||
if PlayerFrame then
|
||||
this = PlayerFrame
|
||||
try(UnitFrame_OnEnter); try(UnitFrame_OnLeave)
|
||||
|
||||
@@ -91,6 +91,20 @@ pub struct RenderReport {
|
||||
}
|
||||
|
||||
impl RenderReport {
|
||||
/// Fold a second surface's reading in, for [`measure_render_on_world_map`]: a quad counts
|
||||
/// wherever it was drawn, and the frame lists union. The per-surface split is not kept —
|
||||
/// "drew something" is one question, and the answer is yes if any surface saw it.
|
||||
pub fn merge(&mut self, other: RenderReport) {
|
||||
self.own_quads += other.own_quads;
|
||||
self.overlay_quads += other.overlay_quads;
|
||||
for name in other.frames {
|
||||
if !self.frames.contains(&name) {
|
||||
self.frames.push(name);
|
||||
}
|
||||
}
|
||||
self.frames.sort();
|
||||
}
|
||||
|
||||
pub fn drew(&self) -> Drew {
|
||||
match (self.own_quads, self.overlay_quads) {
|
||||
(0, 0) => Drew::Nothing,
|
||||
@@ -176,7 +190,60 @@ pub(super) fn measure_render(
|
||||
for _ in 0..10 {
|
||||
script.tick(0.1);
|
||||
}
|
||||
collect(script, baseline)
|
||||
}
|
||||
|
||||
/// The world map on its own surface, after [`measure_render`] has read the ordinary one.
|
||||
///
|
||||
/// It needs a pass of its own because 1.12's world map is a `UIPanelWindows` entry with
|
||||
/// `area = "full"`, and its own `ShowUIPanel` **hides `UIParent`** for it — so while the map is up
|
||||
/// nothing else in the interface draws at all. Measured, with the map merely opened at the end of
|
||||
/// the ordinary probe: the corpus's "drew something" column fell from 37/86 to 18/86, and the
|
||||
/// nineteen it lost were ordinary addons whose frames hang off `UIParent` (minimap buttons,
|
||||
/// a threat meter, an auction helper) — not one of them had changed. So the two surfaces cannot
|
||||
/// be read in one pass, and this is the second.
|
||||
///
|
||||
/// Worth having rather than skipping, because a whole class of addon lives only here: the eight
|
||||
/// in this install that anchor icons to `WorldMapDetailFrame` and `WorldMapButton`, and every
|
||||
/// canvas-map addon. The reading is unioned into the ordinary one
|
||||
/// ([`RenderReport::merge`]), since "drew something" is one question.
|
||||
pub(super) fn measure_render_on_world_map(
|
||||
script: &mut UiScript,
|
||||
baseline: &RenderBaseline,
|
||||
) -> (RenderReport, Vec<Painted>) {
|
||||
let _ = script.run(
|
||||
r#"
|
||||
if WorldMapFrame and type(ToggleWorldMap) == "function" then
|
||||
if not WorldMapFrame:IsShown() then pcall(ToggleWorldMap) end
|
||||
end
|
||||
"#,
|
||||
);
|
||||
// The same ten ticks the ordinary pass takes, and for the same reason: a canvas map builds its
|
||||
// art in the first `OnUpdate` after the map is shown, not in the show itself.
|
||||
for _ in 0..10 {
|
||||
script.tick(0.1);
|
||||
}
|
||||
// Left OPEN: the caller drives input on this surface next, and a point on a hidden frame hits
|
||||
// nothing — which reads as "had nothing a pointer can reach" for an addon whose whole UI is
|
||||
// the map. [`close_world_map`] puts it back when that reading is taken.
|
||||
collect(script, baseline)
|
||||
}
|
||||
|
||||
/// Close the map `measure_render_on_world_map` left open, so `UIParent` is back before anything
|
||||
/// else is measured.
|
||||
pub(super) fn close_world_map(script: &mut UiScript) {
|
||||
let _ = script.run(
|
||||
r#"
|
||||
if WorldMapFrame and type(ToggleWorldMap) == "function" then
|
||||
if WorldMapFrame:IsShown() then pcall(ToggleWorldMap) end
|
||||
end
|
||||
"#,
|
||||
);
|
||||
script.tick(0.1);
|
||||
}
|
||||
|
||||
/// Attribute every painting quad on screen right now, and where it was painted.
|
||||
fn collect(script: &mut UiScript, baseline: &RenderBaseline) -> (RenderReport, Vec<Painted>) {
|
||||
script.resolve();
|
||||
let mut report = RenderReport::default();
|
||||
let mut named: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
@@ -70,6 +70,22 @@ pub struct UseReport {
|
||||
}
|
||||
|
||||
impl UseReport {
|
||||
/// Fold a second surface's reading in, for the world-map pass
|
||||
/// ([`super::render::measure_render_on_world_map`]): "survived being used" is one question
|
||||
/// over every surface driven, so the counts add, the frame lists union and any error from
|
||||
/// either surface is an error.
|
||||
pub fn merge(&mut self, other: UseReport) {
|
||||
self.driven += other.driven;
|
||||
self.touchable += other.touchable;
|
||||
for name in other.frames {
|
||||
if !self.frames.contains(&name) {
|
||||
self.frames.push(name);
|
||||
}
|
||||
}
|
||||
self.frames.sort();
|
||||
self.errors.extend(other.errors);
|
||||
}
|
||||
|
||||
pub fn verdict(&self) -> Used {
|
||||
match (self.driven, self.errors.is_empty()) {
|
||||
(0, _) => Used::Untouched,
|
||||
|
||||
@@ -95,6 +95,7 @@ fn content_kind(c: &QuadContent) -> &'static str {
|
||||
QuadContent::Minimap { .. } => "minimap",
|
||||
QuadContent::ModelPane { .. } => "modelpane",
|
||||
QuadContent::Texture { .. } => "texture",
|
||||
QuadContent::Line { .. } => "line",
|
||||
QuadContent::ColorWheel => "colorwheel",
|
||||
QuadContent::ColorValue { .. } => "colorvalue",
|
||||
QuadContent::Backdrop { .. } => "backdrop",
|
||||
@@ -1501,6 +1502,62 @@ fn convert_entry(
|
||||
..default()
|
||||
});
|
||||
}
|
||||
// A `Line` region: the quad between two points, `thickness` wide. Drawn through
|
||||
// `UiQuad::corners`, the explicit-corner path the cooldown wedge already uses, because a
|
||||
// line is a rotated rectangle and `rect` plus `rotation` cannot express one whose *angle*
|
||||
// is data rather than a setting.
|
||||
QuadContent::Line {
|
||||
path,
|
||||
color,
|
||||
additive,
|
||||
start,
|
||||
end,
|
||||
thickness,
|
||||
} => {
|
||||
// The y flip, as `rect` above: the two ends and the perpendicular are all in UI space.
|
||||
let a = Vec2::new(start.0 * s, h - start.1 * s);
|
||||
let b = Vec2::new(end.0 * s, h - end.1 * s);
|
||||
let along = b - a;
|
||||
let len = along.length();
|
||||
if len <= 0.0001 {
|
||||
// Both ends in one place: a line of no length, which draws nothing rather than a
|
||||
// degenerate quad whose normal is undefined.
|
||||
return;
|
||||
}
|
||||
let half = thickness * 0.5 * s;
|
||||
// The perpendicular, scaled to half the thickness; `(-y, x)` turns the direction a
|
||||
// quarter turn, which is the offset that gives the line its width.
|
||||
let n = Vec2::new(-along.y, along.x) / len * half;
|
||||
let corners = [a + n, b + n, b - n, a - n];
|
||||
let handle = match (path.as_deref(), assets.as_mut()) {
|
||||
(Some(p), Some(av)) => {
|
||||
let resolved = av.sprite_texture(p, images);
|
||||
if resolved.is_none() {
|
||||
return;
|
||||
}
|
||||
resolved
|
||||
}
|
||||
// No art is a solid, which is how every caller in the corpus paints a line
|
||||
// (`SetColorTexture` then `SetVertexColor`).
|
||||
_ => None,
|
||||
};
|
||||
if handle.is_none() && color.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut color = color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
|
||||
color[3] *= eq.alpha;
|
||||
out.push(UiQuad {
|
||||
rect,
|
||||
z_key: eq.z,
|
||||
texture: handle,
|
||||
uv: UvRect::FULL,
|
||||
color,
|
||||
additive,
|
||||
clip,
|
||||
corners: Some(corners),
|
||||
..default()
|
||||
});
|
||||
}
|
||||
// The colour picker's hue disc, a generated image drawn at full value ([`colorselect`]).
|
||||
QuadContent::ColorWheel => {
|
||||
let Some(assets) = assets.as_mut() else {
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
//! `assets/ui/MapCanvas.lua`: the Era map-canvas framework bolted onto 1.12's own world map.
|
||||
//!
|
||||
//! Every test here is over the **whole** shipped interface, not a fixture, because what the file
|
||||
//! does is reparent two of the stock map's frames — so a fixture that did not have them would
|
||||
//! measure nothing. `WOW_DATA` gates them: without the player's chain there is no
|
||||
//! `WorldMapFrame.xml` to bolt onto, which is the same reason the rest of this crate's
|
||||
//! full-interface tests skip.
|
||||
//!
|
||||
//! The three regressions these pin were all found by measurement and all looked like something
|
||||
//! else:
|
||||
//!
|
||||
//! - **The anchor cycle.** Anchoring the container to `WorldMapDetailFrame` and then reparenting
|
||||
//! that frame into the container's own child is a cycle, and a cycle resolves to nothing. The
|
||||
//! symptom was `WorldMapButton:GetCenter()` answering nil inside the stock hover pass — a raise
|
||||
//! in `WorldMapFrame.lua`, pointing at the client's file rather than at ours.
|
||||
//! - **The base scale.** A layer whose `minScale` is below 1 opens the map zoomed *out* of its own
|
||||
//! window: the canvas is sized to the layer and its container is the rect that layer used to
|
||||
//! fill, so 1 is already the whole map. Measured as a 300x200 canvas floating in a 1002x668
|
||||
//! container.
|
||||
//! - **`dataProviders` is a set, not an array.** Callers iterate it with
|
||||
//! `for provider in pairs(...)`, which binds the *key*. An array handed that loop the number 1,
|
||||
//! and the next line indexed a number — inside the addon's own startup, which caught it, printed
|
||||
//! it, and left the whole addon disabled with no error anywhere.
|
||||
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use super::manifest::load_default_ui;
|
||||
|
||||
/// The shipped interface with a session seated far enough for the map to resolve, then
|
||||
/// `PLAYER_LOGIN`, which is where the canvas is built.
|
||||
fn harness() -> Option<UiScript> {
|
||||
benilla_formats::wow_data()?;
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
assert!(
|
||||
load_default_ui(&s).is_empty(),
|
||||
"the shipped interface loads"
|
||||
);
|
||||
s.fire_event("PLAYER_LOGIN", Vec::new());
|
||||
s.resolve();
|
||||
Some(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_canvas_is_built_over_the_stock_map_and_leaves_it_where_it_was() {
|
||||
let Some(s) = harness() else { return };
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(WorldMapFrame.ScrollContainer)")
|
||||
.unwrap(),
|
||||
"table",
|
||||
"the container is bolted on at PLAYER_LOGIN"
|
||||
);
|
||||
// The stock art and the stock mouse surface are inside the canvas, which is what makes a 1.12
|
||||
// addon's icons pan and scale with it.
|
||||
for frame in ["WorldMapDetailFrame", "WorldMapButton"] {
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {frame}:GetParent():GetName()"))
|
||||
.unwrap(),
|
||||
"WorldMapCanvas",
|
||||
"{frame} is reparented into the canvas"
|
||||
);
|
||||
}
|
||||
// At rest: scale 1, centred, and the canvas exactly the rect the detail frame had.
|
||||
let (scale, cw, ch, sw, sh): (f64, f64, f64, f64, f64) = s
|
||||
.eval(
|
||||
"local sc = WorldMapFrame.ScrollContainer \
|
||||
return sc:GetCanvasScale(), sc.Child:GetWidth(), sc.Child:GetHeight(), \
|
||||
sc:GetWidth(), sc:GetHeight()",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(scale - 1.0).abs() < 1e-6,
|
||||
"the base scale is 1 — the canvas is the layer size and the container is the rect that \
|
||||
layer filled, so 1 is already the whole map; got {scale}"
|
||||
);
|
||||
assert!(
|
||||
(cw - sw).abs() < 0.5 && (ch - sh).abs() < 0.5,
|
||||
"canvas {cw}x{ch} must be the container's {sw}x{sh} at rest"
|
||||
);
|
||||
// And the canvas lands exactly on the container, which is what makes the stock window
|
||||
// pixel-identical while nothing is driving the view.
|
||||
let (dx, dy): (f64, f64) = s
|
||||
.eval(
|
||||
"local sc = WorldMapFrame.ScrollContainer \
|
||||
return sc.Child:GetLeft() - sc:GetLeft(), sc.Child:GetTop() - sc:GetTop()",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
dx.abs() < 0.5 && dy.abs() < 0.5,
|
||||
"the canvas is offset ({dx}, {dy}) from the container at rest"
|
||||
);
|
||||
}
|
||||
|
||||
/// The anchor-cycle regression. `GetCenter()` is the read that caught it, because the stock hover
|
||||
/// pass does it every frame the map is up (`WorldMapFrame.lua:491`) and divides by the result.
|
||||
#[test]
|
||||
fn the_stock_map_frames_still_resolve_inside_the_canvas() {
|
||||
let Some(s) = harness() else { return };
|
||||
for frame in [
|
||||
"WorldMapDetailFrame",
|
||||
"WorldMapButton",
|
||||
"WorldMapScrollContainer",
|
||||
"WorldMapCanvas",
|
||||
] {
|
||||
let ok: bool = s
|
||||
.eval(&format!(
|
||||
"local x, y = {frame}:GetCenter() return x ~= nil and y ~= nil"
|
||||
))
|
||||
.unwrap();
|
||||
assert!(ok, "{frame}:GetCenter() must resolve, not answer nil");
|
||||
}
|
||||
// The stock hover pass itself, which is what the nil broke.
|
||||
s.run("this = WorldMapButton WorldMapButton_OnUpdate(0.1) this = nil")
|
||||
.expect("the stock hover pass runs over the reparented button");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `dataProviders` is a **set**, keyed by the provider. Written as an array it handed
|
||||
/// `for provider in pairs(...)` the number 1.
|
||||
#[test]
|
||||
fn the_data_provider_collection_is_keyed_by_the_provider() {
|
||||
let Some(s) = harness() else { return };
|
||||
s.run(
|
||||
r#"
|
||||
probe = CreateFromMixins(MapCanvasDataProviderMixin)
|
||||
probe.Label = { SetLabel = function() end }
|
||||
WorldMapFrame:AddDataProvider(probe)
|
||||
-- The caller's own loop shape (`Map/Extras.lua:280`): the KEY is the provider.
|
||||
found = nil
|
||||
for provider in pairs(WorldMapFrame.dataProviders) do
|
||||
if provider.Label and provider.Label.SetLabel then found = provider end
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return found == probe").unwrap(),
|
||||
"`for provider in pairs(map.dataProviders)` must bind the provider itself"
|
||||
);
|
||||
// Adding twice is once, and removing takes it out of both the set and the walk order.
|
||||
s.run("WorldMapFrame:AddDataProvider(probe) WorldMapFrame:RemoveDataProvider(probe)")
|
||||
.unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>("return WorldMapFrame.dataProviders[probe] == nil")
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return table.getn(WorldMapFrame.dataProviderOrder)")
|
||||
.unwrap(),
|
||||
0,
|
||||
"the ordered walk list is kept in step with the set"
|
||||
);
|
||||
}
|
||||
|
||||
/// The pin frame-level bands, by name. The names are the contract: a canvas addon inserts its own
|
||||
/// band relative to one of Blizzard's and branches on the answer.
|
||||
#[test]
|
||||
fn the_pin_frame_level_bands_are_blizzards_own_names_and_a_band_can_be_inserted_above_one() {
|
||||
let Some(s) = harness() else { return };
|
||||
for band in [
|
||||
"PIN_FRAME_LEVEL_MAP_EXPLORATION",
|
||||
"PIN_FRAME_LEVEL_MAP_HIGHLIGHT",
|
||||
"PIN_FRAME_LEVEL_AREA_POI",
|
||||
"PIN_FRAME_LEVEL_GROUP_MEMBER",
|
||||
] {
|
||||
let level: i64 = s
|
||||
.eval(&format!(
|
||||
"return WorldMapFrame:GetPinFrameLevelsManager():GetValidFrameLevel(\"{band}\")"
|
||||
))
|
||||
.unwrap();
|
||||
assert!(level > 0, "{band} must exist and hand out a level");
|
||||
}
|
||||
let ok: bool = s
|
||||
.eval(
|
||||
"local fl = WorldMapFrame:GetPinFrameLevelsManager() \
|
||||
return fl:InsertFrameLevelAbove(\"PIN_FRAME_LEVEL_TEST_ART\", \
|
||||
\"PIN_FRAME_LEVEL_MAP_EXPLORATION\", 8)",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ok, "a band inserts above a named one");
|
||||
let (art, above): (i64, i64) = s
|
||||
.eval(
|
||||
"local fl = WorldMapFrame:GetPinFrameLevelsManager() \
|
||||
return fl:GetValidFrameLevel(\"PIN_FRAME_LEVEL_TEST_ART\"), \
|
||||
fl:GetValidFrameLevel(\"PIN_FRAME_LEVEL_MAP_EXPLORATION\")",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
art > above,
|
||||
"the inserted band must sit above the one it named: {art} vs {above}"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>(
|
||||
"return WorldMapFrame:GetPinFrameLevelsManager() \
|
||||
:InsertFrameLevelAbove(\"PIN_FRAME_LEVEL_X\", \"PIN_FRAME_LEVEL_NO_SUCH_BAND\", 1)"
|
||||
)
|
||||
.unwrap(),
|
||||
"and an unknown reference band answers false, which is what the caller branches on"
|
||||
);
|
||||
}
|
||||
|
||||
/// The base-art layer a canvas addon hides while it draws its own. It must take the stock *art*
|
||||
/// down and leave every child frame — the POI buttons, the player arrow, and every 1.12 addon's
|
||||
/// icons — drawing, which is why it switches draw layers rather than hiding the frame.
|
||||
#[test]
|
||||
fn hiding_the_base_layer_takes_the_art_and_not_the_pins() {
|
||||
let Some(mut s) = harness() else { return };
|
||||
// Shown, or nothing under it emits a quad at all and both halves of this would pass on an
|
||||
// empty render list.
|
||||
s.run(
|
||||
r#"
|
||||
if not WorldMapFrame:IsShown() then ToggleWorldMap() end
|
||||
layers = {}
|
||||
for layer in WorldMapFrame.detailLayerPool:EnumerateActive() do
|
||||
table.insert(layers, layer)
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert!(
|
||||
s.eval::<bool>("return WorldMapFrame:IsShown() and true or false")
|
||||
.unwrap(),
|
||||
"the map has to be up for this to measure anything"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return table.getn(layers)").unwrap(),
|
||||
1,
|
||||
"one base layer, which is 1.12's own detail frame"
|
||||
);
|
||||
s.run("for i = 1, table.getn(layers) do layers[i]:SetShown(false) end")
|
||||
.unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return WorldMapDetailFrame:IsShown() and true or false")
|
||||
.unwrap(),
|
||||
"the frame itself stays shown — hiding it would take the POI buttons and every addon's \
|
||||
icons with it, since they are its children and its siblings' children"
|
||||
);
|
||||
s.resolve();
|
||||
// The tiles are regions on BACKGROUND; with the layer off they emit no quad.
|
||||
let tiles_drawn = |s: &UiScript| -> usize {
|
||||
s.extract()
|
||||
.iter()
|
||||
.filter(|q| {
|
||||
s.target_owner_name(q.target).as_deref() == Some("WorldMapDetailFrame")
|
||||
&& matches!(q.content, benilla_ui::script::QuadContent::Texture { .. })
|
||||
})
|
||||
.count()
|
||||
};
|
||||
assert_eq!(tiles_drawn(&s), 0, "the base art is off");
|
||||
s.run("for i = 1, table.getn(layers) do layers[i]:SetShown(true) end")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert!(
|
||||
tiles_drawn(&s) > 0,
|
||||
"and switching it back on brings the stock tiles back"
|
||||
);
|
||||
}
|
||||
|
||||
/// The canvas and the stock window are one place. `SetMapID` moves the 1.12 selection, and a
|
||||
/// `SetMapZoom` from anywhere else moves the canvas — so the art under the canvas and the icons
|
||||
/// every 1.12 addon anchors to it can never be a different map from the one the canvas shows.
|
||||
#[test]
|
||||
fn the_canvas_and_the_stock_selection_follow_each_other() {
|
||||
let Some(s) = harness() else { return };
|
||||
// The world sheet both ways round, which is the one id that exists on every chain.
|
||||
let world: i64 = s.eval("return C_Map.GetFallbackWorldMapID()").unwrap();
|
||||
s.run(&format!("WorldMapFrame:SetMapID({world})")).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetCurrentMapContinent()").unwrap(),
|
||||
0,
|
||||
"the world sheet is continent 0 in the stock selection"
|
||||
);
|
||||
// A continent, found through the graph rather than hard-coded, so this holds on any chain.
|
||||
let continent: Option<i64> = s
|
||||
.eval(
|
||||
"local kids = C_Map.GetMapChildrenInfo(C_Map.GetFallbackWorldMapID(), \
|
||||
Enum.UIMapType.Continent) \
|
||||
return kids and kids[1] and kids[1].mapID",
|
||||
)
|
||||
.unwrap();
|
||||
let Some(continent) = continent else { return };
|
||||
s.run(&format!("WorldMapFrame:SetMapID({continent})"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
s.eval::<i64>("return GetCurrentMapContinent()").unwrap() > 0,
|
||||
"setting a continent on the canvas selects it in the stock window"
|
||||
);
|
||||
// …and the other direction, through the stock verb and the event it fires.
|
||||
s.run(&format!("WorldMapFrame.mapID = {world} SetMapZoom(1, 0)"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return WorldMapFrame:GetMapID()").unwrap(),
|
||||
continent,
|
||||
"a SetMapZoom from anywhere else drags the canvas onto the same map"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `Line` region draws between its two points at its own thickness: the flight routes and the
|
||||
/// drawing tool are built from nothing else.
|
||||
#[test]
|
||||
fn a_line_region_spans_the_two_points_it_was_given() {
|
||||
let Some(mut s) = harness() else { return };
|
||||
s.run(
|
||||
r#"
|
||||
host = CreateFrame("Frame", "LineHost", UIParent)
|
||||
host:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 0, 0)
|
||||
host:SetWidth(200) host:SetHeight(100)
|
||||
a = CreateFrame("Frame", nil, host)
|
||||
a:SetPoint("CENTER", host, "BOTTOMLEFT", 20, 20)
|
||||
a:SetWidth(1) a:SetHeight(1)
|
||||
b = CreateFrame("Frame", nil, host)
|
||||
b:SetPoint("CENTER", host, "BOTTOMLEFT", 120, 20)
|
||||
b:SetWidth(1) b:SetHeight(1)
|
||||
line = host:CreateLine(nil, "ARTWORK")
|
||||
line:SetColorTexture(1, 0, 0, 1)
|
||||
line:SetStartPoint("CENTER", a)
|
||||
line:SetEndPoint("CENTER", b)
|
||||
line:SetThickness(4)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<f64>("return line:GetThickness()").unwrap(), 4.0);
|
||||
s.resolve();
|
||||
let found = s.extract().iter().find_map(|q| match &q.content {
|
||||
benilla_ui::script::QuadContent::Line {
|
||||
start,
|
||||
end,
|
||||
thickness,
|
||||
..
|
||||
} => Some((*start, *end, *thickness, q.rect)),
|
||||
_ => None,
|
||||
});
|
||||
let (start, end, thickness, rect) = found.expect("the line reaches the render list");
|
||||
assert!(
|
||||
(start.0 - 20.0).abs() < 0.6 && (end.0 - 120.0).abs() < 0.6,
|
||||
"the ends are the points it was given: {start:?} to {end:?}"
|
||||
);
|
||||
assert!((start.1 - end.1).abs() < 0.6, "and this one is horizontal");
|
||||
assert_eq!(thickness, 4.0);
|
||||
// The rect is the box of the two ends plus half the thickness each side, which is what a
|
||||
// caller's `GetLeft` and the renderer's clip read.
|
||||
let rect = rect.expect("a line has a rect, or the host would never draw it");
|
||||
assert!(
|
||||
(rect.right - rect.left - 104.0).abs() < 1.0 && (rect.top - rect.bottom - 4.0).abs() < 1.0,
|
||||
"the box is the ends plus half the thickness: {rect:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A line with one end set draws nothing — a line with one end is not a line — and so does a line
|
||||
/// whose two ends land in the same place.
|
||||
#[test]
|
||||
fn a_line_with_one_end_or_no_length_draws_nothing() {
|
||||
let Some(mut s) = harness() else { return };
|
||||
s.run(
|
||||
r#"
|
||||
host = CreateFrame("Frame", "HalfLineHost", UIParent)
|
||||
host:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 0, 0)
|
||||
host:SetWidth(200) host:SetHeight(100)
|
||||
line = host:CreateLine(nil, "ARTWORK")
|
||||
line:SetColorTexture(0, 1, 0, 1)
|
||||
line:SetStartPoint("CENTER", host)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let lines = |s: &UiScript| {
|
||||
s.extract()
|
||||
.iter()
|
||||
.filter(|q| matches!(q.content, benilla_ui::script::QuadContent::Line { .. }))
|
||||
.count()
|
||||
};
|
||||
assert_eq!(lines(&s), 0, "one end is not a line");
|
||||
s.run("line:SetEndPoint(\"CENTER\", host)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
lines(&s),
|
||||
1,
|
||||
"both ends on one point still reaches the list — the host drops it, where the \
|
||||
perpendicular is undefined"
|
||||
);
|
||||
}
|
||||
@@ -861,6 +861,10 @@ mod faux_scroll_tests;
|
||||
#[cfg(test)]
|
||||
mod compat_templates_tests;
|
||||
|
||||
/// `assets/ui/MapCanvas.lua`: the Era map-canvas framework over 1.12's own world map.
|
||||
#[cfg(test)]
|
||||
mod map_canvas_tests;
|
||||
|
||||
/// The stock UIPanelTemplates and OptionsFrameTemplates kit, driven the way an addon drives it.
|
||||
#[cfg(test)]
|
||||
mod panel_template_tests;
|
||||
|
||||
@@ -1983,6 +1983,21 @@ fn assets_ui_does_not_grow() {
|
||||
"CompatTemplates.xml",
|
||||
"ContainerFrameAdapters.xml",
|
||||
"GameMenuFrame.xml",
|
||||
// Not a window either, and the same footing as `CompatTemplates.xml` above: the Era
|
||||
// map-canvas framework (`MapCanvasMixin`, `MapCanvasScrollControllerMixin`,
|
||||
// `MapCanvasDataProviderMixin`, `MapCanvasPinMixin`, `PinFrameLevelsManager`, `MapUtil`),
|
||||
// which every map addon written after 4.0 drives and which 1.12 has no counterpart to at
|
||||
// all — its map is a fixed 1002x668 frame showing one zone, with no canvas, no pan, no
|
||||
// zoom, no pins and no notion of where one map sits on another. There is no stock file to
|
||||
// migrate from because there is no stock feature. It lives in Lua rather than
|
||||
// `script/compat/` because it is frames and mixins, not verbs.
|
||||
//
|
||||
// It is **additive by construction**, which is the part that mattered: `WorldMapFrame`
|
||||
// already has eight 1.12 consumers on this install that anchor icons to
|
||||
// `WorldMapDetailFrame` and `WorldMapButton`, so the canvas is built AROUND those two and
|
||||
// reparents them into itself. At rest it is the rect they had, at scale 1 with no pan, so
|
||||
// the stock window is pixel-identical; moved, their icons move with it.
|
||||
"MapCanvas.lua",
|
||||
"KeyBindingsPage.xml",
|
||||
"OptionsFrame.xml",
|
||||
// The six `OptionsFrame_*` enable/disable helpers stock 1.12 keeps in its own
|
||||
|
||||
@@ -62,6 +62,38 @@ end
|
||||
end
|
||||
"#;
|
||||
|
||||
/// `IsMouseOver([top, bottom, left, right])`: whether the cursor is inside this widget's rect,
|
||||
/// with optional per-edge slack.
|
||||
///
|
||||
/// 1.12 has the *computation* and not the method: FrameXML's own `MouseIsOver(region, …)`
|
||||
/// (`UIParent.lua`) is exactly this body, and 2.0 moved it onto the widget. So this is a method
|
||||
/// spelling of a function the player's own chain already defines, written out here rather than
|
||||
/// forwarded to it because the argument *order* differs — `MouseIsOver` takes the region first and
|
||||
/// this does not — and because a frame may be asked before `UIParent.lua` has run.
|
||||
///
|
||||
/// Goes on all three tables: 2.0 put it on Region, so a Texture answers it too, and the body reads
|
||||
/// nothing a region lacks.
|
||||
///
|
||||
/// Returns false rather than nil for an unresolved widget, because every caller treats it as a
|
||||
/// boolean and the reference's own answer for a region with no rect is the same "no".
|
||||
const MOUSE_OVER_DELEGATES: &str = r#"
|
||||
return function(m)
|
||||
function m.IsMouseOver(self, top, bottom, left, right)
|
||||
local l, r, t, b = self:GetLeft(), self:GetRight(), self:GetTop(), self:GetBottom()
|
||||
if not l or not r or not t or not b then return false end
|
||||
local scale = self:GetEffectiveScale()
|
||||
if not scale or scale == 0 then return false end
|
||||
local x, y = GetCursorPosition()
|
||||
x, y = x / scale, y / scale
|
||||
l = l + (left or 0)
|
||||
r = r + (right or 0)
|
||||
t = t + (top or 0)
|
||||
b = b + (bottom or 0)
|
||||
return y > b and y < t and x > l and x < r
|
||||
end
|
||||
end
|
||||
"#;
|
||||
|
||||
/// `SetShown` over the stock `Show`/`Hide` pair. `script/object/frame_state.rs` notes the reference
|
||||
/// registers no `SetShown` at all, which is exactly why ClassicAPI backports it.
|
||||
///
|
||||
@@ -188,6 +220,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
] {
|
||||
delegate(lua, key, GEOMETRY_DELEGATES)?;
|
||||
delegate(lua, key, SHOWN_DELEGATES)?;
|
||||
delegate(lua, key, MOUSE_OVER_DELEGATES)?;
|
||||
}
|
||||
delegate(lua, REG_TEXTURE_METHODS, TEXTURE_DELEGATES)?;
|
||||
delegate(lua, REG_FONTSTRING_METHODS, FONTSTRING_DELEGATES)?;
|
||||
@@ -327,6 +360,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
}
|
||||
|
||||
// `CreateLine(name, layer, template, subLayer)`: a `Line` region — two endpoints and a
|
||||
// thickness, drawn as the quad between them (`region/paint.rs` has the four methods,
|
||||
// `RegionData::line` the geometry).
|
||||
//
|
||||
// 1.12 has no line primitive and nothing stands in for one: a line between two *moving*
|
||||
// points is a rotated quad whose angle and length both change every frame, which no
|
||||
// rect-anchored texture expresses. The map needs it for flight routes and for the drawing
|
||||
// tool, and it is called unguarded at both sites (`Map/Stitch.lua:717`, `Map/Sketch.lua:377`).
|
||||
//
|
||||
// Built over the stock `CreateTexture` on this same table, so a line is an ordinary Texture in
|
||||
// every respect but its geometry — which is what the callers rely on, painting it with
|
||||
// `SetColorTexture` and `SetVertexColor` like any texture.
|
||||
m.set(
|
||||
"CreateLine",
|
||||
lua.create_function(
|
||||
|lua, (this, name, layer, template, _sub): (Table, Value, Value, Value, Value)| {
|
||||
let create: mlua::Function = this.get("CreateTexture")?;
|
||||
let wrapper: Table = create.call((this, name, layer, template))?;
|
||||
let rh = crate::script::region::region_handle_of(lua, &wrapper)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.region_data.entry(rh).or_default().line =
|
||||
Some(crate::script::LineGeometry::default());
|
||||
Ok(wrapper)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// `SetPropagateKeyboardInput(on)` / `GetPropagateKeyboardInput()`: 4.x's switch for whether a
|
||||
// frame with an `OnKeyDown` handler lets the key reach the next frame and the keybinding
|
||||
// system, or swallows it.
|
||||
|
||||
@@ -62,10 +62,47 @@ fn event_utils(lua: &Lua) -> mlua::Result<Table> {
|
||||
lua.create_table()
|
||||
}
|
||||
|
||||
/// `C_Texture`: `GetAtlasInfo(name)`, which answers **nil for every name**.
|
||||
///
|
||||
/// That nil is the *answer*, not a stub. An atlas is a named rectangle inside a shipped sheet,
|
||||
/// published by a table (`UiTextureAtlas`/`UiTextureAtlasMember`) that arrived in 4.x; 1.12 ships
|
||||
/// no such table and no such sheets, so there is no atlas by any name and nothing could make one.
|
||||
///
|
||||
/// It is registered rather than left absent because the *presence of the getter* is what lets a
|
||||
/// caller's guard work, and a missing namespace makes the common guard take the wrong branch.
|
||||
/// Measured, in the addon this was found with:
|
||||
///
|
||||
/// ```text
|
||||
/// if look.atlas and (not C_Texture or not C_Texture.GetAtlasInfo
|
||||
/// or C_Texture.GetAtlasInfo(look.atlas)) then
|
||||
/// tex:SetAtlas(look.atlas)
|
||||
/// else
|
||||
/// tex:SetTexture(look.texture or look.fallback or ...)
|
||||
/// ```
|
||||
///
|
||||
/// Its own comment says "an atlas this client lacks falls back to the texture", and with the
|
||||
/// namespace absent the first two disjuncts are *true* — so it called `SetAtlas` on a client that
|
||||
/// has no atlases and raised, inside the addon's own startup, which caught it and left the whole
|
||||
/// addon disabled. With the getter here answering nil the condition is false and the fallback
|
||||
/// texture is drawn, which is what the author meant. Every other atlas guard in the corpus has the
|
||||
/// same shape.
|
||||
///
|
||||
/// `GetAtlasElementID` and the rest of the namespace are not built: they describe members of a
|
||||
/// table this client does not have, and nothing in the corpus calls one.
|
||||
fn texture_info(lua: &Lua) -> mlua::Result<Table> {
|
||||
let t = lua.create_table()?;
|
||||
t.set(
|
||||
"GetAtlasInfo",
|
||||
lua.create_function(|_, _name: Option<String>| Ok(Value::Nil))?,
|
||||
)?;
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
g.set("C_CurrencyInfo", currency_info(lua)?)?;
|
||||
g.set("C_EventUtils", event_utils(lua)?)?;
|
||||
g.set("C_Texture", texture_info(lua)?)?;
|
||||
|
||||
// `GetFramesRegisteredForEvent(event)`: the registry's own list for that event, in registration
|
||||
// order — the same order `fire_event` walks (`script/tick.rs`), so a caller inspecting it sees
|
||||
|
||||
@@ -44,11 +44,19 @@ const LAYER_WIDTH: u32 = 1002;
|
||||
const LAYER_HEIGHT: u32 = 668;
|
||||
const TILE: u32 = 256;
|
||||
|
||||
/// How far the canvas may scale a layer. Nothing in the catalog says — 1.12 has no zoom at all —
|
||||
/// so these are the canvas framework's own bounds, and they live here because `CreateZoomLevels`
|
||||
/// reads them off the layer rather than off the frame.
|
||||
const MIN_SCALE: f64 = 0.3;
|
||||
const MAX_SCALE: f64 = 2.0;
|
||||
/// How far the canvas may scale a layer, and how many steps between. Nothing in the catalog says
|
||||
/// — 1.12 has no zoom at all — so these are the canvas framework's own bounds, and they live here
|
||||
/// because `CreateZoomLevels` reads them off the layer rather than off the frame.
|
||||
///
|
||||
/// `MIN_SCALE` is **1, not less**: the canvas is sized to the layer and its container is the rect
|
||||
/// that layer used to fill, so scale 1 is already the whole map in the window. A smaller minimum
|
||||
/// opens the map zoomed out of its own frame, which is how this was first wrong — measurable as a
|
||||
/// 300x200 canvas floating in a 1002x668 container.
|
||||
const MIN_SCALE: f64 = 1.0;
|
||||
const MAX_SCALE: f64 = 4.0;
|
||||
/// Steps between the two bounds, so the wheel has somewhere to go. Four levels over 1..4 is a
|
||||
/// factor of about 1.6 a step, which is the step the reference's own zoom buttons take.
|
||||
const ADDITIONAL_ZOOM_STEPS: u32 = 3;
|
||||
|
||||
/// The tile count on each axis for a layer, the `ceil` the reference's own loop takes.
|
||||
fn tile_grid() -> (u32, u32) {
|
||||
@@ -267,7 +275,7 @@ pub(super) fn extend_c_map(lua: &Lua, t: &Table) -> mlua::Result<()> {
|
||||
layer.set("tileHeight", TILE)?;
|
||||
layer.set("minScale", MIN_SCALE)?;
|
||||
layer.set("maxScale", MAX_SCALE)?;
|
||||
layer.set("additionalZoomSteps", 0)?;
|
||||
layer.set("additionalZoomSteps", ADDITIONAL_ZOOM_STEPS)?;
|
||||
out.set(1, layer)?;
|
||||
Ok(out)
|
||||
})?,
|
||||
|
||||
@@ -8,6 +8,46 @@ use crate::widget::FrameHandle;
|
||||
use super::clip::{effective_clip, scroll_clip_sources};
|
||||
use super::{colorselect, slider, ExtractedQuad, FontObject, QuadContent, TexCoords, UiScript};
|
||||
|
||||
/// The named point of a rect, in y-up UI space — the one thing a `Line` endpoint needs that the
|
||||
/// ordinary anchor resolver does not expose, because a line pins to a *point* rather than aligning
|
||||
/// an edge.
|
||||
fn point_on(rect: Rect, p: crate::layout::Point) -> (f32, f32) {
|
||||
use crate::layout::Point;
|
||||
let x = match p {
|
||||
Point::TopLeft | Point::Left | Point::BottomLeft => rect.left,
|
||||
Point::Top | Point::Center | Point::Bottom => (rect.left + rect.right) * 0.5,
|
||||
Point::TopRight | Point::Right | Point::BottomRight => rect.right,
|
||||
};
|
||||
let y = match p {
|
||||
Point::TopLeft | Point::Top | Point::TopRight => rect.top,
|
||||
Point::Left | Point::Center | Point::Right => (rect.top + rect.bottom) * 0.5,
|
||||
Point::BottomLeft | Point::Bottom | Point::BottomRight => rect.bottom,
|
||||
};
|
||||
(x, y)
|
||||
}
|
||||
|
||||
/// One `Line` endpoint resolved against whatever it names: a frame, a sibling region, or — for a
|
||||
/// call with no target — the line's own owner frame. `None` when the target has no resolved rect,
|
||||
/// which leaves the line undrawn rather than drawn from the origin.
|
||||
fn line_point(
|
||||
model: &super::Model,
|
||||
owner: Option<crate::widget::FrameHandle>,
|
||||
end: &super::LineEnd,
|
||||
) -> Option<(f32, f32)> {
|
||||
let rect = match end.relative_to {
|
||||
None => owner.and_then(|o| model.resolved.get(&o).copied()),
|
||||
Some(id) => match model.id_to_frame.get(&id) {
|
||||
Some(h) => model.resolved.get(h).copied(),
|
||||
None => model
|
||||
.id_to_region
|
||||
.get(&id)
|
||||
.and_then(|rh| model.region_resolved.get(rh).copied()),
|
||||
},
|
||||
}?;
|
||||
let (x, y) = point_on(rect, end.relative_point);
|
||||
Some((x + end.x, y + end.y))
|
||||
}
|
||||
|
||||
impl UiScript {
|
||||
/// Every live draw target, visible or not: each frame's slot then its regions, in arena order.
|
||||
/// Handles are generational, so a diff across a load names exactly what the load created.
|
||||
@@ -364,6 +404,41 @@ impl UiScript {
|
||||
|| model.chat_bubbles.holds(o)
|
||||
}),
|
||||
}
|
||||
} else if let Some(line) =
|
||||
data.line.filter(|l| l.start.is_some() && l.end.is_some())
|
||||
{
|
||||
// A `Line` has no rect of its own: its geometry is two points, so its box
|
||||
// is the box of those points — which is what gives a caller's `GetLeft`
|
||||
// and the renderer's clip something true to read. Resolved here rather
|
||||
// than in the layout pass because the ends name *points* on other
|
||||
// widgets, which the edge solver has no slot for.
|
||||
let ends = line
|
||||
.start
|
||||
.as_ref()
|
||||
.and_then(|a| line_point(&model, owner, a))
|
||||
.zip(line.end.as_ref().and_then(|b| line_point(&model, owner, b)));
|
||||
let Some((a, b)) = ends else {
|
||||
continue; // an end naming something unresolved: no line to draw
|
||||
};
|
||||
let half = line.thickness * 0.5;
|
||||
rect = Some(Rect::new(
|
||||
a.1.min(b.1) - half,
|
||||
a.0.min(b.0) - half,
|
||||
a.1.max(b.1) + half,
|
||||
a.0.max(b.0) + half,
|
||||
));
|
||||
let fill = data.fill.or_else(|| data.gradient.map(|g| g.midpoint()));
|
||||
let has_texture = data.texture.is_some() || fill.is_some();
|
||||
QuadContent::Line {
|
||||
path: data.texture,
|
||||
color: has_texture
|
||||
.then(|| texture_color(fill, data.vertex_color))
|
||||
.flatten(),
|
||||
additive: data.blend == crate::script::BlendMode::Add,
|
||||
start: a,
|
||||
end: b,
|
||||
thickness: line.thickness,
|
||||
}
|
||||
} else {
|
||||
// The draw gate is the texture slot, never the colour (`0x7706e0`: empty
|
||||
// `+0xcc` emits nothing); a vertex colour alone is a tint. A gradient fills
|
||||
|
||||
@@ -294,8 +294,8 @@ pub use trainer::{
|
||||
};
|
||||
pub use types::{
|
||||
BlendMode, EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject,
|
||||
FontShadow, Gradient, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline,
|
||||
QuadContent, ScriptValue, TexCoords, TextureMask, UnitPaneSubject,
|
||||
FontShadow, Gradient, JustifyH, JustifyV, LineEnd, LineGeometry, LineMeasureRequest,
|
||||
MeasureRequest, Outline, QuadContent, ScriptValue, TexCoords, TextureMask, UnitPaneSubject,
|
||||
};
|
||||
pub(crate) use types::{FontExplicit, MeasuredText, RegionData};
|
||||
pub use uimap::UiMapNode;
|
||||
@@ -355,7 +355,7 @@ pub(crate) const REGION_LEAF_SHARED: [&str; 9] = [
|
||||
/// Texture-only, each in the client's Texture map (`0x87c128`): `GetVertexColor` though
|
||||
/// `SetVertexColor` is shared, and `SetGradientAlpha`, not FontString's `SetAlphaGradient`. 1.12
|
||||
/// has no Texture `SetSize` or `SetRotation` (PlayerModel's, `0x84f1fc`/`0x505f00`).
|
||||
pub(crate) const TEXTURE_ONLY_METHODS: [&str; 22] = [
|
||||
pub(crate) const TEXTURE_ONLY_METHODS: [&str; 26] = [
|
||||
"SetGradient",
|
||||
"SetGradientAlpha",
|
||||
"GetTexture",
|
||||
@@ -380,6 +380,13 @@ pub(crate) const TEXTURE_ONLY_METHODS: [&str; 22] = [
|
||||
"GetHorizTile",
|
||||
"SetVertTile",
|
||||
"GetVertTile",
|
||||
// The `Line` region's four. On the Texture table because a Line *is* a Texture here
|
||||
// (`RegionData::line`), so a plain Texture answers them inertly — the same sharing the title
|
||||
// region already has with the texture methods.
|
||||
"SetStartPoint",
|
||||
"SetEndPoint",
|
||||
"SetThickness",
|
||||
"GetThickness",
|
||||
];
|
||||
|
||||
/// FontString-only: the font, text, justify and shadow block and the string metrics.
|
||||
|
||||
@@ -435,6 +435,103 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
)?;
|
||||
}
|
||||
|
||||
// SetStartPoint / SetEndPoint / SetThickness / GetThickness, the `Line` region's own four,
|
||||
// **beyond 1.12** (surface rows in `script/tests/widget_surface.rs`): 1.12 has no line
|
||||
// primitive at all, and a texture cannot stand in for one — a line between two moving points
|
||||
// is a rotated quad whose angle and length both change, which no rect-anchored texture
|
||||
// expresses.
|
||||
//
|
||||
// They sit on the Texture table because a `Line` *is* a Texture here
|
||||
// (`RegionData::line`): callers paint one with `SetTexture`/`SetColorTexture`/
|
||||
// `SetVertexColor` exactly as they paint a texture, and the shared table is what makes that
|
||||
// work. A plain Texture therefore answers them too and the geometry is simply never read,
|
||||
// which is the same inert sharing the title region already has.
|
||||
//
|
||||
// `SetStartPoint(relativePoint, relativeTo, x, y)` is **not** `SetPoint`: the point names a
|
||||
// place on the *target*, and nothing on the line, because a line has ends rather than edges.
|
||||
// A missing target is the line's own owner frame, which is what a two-argument call means.
|
||||
for (name, is_start) in [("SetStartPoint", true), ("SetEndPoint", false)] {
|
||||
m.set(
|
||||
name,
|
||||
lua.create_function(
|
||||
move |lua, (this, point, rel, x, y): (Table, Value, Value, Value, Value)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let point = match &point {
|
||||
Value::String(s) => {
|
||||
crate::script::object::point_from_str(s.to_str()?.as_ref())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// An unknown point name is the reference's own fall-through to the first
|
||||
// slot, which is what an addon's typo gets from `SetPoint` too.
|
||||
let point = point.unwrap_or(crate::layout::Point::TopLeft);
|
||||
let relative_to = match &rel {
|
||||
Value::Table(t) => crate::script::object::decode_id(t).ok(),
|
||||
// A name resolves through the same `_G` lookup `SetScrollChild`'s does;
|
||||
// an unknown one leaves the end on the owner, which is where a nil target
|
||||
// puts it. No caller in the corpus passes a name — every one passes the
|
||||
// frame — so this exists for the shape rather than for a call site.
|
||||
Value::String(s) => {
|
||||
let name = s.to_str()?.to_string();
|
||||
let prefetched = crate::script::object::prefetch_named_target(
|
||||
lua,
|
||||
name.as_str(),
|
||||
None,
|
||||
);
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
crate::script::object::resolve_named_target(&model, &prefetched)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let end = crate::script::LineEnd {
|
||||
relative_point: point,
|
||||
relative_to,
|
||||
x: as_f32(lua, &x),
|
||||
y: as_f32(lua, &y),
|
||||
};
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
let mut line = d.line.unwrap_or_default();
|
||||
if is_start {
|
||||
line.start = Some(end);
|
||||
} else {
|
||||
line.end = Some(end);
|
||||
}
|
||||
d.line = Some(line);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
m.set(
|
||||
"SetThickness",
|
||||
lua.create_function(|lua, (this, thickness): (Table, Option<f32>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
let mut line = d.line.unwrap_or_default();
|
||||
// A non-finite or negative thickness would make the quad's corners meaningless; the
|
||||
// reference's own floor is a hairline, which is 1.
|
||||
line.thickness = thickness
|
||||
.filter(|t| t.is_finite() && *t > 0.0)
|
||||
.unwrap_or(1.0);
|
||||
d.line = Some(line);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetThickness",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.and_then(|d| d.line)
|
||||
.map_or(1.0, |l| l.thickness))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTexture (`0x79ba70`), Texture only, answers one value: "Solid Texture" for the colour
|
||||
// form (`0x835708`), not nil, and a path cut at its last `.` (`0x79baf0`), even a dot in a
|
||||
// directory name, as the client cuts it.
|
||||
|
||||
@@ -734,6 +734,12 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
|
||||
// same reveal gate, published in the shape the canvas framework reads
|
||||
// (script/compat/classic_api/uimap.rs).
|
||||
"C_MapExplorationInfo",
|
||||
// `C_Texture.GetAtlasInfo`, which answers nil for every name — see
|
||||
// `compat/classic_api/misc.rs`. The nil is the answer (1.12 ships no atlas table and no
|
||||
// atlas sheets), and the *getter's presence* is what makes the common guard
|
||||
// `not C_Texture or not C_Texture.GetAtlasInfo or C_Texture.GetAtlasInfo(name)` take the
|
||||
// fallback branch instead of calling `SetAtlas` on a client that has no atlases.
|
||||
"C_Texture",
|
||||
// 4.x's cross-realm name shortener. 1.12 has no connected realms and no cross-realm
|
||||
// names, so this is the identity on the part that matters; the `-Realm` split is still
|
||||
// applied because a custom realm can hand out a hyphenated name and the caller's whole
|
||||
|
||||
@@ -405,6 +405,51 @@ const ALLOWED_BEYOND: &[(&str, &str, &str)] = &[
|
||||
the two spellings cannot drift. Ace3 calls it unguarded on every button it builds \
|
||||
(AceConfigDialog-3.0.lua:586)",
|
||||
),
|
||||
(
|
||||
"IsMouseOver",
|
||||
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame FontString Texture",
|
||||
"2.0 moved FrameXML's own `MouseIsOver(region, ...)` onto the widget. 1.12 has the \
|
||||
computation and not the method, so this is a method spelling of a function the player's \
|
||||
chain already defines — written out rather than forwarded because the argument order \
|
||||
differs and because a frame may be asked before UIParent.lua has run. On all three \
|
||||
tables because 2.0 put it on Region, and the body reads nothing a region lacks",
|
||||
),
|
||||
(
|
||||
"CreateLine",
|
||||
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
|
||||
"A `Line` region: two endpoints and a thickness, drawn as the quad between them. 1.12 has \
|
||||
no line primitive and nothing stands in for one — a line between two MOVING points is a \
|
||||
rotated quad whose angle and length both change every frame, which no rect-anchored \
|
||||
texture expresses. Called unguarded at both sites that need it: a flight route \
|
||||
(Map/Stitch.lua:717) and the map's drawing tool (Map/Sketch.lua:377). Built over the \
|
||||
stock CreateTexture on the same table, so a line is an ordinary Texture in every respect \
|
||||
but its geometry, which is what the callers rely on — they paint it with \
|
||||
SetColorTexture and SetVertexColor like any texture",
|
||||
),
|
||||
(
|
||||
"SetStartPoint",
|
||||
"Texture",
|
||||
"A Line's first end. NOT SetPoint: the point names a place on the TARGET and nothing on \
|
||||
the line, because a line has ends rather than edges. On the Texture table because a Line \
|
||||
is a Texture here (RegionData::line), so a plain Texture answers it inertly — the same \
|
||||
sharing the title region already has with the texture methods",
|
||||
),
|
||||
(
|
||||
"SetEndPoint",
|
||||
"Texture",
|
||||
"The Line's other end, see SetStartPoint",
|
||||
),
|
||||
(
|
||||
"SetThickness",
|
||||
"Texture",
|
||||
"The Line's width in px; floored at 1, the reference's hairline, because a non-finite or \
|
||||
negative width makes the quad's corners meaningless",
|
||||
),
|
||||
(
|
||||
"GetThickness",
|
||||
"Texture",
|
||||
"The read half, so the pair round-trips; 1 by default",
|
||||
),
|
||||
(
|
||||
"AddMaskTexture",
|
||||
"Texture",
|
||||
|
||||
@@ -177,6 +177,18 @@ pub enum QuadContent {
|
||||
/// the host's half.
|
||||
tile: (bool, bool),
|
||||
},
|
||||
/// A `Line` region (`Frame:CreateLine`): the quad between two resolved points, `thickness`
|
||||
/// wide, in the same y-up UI space as [`ExtractedQuad::rect`] — which for a line is the
|
||||
/// bounding box of the two ends, so a caller's `GetLeft` and the renderer's clip both have
|
||||
/// something true to read.
|
||||
Line {
|
||||
path: Option<String>,
|
||||
color: Option<[f32; 4]>,
|
||||
additive: bool,
|
||||
start: (f32, f32),
|
||||
end: (f32, f32),
|
||||
thickness: f32,
|
||||
},
|
||||
/// A `ColorSelect`'s `<ColorWheelTexture>` disc, generated: the pixel at normalised offset
|
||||
/// `(nx, ny)` from the centre is `HSV(atan2(ny, nx)·180/π + 180, min(|n|, 1), 1)`, inverting
|
||||
/// the pick law (`0x78bd80`); the fill draws every texel at `V = 1` (`0x78b68b`), so no colour
|
||||
@@ -377,6 +389,42 @@ pub struct ExtractedQuad {
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
/// One end of a `Line` region: a named point on another widget, plus an offset.
|
||||
///
|
||||
/// `Line:SetStartPoint(relativePoint, relativeTo, x, y)` is **not** `SetPoint`: it names a point on
|
||||
/// the *target* and nothing on the line, because a line has no edges to pin — it has two ends.
|
||||
/// So this is an anchor with the near point left out.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct LineEnd {
|
||||
/// The point on [`Self::relative_to`] this end sits at.
|
||||
pub relative_point: crate::layout::Point,
|
||||
/// The target's **script id**, the number a widget wrapper carries — a frame's or a region's,
|
||||
/// resolved against whichever map holds it. `None` is the line's own owner frame, which is
|
||||
/// what a call with no target means.
|
||||
pub relative_to: Option<u32>,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
/// A `Line` region's geometry. `None` ends leave the line undrawn, as a line with one end is not
|
||||
/// a line; the thickness defaults to the reference's 1.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct LineGeometry {
|
||||
pub start: Option<LineEnd>,
|
||||
pub end: Option<LineEnd>,
|
||||
pub thickness: f32,
|
||||
}
|
||||
|
||||
impl Default for LineGeometry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
start: None,
|
||||
end: None,
|
||||
thickness: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved mask over a Texture quad (`Texture:AddMaskTexture`): the mask region's own art and
|
||||
/// the rect it occupies on screen, in the same y-up UI space as [`ExtractedQuad::rect`].
|
||||
///
|
||||
@@ -516,6 +564,17 @@ pub(crate) struct RegionData {
|
||||
/// host repeats the art across the region at its own texel size when no explicit `SetTexCoord`
|
||||
/// says otherwise.
|
||||
pub(crate) tile: (bool, bool),
|
||||
/// `Frame:CreateLine`'s geometry. `Some` makes this region a **Line**: a Texture carrying two
|
||||
/// endpoints instead of a rect, drawn as the quad between them.
|
||||
///
|
||||
/// A flag on a Texture rather than a `RegionKind` of its own, and the reason is what callers
|
||||
/// do with it: every paint method — `SetTexture`, `SetColorTexture`, `SetVertexColor`,
|
||||
/// `SetBlendMode` — is used on a line exactly as on a texture, so sharing the Texture method
|
||||
/// table is not a shortcut but the behaviour. The same footing as the title region, which
|
||||
/// shares the Region table and accepts the texture methods inertly. The one visible deviation
|
||||
/// is `GetObjectType()`, which answers `"Texture"` where retail answers `"Line"`; nothing in
|
||||
/// the corpus asks a line what it is.
|
||||
pub(crate) line: Option<LineGeometry>,
|
||||
/// The font object this FontString inherits (`FONTINSTANCE+0x028`, a FontString's `+0xd0`), a
|
||||
/// live link: its paint is copied below, and
|
||||
/// [`script::font::propagate`](super::font::propagate) repaints on a change.
|
||||
|
||||
@@ -268,6 +268,12 @@ pub fn build_graph(
|
||||
// has three. Measured: `SetMapZoom(3, 0)` then `GetMapInfo()` answers `World`, and because
|
||||
// the row carries `Map.dbc` id 0 its zone list is Eastern Kingdoms' own, re-collected.
|
||||
//
|
||||
// **The realm's own interface drops it too**, which is the authority for doing so here:
|
||||
// its `Interface\FrameXML\Overrides.lua` sets `CONTINENTS_LENGTH = 2` and replaces
|
||||
// `GetMapContinents` with a wrapper returning exactly Kalimdor and Azeroth, discarding any
|
||||
// further row the chain's own tables carry. So the window the player sees has two
|
||||
// continents; a graph with three would be the one thing on this client that disagreed.
|
||||
//
|
||||
// Left in, it is a phantom continent on the sheet holding a second copy of all 39 Eastern
|
||||
// Kingdoms zones: `GetMapInfoAtPosition` could walk into it, and a caller caching per-map
|
||||
// art would build every one of those zones twice. The row is kept out rather than merged,
|
||||
|
||||
@@ -716,6 +716,68 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `BenillaMapIDForSelection()` / `BenillaSelectMapID(uiMapID)`: the two directions between
|
||||
// the 1.12 selection and the uiMapID graph ([`crate::script::uimap`]).
|
||||
//
|
||||
// **Not an Era API, and named so.** Era's canvas map owns `SetMapID`/`GetMapID` outright and
|
||||
// never has to agree with anything else, because its client has no second map. This one does:
|
||||
// the stock `WorldMapFrame` draws from the `(continent, zone)` selection and this install's
|
||||
// eight map addons anchor their icons to the frames that selection paints. So the shipped
|
||||
// canvas (`assets/ui/MapCanvas.lua`) moves *both* when the player changes map, and these are
|
||||
// the join — a selection has a uiMapID and a uiMapID has a selection, by the one graph, so the
|
||||
// canvas and the stock window can never show different places.
|
||||
//
|
||||
// The `Benilla` prefix is the reference-surface wall's own exemption for exactly this: a verb
|
||||
// that is ours, that an addon must never feature-detect, and that is honest about not being
|
||||
// 1.12's. The alternative was putting the selection on `C_Map.GetMapInfo`'s table, which would
|
||||
// have been a field Era does not have on a table addons read.
|
||||
g.set(
|
||||
"BenillaMapIDForSelection",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let wm = &model.worldmap;
|
||||
let node = match wm.direct_area {
|
||||
Some(row) => crate::script::uimap::node_for_direct_area(wm, row),
|
||||
None => crate::script::uimap::node_for_selection(wm, wm.selection),
|
||||
};
|
||||
Ok(node.map(|n| n.ui_map_id))
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"BenillaSelectMapID",
|
||||
lua.create_function(|lua, ui_map_id: Option<u32>| {
|
||||
let moved = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let Some(target) = ui_map_id
|
||||
.and_then(|id| crate::script::uimap::node(&model.worldmap, id))
|
||||
.map(|n| (n.selection, n.direct_area))
|
||||
else {
|
||||
return Ok(crate::script::binding_abi::flag(false));
|
||||
};
|
||||
match target {
|
||||
// A continent or zone: the ordinary pair, through the same clamp `SetMapZoom`
|
||||
// takes, so an id whose selection the catalog has since dropped lands where a
|
||||
// stale `SetMapZoom` would.
|
||||
(Some((c, z)), _) => {
|
||||
select(&mut model, i64::from(c), i64::from(z));
|
||||
true
|
||||
}
|
||||
// An instance map: the third selection cell, exclusive with the pair.
|
||||
(None, Some(row)) => {
|
||||
store_selection(&mut model, (0, 0), Some(row));
|
||||
true
|
||||
}
|
||||
// A node with neither — nothing the window can display.
|
||||
(None, None) => false,
|
||||
}
|
||||
};
|
||||
if moved {
|
||||
signal_world_map_update(lua);
|
||||
}
|
||||
Ok(crate::script::binding_abi::flag(moved))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetMapToCurrentZone(): the resolver's whole answer, orphan leg included (`0x4a7e20` is
|
||||
// `call 0x4a6650; xor eax,eax; ret`).
|
||||
g.set(
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user