The map's darkening was a grid of grey boxes, and a 12 px string was emptying the glyph sheet

grom, after a night's play: "make the map look a lot nicer, it would be really nice if the
darkened areas were not just massive squares on the map, and should fit into the actual
edges of the terrian if that makes sense, also I shouldn't see this dark squares on the
fully zoomed out world map, also make it so the world map can't move out of the frame."

**The darkening.** OpenMapClassic washed a never-visited zone through its land mask, and
those masks are Voronoi-dilated by construction — "so that the forty of them tile each
continent with no gap" — so for any zone that is not on a coast the mask *is* the
rectangle. Measured at zoom 2.5 over the Eastern Kingdoms: a grid of flat grey boxes.
Nothing could have been softened into a fix; it was the wrong mask. It was also drawn at
full strength from every zoom, and at the whole-world view the zones' own art is not drawn
at all, so the only thing on the world map was the fog's rectangles.

A land mask for every map now comes from the client's own data. `bake_zone_land` unions a
map's `WorldMapOverlay` alpha — the pieces the game reveals as you explore, cut to the
shape of the ground — into one 256×256 mask; `overlay-land-index.py` writes the list the
addon reads, because Lua cannot test for a file and a *missing* mask is dropped by the
engine so the whole rectangle draws, which is the defect itself. 53 maps have overlays
(the 40 stock zones, Hyjal, Alterac Valley and 11 of the realm's own); 76 have none and
keep a wide feather. `clip-zone-masks-to-coast.py` then takes the open sea off the dilated
sheet masks — the same dilation was painting parchment out on the ocean — by closing the
continent's own land by 500 yards (above the 466-yard inradius of the widest enclosed gap,
so seams fill and the sea does not) and keeping a 300-yard coastal margin. Twelve
landlocked zones lose 0.0%; Darkshore loses 53%, all of it water.

`tga.rs` gains the writer that needed, `script::uimap` and `ui_world_map::build_catalog`
become public so an example can reach the uiMapID graph, and `WorldMapUiData` grows the
accessor so the VM push and an outside caller cannot build different lists.

**The glyph sheet.** His four-minute session reset it 27 times, each dropping every cached
glyph. The producer is Atlas-CFM's cursor coordinate readout: a 12 px FontString on
`WorldMapDetailFrame`, rewritten every frame, and the canvas zooms by scaling that frame —
140 device px a digit at a canvas scale of 6.9. Nothing about the addon is wrong, so the
fix is ours: `raster_ppem` rasterizes exactly up to 64 device px and snaps to a
third-octave ladder above it, with `cell()` scaling the cached bitmap and metrics left
keyed on the true ppem, so advances and `GetStringWidth` are bit-for-bit unchanged. Same
1,200-frame reproduction: 3 resets and 6,623 rasters before, **0 and 1,424** after, with
occupancy climbing once to 30% and stopping. A reset no longer clears the metric cache
either, and `note_loud_cell` names the string that asked for an outsized cell.

**Four more from the same log.** `SetOwner`'s unknown-anchor line named a forwarding
wrapper rather than the addon that chose the anchor (two `debugstack` frames now); the nine
reference anchor names are written down with the binary evidence, because `ANCHOR_TOP` is
not one of them. The `CreateFrame(kind, …, inherits=<other kind>)` warning fired in both
directions and only one drops content — `kind_subsumes` makes it directional, and
`type_chain` moves out of `install` so `IsObjectType` and the loader read one table. A
string that is not UTF-8 now names the Lua line that cut it, and both glyph warnings quote
the string the character came out of. The three world-map CVars stay unregistered, with the
argument written down beside `REGISTERED` so it is not re-litigated from the warning.

**`World.zmp`.** The realm's `WorldMapArea` carries a third `areaID == 0` row, `694 World`,
with an all-zero rect; the reference ships no bitmap for it and neither the graph nor the
realm's own `Overrides.lua` lets it reach a player. The miss is a `debug!` line now, and
the `warn!` is kept for a continent that *has* a rectangle and is missing its bitmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-04 21:16:20 -07:00
co-authored by Claude Opus 5
parent 74fd1a52a2
commit 65520a4a58
21 changed files with 2715 additions and 103 deletions
+317
View File
@@ -3231,6 +3231,65 @@ With that in, `initializationErrors` is **0**, the window is `shown=0` at login,
map, and a real `--drag` pans it. The settings window opens with its real tabs — Audio, General,
Voice Packs, Profiles — and clicking them switches pages with no raise.
### The same trap again, one library down: `CallbackHandler-1.0`'s own `Fire`
A session logged `...ills\lib\CallbackHandler-1.0\CallbackHandler-1.0.lua:113: attempt to perform
arithmetic on local 'argc' (a boolean value)`, and the shape is the one above with a different
library. `WidgetBase.Fire` is the *widget* side; `registry:Fire` is the *callback registry* side,
and only the first was shifted.
Four copies of `CallbackHandler-1.0` are installed. Three are Ace3v (MINOR 6:
MasterTradeSkills, SoftSpoken, UnitXP_SP3_Addon) with `registry:Fire(eventname, argc, a1…a10)`;
OpenMapClassic's is modern (MINOR 8) with `registry:Fire(eventname, ...)`. `LibStub.minors` reads
2 for every library, so the first copy to load wins outright, and alphabetical load order makes
that MasterTradeSkills'. OpenMapClassic's `API.lua:27` then fires modern-style through an Ace3v
registry:
```lua
local function Fire(...) if events then events:Fire(...) end end
...
mod.OnStitchedChanged = function(on)
on = on and true or false
if on ~= wasStitched then Fire("OpenMap_StitchedChanged", on) end -- API.lua:162
end
```
`on` lands in `argc`, and `Dispatchers[argc+1]` at `:113` raises on a boolean. **Not a verb of
ours**: no engine call is anywhere in that chain, and the reference client raises here too, off the
same realm `LibStub`. The blast radius is OpenMap's whole published callback surface, not one
event: `OpenMap_ViewChanged` passes a 0..1 fraction where the count belongs, so `Dispatchers[u+1]`
is nil and that one raises on the *call* instead of the add, and the two dungeon-map events pass a
table. No addon can follow OpenMap's map at all — which is the feature `API.lua`'s header
advertises to Questie, TomTom and the gatherers.
**Fixed caller-side, in `API.lua`, and the choice is the interesting part.** The callee-side edit
is tempting — shift a non-numeric `argc` down and count what arrived, once, and all three Ace3v
copies are covered. But those three copies live in *MasterTradeSkills*, *SoftSpoken* and
*UnitXP_SP3_Addon*: third-party addons in the player's own folder, which he updates. A fix there is
a fix that disappears, and patching a stranger's vendored library is the opposite of the thing this
whole document is for. `OpenMapClassic` is ours.
The objection to caller-side was that a caller cannot know which registry it got. It can: ask.
```lua
local probe = {}
local reg = CBH:New(probe, "RegisterCallback", "UnregisterCallback", "UnregisterAllCallbacks")
local seen
probe.RegisterCallback(probe, "OpenMapAbiProbe", function(_, v) seen = v end)
reg:Fire("OpenMapAbiProbe", 7)
fireTakesArgc = seen ~= 7
```
A *number* is fired so neither ABI can raise on the probe itself: Ace3v reads 7 as a count and
hands the handler seven nils, the modern one hands it the 7. Probed rather than read off `MINOR`
because the minor number is the thing that has already lied — `LibStub.minors` says 2 for
everything. Then `Fire` adds `select("#", ...)` or does not.
Measured in a capture that registers a real callback and then moves the view: **5 `OpenMap_ViewChanged`
callbacks delivered, `0.700, 0.460, 12.00`**, where before the fix the same probe got none and the
log carried the raise. The one remaining `argc` line in that capture's log is the probe's own
answer, `argcAbi=true`.
### Two more defects it was hiding
Opening all three registered options tables (`MasterTradeSkills`, `OpenMapClassic`,
@@ -3774,3 +3833,261 @@ ruins — not the city. That is true of this install and not only of retail: `te
the player's own chain holds `Azeroth` and `Kalimdor` and a folder per dungeon, and no folder for
any city (measured across 112,411 files). There is no interior minimap to show, so the two
underground cities keep their plan whatever the new setting says.
## "Darken unexplored areas" drew a grid of grey boxes, because the mask it used is dilated on purpose
> "it would be really nice if the darkened areas were not just massive squares on the map, and
> should fit into the actual edges of the terrian if that makes sense, also I shouldn't see this
> dark squares on the fully zoomed out world map"
Two separate faults with one symptom, and the second one is the kind that is only obvious once you
stop looking at the code and look at the picture.
### The shade had two paths and the cheap one was a rectangle
`Stitch.lua`'s `Shade` darkened a zone two ways. A zone the player had been to got **one darkened
copy of each unexplored piece's art**, which carries that piece's own alpha and therefore the shape
of the ground. A zone the player had found *none* of got `Shade.Texture`: a single
`SetColorTexture` over the whole 1002×668 art rectangle, cut down by the zone's land mask.
The land masks are `MapZoneDilate` — forty hand-made files whose own purpose, stated in their
generator, is to be **Voronoi-dilated "so that the forty of them tile each continent with no
gap"**. For a zone that is not on a coast, a mask that tiles the continent *is* the zone's
rectangle. So the cheap path painted a grey box, by construction, for every inland zone.
Measured at zoom 2.5 over the Eastern Kingdoms with nothing explored: a grid of flat grey
rectangles with two or three coastal zones the only honest shapes among them.
### …and the expensive path was covering something this fork does not have
The flat path is **gone**, and so is the copy layer behind it. The copies exist upstream for a
stated reason: they are drawn over the **baked tiles** (`ns.MapZoneArt`, one pre-rendered texture
per world tile standing in for the live layers at mid zoom), and a baked tile is many pieces in
one, so it cannot be tinted piece by piece. This fork has no baked tiles — `Data/ZoneArt.lua` is
three lines, *"Forever MapZones bake removed. Baked path disabled."*, and its `colorMatch = -1`
means `Baked.use` can never become true. The copies were covering nothing.
So the darkening is now one line in `GradeEntry`: multiply an unexplored piece's **own** texture by
`Shade.DIM`. That is the same picture — a texture multiplied by `DIM` is a texture multiplied by
`DIM`, whether it is the art or a copy of the art over it — and it is a *better* one in a way the
copies had to work for: a zone's base art carries no piece (`PlaceBase` passes `false`) and the
overlays only ever cover land, so the sea a 1.12 sheet paints stays bright by construction, where
the copies needed a land mask to achieve the same thing.
That removed `Shade.Texture`, `Shade.Zone`, `Shade.Pieces`, `Shade.Colour`, `Shade.Near`,
`Shade.Show`, the per-zone shade cull and the `Shade.root` layer — about 120 lines down to four
lines of state and one function. `shadeRoot` is kept, empty, only so every layer above it keeps the
frame level it had.
Quads a frame at zoom 5 with half the overlays explored: `show` 4,729 · `dim` **4,675** · `hide`
4,550. Darkening a map now costs *fewer* quads than not darkening it, because the colour-match twin
over a darkened piece is hidden rather than drawn. Before the change `dim` was 5,124 against
`show`'s 4,674 — 450 extra quads, every one of them masked.
Frame times, measured once the machine was actually idle — which is the point worth keeping:
`show` **5.20 ms** · `dim` **5.14** · `hide` **4.88** (240 frames each, same scene, zoom 5).
Darkening is free now. Every earlier reading that night shared the machine with a release build and
was worthless: the same `dim` scene read 8.68 ms, then 8.94, then **26.05** under load average 8.
Quad counts were load-independent and said the same thing throughout, which is why they are the
numbers above. A frame-time A/B taken while something else is compiling is not a measurement.
*What is given up, stated plainly.* The flat shade sat on `shadeRoot`, **over** the close-up
minimap terrain; the piece copies sat under it, and the tint is the art itself, so neither darkens
the minimap. At a high zoom inside a zone you have explored none of, the minimap terrain therefore
reads at full brightness with the darkened sheet showing around it. That was already true of
upstream's behaviour for any zone you had partly explored — the flat path only ever ran for
"none of it" — and buying it back means a rectangle, which is the thing this whole section is
about. Left as it is, deliberately.
### A fog that was the only thing on the whole-world view
The darkening was drawn at full strength "from every zoom". At the whole-world view the *zones' own
art is not drawn at all* — `DETAIL_FADE` starts at zoom 3 — so on the world map there was Blizzard's
clean painted world with nothing on it but the fog's rectangles.
It now rides `detailNow`, the very ramp the art it darkens rides. Measured on the current build:
the whole-world view with "Darken unexplored areas" **on** and with it **off** differ by **0
pixels**. The same comparison before the change differed by 84,818.
Riding the art's own ramp is also what bounds the cost. `Shade.vis` is false out there, and while
it is false nothing is *built*: at the far zoom the cull box is a whole continent, so the old code
was free to lay out the piece copies for forty zones at once.
### The cities had to be switched off, not faded
A city's own sheet is darkened by **multiplying its art** (`cities.Dim`), not by a shade laid over
it, so it cannot be faded out with everything else — a vertex colour has no alpha to ramp. Left
alone, an unvisited city stayed a small hard black rectangle on the world view after the rest of
the fog had gone. It now reads `Shade.vis` too, and `S.SetShadeZoom` re-runs `cities.Dim()` when
that crosses. That is also the `black-rectangle dungeon insets in Darken mode` from an earlier
report: half of the remaining pixels at the world view were cities, and the count went 2,894 → 1,385
→ 0 as the two were fixed.
## A land mask for every map, generated from the exploration overlays
The hand-made mask set has **40** files. This realm's `WorldMapArea.dbc` has **175 rows**. Every
map with no mask was drawn through a plain edge feather — `MapFeather` is opaque from 7.8% in, so
over a zone-sized sheet that is a rectangle with a thin soft rim — and shaded the same way.
`cargo run -p benilla-app --example bake_zone_land` writes a mask per map from the client's own
data: the union of that map's `WorldMapOverlay` alpha, composited into the 1002×668 sheet at each
overlay's `offsetX/offsetY` and downscaled to 256×256. The overlays are the pieces the game reveals
as you explore, cut to the shape of the ground, so their union is the map's land and nothing else —
which is exactly what "should fit into the actual edges of the terrain" asks for, and it comes from
the player's own install rather than from a table somebody traced.
**53 maps have overlays**: the 40 stock outdoor zones, Alterac Valley, Hyjal, and 11 of the realm's
own — Gillijim's Isle, Lapidis Isle, Icepoint Rock, Tel'Abim, Gilneas, Thalassian Highlands,
Blackstone Island, Balor, Northwind, Grim Reaches, Moonwhisper Coast. `scripts/overlay-land-index.py`
turns the baked folder into `Data/OverlayLand.lua`, because Lua cannot ask whether a texture file
exists — and a mask whose art is missing is *dropped* by the engine, so the whole rectangle draws,
which is the very defect this set removes.
Two things the baker had to get right, both recorded in its own source: the reveal is alpha-over
rather than `max`, because `WorldMapFrame.lua` puts one texture per piece on the detail frame at
the default blend; and bare pixels the overlay art completely *encloses* are flood-filled, because
the pieces are feathered into their own rects and where two abut their ramps cross below opaque —
Westfall's raw union had 10,165 pinholes through the middle of the zone.
Where the two sets disagree they disagree in the expected direction. Against the hand-made masks,
84–93% of the generated coverage falls inside theirs, while only 50–76% of theirs falls inside the
generated — that gap *is* the dilation, filling sea to tile the continent. So the two are used for
different jobs: a zone's **sheet** still prefers the dilated mask, because a sheet has to reach its
neighbour; the **shade** prefers the generated one, because a shade should stop at the coast.
**76 maps have no `WorldMapOverlay` rows at all** — the six cities, two battlegrounds, every
instance, and about twenty of the realm's own outdoor pockets, Alah'Thalas among them. No source in
the client can give those a coastline, so they keep the feather fallback, now `FEATHER_WIDE` (13.7%)
rather than `FEATHER` (7.8%): a sheet laid down rather than a box cut out. That is the half of
"edges of maps are the same" that can be answered without new art.
## Zone sheets were painting parchment on the open sea
The same dilation, one layer up. A zone's **sheet** is cut by `MapZoneSoft`, and that mask is
dilated so the forty of them tile a continent with no gap — which is right on land, where the
dilation is what closes the seam to the neighbour, and wrong past the coast, where there is no
neighbour and nothing underneath. So a zone painted its blank parchment on the ocean. Measured at
zoom 6 west of the Eastern Kingdoms: pale rectangles floating in the Great Sea at world
(0.725, 0.483) and (0.748, 0.407), both inside the rects of stock zones that *have* a mask.
`scripts/clip-zone-masks-to-coast.py` writes `MapZoneCoast`: `MapZoneSoft` intersected with the
continent's own land — the union of every `MapOverlayLand` mask — **morphologically closed by 500
yards**, then grown by a **300-yard coastal margin** with a 150-yard feather.
Three numbers, each measured rather than chosen:
- **500 yards** because the close has to fill the gaps *between* zones without filling the ocean,
and a close by R removes exactly the background a disk of radius R cannot sit in — so R must
exceed the widest enclosed gap's **inradius**, not its width. Those are 466 yards on Kalimdor
and 267 on the Eastern Kingdoms; after the close, zero enclosed gap pixels remain on either.
- **300 yards** because a 1.12 sheet deliberately paints the hatched shallows off its own coast
and those should stay. 10.4% of everything the forty sheets paint sits more than 600 yards past
any land; at a 600-yard margin a straight rect edge is still plainly visible off the Wetlands,
at 300 the kept water follows the coastline everywhere.
- The buffer is sized in **yards, not world units**, because the sheet is 44,533 yards across a
world-u and 29,689 across a world-v — exactly 1.5:1, the detail frame's 1002:668. A circular
dilate in world units would have been an ellipse on the map.
Twelve genuinely landlocked stock zones lose **0.0%** — that is the guard the script refuses to
ship without. Darkshore loses 53%, Teldrassil 46%, Stranglethorn 39%, Durotar 27%; all of it is
water.
## The map could be dragged out of its own window
> "make it so the world map can't move out of the frame like it is now, still pan and zoom when
> zoomed, but zoomed out should fit and stay in the frame"
`WorldMap.lua`'s `Extents` gives the view `EDGE_SLACK = 0.2` of room past the world's edge, so the
wheel can zoom about a point near the edge without the pan fighting it. The slack is a fifth of a
**world** width, and it does not shrink with the zoom: at zoom 30 the view is a thirtieth of the
world, so a fifth of a world is six screenfuls of empty sea. Measured before the change — the view
driven to the world's top-left corner at zoom 6 sat there, and the whole window was open water with
a strip of the world's top edge along the bottom.
`mod.db.clampView`, on by default, drops the slack to zero and makes `Range` return the centre
outright on whichever axis the world is smaller than the view, at every zoom rather than
interpolating out of it — that second half is what "zoomed out should fit and stay in the frame"
asks for, because interpolating let the world slide about inside its own letterbox. Driving the
view at the world's corner now answers:
```
zoom 1 → u 0.5000 v 0.5000 (locked centred: the world fits)
zoom 2 → u 0.2500 v 0.2555 (half a view from the edge)
zoom 6 → u 0.0833 v 0.0852
zoom 20 → u 0.0250 v 0.0255
```
i.e. the map's edge meets the window's edge and stops, at every zoom. The cost is that the wheel can
no longer zoom exactly about a point hard against the map's edge, which is what upstream bought with
the slack; "Keep the map in the window" in the options turns it back off.
## The `World.zmp` warning was the data agreeing with itself
Every boot logged `world map: no zone bitmap for World: reading Interface\WorldMap\World.zmp: file
not in patch chain`. The realm's `WorldMapArea.dbc` carries three `areaID == 0` rows — `13 Kalimdor`
(map 1), `14 Azeroth` (map 0) and `694 World` (map 0, **all-zero rect**) — and the reference's own
catalog walk selects continents by exactly that column, so the third reaches our catalog too.
It has no rectangle, so there is nothing to hover; and the reference ships no bitmap for it either —
`Interface\WorldMap\` holds exactly `Azeroth.zmp`, `Kalimdor.zmp` and `Expansion01.zmp` across the
whole 112,523-file chain. Nothing a player sees carries the row: `uimap::build_graph` drops it by
art folder and the realm's own `Overrides.lua` pins `CONTINENTS_LENGTH = 2`. So the miss is now a
`debug!` line with the reasoning in it, and the `warn!` is kept for a continent that *does* have a
rectangle and *is* missing its bitmap — which is the case worth seeing.
## A 12 px FontString inside a zoomed canvas emptied the glyph sheet 27 times in four minutes
His session logged `ui_text: glyph sheet full (…/4194304 texels, N cells) — resetting at the frame
boundary` **27 times in four minutes**, N running from 460 to 2,537. Every one of those drops the
whole glyph cache and the next frame re-rasterizes every string on screen. That is a hitch, 27
times, and it is a large part of "we need the whole thing running smoother".
**The producer is `AtlasCFMWorldMapCursorText`** — Atlas-CFM's world-map cursor coordinate
readout, a Friz Quadrata **12 px** FontString created directly on `WorldMapDetailFrame`
(`CFMAtlas/AtlasUI.lua:561`) and rewritten from an `OnUpdate` on that same frame every frame the
cursor is over the map. It is on in his saved variables (`AtlasCursorCoords = true`).
Nothing about that is wrong. `MapCanvas.lua` reparents the detail frame into the canvas and the
canvas zooms by **scaling that child**, so the FontString's effective scale is the zoom: 12 px ×
his 1.6875 seam × a canvas scale of 6.9 is **140 device px per digit**, and the string is different
every frame, so a glide minted a fresh set of ~13 display-size cells per frame. The evidence is the
correlation: every one of the 27 resets opens within ~2 s of a `world map art: … scale N` line with
N > 1, and there is not one while the canvas sits at its resting 0.90.
**The engine was what turned it into a hitch**, so that is where it is fixed. `raster_ppem()` now
rasterizes at exactly the requested size up to **64 device px** and snaps to a third-octave ladder
(`64, 81, 102, 128, 161, 203, 256`) above it, with `cell()` scaling the cached cell's extent and
bearings by `ppem / rung` so the quad lands on the same device pixels. **Metrics are untouched** —
`chars` stays keyed by the true ppem, so every advance, wrap and `GetStringWidth` is bit-for-bit
what it was. 192 device sizes over one glide become 7 rasters. 64 is chosen so nothing the stock
interface draws at 1:1 is quantized: the largest is `ZoneTextFont`/`WorldMapTextFont` at 32 units
after the reference's own cap, 54 device px on his screen.
Same reproduction both sides — his saved variables, all his addons, 1,200 frames, the canvas glided
between min and max zoom every 90:
| | `glyph sheet full` | cells rasterized | sheet occupancy |
|---|---|---|---|
| before | 3 | 6,623 | sawtooth 0 → 95% → reset |
| after | **0** | **1,424** | climbs once to 30.2% and stops |
Saturation is the real result: the cache reaches a bounded steady state instead of sawtoothing.
Two things came with it. A sheet reset no longer clears the **metric** cache — `chars` holds pen
metrics and shaper keys and nothing about the sheet, so clearing it made the frame after a reset
re-*shape* every string as well as re-rasterize it, which is the expensive half. And a new
`note_loud_cell` line names the offender once per `(face, rung, radius)`, with the string that
asked for it:
```
ui_text: a 128 px cell for '7' in 'Fonts\FRIZQT__.TTF' (outline radius 0) is 65×91 texels,
1/709 of the glyph sheet, drawn at 115 px — asked for by "70.1, 11.5" (byte 0).
```
`"70.1, 11.5"` is Atlas-CFM's readout and nothing else — two lines for a 1,200-frame run. The next
session that does this will say so itself instead of needing to be correlated out of timestamps.
Quality was checked rather than assumed: four THICKOUTLINE rows at the ladder's worst points
(0.889× minify, 1.118× magnify), same scene, both binaries. A control band with no rig text is
**pixel-identical**, the tight bounding boxes of the 72 px and 90 px rows are identical to the
pixel, and the text bands differ by 4.6–6.0% RMSE — edge antialiasing, invisible at 1:1.