diff --git a/crates/benilla-app/assets/ui/MapCanvas.lua b/crates/benilla-app/assets/ui/MapCanvas.lua index 9b9b3a19..aa6fa061 100644 --- a/crates/benilla-app/assets/ui/MapCanvas.lua +++ b/crates/benilla-app/assets/ui/MapCanvas.lua @@ -54,9 +54,17 @@ MAP_CANVAS_MOUSE_WHEEL_ZOOM_BEHAVIOR_SMOOTH = 2 -- 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. +-- How fast the view approaches its target, per second, as a fraction closed: the ease closes +-- `1 - 0.5^(elapsed * RATE / 7)` of the remaining gap, so the half-life is `7 / RATE` seconds. +-- +-- The scale's was 10 — a 0.7 s half-life, still visibly moving two seconds after the last wheel +-- notch. That was tolerable only while a notch could not accumulate, because the target was never +-- more than one rung away; once notches add up the glide is the thing you feel. grom, 2026-11-04: +-- *"scroll is better but feels pretty slippery, like scrolls past where I stop scrolling."* At 24 +-- the half-life is 0.29 s and a multi-rung spin lands about where the wheel stopped. The pan's is +-- left alone: dragging writes the scroll directly and never eases. local PAN_RATE = 10 -local SCALE_RATE = 10 +local SCALE_RATE = 24 -- A drag under this many pixels is still a click, which is what `WouldCursorPositionBeClick` -- answers; Era's own threshold. local CLICK_SLOP = 5 @@ -1842,10 +1850,24 @@ local function BuildCanvas() 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) + -- The container takes the mouse as well, UNDER 1.12's `WorldMapButton`. + -- + -- The button was the whole mouse surface here, on the reasoning that it covers the same rect + -- and sits above this in the tree. That holds at rest and stops holding the moment a canvas + -- addon scales the canvas: the button is pinned to the canvas child, so at a scale below 1 it + -- shrinks away from the container and leaves the rest of the visible map with no mouse + -- surface at all. A press there reaches the next frame that takes the mouse, which with pfUI + -- loaded is `WorldMapFrame` itself -- `modules/map.lua:45` makes the whole window a + -- `RegisterForDrag("LeftButton")` handle -- so dragging the map moved the window instead of + -- panning, and whether it did depended on the zoom and where you grabbed. grom, 2026-11-04: + -- *"sometimes its panning the map as it should and sometimes its grabbing and moving the + -- whole window, this should only happen on the borders."* + -- + -- The button still wins wherever it reaches, being deeper; this only catches what used to + -- fall through the floor, and it drives the same controller, so a pan begun on either is one + -- gesture. It takes no clicks (`RegisterForClicks` is never called), so it cannot swallow a + -- click the map's own surface would have had. + sc:EnableMouse(true) -- ...and the container crops everything below it as well, which the ScrollFrame alone does -- not: that crops the scroll child's subtree, and an addon is free to parent a layer to the @@ -1997,6 +2019,43 @@ local function BuildCanvas() button:EnableMouseWheel(true) button:RegisterForClicks("LeftButtonUp", "RightButtonUp", "MiddleButtonUp") + -- ...and the same three off the container, for the part of the window the button has shrunk + -- away from. `OnUpdate` is the button's alone: the controller eases once a frame, and two + -- callers would ease it twice. + -- + -- The container must out-level the map window, and the button must out-level the container. + -- + -- The hit sweep probes one `(strata, level)` plane in **link** order, and a parent is linked + -- before its children (`0x764aa0`; `TargetFrame.lua:33` relies on it), so a frame tied with + -- its own parent loses every press to it. Tied with `WorldMapFrame` the container took + -- nothing, which is a mouse-enabled container that still handed the drag to pfUI. + -- + -- Re-asserted on every show, not seated once: `WorldMapFrame` is level 0 while this builds + -- and 1 once `ShowUIPanel` has raised it, so a level computed at build time ties the moment + -- the window is first opened. + local function SeatLevels() + local base = map:GetFrameLevel() or 0 + sc:SetFrameLevel(base + 1) + if canvas:GetFrameLevel() <= sc:GetFrameLevel() then + canvas:SetFrameLevel(sc:GetFrameLevel() + 1) + end + if button:GetFrameLevel() <= canvas:GetFrameLevel() then + button:SetFrameLevel(canvas:GetFrameLevel() + 1) + end + end + SeatLevels() + map:HookScript("OnShow", SeatLevels) + sc:SetScript("OnMouseDown", function() + sc:OnMouseDown(arg1) + end) + sc:SetScript("OnMouseUp", function() + sc:OnMouseUp(arg1) + end) + sc:SetScript("OnMouseWheel", function() + sc:OnMouseWheel(arg1) + end) + sc:EnableMouseWheel(true) + -- 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 diff --git a/crates/benilla-app/src/ui_script/map_canvas_tests.rs b/crates/benilla-app/src/ui_script/map_canvas_tests.rs index 74195374..1ab03c48 100644 --- a/crates/benilla-app/src/ui_script/map_canvas_tests.rs +++ b/crates/benilla-app/src/ui_script/map_canvas_tests.rs @@ -538,3 +538,51 @@ fn wheel_notches_accumulate_while_the_zoom_is_still_easing() { .unwrap(); assert_eq!((lo, hi), (1.0, 10.0), "the ladder's own ends hold"); } + +/// The container takes the mouse, and out-levels the map window so it actually gets it. +/// +/// The map's mouse surface used to be 1.12's `WorldMapButton` alone, which is pinned to the +/// canvas child — so once a canvas addon scales the canvas down, the button shrinks away from +/// the container and the rest of the visible map has no mouse surface at all. Measured at the +/// world view with OpenMapClassic driving: a 23x16 px button inside a 701x468 px window. A press +/// on the other 99% reached the next frame that takes the mouse, which with pfUI loaded is +/// `WorldMapFrame` itself — a `RegisterForDrag("LeftButton")` handle for the whole window. +/// +/// The level matters as much as the mouse bit: the hit sweep probes one `(strata, level)` plane +/// in link order and a parent is linked before its children, so a container tied with its own +/// parent loses every press to it. +#[test] +fn the_container_takes_the_mouse_and_out_levels_the_window() { + let Some(mut s) = harness() else { return }; + s.run("ShowUIPanel(WorldMapFrame)").unwrap(); + s.resolve(); + let (mouse, wheel, has_down, sc_level, map_level, button_level): ( + bool, + bool, + bool, + i64, + i64, + i64, + ) = s + .eval( + "local sc = WorldMapFrame.ScrollContainer \ + return sc:IsMouseEnabled() and true or false, \ + sc:IsMouseWheelEnabled() and true or false, \ + sc:GetScript(\"OnMouseDown\") ~= nil, \ + sc:GetFrameLevel(), WorldMapFrame:GetFrameLevel(), WorldMapButton:GetFrameLevel()", + ) + .unwrap(); + assert!(mouse, "the container takes the mouse"); + assert!(wheel, "...and the wheel, for the same bare area"); + assert!(has_down, "...and drives the controller from it"); + assert!( + sc_level > map_level, + "the container ({sc_level}) must out-level the map window ({map_level}), or the sweep \ + gives the press to the parent" + ); + assert!( + button_level > sc_level, + "and 1.12's own surface ({button_level}) stays above the container ({sc_level}), so it \ + keeps every press it does reach" + ); +} diff --git a/crates/benilla-ui/src/script/compat/classic_api/mod.rs b/crates/benilla-ui/src/script/compat/classic_api/mod.rs index de869c6f..bf47cbc8 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/mod.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/mod.rs @@ -85,6 +85,7 @@ mod spellbook; mod state; mod stdlib_backport; mod string_methods; +mod string_trim; mod table_util; mod taxi_map; mod timer; @@ -165,6 +166,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { g.set("C_Map", c_map)?; g.set("C_MapExplorationInfo", uimap::install_exploration(lua)?)?; uimap::install_globals(lua)?; + uimap::install_all_textures(lua)?; taxi_map::install(lua)?; g.set("C_PlayerCache", player_cache::install(lua)?)?; g.set("C_PlayerInfo", player_info::install(lua)?)?; @@ -200,6 +202,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { tooltip::install(lua)?; stdlib_backport::install(lua)?; string_methods::install(lua)?; + // After `string_methods`: `string.trim` is reached as `("x"):trim()` through that metatable. + string_trim::install(lua)?; table_util::install(lua)?; // After `item::install`: the mixin's `IsValid` calls `C_Item.DoesItemExist`. Only at *call* // time, as `color_mixin.rs` explains for `CreateColor`, so the order is for the reader rather diff --git a/crates/benilla-ui/src/script/compat/classic_api/string_trim.rs b/crates/benilla-ui/src/script/compat/classic_api/string_trim.rs new file mode 100644 index 00000000..374581ee --- /dev/null +++ b/crates/benilla-ui/src/script/compat/classic_api/string_trim.rs @@ -0,0 +1,112 @@ +//! `strtrim` and `string.trim`, which 1.12 has and this client deliberately did not. +//! +//! `script/stdlib.rs` lists `strtrim` under "NOT here", with the reason: 1.12 has no such global, +//! no modified-1.12 UI ships a caller, and "an addon reaching for one of these is reaching for a +//! later client's API, and it should find what it would find there: nothing." That reasoning is +//! about the **1.12** surface and it still holds there, which is why this is in `compat/` instead +//! — on the client those addons are written for, the name is present. +//! +//! The evidence is a live defect rather than a survey. OpenMapClassic's `Map/Pins.lua:57` is +//! `text = strtrim(text)` on the path that stores a dropped map pin, so with the global nil +//! *every pin drop raised and no pin was ever saved* — grom, 2026-11-04: *"still can't drop a pin +//! like intended."* The rest of the corpus, bare calls only (a library that defines its own under +//! its namespace does not count): +//! +//! | caller | sites | +//! |---|---| +//! | `OpenMapClassic/Libs/AceConfig-3.0/AceConfigCmd-3.0` | 5, via `string.trim` | +//! | `OpenMapClassic/Map/Pins.lua`, `Map/Paint.lua` | 2 | +//! | `pfUI/api/ui-widgets.lua:1472` | 1, every keystroke in a search box | +//! +//! Both spellings, because `AceConfigCmd-3.0.lua:31` takes the member (`local … strtrim = +//! string.trim`) while everything else calls the global. The member also gives `("x"):trim()` for +//! free through [`super::string_methods`]'s metatable, which is how Ace3 reaches it in later +//! versions. +//! +//! The two-argument form is Blizzard's: `strtrim(s, chars)` strips any character of `chars` from +//! both ends, and with no `chars` it strips whitespace. `script/stdlib.rs`'s own TurtleWoW `trim` +//! is the one-argument shape and is left exactly as it is — this does not touch it. + +use mlua::Lua; + +/// Written in Lua rather than Rust so the pattern engine is the client's own: a Rust +/// `trim_matches` would be a second, subtly different implementation of the same thing, and +/// `%s` here must mean what every other pattern in the VM means by it. +const STRING_TRIM: &str = r#" +function strtrim(s, chars) + if s == nil then return nil end + s = tostring(s) + if chars == nil or chars == "" then + -- The shape every corpus caller writes by hand when it defines its own. + return (gsub(s, "^%s*(.-)%s*$", "%1")) + end + -- A character class built from the argument, with every non-alphanumeric escaped so a + -- trimmed "]" or "%" is a character and not pattern syntax. + local set = "[" .. gsub(chars, "(%W)", "%%%1") .. "]" + s = gsub(s, "^" .. set .. "*", "") + return (gsub(s, set .. "*$", "")) +end + +-- The member form, which is the same function: `strtrim(s)` already takes the string first, so +-- `("x "):trim()` and `string.trim("x ")` and `strtrim("x ")` are one call. +string.trim = strtrim +"#; + +pub(super) fn install(lua: &Lua) -> mlua::Result<()> { + lua.load(STRING_TRIM) + .set_name("classic_api/string_trim") + .exec() +} + +#[cfg(test)] +mod tests { + use crate::script::UiScript; + + /// OpenMapClassic's own line, the one that ate every dropped pin. + #[test] + fn strtrim_strips_whitespace_from_both_ends() { + let s = UiScript::new().unwrap(); + assert_eq!( + s.eval::(r#"return strtrim(" a pin note ")"#) + .unwrap(), + "a pin note" + ); + assert_eq!(s.eval::(r#"return strtrim("")"#).unwrap(), ""); + assert_eq!(s.eval::(r#"return strtrim(" ")"#).unwrap(), ""); + // Inner whitespace is not touched, which is what makes it a trim and not a squeeze. + assert_eq!( + s.eval::(r#"return strtrim(" a b ")"#).unwrap(), + "a b" + ); + } + + /// The `chars` form, and that a pattern character in it is a character. + #[test] + fn the_second_argument_names_the_characters_to_strip() { + let s = UiScript::new().unwrap(); + assert_eq!( + s.eval::(r#"return strtrim("xxhelloxx", "x")"#) + .unwrap(), + "hello" + ); + assert_eq!( + s.eval::(r#"return strtrim("%%hi%%", "%")"#) + .unwrap(), + "hi" + ); + assert_eq!( + s.eval::(r#"return strtrim("]]hi]]", "]")"#) + .unwrap(), + "hi" + ); + } + + /// Both spellings are the one function, which is what `AceConfigCmd-3.0.lua:31` needs when it + /// binds `local strtrim = string.trim` and then calls it with the string first. + #[test] + fn the_global_the_member_and_the_method_are_one_call() { + let s = UiScript::new().unwrap(); + assert!(s.eval::("return string.trim == strtrim").unwrap()); + assert_eq!(s.eval::(r#"return (" m "):trim()"#).unwrap(), "m"); + } +} 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 281f5c84..9dd8ad5b 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/uimap.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/uimap.rs @@ -661,6 +661,60 @@ pub(super) fn install_exploration(lua: &Lua) -> mlua::Result { Ok(t) } +/// `BenillaMapTextures(uiMapID)` — every overlay a map has, explored or not, in +/// `GetExploredMapTextures`' own shape with an `explored` flag added. +/// +/// Era's API has no such call because Era's client has no use for one: a modern map addon that +/// wants to show unexplored ground ships a **table of FileDataIDs** scraped from the retail +/// client (OpenMapClassic's `ns.MapReveal`, 41 maps of them) and hands those straight to +/// `SetTexture`. Those ids name retail files; this client has none of them, so reveal mode drew +/// exactly nothing and "show unexplored areas" was a switch with no effect — measured, the same +/// view with the fog `default` and `reveal` is pixel-identical. +/// +/// The overlays themselves are not missing: `WorldMapOverlay.dbc` has every one of them with its +/// texture and its rect, and the explored half already draws from exactly this function. All +/// that was missing was a way to ask for the other half. Named for this client rather than +/// dressed as a `C_` member, as `BenillaMapIDForSelection` is, so no addon mistakes it for an +/// API it can expect elsewhere; the port that uses it feature-tests the name. +pub(super) fn install_all_textures(lua: &Lua) -> mlua::Result<()> { + lua.globals().set( + "BenillaMapTextures", + lua.create_function(|lua, ui_map_id: Option| { + let model = lua.app_data_ref::().expect("model app_data"); + let wm = &model.worldmap; + let Some(node) = ui_map_id.and_then(|id| uimap::node(wm, id)) else { + return Ok(Value::Nil); + }; + let out = lua.create_table()?; + let Some((_, zone)) = views(wm, node) else { + return Ok(Value::Table(out)); + }; + for o in &zone.overlays { + let row = lua.create_table()?; + row.set("textureWidth", o.width)?; + row.set("textureHeight", o.height)?; + row.set("offsetX", o.offset_x)?; + row.set("offsetY", o.offset_y)?; + let (cols, rows) = (o.width.div_ceil(TILE), o.height.div_ceil(TILE)); + let files = lua.create_table()?; + for n in 1..=(cols * rows) { + files.set(n, format!("{}{n}", o.texture))?; + } + row.set("fileDataIDs", files)?; + row.set("isShownByMouseOver", false)?; + row.set( + "explored", + o.explore_bits + .iter() + .any(|&b| explored_bit(&wm.explored, b)), + )?; + out.set(out.raw_len() + 1, row)?; + } + Ok(Value::Table(out)) + })?, + ) +} + /// Bit `n` of the `PLAYER_EXPLORED_ZONES_1` bitset, the same read the window's own overlay gate /// makes. fn explored_bit(explored: &[u32], n: u32) -> bool { diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 824ffab9..2b713ada 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -306,6 +306,12 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> { // `SlashCmdList` outright and would drop entries made before it. "FocusUnit", "ClearFocus", + // ClassicAPI's `strtrim` (script/compat/classic_api/string_trim.rs). 1.12 has no such + // global and `script/stdlib.rs` still refuses it on the 1.12 surface for that reason; + // this build claims ClassicAPI, whose client does have it, and the corpus calls it bare + // in eight places. The one that forced it: OpenMapClassic's `Map/Pins.lua:57` trims a + // pin's note, so with the global nil every dropped map pin raised and none was stored. + "strtrim", // SuperWoW's UnitPosition/CursorPosition (script/compat/superwow.rs): world XYZ, not a // 1.12 verb. "UnitPosition", diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index f3b87a46..397bf229 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -3372,3 +3372,104 @@ the fog `default` and `reveal` is pixel-indistinguishable — and switching `lod `zoneart` off one at a time changed nothing in a capture either (those take a rebuild the slash command alone does not force). What a capture cannot reproduce is which zones the player has actually explored, and that is the one input the complaint turns on. + +## The map's mouse surface shrank with the zoom, and four things fell through the hole + +grom, 2026-11-04, after the rail tabs came back: *"still can't drop a pin like intended … scroll +is better but feels pretty slippery, like scrolls past where I stop scrolling, and my arrow does +not stay where it should on scroll … drag on the map is weird, sometimes its panning the map as +it should and sometimes its grabbing and moving the whole window, this should only happen on the +borders."* + +### One cause behind the drag and the pin: `WorldMapButton` is pinned to the canvas child + +The canvas was built with the container taking no mouse at all, on the stated reasoning that +1.12's `WorldMapButton` "covers the same rect and is above this in the tree, so a mouse-enabled +container here would never see a press anyway". That holds at rest and stops holding the moment +a canvas addon scales the canvas: the button is **inside** the child, so it scales with it. +Measured at the world view with OpenMapClassic driving, at grom's own 2560x1440: + +``` +container SCREEN L=695 R=1396 B=552 T=1020 (701 x 468) +child SCREEN L=1242 R=1265 B=926 T=942 (23 x 16) scale 0.033 +``` + +A 23x16 mouse surface in a 701x468 window. Every press on the other 99% reached the next frame +that takes the mouse — `WorldMapFrame`, which pfUI's `modules/map.lua:45-47` makes a +`RegisterForDrag("LeftButton")` handle for the whole window. So dragging the map moved the +window, and whether it did depended on the zoom and where you grabbed; and a click never reached +`ProcessCanvasClickHandlers`, which is the only way a pin is ever dropped. + +The container now takes the mouse and the wheel and drives the same controller. **The frame level +mattered as much as the mouse bit**: the hit sweep probes one `(strata, level)` plane in link +order and a parent is linked before its children (`0x764aa0`, `TargetFrame.lua:33` relies on it), +so the container tied with `WorldMapFrame` at level 1 still lost every press to its own parent. +It is re-seated to `map:GetFrameLevel() + 1` on every show, because the window is level 0 while +the canvas builds and level 1 once `ShowUIPanel` has raised it — a level computed once at build +time ties again the first time the map opens. + +Measured after: a drag on the bare map area gives `WorldMapFrame` nothing, the container's +`OnMouseDown` once, and the scroll moves. + +### `strtrim` was nil, and that ate every pin + +With the click arriving, dropping a pin still stored nothing. `Map/Pins.lua:57` is +`text = strtrim(text)` and `strtrim` was deliberately absent — `script/stdlib.rs` lists it under +"NOT here" with the reasoning that an addon reaching for a later client's API should find what it +would find there: nothing. That reasoning is about the **1.12** surface and still holds there, +which is why the name is now in `compat/classic_api/string_trim.rs` instead: this build sets +`CLASSIC_API_VERSION`, and on the client these addons are written for the name exists. Both +spellings, because `AceConfigCmd-3.0.lua:31` binds `string.trim` while everything else calls the +global; the string metatable then gives `("x"):trim()` for free. Corpus call sites, bare ones +only: 5 in OpenMapClassic's AceConfigCmd, 2 in its own map files, 1 in +`pfUI/api/ui-widgets.lua:1472` on every keystroke of a search box. + +Pins now store: `pins 0 → 1` on a driven click over land. + +### The zoom's glide + +`SCALE_RATE` was 10 — a 0.7 s half-life, still visibly moving two seconds after the last notch. +That was tolerable only while a notch could not accumulate, because the target was never more +than one rung away. Now that notches add up, the glide is the thing you feel, so the rate is 24 +(a 0.29 s half-life). The pan's is untouched: a drag writes the scroll directly and never eases. + +### The player arrow is on the wrong projection + +`WorldMapUnit_Update` places `WorldMapPlayer` at `GetPlayerMapPosition("player")` as a fraction of +`WorldMapDetailFrame`, which the canvas pins to the canvas child. Both halves of that check out — +the arrow sits at exactly the fraction the game reports (measured: `arrowFrac 0.7351,0.6348` for +`GetPlayerMapPosition 0.7351,0.6348`), and the child **is** the host map's rect, confirmed against +OpenMap's own coordinates by mapping the view centre through `MapFromWorld` and landing on the +container's centre to the pixel. + +What is wrong is which map that fraction is *of*. On a continent or the world, 1.12 measures +against Blizzard's `WorldMap\World` and `WorldMapContinent` art — an illustration, not OpenMap's +grid — so the arrow lands somewhere plausible and wrong; and over a zone the player is not in, +1.12 answers (0, 0) and hides it. `Addon_Ports/openmap_port`'s `Map/StockArrow.lua` places both +the anchor and the Model through OpenMap's own chain instead: the player's zone and his position +in it (`C_Map.GetPlayerMapPosition`, which takes a map id and does not care what the window has +selected) → `WorldFromMap` → `MapFromWorld`. The offsets are scaled by `child / self`, because a +`SetPoint` offset is in the **anchored** frame's units and the Model hangs off `WorldMapFrame` +while the child is scaled by the zoom — without that the error grows with the zoom, which is the +shape of "my arrow does not stay where it should on scroll". Every step is guarded, so a client +that cannot answer leaves 1.12's own placement alone. **Not verified offline**: a capture's player +has no zone (`GetBestMapForUnit` answers the continent), so this one needs a play test. + +### "Show unexplored areas" had no art to show + +Reveal mode draws the overlays the client has not reported as explored, out of `ns.MapReveal` — +a table scraped from the retail client, `"w:h:x:y"` → a list of **FileDataIDs**. Those ids name +retail files; this client resolves none of them (`SetTexture(1396762)` → nil), so every reveal +overlay asked for a texture that does not exist. Captured with the fog `default` and with +`reveal`, the same view came back **pixel-identical** — `compare -metric AE` says 0. + +The overlays are not missing, only their names: `WorldMapOverlay.dbc` carries every one with its +texture and rect, and the explored half already draws from exactly that data through +`C_MapExplorationInfo.GetExploredMapTextures`. `BenillaMapTextures(uiMapID)` is the same list +without the explored filter (benilla-named, as `BenillaMapIDForSelection` is, so no addon mistakes +it for an API it can expect elsewhere), and `Map/RevealFromClient.lua` rebuilds `ns.MapReveal` +from it with paths in place of ids. One line of `Map/Stitch.lua` had to stop `tonumber`-ing every +entry. Measured: Dun Morogh has 20 overlays, 9 unexplored, and `ns.MapReveal[1426]` now carries +those 9 as `Interface\WorldMap\DunMorogh\…` paths where it used to carry retail ids. The render +is **not** verified — the stitch is built before a capture's setup chunk can run, and the addon's +own switch says `/reload required (overlays built on map open)`.