diff --git a/crates/benilla-app/assets/ui/MapCanvas.lua b/crates/benilla-app/assets/ui/MapCanvas.lua new file mode 100644 index 00000000..16a701db --- /dev/null +++ b/crates/benilla-app/assets/ui/MapCanvas.lua @@ -0,0 +1,1654 @@ +-- MapCanvas.lua — the Era map-canvas framework, bolted onto 1.12's own WorldMapFrame. +-- +-- WHY THIS FILE EXISTS, AND WHY IT IS NOT A MIGRATION +-- +-- 1.12's world map is a fixed 1002x668 frame showing one zone at a time: twelve detail tiles, a +-- hit-testing button over them, POI buttons placed in its coordinates, and two dropdowns. It has +-- no canvas, no pan, no zoom, no pins and no notion of where one map sits on another. Every map +-- addon written after 4.0 — a seamless world map, Mapster, TomTom's pin layer, Questie's map — +-- drives the *canvas* framework instead: a scaled, pannable child frame, pins acquired from pools +-- at named frame levels, and data providers notified when the map, the view or the scale changes. +-- There is no stock 1.12 file to migrate from, because 1.12 has no such thing. This is the same +-- footing as `CompatTemplates.xml`: a FrameXML-era name addons inherit and reach for, which this +-- client has to provide or the addon simply does not run. +-- +-- THE ONE CONSTRAINT THAT SHAPED EVERY DECISION HERE +-- +-- `WorldMapFrame` already has eight consumers on this install (Atlas-CFM, FlightMap, +-- LevelRange-Turtle, pfQuest, pfUI, pfUI-locplus, SoftSpoken, SoftSpoken_Places), and all eight +-- are 1.12 addons: they anchor their icons to `WorldMapDetailFrame` and `WorldMapButton` and read +-- the `(continent, zone)` selection. Replacing the window would break all eight. So nothing here +-- replaces anything: +-- +-- * The canvas is ADDED to `WorldMapFrame`, as `ScrollContainer` and `ScrollContainer.Child`. +-- * `WorldMapDetailFrame` is REPARENTED INTO the canvas and `SetAllPoints`ed to it. At rest the +-- canvas is exactly the rect the detail frame had, at scale 1 with no pan, so the stock map is +-- pixel-identical. The payoff is that the 1.12 art and every POI anchored to it now pan and +-- zoom WITH the canvas for free — a 1.12 quest icon stays on its quest while a canvas addon +-- moves the view, which no amount of per-addon work could achieve. +-- * `SetMapID` moves the 1.12 selection too (`BenillaSelectMapID`), so the stock window and the +-- canvas can never show different places. +-- +-- WHAT GIVES US CLIPPING +-- +-- Era's `ScrollContainer` is a plain Frame with `SetClipsChildren(true)`, which this client has +-- no verb for. Ours is a real `ScrollFrame` with the canvas as its scroll child, because this +-- client clips a ScrollFrame's scroll child and every descendant of it to the ScrollFrame's rect +-- — in the draw pass and in the pointer hit-test, from one shared source (`script/clip.rs`), so +-- drawing and hitting agree. The pan is the scroll offset, which is what a ScrollFrame's offsets +-- already mean; the zoom is `Child:SetScale`. +-- +-- DIALECT +-- +-- 1.12 Lua 5.0: `table.getn` rather than `#`, `arg`/`unpack(arg)` rather than `...` as an +-- expression, `string.format` reachable as `format`. Written the way the rest of this chain is. + +-- The world sheet, Era's own id for Azeroth, which `C_Map.GetFallbackWorldMapID` answers. +local WORLD_MAP_ID = C_Map.GetFallbackWorldMapID() + +-- Zoom behaviour constants Era publishes and addons read off the container. +MAP_CANVAS_MOUSE_WHEEL_ZOOM_BEHAVIOR_FULL = 1 +MAP_CANVAS_MOUSE_WHEEL_ZOOM_BEHAVIOR_SMOOTH = 2 + +-- How close to the target the smooth pan/zoom has to be before it snaps and stops. Era's own +-- values; small enough that the snap is invisible and large enough that the OnUpdate stops. +local PAN_SNAP = 0.0005 +local SCALE_SNAP = 0.001 +-- How fast the view approaches its target, per second, as a fraction closed. +local PAN_RATE = 10 +local SCALE_RATE = 10 +-- A drag under this many pixels is still a click, which is what `WouldCursorPositionBeClick` +-- answers; Era's own threshold. +local CLICK_SLOP = 5 + +-------------------------------------------------------------------------------- +-- PinFrameLevelsManager +-- +-- Pins draw in named bands rather than at raw frame levels, so an addon can insert its own band +-- relative to Blizzard's by name and get a range of levels inside it. Era's structure exactly, +-- because the names are the contract: `InsertFrameLevelAbove("PIN_FRAME_LEVEL_OPENMAP_STITCH", +-- "PIN_FRAME_LEVEL_MAP_EXPLORATION", n)` has to mean what it means upstream or the art lands in +-- the wrong band. +-------------------------------------------------------------------------------- + +PinFrameLevelsManagerMixin = {} + +function PinFrameLevelsManagerMixin:Initialize() + self.frameLevelRanges = {} + self.ordered = {} + self.currentFrameLevels = {} +end + +-- The base level every band is offset from: above the canvas itself, so a pin never hides behind +-- the art it sits on. +local PIN_FRAME_LEVEL_BASE = 100 + +function PinFrameLevelsManagerMixin:Reset() + self.currentFrameLevels = {} + for i = 1, table.getn(self.ordered) do + self.currentFrameLevels[self.ordered[i]] = 0 + end +end + +-- Append a band with `count` levels in it; the first call for a name wins, as upstream's does. +function PinFrameLevelsManagerMixin:InsertFrameLevel(frameLevelType, count) + if self.frameLevelRanges[frameLevelType] then + return false + end + local base = PIN_FRAME_LEVEL_BASE + local n = table.getn(self.ordered) + if n > 0 then + local last = self.frameLevelRanges[self.ordered[n]] + base = last.base + last.count + end + self.frameLevelRanges[frameLevelType] = { base = base, count = count or 1 } + self.ordered[n + 1] = frameLevelType + self.currentFrameLevels[frameLevelType] = 0 + return true +end + +-- Insert a band immediately above a named one, shifting every band above it up. False when the +-- reference band does not exist, which is the answer a caller branches on: OpenMapClassic prints +-- "Blizzard's map layers aren't the expected ones" and carries on. +function PinFrameLevelsManagerMixin:InsertFrameLevelAbove(frameLevelType, aboveType, count) + if self.frameLevelRanges[frameLevelType] or not self.frameLevelRanges[aboveType] then + return false + end + count = count or 1 + local at + for i = 1, table.getn(self.ordered) do + if self.ordered[i] == aboveType then + at = i + break + end + end + if not at then + return false + end + table.insert(self.ordered, at + 1, frameLevelType) + -- Re-base every band from the bottom, so the ranges stay contiguous and ordered. + local base = PIN_FRAME_LEVEL_BASE + for i = 1, table.getn(self.ordered) do + local name = self.ordered[i] + local range = self.frameLevelRanges[name] + if not range then + range = { count = count } + self.frameLevelRanges[name] = range + self.currentFrameLevels[name] = 0 + end + range.base = base + base = base + range.count + end + return true +end + +function PinFrameLevelsManagerMixin:InsertFrameLevelBelow(frameLevelType, belowType, count) + if not self.frameLevelRanges[belowType] then + return false + end + -- Below `belowType` is above whatever is under it; with nothing under it, the bottom. + local at + for i = 1, table.getn(self.ordered) do + if self.ordered[i] == belowType then + at = i + break + end + end + if not at then + return false + end + if at == 1 then + if self.frameLevelRanges[frameLevelType] then + return false + end + table.insert(self.ordered, 1, frameLevelType) + self.frameLevelRanges[frameLevelType] = { base = 0, count = count or 1 } + self.currentFrameLevels[frameLevelType] = 0 + local base = PIN_FRAME_LEVEL_BASE + for i = 1, table.getn(self.ordered) do + local range = self.frameLevelRanges[self.ordered[i]] + range.base = base + base = base + range.count + end + return true + end + return self:InsertFrameLevelAbove(frameLevelType, self.ordered[at - 1], count) +end + +-- The next free level inside a band, which is what a pin asks for as it is acquired. A band that +-- runs out keeps handing back its top level rather than spilling into the next band's. +function PinFrameLevelsManagerMixin:GetValidFrameLevel(frameLevelType) + local range = self.frameLevelRanges[frameLevelType] + if not range then + return PIN_FRAME_LEVEL_BASE + end + local used = self.currentFrameLevels[frameLevelType] or 0 + if used < range.count then + self.currentFrameLevels[frameLevelType] = used + 1 + else + used = range.count - 1 + end + return range.base + used +end + +function PinFrameLevelsManagerMixin:GetFrameLevelRange(frameLevelType) + local range = self.frameLevelRanges[frameLevelType] + if not range then + return nil + end + return range.base, range.base + range.count - 1 +end + +local function CreatePinFrameLevelsManager() + local manager = {} + for k, v in pairs(PinFrameLevelsManagerMixin) do + manager[k] = v + end + manager:Initialize() + return manager +end + +-------------------------------------------------------------------------------- +-- MapCanvasDataProviderMixin — the base every data provider is built from. +-- +-- Every member is a no-op, deliberately: upstream's is too, and that is the whole contract. A +-- provider overrides the two or three hooks it cares about and inherits silence for the rest, so +-- adding a hook here can never break an existing provider. +-------------------------------------------------------------------------------- + +MapCanvasDataProviderMixin = {} + +function MapCanvasDataProviderMixin:OnAdded(mapCanvas) + self.owningMap = mapCanvas +end + +function MapCanvasDataProviderMixin:OnRemoved(mapCanvas) + self.owningMap = nil +end + +function MapCanvasDataProviderMixin:GetMap() + return self.owningMap +end + +function MapCanvasDataProviderMixin:RemoveAllData() end +function MapCanvasDataProviderMixin:RefreshAllData(fromOnShow) end +function MapCanvasDataProviderMixin:OnShow() end +function MapCanvasDataProviderMixin:OnHide() end +function MapCanvasDataProviderMixin:OnEvent(event, arg1, arg2, arg3) end +function MapCanvasDataProviderMixin:OnMapChanged() end +function MapCanvasDataProviderMixin:OnMapInsetSizeChanged(insetIndex, expanded) end +function MapCanvasDataProviderMixin:OnCanvasScaleChanged() end +function MapCanvasDataProviderMixin:OnCanvasPanChanged() end +function MapCanvasDataProviderMixin:OnCanvasSizeChanged() end +function MapCanvasDataProviderMixin:OnGlobalAlphaChanged() end + +-------------------------------------------------------------------------------- +-- MapCanvasPinMixin — the base every pin is built from. +-- +-- A pin is a frame on the canvas placed by a normalized position rather than by anchors, so it +-- stays on its place as the canvas pans and scales. `ApplyCurrentPosition` is the one that does +-- the work and is called on every pan and scale change. +-------------------------------------------------------------------------------- + +MapCanvasPinMixin = {} + +function MapCanvasPinMixin:OnLoad() + self.numActiveAnimations = 0 + self.scaleFactor = 1 + self.startScale = 1 + self.endScale = 1 + self.normalizedX = 0.5 + self.normalizedY = 0.5 +end + +function MapCanvasPinMixin:GetMap() + return self.owningMap +end + +function MapCanvasPinMixin:OnAcquired() end +function MapCanvasPinMixin:OnReleased() end +function MapCanvasPinMixin:OnClick(button) end +function MapCanvasPinMixin:OnMouseEnter() end +function MapCanvasPinMixin:OnMouseLeave() end +function MapCanvasPinMixin:OnMapInsetSizeChanged(insetIndex, expanded) end +function MapCanvasPinMixin:OnCanvasScaleChanged() + self:ApplyCurrentScale() +end +function MapCanvasPinMixin:OnCanvasPanChanged() end +function MapCanvasPinMixin:OnCanvasSizeChanged() end + +function MapCanvasPinMixin:SetPosition(normalizedX, normalizedY, insetIndex) + self.normalizedX = normalizedX + self.normalizedY = normalizedY + self.insetIndex = insetIndex + self:ApplyCurrentPosition() +end + +function MapCanvasPinMixin:GetPosition() + return self.normalizedX, self.normalizedY +end + +function MapCanvasPinMixin:ApplyCurrentPosition() + local map = self:GetMap() + if not map then + return + end + local canvas = map:GetCanvas() + if not canvas then + return + end + self:ClearAllPoints() + self:SetPoint("CENTER", canvas, "TOPLEFT", + (self.normalizedX or 0.5) * canvas:GetWidth(), + -(self.normalizedY or 0.5) * canvas:GetHeight()) +end + +-- A pin's own scale, held constant on screen as the canvas scales: a quest icon should stay the +-- same size however far the map is zoomed out, which is `1 / canvasScale` times whatever the pin +-- asked for. `scaleFactor` 0 opts out and lets the pin scale with the map, which is what an area +-- outline (a "blob") wants. +function MapCanvasPinMixin:SetScalingLimits(scaleFactor, startScale, endScale) + self.scaleFactor = scaleFactor + self.startScale = startScale + self.endScale = endScale +end + +function MapCanvasPinMixin:ApplyCurrentScale() + local map = self:GetMap() + if not map then + return + end + local factor = self.scaleFactor + if not factor or factor == 0 then + self:SetScale(1) + return + end + local canvasScale = map:GetCanvasScale() + if not canvasScale or canvasScale <= 0 then + return + end + local scale = factor / canvasScale + local lo = self.startScale + local hi = self.endScale + if lo and hi and lo > hi then + lo, hi = hi, lo + end + if lo and scale < lo then + scale = lo + end + if hi and scale > hi then + scale = hi + end + self:SetScale(scale) +end + +function MapCanvasPinMixin:UseFrameLevelType(pinFrameLevelType, index) + self.pinFrameLevelType = pinFrameLevelType + self.pinFrameLevelIndex = index +end + +function MapCanvasPinMixin:GetFrameLevelType() + return self.pinFrameLevelType +end + +function MapCanvasPinMixin:ApplyFrameLevel() + local map = self:GetMap() + if not map or not self.pinFrameLevelType then + return + end + local manager = map:GetPinFrameLevelsManager() + if manager then + self:SetFrameLevel(manager:GetValidFrameLevel(self.pinFrameLevelType)) + end +end + +-- Which mouse buttons fall through to the canvas underneath. Era's pins declare this so a +-- right-drag over an icon still pans the map; a pin with no declaration swallows everything, +-- which is what a clickable icon wants. +function MapCanvasPinMixin:SetPassThroughButtons(button1, button2, button3) + self.passThroughButtons = { button1, button2, button3 } +end + +function MapCanvasPinMixin:CheckMouseButtonPassthrough(button) + local list = self.passThroughButtons + if not list then + return false + end + for i = 1, table.getn(list) do + if list[i] == button then + return true + end + end + return false +end + +-------------------------------------------------------------------------------- +-- MapCanvasScrollControllerMixin — the pan and zoom. +-- +-- The view is held as a NORMALIZED CENTRE (`currentScrollX`, `currentScrollY`: the canvas point +-- under the middle of the container, 0..1 across the canvas) plus a scale. That is upstream's own +-- representation, and it has to be, because a canvas addon reads and writes those fields directly +-- — OpenMapClassic's `SetView` assigns `currentScrollX`, `targetScrollX`, `currentScale`, +-- `targetScale` and `zoomTarget` by hand (`Map/WorldMap.lua:184-191`) and then calls +-- `SetNormalizedHorizontalScroll`. So the field names and their meanings are the contract, not an +-- implementation detail. +-- +-- The pan is applied as the ScrollFrame's own scroll offset, which is what a ScrollFrame offset +-- already means and is the cheapest write this client has (`SetVerticalScroll` re-anchors one +-- node and skips the layout touch). +-------------------------------------------------------------------------------- + +MapCanvasScrollControllerMixin = {} + +function MapCanvasScrollControllerMixin:OnLoad() + self.currentScrollX = 0.5 + self.currentScrollY = 0.5 + self.targetScrollX = 0.5 + self.targetScrollY = 0.5 + self.currentScale = 1 + self.targetScale = 1 + self.zoomTarget = 1 + self.baseScale = 1 + self.zoomLevels = {} + self.scrollXExtentsMin = 0.5 + self.scrollXExtentsMax = 0.5 + self.scrollYExtentsMin = 0.5 + self.scrollYExtentsMax = 0.5 + self.mouseButtonInfo = { + LeftButton = { down = false, x = 0, y = 0 }, + RightButton = { down = false, x = 0, y = 0 }, + MiddleButton = { down = false, x = 0, y = 0 }, + } + self.scalingMode = "SCALING_MODE_TRANSLATE_FASTER_THAN_SCALE" + self.mouseWheelZoomMode = MAP_CANVAS_MOUSE_WHEEL_ZOOM_BEHAVIOR_FULL + self.panning = false + self.viewRectDirty = true + self.areaTriggersDirty = true +end + +function MapCanvasScrollControllerMixin:GetCanvasContainer() + return self +end + +function MapCanvasScrollControllerMixin:GetCanvas() + return self.Child +end + +function MapCanvasScrollControllerMixin:GetCanvasScale() + return self.currentScale or 1 +end + +function MapCanvasScrollControllerMixin:SetScalingMode(mode) + self.scalingMode = mode +end + +function MapCanvasScrollControllerMixin:SetMouseWheelZoomMode(mode) + self.mouseWheelZoomMode = mode +end + +function MapCanvasScrollControllerMixin:MarkViewRectDirty() + self.viewRectDirty = true +end + +function MapCanvasScrollControllerMixin:MarkAreaTriggersDirty() + self.areaTriggersDirty = true +end + +-- Canvas px to a fraction of the canvas, the two helpers every extents computation goes through. +function MapCanvasScrollControllerMixin:NormalizeHorizontalSize(size) + local w = self.Child and self.Child:GetWidth() + if not w or w <= 0 then + return 0 + end + return size / w +end + +function MapCanvasScrollControllerMixin:NormalizeVerticalSize(size) + local h = self.Child and self.Child:GetHeight() + if not h or h <= 0 then + return 0 + end + return size / h +end + +function MapCanvasScrollControllerMixin:DenormalizeHorizontalSize(size) + local w = self.Child and self.Child:GetWidth() + return size * (w or 0) +end + +function MapCanvasScrollControllerMixin:DenormalizeVerticalSize(size) + local h = self.Child and self.Child:GetHeight() + return size * (h or 0) +end + +-- The normalized centre may range over this, so the view stays inside the canvas. When the canvas +-- is smaller than the container on an axis the range collapses to its middle, which centres it. +function MapCanvasScrollControllerMixin:CalculateScrollExtentsAtScale(scale) + local halfW = self:NormalizeHorizontalSize(self:GetWidth() * 0.5 / scale) + local halfH = self:NormalizeVerticalSize(self:GetHeight() * 0.5 / scale) + local minX, maxX = halfW, 1 - halfW + local minY, maxY = halfH, 1 - halfH + if minX > maxX then + minX, maxX = 0.5, 0.5 + end + if minY > maxY then + minY, maxY = 0.5, 0.5 + end + return minX, maxX, minY, maxY +end + +-- Through `CalculateScrollExtentsAtScale`, never duplicating its arithmetic: a canvas addon +-- replaces that one method to widen the pan (OpenMapClassic pans the whole world, not just the +-- map in view), and a second copy of the law here would ignore the replacement. +function MapCanvasScrollControllerMixin:CalculateScrollExtents() + self.scrollXExtentsMin, self.scrollXExtentsMax, self.scrollYExtentsMin, self.scrollYExtentsMax = + self:CalculateScrollExtentsAtScale(self:GetCanvasScale()) +end + +local function clamp(v, lo, hi) + if lo and v < lo then + return lo + end + if hi and v > hi then + return hi + end + return v +end + +function MapCanvasScrollControllerMixin:ClampScrollToExtents(x, y) + return clamp(x, self.scrollXExtentsMin, self.scrollXExtentsMax), + clamp(y, self.scrollYExtentsMin, self.scrollYExtentsMax) +end + +function MapCanvasScrollControllerMixin:SetNormalizedHorizontalScroll(x) + self.currentScrollX = x + self:ApplyCurrentScroll() + self:MarkViewRectDirty() +end + +function MapCanvasScrollControllerMixin:SetNormalizedVerticalScroll(y) + self.currentScrollY = y + self:ApplyCurrentScroll() + self:MarkViewRectDirty() +end + +function MapCanvasScrollControllerMixin:GetCurrentScrollX() + return self.currentScrollX +end + +function MapCanvasScrollControllerMixin:GetCurrentScrollY() + return self.currentScrollY +end + +function MapCanvasScrollControllerMixin:GetNormalizedHorizontalScroll() + return self.currentScrollX +end + +function MapCanvasScrollControllerMixin:GetNormalizedVerticalScroll() + return self.currentScrollY +end + +-- The whole view at once, no easing: the canvas scaled, the extents recomputed at that scale, and +-- the centre clamped into them. +function MapCanvasScrollControllerMixin:InstantPanAndZoom(scale, x, y) + self.currentScale = scale + self.targetScale = scale + self.zoomTarget = scale + if self.Child then + self.Child:SetScale(scale) + end + self:CalculateScrollExtents() + x, y = self:ClampScrollToExtents(x, y) + self.currentScrollX, self.targetScrollX = x, x + self.currentScrollY, self.targetScrollY = y, y + self:ApplyCurrentScroll() + self:MarkViewRectDirty() + self:MarkAreaTriggersDirty() + local map = self:GetMapCanvas() + if map then + map:OnCanvasScaleChanged() + map:OnCanvasPanChanged() + end +end + +function MapCanvasScrollControllerMixin:GetMapCanvas() + return self.mapCanvas +end + +-- The zoom steps, derived from the map's own art layers: one step per layer, at that layer's +-- minimum scale, plus a fitted step so the whole map is reachable. A canvas addon replaces this +-- wholesale (OpenMapClassic builds a geometric ladder from its own zoom option), which is why it +-- is one method and why `ResetZoom` reads `zoomLevels` rather than recomputing. +function MapCanvasScrollControllerMixin:CreateZoomLevels() + local mapID = self.mapID + local levels = {} + if mapID then + local layers = C_Map.GetMapArtLayers(mapID) + local layer = layers and layers[1] + if layer then + -- A geometric ladder from the layer's own bounds. Scale 1 is the whole map in the + -- container by construction — the canvas is sized to the layer and the container is the + -- rect that layer used to fill — so a `minScale` below 1 would open the map zoomed + -- *out* of its own window, which is how this was first wrong. + local lo = layer.minScale or 1 + local hi = layer.maxScale or 1 + if lo > 1 then + lo = 1 + end + if hi < lo then + hi = lo + end + local steps = (layer.additionalZoomSteps or 0) + 1 + if steps < 1 then + steps = 1 + end + local ratio = 1 + if steps > 1 and lo > 0 then + ratio = math.pow(hi / lo, 1 / (steps - 1)) + end + local scale = lo + for i = 1, steps do + levels[i] = { scale = scale, layerIndex = 1 } + scale = scale * ratio + end + end + end + if table.getn(levels) == 0 then + levels[1] = { scale = 1, layerIndex = 1 } + end + self.zoomLevels = levels + self.baseScale = levels[1].scale + -- The scale itself is only seeded when there is none: `CreateZoomLevels` is called again on + -- every map change and on every container resize, and a view the player has zoomed must not + -- snap back because the window was dragged. + if not self.currentScale then + self.currentScale = levels[1].scale + self.targetScale = levels[1].scale + self.zoomTarget = levels[1].scale + end +end + +function MapCanvasScrollControllerMixin:GetScaleForMaxZoom() + local n = table.getn(self.zoomLevels) + if n == 0 then + return 1 + end + return self.zoomLevels[n].scale +end + +function MapCanvasScrollControllerMixin:GetScaleForMinZoom() + if table.getn(self.zoomLevels) == 0 then + return 1 + end + return self.zoomLevels[1].scale +end + +function MapCanvasScrollControllerMixin:IsAtMaxZoom() + return self:GetCanvasScale() >= self:GetScaleForMaxZoom() +end + +function MapCanvasScrollControllerMixin:IsAtMinZoom() + return self:GetCanvasScale() <= self:GetScaleForMinZoom() +end + +function MapCanvasScrollControllerMixin:IsZoomingIn() + return (self.targetScale or 1) > (self.currentScale or 1) +end + +function MapCanvasScrollControllerMixin:IsZoomingOut() + return (self.targetScale or 1) < (self.currentScale or 1) +end + +function MapCanvasScrollControllerMixin:IsZooming() + return self:IsZoomingIn() or self:IsZoomingOut() +end + +function MapCanvasScrollControllerMixin:IsGamepadZooming() + return self.gamepadZoom and self.gamepadZoom ~= 0 +end + +function MapCanvasScrollControllerMixin:HasGamepadScrollSpeed() + return self.gamepadZoomSpeed ~= nil +end + +-- The next step up or down the ladder, toward a point if one is given. +function MapCanvasScrollControllerMixin:SetZoomTarget(scale, x, y) + self.zoomTarget = scale + self.targetScale = scale + if x and y then + self.targetScrollX, self.targetScrollY = x, y + end +end + +function MapCanvasScrollControllerMixin:ZoomIn() + local n = table.getn(self.zoomLevels) + local current = self:GetCanvasScale() + for i = 1, n do + if self.zoomLevels[i].scale > current + 0.0001 then + self:SetZoomTarget(self.zoomLevels[i].scale) + return + end + end +end + +function MapCanvasScrollControllerMixin:ZoomOut() + local current = self:GetCanvasScale() + for i = table.getn(self.zoomLevels), 1, -1 do + if self.zoomLevels[i].scale < current - 0.0001 then + self:SetZoomTarget(self.zoomLevels[i].scale) + return + end + end +end + +function MapCanvasScrollControllerMixin:ResetZoom() + local scale = self:GetScaleForMinZoom() + self:InstantPanAndZoom(scale, 0.5, 0.5) +end + +-- Called when the container or the canvas changes size, and on the first show: the canvas is +-- sized from the map's own art layer, the zoom ladder rebuilt and the view reset. +function MapCanvasScrollControllerMixin:OnCanvasSizeChanged() + local child = self.Child + if not child then + return + end + local mapID = self.mapID + local w, h + if mapID then + local layers = C_Map.GetMapArtLayers(mapID) + local layer = layers and layers[1] + if layer then + w, h = layer.layerWidth, layer.layerHeight + end + end + -- With no art layer (an instance map, a map the realm has no folder for) the canvas keeps the + -- container's own size, so pins still place and the view is simply not zoomable. + if not w or w <= 0 or not h or h <= 0 then + w, h = self:GetWidth(), self:GetHeight() + end + child:SetWidth(w) + child:SetHeight(h) + self:CreateZoomLevels() + self:CalculateScrollExtents() + self.currentScrollX, self.currentScrollY = self:ClampScrollToExtents( + self.currentScrollX or 0.5, self.currentScrollY or 0.5) + self.targetScrollX, self.targetScrollY = self.currentScrollX, self.currentScrollY + child:SetScale(self:GetCanvasScale()) + self:ApplyCurrentScroll() + self:MarkViewRectDirty() + local map = self:GetMapCanvas() + if map then + map:OnCanvasSizeChanged() + end +end + +-- The cursor in canvas-normalized coordinates, which is what a click handler is handed. +function MapCanvasScrollControllerMixin:GetCursorPosition() + local x, y = GetCursorPosition() + local effective = self:GetEffectiveScale() + if not effective or effective == 0 then + return 0.5, 0.5 + end + return x / effective, y / effective +end + +function MapCanvasScrollControllerMixin:GetNormalizedCursorPosition() + local cx, cy = self:GetCursorPosition() + local left, bottom = self:GetLeft(), self:GetBottom() + local w, h = self:GetWidth(), self:GetHeight() + if not left or not w or w <= 0 or h <= 0 then + return 0.5, 0.5 + end + -- Inside the container first, then through the view onto the canvas: the container's centre is + -- the normalized scroll, and one container width spans `containerW / (canvasW * scale)` of it. + local fx = (cx - left) / w + local fy = 1 - (cy - bottom) / h + local spanX = self:NormalizeHorizontalSize(w / self:GetCanvasScale()) + local spanY = self:NormalizeVerticalSize(h / self:GetCanvasScale()) + return (self.currentScrollX or 0.5) + (fx - 0.5) * spanX, + (self.currentScrollY or 0.5) + (fy - 0.5) * spanY +end + +function MapCanvasScrollControllerMixin:WouldCursorPositionBeClick(button, x, y) + local info = self.mouseButtonInfo and self.mouseButtonInfo[button] + if not info or not info.down then + return false + end + local dx = (x or 0) - (info.x or 0) + local dy = (y or 0) - (info.y or 0) + return dx * dx + dy * dy <= CLICK_SLOP * CLICK_SLOP +end + +function MapCanvasScrollControllerMixin:OnMouseDown(button) + local info = self.mouseButtonInfo and self.mouseButtonInfo[button] + if not info then + return + end + local x, y = self:GetCursorPosition() + info.down = true + info.x, info.y = x, y + if button == "LeftButton" then + self.panning = true + self.panStartX, self.panStartY = x, y + self.panStartScrollX = self.currentScrollX + self.panStartScrollY = self.currentScrollY + end +end + +function MapCanvasScrollControllerMixin:OnMouseUp(button) + local info = self.mouseButtonInfo and self.mouseButtonInfo[button] + if not info then + return + end + local x, y = self:GetCursorPosition() + local wasClick = self:WouldCursorPositionBeClick(button, x, y) + info.down = false + if button == "LeftButton" then + self.panning = false + end + -- Recorded, not just returned: the stock `WorldMapButton`'s own mouse-up is the 1.12 + -- click-to-zoom, and it must not run when a canvas handler took the click or when the gesture + -- was a drag. The wrapper in `BuildCanvas` reads this. + self.lastClickConsumed = true + local map = self:GetMapCanvas() + if wasClick and map then + local x, y = self:GetNormalizedCursorPosition() + self.lastClickConsumed = map:ProcessCanvasClickHandlers(button, x, y) and true or false + end +end + +function MapCanvasScrollControllerMixin:OnMouseWheel(delta) + if delta > 0 then + self:ZoomIn() + else + self:ZoomOut() + end +end + +-- The drag, and the ease toward the pan and zoom targets. One place, so a drag during a zoom +-- keeps working, and it stops writing once both have arrived. +function MapCanvasScrollControllerMixin:OnUpdate(elapsed) + local moved = false + if self.panning then + local x, y = self:GetCursorPosition() + local scale = self:GetCanvasScale() + if scale > 0 then + local dx = self:NormalizeHorizontalSize((self.panStartX - x) / scale) + local dy = self:NormalizeVerticalSize((y - self.panStartY) / scale) + local nx, ny = self:ClampScrollToExtents( + (self.panStartScrollX or 0.5) + dx, (self.panStartScrollY or 0.5) + dy) + if nx ~= self.currentScrollX or ny ~= self.currentScrollY then + self.currentScrollX, self.currentScrollY = nx, ny + self.targetScrollX, self.targetScrollY = nx, ny + moved = true + end + end + else + local rate = 1 - math.pow(0.5, (elapsed or 0) * PAN_RATE / 7) + local tx, ty = self.targetScrollX or 0.5, self.targetScrollY or 0.5 + local cx, cy = self.currentScrollX or 0.5, self.currentScrollY or 0.5 + if math.abs(tx - cx) > PAN_SNAP or math.abs(ty - cy) > PAN_SNAP then + self.currentScrollX = cx + (tx - cx) * rate + self.currentScrollY = cy + (ty - cy) * rate + moved = true + elseif cx ~= tx or cy ~= ty then + self.currentScrollX, self.currentScrollY = tx, ty + moved = true + end + end + + local scaled = false + local ts, cs = self.targetScale or 1, self.currentScale or 1 + if math.abs(ts - cs) > SCALE_SNAP then + local rate = 1 - math.pow(0.5, (elapsed or 0) * SCALE_RATE / 7) + self.currentScale = cs + (ts - cs) * rate + scaled = true + elseif cs ~= ts then + self.currentScale = ts + scaled = true + end + + if scaled then + if self.Child then + self.Child:SetScale(self.currentScale) + end + self:CalculateScrollExtents() + self.currentScrollX, self.currentScrollY = self:ClampScrollToExtents( + self.currentScrollX, self.currentScrollY) + moved = true + end + if moved then + self:ApplyCurrentScroll() + self:MarkViewRectDirty() + end + local map = self:GetMapCanvas() + if map then + if scaled then + map:OnCanvasScaleChanged() + end + if moved then + map:OnCanvasPanChanged() + end + end +end + +-------------------------------------------------------------------------------- +-- MapCanvasMixin — the map itself. +-------------------------------------------------------------------------------- + +MapCanvasMixin = {} + +-- What a global pin mouse-action handler is told happened. The values are opaque to a caller, +-- which compares against these names (`MapCanvasMixin.MouseAction.Click`), so only the identity +-- matters. +MapCanvasMixin.MouseAction = { Click = 1, Up = 2, Down = 3 } + +function MapCanvasMixin:OnLoad() + -- A SET, keyed by the provider with `true` as the value, because that is upstream's shape and + -- callers iterate it: `for provider in pairs(map.dataProviders) do` binds the *key*, so an + -- array here hands the loop the number 1 and the next line indexes a number. Measured: it + -- raised at `Map/Extras.lua:281`, inside the addon's own `OnEnable`, which caught it, printed + -- it and left the whole addon disabled — the same trap `pools.rs` names for `activeObjects`, + -- where a "tidier" array would break `for icon in pool:EnumerateActive()` silently. + -- + -- `dataProviderOrder` is ours, beside it: `pairs` order is undefined, and a map whose + -- providers are notified in a different order each session is a bug nobody can reproduce. So + -- the set is the shape addons read and the list is the order we walk. + self.dataProviders = {} + self.dataProviderOrder = {} + self.dataProviderEventsCount = {} + self.pinPools = {} + self.pinTemplateTypes = {} + self.activePins = {} + self.canvasClickHandlers = {} + self.globalPinMouseActionHandlers = {} + self.cursorHandlers = {} + self.pinFrameLevelsManager = CreatePinFrameLevelsManager() + self.mapID = nil + self.globalPinScale = 1 +end + +function MapCanvasMixin:GetMapID() + return self.mapID +end + +function MapCanvasMixin:GetCanvas() + return self.ScrollContainer and self.ScrollContainer.Child +end + +function MapCanvasMixin:GetCanvasContainer() + return self.ScrollContainer +end + +function MapCanvasMixin:GetCanvasScale() + return self.ScrollContainer and self.ScrollContainer:GetCanvasScale() or 1 +end + +function MapCanvasMixin:GetGlobalPinScale() + return self.globalPinScale or 1 +end + +function MapCanvasMixin:SetGlobalPinScale(scale) + self.globalPinScale = scale + self:OnCanvasScaleChanged() +end + +function MapCanvasMixin:GetPinFrameLevelsManager() + return self.pinFrameLevelsManager +end + +-- The view in canvas-normalized coordinates: left, right, top, bottom. +function MapCanvasMixin:GetViewRect() + local sc = self.ScrollContainer + if not sc then + return 0, 1, 0, 1 + end + local scale = sc:GetCanvasScale() + if scale <= 0 then + return 0, 1, 0, 1 + end + local halfW = sc:NormalizeHorizontalSize(sc:GetWidth() * 0.5 / scale) + local halfH = sc:NormalizeVerticalSize(sc:GetHeight() * 0.5 / scale) + local x, y = sc.currentScrollX or 0.5, sc.currentScrollY or 0.5 + return x - halfW, x + halfW, y - halfH, y + halfH +end + +function MapCanvasMixin:NormalizeHorizontalSize(size) + return self.ScrollContainer and self.ScrollContainer:NormalizeHorizontalSize(size) or 0 +end + +function MapCanvasMixin:NormalizeVerticalSize(size) + return self.ScrollContainer and self.ScrollContainer:NormalizeVerticalSize(size) or 0 +end + +function MapCanvasMixin:SetPinNudgingDirty(dirty) + self.pinNudgingDirty = dirty +end + +-------------------------------------------------------------------------------- +-- Data providers +-------------------------------------------------------------------------------- + +function MapCanvasMixin:AddDataProvider(dataProvider) + if not dataProvider or self.dataProviders[dataProvider] then + return + end + self.dataProviders[dataProvider] = true + self.dataProviderOrder[table.getn(self.dataProviderOrder) + 1] = dataProvider + if dataProvider.OnAdded then + dataProvider:OnAdded(self) + end + if self:IsShown() and dataProvider.RefreshAllData then + dataProvider:RefreshAllData(false) + end +end + +function MapCanvasMixin:RemoveDataProvider(dataProvider) + if not dataProvider or not self.dataProviders[dataProvider] then + return + end + self.dataProviders[dataProvider] = nil + for i = 1, table.getn(self.dataProviderOrder) do + if self.dataProviderOrder[i] == dataProvider then + table.remove(self.dataProviderOrder, i) + break + end + end + if dataProvider.RemoveAllData then + dataProvider:RemoveAllData() + end + if dataProvider.OnRemoved then + dataProvider:OnRemoved(self) + end +end + +function MapCanvasMixin:RemoveAllDataProviders() + while table.getn(self.dataProviderOrder) > 0 do + self:RemoveDataProvider(self.dataProviderOrder[1]) + end +end + +-- One walk, one guard: a provider that raises must not stop the providers after it, because a +-- third-party provider is ordinary here and a half-notified map draws a half-built view. +function MapCanvasMixin:CallDataProviders(method, a, b, c) + local providers = self.dataProviderOrder + for i = 1, table.getn(providers) do + local provider = providers[i] + local fn = provider[method] + if fn then + local ok, err = pcall(fn, provider, a, b, c) + if not ok then + geterrorhandler()(err) + end + end + end +end + +function MapCanvasMixin:RefreshAllDataProviders(fromOnShow) + self:CallDataProviders("RefreshAllData", fromOnShow) +end + +function MapCanvasMixin:OnMapChanged() + self:CallDataProviders("OnMapChanged") +end + +function MapCanvasMixin:OnCanvasScaleChanged() + local pins = self.activePins + for i = 1, table.getn(pins) do + local pin = pins[i] + if pin.OnCanvasScaleChanged then + pin:OnCanvasScaleChanged() + end + end + self:CallDataProviders("OnCanvasScaleChanged") +end + +function MapCanvasMixin:OnCanvasPanChanged() + local pins = self.activePins + for i = 1, table.getn(pins) do + local pin = pins[i] + if pin.OnCanvasPanChanged then + pin:OnCanvasPanChanged() + end + end + self:CallDataProviders("OnCanvasPanChanged") +end + +function MapCanvasMixin:OnCanvasSizeChanged() + local pins = self.activePins + for i = 1, table.getn(pins) do + local pin = pins[i] + if pin.ApplyCurrentPosition then + pin:ApplyCurrentPosition() + end + end + self:CallDataProviders("OnCanvasSizeChanged") +end + +-------------------------------------------------------------------------------- +-- Pins +-------------------------------------------------------------------------------- + +function MapCanvasMixin:GetPinPool(pinTemplate) + local pool = self.pinPools[pinTemplate] + if not pool then + local frameType = self.pinTemplateTypes[pinTemplate] or "Frame" + pool = CreateFramePool(frameType, self:GetCanvas(), pinTemplate, function(_, pin) + pin:Hide() + pin:ClearAllPoints() + if pin.OnReleased then + pin:OnReleased() + end + end) + self.pinPools[pinTemplate] = pool + end + return pool +end + +-- Declare the frame type a template needs, for the templates that are not plain Frames. Era's own +-- `SetPinTemplateType`; without it a Button-shaped pin would be created as a Frame and its +-- `SetScript("OnClick")` would be nil. +function MapCanvasMixin:SetPinTemplateType(pinTemplate, pinFrameType) + self.pinTemplateTypes[pinTemplate] = pinFrameType +end + +-- Declared vararg so `arg` is this function's own table: `LUA_COMPAT_VARARG` hands it over to a +-- vararg function that does not otherwise mention `...` (`script/lua50.rs`), and without the `...` +-- `arg` would resolve to the *global* one a script handler leaves behind. +function MapCanvasMixin:AcquirePin(pinTemplate, ...) + local extra = arg + local pool = self:GetPinPool(pinTemplate) + local pin, isNew = pool:Acquire() + pin.pinTemplate = pinTemplate + pin.owningMap = self + if isNew then + for k, v in pairs(MapCanvasPinMixin) do + if pin[k] == nil then + pin[k] = v + end + end + if pin.OnLoad then + pin:OnLoad() + end + end + self.activePins[table.getn(self.activePins) + 1] = pin + pin:Show() + pin:ApplyFrameLevel() + if pin.OnAcquired then + if extra and table.getn(extra) > 0 then + pin:OnAcquired(unpack(extra)) + else + pin:OnAcquired() + end + end + pin:ApplyCurrentScale() + return pin +end + +function MapCanvasMixin:RemovePin(pin) + for i = 1, table.getn(self.activePins) do + if self.activePins[i] == pin then + table.remove(self.activePins, i) + break + end + end + local pool = pin.pinTemplate and self.pinPools[pin.pinTemplate] + if pool then + pool:Release(pin) + else + pin:Hide() + end +end + +function MapCanvasMixin:RemoveAllPinsByTemplate(pinTemplate) + local i = 1 + while i <= table.getn(self.activePins) do + local pin = self.activePins[i] + if pin.pinTemplate == pinTemplate then + table.remove(self.activePins, i) + local pool = self.pinPools[pinTemplate] + if pool then + pool:Release(pin) + else + pin:Hide() + end + else + i = i + 1 + end + end +end + +function MapCanvasMixin:RemoveAllPins() + while table.getn(self.activePins) > 0 do + self:RemovePin(self.activePins[1]) + end +end + +-- An iterator, because that is what every caller writes: `for pin in +-- map:EnumeratePinsByTemplate(t) do`. A template with no pins answers an iterator that stops +-- immediately, never nil, so the loop is always safe. +function MapCanvasMixin:EnumeratePinsByTemplate(pinTemplate) + local pins = self.activePins + local i = 0 + return function() + while true do + i = i + 1 + local pin = pins[i] + if not pin then + return nil + end + if pin.pinTemplate == pinTemplate then + return pin + end + end + end +end + +function MapCanvasMixin:EnumerateAllPins() + local pins = self.activePins + local i = 0 + return function() + i = i + 1 + return pins[i] + end +end + +function MapCanvasMixin:GetNumActivePinsByTemplate(pinTemplate) + local n = 0 + for i = 1, table.getn(self.activePins) do + if self.activePins[i].pinTemplate == pinTemplate then + n = n + 1 + end + end + return n +end + +function MapCanvasMixin:GetHoveredPin() + for i = 1, table.getn(self.activePins) do + local pin = self.activePins[i] + if pin:IsShown() and pin:IsMouseOver() then + return pin + end + end + return nil +end + +-------------------------------------------------------------------------------- +-- Handlers: canvas clicks, global pin mouse actions, the cursor +-- +-- Each is a priority-sorted list, highest first, and the first handler that returns true consumes +-- the event. That ordering is the contract — a canvas addon registers at priority 1000 precisely +-- so it sees a click before Blizzard's own handlers. +-------------------------------------------------------------------------------- + +local function InsertByPriority(list, handler, priority) + local entry = { handler = handler, priority = priority or 0 } + local n = table.getn(list) + local at = n + 1 + for i = 1, n do + if (list[i].priority or 0) < entry.priority then + at = i + break + end + end + table.insert(list, at, entry) +end + +function MapCanvasMixin:AddCanvasClickHandler(handler, priority) + InsertByPriority(self.canvasClickHandlers, handler, priority) +end + +function MapCanvasMixin:AddGlobalPinMouseActionHandler(handler, priority) + InsertByPriority(self.globalPinMouseActionHandlers, handler, priority) +end + +function MapCanvasMixin:AddCursorHandler(handler, priority) + InsertByPriority(self.cursorHandlers, handler, priority) +end + +function MapCanvasMixin:ProcessCanvasClickHandlers(button, cursorX, cursorY) + local list = self.canvasClickHandlers + for i = 1, table.getn(list) do + local ok, consumed = pcall(list[i].handler, self, button, cursorX, cursorY) + if not ok then + geterrorhandler()(consumed) + elseif consumed then + return true + end + end + return false +end + +function MapCanvasMixin:ProcessGlobalPinMouseActionHandlers(mouseAction, button) + local list = self.globalPinMouseActionHandlers + for i = 1, table.getn(list) do + local ok, consumed = pcall(list[i].handler, self, mouseAction, button) + if not ok then + geterrorhandler()(consumed) + elseif consumed then + return true + end + end + return false +end + +function MapCanvasMixin:ProcessCursorHandlers() + local list = self.cursorHandlers + for i = 1, table.getn(list) do + local ok, cursor = pcall(list[i].handler, self) + if not ok then + geterrorhandler()(cursor) + elseif cursor then + return cursor + end + end + return nil +end + +-------------------------------------------------------------------------------- +-- Navigation +-------------------------------------------------------------------------------- + +function MapCanvasMixin:NavigateToMap(mapID) + self:SetMapID(mapID) +end + +function MapCanvasMixin:NavigateToParentMap() + local info = self.mapID and C_Map.GetMapInfo(self.mapID) + if info and info.parentMapID and info.parentMapID ~= 0 then + self:SetMapID(info.parentMapID) + return true + end + return false +end + +function MapCanvasMixin:NavigateToCursor() + local sc = self.ScrollContainer + if not sc or not self.mapID then + return false + end + local x, y = sc:GetNormalizedCursorPosition() + local info = C_Map.GetMapInfoAtPosition(self.mapID, x, y) + if info and info.mapID ~= self.mapID then + self:SetMapID(info.mapID) + return true + end + return false +end + +-------------------------------------------------------------------------------- +-- The map change +-------------------------------------------------------------------------------- + +function MapCanvasMixin:SetMapID(mapID) + if not mapID or self.mapID == mapID then + return + end + self.mapID = mapID + if self.ScrollContainer then + self.ScrollContainer.mapID = mapID + end + -- The stock window follows, so the 1.12 detail art under this canvas and every 1.12 addon's + -- icons on it are the same place the canvas shows. The engine's own selection is the authority + -- for both; see `BenillaSelectMapID`. + BenillaSelectMapID(mapID) + self:RefreshDetailLayers() + if self.ScrollContainer then + self.ScrollContainer:OnCanvasSizeChanged() + self.ScrollContainer:ResetZoom() + end + self:OnMapChanged() + self:RefreshAllDataProviders(false) +end + +-- Follow the stock window when something else moved it (its dropdowns, `SetMapToCurrentZone`, +-- another addon's `SetMapZoom`), without calling back into it. +function MapCanvasMixin:SyncFromSelection() + local mapID = BenillaMapIDForSelection() + if not mapID or mapID == self.mapID then + return + end + self.mapID = mapID + if self.ScrollContainer then + self.ScrollContainer.mapID = mapID + end + self:RefreshDetailLayers() + if self.ScrollContainer then + self.ScrollContainer:OnCanvasSizeChanged() + self.ScrollContainer:ResetZoom() + end + self:OnMapChanged() + self:RefreshAllDataProviders(false) +end + +-------------------------------------------------------------------------------- +-- The detail layers +-- +-- Era's canvas builds its base art from a pool of layer frames. Ours has exactly one layer and it +-- is 1.12's own `WorldMapDetailFrame`, reparented into the canvas — so the base art is whatever +-- the stock window draws, from the same selection, and there is no second copy of the tiling law +-- to disagree with it. The pool exists because a canvas addon enumerates it to hide the base art +-- while it draws its own (`for layer in map.detailLayerPool:EnumerateActive() do`), and that is +-- the only thing anyone does with it. +-------------------------------------------------------------------------------- + +local DetailLayerPoolMixin = {} + +function DetailLayerPoolMixin:EnumerateActive() + local layers = self.layers + local i = 0 + return function() + i = i + 1 + return layers[i] + end +end + +function DetailLayerPoolMixin:GetNumActive() + return table.getn(self.layers) +end + +function MapCanvasMixin:RefreshDetailLayers() + -- `WorldMapFrame_Update` is the stock repaint, driven by `WORLD_MAP_UPDATE`; nothing to do + -- here but let a hook see the call, which is what a canvas addon hooks it for. +end + +-------------------------------------------------------------------------------- +-- MapUtil +-------------------------------------------------------------------------------- + +MapUtil = {} + +function MapUtil.GetDisplayableMapForPlayer() + local mapID = C_Map.GetBestMapForUnit("player") + if mapID then + return mapID + end + return C_Map.GetFallbackWorldMapID() +end + +-- The name of the smallest thing under the cursor: a sub-area's if its highlight claims the +-- point, else the child map's, else nothing. Over `C_Map`, so the name is the one the stock +-- window's own hover would show for that point. +function MapUtil.FindBestAreaNameAtMouse(mapID, x, y) + if not mapID or not x or not y then + return nil + end + local info = C_Map.GetMapInfoAtPosition(mapID, x, y) + if info and info.mapID ~= mapID then + return info.name + end + return nil +end + +function MapUtil.ShouldMapTypeShowQuests() + return true +end + +function MapUtil.IsMapTypeAllowed(mapType) + return mapType == Enum.UIMapType.Continent or mapType == Enum.UIMapType.Zone +end + +-------------------------------------------------------------------------------- +-- Bolting the canvas onto 1.12's WorldMapFrame +-------------------------------------------------------------------------------- + +local function Mix(target, mixin) + for k, v in pairs(mixin) do + target[k] = v + end +end + +local function BuildDefaultFrameLevels(manager) + -- Blizzard's own band names and order, bottom to top. The names are the contract: a canvas + -- addon inserts its own band relative to one of these by name, so a missing or renamed band is + -- art landing in the wrong place. + manager:InsertFrameLevel("PIN_FRAME_LEVEL_TERRAIN", 4) + manager:InsertFrameLevel("PIN_FRAME_LEVEL_MAP_EXPLORATION", 4) + manager:InsertFrameLevel("PIN_FRAME_LEVEL_MAP_HIGHLIGHT", 4) + manager:InsertFrameLevel("PIN_FRAME_LEVEL_AREA_POI", 8) + manager:InsertFrameLevel("PIN_FRAME_LEVEL_FLIGHT_POINT", 8) + manager:InsertFrameLevel("PIN_FRAME_LEVEL_WORLD_QUEST", 16) + manager:InsertFrameLevel("PIN_FRAME_LEVEL_STORY_LINE", 8) + manager:InsertFrameLevel("PIN_FRAME_LEVEL_GROUP_MEMBER", 8) + manager:InsertFrameLevel("PIN_FRAME_LEVEL_CORPSE", 4) + manager:InsertFrameLevel("PIN_FRAME_LEVEL_UNIT", 8) + manager:InsertFrameLevel("PIN_FRAME_LEVEL_MAP_LINK", 4) + manager:InsertFrameLevel("PIN_FRAME_LEVEL_DEATH_RELEASE", 4) +end + +-- The pan mechanism: the canvas's own TOPLEFT against the container's, which is Blizzard's own +-- way and the one whose signs this file controls. A ScrollFrame's scroll offsets would do it with +-- no layout touch, but their sign convention is the engine's rather than this file's, and a pan +-- already dirties the layout of everything on the canvas. +function MapCanvasScrollControllerMixin:ApplyCurrentScroll() + local child = self.Child + if not child then + return + end + local scale = self:GetCanvasScale() + local cw = child:GetWidth() * scale + local ch = child:GetHeight() * scale + -- The canvas point under the container's middle is `currentScroll`; place the canvas so that + -- point lands there. Divided by the scale because the offsets are in the canvas's own units, + -- which the scale multiplies. + local offsetX = ((self.currentScrollX or 0.5) * cw - self:GetWidth() * 0.5) / scale + local offsetY = ((self.currentScrollY or 0.5) * ch - self:GetHeight() * 0.5) / scale + child:ClearAllPoints() + child:SetPoint("TOPLEFT", self, "TOPLEFT", -offsetX, offsetY) +end + +local function BuildCanvas() + local map = WorldMapFrame + local detail = WorldMapDetailFrame + local button = WorldMapButton + if not map or not detail or not button or map.ScrollContainer then + return + end + + Mix(map, MapCanvasMixin) + map:OnLoad() + + -- The container takes the detail frame's own rect, so at rest the canvas is exactly where the + -- stock art was and the window is pixel-identical. A real ScrollFrame, for the clipping its + -- scroll child and every descendant of it get (see this file's header). + local sc = CreateFrame("ScrollFrame", "WorldMapScrollContainer", map) + sc:SetWidth(detail:GetWidth()) + sc:SetHeight(detail:GetHeight()) + -- The detail frame's **own** XML anchor, copied rather than referenced: once it is reparented + -- into the canvas below, anchoring the container to it would be a cycle — container to detail + -- to canvas to container — and a cycle resolves to nothing, which showed up as + -- `WorldMapButton:GetCenter()` answering nil inside the stock hover pass. The measured offsets + -- against `WorldMapFrame` are the fallback for a chain whose map window is laid out some other + -- way. + local guide = WorldMapPositioningGuide + if guide then + sc:SetPoint("TOPLEFT", guide, "TOP", -502, -69) + else + local l, t = detail:GetLeft(), detail:GetTop() + local ml, mt = map:GetLeft(), map:GetTop() + if l and t and ml and mt then + sc:SetPoint("TOPLEFT", map, "TOPLEFT", l - ml, t - mt) + else + sc:SetPoint("TOPLEFT", map, "TOPLEFT", 0, 0) + end + end + -- No mouse of its own: the map's mouse surface is 1.12's `WorldMapButton`, which covers the + -- same rect and is above this in the tree, so a mouse-enabled container here would never see + -- a press anyway. The hooks below drive the controller from that button instead. + sc:EnableMouse(false) + + local canvas = CreateFrame("Frame", "WorldMapCanvas", sc) + canvas:SetWidth(detail:GetWidth()) + canvas:SetHeight(detail:GetHeight()) + sc:SetScrollChild(canvas) + + Mix(sc, MapCanvasScrollControllerMixin) + sc.Child = canvas + sc.mapCanvas = map + sc:OnLoad() + + map.ScrollContainer = sc + map.Canvas = canvas + + -- 1.12's art and its mouse surface become the canvas's content: the twelve detail tiles, the + -- exploration overlays, the hover highlight, the POI buttons, the player arrow and the party + -- and raid pins — and with them every 1.12 addon's icons, which are all anchored to or + -- parented into these two frames. They now pan, scale and clip with the canvas, which is the + -- whole reason the canvas is built around them instead of beside them: a 1.12 quest icon stays + -- on its quest while a canvas addon moves the view. + detail:SetParent(canvas) + detail:ClearAllPoints() + detail:SetAllPoints(canvas) + button:SetParent(canvas) + + -- The base-art layer a canvas addon hides while it draws its own. It is a proxy rather than the + -- detail frame itself, and that distinction is the point: `Hide()` on the frame would take the + -- POI buttons and every addon's icons down with it, because they are its children and its + -- siblings' children. Switching off its BACKGROUND and ARTWORK **draw layers** removes exactly + -- the stock tiles, the exploration overlays and the hover highlight — regions, all three — + -- and leaves every child frame drawing. + local baseLayer = { + SetShown = function(self, shown) + if shown then + detail:EnableDrawLayer("BACKGROUND") + detail:EnableDrawLayer("ARTWORK") + else + detail:DisableDrawLayer("BACKGROUND") + detail:DisableDrawLayer("ARTWORK") + end + self.shown = shown and true or false + end, + Show = function(self) + self:SetShown(true) + end, + Hide = function(self) + self:SetShown(false) + end, + IsShown = function(self) + return self.shown ~= false + end, + SetAlpha = function() end, + GetAlpha = function() + return 1 + end, + } + map.detailLayerPool = { layers = { baseLayer } } + Mix(map.detailLayerPool, DetailLayerPoolMixin) + + BuildDefaultFrameLevels(map.pinFrameLevelsManager) + + -- The controller's input, off 1.12's own map button. + -- + -- Its `` *is* the 1.12 click-to-zoom (`WorldMapButton_OnClick`), so that one is + -- replaced rather than hooked: a canvas click handler that consumes the click must stop the + -- stock zoom, and a drag that ended over the map is not a click at all. Both of those are + -- decisions the controller has already made by the time the wrapper reads + -- `lastClickConsumed`, and neither can be expressed by a hook, which cannot refuse. + local stockMouseUp = button:GetScript("OnMouseUp") + button:SetScript("OnMouseUp", function() + sc:OnMouseUp(arg1) + if not sc.lastClickConsumed and stockMouseUp then + stockMouseUp() + end + end) + button:SetScript("OnMouseDown", function() + sc:OnMouseDown(arg1) + end) + button:SetScript("OnMouseWheel", function() + sc:OnMouseWheel(arg1) + end) + button:HookScript("OnUpdate", function() + sc:OnUpdate(arg1) + end) + button:EnableMouseWheel(true) + button:RegisterForClicks("LeftButtonUp", "RightButtonUp", "MiddleButtonUp") + + -- Seat the first map before anything reads `GetMapID()`: the stock window has already resolved + -- the player's zone by the time this runs, so the canvas starts where the window is. + map.mapID = BenillaMapIDForSelection() or WORLD_MAP_ID + sc.mapID = map.mapID + sc:OnCanvasSizeChanged() + + map:HookScript("OnShow", function() + WorldMapFrame:SyncFromSelection() + WorldMapFrame:RefreshAllDataProviders(true) + WorldMapFrame:CallDataProviders("OnShow") + end) + map:HookScript("OnHide", function() + WorldMapFrame:CallDataProviders("OnHide") + end) +end + +local loader = CreateFrame("Frame", "BenillaMapCanvasLoader") +loader:RegisterEvent("PLAYER_LOGIN") +loader:RegisterEvent("WORLD_MAP_UPDATE") +loader:SetScript("OnEvent", function() + if event == "PLAYER_LOGIN" then + BuildCanvas() + elseif event == "WORLD_MAP_UPDATE" and WorldMapFrame and WorldMapFrame.SyncFromSelection then + -- Something moved the stock window: its dropdowns, `SetMapToCurrentZone`, another addon. + -- The canvas follows without calling back, so the two cannot fight over the selection. + WorldMapFrame:SyncFromSelection() + end +end) diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index 6c060dd7..61028766 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -150,6 +150,14 @@ Interface\FrameXML\FadingFrame.xml Interface\FrameXML\ZoneText.xml Interface\FrameXML\ColorPickerFrame.xml Interface\FrameXML\WorldMapFrame.xml +# Ours: the Era map-canvas framework (`MapCanvasMixin` and friends), bolted onto the stock window +# above — which is why it sits immediately after it, and why the file reads `WorldMapFrame`, +# `WorldMapDetailFrame` and `WorldMapButton` by name. 1.12's map is a fixed 1002x668 frame showing +# one zone with no canvas, no pan, no zoom and no pins; every map addon written after 4.0 drives +# the canvas framework instead, and there is no stock file to migrate from because 1.12 has no +# such thing. Nothing here replaces anything: the canvas is added, and the stock art and its mouse +# surface are reparented INTO it, so a 1.12 addon's icons pan and zoom with the map for free. +MapCanvas.lua Interface\FrameXML\CastingBarFrame.xml Interface\FrameXML\MirrorTimer.xml Interface\FrameXML\DurabilityFrame.xml diff --git a/crates/benilla-app/src/addon_harness/mod.rs b/crates/benilla-app/src/addon_harness/mod.rs index 6fd33ef6..0e56f02a 100644 --- a/crates/benilla-app/src/addon_harness/mod.rs +++ b/crates/benilla-app/src/addon_harness/mod.rs @@ -288,6 +288,17 @@ fn survey_one( .flat_map(|d| methods_defined_by(root, d, defined_methods).clone()) .collect(); + // The stock world map, opened and closed once BEFORE the baseline is taken, so the regions + // and buttons its own first update creates are the *client's* and not the addon's. + // + // It has to be here and not later: `WorldMapFrame_Update` builds its exploration overlay + // textures lazily (`WorldMapDetailFrame:CreateTexture("WorldMapOverlay"..j)`) and its POI + // buttons likewise (`CreateFrame("Button", "WorldMapFramePOI"..i, WorldMapButton)`), so a + // first open after the snapshot would charge every one of them to whichever addon happened to + // be surveyed — the exact mis-attribution the probe must never make. Closed again, because + // the 1.12 client loads addons with the map shut and an addon's own startup may read it. + prime_world_map(&mut script); + // The render baseline: every widget that exists before this addon runs, taken after the // dependency chain so a library's frames are not charged to its consumer. let baseline = RenderBaseline::of(&script); @@ -323,10 +334,22 @@ fn survey_one( .collect(); // After the UI probe, because it reopens what the probe's second toggle closed; before the // method oracle, whose probe widgets would otherwise count as this addon's drawing. - let (render, painted) = measure_render(&mut script, &baseline); + let (mut render, painted) = measure_render(&mut script, &baseline); // Then use what it drew: after every other column so the input it invents perturbs none, before // the oracle so its widgets never become targets. It reuses `render`'s attribution. - let used = measure_use(&mut script, &baseline, &painted); + let mut used = measure_use(&mut script, &baseline, &painted); + // The world map is its own surface and its own pass, because 1.12's full-screen map hides + // `UIParent` while it is up — `measure_render_on_world_map` has the measurement. Both columns + // are read there and unioned in: an addon that draws only on the map, and survives being + // clicked there, must read the same as one that does it on the action bar. + let (map_render, map_painted) = render::measure_render_on_world_map(&mut script, &baseline); + render.merge(map_render); + if !map_painted.is_empty() { + // Driven while the map is still up: a point on a hidden frame hits nothing, which would + // read as "nothing a pointer can reach" for an addon whose whole UI is the map. + used.merge(measure_use(&mut script, &baseline, &map_painted)); + } + render::close_world_map(&mut script); // Last: the oracle writes probe frames and a global into the VM, and no addon code runs after // this, so every number above is unperturbed by it. One oracle call serves both question sets // and the per-kind pass. @@ -668,6 +691,29 @@ fn raised_inside_a_demand_load(err: &str, mine: &str) -> bool { /// /// Each call is guarded on the global existing and wrapped so a raise is recorded. It proves the /// body ran, not that its result is right. +/// Open the stock world map, let its first update run, and close it again — see the call site for +/// why the timing is the whole point. Errors are swallowed: a raise here is the client's or a +/// dependency's, never the surveyed addon's, and the columns after this must not be zeroed by it. +fn prime_world_map(script: &mut UiScript) { + let _ = script.run( + r#" + if type(ToggleWorldMap) == "function" and WorldMapFrame then + if not WorldMapFrame:IsShown() then pcall(ToggleWorldMap) end + end + "#, + ); + script.tick(0.1); + script.resolve(); + let _ = script.run( + r#" + if type(ToggleWorldMap) == "function" and WorldMapFrame and WorldMapFrame:IsShown() then + pcall(ToggleWorldMap) + end + "#, + ); + script.tick(0.1); +} + fn drive_ui_probe(script: &mut UiScript) -> Vec { let before = script.errors().len(); // `this` is set for the `this`-shaped entry points, which read it. @@ -690,6 +736,13 @@ fn drive_ui_probe(script: &mut UiScript) -> Vec { -- Open, then close: a toggle left open would leak state into nothing here, but the second -- call is what exercises an override's close path. try(ToggleBackpack); try(ToggleBackpack) + -- The world map, open then close, for the same reason the backpack is here: a whole class + -- of addon lives only on that surface — the eight in this install that anchor icons to + -- `WorldMapDetailFrame` and `WorldMapButton`, and every canvas-map addon — and a raise in + -- its open or close path is invisible to every other call in this probe. + if WorldMapFrame then + try(ToggleWorldMap); try(ToggleWorldMap) + end if PlayerFrame then this = PlayerFrame try(UnitFrame_OnEnter); try(UnitFrame_OnLeave) diff --git a/crates/benilla-app/src/addon_harness/render.rs b/crates/benilla-app/src/addon_harness/render.rs index 28c9efbf..59384944 100644 --- a/crates/benilla-app/src/addon_harness/render.rs +++ b/crates/benilla-app/src/addon_harness/render.rs @@ -91,6 +91,20 @@ pub struct RenderReport { } impl RenderReport { + /// Fold a second surface's reading in, for [`measure_render_on_world_map`]: a quad counts + /// wherever it was drawn, and the frame lists union. The per-surface split is not kept — + /// "drew something" is one question, and the answer is yes if any surface saw it. + pub fn merge(&mut self, other: RenderReport) { + self.own_quads += other.own_quads; + self.overlay_quads += other.overlay_quads; + for name in other.frames { + if !self.frames.contains(&name) { + self.frames.push(name); + } + } + self.frames.sort(); + } + pub fn drew(&self) -> Drew { match (self.own_quads, self.overlay_quads) { (0, 0) => Drew::Nothing, @@ -176,7 +190,60 @@ pub(super) fn measure_render( for _ in 0..10 { script.tick(0.1); } + collect(script, baseline) +} +/// The world map on its own surface, after [`measure_render`] has read the ordinary one. +/// +/// It needs a pass of its own because 1.12's world map is a `UIPanelWindows` entry with +/// `area = "full"`, and its own `ShowUIPanel` **hides `UIParent`** for it — so while the map is up +/// nothing else in the interface draws at all. Measured, with the map merely opened at the end of +/// the ordinary probe: the corpus's "drew something" column fell from 37/86 to 18/86, and the +/// nineteen it lost were ordinary addons whose frames hang off `UIParent` (minimap buttons, +/// a threat meter, an auction helper) — not one of them had changed. So the two surfaces cannot +/// be read in one pass, and this is the second. +/// +/// Worth having rather than skipping, because a whole class of addon lives only here: the eight +/// in this install that anchor icons to `WorldMapDetailFrame` and `WorldMapButton`, and every +/// canvas-map addon. The reading is unioned into the ordinary one +/// ([`RenderReport::merge`]), since "drew something" is one question. +pub(super) fn measure_render_on_world_map( + script: &mut UiScript, + baseline: &RenderBaseline, +) -> (RenderReport, Vec) { + let _ = script.run( + r#" + if WorldMapFrame and type(ToggleWorldMap) == "function" then + if not WorldMapFrame:IsShown() then pcall(ToggleWorldMap) end + end + "#, + ); + // The same ten ticks the ordinary pass takes, and for the same reason: a canvas map builds its + // art in the first `OnUpdate` after the map is shown, not in the show itself. + for _ in 0..10 { + script.tick(0.1); + } + // Left OPEN: the caller drives input on this surface next, and a point on a hidden frame hits + // nothing — which reads as "had nothing a pointer can reach" for an addon whose whole UI is + // the map. [`close_world_map`] puts it back when that reading is taken. + collect(script, baseline) +} + +/// Close the map `measure_render_on_world_map` left open, so `UIParent` is back before anything +/// else is measured. +pub(super) fn close_world_map(script: &mut UiScript) { + let _ = script.run( + r#" + if WorldMapFrame and type(ToggleWorldMap) == "function" then + if WorldMapFrame:IsShown() then pcall(ToggleWorldMap) end + end + "#, + ); + script.tick(0.1); +} + +/// Attribute every painting quad on screen right now, and where it was painted. +fn collect(script: &mut UiScript, baseline: &RenderBaseline) -> (RenderReport, Vec) { script.resolve(); let mut report = RenderReport::default(); let mut named: BTreeSet = BTreeSet::new(); diff --git a/crates/benilla-app/src/addon_harness/use_probe.rs b/crates/benilla-app/src/addon_harness/use_probe.rs index 92f0a304..5cec3121 100644 --- a/crates/benilla-app/src/addon_harness/use_probe.rs +++ b/crates/benilla-app/src/addon_harness/use_probe.rs @@ -70,6 +70,22 @@ pub struct UseReport { } impl UseReport { + /// Fold a second surface's reading in, for the world-map pass + /// ([`super::render::measure_render_on_world_map`]): "survived being used" is one question + /// over every surface driven, so the counts add, the frame lists union and any error from + /// either surface is an error. + pub fn merge(&mut self, other: UseReport) { + self.driven += other.driven; + self.touchable += other.touchable; + for name in other.frames { + if !self.frames.contains(&name) { + self.frames.push(name); + } + } + self.frames.sort(); + self.errors.extend(other.errors); + } + pub fn verdict(&self) -> Used { match (self.driven, self.errors.is_empty()) { (0, _) => Used::Untouched, diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 456f6d52..21e61e45 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -95,6 +95,7 @@ fn content_kind(c: &QuadContent) -> &'static str { QuadContent::Minimap { .. } => "minimap", QuadContent::ModelPane { .. } => "modelpane", QuadContent::Texture { .. } => "texture", + QuadContent::Line { .. } => "line", QuadContent::ColorWheel => "colorwheel", QuadContent::ColorValue { .. } => "colorvalue", QuadContent::Backdrop { .. } => "backdrop", @@ -1501,6 +1502,62 @@ fn convert_entry( ..default() }); } + // A `Line` region: the quad between two points, `thickness` wide. Drawn through + // `UiQuad::corners`, the explicit-corner path the cooldown wedge already uses, because a + // line is a rotated rectangle and `rect` plus `rotation` cannot express one whose *angle* + // is data rather than a setting. + QuadContent::Line { + path, + color, + additive, + start, + end, + thickness, + } => { + // The y flip, as `rect` above: the two ends and the perpendicular are all in UI space. + let a = Vec2::new(start.0 * s, h - start.1 * s); + let b = Vec2::new(end.0 * s, h - end.1 * s); + let along = b - a; + let len = along.length(); + if len <= 0.0001 { + // Both ends in one place: a line of no length, which draws nothing rather than a + // degenerate quad whose normal is undefined. + return; + } + let half = thickness * 0.5 * s; + // The perpendicular, scaled to half the thickness; `(-y, x)` turns the direction a + // quarter turn, which is the offset that gives the line its width. + let n = Vec2::new(-along.y, along.x) / len * half; + let corners = [a + n, b + n, b - n, a - n]; + let handle = match (path.as_deref(), assets.as_mut()) { + (Some(p), Some(av)) => { + let resolved = av.sprite_texture(p, images); + if resolved.is_none() { + return; + } + resolved + } + // No art is a solid, which is how every caller in the corpus paints a line + // (`SetColorTexture` then `SetVertexColor`). + _ => None, + }; + if handle.is_none() && color.is_none() { + return; + } + let mut color = color.unwrap_or([1.0, 1.0, 1.0, 1.0]); + color[3] *= eq.alpha; + out.push(UiQuad { + rect, + z_key: eq.z, + texture: handle, + uv: UvRect::FULL, + color, + additive, + clip, + corners: Some(corners), + ..default() + }); + } // The colour picker's hue disc, a generated image drawn at full value ([`colorselect`]). QuadContent::ColorWheel => { let Some(assets) = assets.as_mut() else { diff --git a/crates/benilla-app/src/ui_script/map_canvas_tests.rs b/crates/benilla-app/src/ui_script/map_canvas_tests.rs new file mode 100644 index 00000000..220f898b --- /dev/null +++ b/crates/benilla-app/src/ui_script/map_canvas_tests.rs @@ -0,0 +1,381 @@ +//! `assets/ui/MapCanvas.lua`: the Era map-canvas framework bolted onto 1.12's own world map. +//! +//! Every test here is over the **whole** shipped interface, not a fixture, because what the file +//! does is reparent two of the stock map's frames — so a fixture that did not have them would +//! measure nothing. `WOW_DATA` gates them: without the player's chain there is no +//! `WorldMapFrame.xml` to bolt onto, which is the same reason the rest of this crate's +//! full-interface tests skip. +//! +//! The three regressions these pin were all found by measurement and all looked like something +//! else: +//! +//! - **The anchor cycle.** Anchoring the container to `WorldMapDetailFrame` and then reparenting +//! that frame into the container's own child is a cycle, and a cycle resolves to nothing. The +//! symptom was `WorldMapButton:GetCenter()` answering nil inside the stock hover pass — a raise +//! in `WorldMapFrame.lua`, pointing at the client's file rather than at ours. +//! - **The base scale.** A layer whose `minScale` is below 1 opens the map zoomed *out* of its own +//! window: the canvas is sized to the layer and its container is the rect that layer used to +//! fill, so 1 is already the whole map. Measured as a 300x200 canvas floating in a 1002x668 +//! container. +//! - **`dataProviders` is a set, not an array.** Callers iterate it with +//! `for provider in pairs(...)`, which binds the *key*. An array handed that loop the number 1, +//! and the next line indexed a number — inside the addon's own startup, which caught it, printed +//! it, and left the whole addon disabled with no error anywhere. + +use benilla_ui::script::UiScript; + +use super::manifest::load_default_ui; + +/// The shipped interface with a session seated far enough for the map to resolve, then +/// `PLAYER_LOGIN`, which is where the canvas is built. +fn harness() -> Option { + 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::("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::(&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::("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::("return WorldMapFrame.dataProviders[probe] == nil") + .unwrap()); + assert_eq!( + s.eval::("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::( + "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::("return WorldMapFrame:IsShown() and true or false") + .unwrap(), + "the map has to be up for this to measure anything" + ); + assert_eq!( + s.eval::("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::("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::("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 = 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::("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::("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::("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" + ); +} diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 1ab6eb6d..0e91d3e0 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -861,6 +861,10 @@ mod faux_scroll_tests; #[cfg(test)] mod compat_templates_tests; +/// `assets/ui/MapCanvas.lua`: the Era map-canvas framework over 1.12's own world map. +#[cfg(test)] +mod map_canvas_tests; + /// The stock UIPanelTemplates and OptionsFrameTemplates kit, driven the way an addon drives it. #[cfg(test)] mod panel_template_tests; diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs index f2324b7d..96a997a5 100644 --- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs +++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs @@ -1983,6 +1983,21 @@ fn assets_ui_does_not_grow() { "CompatTemplates.xml", "ContainerFrameAdapters.xml", "GameMenuFrame.xml", + // Not a window either, and the same footing as `CompatTemplates.xml` above: the Era + // map-canvas framework (`MapCanvasMixin`, `MapCanvasScrollControllerMixin`, + // `MapCanvasDataProviderMixin`, `MapCanvasPinMixin`, `PinFrameLevelsManager`, `MapUtil`), + // which every map addon written after 4.0 drives and which 1.12 has no counterpart to at + // all — its map is a fixed 1002x668 frame showing one zone, with no canvas, no pan, no + // zoom, no pins and no notion of where one map sits on another. There is no stock file to + // migrate from because there is no stock feature. It lives in Lua rather than + // `script/compat/` because it is frames and mixins, not verbs. + // + // It is **additive by construction**, which is the part that mattered: `WorldMapFrame` + // already has eight 1.12 consumers on this install that anchor icons to + // `WorldMapDetailFrame` and `WorldMapButton`, so the canvas is built AROUND those two and + // reparents them into itself. At rest it is the rect they had, at scale 1 with no pan, so + // the stock window is pixel-identical; moved, their icons move with it. + "MapCanvas.lua", "KeyBindingsPage.xml", "OptionsFrame.xml", // The six `OptionsFrame_*` enable/disable helpers stock 1.12 keeps in its own diff --git a/crates/benilla-ui/src/script/compat/classic_api/frame.rs b/crates/benilla-ui/src/script/compat/classic_api/frame.rs index 8d7f550c..56b2e59c 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/frame.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/frame.rs @@ -62,6 +62,38 @@ end end "#; +/// `IsMouseOver([top, bottom, left, right])`: whether the cursor is inside this widget's rect, +/// with optional per-edge slack. +/// +/// 1.12 has the *computation* and not the method: FrameXML's own `MouseIsOver(region, …)` +/// (`UIParent.lua`) is exactly this body, and 2.0 moved it onto the widget. So this is a method +/// spelling of a function the player's own chain already defines, written out here rather than +/// forwarded to it because the argument *order* differs — `MouseIsOver` takes the region first and +/// this does not — and because a frame may be asked before `UIParent.lua` has run. +/// +/// Goes on all three tables: 2.0 put it on Region, so a Texture answers it too, and the body reads +/// nothing a region lacks. +/// +/// Returns false rather than nil for an unresolved widget, because every caller treats it as a +/// boolean and the reference's own answer for a region with no rect is the same "no". +const MOUSE_OVER_DELEGATES: &str = r#" +return function(m) +function m.IsMouseOver(self, top, bottom, left, right) + local l, r, t, b = self:GetLeft(), self:GetRight(), self:GetTop(), self:GetBottom() + if not l or not r or not t or not b then return false end + local scale = self:GetEffectiveScale() + if not scale or scale == 0 then return false end + local x, y = GetCursorPosition() + x, y = x / scale, y / scale + l = l + (left or 0) + r = r + (right or 0) + t = t + (top or 0) + b = b + (bottom or 0) + return y > b and y < t and x > l and x < r +end +end +"#; + /// `SetShown` over the stock `Show`/`Hide` pair. `script/object/frame_state.rs` notes the reference /// registers no `SetShown` at all, which is exactly why ClassicAPI backports it. /// @@ -188,6 +220,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { ] { delegate(lua, key, GEOMETRY_DELEGATES)?; delegate(lua, key, SHOWN_DELEGATES)?; + delegate(lua, key, MOUSE_OVER_DELEGATES)?; } delegate(lua, REG_TEXTURE_METHODS, TEXTURE_DELEGATES)?; delegate(lua, REG_FONTSTRING_METHODS, FONTSTRING_DELEGATES)?; @@ -327,6 +360,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; } + // `CreateLine(name, layer, template, subLayer)`: a `Line` region — two endpoints and a + // thickness, drawn as the quad between them (`region/paint.rs` has the four methods, + // `RegionData::line` the geometry). + // + // 1.12 has no line primitive and nothing stands in for one: a line between two *moving* + // points is a rotated quad whose angle and length both change every frame, which no + // rect-anchored texture expresses. The map needs it for flight routes and for the drawing + // tool, and it is called unguarded at both sites (`Map/Stitch.lua:717`, `Map/Sketch.lua:377`). + // + // Built over the stock `CreateTexture` on this same table, so a line is an ordinary Texture in + // every respect but its geometry — which is what the callers rely on, painting it with + // `SetColorTexture` and `SetVertexColor` like any texture. + m.set( + "CreateLine", + lua.create_function( + |lua, (this, name, layer, template, _sub): (Table, Value, Value, Value, Value)| { + let create: mlua::Function = this.get("CreateTexture")?; + let wrapper: Table = create.call((this, name, layer, template))?; + let rh = crate::script::region::region_handle_of(lua, &wrapper)?; + let mut model = lua.app_data_mut::().expect("model app_data"); + model.region_data.entry(rh).or_default().line = + Some(crate::script::LineGeometry::default()); + Ok(wrapper) + }, + )?, + )?; + // `SetPropagateKeyboardInput(on)` / `GetPropagateKeyboardInput()`: 4.x's switch for whether a // frame with an `OnKeyDown` handler lets the key reach the next frame and the keybinding // system, or swallows it. diff --git a/crates/benilla-ui/src/script/compat/classic_api/misc.rs b/crates/benilla-ui/src/script/compat/classic_api/misc.rs index 9ceaa8c1..f8a2af8b 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/misc.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/misc.rs @@ -62,10 +62,47 @@ fn event_utils(lua: &Lua) -> mlua::Result { lua.create_table() } +/// `C_Texture`: `GetAtlasInfo(name)`, which answers **nil for every name**. +/// +/// That nil is the *answer*, not a stub. An atlas is a named rectangle inside a shipped sheet, +/// published by a table (`UiTextureAtlas`/`UiTextureAtlasMember`) that arrived in 4.x; 1.12 ships +/// no such table and no such sheets, so there is no atlas by any name and nothing could make one. +/// +/// It is registered rather than left absent because the *presence of the getter* is what lets a +/// caller's guard work, and a missing namespace makes the common guard take the wrong branch. +/// Measured, in the addon this was found with: +/// +/// ```text +/// if look.atlas and (not C_Texture or not C_Texture.GetAtlasInfo +/// or C_Texture.GetAtlasInfo(look.atlas)) then +/// tex:SetAtlas(look.atlas) +/// else +/// tex:SetTexture(look.texture or look.fallback or ...) +/// ``` +/// +/// Its own comment says "an atlas this client lacks falls back to the texture", and with the +/// namespace absent the first two disjuncts are *true* — so it called `SetAtlas` on a client that +/// has no atlases and raised, inside the addon's own startup, which caught it and left the whole +/// addon disabled. With the getter here answering nil the condition is false and the fallback +/// texture is drawn, which is what the author meant. Every other atlas guard in the corpus has the +/// same shape. +/// +/// `GetAtlasElementID` and the rest of the namespace are not built: they describe members of a +/// table this client does not have, and nothing in the corpus calls one. +fn texture_info(lua: &Lua) -> mlua::Result
{ + let t = lua.create_table()?; + t.set( + "GetAtlasInfo", + lua.create_function(|_, _name: Option| Ok(Value::Nil))?, + )?; + Ok(t) +} + pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); g.set("C_CurrencyInfo", currency_info(lua)?)?; g.set("C_EventUtils", event_utils(lua)?)?; + g.set("C_Texture", texture_info(lua)?)?; // `GetFramesRegisteredForEvent(event)`: the registry's own list for that event, in registration // order — the same order `fire_event` walks (`script/tick.rs`), so a caller inspecting it sees diff --git a/crates/benilla-ui/src/script/compat/classic_api/uimap.rs b/crates/benilla-ui/src/script/compat/classic_api/uimap.rs index 4c4b2bd4..281f5c84 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/uimap.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/uimap.rs @@ -44,11 +44,19 @@ const LAYER_WIDTH: u32 = 1002; const LAYER_HEIGHT: u32 = 668; const TILE: u32 = 256; -/// How far the canvas may scale a layer. Nothing in the catalog says — 1.12 has no zoom at all — -/// so these are the canvas framework's own bounds, and they live here because `CreateZoomLevels` -/// reads them off the layer rather than off the frame. -const MIN_SCALE: f64 = 0.3; -const MAX_SCALE: f64 = 2.0; +/// How far the canvas may scale a layer, and how many steps between. Nothing in the catalog says +/// — 1.12 has no zoom at all — so these are the canvas framework's own bounds, and they live here +/// because `CreateZoomLevels` reads them off the layer rather than off the frame. +/// +/// `MIN_SCALE` is **1, not less**: the canvas is sized to the layer and its container is the rect +/// that layer used to fill, so scale 1 is already the whole map in the window. A smaller minimum +/// opens the map zoomed out of its own frame, which is how this was first wrong — measurable as a +/// 300x200 canvas floating in a 1002x668 container. +const MIN_SCALE: f64 = 1.0; +const MAX_SCALE: f64 = 4.0; +/// Steps between the two bounds, so the wheel has somewhere to go. Four levels over 1..4 is a +/// factor of about 1.6 a step, which is the step the reference's own zoom buttons take. +const ADDITIONAL_ZOOM_STEPS: u32 = 3; /// The tile count on each axis for a layer, the `ceil` the reference's own loop takes. fn tile_grid() -> (u32, u32) { @@ -267,7 +275,7 @@ pub(super) fn extend_c_map(lua: &Lua, t: &Table) -> mlua::Result<()> { layer.set("tileHeight", TILE)?; layer.set("minScale", MIN_SCALE)?; layer.set("maxScale", MAX_SCALE)?; - layer.set("additionalZoomSteps", 0)?; + layer.set("additionalZoomSteps", ADDITIONAL_ZOOM_STEPS)?; out.set(1, layer)?; Ok(out) })?, diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index 7ed09b6e..74ecdd63 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -8,6 +8,46 @@ use crate::widget::FrameHandle; use super::clip::{effective_clip, scroll_clip_sources}; use super::{colorselect, slider, ExtractedQuad, FontObject, QuadContent, TexCoords, UiScript}; +/// The named point of a rect, in y-up UI space — the one thing a `Line` endpoint needs that the +/// ordinary anchor resolver does not expose, because a line pins to a *point* rather than aligning +/// an edge. +fn point_on(rect: Rect, p: crate::layout::Point) -> (f32, f32) { + use crate::layout::Point; + let x = match p { + Point::TopLeft | Point::Left | Point::BottomLeft => rect.left, + Point::Top | Point::Center | Point::Bottom => (rect.left + rect.right) * 0.5, + Point::TopRight | Point::Right | Point::BottomRight => rect.right, + }; + let y = match p { + Point::TopLeft | Point::Top | Point::TopRight => rect.top, + Point::Left | Point::Center | Point::Right => (rect.top + rect.bottom) * 0.5, + Point::BottomLeft | Point::Bottom | Point::BottomRight => rect.bottom, + }; + (x, y) +} + +/// One `Line` endpoint resolved against whatever it names: a frame, a sibling region, or — for a +/// call with no target — the line's own owner frame. `None` when the target has no resolved rect, +/// which leaves the line undrawn rather than drawn from the origin. +fn line_point( + model: &super::Model, + owner: Option, + end: &super::LineEnd, +) -> Option<(f32, f32)> { + let rect = match end.relative_to { + None => owner.and_then(|o| model.resolved.get(&o).copied()), + Some(id) => match model.id_to_frame.get(&id) { + Some(h) => model.resolved.get(h).copied(), + None => model + .id_to_region + .get(&id) + .and_then(|rh| model.region_resolved.get(rh).copied()), + }, + }?; + let (x, y) = point_on(rect, end.relative_point); + Some((x + end.x, y + end.y)) +} + impl UiScript { /// Every live draw target, visible or not: each frame's slot then its regions, in arena order. /// Handles are generational, so a diff across a load names exactly what the load created. @@ -364,6 +404,41 @@ impl UiScript { || model.chat_bubbles.holds(o) }), } + } else if let Some(line) = + data.line.filter(|l| l.start.is_some() && l.end.is_some()) + { + // A `Line` has no rect of its own: its geometry is two points, so its box + // is the box of those points — which is what gives a caller's `GetLeft` + // and the renderer's clip something true to read. Resolved here rather + // than in the layout pass because the ends name *points* on other + // widgets, which the edge solver has no slot for. + let ends = line + .start + .as_ref() + .and_then(|a| line_point(&model, owner, a)) + .zip(line.end.as_ref().and_then(|b| line_point(&model, owner, b))); + let Some((a, b)) = ends else { + continue; // an end naming something unresolved: no line to draw + }; + let half = line.thickness * 0.5; + rect = Some(Rect::new( + a.1.min(b.1) - half, + a.0.min(b.0) - half, + a.1.max(b.1) + half, + a.0.max(b.0) + half, + )); + let fill = data.fill.or_else(|| data.gradient.map(|g| g.midpoint())); + let has_texture = data.texture.is_some() || fill.is_some(); + QuadContent::Line { + path: data.texture, + color: has_texture + .then(|| texture_color(fill, data.vertex_color)) + .flatten(), + additive: data.blend == crate::script::BlendMode::Add, + start: a, + end: b, + thickness: line.thickness, + } } else { // The draw gate is the texture slot, never the colour (`0x7706e0`: empty // `+0xcc` emits nothing); a vertex colour alone is a tint. A gradient fills diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 06a40563..060923a9 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -294,8 +294,8 @@ pub use trainer::{ }; pub use types::{ BlendMode, EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, - FontShadow, Gradient, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, - QuadContent, ScriptValue, TexCoords, TextureMask, UnitPaneSubject, + FontShadow, Gradient, JustifyH, JustifyV, LineEnd, LineGeometry, LineMeasureRequest, + MeasureRequest, Outline, QuadContent, ScriptValue, TexCoords, TextureMask, UnitPaneSubject, }; pub(crate) use types::{FontExplicit, MeasuredText, RegionData}; pub use uimap::UiMapNode; @@ -355,7 +355,7 @@ pub(crate) const REGION_LEAF_SHARED: [&str; 9] = [ /// Texture-only, each in the client's Texture map (`0x87c128`): `GetVertexColor` though /// `SetVertexColor` is shared, and `SetGradientAlpha`, not FontString's `SetAlphaGradient`. 1.12 /// has no Texture `SetSize` or `SetRotation` (PlayerModel's, `0x84f1fc`/`0x505f00`). -pub(crate) const TEXTURE_ONLY_METHODS: [&str; 22] = [ +pub(crate) const TEXTURE_ONLY_METHODS: [&str; 26] = [ "SetGradient", "SetGradientAlpha", "GetTexture", @@ -380,6 +380,13 @@ pub(crate) const TEXTURE_ONLY_METHODS: [&str; 22] = [ "GetHorizTile", "SetVertTile", "GetVertTile", + // The `Line` region's four. On the Texture table because a Line *is* a Texture here + // (`RegionData::line`), so a plain Texture answers them inertly — the same sharing the title + // region already has with the texture methods. + "SetStartPoint", + "SetEndPoint", + "SetThickness", + "GetThickness", ]; /// FontString-only: the font, text, justify and shadow block and the string metrics. diff --git a/crates/benilla-ui/src/script/region/paint.rs b/crates/benilla-ui/src/script/region/paint.rs index af9210a4..4cc2d2eb 100644 --- a/crates/benilla-ui/src/script/region/paint.rs +++ b/crates/benilla-ui/src/script/region/paint.rs @@ -435,6 +435,103 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { )?; } + // SetStartPoint / SetEndPoint / SetThickness / GetThickness, the `Line` region's own four, + // **beyond 1.12** (surface rows in `script/tests/widget_surface.rs`): 1.12 has no line + // primitive at all, and a texture cannot stand in for one — a line between two moving points + // is a rotated quad whose angle and length both change, which no rect-anchored texture + // expresses. + // + // They sit on the Texture table because a `Line` *is* a Texture here + // (`RegionData::line`): callers paint one with `SetTexture`/`SetColorTexture`/ + // `SetVertexColor` exactly as they paint a texture, and the shared table is what makes that + // work. A plain Texture therefore answers them too and the geometry is simply never read, + // which is the same inert sharing the title region already has. + // + // `SetStartPoint(relativePoint, relativeTo, x, y)` is **not** `SetPoint`: the point names a + // place on the *target*, and nothing on the line, because a line has ends rather than edges. + // A missing target is the line's own owner frame, which is what a two-argument call means. + for (name, is_start) in [("SetStartPoint", true), ("SetEndPoint", false)] { + m.set( + name, + lua.create_function( + move |lua, (this, point, rel, x, y): (Table, Value, Value, Value, Value)| { + let rh = region_handle_of(lua, &this)?; + let point = match &point { + Value::String(s) => { + crate::script::object::point_from_str(s.to_str()?.as_ref()) + } + _ => None, + }; + // An unknown point name is the reference's own fall-through to the first + // slot, which is what an addon's typo gets from `SetPoint` too. + let point = point.unwrap_or(crate::layout::Point::TopLeft); + let relative_to = match &rel { + Value::Table(t) => crate::script::object::decode_id(t).ok(), + // A name resolves through the same `_G` lookup `SetScrollChild`'s does; + // an unknown one leaves the end on the owner, which is where a nil target + // puts it. No caller in the corpus passes a name — every one passes the + // frame — so this exists for the shape rather than for a call site. + Value::String(s) => { + let name = s.to_str()?.to_string(); + let prefetched = crate::script::object::prefetch_named_target( + lua, + name.as_str(), + None, + ); + let model = lua.app_data_ref::().expect("model"); + crate::script::object::resolve_named_target(&model, &prefetched) + } + _ => None, + }; + let end = crate::script::LineEnd { + relative_point: point, + relative_to, + x: as_f32(lua, &x), + y: as_f32(lua, &y), + }; + let mut model = lua.app_data_mut::().expect("model"); + let d = model.region_data.entry(rh).or_default(); + let mut line = d.line.unwrap_or_default(); + if is_start { + line.start = Some(end); + } else { + line.end = Some(end); + } + d.line = Some(line); + Ok(()) + }, + )?, + )?; + } + m.set( + "SetThickness", + lua.create_function(|lua, (this, thickness): (Table, Option)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let d = model.region_data.entry(rh).or_default(); + let mut line = d.line.unwrap_or_default(); + // A non-finite or negative thickness would make the quad's corners meaningless; the + // reference's own floor is a hairline, which is 1. + line.thickness = thickness + .filter(|t| t.is_finite() && *t > 0.0) + .unwrap_or(1.0); + d.line = Some(line); + Ok(()) + })?, + )?; + m.set( + "GetThickness", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + Ok(model + .region_data + .get(&rh) + .and_then(|d| d.line) + .map_or(1.0, |l| l.thickness)) + })?, + )?; + // GetTexture (`0x79ba70`), Texture only, answers one value: "Solid Texture" for the colour // form (`0x835708`), not nil, and a path cut at its last `.` (`0x79baf0`), even a dot in a // directory name, as the client cuts it. diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 4ed8d119..4b78b7d3 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -734,6 +734,12 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> { // same reveal gate, published in the shape the canvas framework reads // (script/compat/classic_api/uimap.rs). "C_MapExplorationInfo", + // `C_Texture.GetAtlasInfo`, which answers nil for every name — see + // `compat/classic_api/misc.rs`. The nil is the answer (1.12 ships no atlas table and no + // atlas sheets), and the *getter's presence* is what makes the common guard + // `not C_Texture or not C_Texture.GetAtlasInfo or C_Texture.GetAtlasInfo(name)` take the + // fallback branch instead of calling `SetAtlas` on a client that has no atlases. + "C_Texture", // 4.x's cross-realm name shortener. 1.12 has no connected realms and no cross-realm // names, so this is the identity on the part that matters; the `-Realm` split is still // applied because a custom realm can hand out a hyphenated name and the caller's whole diff --git a/crates/benilla-ui/src/script/tests/widget_surface.rs b/crates/benilla-ui/src/script/tests/widget_surface.rs index 12c1ce13..14694a68 100644 --- a/crates/benilla-ui/src/script/tests/widget_surface.rs +++ b/crates/benilla-ui/src/script/tests/widget_surface.rs @@ -405,6 +405,51 @@ const ALLOWED_BEYOND: &[(&str, &str, &str)] = &[ the two spellings cannot drift. Ace3 calls it unguarded on every button it builds \ (AceConfigDialog-3.0.lua:586)", ), + ( + "IsMouseOver", + "Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame FontString Texture", + "2.0 moved FrameXML's own `MouseIsOver(region, ...)` onto the widget. 1.12 has the \ + computation and not the method, so this is a method spelling of a function the player's \ + chain already defines — written out rather than forwarded because the argument order \ + differs and because a frame may be asked before UIParent.lua has run. On all three \ + tables because 2.0 put it on Region, and the body reads nothing a region lacks", + ), + ( + "CreateLine", + "Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame", + "A `Line` region: two endpoints and a thickness, drawn as the quad between them. 1.12 has \ + no line primitive and nothing stands in for one — a line between two MOVING points is a \ + rotated quad whose angle and length both change every frame, which no rect-anchored \ + texture expresses. Called unguarded at both sites that need it: a flight route \ + (Map/Stitch.lua:717) and the map's drawing tool (Map/Sketch.lua:377). Built over the \ + stock CreateTexture on the same table, so a line is an ordinary Texture in every respect \ + but its geometry, which is what the callers rely on — they paint it with \ + SetColorTexture and SetVertexColor like any texture", + ), + ( + "SetStartPoint", + "Texture", + "A Line's first end. NOT SetPoint: the point names a place on the TARGET and nothing on \ + the line, because a line has ends rather than edges. On the Texture table because a Line \ + is a Texture here (RegionData::line), so a plain Texture answers it inertly — the same \ + sharing the title region already has with the texture methods", + ), + ( + "SetEndPoint", + "Texture", + "The Line's other end, see SetStartPoint", + ), + ( + "SetThickness", + "Texture", + "The Line's width in px; floored at 1, the reference's hairline, because a non-finite or \ + negative width makes the quad's corners meaningless", + ), + ( + "GetThickness", + "Texture", + "The read half, so the pair round-trips; 1 by default", + ), ( "AddMaskTexture", "Texture", diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index 4981e7f6..feacdd64 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -177,6 +177,18 @@ pub enum QuadContent { /// the host's half. tile: (bool, bool), }, + /// A `Line` region (`Frame:CreateLine`): the quad between two resolved points, `thickness` + /// wide, in the same y-up UI space as [`ExtractedQuad::rect`] — which for a line is the + /// bounding box of the two ends, so a caller's `GetLeft` and the renderer's clip both have + /// something true to read. + Line { + path: Option, + color: Option<[f32; 4]>, + additive: bool, + start: (f32, f32), + end: (f32, f32), + thickness: f32, + }, /// A `ColorSelect`'s `` disc, generated: the pixel at normalised offset /// `(nx, ny)` from the centre is `HSV(atan2(ny, nx)·180/π + 180, min(|n|, 1), 1)`, inverting /// the pick law (`0x78bd80`); the fill draws every texel at `V = 1` (`0x78b68b`), so no colour @@ -377,6 +389,42 @@ pub struct ExtractedQuad { pub scale: f32, } +/// One end of a `Line` region: a named point on another widget, plus an offset. +/// +/// `Line:SetStartPoint(relativePoint, relativeTo, x, y)` is **not** `SetPoint`: it names a point on +/// the *target* and nothing on the line, because a line has no edges to pin — it has two ends. +/// So this is an anchor with the near point left out. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LineEnd { + /// The point on [`Self::relative_to`] this end sits at. + pub relative_point: crate::layout::Point, + /// The target's **script id**, the number a widget wrapper carries — a frame's or a region's, + /// resolved against whichever map holds it. `None` is the line's own owner frame, which is + /// what a call with no target means. + pub relative_to: Option, + pub x: f32, + pub y: f32, +} + +/// A `Line` region's geometry. `None` ends leave the line undrawn, as a line with one end is not +/// a line; the thickness defaults to the reference's 1. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LineGeometry { + pub start: Option, + pub end: Option, + pub thickness: f32, +} + +impl Default for LineGeometry { + fn default() -> Self { + Self { + start: None, + end: None, + thickness: 1.0, + } + } +} + /// A resolved mask over a Texture quad (`Texture:AddMaskTexture`): the mask region's own art and /// the rect it occupies on screen, in the same y-up UI space as [`ExtractedQuad::rect`]. /// @@ -516,6 +564,17 @@ pub(crate) struct RegionData { /// host repeats the art across the region at its own texel size when no explicit `SetTexCoord` /// says otherwise. pub(crate) tile: (bool, bool), + /// `Frame:CreateLine`'s geometry. `Some` makes this region a **Line**: a Texture carrying two + /// endpoints instead of a rect, drawn as the quad between them. + /// + /// A flag on a Texture rather than a `RegionKind` of its own, and the reason is what callers + /// do with it: every paint method — `SetTexture`, `SetColorTexture`, `SetVertexColor`, + /// `SetBlendMode` — is used on a line exactly as on a texture, so sharing the Texture method + /// table is not a shortcut but the behaviour. The same footing as the title region, which + /// shares the Region table and accepts the texture methods inertly. The one visible deviation + /// is `GetObjectType()`, which answers `"Texture"` where retail answers `"Line"`; nothing in + /// the corpus asks a line what it is. + pub(crate) line: Option, /// The font object this FontString inherits (`FONTINSTANCE+0x028`, a FontString's `+0xd0`), a /// live link: its paint is copied below, and /// [`script::font::propagate`](super::font::propagate) repaints on a change. diff --git a/crates/benilla-ui/src/script/uimap.rs b/crates/benilla-ui/src/script/uimap.rs index 99a8a92a..d6418789 100644 --- a/crates/benilla-ui/src/script/uimap.rs +++ b/crates/benilla-ui/src/script/uimap.rs @@ -268,6 +268,12 @@ pub fn build_graph( // has three. Measured: `SetMapZoom(3, 0)` then `GetMapInfo()` answers `World`, and because // the row carries `Map.dbc` id 0 its zone list is Eastern Kingdoms' own, re-collected. // + // **The realm's own interface drops it too**, which is the authority for doing so here: + // its `Interface\FrameXML\Overrides.lua` sets `CONTINENTS_LENGTH = 2` and replaces + // `GetMapContinents` with a wrapper returning exactly Kalimdor and Azeroth, discarding any + // further row the chain's own tables carry. So the window the player sees has two + // continents; a graph with three would be the one thing on this client that disagreed. + // // Left in, it is a phantom continent on the sheet holding a second copy of all 39 Eastern // Kingdoms zones: `GetMapInfoAtPosition` could walk into it, and a caller caching per-map // art would build every one of those zones twice. The row is kept out rather than merged, diff --git a/crates/benilla-ui/src/script/worldmap.rs b/crates/benilla-ui/src/script/worldmap.rs index 1b3dd55d..d869052e 100644 --- a/crates/benilla-ui/src/script/worldmap.rs +++ b/crates/benilla-ui/src/script/worldmap.rs @@ -716,6 +716,68 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; + // `BenillaMapIDForSelection()` / `BenillaSelectMapID(uiMapID)`: the two directions between + // the 1.12 selection and the uiMapID graph ([`crate::script::uimap`]). + // + // **Not an Era API, and named so.** Era's canvas map owns `SetMapID`/`GetMapID` outright and + // never has to agree with anything else, because its client has no second map. This one does: + // the stock `WorldMapFrame` draws from the `(continent, zone)` selection and this install's + // eight map addons anchor their icons to the frames that selection paints. So the shipped + // canvas (`assets/ui/MapCanvas.lua`) moves *both* when the player changes map, and these are + // the join — a selection has a uiMapID and a uiMapID has a selection, by the one graph, so the + // canvas and the stock window can never show different places. + // + // The `Benilla` prefix is the reference-surface wall's own exemption for exactly this: a verb + // that is ours, that an addon must never feature-detect, and that is honest about not being + // 1.12's. The alternative was putting the selection on `C_Map.GetMapInfo`'s table, which would + // have been a field Era does not have on a table addons read. + g.set( + "BenillaMapIDForSelection", + lua.create_function(|lua, ()| { + let model = lua.app_data_ref::().expect("model app_data"); + let wm = &model.worldmap; + let node = match wm.direct_area { + Some(row) => crate::script::uimap::node_for_direct_area(wm, row), + None => crate::script::uimap::node_for_selection(wm, wm.selection), + }; + Ok(node.map(|n| n.ui_map_id)) + })?, + )?; + g.set( + "BenillaSelectMapID", + lua.create_function(|lua, ui_map_id: Option| { + let moved = { + let mut model = lua.app_data_mut::().expect("model app_data"); + let Some(target) = ui_map_id + .and_then(|id| crate::script::uimap::node(&model.worldmap, id)) + .map(|n| (n.selection, n.direct_area)) + else { + return Ok(crate::script::binding_abi::flag(false)); + }; + match target { + // A continent or zone: the ordinary pair, through the same clamp `SetMapZoom` + // takes, so an id whose selection the catalog has since dropped lands where a + // stale `SetMapZoom` would. + (Some((c, z)), _) => { + select(&mut model, i64::from(c), i64::from(z)); + true + } + // An instance map: the third selection cell, exclusive with the pair. + (None, Some(row)) => { + store_selection(&mut model, (0, 0), Some(row)); + true + } + // A node with neither — nothing the window can display. + (None, None) => false, + } + }; + if moved { + signal_world_map_update(lua); + } + Ok(crate::script::binding_abi::flag(moved)) + })?, + )?; + // SetMapToCurrentZone(): the resolver's whole answer, orphan leg included (`0x4a7e20` is // `call 0x4a6650; xor eax,eax; ret`). g.set( diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index d5d006f2..31722db8 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -460,6 +460,7 @@ a loot roll"; it does not, because that module never runs.** The call path was n | `C_EncodingUtil` (8), `C_FriendList.SetFriendNotes`, `C_Item.GetCurrentItemLevel`, `C_Map.GetAreaTriggerInfo`, `C_SpellBook.GetSkillLineRank`, `C_TaxiMap.GetTaxiNodesForMap`, `C_Spell.IsSpellInRange`/`IsNextMeleeSpell`/`IsRangedAutoAttackSpell`/`ResetsMeleeSwing`, `C_Item.RequestLoadItemDataByID`, `C_LootHistory.*` | not built | Each either guarded by its caller (all of pfQuest's) or behind a user action not yet taken: `SetFriendNotes` on editing a friend note, `SerializeJSON` on sharing a config, `GetCurrentItemLevel` behind `if ShaguScore` (an addon not installed), the hunter range pair on a hunter's bar. None raises in ordinary play as installed | | `C_Map`'s uiMapID half (14 members), `C_MapExplorationInfo`, `Enum.UIMapType`, `CreateVector2D`, `UiMapPoint` — **the map graph** | done | `script/uimap.rs` builds the graph, `compat/classic_api/uimap.rs` publishes it; supersedes the "nine waypoint members, the layered-art trio, the four-member projection family" clause of the `C_Map.GetAreas`/`GetAreaTriggerInfo` row above. **What 1.12 withholds is not a getter, it is the tree.** Its map is addressed by a `(continent, zone)` pair of *dropdown indices* — a position in two lists, which moves when a zone is inserted — and it publishes no map's rectangle on any other map at all, because the window draws one zone at a time into a fixed 1002x668 frame and never needs to know where that zone sits. A seamless, pannable map is exactly that missing relation: one rectangle per map composed up to a common root. **Built from the realm's own catalog, which is the point.** OctoWoW's `WorldMapArea.dbc` carries 175 rows against stock 1.12's 51 — 124 added (`Alah'Thalas`, `Balor`, `Blackstone Isle`, `Gilneas`, `Grim Reaches`, `Icepoint`, `Lapidis Isle`, `Moonwhisper`, `Northwind`, `Tel'Abim`, `Thalassian Highlands`, the Timbermaw tunnels, Hyjal) and 49 of the shipped rows' world rects re-tuned, measured with `dbc_diff`. A graph transcribed from Classic Era would be the right shape describing a different world. No new DBC read: the graph is a pure function of the catalog `ui_world_map.rs` already pushes, rebuilt inside `set_world_map_catalog`/`set_world_map_direct_areas` so the two cannot disagree. **Era's ids are reused where the map is the same map**, keyed on the `WorldMapArea` **art folder** rather than the zone name, because the folder is the column a realm has no reason to touch — this realm spells Azshara's `Aszhara` and Hillsbrad's `Hilsbrad`, exactly as 1.12 does, while its localized names are the ordinary ones. That join is what lets an Era addon's own uiMapID-keyed data (level ranges, colour grading, flight paths) find this realm's zones. The ids are **read off the consumer's own data files**, which carry the zone name in a comment beside each (`Data/Levels.lua`, `Data/Grade.lua`, `Data/Tones.lua` give the 40 outdoor zones) plus its source constants for the sheet (`WORLD = 947`), the two continents (`CONT_OF = { [1415] = 0, [1414] = 1 }`) and three cities; the other three cities are the gap that contiguous block leaves. A row with no Era counterpart is minted at `100000 + areaID` — unique because `AreaTable` ids are, stable because the id is the realm's own, and clear of anything Era can add. **One realm quirk had to be handled and it is measurable:** this install's `WorldMapArea.dbc` carries a *third* `areaID == 0` row whose art folder is `World` — the world sheet's own row — and the reference's catalog walk (`0x4a5d00`, which selects continents by exactly that column) files it beside Kalimdor and Azeroth. `SetMapZoom(3, 0)` then `GetMapInfo()` answers `World`, and because the row carries `Map.dbc` id 0 its zone list is Eastern Kingdoms' own re-collected, so left in it is a phantom continent holding a second copy of all 39 Eastern Kingdoms zones. It is kept out of the graph rather than merged into the sheet, because its `loc` rect is in map 0's coordinate space while the sheet spans both continents. **Art is answered as paths, not fileIDs**: `GetMapArtLayerTextures` hands back `Interface\WorldMap\\N`, so the asset chain serves whichever copy wins — on this install the upscaled pack, `patch-M.mpq`, three copies deep. The 1002x668 / 256x256 layer geometry is the **logical** size, not the shipped pixel size, because the drawn fractions are normalized and a uniform upscale leaves them unchanged; that is the same invariance `map.rs`'s `art_scale` has to *correct* for, and only because `GetMapOverlays` publishes absolute pixels. `C_MapExplorationInfo.GetExploredMapTextures` is the same `WorldMapOverlay` rows and the same reveal gate the stock window draws (`WorldMapFrame.lua:107-160`), in the shape a canvas reads. **Not built, listed rather than stubbed**: `GetMapPosFromWorldPos` (an inverse search over every rect, no caller), `GetMapGroupID`/`GetMapLinksForMap`/`GetBountySetIDForMap` (about maps this client has no concept of) and `C_MapExplorationInfo.GetExploredAreaIDsAtPosition`. A member answering a plausible-looking nothing is worse than an absent one, which `if C_Map.X then` can see. **One harness gap closed with it**: `seat_a_session` now seats the player a world *position* and the per-frame map feed the live client pushes every frame, because every verb that answers *where* the player is reads the position rather than the zone name — with none answering, a map addon draws its art and no player arrow, which the survey's "drew something" column cannot tell from drawing nothing. Corpus roster unchanged by both: 85 ok / 1 fail, digest identical | | Texture masking, the later-era texture dials, `CreateMaskTexture`, `SetPropagateKeyboardInput`, `EditBox:HasFocus`/`SetCursorPosition`, the frame and font-string pools, `Ambiguate`, `IsModifiedClick`, `GetQuestDifficultyColor` — **what a canvas map draws with** | done | **The mask pair is the one that matters and the one nothing can stand in for.** 1.12 has no texture masking at all — its single masked thing is the minimap disc, cut engine-side — and no idiom reaches the same end, because the whole point is that a *rectangular* texture draws as a non-rectangular shape. A seamless world map is that operation and nothing else: a zone's art is a rectangle of parchment and the map wants it cut to the zone's coastline, so without it every zone draws as a paper rectangle over its neighbours' oceans and labels. The consumer's own changelog names the symptom ("no overlapping parchment rectangles / duplicated sea labels"), and it calls `AddMaskTexture` **unguarded** (`Map/Close.lua:162`). It was affordable because the render pass already had the slot: `UiQuad::mask` is a **screen-anchored** alpha mask, built for the minimap's fixed circle with tiles panning under it — which is exactly `AddMaskTexture`'s semantics, since the mask is another region with its own anchors and the subject keeps only the fragments its alpha covers *at that place*. So the work was plumbing, not a new material: `RegionData::masks` holds the handles, `extract` resolves the first that has both art and a resolved rect, and the host converts it through the same y-flip as the ScrollFrame clip. **One mask per quad, stated rather than hidden**: widening it is a second texture binding and a second uniform on every UI quad in the client. The list is kept whole anyway, because the methods must round-trip and because the *order* decides the winner — first added, which for a stitched map is the land mask, the one that shapes the coast. A second mask is recorded and not applied. Two traps are pinned by tests: a mask region is **skipped at extract rather than marked hidden**, because its rect is what it covers and hiding it would take it out of the layout too; and a mask whose art the archives lack drops the **mask**, not the quad — honouring zero coverage would blank the art it was meant to shape, which reads as the map's own art failing to load. **`SetRotation` on a Texture** was almost free: 1.12 registers it on PlayerModel alone (`0x84f1fc`) but the renderer has carried `RegionData::rotation` since the minimap arrow, so this is the Lua setter for an angle the quad path already negates for the pass's clockwise convention. The map's player arrow and compass rail need it. **`SetDesaturation`** is the fractional form of 1.12's own `SetDesaturated`, rounded onto the one material flag this renderer has (it greys all-or-nothing, not by weight) with the getter answering the caller's own fraction — an approximation, and the honest one, since the only corpus use is dimming art the caller has already decided to dim. **`SetHorizTile`/`SetVertTile` are applied, not stored**: 1.12 reaches the same end with `SetTexCoord(0, n, 0, 1)`, the idiom this renderer already keys its wrap mode on, but has no switch meaning "however many times it takes" because that count is `rect / texels` and 1.12's Lua is never told the texel size — so the flag rides across and the host computes the count once the art has loaded, with an explicit `SetTexCoord` winning. **`SetPropagateKeyboardInput` is stored, not applied**, and the reason is that 1.12's answer is already always-propagate: its key routing hands the key to the binding system regardless of what a handler did, so there is no swallow mode for `false` to select. Refusing it costs a caller its whole key path — `Map/Sketch.lua:579` arms a catcher frame and flips the flag *inside its own handler*, so a nil method raises once per keypress. **`EditBox:HasFocus` publishes a fact this client already tracks** (`Model::focused_editbox`, the reference's own `DAT_00cf4dc8`) and simply never exposed; it is called unguarded inside an `OnEditFocusLost` callback, so a nil method raised every time a search box lost focus. `SetCursorPosition` lands through `HighlightText`'s own clamp and UTF-8 snap with the selection collapsed, so there is one definition of where a caret may sit. **`CreateFramePool`/`CreateFontStringPool`** supersede the "not built: nothing calls either" note in `pools.rs` — a canvas map pools a frame per pin and a FontString per label, and calls both at file scope the way pfUI calls `CreateTexturePool`. Upstream's mixin and default-resetter names are kept, because a caller passing `FramePool_HideAndClearAnchors` **by name** needs them. The frame factory names its frames `--` as upstream does, which matters: a pooled frame has to be reachable by name for a template's `$parent` children to resolve. **`IsModifiedClick` composes two verbs this client already answers** rather than adding state: 1.12 has the modified-click *bindings* (`GetModifiedClick`/`SetModifiedClick`) and its own FrameXML tests the key itself, but registers no predicate doing both steps. `GetQuestDifficultyColor` is a **forwarder** to 1.12's own FrameXML `GetDifficultyColor`, so the colours are the player's chain's and the two spellings cannot drift. `Ambiguate` is the identity on the part that matters — 1.12 has no connected realms, so a name here carries no realm — with the `-Realm` split still applied, because a custom realm can hand out a hyphenated name. **Three stay unbuilt, with reasons**: `GetUnitSpeed` (1.12 sends no speed field for any unit, so the only honest answer is the absent one — and its caller already falls back to a distance-over-time estimate of its own, which is a better number than anything this could invent), `GetAddOnMemoryUsage`/`UpdateAddOnMemoryUsage` (per-addon attribution needs a per-addon Lua allocator this VM does not have; both callers are guarded and print a debug line), and `Texture:SetAtlas`/`C_Texture.GetAtlasInfo` (1.12 ships no atlases, and every call site here is gated on `GetAtlasInfo` answering, so the absence *is* the working path). Corpus roster unchanged: 85 ok / 1 fail, digest identical; the 12 data-half reds are byte-identical to HEAD's | +| The Era **map canvas** (`assets/ui/MapCanvas.lua`), `Frame:CreateLine`, `C_Texture.GetAtlasInfo`, `Frame:IsMouseOver` — **openmap draws** | done | **What 1.12 withholds here is a framework, not a verb.** Its world map is a fixed 1002x668 frame showing one zone: twelve detail tiles, a hit button over them, POI buttons in its coordinates, two dropdowns. No canvas, no pan, no zoom, no pins, and no notion of where one map sits on another. Every map addon written after 4.0 drives the *canvas* framework instead — a scaled, pannable child frame, pins acquired from pools at named frame levels, and data providers notified on every map, view and scale change — and there is no stock 1.12 file to migrate from because there is no stock feature. Same footing as `CompatTemplates.xml`: a FrameXML-era name addons reach for, which this client provides or the addon does not run. **The one constraint that shaped every decision: `WorldMapFrame` already has eight consumers on this install** (Atlas-CFM, FlightMap, LevelRange-Turtle, pfQuest, pfUI, pfUI-locplus, SoftSpoken, SoftSpoken_Places), all 1.12 addons that anchor icons to `WorldMapDetailFrame` and `WorldMapButton` and read the `(continent, zone)` selection. So nothing is replaced: the canvas is **added** as `WorldMapFrame.ScrollContainer` and reparents those two frames **into** itself. At rest it is the rect they had, at scale 1 with no pan, so the stock window is pixel-identical — and the payoff is that the 1.12 art and every icon anchored to it now pan and scale **with** the canvas for free, which no amount of per-addon work could achieve. `SetMapID` moves the 1.12 selection too (`BenillaSelectMapID`/`BenillaMapIDForSelection`, the one join between the selection and the graph), so the two can never show different places. **Clipping comes from a real ScrollFrame**: Era's container is a plain Frame with `SetClipsChildren`, which this client has no verb for, but it clips a ScrollFrame's scroll child *and every descendant of it* from one shared source (`script/clip.rs`) in both the draw pass and the pointer hit-test — so the canvas is a ScrollFrame and drawing and hitting agree. **The base-art layer is a proxy, not the frame.** A canvas addon hides `detailLayerPool`'s layers while it draws its own; `Hide()` on `WorldMapDetailFrame` would take the POI buttons and every addon's icons down with it, because they are its children and its siblings' children. Switching off its BACKGROUND and ARTWORK **draw layers** removes exactly the stock tiles, the exploration overlays and the hover highlight — regions, all three — and leaves every child frame drawing. **Three regressions found by measurement, each pinned by a test, each of which looked like something else.** (1) *The anchor cycle*: anchoring the container to the detail frame and then reparenting that frame into the container's 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 the client's file. (2) *The base scale*: a layer whose `minScale` is below 1 opens the map zoomed **out** of its own window, measurable as a 300x200 canvas floating in a 1002x668 container; the canvas is sized to the layer and the container is the rect that layer filled, so 1 is already the whole map. (3) *`dataProviders` is a set, not an array*: callers iterate it with `for provider in pairs(...)`, which binds the **key**, so an array handed the 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. The same trap `pools.rs` already names for `activeObjects`. **`Frame:CreateLine`** is a new region shape and nothing stands in for it: a line between two *moving* points is a rotated quad whose angle and length both change every frame. It is a Texture carrying two endpoints (`RegionData::line`) rather than a `RegionKind` of its own, because callers paint a line with `SetColorTexture` and `SetVertexColor` exactly as they paint a texture; it draws through `UiQuad::corners`, the explicit-corner path the cooldown wedge already uses. Its rect is the box of its ends plus half the thickness, so `GetLeft` and the clip have something true to read. **`C_Texture.GetAtlasInfo` answers nil for every name, and the nil is the answer** — 1.12 ships no atlas table and no atlas sheets — but registering the *getter* is what makes the common guard `not C_Texture or not C_Texture.GetAtlasInfo or C_Texture.GetAtlasInfo(name)` take its fallback branch instead of calling `SetAtlas` on a client that has no atlases; with the namespace absent the first two disjuncts are true, and that raise is what disabled the addon one step earlier. **`Frame:IsMouseOver`** is a method spelling of FrameXML's own `MouseIsOver`, which 1.12 has as a function and 2.0 moved onto the widget. **The harness grew a second surface, and that is a measurement finding worth keeping.** 1.12's world map is a `UIPanelWindows` entry with `area = "full"`, and its own `ShowUIPanel` **hides `UIParent`** — so while the map is up nothing else in the interface draws at all. Opening it at the end of the ordinary render probe dropped the corpus's "drew something" column from 37/86 to **18/86**, and the nineteen it lost were ordinary addons whose frames hang off `UIParent` (minimap buttons, a threat meter, an auction helper), none of which had changed. So the map is its own pass, read after the ordinary one and unioned in, and the stock map is opened and closed once **before** the render baseline so the regions its first update creates lazily (`WorldMapOverlay*`, `WorldMapFramePOI*`) are the client's and not the surveyed addon's. Per-addon result: **no addon lost a single quad and nineteen gained** — pfQuest 374 → 911, pfUI 1063 → 2259, ShaguDPS 363 → 938, Atlas-CFM 294 → 410, and SoftSpoken_Places moved verdict from `overlay(3)` to `own(50)`. That whole surface had simply never been driven. **Result**: OpenMapClassic now reads 1/1 on all five columns — loads, session start, UI probe, **drew something**, **survived being used** — with the stitched map active over **74 of the realm's own zones**, its custom ones included (the harness's seated zone resolves to `102040`, a minted id for Alah'Thalas). Corpus roster unchanged at 85 ok / 1 fail; the 12 data-half reds byte-identical to HEAD's | ## The offline UI-load harness — why 349 tests were failing, and what is fixed