diff --git a/crates/benilla-ui/src/script/worldmap.rs b/crates/benilla-ui/src/script/worldmap.rs index e2bb8212..097cff8a 100644 --- a/crates/benilla-ui/src/script/worldmap.rs +++ b/crates/benilla-ui/src/script/worldmap.rs @@ -206,6 +206,17 @@ pub struct WorldMapState { pub raid_uv: Vec>, /// 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; diff --git a/crates/benilla-ui/src/script/worldmap_arrow.rs b/crates/benilla-ui/src/script/worldmap_arrow.rs index 9141c321..24245c6a 100644 --- a/crates/benilla-ui/src/script/worldmap_arrow.rs +++ b/crates/benilla-ui/src/script/worldmap_arrow.rs @@ -164,6 +164,9 @@ fn arrow_wrapper(lua: &Lua, which: Arrow) -> mlua::Result> { /// `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::().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::("return arrow:GetFacing()").unwrap() - 1.25).abs() < 1e-6, - "no tracked object: the facing does not move" + (s.eval::("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::("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::("return arrow:GetFacing()").unwrap() - 2.0).abs() < 1e-6, + "once a player has fed one, it is the arrow's" ); } } diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 0bd30281..4356d261 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -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/.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. diff --git a/scripts/soften-zone-masks.py b/scripts/soften-zone-masks.py new file mode 100755 index 00000000..de0cd80a --- /dev/null +++ b/scripts/soften-zone-masks.py @@ -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/.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())