diff --git a/crates/benilla-app/assets/ui/MapCanvas.lua b/crates/benilla-app/assets/ui/MapCanvas.lua index daf50772..6501bb4c 100644 --- a/crates/benilla-app/assets/ui/MapCanvas.lua +++ b/crates/benilla-app/assets/ui/MapCanvas.lua @@ -586,14 +586,18 @@ function MapCanvasScrollControllerMixin:CreateZoomLevels() 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. + -- A geometric ladder from the layer's own bounds, floored at the scale that fits the + -- whole map in the container. That used to be the constant 1 and the comment here + -- said why: the canvas is sized to the layer and the container *is* the rect that + -- layer filled, so 1 was the whole map by construction and a `minScale` below 1 would + -- open the map zoomed out of its own window. The container is the window's content + -- area now (`SeatContainer`), which is 1002x668 only when the window is 1.12's own + -- size, so the fit has to be measured. It still comes out exactly 1 when it is. + local fit = self:GetFitScale() local lo = layer.minScale or 1 local hi = layer.maxScale or 1 - if lo > 1 then - lo = 1 + if lo > fit then + lo = fit end if hi < lo then hi = lo @@ -614,10 +618,32 @@ function MapCanvasScrollControllerMixin:CreateZoomLevels() end end if table.getn(levels) == 0 then - levels[1] = { scale = 1, layerIndex = 1 } + -- No art layer to build a ladder from: one step, at the scale that fits the canvas in + -- the window. 1 while the two are the same size, which is 1.12's own map. + levels[1] = { scale = self:GetFitScale(), layerIndex = 1 } end self.zoomLevels = levels self.baseScale = levels[1].scale + -- A view left outside the new ladder is pulled back onto its nearest end. `ZoomIn`/`ZoomOut` + -- walk the ladder **by value** — the first step above or below the current scale — so a view + -- outside it at either end cannot be climbed back onto by scrolling. Both ends happen: a + -- window that grew raises the floor, and a canvas addon that replaces the ladder wholesale + -- lowers the ceiling, which is OpenMapClassic's case (its stitched world is reached by + -- zooming *out* far past this file's own scale 1, so its whole ladder sits below ours). + local floor, ceil = levels[1].scale, levels[table.getn(levels)].scale + local clamped = self.currentScale + if clamped then + if clamped < floor then + clamped = floor + elseif clamped > ceil then + clamped = ceil + end + end + if clamped and clamped ~= self.currentScale then + self.currentScale = clamped + self.targetScale = clamped + self.zoomTarget = clamped + end -- 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. @@ -628,6 +654,26 @@ function MapCanvasScrollControllerMixin:CreateZoomLevels() end end +-- The scale at which the whole canvas child fits inside the container, which is the zoom ladder's +-- floor. 1 whenever the container is the art's own size, which is 1.12's own map. +function MapCanvasScrollControllerMixin:GetFitScale() + local child = self.Child + if not child then + return 1 + end + self:GetLeft() + local cw, ch = child:GetWidth(), child:GetHeight() + local w, h = self:GetWidth(), self:GetHeight() + if not cw or cw <= 0 or not ch or ch <= 0 or not w or w <= 0 or not h or h <= 0 then + return 1 + end + local fit = math.min(w / cw, h / ch) + if fit <= 0 then + return 1 + end + return fit +end + function MapCanvasScrollControllerMixin:GetScaleForMaxZoom() local n = table.getn(self.zoomLevels) if n == 0 then @@ -814,6 +860,68 @@ local function CaptureOverlays() ScaleDetailArt() end +-- The window's content area, the rect 1.12 gives its map art, in both of the two layouts the +-- stock map has. Taken from `WorldMapFrame_Minimize`/`_Maximize` themselves, as insets rather +-- than as the one size each of them happens to produce: +-- +-- windowed `WorldMapDetailFrame:SetPoint("TOPLEFT", 15, -33)` at scale 0.7 inside a 720x521 +-- window — 701x468 of art, so 15 left, 33 top, 4 right, 20 bottom. +-- maximized `TOPLEFT WorldMapPositioningGuide TOP -502,-69` at scale 1 — the guide is a fixed +-- **1024x768** frame centred on the window, so 10 left, 69 top, 12 right, 31 bottom, +-- and a maximized map that fills the screen still draws its sheet at 1024x768 with +-- `BlackoutWorld` around it, which is 1.12's own full-screen map. +-- +-- Read as insets, the same two numbers reproduce 1.12 exactly at 1.12's sizes and do the obvious +-- thing at any other: pfUI resizes the window to `WorldMapButton`'s size plus its own chrome +-- (`pfUI/modules/map.lua:111`), and the art then fills that instead of sitting at 70% in the +-- corner of it. Before this the container kept the maximized anchor whatever the mode, so the +-- maximize button left the art a 1002x668 patch in a full-screen window and the minimize button +-- could not put it back. +local WINDOWED_INSET = { l = 15, t = 33, r = 4, b = 20 } +local MAXIMIZED_INSET = { l = 10, t = 69, r = 12, b = 31 } + +-- `(host, inset)` for the current mode: the frame the content area is measured in, and how far +-- inside it the art sits. +local function ContentHost() + if WORLDMAP_WINDOWED == 1 then + return WorldMapFrame, WINDOWED_INSET + end + return WorldMapPositioningGuide or WorldMapFrame, MAXIMIZED_INSET +end + +-- Put the container on that content area. The size is written explicitly rather than left to a +-- two-corner anchor, because this client fires `OnSizeChanged` only for an explicit +-- `SetWidth`/`SetHeight` — and that event is what rebuilds the zoom ladder. +local function SeatContainer() + local sc = WorldMapFrame and WorldMapFrame.ScrollContainer + local host, inset = ContentHost() + if not sc or not host then + return + end + host:GetLeft() + local hw, hh = host:GetWidth(), host:GetHeight() + if not hw or hw <= 0 or not hh or hh <= 0 then + return + end + local w = hw - inset.l - inset.r + local h = hh - inset.t - inset.b + if w <= 0 or h <= 0 then + return + end + sc:ClearAllPoints() + sc:SetPoint("TOPLEFT", host, "TOPLEFT", inset.l, -inset.t) + if math.abs((sc:GetWidth() or 0) - w) > 0.5 or math.abs((sc:GetHeight() or 0) - h) > 0.5 then + sc:SetWidth(w) + sc:SetHeight(h) + -- Driven rather than waited for: a resize is what rebuilds the zoom ladder and the scroll + -- extents, and this runs from inside `SeatOnCanvas`, which `OnCanvasSizeChanged` itself + -- calls -- so leaving it to the `OnSizeChanged` script would land the rebuild after the + -- ladder had already been built from the old rect. The size test above is the recursion + -- guard: the re-entry finds the container already the right size and sets nothing. + sc:OnCanvasSizeChanged() + end +end + -- The two frames the canvas took over, re-seated on it: both at scale 1, the art filling the -- canvas and the mouse surface covering the art. This is what keeps `WorldMapDetailFrame` at -- 1002x668 — see "1002x668 is a contract, not a size". @@ -830,6 +938,7 @@ local function SeatOnCanvas(w, h) if not canvas or not detail then return end + SeatContainer() detail:SetScale(1) detail:ClearAllPoints() detail:SetAllPoints(canvas) @@ -870,10 +979,15 @@ function MapCanvasScrollControllerMixin:OnCanvasSizeChanged() 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. + -- With no art layer (an instance map, a map the realm has no folder for) the canvas is still + -- 1.12's own art rect, which is what `WorldMapDetailFrame` and everything pinned into it are + -- placed in. It used to take the container's size instead, and that was only ever safe while + -- the container *was* 1002x668: now that the container is the window's content area + -- (`SeatContainer`), the fallback would hand the whole 1002x668 contract over to whatever + -- size the player's map window happens to be. The view still fits, because the zoom ladder is + -- floored at `GetFitScale` rather than at 1. if not w or w <= 0 or not h or h <= 0 then - w, h = self:GetWidth(), self:GetHeight() + w, h = DETAIL_BASE_W, DETAIL_BASE_H end child:SetWidth(w) child:SetHeight(h) @@ -1750,6 +1864,16 @@ local function BuildCanvas() self:OnCanvasSizeChanged() end) + -- The window resizing moves the content area, so the container has to follow it. pfUI's map + -- module is the case that reaches a player: it sizes `WorldMapFrame` from + -- `WorldMapButton:GetSize()` at `PLAYER_ENTERING_WORLD`, long after this ran. Same limit as + -- the container's own hook — only an explicit `SetWidth`/`SetHeight` fires it, which is what + -- `WorldMapFrame_Minimize` and pfUI both do; the maximize leg has no size of its own + -- (`SetAllPoints`) and is covered by the `WorldMapFrame_Maximize` hook below. + map:HookScript("OnSizeChanged", function() + SeatOnCanvas() + end) + -- Everything the button carries is map content except one frame: `WorldMapFrameAreaFrame`, -- the hovered-zone name. 1.12's XML hangs it off the mouse surface (TOP, 400x128) because -- that is what covers the art, but it is chrome — it names what the cursor is over and sits 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 42545291..6232d1f1 100644 --- a/crates/benilla-app/src/ui_script/map_canvas_tests.rs +++ b/crates/benilla-app/src/ui_script/map_canvas_tests.rs @@ -433,3 +433,50 @@ fn the_windowed_scale_does_not_reach_the_detail_frames_own_units() { "a child pinned at 1.12's own (500, -300) landed at {got}, not {want}" ); } + +/// 1.12 lays its map art out twice, once per window mode, and the canvas container has to be +/// where that art would have been — `WorldMapFrame_Minimize` insets the detail frame 15,-33 +/// inside a 720x521 window at scale 0.7 (701x468 of art: 15 left, 4 right, 33 top, 20 bottom), +/// `_Maximize` puts it at the fixed 1024x768 positioning guide's TOP -502,-69 (10/12/69/31). +/// +/// Taking only the maximized anchor, whatever the mode, is what left grom's map a 1002x668 patch +/// in a full-screen window the moment he pressed maximize, with no way back: minimize re-sized +/// the window and the art stayed where the maximized sheet had put it. +/// +/// The zoom ladder's floor moves with it. It used to be the constant 1, which was right only +/// while the container was the art's own size; it is the fitted scale now, and that still comes +/// out exactly 1 when the window is 1.12's own. +#[test] +fn the_canvas_container_is_the_windows_content_area_in_both_map_modes() { + let Some(mut s) = harness() else { return }; + + for (call, want) in [ + ("WorldMapFrame_Minimize()", (701.0, 468.0)), + ("WorldMapFrame_Maximize()", (1002.0, 668.0)), + ] { + s.run(call).expect("the stock mode switch runs"); + s.resolve(); + let (w, h): (f64, f64) = s + .eval( + "WorldMapScrollContainer:GetLeft() \ + return WorldMapScrollContainer:GetWidth(), WorldMapScrollContainer:GetHeight()", + ) + .unwrap(); + assert!( + (w - want.0).abs() < 1.0 && (h - want.1).abs() < 1.0, + "{call}: the container must be 1.12's own art rect, wanted {want:?}, got {w}x{h}" + ); + // And the whole map has to be reachable in it: the ladder's floor is the fit, which is + // what `ResetZoom` and the right-click zoom-out both walk down to. + let (fit, floor): (f64, f64) = s + .eval( + "local sc = WorldMapFrame.ScrollContainer \ + return sc:GetFitScale(), sc:GetScaleForMinZoom()", + ) + .unwrap(); + assert!( + (fit - floor).abs() < 1e-3, + "{call}: the zoom ladder must reach the fitted scale, fit {fit} floor {floor}" + ); + } +} diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index 8a5f191c..be9b9beb 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -191,8 +191,21 @@ impl UiScript { owner_frame.map(|f| &f.kind_state) { if sl.thumb == Some(rh) { - // The thumb's own `CSimpleTexture::GetWidth`/`GetHeight`, as the drag - // reads them (`slider::thumb_extent`), not its authored ``. + // Computed here from the live value rather than read back from the + // layout's published rect, because `extract` takes `&self` and cannot + // resolve: a `SetValue` with no resolve behind it still has to draw + // the thumb where the value now is. The StatusBar fill above is the + // same arrangement for the same reason. + // + // The layout publishes the same rect from the same inputs + // (`script::layout`'s thumb branch) for the other half of the job — + // `GetThumbTexture():GetLeft()` and, far more to the point, anchoring + // to the thumb, which is how every pfUI-skinned scrollbar hangs its + // own knob. Two call sites of one formula, not two formulas. + // + // `tsize` is the thumb's own `CSimpleTexture::GetWidth`/`GetHeight`, + // as the drag reads them (`slider::thumb_extent`), not its authored + // ``. let tsize = super::region::virtual_span(&model, rh); rect = rect .map(|r| slider::thumb_rect(r, tsize, sl.vertical, sl.fraction())); diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index 39fd99a2..92affb82 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -97,8 +97,34 @@ pub(crate) fn frame_node_hash(input: &LayoutInput) -> u64 { /// A region's per-node input hash: its anchors, explicit size and measured text extent. #[inline] -fn region_node_hash(data: &RegionData) -> u64 { +/// Where a Slider puts its thumb, for the one region whose rect that is: `(vertical, fraction)`, +/// and `None` for every other region. A thumb has no anchors, so none of this hash's other +/// inputs move when the slider's value does — and a named write whose hash does not move seeds +/// nothing, which is how a `SetValue` left the published thumb rect where it was. +fn thumb_placement( + arena: &crate::widget::WidgetArena, + rh: crate::widget::RegionHandle, +) -> Option<(bool, f32)> { + match arena + .region(rh) + .and_then(|r| arena.frame(r.owner)) + .map(|f| &f.kind_state) + { + Some(crate::widget::KindState::Slider(sl)) if sl.thumb == Some(rh) => { + Some((sl.vertical, sl.fraction())) + } + _ => None, + } +} + +fn region_node_hash(data: &RegionData, thumb: Option<(bool, f32)>) -> u64 { let mut node = InputFingerprint::default(); + // Only for a thumb, so no other region's hash moves and nothing re-resolves that did not + // have to. + if let Some((vertical, fraction)) = thumb { + node.feed(u64::from(vertical)); + node.f32(fraction); + } node.anchors(&data.anchors); match data.size { Some((w, h)) => { @@ -772,7 +798,7 @@ impl UiScript { let now = if n & REGION_TAG != 0 { let rh = scope.regions[(n & !REGION_TAG) as usize].rh; match region_data.get(&rh) { - Some(d) => region_node_hash(d), + Some(d) => region_node_hash(d, thumb_placement(arena, rh)), None => continue, } } else { @@ -1056,6 +1082,33 @@ impl UiScript { } } } + // A Slider's thumb is placed by its slider, not by anchors: it has none, so the + // resolver below would drop it and leave it rectless forever. That was invisible + // for as long as the only reader was the draw pass, which computes the same rect + // itself — but `GetThumbTexture()` is a region like any other, and **addons anchor + // to it**. pfUI's `SkinScrollbar` clears the stock knob and hangs its own texture + // off the thumb's two corners (`pfUI/api/ui-widgets.lua:813-820`), so with no rect + // here every pfUI-skinned scrollbar in the interface had a thumb that could not be + // seen or grabbed — which is a scroll frame that silently cannot be scrolled. + // + // Into the solver as well as the map, so a region anchored to the thumb resolves + // on a later round of this same fixpoint, as region-to-region anchors already do. + let thumb_of = match arena.frame(owner).map(|f| &f.kind_state) { + Some(crate::widget::KindState::Slider(sl)) if sl.thumb == Some(rh) => resolved + .get(&owner) + .map(|&track| (track, sl.vertical, sl.fraction())), + _ => None, + }; + if let Some((track, vertical, fraction)) = thumb_of { + let rect = + super::slider::thumb_rect(track, (width, height), vertical, fraction); + solver.set_external(region_id, rect); + if region_resolved.get(&rh) != Some(&rect) { + region_resolved.insert(rh, rect); + changed = true; + } + continue; + } scratch.anchors.clear(); scratch.anchors.extend_from_slice(&data.anchors); scratch.width = width; @@ -1215,8 +1268,20 @@ impl UiScript { // reference's resolving-flag bail. Every rect is recomputed from inputs and externals, so // last frame's rects are a legal seed: only dead or unanchored regions drop. region_resolved.retain(|rh, _| { - arena.region(*rh).is_some() - && region_data.get(rh).is_some_and(|d| !d.anchors.is_empty()) + if arena.region(*rh).is_none() { + return false; + } + // A Slider's thumb is placed by its slider and carries no anchors, so the + // unanchored rule would drop the rect the sweep just gave it. See the thumb branch + // in the region sweep. + let is_slider_thumb = matches!( + arena + .region(*rh) + .and_then(|r| arena.frame(r.owner)) + .map(|f| &f.kind_state), + Some(crate::widget::KindState::Slider(sl)) if sl.thumb == Some(*rh) + ); + is_slider_thumb || region_data.get(rh).is_some_and(|d| !d.anchors.is_empty()) }); pre.retain = pre.lap(); @@ -1261,7 +1326,20 @@ impl UiScript { for (&rh, data) in region_data.iter() { // Anchor-less entries are not layout inputs: hashing them would make a paint-only // setter that creates one (`SetTexture`'s `or_default()`) read as a layout change. - if data.anchors.is_empty() { + // + // A Slider's thumb is the exception, and it is anchor-less by construction: its + // slider places it. It has to be on the roster anyway, because its rect is what an + // addon anchors to when it re-skins a scrollbar, and a region that never enters the + // sweep never gets one. `Slider:SetValue` and friends mark it dirty themselves + // (`touch_layout_region`), since the fraction that moves it is not a region input. + let is_slider_thumb = matches!( + arena + .region(rh) + .and_then(|r| arena.frame(r.owner)) + .map(|f| &f.kind_state), + Some(crate::widget::KindState::Slider(sl)) if sl.thumb == Some(rh) + ); + if data.anchors.is_empty() && !is_slider_thumb { continue; } fed_regions += 1; @@ -1297,8 +1375,15 @@ impl UiScript { }; #[allow(clippy::cast_possible_truncation)] let idx = scope.regions.len() as u32 | REGION_TAG; + let placement = is_slider_thumb + .then(|| thumb_placement(arena, rh)) + .flatten(); + if let Some((vertical, fraction)) = placement { + fp.feed(u64::from(vertical)); + fp.f32(fraction); + } if let Some(slot) = scope.now.get_mut(id as usize) { - *slot = region_node_hash(data); + *slot = region_node_hash(data, placement); scope.node_of[id as usize] = idx; } // A region reads its anchor targets' rects and its owner's scale. diff --git a/crates/benilla-ui/src/script/slider.rs b/crates/benilla-ui/src/script/slider.rs index bb0dbede..40ae6860 100644 --- a/crates/benilla-ui/src/script/slider.rs +++ b/crates/benilla-ui/src/script/slider.rs @@ -85,6 +85,24 @@ fn ensure_thumb(lua: &Lua, this: &Table, layer: Option) -> mlua::Resu Ok(model.region_id(rh)) } +/// Mark the thumb's rect dirty after anything that moves it. The thumb is placed by the layout +/// pass (`script::layout`), not by anchors, so the fraction that moves it is not one of the +/// region inputs the change gate hashes — without this a `SetValue` leaves the thumb's published +/// rect where it was, and an addon anchored to it (every pfUI-skinned scrollbar) follows the +/// stale one. +fn touch_thumb(lua: &Lua, this: &Table) -> mlua::Result<()> { + let h = frame_handle_of(lua, this)?; + let mut model = lua.app_data_mut::().expect("model app_data"); + let thumb = match model.arena.frame(h).map(|f| &f.kind_state) { + Some(KindState::Slider(s)) => s.thumb, + _ => None, + }; + if let Some(rh) = thumb { + model.touch_layout_region(rh); + } + Ok(()) +} + pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let m = lua.create_table()?; @@ -94,6 +112,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // A reversed pair is kept, not swapped (`0x789580`). A held value re-clamps and fires // on a move; a fresh slider with no value yet fires nothing (bit 2). let changed = with_slider(lua, &this, |s| s.set_min_max(min, max))?; + touch_thumb(lua, &this)?; fire_value_changed(lua, &this, changed) })?, )?; @@ -105,6 +124,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "SetValue", lua.create_function(|lua, (this, v): (Table, f32)| { let changed = with_slider(lua, &this, |s| s.store_value(v))?; + touch_thumb(lua, &this)?; fire_value_changed(lua, &this, changed) })?, )?; @@ -118,6 +138,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // moved onto the new step fires `OnValueChanged`. lua.create_function(|lua, (this, step): (Table, f32)| { let changed = with_slider(lua, &this, |s| s.set_value_step(step))?; + touch_thumb(lua, &this)?; fire_value_changed(lua, &this, changed) })?, )?; @@ -137,7 +158,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { ))) } }; - with_slider(lua, &this, |s| s.vertical = vertical) + with_slider(lua, &this, |s| s.vertical = vertical)?; + touch_thumb(lua, &this) })?, )?; m.set( @@ -356,6 +378,13 @@ pub(super) fn drag_move(model: &mut Model, x: f32, y: f32) -> Option<(u32, f32)> Some(KindState::Slider(s)) => s.store_value(value), _ => None, }; + // As `touch_thumb` does for the Lua setters: the drag moves the thumb and nothing else the + // change gate hashes moved with it. + if changed.is_some() { + if let Some(rh) = thumb { + model.touch_layout_region(rh); + } + } changed.map(|v| (model.frame_id(slider), v)) } diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 7e363da3..a3cce568 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -2628,3 +2628,100 @@ anchor is `WorldMapFrame TOPLEFT 15,-33` at scale 0.7, which would fill 70% of h pfUI sizes that window from the **unscaled** `WorldMapButton` — it is written for the maximized map and he is running the windowed one. Flipping the map to maximized is the one-click test of that reading. + +## No scrollbar in the interface had a thumb anything could anchor to + +grom: "half of the info on the right is out of the frame" about the character panel. It was not +out of the frame. `BCPPFUIStatsContent` is 699 tall inside a 451-tall scroll frame, with a +scrollbar and a scroll range of 228 — the information was behind a scrollbar he could not see, +could not grab, and had no reason to think was there. + +A Slider's thumb texture had **no resolved rect**. Measured, with his own addons loaded: + +``` +bar 16x419 L=487.0 B=881.0 +realthumb 16x16 L=nil B=nil +pfUIthumb 8x8 L=nil B=nil <- pfUI's own knob, anchored to the thumb +anchored-to-thumb 0x0 L=nil <- a fresh texture, same anchors, same answer +``` + +The rect was only ever computed inside the **draw** pass (`extract`'s thumb branch, from +`slider::thumb_rect`), never published, and the region sweep skipped the thumb outright: it +carries no anchors, because its slider places it, and anchor-less regions are not layout inputs. +A thumb that draws but has no rect is invisible for exactly as long as nobody anchors to it — + +and pfUI anchors to it. `SkinScrollbar` clears the stock knob's texture and hangs its own off the +thumb's two corners (`pfUI/api/ui-widgets.lua:813-820`). Stock knob blanked, replacement +unplaceable: **every pfUI-skinned scrollbar in the interface was a bare track**, which is a scroll +frame that silently cannot be scrolled. The character panel is where he noticed; the Cine settings +page he reported two rounds ago is the same fault. + +### What it took + +Three things, because the thumb had fallen out of the layout at three separate points: + +1. **The roster.** `if data.anchors.is_empty() { continue; }` in the region walk — the thumb is + anchor-less by construction, so it never entered the sweep. It is on the roster now. +2. **The prune.** `region_resolved.retain(…)` drops unanchored regions, which would have taken + the rect back off the thumb the moment the sweep gave it one. +3. **The node hash.** The incremental pass re-hashes only the ids a write *named*, and seeds only + those whose hash moved — "a named write that did not move the hash seeds nothing". The thumb's + hash is over its anchors, size and measured extent, and a `SetValue` moves none of them, so a + named touch alone changed nothing. The slider's `(vertical, fraction)` is now part of that + hash, for thumbs only, and `Slider:SetValue`/`SetMinMaxValues`/`SetValueStep`/`SetOrientation` + and the drag name the thumb through `touch_layout_region`. + +`WOW_LAYOUT_VERIFY=1` caught stage 3 outright — it panicked with "an INCREMENTAL layout pass +disagreed with a full derivation" — which is exactly what that gate is for and the reason the +first two stages alone looked like they had worked. + +**The draw pass still computes its own rect, and that is deliberate.** `extract` takes `&self` and +cannot resolve, so a `SetValue` with no resolve behind it must still draw the thumb where the +value now is; the published rect is for `GetLeft` and for anchoring, and is refreshed on the next +resolve, which the client runs every frame. Two call sites of one formula, like the StatusBar fill +beside it. Reading the published rect in `extract` instead broke +`slider_thumb_draws_at_value_fraction_along_the_track`, which is the test that says so. + +Measured after, on the same scrollbar: thumb at the track top for value 0, and for value 200 of +0..228, `thumbB=930.49` against 881 + (1 − 200/228) × (419 − 16) = 930.5 by hand, with pfUI's +replacement knob 4 px inside it. + +## The map window has two layouts, and the canvas only knew one + +grom pressed the maximize button and "it got worse, doesn't go back to what it was". Both halves +are one bug: the canvas container was anchored once, at build, to `WorldMapPositioningGuide TOP +-502,-69` — 1.12's **maximized** anchor, against a guide that is a fixed 1024x768 frame. + +1.12 lays the map art out twice and the two have nothing in common: + +| | window | art anchor | art size | +|---|---|---|---| +| `WorldMapFrame_Minimize` | 720x521 | `WorldMapFrame TOPLEFT 15,-33` | 1002x668 at scale **0.7** | +| `WorldMapFrame_Maximize` | the whole screen | guide `TOP -502,-69` | 1002x668 at scale 1, inside the fixed 1024x768 guide, `BlackoutWorld` around it | + +So maximize left the art a 1002x668 patch in a full-screen window, and minimize put the window +back at 720x521 with the art still where the maximized sheet had it. + +Read as **insets** rather than as the one size each produces — 15/4/33/20 windowed, 10/12/69/31 +maximized — the same two numbers reproduce 1.12 exactly at 1.12's sizes and do the obvious thing +at any other, which matters because pfUI resizes the window to `WorldMapButton`'s size plus its own +chrome (`pfUI/modules/map.lua:111`). That is also the answer to the left-edge bleed left open in +the previous section: it was the maximized `-502` being read against a guide stretched to a +1017-wide window, so the art started 6.5 px in instead of 10. + +Two consequences had to follow it: + +- **The zoom ladder's floor was the constant 1**, and the comment there said why: the canvas is + sized to the art layer and the container *was* the rect that layer filled, so 1 was the whole + map by construction. The container is the window's content area now, so the floor is measured + (`GetFitScale`) — and comes out exactly 1 when the window is 1.12's own size. +- **A view outside the new ladder is pulled onto its nearest end.** `ZoomIn`/`ZoomOut` walk the + ladder by value, so a scale outside it cannot be climbed back onto by scrolling. Both ends + happen: a window that grew raises the floor, and a canvas addon that replaces the ladder + wholesale lowers the ceiling — OpenMapClassic's stitched world is reached by zooming out far + past this file's own scale 1, so its entire ladder sits below ours. Measured with OpenMap + loaded: `minZoom=0.0125 maxZoom=0.899 current=1.0`, a view above its own ceiling. + +The no-art-layer fallback changed with them: the canvas used to take the **container's** size when +a map had no art layer, which was safe only while the container was 1002x668. It is 1.12's own art +rect now in every case, and the fitted floor is what keeps it visible in a smaller window.