The map's arrow, its stratum, and a fade while you walk

Three of grom's 2026-11-05 reports about the built-in map, all measured.

**The player arrow was the one piece of 1.12's map content left outside the canvas.**
*"my player arrow goes to the wrong spot at any zoom level, this needs to be accurate all
of the time."* `WorldMapFrame_OnLoad` builds it with
`CreateWorldMapArrowFrame(WorldMapFrame)` (`WorldMapFrame.lua:19`), so its parent is the
WINDOW, while `MapCanvas.lua` reparents everything it is anchored against into the canvas.
`SetPoint`'s offsets are in the positioned frame's own units, so the offsets the stock
update computes — `playerX * GetWidth()`, times `WorldMapDetailFrame:GetScale()`, which
`SeatOnCanvas` pins at 1 because the zoom lives one level up on `ScrollContainer.Child` —
are short by exactly the canvas zoom. Measured on a 1600x900 harness with the view centred
on the player: exact at zoom 1, 250.5 x 501.0 screen px out at zoom 2.

`WorldMapPlayer` — the invisible mouseover rect for the same spot, from the same two
FrameXML lines — has always been right, because 1.12's XML nests it under `WorldMapButton`,
which IS in the canvas. That makes it the oracle the test uses rather than a recomputed
expectation. Not reparented like the detail frame and the button: the arrow is a size-less
`Model` whose rect is the file's bounding box, so inside the canvas it would grow with the
zoom, and no frame scale fixes the size without undoing the position — both read the same
effective scale. The cost of staying outside is clipping, hence an explicit bounds test,
which is a hide the stock code never needed because a map that cannot pan cannot put the
player off-screen.

That re-seat runs every frame the map is open, so nothing in it may force a layout resolve:
the art's size is read off `WorldMapButton` (explicitly sized) rather than the detail frame
(`SetAllPoints`, so derived), and the bounds test is arithmetic on the controller's own
normalized scroll rather than a rect comparison — `GetLeft` on a dirty tree measures
30-370 us against 0.42 us settled, and a pan writes the scroll every frame.

While in there: `InstantPanAndZoom` set `Child:SetScale` from its raw argument while
`GetCanvasScale` clamps to the zoom ladder, so a scale past the ceiling drew the art at one
scale with every extent, pan clamp and pin placement computed for another. It clamps first
now, as the ease path already did by construction.

**The window fell out of its own stratum every time it opened.** *"the map needs to be on a
higher level as I can see my unitframe over it."* `WorldMapFrame.xml:5` declares
`frameStrata="FULLSCREEN"`; measured FULLSCREEN with no addons and MEDIUM at level 0 with
his, which is the bottom of the bucket pfUI's unit frames live in
(`pfUI/api/unitframes.lua:413`). Five visible frames drew over the map rect.

No addon does it. The new `$WOW_WATCH_STRATA=<FrameName>` instrument — every
`SetFrameStrata` AND every `SetParent` on a named frame, with the Lua caller site — found
one FULLSCREEN from the XML loader and then `WorldMapFrame:SetParent(UIParent)`, from the
realm's own `WorldMapFrame_Minimize` (`WorldMapFrame.lua:183`) by way of
`WorldMapFrame_OnEvent`, on the open path. A reparent forces the moved frame into its new
parent's stratum (`0x76ab5a`, pinned off the binary by `script::tests::setparent`), and
`UIParent` is MEDIUM. The reparent is right and stays; the window's own declared stratum is
re-asserted on `SeatLevels` instead, which already re-seats the frame levels on every show
for a cousin reason, plus hooks on Minimize/Maximize since the minimize runs while the
window is up. After: FULLSCREEN, 0 frames over the map.

**And the fade.** *"It would also be nice if the whole map window went to 80%
transparentcy when the player is moving."* `mapFadeWhileMoving`, percent, 0 off, default 80,
with a slider on the Options window's Interface page. Written as a FACTOR of whatever alpha
the window already has, because that alpha belongs to somebody else — pfUI sets it from its
own config and offers shift-wheel over the map — so the base is re-read whenever the live
alpha is not the one this last wrote, which is another writer's signature; their setting is
what gets faded, and closing the map restores it. `GetSpeed()` is the movement test: ours,
always present, and 0 standing still by construction, off the same move flags the engine
answers `IsSwimming` and `IsMounted` from.

Also `$WOW_CAPTURE_EVAL`/`_SETUP` take a path ending in `.lua` as the chunk. A probe worth
writing is longer than a shell argument survives, and a quoting error in a probe reads
exactly like the thing being measured not being there.

Verified: `the_player_arrow_tracks_the_hit_area_at_every_canvas_zoom` walks zoom 1, 1.5,
2.5 and 4 asserting the arrow and the hit area agree within a screen pixel, and that the
canvas applied the zoom asked for. In a real session with his addons: FULLSCREEN and 0
visible frames over the map (was MEDIUM/0 and 5). Options census 140 -> 141 on both counts.
benilla-app at its recorded baseline, benilla-ui 2128/0, clippy and the two lints clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-05 10:38:31 -07:00
co-authored by Claude Opus 5
parent a7a54c6d02
commit 082dd2ed0f
7 changed files with 436 additions and 2 deletions
+178
View File
@@ -68,6 +68,9 @@ local SCALE_RATE = 24
-- A drag under this many pixels is still a click, which is what `WouldCursorPositionBeClick`
-- answers; Era's own threshold.
local CLICK_SLOP = 5
-- The stratum `WorldMapFrame.xml` declares for itself, re-asserted on every show; see
-- `SeatLevels`.
local MAP_STRATA = "FULLSCREEN"
--------------------------------------------------------------------------------
-- PinFrameLevelsManager
@@ -605,6 +608,14 @@ function MapCanvasScrollControllerMixin:InstantPanAndZoom(scale, x, y)
self.currentScale = scale
self.targetScale = scale
self.zoomTarget = scale
-- Pulled onto the ladder BEFORE the child is scaled, which the ease path below already does
-- by construction (it applies `self.currentScale`) and this one did not. The child's scale is
-- what the player SEES; `GetCanvasScale` -- which clamps, in `ClampZoomToLevels` -- is what
-- every scroll extent, pan clamp and pin placement is computed FROM. Setting the raw argument
-- let the two disagree: past the ladder's ceiling the art drew at one scale while the whole
-- framework believed another, so the extents were computed for the wrong canvas size and the
-- view would not pan where it should.
scale = self:GetCanvasScale()
if self.Child then
self.Child:SetScale(scale)
end
@@ -2066,7 +2077,26 @@ local function BuildCanvas()
-- Re-asserted on every show, not seated once: `WorldMapFrame` is level 0 while this builds
-- and 1 once `ShowUIPanel` has raised it, so a level computed at build time ties the moment
-- the window is first opened.
-- ...and the window's own stratum with them, because a reparent takes it away.
--
-- `WorldMapFrame.xml:5` declares `frameStrata="FULLSCREEN"`, which is what puts 1.12's map
-- over every other window. The realm's own `WorldMapFrame_Minimize`
-- (`WorldMapFrame.lua:183`, reached from `WorldMapFrame_OnEvent`) calls
-- `WorldMapFrame:SetParent(UIParent)`, and a reparent forces the moved frame into its new
-- parent's stratum (`0x76ab5a`, pinned by `script::tests::setparent`) -- so on every open the
-- window fell into `UIParent`'s MEDIUM at level 0, underneath every pfUI frame, pfUI's unit
-- frames being MEDIUM themselves (`pfUI/api/unitframes.lua:413`). Measured on grom's install:
-- FULLSCREEN with no addons loaded, MEDIUM/0 with his. grom, 2026-11-05: *"the map needs to be
-- on a higher level as I can see my unitframe over it."*
--
-- Re-asserted rather than fixed in the reparent, because the reparent is right: the force is
-- read off the binary, and the realm's minimize is the thing that spends it. Here it is one
-- window's own declared stratum restored after the code that drops it, on the same hook that
-- already re-seats the levels for the same reason.
local function SeatLevels()
if map:GetFrameStrata() ~= MAP_STRATA then
map:SetFrameStrata(MAP_STRATA)
end
local base = map:GetFrameLevel() or 0
sc:SetFrameLevel(base + 1)
if canvas:GetFrameLevel() <= sc:GetFrameLevel() then
@@ -2078,6 +2108,14 @@ local function BuildCanvas()
end
SeatLevels()
map:HookScript("OnShow", SeatLevels)
-- The minimize is where the stratum goes, so re-seat on it rather than waiting for the next
-- show: it runs from `WorldMapFrame_OnEvent`, which fires while the window is already up.
if WorldMapFrame_Minimize then
hooksecurefunc("WorldMapFrame_Minimize", SeatLevels)
end
if WorldMapFrame_Maximize then
hooksecurefunc("WorldMapFrame_Maximize", SeatLevels)
end
sc:SetScript("OnMouseDown", function()
sc:OnMouseDown(arg1)
end)
@@ -2095,6 +2133,146 @@ local function BuildCanvas()
sc.mapID = map.mapID
sc:OnCanvasSizeChanged()
-- THE PLAYER ARROW, THE ONE PIECE OF 1.12'S MAP CONTENT THAT IS NOT IN THE CANVAS
--
-- `WorldMapFrame_OnLoad` builds it with `CreateWorldMapArrowFrame(WorldMapFrame)`
-- (WorldMapFrame.lua:19), so its parent is the WINDOW, while everything it is anchored
-- against was reparented into the canvas above. `SetPoint`'s offsets are in the positioned
-- frame's OWN units, so the offsets `WorldMapButton_OnUpdate` computes --
-- `playerX * WorldMapDetailFrame:GetWidth()`, times `WorldMapDetailFrame:GetScale()`, which
-- `SeatOnCanvas` pins at 1 because the zoom lives one level up on `ScrollContainer.Child` --
-- are short by exactly the canvas zoom. grom, 2026-11-05: *"my player arrow goes to the wrong
-- spot at any zoom level, this needs to be accurate all of the time."* Measured on a 1600x900
-- harness with the view centred on the player: exact at zoom 1, 250.5 px right and 501.0 px
-- down at zoom 2, so the error is the zoom and nothing else.
--
-- Note that `WorldMapPlayer` -- the invisible mouseover rect for the same spot, set from the
-- same two FrameXML lines -- has always been right, because 1.12's XML nests it under
-- `WorldMapButton`, which IS in the canvas. That is what makes it the oracle the test uses
-- instead of a recomputed expectation.
--
-- Why not reparent the arrow as well: it is a size-less `Model` whose rect is the arrow
-- file's bounding box, so inside the canvas it would grow with the zoom, and there is no
-- frame scale that fixes the size without undoing the position -- both read the same
-- effective scale. Outside the canvas it keeps one apparent size at every zoom, which is what
-- the player pin does on every later client.
--
-- The cost of staying outside is clipping: the container crops its own subtree and the arrow
-- is not in it. So the bounds test below, which is a hide the stock code never needed because
-- a map that cannot pan cannot put the player off-screen.
local arrow
for _, child in ipairs({ map:GetChildren() }) do
if child.GetObjectType and child:GetObjectType() == "Model" then
arrow = child
break
end
end
-- Runs every frame the map is open, so nothing in it may force a layout resolve. Two rules
-- follow from that, and both are why this reads the frames it reads:
--
-- * the ART'S SIZE comes off `WorldMapButton`, not off `WorldMapDetailFrame`. The detail
-- frame is `SetAllPoints` to the canvas, so its width is DERIVED and asking for it
-- resolves the tree; the button carries the same rect as an explicit `SetWidth`, for the
-- reason `SeatOnCanvas` states (pfUI reads it).
-- * the BOUNDS TEST is arithmetic on the controller's own normalized scroll, not a rect
-- comparison. `GetLeft` on a dirty tree measured 30-370 us against 0.42 us settled
-- (`benilla-getleft-forces-a-layout-resolve`), and a pan writes the scroll every frame,
-- so the tree is dirty every frame this runs — six of those is a millisecond of nothing.
local function SeatPlayerArrow()
if not map:IsVisible() then
return
end
local px, py = GetPlayerMapPosition("player")
if not px or not py or (px == 0 and py == 0) then
return -- the stock update has already hidden it
end
local de = detail:GetEffectiveScale()
local ae = arrow and arrow:GetEffectiveScale() or map:GetEffectiveScale()
if not de or not ae or de == 0 or ae == 0 then
return
end
local aw = button and button:GetWidth() or 0
local ah = button and button:GetHeight() or 0
if aw <= 0 or ah <= 0 then
return
end
-- In the detail frame's own units, which is where the art and every 1.12 icon live...
-- ...and out again into the arrow's, which is the whole conversion that was missing.
local k = de / ae
PositionWorldMapArrowFrame("CENTER", "WorldMapDetailFrame", "TOPLEFT",
px * aw * k, -py * ah * k)
-- Hidden where the container would have cropped it, which it cannot do for a frame that
-- is not in the container. The view is the box of half-width `halfU` around the scroll
-- centre, in the canvas's own normalized space -- the same numbers
-- `CalculateScrollExtentsAtScale` works in, which is why they are asked for rather than
-- recomputed.
local scale = sc:GetCanvasScale()
if scale and scale > 0 then
local halfU = sc:NormalizeHorizontalSize(sc:GetWidth() * 0.5 / scale)
local halfV = sc:NormalizeVerticalSize(sc:GetHeight() * 0.5 / scale)
local cu = sc:GetNormalizedHorizontalScroll() or 0.5
local cv = sc:GetNormalizedVerticalScroll() or 0.5
if math.abs(px - cu) > halfU or math.abs(py - cv) > halfV then
ShowWorldMapArrowFrame(nil)
end
end
end
-- THE MAP FADES WHILE THE PLAYER IS MOVING
--
-- grom, 2026-11-05: *"It would also be nice if the whole map window went to 80%
-- transparentcy when the player is moving."* `mapFadeWhileMoving` is the percent, 0 off, with
-- a row on the Options window's Interface page; 1.12 has no such behaviour at all.
--
-- Written as a FACTOR of whatever alpha the window already has, not as an absolute, because
-- the window's alpha belongs to somebody else: pfUI sets it from its own saved config and
-- offers shift-wheel over the map to change it (`pfUI/modules/map.lua:67`). So the base is
-- re-read whenever the live alpha is not the one this last wrote — that is the signature of
-- another writer, and it makes their setting the thing that gets faded rather than the thing
-- that gets overwritten.
--
-- `GetSpeed()` is the movement test: 1.12 has no "is the player moving" verb, and this one is
-- ours, always present, and 0 standing still by construction (`compat/superwow.rs`) — the same
-- move flags the engine answers `IsSwimming` and `IsMounted` from, so it cannot disagree with
-- them about what the player is doing this frame.
local fadeBase, fadeApplied
local function FadeWhileMoving()
if not map:IsVisible() then
return
end
local pct = tonumber(GetCVar("mapFadeWhileMoving")) or 0
local now = map:GetAlpha() or 1
if not fadeApplied or math.abs(now - fadeApplied) > 0.001 then
fadeBase = now -- somebody else wrote it: that is the alpha to fade FROM
end
local want = fadeBase or 1
if pct > 0 and GetSpeed and GetSpeed() > 0 then
want = want * (1 - pct / 100)
end
if math.abs(now - want) > 0.001 then
map:SetAlpha(want)
end
fadeApplied = want
end
if WorldMapButton_OnUpdate then
-- Hooked rather than replaced, and hooked on the function that places it, so if anything
-- ever suppresses the stock update this stops running with it rather than fighting it.
hooksecurefunc("WorldMapButton_OnUpdate", function()
SeatPlayerArrow()
FadeWhileMoving()
end)
end
-- A map closed while faded must not come back faded: the base is whatever it was before.
map:HookScript("OnHide", function()
if fadeApplied and fadeBase and math.abs((map:GetAlpha() or 1) - fadeApplied) <= 0.001 then
map:SetAlpha(fadeBase)
end
fadeApplied = nil
end)
map:HookScript("OnShow", function()
WorldMapFrame:SyncFromSelection()
WorldMapFrame:RefreshAllDataProviders(true)
@@ -479,6 +479,14 @@
.. "In Windowed (Fullscreen) the window already fills the screen, so this sets the "
.. "size it returns to in Windowed. Takes effect on Apply."
-- 1.12 has no such setting: its map does not fade, because its map cannot be read while
-- you walk either way (it covers the screen and takes the keyboard).
BENILLA_TOOLTIP_MAP_FADE_WHILE_MOVING =
"How far the world map fades out while you are moving, so you can keep it open and "
.. "still see where you are going.\n\n"
.. "At 0 the map does not fade. The map's own alpha is whatever you or your interface "
.. "addon set it to; this fades from there, and restores it the moment you stop."
-- 1.12's GAMMA text points at gray-bar art this page does not have.
BENILLA_TOOLTIP_BRIGHTNESS =
"Lightens or darkens the whole picture — nights, caves and interiors are what it is "
@@ -1573,6 +1581,8 @@
"RowInstantQuestText", "RowBuffDurations", "RowAutoQuestWatch",
"RowProfanityFilter",
"RowShowTutorials", "RowTipOfTheDay", "RowNewbieTips",
-- Appended, as this list's own rule says: the tests assert positional indices.
"RowMapFadeWhileMoving",
},
-- 1.12's chat box (UIOptionsFrame.xml:751-840). Not shipped: Simple Chat, which needs
-- FCF_Set_SimpleChat's re-layout, Lock Chat Settings and Guild Recruitment.
@@ -1924,6 +1934,15 @@
if n <= 0 then return BENILLA_ADVGFX.everyFrame end
return string.format(BENILLA_ADVGFX.hz, n)
end
-- A row whose stored value ALREADY IS a percent, where the fallback below would
-- multiply by 100 and print 8000%. 0 reads "Off" rather than "0%", on the precedent
-- the anisotropy groove sets below: a zero that means "not at all" is a word, because
-- a number next to the next stop up reads as the weakest setting rather than as none.
if row.fmt == "pct" then
local n = math.floor(value + 0.5)
if n <= 0 then return "Off" end
return string.format("%d%%", n)
end
-- The anisotropy groove. 1 is not "1x", it is the feature OFF (`tex_filter.rs`:
-- the reference writes filter mode 5 only at 2 and above), and a player reading "1x"
-- next to "2x" would take it for the weakest setting rather than for none.
@@ -5043,6 +5062,20 @@
"Show Detailed Tooltips", "OPTION_TOOLTIP_SHOW_NEWBIE_TIPS")
</OnLoad></Scripts>
</Frame>
<!-- Read by `MapCanvas.lua`, which owns the behaviour; 0
is off and the row's own floor. -->
<Frame name="$parentRowMapFadeWhileMoving" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowNewbieTips" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowNewbieTips" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "mapFadeWhileMoving",
"Fade World Map While Moving",
"BENILLA_TOOLTIP_MAP_FADE_WHILE_MOVING")
OptionsSliderRow_Setup(self, 0, 90, 10, "pct")
</OnLoad></Scripts>
</Frame>
</Frames>
</Frame>
<!-- Party Chat Bubbles is not a child: the client gates party
+16
View File
@@ -895,6 +895,22 @@ fn capture_lua(var: &str, label: &str, script: Option<NonSendMut<benilla_ui::scr
warn!("capture {label}: no interface in this capture, nothing to ask");
return;
};
// A value ending in `.lua` is a **path**, read as the chunk. A probe worth writing is usually
// longer than a shell argument survives: quoting Lua inside `env VAR=...` is where a
// measurement turns into a syntax error, and a syntax error in a probe reads exactly like the
// thing being measured not being there. Anything else is the chunk itself, which is every
// caller this had before.
let chunk = if chunk.ends_with(".lua") {
match std::fs::read_to_string(&chunk) {
Ok(text) => text,
Err(e) => {
warn!("capture {label}: {chunk}: {e}");
return;
}
}
} else {
chunk
};
match script.eval::<String>(&chunk) {
Ok(out) => {
for line in out.lines() {
+18
View File
@@ -1948,6 +1948,17 @@ pub(crate) const REGISTERED: &[Registered] = &[
(`benilla_ui::script::nameplate`), so 0 is the client's own behaviour and there is \
nothing for a 1 to turn on",
),
// Read by `MapCanvas.lua`, not by any Rust system: the map window is a frame, its alpha is a
// frame verb, and `IsPlayerMoving()` is already a verb — so the whole feature is Lua over a
// CVar, and the CVar is what gives it a home in the Options window and in `config.toml`.
// 0 switches it off; the Options row offers 0 and 10..90.
ours(
"mapFadeWhileMoving",
"80",
"how transparent the world map goes while the player is moving, in percent; 0 is off. \
1.12 has no such behaviour — grom asked for it (2026-11-05) because a map you can read \
while walking is a map you walk into things behind",
),
];
/// `config.toml`: a `[cvars]` table of `Name = "value"` strings, sorted so every save is stable.
@@ -5814,6 +5825,13 @@ mod tests {
"ShowVKeyCastbar",
"pfUI modules/nameplates.lua:3 writes it; benilla draws no plate cast bar",
),
// Ours, and the whole feature is Lua: the map window is a frame, its alpha is a frame
// verb and `GetSpeed()` is a verb, so there is nothing for a Rust system to do. The
// CVar is what gives it the Options row and `config.toml`.
(
"mapFadeWhileMoving",
"MapCanvas.lua's `FadeWhileMoving`, every frame the map is open",
),
// The five per-kind plate rows with no gate of their own. They exist because an addon
// written for a later client greys a whole row on a nil `GetCVar` — registering is the
// answer to "does this client have this setting", and each default below is this
@@ -3760,6 +3760,12 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
"AudioRowBackgroundSound",
),
("BENILLA_TOOLTIP_BRIGHTNESS", "GraphicsRowBrightness"),
// The world map's fade-while-moving dial: a behaviour 1.12 has no setting for,
// because 1.12's map cannot be read while walking either way.
(
"BENILLA_TOOLTIP_MAP_FADE_WHILE_MOVING",
"InterfaceRowMapFadeWhileMoving",
),
// MONKEY (advanced graphics): the whole Advanced Graphics page. Every row on it is
// benilla's own mechanism — 1.12 has no realtime shadow, no fixture-lit interior and
// no invented light — so there is no `OPTION_TOOLTIP_*` any of them could resolve,
@@ -3896,7 +3902,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// GFX (sharpening) / (field of view): one row each, on the two different pages. 136 -> 138.
// GFX (froxel volumetrics): the shadowed-air dial beside the haze tier. 138 -> 139.
// GFX (foliage distance): the ground-clutter horizon. 139 -> 140.
assert_eq!(checked, 140, "every tipped row carries a live key");
// The world map's fade-while-moving dial. 140 -> 141.
assert_eq!(checked, 141, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -4005,7 +4012,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// GFX (shadow rig): the five Advanced Graphics shadow rows. 119 -> 124.
// GFX (interior rig): the ten interior-lighting rows. 124 -> 134.
// GFX (auto-exposure): the tier and its blend. 134 -> 136.
assert_eq!(raised, 140, "every row but Auto Loot raises a description");
assert_eq!(raised, 141, "every row but Auto Loot raises a description");
}
/// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a
@@ -377,6 +377,115 @@ fn the_player_arrow_is_the_stock_model_pane_seated_and_turned_by_the_update() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The arrow has to stay on the player at every canvas zoom, which is a different claim from the
/// test above: that one measures the canvas at rest, where every scale in the chain is 1 and a
/// missing conversion cannot show.
///
/// The arrow is the one piece of 1.12's map content that is NOT inside the canvas --
/// `WorldMapFrame_OnLoad` builds it with `CreateWorldMapArrowFrame(WorldMapFrame)` -- while
/// everything it is anchored against was reparented into it, so `SetPoint`'s offsets (which are
/// in the positioned frame's OWN units) are short by exactly the canvas zoom unless something
/// converts them. Measured before the fix: exact at zoom 1, 250.5 x 501.0 screen px out at zoom 2.
///
/// `WorldMapPlayer` is the oracle rather than a computed expectation: it is the same position from
/// the same FrameXML line, 1.12's XML nests it under `WorldMapButton`, and the button IS in the
/// canvas -- so it is right by construction, and the two must agree or one of them is wrong.
#[test]
fn the_player_arrow_tracks_the_hit_area_at_every_canvas_zoom() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
..Default::default()
}),
);
// A map with art of its own, because the zoom ladder is built from `C_Map.GetMapArtLayers`:
// with no catalog seated every map answers no layers, `CreateZoomLevels` falls back to its
// one-rung "fit" ladder, and `ClampZoomToLevels` then pins the canvas at scale 1 — which is
// a state this test has to get past rather than the state it is about.
s.set_world_map_catalog(vec![benilla_ui::script::WorldMapContinentView {
name: "Azeroth".to_owned(),
map_file: "Azeroth".to_owned(),
map_id: 0,
world_rect: (0.0, 0.0, 1.0, 1.0),
world_art_rect: (0.0, 0.0, 1.0, 1.0),
loc_rect: (-5000.0, -10000.0, 3000.0, -400.0),
zone_grid: Vec::new(),
zones: vec![benilla_ui::script::WorldMapZoneView {
name: "Dun Morogh".to_owned(),
area_id: 1,
map_file: "DunMorogh".to_owned(),
loc_rect: (-5000.0, -10000.0, 3000.0, -400.0),
overlays: Vec::new(),
}],
}]);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
arrow_facts(&mut s);
s.resolve();
// `MapCanvas.lua` builds the canvas at `PLAYER_LOGIN`, which no harness fires on its own.
s.fire_event("PLAYER_LOGIN", Vec::new());
s.resolve();
s.run("ShowUIPanel(WorldMapFrame)").unwrap();
// The zone's own id, found by the art rather than hardcoded: the catalog above assigns it.
s.run(
r#"for id = 1, 2000 do
local layers = C_Map.GetMapArtLayers(id)
if layers and table.getn(layers) > 0 then WorldMapFrame:SetMapID(id) break end
end"#,
)
.unwrap();
s.resolve();
s.set_world_map_feed(None, Some((0.25, 0.75)), 1.25, None, Vec::new(), Vec::new());
// Both centres in SCREEN pixels, which is the only space the two frames share: `GetCenter`
// answers in the frame's own units, and the whole bug is that those units differ.
let gap = |s: &mut UiScript| -> (f64, f64) {
s.eval::<(f64, f64)>(
r#"local px, py = WorldMapPlayer:GetCenter()
local pe = WorldMapPlayer:GetEffectiveScale()
for _, child in ipairs({ WorldMapFrame:GetChildren() }) do
if child:GetObjectType() == "Model" and child:IsShown() then
local ax, ay = child:GetCenter()
local ae = child:GetEffectiveScale()
return ax * ae - px * pe, ay * ae - py * pe
end
end
return 1e9, 1e9"#,
)
.unwrap()
};
for zoom in [1.0_f64, 1.5, 2.5, 4.0] {
// Centred on the player, so the pan clamp cannot park the view where the arrow is hidden.
s.run(&format!(
"WorldMapFrame.ScrollContainer:InstantPanAndZoom({zoom}, 0.25, 0.75)"
))
.unwrap();
update(&mut s);
s.resolve();
// The ladder runs 1..4, so each of these is reachable and the canvas really is scaled —
// and it also pins `InstantPanAndZoom`'s own rule that what the child draws at is what
// `GetCanvasScale` reports.
let applied = s
.eval::<f64>("return WorldMapFrame.ScrollContainer.Child:GetScale()")
.unwrap();
assert!(
(applied - zoom).abs() < 1e-3,
"zoom {zoom}: the canvas applied {applied}"
);
let (dx, dy) = gap(&mut s);
assert!(
dx.abs() < 1.0 && dy.abs() < 1.0,
"zoom {zoom}: the arrow is {dx:.1}, {dy:.1} screen px off the hit area"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Battleground teammates take the `WorldMapRaid` frames past the raid's own, named for the
/// tooltip; the flag carrier takes `WorldMapFlag1` in the token's texture; (0, 0) hides.
#[test]
@@ -362,6 +362,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
};
// None: the same parent, a total no-op (`0x76ab20`), not even a layout touch.
let Some(hidden) = hidden else { return Ok(()) };
watch_parent(lua, h, new_parent);
let was_visible = !hidden.is_empty();
event::fire_visibility_changes(lua, hidden);
let shown = {
@@ -383,6 +384,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let h = frame_handle_of(lua, &this)?;
let s = strata_from_str(&strata)
.ok_or_else(|| mlua::Error::runtime(format!("unknown frameStrata '{strata}'")))?;
watch_strata(lua, h, &strata);
lua.app_data_mut::<Model>()
.expect("model")
.arena
@@ -896,3 +898,74 @@ fn backdrop_color(lua: &Lua, args: mlua::MultiValue) -> [f32; 4] {
backdrop_channel(lua, a.get(3).cloned(), true),
]
}
/// `$WOW_WATCH_STRATA=<FrameName>[,<FrameName>…]`: log every `SetFrameStrata` on those frames with
/// the Lua site that made it.
///
/// A frame's stratum is written by name from anywhere, and with ninety-nine addons loaded "who
/// lowered this window" is not a question grep answers — a `SetFrameStrata` on a local that
/// happens to hold the frame looks like any other. 1.12's own `WorldMapFrame.xml:5` declares
/// `frameStrata="FULLSCREEN"`, and grom's install shows it at `MEDIUM`, which is how this came up:
/// *"the map needs to be on a higher level as I can see my unitframe over it"* (pfUI's unit frames
/// are `MEDIUM`, `pfUI/api/unitframes.lua:413`).
fn watched() -> &'static [String] {
static WATCH: std::sync::LazyLock<Vec<String>> = std::sync::LazyLock::new(|| {
std::env::var("WOW_WATCH_STRATA")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
.collect()
});
&WATCH
}
fn is_watched(name: &str) -> bool {
watched().contains(&name.to_ascii_lowercase())
}
fn watch_strata(lua: &Lua, h: crate::widget::FrameHandle, strata: &str) {
if watched().is_empty() {
return;
}
let name = {
let model = lua.app_data_ref::<Model>().expect("model");
model.arena.frame(h).and_then(|f| f.name.clone())
};
let Some(name) = name else { return };
if !is_watched(&name) {
return;
}
// After the borrow is released: `caller_site` calls back into Lua.
let site = crate::script::caller_site(lua);
eprintln!("[strata-watch] {name}:SetFrameStrata(\"{strata}\") <- {site}");
}
/// The other half of [`watch_strata`], because a reparent is the **other** way a frame's stratum
/// moves: `reparent_finish` forces the subtree into the new parent's stratum, so a `SetParent`
/// silently discards whatever the frame's own XML declared.
fn watch_parent(
lua: &Lua,
h: crate::widget::FrameHandle,
new_parent: Option<crate::widget::FrameHandle>,
) {
if watched().is_empty() {
return;
}
let (name, parent) = {
let model = lua.app_data_ref::<Model>().expect("model");
(
model.arena.frame(h).and_then(|f| f.name.clone()),
new_parent
.and_then(|p| model.arena.frame(p))
.and_then(|f| f.name.clone()),
)
};
let Some(name) = name else { return };
if !is_watched(&name) {
return;
}
let site = crate::script::caller_site(lua);
let parent = parent.unwrap_or_else(|| "<nil>".to_string());
eprintln!("[strata-watch] {name}:SetParent({parent}) <- {site}");
}