From 082dd2ed0f908ba7938b5a197d4f7fe53f18951f Mon Sep 17 00:00:00 2001 From: grom Date: Thu, 5 Nov 2026 10:38:31 -0700 Subject: [PATCH] The map's arrow, its stratum, and a fade while you walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=` 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 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/assets/ui/MapCanvas.lua | 178 ++++++++++++++++++ crates/benilla-app/assets/ui/OptionsFrame.xml | 33 ++++ crates/benilla-app/src/capture/mod.rs | 16 ++ crates/benilla-app/src/cvars.rs | 18 ++ .../src/ui_script/options_tests.rs | 11 +- .../src/ui_script/world_map_tests.rs | 109 +++++++++++ .../src/script/object/frame_state.rs | 73 +++++++ 7 files changed, 436 insertions(+), 2 deletions(-) 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") + +