`UnitState::position` was fed from a streamed object's `Transform` read raw, and a
`Transform` is Bevy's frame (`wow_to_bevy` is `(-y, z, -x)`): the two map axes arrived
swapped and negated and the third was the player's height. Three compat verbs answer
from that field — SuperWoW's `UnitPosition`, UnitXP_SP3's `getUnitPosition`, and
`C_Map.GetPlayerMapPosition` — while 1.12's own `GetPlayerMapPosition` reads the
per-frame world-map feed, which converts. So the client sat in a state no real client
can be in: the 1.12 verb placing the arrow and the modern verb saying the player was on
no map at all.
Against a zone's few-thousand-yard rect the height lands outside 0..1, so the modern
verb answered nil — which is OpenMapClassic's "You aren't on the world map here" and
`Map/StockArrow.lua` declining to place anything, leaving 1.12's wrong placement
standing. Against a continent's sixteen thousand it lands inside, and answers a
plausible wrong fraction. Both of grom's reports, one cause. Distance was the one read
this never broke, the transform being rigid.
Measured in a capture with the player at Northshire: `UnitPosition` now answers
(-8980, -160, 110), `C_Map.GetPlayerMapPosition(1415)` and the 1.12 verb agree to seven
places, Elwynn answers a fraction and Dun Morogh correctly answers nil; and with the
view centred on the player's own world point the arrow lands on the container's centre
to a third of a pixel.
A capture could not have shown any of this — its synthetic player carried no position,
so every reader answered nil there. `demo_unit_feed` now publishes the capture's own
parking spot, at the cost of one ambiguous pair written up at the ceiling.
Also here, from the same play test:
* A canvas zoom now tells the two 1.12 frames their scale moved, with their own scale,
which is the only way a 1.12 map addon can hear it. pfQuest counter-scales its pins
inside that hook; the canvas scales its child, so the hook never fired and the pins
kept the size they were built at. Measured 0 -> 24 `pfMap.ResizeNodes` over one
glide from 8x to 30x.
* `CLASSIC_API_VERSION` says 1.18.0, at grom's ask. The DLL beside the real client
reads 1.15.0.0 and nothing in his folder gates above pfUI's 11400 nag threshold, so
the trade is the one that constant has always named and the floor is unchanged.
The addon-side half is in `Addon_Ports/openmap_port`: `unexploredMode` defaults to
`reveal`, with the one-shot migration this addon already uses for the kind. A 1.12 zone
sheet is blank parchment and every scrap of terrain is an overlay the client hands out
only where explored, while a city sheet has no overlays at all — which is the whole of
"it will only paint the actual map art for cities".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
632 lines
27 KiB
Rust
632 lines
27 KiB
Rust
//! `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"
|
|
);
|
|
}
|
|
|
|
/// 1.12's `WorldMapDetailFrame` is 1002x668 and **everything** drawn into it — the stock tiles,
|
|
/// the exploration overlays, the hover highlight, and every 1.12 map addon's own layers — is
|
|
/// placed in absolute pixels against that number. So the frame's size is a contract, and the
|
|
/// canvas has to express zoom as scale rather than by resizing it.
|
|
///
|
|
/// What broke it was a second layout authority: `WorldMapFrame_Minimize` puts
|
|
/// `WORLDMAP_WINDOWED_SCALE` (0.7) on the frame and on `WorldMapButton`, and with the frame
|
|
/// `SetAllPoints` to the canvas the half nobody wanted came back as unit size — 1002 / 0.7 =
|
|
/// 1431. Everything absolute inside it then landed at 0.7 of where it belonged, which is what
|
|
/// grom's screenshot showed as the same zone name drawn twice, 0.700 apart about the frame's own
|
|
/// top-left. The mouse surface went with it: `WorldMapButton` kept its XML 1002x668 beside 1431
|
|
/// units of art, so clicks were read against 70% of the map.
|
|
#[test]
|
|
fn the_windowed_scale_does_not_reach_the_detail_frames_own_units() {
|
|
let Some(mut s) = harness() else { return };
|
|
s.run("WorldMapFrame_Minimize()").expect("windowed mode");
|
|
s.resolve();
|
|
|
|
for frame in ["WorldMapDetailFrame", "WorldMapButton"] {
|
|
let (w, h, scale): (f64, f64, f64) = s
|
|
.eval(&format!(
|
|
"{frame}:GetLeft() return {frame}:GetWidth(), {frame}:GetHeight(), {frame}:GetScale()"
|
|
))
|
|
.unwrap();
|
|
assert!(
|
|
(scale - 1.0).abs() < 1e-6,
|
|
"{frame} must carry no scale of its own under the canvas, got {scale}"
|
|
);
|
|
assert!(
|
|
(w - 1002.0).abs() < 1.0 && (h - 668.0).abs() < 1.0,
|
|
"{frame} must stay 1002x668, got {w}x{h}"
|
|
);
|
|
}
|
|
|
|
// The contract as a child sees it: a region pinned at a 1002x668-space offset has to land
|
|
// where that space says, which is the whole reason the size may not drift.
|
|
let (got, want): (f64, f64) = s
|
|
.eval(
|
|
r#"
|
|
local d = WorldMapDetailFrame
|
|
local probe = d:CreateTexture(nil, "OVERLAY")
|
|
probe:SetWidth(1) probe:SetHeight(1)
|
|
probe:SetPoint("TOPLEFT", d, "TOPLEFT", 500, -300)
|
|
d:GetLeft()
|
|
return probe:GetLeft(), d:GetLeft() + 500 * (d:GetWidth() / 1002)
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
(got - want).abs() < 0.5,
|
|
"a child pinned at 1.12's own (500, -300) landed at {got}, not {want}"
|
|
);
|
|
}
|
|
|
|
/// 1.12 lays its map art out twice, once per window mode, and the canvas container has to be
|
|
/// where that art would have been — `WorldMapFrame_Minimize` insets the detail frame 15,-33
|
|
/// inside a 720x521 window at scale 0.7 (701x468 of art: 15 left, 4 right, 33 top, 20 bottom),
|
|
/// `_Maximize` puts it at the fixed 1024x768 positioning guide's TOP -502,-69 (10/12/69/31).
|
|
///
|
|
/// Taking only the maximized anchor, whatever the mode, is what left grom's map a 1002x668 patch
|
|
/// in a full-screen window the moment he pressed maximize, with no way back: minimize re-sized
|
|
/// the window and the art stayed where the maximized sheet had put it.
|
|
///
|
|
/// The zoom ladder's floor moves with it. It used to be the constant 1, which was right only
|
|
/// while the container was the art's own size; it is the fitted scale now, and that still comes
|
|
/// out exactly 1 when the window is 1.12's own.
|
|
#[test]
|
|
fn the_canvas_container_is_the_windows_content_area_in_both_map_modes() {
|
|
let Some(mut s) = harness() else { return };
|
|
|
|
for (call, want) in [
|
|
("WorldMapFrame_Minimize()", (701.0, 468.0)),
|
|
("WorldMapFrame_Maximize()", (1002.0, 668.0)),
|
|
] {
|
|
s.run(call).expect("the stock mode switch runs");
|
|
s.resolve();
|
|
let (w, h): (f64, f64) = s
|
|
.eval(
|
|
"WorldMapScrollContainer:GetLeft() \
|
|
return WorldMapScrollContainer:GetWidth(), WorldMapScrollContainer:GetHeight()",
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
(w - want.0).abs() < 1.0 && (h - want.1).abs() < 1.0,
|
|
"{call}: the container must be 1.12's own art rect, wanted {want:?}, got {w}x{h}"
|
|
);
|
|
// And the whole map has to be reachable in it: the ladder's floor is the fit, which is
|
|
// what `ResetZoom` and the right-click zoom-out both walk down to.
|
|
let (fit, floor): (f64, f64) = s
|
|
.eval(
|
|
"local sc = WorldMapFrame.ScrollContainer \
|
|
return sc:GetFitScale(), sc:GetScaleForMinZoom()",
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
(fit - floor).abs() < 1e-3,
|
|
"{call}: the zoom ladder must reach the fitted scale, fit {fit} floor {floor}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A wheel notch steps from where the view is **heading**, so notches accumulate while the
|
|
/// smooth zoom is still easing toward the last one.
|
|
///
|
|
/// Before this, `ZoomIn`/`ZoomOut` read `GetCanvasScale()` — the live, easing value. The ease
|
|
/// closes about 63% of the gap a second, so the notch after the first found the same rung still
|
|
/// under it and re-set the target it already had: measured live at grom's window size with his
|
|
/// addons, four wheel-down notches moved the ladder one step and two more moved it none, which
|
|
/// is what "scroll only goes a little way out" is. The ladder here is synthetic because the
|
|
/// rule has to hold for a canvas addon's own ladder, which is the only kind with enough rungs
|
|
/// for the defect to show — `CreateZoomLevels` is the documented place to replace it.
|
|
#[test]
|
|
fn wheel_notches_accumulate_while_the_zoom_is_still_easing() {
|
|
let Some(s) = harness() else { return };
|
|
let seat = "local sc = WorldMapFrame.ScrollContainer \
|
|
sc.zoomLevels = {} \
|
|
for i = 1, 10 do sc.zoomLevels[i] = { scale = i, layerIndex = 1 } end \
|
|
sc.currentScale, sc.targetScale, sc.zoomTarget = 5, 5, 5 ";
|
|
let (a, b, c, live): (f64, f64, f64, f64) = s
|
|
.eval(&format!(
|
|
"{seat} \
|
|
sc:ZoomOut() local a = sc.targetScale \
|
|
sc:ZoomOut() local b = sc.targetScale \
|
|
sc:ZoomOut() \
|
|
return a, b, sc.targetScale, sc.currentScale"
|
|
))
|
|
.unwrap();
|
|
assert_eq!(
|
|
(a, b, c),
|
|
(4.0, 3.0, 2.0),
|
|
"three notches down the ladder must be three rungs, not one repeated"
|
|
);
|
|
assert_eq!(
|
|
live, 5.0,
|
|
"and none of it touched the easing scale — the OnUpdate owns that"
|
|
);
|
|
let (a, b, c): (f64, f64, f64) = s
|
|
.eval(&format!(
|
|
"{seat} \
|
|
sc:ZoomIn() local a = sc.targetScale \
|
|
sc:ZoomIn() local b = sc.targetScale \
|
|
sc:ZoomIn() \
|
|
return a, b, sc.targetScale"
|
|
))
|
|
.unwrap();
|
|
assert_eq!((a, b, c), (6.0, 7.0, 8.0), "and the same going in");
|
|
// The step is still clamped to the ladder's own ends, which is what stops a held wheel
|
|
// walking the target past a rung that exists.
|
|
let (lo, hi): (f64, f64) = s
|
|
.eval(&format!(
|
|
"{seat} \
|
|
for _ = 1, 20 do sc:ZoomOut() end local lo = sc.targetScale \
|
|
for _ = 1, 40 do sc:ZoomIn() end \
|
|
return lo, sc.targetScale"
|
|
))
|
|
.unwrap();
|
|
assert_eq!((lo, hi), (1.0, 10.0), "the ladder's own ends hold");
|
|
}
|
|
|
|
/// The container takes the mouse, and out-levels the map window so it actually gets it.
|
|
///
|
|
/// The map's mouse surface used to be 1.12's `WorldMapButton` alone, which is pinned to the
|
|
/// canvas child — so once a canvas addon scales the canvas down, the button shrinks away from
|
|
/// the container and the rest of the visible map has no mouse surface at all. Measured at the
|
|
/// world view with OpenMapClassic driving: a 23x16 px button inside a 701x468 px window. A press
|
|
/// on the other 99% reached the next frame that takes the mouse, which with pfUI loaded is
|
|
/// `WorldMapFrame` itself — a `RegisterForDrag("LeftButton")` handle for the whole window.
|
|
///
|
|
/// The level matters as much as the mouse bit: the hit sweep probes one `(strata, level)` plane
|
|
/// in link order and a parent is linked before its children, so a container tied with its own
|
|
/// parent loses every press to it.
|
|
#[test]
|
|
fn the_container_takes_the_mouse_and_out_levels_the_window() {
|
|
let Some(mut s) = harness() else { return };
|
|
s.run("ShowUIPanel(WorldMapFrame)").unwrap();
|
|
s.resolve();
|
|
let (mouse, wheel, has_down, sc_level, map_level, button_level): (
|
|
bool,
|
|
bool,
|
|
bool,
|
|
i64,
|
|
i64,
|
|
i64,
|
|
) = s
|
|
.eval(
|
|
"local sc = WorldMapFrame.ScrollContainer \
|
|
return sc:IsMouseEnabled() and true or false, \
|
|
sc:IsMouseWheelEnabled() and true or false, \
|
|
sc:GetScript(\"OnMouseDown\") ~= nil, \
|
|
sc:GetFrameLevel(), WorldMapFrame:GetFrameLevel(), WorldMapButton:GetFrameLevel()",
|
|
)
|
|
.unwrap();
|
|
assert!(mouse, "the container takes the mouse");
|
|
assert!(wheel, "...and the wheel, for the same bare area");
|
|
assert!(has_down, "...and drives the controller from it");
|
|
assert!(
|
|
sc_level > map_level,
|
|
"the container ({sc_level}) must out-level the map window ({map_level}), or the sweep \
|
|
gives the press to the parent"
|
|
);
|
|
assert!(
|
|
button_level > sc_level,
|
|
"and 1.12's own surface ({button_level}) stays above the container ({sc_level}), so it \
|
|
keeps every press it does reach"
|
|
);
|
|
}
|
|
|
|
/// A 1.12 map addon learns the map's scale moved from `SetScale` on the frames it anchors to, and
|
|
/// from nothing else — on 1.12 that is the only thing that *can* move it. The canvas zooms its own
|
|
/// child, so the effective scale such an addon reads is right while the hook that would make it
|
|
/// read it never fires: measured with OpenMapClassic driving a glide from 8x to 30x,
|
|
/// `pfMap.ResizeNodes` ran 0 times and a 16 px icon on `WorldMapButton` drew 25.3 px at one zoom
|
|
/// and 53.1 px at another. With the announcement the same glide runs it 24 times, once an easing
|
|
/// frame.
|
|
///
|
|
/// The announcement passes the frame's **own** scale, so it moves nothing: that is asserted here
|
|
/// too, because a value that is not the frame's own would scale the art a second time.
|
|
#[test]
|
|
fn a_canvas_zoom_tells_the_1_12_frames_their_scale_moved() {
|
|
let Some(mut s) = harness() else { return };
|
|
s.run("ShowUIPanel(WorldMapFrame)").unwrap();
|
|
s.resolve();
|
|
// Hooked the way an addon hooks it (pfQuest/map.lua:1622-1630), argument and all.
|
|
s.run(
|
|
"BenillaScaleCalls, BenillaScaleArgs = 0, {} for _, f in ipairs({ WorldMapDetailFrame, WorldMapButton }) do local old = f.SetScale f.SetScale = function(self, v) BenillaScaleCalls = BenillaScaleCalls + 1 table.insert(BenillaScaleArgs, v) return old(self, v) end end",
|
|
)
|
|
.unwrap();
|
|
s.run("WorldMapFrame.ScrollContainer:InstantPanAndZoom(2, 0.5, 0.5)")
|
|
.unwrap();
|
|
s.resolve();
|
|
let (calls, detail, button, worst): (i64, f64, f64, f64) = s
|
|
.eval(
|
|
"local worst = 0 for _, v in ipairs(BenillaScaleArgs) do worst = math.max(worst, math.abs((v or 0) - 1)) end return BenillaScaleCalls, WorldMapDetailFrame:GetScale(), WorldMapButton:GetScale(), worst",
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
calls >= 2,
|
|
"a zoom has to reach both 1.12 frames (saw {calls} SetScale calls)"
|
|
);
|
|
assert!(
|
|
worst < 1e-6,
|
|
"the announcement carries the frame's own scale, not a new one (worst |v - 1| = {worst})"
|
|
);
|
|
assert_eq!(
|
|
(detail, button),
|
|
(1.0, 1.0),
|
|
"...so both frames are still at scale 1 afterwards"
|
|
);
|
|
}
|