diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 74bf918f..016059f2 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -3015,3 +3015,49 @@ Both ends reachable, which is what "scroll all the way out to the world map and the minimap" asks for. Captured at zoom 1, 3, 9 and 40 with grom's own settings and no setup chunk: the world sheet with both continents, Eastern Kingdoms with its zones, Dun Morogh with its neighbours stitched in, and the minimap's terrain drawn through the map. + +## The rectangles: a 1.12 zone sheet is opaque, and Era's is not + +With the stitch's geometry right, what was left at the zone zooms was hard rectangles. Measured +rather than guessed, with OpenMapClassic's own `/omc layer` switches as the bisect: turning +`baked` or `gaps` off changed nothing (neither draws on this build), turning `zoneart` off took +them all. So the rectangles are the per-zone sheets. + +`PlaceBase` masks a zone's sheet to its land and says why: *"Full C_Map zone textures include +parchment borders and painted sea; without a land mask they draw as overlapping rectangles."* The +mask comes from `ZoneMask`, which answers only for ids in `S.ZoneMaskIDs` — the contiguous Classic +range **1411–1452**, forty zones. On this realm that leaves **thirty-four** without one: every +city, the dungeons, and every zone the realm added of its own (Gilneas, Hyjal, Alah'Thalas, +Tel'Abim, Grim Reaches, Thalassian Highlands, Northwind, Balor, Gillijim's Isle …). + +Cities are already handled — the code declines to draw one with no outline mask, naming Stormwind +and Ironforge. The zones are not, and Gilneas is the clearest case: its whole 1002x668 sheet draws +over Silverpine, painted sea, "THE GREAT SEA" lettering, compass rose and a torn parchment border +and all. + +### Why the feather was off, and why it belongs on here + +`PlaceBase` takes a feather for exactly this and the zone walk passes `nil` for it, behind +`(not era) and FEATHER`. The reason is sound on Era: a `C_Map` zone texture there carries a +transparent surround, so a feather would do nothing. **It is not true of this client.** Measured +on the realm's own art: `Interface\WorldMap\AlahThalas\AlahThalas1.blp` and its siblings are alpha +255 across every texel. A 1.12 sheet is an opaque rectangle that paints its own sea. + +So the port passes the feather. `PlaceBase` prefers the land mask when there is one, so the forty +masked zones are untouched — measured, 0.57% of pixels moved in a Dun Morogh view, all of it at +the edges where a neighbour now fades. Gilneas loses its border and blends into Silverpine. + +### Generating the feather, and what was *not* generated + +`scripts/make-map-feather.py` writes `MapFeather`, `MapFeatherThin` and `MapFeatherWide` as +256x256 uncompressed 32-bit TGA, the format the shipped masks use. A feather is a smoothstep ramp +in from each edge — upstream's own NOTICE calls these "generated from noise" rather than cut from +art, so this reproduces them rather than imitating an artist. + +A per-zone **land** mask is a different thing and was deliberately not attempted. Upstream bakes +those by classifying painted sea against land by colour, and on this art the two do not separate: +sampled on the Gilneas sheet, a sea patch reads `R−B +28` and a land patch `+34`, overlapping +across the whole zone. The realm's own `.zmp` is authoritative about territory but is +128x128 over a continent — the largest zone on Azeroth owns 169 cells, about 13x13, which is a +28-px block on a 256 mask. Either route would be a guess dressed as art. The feather is not: it is +the mask the addon already asks for. diff --git a/scripts/make-map-feather.py b/scripts/make-map-feather.py new file mode 100755 index 00000000..cd798daa --- /dev/null +++ b/scripts/make-map-feather.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Generate OpenMapClassic's edge-feather masks. + +A feather is a mask over a map sheet's whole rect: opaque in the middle, falling to nothing at +the border, so the sheet blends into what is around it instead of ending in a straight line. +Upstream's own NOTICE calls these "generated from noise" rather than cut from art -- they are +parametric, so this reproduces them rather than approximating an artist. + +1.12 needs them where Classic Era does not. Era's `C_Map` zone textures carry a transparent +surround; the 1.12 sheets in this client's archives are **fully opaque** (measured: alpha 255 +everywhere) and paint their own sea, lettering and a torn parchment border right to the edge. So +a zone drawn without a mask covers its neighbours with a rectangle -- which is what +`OpenMapClassic/Map/Stitch.lua`'s own comment predicts, and what the Gilneas sheet does over +Silverpine. + +Writes 256x256 uncompressed 32-bit TGA, matching the shipped `MapZoneDilate` masks: RGB black, +alpha the coverage. +""" +import sys +from pathlib import Path + +import numpy as np +from PIL import Image + +SIZE = 256 +# Margin as a fraction of the sheet, per variant. The parchment border on a 1002-px sheet runs +# ~35 px (3.5%); a feather has to clear that and then blend, so the plain one takes 9%. +VARIANTS = {"MapFeather": 0.09, "MapFeatherThin": 0.045, "MapFeatherWide": 0.16} + + +def ramp(n: int, margin: float) -> np.ndarray: + """1 in the middle, 0 at both ends, smoothstep across `margin` of the span.""" + x = (np.arange(n) + 0.5) / n + d = np.minimum(x, 1.0 - x) / margin # distance from the nearer edge, in margins + t = np.clip(d, 0.0, 1.0) + return t * t * (3.0 - 2.0 * t) + + +def feather(margin: float) -> Image.Image: + a = np.outer(ramp(SIZE, margin), ramp(SIZE, margin)) + rgba = np.zeros((SIZE, SIZE, 4), dtype=np.uint8) + rgba[:, :, 3] = np.round(a * 255.0).astype(np.uint8) + return Image.fromarray(rgba, "RGBA") + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: make-map-feather.py ", file=sys.stderr) + return 2 + out = Path(sys.argv[1]) + if not out.is_dir(): + print(f"not a directory: {out}", file=sys.stderr) + return 2 + for name, margin in VARIANTS.items(): + path = out / f"{name}.tga" + feather(margin).save(path) + print(f"wrote {path} ({margin:.0%} margin)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())