diff --git a/crates/benilla-app/assets/ui/MapCanvas.lua b/crates/benilla-app/assets/ui/MapCanvas.lua
index 9b392708..4af321d0 100644
--- a/crates/benilla-app/assets/ui/MapCanvas.lua
+++ b/crates/benilla-app/assets/ui/MapCanvas.lua
@@ -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)
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index e283e8e4..31dbb614 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -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")
+
+
+
+
+
+
+
+ OptionsRow_OnLoad(self, "mapFadeWhileMoving",
+ "Fade World Map While Moving",
+ "BENILLA_TOOLTIP_MAP_FADE_WHILE_MOVING")
+ OptionsSliderRow_Setup(self, 0, 90, 10, "pct")
+
+