The map arrow pointed the wrong way wherever the player was not

`UpdateWorldMapArrowFrames` (`0x4a8d10`, `0x4a7c20`) copies the camera-tracked object's
facing to both arrows and leaves them alone when there is no such object. Ours tested "is
there a player" with `WorldMapState::player_uv.is_some()` — a different question, because
that field is `GetPlayerMapPosition` on the **displayed** map. A view sitting on a
continent, on another zone, or on an addon's stitched world answers `None` on every frame,
so the arrow kept the heading it had when the window last sat on the player's own zone: the
right place, pointing the wrong way.

`player_facing_seen` is the real test, set the first time the feed pushes a facing — the
feed runs only where there is a player, and the facing it pushes is unconditional. The
existing test asserted the old behaviour on its second leg; that leg now asserts the
heading following off the displayed map, and a new test holds the other half (before any
feed, the login and loading screens, the arrow is left alone).

`scripts/soften-zone-masks.py` is for the OpenMapClassic port rather than the client: its
per-zone land masks are binary, so every map sheet ends on a crisp line against a
differently-lit neighbour. It writes a softened copy — dilate, blur, times a rect ramp that
reaches zero inside the sheet's own torn parchment border, which is the half that is not
obvious.

`docs/ADDON_COMPAT.md` writes up this round's five: the flashing arrow (two writers on one
frame, measured `h h S h h S`), why correcting after 1.12 rather than suppressing it cost a
full UI conversion every frame, pfQuest's pins being both behind the map and only ever for
one zone, the softened masks, and the cities.

Gates: fmt 0, clippy 0, benilla-ui 2121 pass / 0 fail, benilla-app 4233 pass / 12 fail (the
recorded data-golden baseline). Corpus 86/87 load clean, 86/87 survive a session start and
a UI probe, 0 raised on hover, click or drag — unchanged.

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 17:00:14 -07:00
co-authored by Claude Opus 5
parent 841fd1a81f
commit ee125519f1
4 changed files with 331 additions and 3 deletions
+12
View File
@@ -206,6 +206,17 @@ pub struct WorldMapState {
pub raid_uv: Vec<Option<(f32, f32)>>,
/// Radians, 0 north, counterclockwise-positive; `UpdateWorldMapArrowFrames` turns the arrows.
pub player_facing: f32,
/// Whether [`Self::player_facing`] has ever been pushed, i.e. whether there is a player at
/// all. `UpdateWorldMapArrowFrames` (`0x4a7c20`) copies the camera-tracked object's facing and
/// leaves the arrow's alone when there is no such object — this is that test.
///
/// It used to be [`Self::player_uv`]`.is_some()`, which is a different question: that is
/// `GetPlayerMapPosition` on the **displayed** map, so a view sitting on a continent, another
/// zone, or an addon's stitched world — anywhere the player is not — froze the arrow's
/// heading at whatever it was when the map last showed the player's own zone. The arrow was
/// in the right place and pointing the wrong way, which is half of "where it shows the arrow
/// is still incorrect".
pub player_facing_seen: bool,
/// `GetCorpseMapPosition()` on the displayed map; `None` is no corpse or off the map.
pub corpse_uv: Option<(f32, f32)>,
/// `PLAYER_EXPLORED_ZONES_1`, 64 words, bit n being `AreaTable.dbc` exploreFlag n.
@@ -541,6 +552,7 @@ impl super::UiScript {
model.worldmap.player_zone = player_zone;
model.worldmap.player_uv = player_uv;
model.worldmap.player_facing = player_facing;
model.worldmap.player_facing_seen = true;
model.worldmap.corpse_uv = corpse_uv;
model.worldmap.party_uv = party_uv;
model.worldmap.raid_uv = raid_uv;
+34 -3
View File
@@ -164,6 +164,9 @@ fn arrow_wrapper(lua: &Lua, which: Arrow) -> mlua::Result<Option<Table>> {
/// `UpdateWorldMapArrowFrames()` (`0x4a8d10`, `0x4a7c20`): re-centre each arrow and copy the
/// camera-tracked object's facing, the player's; with no player the facing stays.
///
/// "No player" is `WorldMapState::player_facing_seen`, not "the player is on the map the window
/// is showing" — see that field for the arrow this used to freeze.
fn update(lua: &Lua) -> mlua::Result<()> {
for which in [Arrow::World, Arrow::Mini] {
let Some(wrapper) = arrow_wrapper(lua, which)? else {
@@ -173,7 +176,7 @@ fn update(lua: &Lua) -> mlua::Result<()> {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
(
model.worldmap.player_facing,
model.worldmap.player_uv.is_some(),
model.worldmap.player_facing_seen,
model.model_facts_for(ARROW_MODEL),
)
};
@@ -490,11 +493,39 @@ mod tests {
(facing - 1.25).abs() < 1e-6,
"the tracked object's facing: {facing}"
);
// Off the displayed map, the heading still follows. `0x4a7c20` reads the camera-tracked
// object, which is the player wherever the window happens to be looking; this leg used to
// assert the opposite, because the "is there a player" test was `player_uv.is_some()` —
// `GetPlayerMapPosition` on the *displayed* map. A stitched continent view answers `None`
// there on every frame, so the arrow kept the heading it had when the map last sat on the
// player's own zone.
s.set_world_map_feed(None, None, 2.0, None, Vec::new(), Vec::new());
s.run("UpdateWorldMapArrowFrames()").unwrap();
assert!(
(s.eval::<f32>("return arrow:GetFacing()").unwrap() - 1.25).abs() < 1e-6,
"no tracked object: the facing does not move"
(s.eval::<f32>("return arrow:GetFacing()").unwrap() - 2.0).abs() < 1e-6,
"the player is still the tracked object off the displayed map"
);
}
/// The other half of that rule: with no player at all — nothing has fed a facing yet, which
/// is the login screen and the loading screen — the arrow keeps whatever facing it has.
#[test]
fn a_world_map_arrow_keeps_its_facing_until_a_player_feeds_one() {
let mut s = vm();
arrow_facts(&mut s);
s.run("CreateWorldMapArrowFrame(WM)").unwrap();
s.run("arrow = ({WM:GetChildren()})[2]").unwrap();
s.run("arrow:SetFacing(0.75)").unwrap();
s.run("UpdateWorldMapArrowFrames()").unwrap();
assert!(
(s.eval::<f32>("return arrow:GetFacing()").unwrap() - 0.75).abs() < 1e-6,
"no feed has run: the facing is left alone"
);
s.set_world_map_feed(None, None, 2.0, None, Vec::new(), Vec::new());
s.run("UpdateWorldMapArrowFrames()").unwrap();
assert!(
(s.eval::<f32>("return arrow:GetFacing()").unwrap() - 2.0).abs() < 1e-6,
"once a player has fed one, it is the arrow's"
);
}
}
+185
View File
@@ -3589,3 +3589,188 @@ trade is the one that constant's own comment has always named: an addon that gat
rather than on a function takes the newer branch and may reach for a name this layer has not
built, where it gets a clean `nil`-call error naming the function. The floor is unchanged and
still load-bearing — below `11304`, pfUI does not load at all.
## The player arrow was flashing because two things were placing it
> "my player arrow is still all messed up, its like flashing between a position and where I
> actually am" — grom
1.12 places the world map's arrow from `WorldMapButton_OnUpdate`, **every frame the map is
shown** (`WorldMapFrame.lua`, the `-- Position player` block): `PositionWorldMapArrowFrame`
against `WorldMapDetailFrame` when `GetPlayerMapPosition("player")` answers, and
`ShowWorldMapArrowFrame(nil)` — a hide — when it answers `(0, 0)`, which is every frame the
window is not on the player's own zone.
On a stitched map that placement is wrong in both arms: the fraction is measured against
Blizzard's own continent art, which is a different projection from OpenMap's grid, and the
`(0, 0)` arm fires constantly because the map underneath is usually some other zone. The port's
`Map/StockArrow.lua` already placed the arrow properly — on a `0.05 s` throttle. At 60 fps that
does not *replace* 1.12's placement, it **alternates** with it. Counted over 199 frames of a
capture, sampling the arrow model every frame:
```
before shown 66, hidden 133 run: h h S h h S h h S h h S h h (stock hid it on 199 frames)
after shown 199, hidden 0 run: S S S S S S S S S S S S S S
```
That is the flashing, measured. It only started when `C_Map.GetPlayerMapPosition` began answering
at all — before the unit feed stopped publishing Bevy's frame as world yards, `WorldMap.lua`'s
`S.SetPlayer` path drew OpenMap's own arrow and the stock one stayed hidden, so there was nothing
to fight.
The fix is not to write after 1.12 but to stop it writing. `StockArrow.lua` wraps
`WorldMapButton_OnUpdate`'s global — the XML calls it by name, so replacing the global is enough —
computes the placement first, and for the frames where it has an answer makes
`PositionWorldMapArrowFrame` and `ShowWorldMapArrowFrame` no-ops for the length of the stock
call. Re-asserted on the map's `OnShow`, because three addons in this realm's own folder
(`FlightMap`, `LevelRange-Turtle`) replace that global outright rather than hooking it.
### Correcting *after* 1.12 would have cost a full UI pass every frame
The first version of this did run after the stock handler and re-show the arrow. It looked right
and was expensive, which is worth writing down because the same shape will come up again: a frame
going hidden → shown takes a **new link-stamp** (`WidgetArena::resequence_to_tail`, bits 21..38
of the packed `ZKey`), the stamp is in the extracted entry, and the extract gate compares
extracted entries. So an arrow hidden and shown once a frame moved one entry's sort key once a
frame, and `ui_script`'s gate — which otherwise skips the whole conversion when nothing changed —
missed on every single frame.
```
WOW_UI_GATE=1, stitched map, settled
correcting after [ui-gate] miss: entry 1292/1372 … ModelPane … z 6922020567064772608 → …066869760 every frame
suppressing [ui-gate] miss: capture mode (the gate never skips under a capture) i.e. nothing differed
```
With his own saved variables and the map open that conversion is **1.19–1.54 ms a frame** on top
of a 0.88 ms extract (`WOW_UI_PHASES`, 5,148 quads at 1100x800). The map's whole UI pass is
2.1–3.0 ms/f against 0.6 ms with the map closed, and the frame itself 8.67 ms open against 3.73
closed — so the stitched map is the most expensive window in the interface and a gate miss on it
is not a rounding error.
The *point* is still written every frame, and the distinction is worth keeping straight: a
`SetPoint` is read by the extract once, after every handler has run, so the last writer wins and
the rect it sees is constant. A `Show` is not — it moves the frame in the draw order and the new
stamp is what the gate reads. Memoizing the point to skip the write looked like the same saving
and was a bug: 1.12 re-anchors `WorldMapPlayer` from its own handler and only the arrow frames'
two verbs are suppressed, so the frames where the value had not changed kept 1.12's answer on the
button that carries the arrow's tooltip.
## The arrow's heading froze wherever the player was not
`UpdateWorldMapArrowFrames` (`0x4a8d10`, `0x4a7c20`) copies the camera-tracked object's facing to
both arrows and leaves them alone when there is no such object. Ours tested "is there a player"
with `WorldMapState::player_uv.is_some()` — and that field is `GetPlayerMapPosition` on the
**displayed** map, which is a different question. A view sitting on a continent, on another zone,
or on an addon's stitched world answers `None` on every frame, so the arrow kept whatever heading
it had when the window last sat on the player's own zone: the right place, pointing the wrong way.
`player_facing_seen` is the real test, set the first time the feed pushes a facing — the feed runs
only where there is a player, and the facing it pushes is `player.facing()` unconditionally. Two
tests hold both halves: the heading follows off the displayed map, and before any feed (login and
loading screens) the arrow is left alone.
## pfQuest's pins were behind the map, and only ever for one zone
> "pfui pins are still messed up, can't see them at most zooms and the ones I can see them at are
> pretty useless" — grom
Two separate defects with one symptom, and the zoom-announcement fix of the previous round was
neither of them. With four nodes seeded into Dun Morogh and the rest from pfQuest's own database:
```
zoom 4 (host = continent) pfQuest pins built 131, shown 0 ← GetCurrentMapZone() is 0
zoom 8 / 16 / 30 (zone) built 131, shown 131, 13.6 / 15.2 / 17.9 screen px
```
The sizes were right all along. What was wrong was the **set** and the **level**.
*The set.* `pfMap:UpdateNodes` opens `local map = pfMap:GetMapID(GetCurrentMapContinent(),
GetCurrentMapZone())` and returns at once when that is nil. On Blizzard's map that is the whole
story, because the window only ever shows one map. On a stitched map the view is usually looking
at several zones and only the map underneath has any pins at all — and zoomed out far enough that
OpenMap switches the map underneath to a continent, `GetCurrentMapZone()` is 0 and **every pin
disappears**.
*The level.* pfQuest fixes no frame level: every pin is a child of `WorldMapButton` and takes its
level, 3. `S.Build` lays 80 levels of stitched art over the canvas, `artTop` 188. So at zoom 8,
131 pins answered `IsShown()` and **not one pixel of any of them was on the screenshot**. Questie's
and Leatrix's are lifted for exactly this reason (`Map/Extras.lua`); pfQuest reached nobody's list
because it does not place its pins through HereBeDragons.
`Map/PfQuest.lua` (new, port-authored) does both: it lifts pfQuest's own pins above `S.artTop`,
and for every zone in view that is *not* the map underneath it places the **quest log's** nodes
(`pfMap.nodes["PFQUEST"]`, what `quest.lua` writes) through pfQuest's own `BuildNode` /
`UpdateNode` / `ResizeNode`, so they are its pins in every way a player can tell — its textures,
its `WorldMapTooltip` tooltips, its zoom sizing. Quest-log nodes only, not the database search's:
a search can put thousands of spawns on one zone and twenty zones of that is a different
complaint. Bounded at 300 pins, refreshed on `OpenMap_ViewChanged` (throttled to a few a second)
and on pfQuest's own `UpdateNodes`. A switch in the map's own checklist, shown only when pfQuest
is loaded, as Questie's row is.
Measured after, same seeds: continent zoom 12 bridge pins shown where 0 pins showed before; zone
zoom 131 of pfQuest's own now visible plus 5 neighbours'.
## Zone sheets butted against each other, so the continent looked like a grid of boxes
> "it looks super boxy and weird, edges of maps are the same, is there a way that we can blend
> together maps that overlap?" — grom
`Media/Textures/MapZoneDilate/<uiMapID>.tga` is one 256x256 alpha mask per Classic outdoor zone,
Voronoi-dilated so the forty of them tile each continent with no gap. They are **binary**:
measured over four of them, 19k–48k pixels at alpha 0, 12k–51k at 255, and under 250 pixels
anywhere in between. Every sheet, and every shade over an unexplored one, therefore ends on a
crisp line against a neighbour painted at a different brightness, and the two sheets are different
paintings of the same ground. Nothing blends because nothing was asked to.
`scripts/soften-zone-masks.py` writes `MapZoneSoft` beside it: the same coverage dilated 2 px,
blurred 3 px, and multiplied by a rect ramp that reaches zero 5% in from the sheet's own border.
The inset is the half that is not obvious — a 1.12 zone sheet paints its own torn parchment frame
and its own sea right to the edge of its rect, and the shipped masks are cut so that none of that
shows. Softening with no inset pulls it back in: measured, a hard straight line of Wetlands'
parchment border across a view where the binary mask had a soft coast. With the inset, the land
the mask gives up is covered by the neighbour's own dilated mask, which is what the dilation was
for.
Captured at the same view before and after, the vertical and horizontal seams across Khaz Modan
and the Wetlands are gone and the continent reads as one painting; at zone zoom (22x over
Ironforge) the hard diagonal where the next sheet cut in is gone too. `MapZoneDilate` stays beside
it as the script's source, so the transform is re-runnable and the two can be compared.
### Where "darken unexplored areas" did not reach
The shade over a zone the player has not found takes `ZoneMask(...)`, which answers only for the
forty zones in `S.ZoneMaskIDs`; the zone's **art** falls back to the plain edge feather when there
is no mask (`PlaceBase`). So a zone with no generated mask was drawn and never shaded — the art
had a fallback and the shade did not. Both shade paths now take the same fallback the art takes.
On this realm that reaches Hyjal (2482) and anything added later; the realm's other new land
(Alah'Thalas, the Grim Reaches, Dun Kithas, Ambershire) is painted *inside* the vanilla sheets
rather than given maps of its own — measured off the client, 47 zone maps in all, 1411–1458 plus
2482, and no others.
## Cities: a half-faded plan over the zone's own painting, and the minimap you cannot have
> "on certain cities I can't get it to go into the minimap view, or it does but there is still an
> overlap of the map art above it" — grom
Two different things, one of them not a defect.
*The overlap.* A city's own sheet crossfades in over the zone's painting of the same ground across
`CITY_FADE`, a share of the view. Halfway through that band the city is a half-opaque sheet over
the zone's art, and a 1.12 sheet is parchment with no transparent surround, so it reads as a dark
rectangle sitting on the map. Measured over Ironforge: a grey box at zoom 30, the finished plan at
70. The band is now `{ 0.26, 0.36 }` rather than `{ 0.2, 0.4 }` and the three cities with no
traced outline (Stormwind, Ironforge, Darnassus) are drawn through `FEATHER_WIDE` rather than
`FEATHER` — 16% of the sheet rather than 9%. Neither removes the blend; they shorten the zoom
range that shows it and soften the box, which is the half a player can see.
*The minimap.* Over a surface city `Close.lua` capped the minimap at `CITY_MINI` (0.5, and 0.15
over Orgrimmar and Thunder Bluff), on the reasoning that a city's own plan should lead. That is a
taste, so it is now a setting — "Minimap detail in cities", default 100%, dialling down to
upstream's own caps.
Ironforge and Undercity are a different case and the answer there is no. `Close.lua` keeps the
minimap off them because the minimap at those coordinates is the *surface* — the mountain, the
ruins — not the city. That is true of this install and not only of retail: `textures\Minimap` on
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.
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Soften OpenMapClassic's per-zone land masks, so neighbouring sheets blend instead of butting.
`Media/Textures/MapZoneDilate/<uiMapID>.tga` is one 256x256 alpha mask per Classic outdoor zone,
Voronoi-dilated so that the forty of them tile each continent with no gap. They are **binary**:
measured over four of them, 19k-48k pixels at alpha 0, 12k-51k at alpha 255, and under 250 pixels
anywhere in between -- the anti-aliasing of a traced outline and nothing more.
A binary mask is why grom's map "looks super boxy and weird, edges of maps are the same". Every
zone's sheet, and the shade over an unexplored one, ends on a crisp line against its neighbour,
and the two sheets are different paintings of the same land at different brightnesses. Nothing
blends because nothing was ever asked to.
So: keep the mask fully opaque everywhere it already was, and let it fall off *outwards* over a
few pixels. `DILATE` pushes the coverage out so the ramp starts at the old boundary rather than
eating into the zone, and `BLUR` is the ramp. Where two zones meet, the one drawn second fades in
over the one drawn first across roughly `BLUR` mask pixels -- about a dozen screen pixels at the
zoom where a continent fills the window, which is the zoom the complaint came from.
Outward, not symmetric, on purpose: a symmetric blur would take both neighbours to 50% at their
shared border and let the sea-grey underlay show through as a bright seam -- trading a hard edge
for a worse one.
Reads `MapZoneDilate`, writes `MapZoneSoft`; the source folder is left alone so this is
re-runnable and the two can be compared. 256x256 uncompressed 32-bit TGA either way: RGB black,
alpha the coverage, as the shipped masks are.
"""
import argparse
import sys
from pathlib import Path
import numpy as np
from PIL import Image, ImageFilter
# Mask pixels. A zone mask is 256 px across its own rect however far the map is zoomed, so these
# are a fraction of a zone, not a count of screen pixels: 3 + 4 is a ramp a little under 3% of a
# zone's width.
DILATE = 2
BLUR = 3.0
# How far in from the sheet's own border the softened coverage is held at zero, as a fraction of
# the sheet. A 1.12 zone sheet paints its own torn parchment frame and its own sea right to the
# edge of its rect, and the shipped masks are cut so that none of that shows. A blur alone pulls
# it back in -- measured: softening with no inset put a hard straight line of Wetlands' parchment
# border across the view where the binary mask had a soft coast. So the ramp is multiplied by a
# rect ramp that reaches zero at the border, and the neighbour's own (dilated) mask covers the
# land the inset gives up.
RECT_MARGIN = 0.05
def rect_ramp(n: int, margin: float) -> np.ndarray:
"""1 in the middle, 0 at both ends, smoothstep across `margin` of the span."""
if margin <= 0:
return np.ones(n)
x = (np.arange(n) + 0.5) / n
t = np.clip(np.minimum(x, 1.0 - x) / margin, 0.0, 1.0)
return t * t * (3.0 - 2.0 * t)
def soften(src: Path, dilate: int, blur: float, margin: float) -> Image.Image:
im = Image.open(src).convert("RGBA")
a = im.split()[-1]
# MaxFilter is a square dilation; its size must be odd and is the full width, not the radius.
if dilate > 0:
a = a.filter(ImageFilter.MaxFilter(2 * dilate + 1))
if blur > 0:
a = a.filter(ImageFilter.GaussianBlur(blur))
w, h = im.size
cover = np.asarray(a, dtype=np.float32) / 255.0
cover = cover * np.outer(rect_ramp(h, margin), rect_ramp(w, margin))
out = np.zeros((h, w, 4), dtype=np.uint8)
out[:, :, 3] = np.round(cover * 255.0).astype(np.uint8)
return Image.fromarray(out, "RGBA")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("textures", type=Path, help="OpenMapClassic/Media/Textures")
ap.add_argument("--from-dir", default="MapZoneDilate")
ap.add_argument("--to-dir", default="MapZoneSoft")
ap.add_argument("--dilate", type=int, default=DILATE)
ap.add_argument("--blur", type=float, default=BLUR)
ap.add_argument("--rect-margin", type=float, default=RECT_MARGIN)
args = ap.parse_args()
src = args.textures / args.from_dir
dst = args.textures / args.to_dir
if not src.is_dir():
print(f"not a directory: {src}", file=sys.stderr)
return 2
dst.mkdir(parents=True, exist_ok=True)
n = 0
for path in sorted(src.glob("*.tga")):
soften(path, args.dilate, args.blur, args.rect_margin).save(dst / path.name)
n += 1
print(f"wrote {n} mask(s) to {dst} (dilate {args.dilate}, blur {args.blur}, rect margin {args.rect_margin})")
return 0
if __name__ == "__main__":
raise SystemExit(main())