From ae929e8a819561a5cb309e95c12b249adf60c68a Mon Sep 17 00:00:00 2001 From: grom Date: Mon, 2 Nov 2026 21:15:38 -0700 Subject: [PATCH] map: the art trace reads the tiles the repaint put up, not the ones it replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line fired in the frame the selection changed, and the repaint that fills the tiles is Lua's own off a queued `WORLD_MAP_UPDATE` — so it read the map that had just left. That is how "the map shows Dun Morogh and the tiles are Azeroth's" got into a log with nothing wrong behind it. It now waits three frames, restarting the wait if the selection moves again. docs/ADDON_COMPAT.md records this round: the cursor position no verb ever wrote, the per-kind nameplate CVars a nil answer greys out, the capture lever above with its two limits measured rather than assumed, and one open item — the map's Continent and Zone pickers, blank on the first open with the addons loaded and fine without them, with the candidate engine fix rejected because the test for it passed either way. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/src/ui_world_map.rs | 40 ++++++- docs/ADDON_COMPAT.md | 159 +++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 3 deletions(-) diff --git a/crates/benilla-app/src/ui_world_map.rs b/crates/benilla-app/src/ui_world_map.rs index c700069d..f69d5a20 100644 --- a/crates/benilla-app/src/ui_world_map.rs +++ b/crates/benilla-app/src/ui_world_map.rs @@ -69,6 +69,16 @@ struct DirectAreaEntry { /// through the host's size probe, which is why a scratch region measures the file rather than the /// tile (the tile declares 256x256 in `WorldMapFrame.xml` and would answer that whatever the file /// holds). +/// What `UiScript::world_map_selection` answers: `(continent, zone, direct map id)`. Named so the +/// memo fields below can carry it without clippy counting the parentheses. +type MapSelection = (u32, u32, Option); + +/// Frames the art line waits after the selection changes before reading the tiles. The repaint +/// that fills them is Lua's, off a `WORLD_MAP_UPDATE` that is queued rather than called, so the +/// tiles are a frame or more behind the selection and a line printed on the change itself names +/// the map that just left. +const ART_TRACE_DELAY: u8 = 3; + const MAP_ART_TRACE: &str = r#" local d = WorldMapDetailFrame if not d then return "no WorldMapDetailFrame" end @@ -698,10 +708,16 @@ struct FeedMemos { zone_logged: Option<(u32, u32, u32, u32, u32, u32, u32)>, /// The map selection the art line last reported, so it prints once per map /// rather than once per frame. - art_logged: Option<(u32, u32, Option)>, + art_logged: Option, + /// A selection whose art line is waiting out [`ART_TRACE_DELAY`] frames, and how many are + /// left: the repaint that puts the new map's tiles up is Lua's own, off a queued + /// `WORLD_MAP_UPDATE`, so a line printed in the frame the selection changed reads the + /// *previous* map's tiles. That is how a reading of "the map shows Dun Morogh and the tiles + /// are Azeroth's" got into a log with nothing wrong behind it. + art_pending: Option<(MapSelection, u8)>, /// The resolved pair last seen, so the watchdog in [`feed_world_map`] can tell the player /// moving to a new zone from the map being browsed away from the player's. - last_resolved: Option<(u32, u32, Option)>, + last_resolved: Option, /// An episode of the selection not following the player, and when it started. `None` whenever /// the map is on the player's zone, and whenever it is merely being browsed off it. selection_lag: Option, @@ -1042,10 +1058,28 @@ fn feed_world_map( // pitch, or whose file is not the size the layout assumes, names the fault outright. It was // behind `WOW_MAP_TRACE` and that cost a round trip: a play report about the map arrives // without it, and the next one cannot be asked for until the player plays again. + // + // (The claim above that "the capture harness seats no UI" is no longer true of either half: + // `WOW_CAPTURE=ui-worldmap` seats the map, and `WOW_CAPTURE_ADDONS=1` beside it puts the + // player's own map addon in front of the real renderer — see `capture::fixtures`. This line + // still earns its place, because neither harness is what a player is running.) { let key = script.world_map_selection(); let memo = memo.get(&script); - if memo.art_logged != Some(key) { + if memo.art_logged != Some(key) && memo.art_pending.is_none_or(|(k, _)| k != key) { + memo.art_pending = Some((key, ART_TRACE_DELAY)); + } + let due = match memo.art_pending { + Some((k, 0)) if k == key => true, + Some((k, n)) if k == key => { + memo.art_pending = Some((k, n - 1)); + false + } + // The selection moved again while the wait ran: start it over for the new one. + _ => false, + }; + if due { + memo.art_pending = None; memo.art_logged = Some(key); match script.eval::(MAP_ART_TRACE) { Ok(line) => info!("world map art: {line}"), diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 9806c7ba..bf8672fb 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -2060,3 +2060,162 @@ after any port: each one is something a player is not seeing.** The grips are drawn now the way 1.12 windows draw a corner grip — two nested squares of `Interface\Tooltips\UI-Tooltip-Border` turned 45 degrees with the eight-argument `SetTexCoord`, AceGUI's own sizer art, brightened on hover. + +## `GetCursorPosition()` answered `0, 0` for every session there has ever been + +Last round's grips were invisible but alive. Painting them did not make the map resizable, and +grom said so again. The grip's code is the universal idiom: + +```lua +g:SetScript("OnMouseDown", function() + local x, y = GetCursorPosition() + start = { x = x, y = y, w = map:GetWidth(), h = map:GetHeight(), ... } +end) +g:SetScript("OnUpdate", function() + if not start then return end + if not IsMouseButtonDown("LeftButton") then Finish() return end + local x, y = GetCursorPosition() + local s = map:GetEffectiveScale() + local w = math.max(MIN_W, start.w + (left and -(x - start.x) or (x - start.x)) / s) + ... +end) +``` + +Stamp the cursor on the press, subtract it each frame, size by the difference. `Model::cursor_pos` +is read by `GetCursorPosition` in two places and **was written by nothing at all** — declared, +initialised to `(0.0, 0.0)`, and never assigned anywhere in the workspace. So every call of every +session answered the origin, every delta came out zero, and the window was re-anchored to its own +current size sixty times a second while the player dragged. + +Nothing raises. Nothing logs. `Finish` sees a width that moved by less than half a pixel, decides +the drag was a click, and declines to save — which is exactly right for a click and indis­tinguish­able +from a drag that measured nothing. + +This is not one addon's bug. The same four lines move a frame, drive a custom slider, draw a +selection box, place a world marker and resize a chat window; `IsMouseButtonDown` was already fed +from the host's own device state (`ui_script/input.rs`) and the cursor beside it was not. It is fed +now, in the same place and for the same stated reason — the pointer feed below it is skipped for a +cursor off the window and for a UI hidden by TOGGLEUI, and the reference answers through both. + +**Units.** UI units, y-up from the bottom-left, undivided: `UIParent` is scale 1 in this client, so +the universal `x / frame:GetEffectiveScale()` lands in the frame's own units exactly as it does on +the reference. `script::tests::input::get_cursor_position_drives_the_drag_to_size_idiom` pins the +whole idiom end to end rather than the getter alone, because the getter answering *something* was +never the question. + +## The later clients' per-kind nameplate CVars: a nil answer greys a whole row + +> "I can't edit anything on the friendly npc nameplates in there amongst others" + +Cinematic's Nameplates page asks before it offers: + +```lua +function ns.HasPlateSettings(kind) + for _, cvar in ipairs(PLATE_KIND_CVARS[UNIT_ROW_KIND[kind] or kind]) do + if GetCVar(cvar) ~= nil then return true end + end + return false +end +``` + +`nil` is not "off" to an addon written for a later client — it is "this client has no such +setting", and the row goes grey. The session log named the whole set, one line each: + +``` +ui_script: unknown CVar 'nameplateShowFriendlyNPCs' (not host-registered) — ignored +ui_script: unknown CVar 'nameplateShowFriendlyPlayers' … +ui_script: unknown CVar 'nameplateShowEnemyPlayers' … (17 nameplate rows in all) +``` + +The two rows that *did* work were the two whose `PLATE_KIND_CVARS` entry happens to name +`nameplateShowEnemies` or `nameplateShowFriends` — the only two nameplate CVars this client had, +because 1.12 has no nameplate CVar at all and those two names were borrowed from later clients to +persist its `[0xc4da34]` bitmask. + +Seventeen rows are registered now. Sixteen of them are live: friendly players, friendly NPCs, +hostile players, and the pets/guardians/totems/minions of each side — eight names on the friendly +side and four on the hostile one, each side driving **one** field, because a 1.12 object update +carries an owner and not a kind of owner, and because an addon that means "show them" writes the +whole set in one go (`Effects.lua`'s `PLATE_KIND_CVARS.FriendlyMinions`), so last-writer-wins is +what the caller meant. `nameplateMaxDistance` joins `NameplateRange` and `NP_NameplateDistance` as +a third name for the one reach. + +Every one defaults `"1"`, the least-restrictive value, for the reason `NameplateRange` gives: the +two masters already decide whether plates show at all, and a refinement shipped off would take away +plates a player has today. They are paired against `VPlateMode::default()` in `cvars.rs`'s own +defaults test, the same pairing the derived-`Default` trap forced on `occlusion`. + +**Five are registered and inert, and that is written on each row.** `nameplateShowAll` is `"1"` +because 1.12 plates whatever the masters allow whether or not you are fighting — "always" is +already what happens and there is no combat gate to switch off. `nameplateShowSelf` is `"0"` +because nothing here draws a plate over the player. `nameplateShowOnlyNameForFriendlyPlayerUnits` +and `nameplateForceShowUnitName` name a plate mode this client does not have. `nameplateShowEnemyMinus` +needs a creature's `Creature.dbc` rank, which the plate path never looks up — it reads reaction and +owner off the object update and nothing else. + +Still nil, and so still greying their own rows elsewhere in that window: the sixteen later-client +`UnitName*` names (`UnitNameFriendlyPlayerName` and its family — this client has the four 1.12 +ones), `raidFramesDisplayBuffs`/`Debuffs`, the six `floatingCombatText*` names, +`worldMapShowPlayerCoords`/`worldMapShowCursorCoords` and `cooldownViewerEnabled`. + +## Putting an addon's own drawing in front of the real renderer + +The map reports could not be answered from either harness, and the reason is worth writing down: +`addon_harness` runs the VM with no renderer, so it can say what a region *is* and never what it +looks like; the capture harness renders for real but is hermetic — `local_state::home` answers +`None` under `$WOW_CAPTURE`, so no config, no saved variables and **no addons**, which is what makes +a baseline comparable between machines. + +So a stitched-map addon drawing its own art was unseeable without a server, and the only evidence +was a player's screen recording with no knobs on it. `WOW_CAPTURE_ADDONS=1` opts the player's addon +folder back in for one run — `ui_script::addons::root`, the single narrow exception, with saved +variables still out. `capture::fixtures` will also *ask* for a view through the addon's own +published API, retrying until the map is stitched: + +``` +WOW_CAPTURE=ui-worldmap WOW_CAPTURE_ADDONS=1 \ +WOW_CAPTURE_MAP_ZOOM=8 WOW_CAPTURE_MAP_AT=0.7655,0.4942 \ +WOW_CAPTURE_AGE=700 WOW_CAPTURE_OUT=/tmp/omc.png cargo run -q --release -p benilla +``` + +`scripts/visual.sh` never sets it, so no baseline sees an addon. The first shot taken this way +answered the open question outright: with OpenMapClassic loaded on its shipped defaults, this +client draws the stitched Dun Morogh map correctly — zone art, neighbours, labels, lore panel and +player dot — so what grom is looking at is not the art pipeline coming apart. + +**Two limits, measured rather than assumed.** The zoom ask does not stick: shots at 8, 20, 24 and +48 came out byte-identical, because OpenMapClassic re-seats its own remembered view +(`reopenView`) after the window opens and `Glide` is travelling toward it. And the player's *saved* +settings — the window size and max zoom that his map is actually stuck at — cannot be reproduced, +because loading saved variables would also mean writing them back at exit, over a real player's +file. Both are why this instrument narrows the question rather than closing it. + +## Open: the map's Continent and Zone pickers are blank on the first open — with the addons loaded + +Found in grom's recording rather than reported: over Dun Morogh both dropdowns carry no text and +Zoom Out is dead; later in the same clip, over Elwynn, both read correctly. Reproduced offline: + +``` +addon_harness … --probe OpenMapClassic --eval 'ToggleWorldMap()' --tick … + after open1: [nil] + after open2: [Eastern Kingdoms] zone=[Alah'Thalas] +``` + +Blank on the first open, correct from the second, and a `WORLD_MAP_UPDATE` fired by hand after the +first open fills both — so the mechanism is clear: the realm's `WorldMapFrame.xml` fills them from +exactly one place, the Zone dropdown's `` on `WORLD_MAP_UPDATE` (:321), and that dropdown +registers the event in its own `` (:318 — the realm commented the `OnLoad` out and moved it +there) while `WorldMapFrame`'s `` fires the event through `SetMapToCurrentZone()` (:711). A +parent's OnShow runs before its children's, so the event arrives one step early. + +**What is not established is that this is ours.** The same open on the whole shipped interface with +*no* addons fills both pickers on the first try — measured, as a test that passed with and without +a candidate fix, which is what sent that fix back. So something in OpenMapClassic or one of its +dependencies changes the order, and the engine-side change that looked obvious (queueing a second +`WORLD_MAP_UPDATE` behind the inline one) buys nothing measurable and costs a second +`WorldMapFrame_Update` per call — the cost `ui_script/lifecycle.rs:107` already warns about. It is +not in the tree. + +**Next step, written down so it is not re-derived:** bisect the probe — the stock interface alone +is clean, `--probe OpenMapClassic` is not, and that probe loads only that addon and its +dependencies. Whatever re-shows or re-parents those dropdowns is in that short list.