map: the art trace reads the tiles the repaint put up, not the ones it replaced

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-02 21:15:38 -07:00
co-authored by Claude Opus 5
parent ba11fc3315
commit ae929e8a81
2 changed files with 196 additions and 3 deletions
+37 -3
View File
@@ -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<u32>);
/// 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<u32>)>,
art_logged: Option<MapSelection>,
/// 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<u32>)>,
last_resolved: Option<MapSelection>,
/// 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<SelectionLag>,
@@ -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::<String>(MAP_ART_TRACE) {
Ok(line) => info!("world map art: {line}"),
+159
View File
@@ -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 `<OnEvent>` on `WORLD_MAP_UPDATE` (:321), and that dropdown
registers the event in its own `<OnShow>` (:318 — the realm commented the `OnLoad` out and moved it
there) while `WorldMapFrame`'s `<OnShow>` 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.