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
+515
View File
@@ -0,0 +1,515 @@
#!/usr/bin/env python3
"""Clip OpenMapClassic's per-zone sheet masks to the continent's own coastline.
grom, at mid zoom out over the Great Sea west of the Eastern Kingdoms: pale parchment
**rectangles float on the open ocean**. Measured from a capture at world point (0.68, 0.42), zoom
6: the pale blocks sit at world points (0.725, 0.483) and (0.748, 0.407), and
`OpenMapClassic.MapStitch.Rect(id)` puts both inside the rects of uiMapIDs 1426 (Dun Morogh) and
1437 (Wetlands) -- two stock zones, each with a hand-made mask.
So the cause is the **dilation**. `Media/Textures/MapZoneDilate/<id>.tga` is Voronoi-dilated, "so
that the forty of them tile each continent with no gap", and a Voronoi cell does not stop at the
water: a coastal zone's cell runs out to sea until it meets the next cell, which out there is
never. The mask is opaque over open ocean, so the zone's sheet paints its blank parchment there.
`MapZoneSoft` is that same set softened and inherits it exactly.
On land the dilation is *right* and must be kept -- it is the only thing closing the seam between
two neighbouring sheets. It is wrong only past the coast, where there is no neighbour to meet. So
this intersects each soft mask with the continent's own land, and the three constants below are
the three ways that intersection can be got wrong.
What the land is: `MapOverlayLand/<id>.tga`, the union of each map's `WorldMapOverlay` alpha --
the pieces the game reveals as you explore, cut to the ground (`examples/bake_zone_land.rs`).
Composited into one world-space buffer at each map's rect on the stitched sheet, it is the shape
of Azeroth, measured rather than traced.
Three constants, each settling one trade-off:
* `CLOSE_YARDS`, the morphological close. The overlay union has gaps along every zone border --
two sheets' art abutting, not sea -- and intersecting with it raw would re-cut every seam the
dilation exists to hide. A close (dilate R, erode R) removes any background a disk of radius R
cannot sit in, so it fills the seams and leaves the ocean. R is set from the data: the widest
fully-enclosed gap in the union, per continent, which `--dry-run` measures and then re-measures
after the close to show it is gone.
* `MARGIN_YARDS`, the coastal keep. A 1.12 zone sheet deliberately paints the water off its own
coast -- Blizzard's hatching, the islets, the ships -- and clipping exactly at the overlay's
edge would shave all of it off. So the closed land is grown by this much before the intersect.
Coastal water yes, open ocean no.
* `FEATHER_YARDS`, the ramp at the outer edge of that margin. A hard cut would put a straight line
across open water wherever the margin ends inside a sheet's rect -- the defect's own shape, one
step further out. This is the same argument `soften-zone-masks.py` makes for its `BLUR`.
Reads `MapZoneSoft` and `MapOverlayLand`, writes `MapZoneCoast`; both sources are left alone so
this is re-runnable and the three sets can be compared. 256x256 uncompressed 32-bit TGA out, RGB
black, alpha the coverage, exactly as `soften-zone-masks.py` writes and the shipped masks are.
The world rects come from the client's own uiMap graph, dumped by
cargo run -q -p benilla-app --example bake_zone_land -- --dry-run --dump-rects <file>
which is the same graph the map window is driven by, so the placement here cannot drift from what
the addon draws. `--rects` takes that file.
"""
import argparse
import re
import sys
from pathlib import Path
import numpy as np
from PIL import Image
# Yards per world unit on the stitched sheet, measured by `--dump-rects` over every placed map:
# 44533 across u and 29689 down v on both continents, to the yard, on all 76 of them. The sheet is
# therefore *anisotropic in world units* and isotropic in yards -- 1002:668 is exactly 1.5, and so
# is 44533:29689 -- which is why the land buffer below is sized in yards and not in world units: a
# circular dilate in world units would be an ellipse on the map.
YARDS_PER_U = 44533.0
YARDS_PER_V = 29689.0
# The land buffer's resolution, as the width in pixels; the height follows from the yard extent so
# a pixel is square in yards. 4096 across the 60100-yard span is 15.1 yards a pixel, finer than
# the 256x256 source masks are over any zone (~19 yards for Dun Morogh), so the composite does not
# throw away mask detail.
BUFFER_WIDTH = 4096
# Pad the world extent by this much on every side, in world units, so a dilate near the outermost
# map's edge is not clipped by the buffer border.
EXTENT_PAD = 0.02
# Below this the composited coverage is background. The sources are softened (dilate 2, blur 3),
# and a gaussian's half level sits on the step it blurred, so 0.5 recovers the dilated boundary.
LAND_LEVEL = 0.5
# The close radius. A gap is closed when R exceeds its *inradius* -- the largest disk that fits
# inside it -- because closing leaves exactly the background a disk of radius R can sit in. The
# report measures that over every fully-enclosed gap in the union: 467 yards on Kalimdor (a 933
# yard gap) and 267 on the Eastern Kingdoms (534 yards). 500 is just above the larger, and the
# report then re-measures the gaps after the close to show there are none left.
#
# The guard that this is not *too* large is the other end of the report: a zone with no coastline
# -- Elwynn, Badlands, Loch Modan, Duskwood -- must lose ~nothing, because a radius big enough to
# bridge open water is a radius that has already swallowed the gaps around a landlocked zone. Too
# small and the seams between neighbouring sheets re-open; too large and the coastline grows back
# out to sea and nothing has been fixed.
#
# What it buys, measured with the coastal margin set to zero so the close is the only thing acting:
# with no close the landlocked zones lose 2.7--35.8% of their coverage to the seams (Redridge
# 35.8, Alterac 18.5, Loch Modan 17.8, Elwynn 14.2); at 500 that is 0.0--8.0%, and the 300-yard
# margin takes the rest of it to 0.0 for every one of them.
CLOSE_YARDS = 500.0
# The coastal keep, and the ramp past it. 300 yards is about 45 pixels of a 1.12 zone sheet (one
# is 1002 px over 4900--7000 yards on these rects): the width of the hatched shallows and the
# islets Blizzard paints off a coast, and far short of the 1000--1500 yards of blank parchment the
# Voronoi cells were reaching into -- measured, 10.4% of everything the forty sheets paint sits
# more than 600 yards past any land, and 2.9% sits more than 1200. Looked at, 300 is where the
# kept water stops being a rectangle and starts being a coast; at 600 a straight rect edge is
# still visible off the Wetlands and Dun Morogh, which is the defect itself. The feather is half
# again, so the sheet's own water fades out instead of ending on a line of its own.
MARGIN_YARDS = 300.0
FEATHER_YARDS = 150.0
# Names for the report only, read from the addon's own data file, which carries the zone name in a
# comment beside each uiMapID.
NAMES_LUA = "Data/Levels.lua"
# Coverage loss above which a zone with no coastline is a failure rather than a result: the close
# has eaten the inter-zone gaps instead of the ocean. See `CLOSE_YARDS`.
INLAND_LOSS_LIMIT = 0.03
# The zones used as that guard: stock zones with no coastline, so every pixel of their dilated
# mask is over their own ground or a neighbour's and nothing here may touch them. Arathi,
# Stonetalon, Hillsbrad, Dun Morogh and Thousand Needles read as inland and are deliberately not
# on the list -- each reaches water somewhere, and each does lose a slice. **Moonglade** is off it
# for the same reason and it is the one that is not obvious: its rect's north edge is past
# Kalimdor's own north coast (looked at: `--dump-png`'s land buffer, Moonglade's rect sits on the
# shoreline north of Felwood), so it loses 9% and that loss is the fix working, not failing.
INLAND_GUARD = (1416, 1418, 1422, 1427, 1428, 1429, 1430, 1431, 1432, 1433, 1449, 1452)
# grom's own two measurements of the defect, from a capture at world point (0.68, 0.42), zoom 6:
# the middle of each pale parchment block floating on the Great Sea. The report answers what the
# clip does at exactly those two points, so a run that does not close the reported symptom says so
# rather than being judged on an average.
DEFECT_PROBES = ((0.725, 0.483), (0.748, 0.407))
def read_rects(path: Path) -> dict[int, tuple[float, float, float, float]]:
"""`uiMapID<TAB>left<TAB>right<TAB>top<TAB>bottom`, normalized on uiMapID 947."""
out = {}
for line in path.read_text().splitlines():
if not line.strip() or line.startswith("#"):
continue
parts = line.split("\t")
out[int(parts[0])] = tuple(float(v) for v in parts[1:5])
return out
def read_names(path: Path) -> dict[int, str]:
out = {}
if not path.is_file():
return out
for m in re.finditer(r"\[(\d+)\]\s*=\s*\{[^}]*\}\s*,\s*--\s*(.+)", path.read_text()):
out[int(m.group(1))] = m.group(2).strip()
return out
def read_alpha(path: Path) -> np.ndarray:
"""A mask's alpha as 0..1 floats, row 0 at the top."""
im = Image.open(path).convert("RGBA")
return np.asarray(im.split()[-1], dtype=np.float32) / 255.0
def sample(mask: np.ndarray, u: np.ndarray, v: np.ndarray) -> np.ndarray:
"""Bilinear sample `mask` at normalized coordinates `u` (across) and `v` (down), clamped."""
h, w = mask.shape
x = np.clip(u * w - 0.5, 0.0, w - 1.0)
y = np.clip(v * h - 0.5, 0.0, h - 1.0)
x0 = np.floor(x).astype(np.int32)
y0 = np.floor(y).astype(np.int32)
x1 = np.minimum(x0 + 1, w - 1)
y1 = np.minimum(y0 + 1, h - 1)
fx = (x - x0)[None, :]
fy = (y - y0)[:, None]
top = mask[np.ix_(y0, x0)] * (1 - fx) + mask[np.ix_(y0, x1)] * fx
bot = mask[np.ix_(y1, x0)] * (1 - fx) + mask[np.ix_(y1, x1)] * fx
return top * (1 - fy) + bot * fy
class World:
"""The world-space land buffer: a pixel grid over the stitched sheet, square in yards."""
def __init__(self, rects: dict[int, tuple[float, float, float, float]], width: int):
# The extent is every map's rect, not the unit square: the continents hang off both ends
# of the world sheet on this realm's data (Kalimdor's left edge is u -0.187, the Eastern
# Kingdoms' right edge is 1.163), so a buffer cut to 0..1 would clip real land.
us = [r[0] for r in rects.values()] + [r[1] for r in rects.values()]
vs = [r[2] for r in rects.values()] + [r[3] for r in rects.values()]
self.u0, self.u1 = min(us) - EXTENT_PAD, max(us) + EXTENT_PAD
self.v0, self.v1 = min(vs) - EXTENT_PAD, max(vs) + EXTENT_PAD
self.w = width
self.ypp = (self.u1 - self.u0) * YARDS_PER_U / width # yards per pixel, both axes
self.h = max(1, int(round((self.v1 - self.v0) * YARDS_PER_V / self.ypp)))
self.buf = np.zeros((self.h, self.w), dtype=np.float32)
def cols(self, u0: float, u1: float) -> tuple[int, int]:
a = int(np.floor((u0 - self.u0) / (self.u1 - self.u0) * self.w))
b = int(np.ceil((u1 - self.u0) / (self.u1 - self.u0) * self.w))
return max(0, a), min(self.w, b)
def rows(self, v0: float, v1: float) -> tuple[int, int]:
a = int(np.floor((v0 - self.v0) / (self.v1 - self.v0) * self.h))
b = int(np.ceil((v1 - self.v0) / (self.v1 - self.v0) * self.h))
return max(0, a), min(self.h, b)
def u_of(self, cols: np.ndarray) -> np.ndarray:
return self.u0 + (cols + 0.5) / self.w * (self.u1 - self.u0)
def v_of(self, rows: np.ndarray) -> np.ndarray:
return self.v0 + (rows + 0.5) / self.h * (self.v1 - self.v0)
def paste_max(self, rect: tuple[float, float, float, float], mask: np.ndarray) -> None:
left, right, top, bottom = rect
c0, c1 = self.cols(left, right)
r0, r1 = self.rows(top, bottom)
if c1 <= c0 or r1 <= r0:
return
u = (self.u_of(np.arange(c0, c1)) - left) / (right - left)
v = (self.v_of(np.arange(r0, r1)) - top) / (bottom - top)
# `cols`/`rows` round outwards, so the first and last pixel of the span can sit just past
# the rect. `sample` clamps there rather than wrapping, which would smear the mask's edge
# row one pixel out all the way round; these zero it instead.
inside = (u >= 0) & (u <= 1)
inside_v = (v >= 0) & (v <= 1)
patch = sample(mask, u, v) * inside[None, :] * inside_v[:, None]
np.maximum(self.buf[r0:r1, c0:c1], patch, out=self.buf[r0:r1, c0:c1])
def read(self, rect: tuple[float, float, float, float], field: np.ndarray, n: int) -> np.ndarray:
"""Sample `field` over `rect` into an n x n grid -- the inverse of `paste_max`."""
left, right, top, bottom = rect
u = left + (np.arange(n) + 0.5) / n * (right - left)
v = top + (np.arange(n) + 0.5) / n * (bottom - top)
return sample(field, (u - self.u0) / (self.u1 - self.u0), (v - self.v0) / (self.v1 - self.v0))
def edt(fg: np.ndarray, cap: float) -> np.ndarray:
"""Euclidean distance in pixels from every pixel to the nearest `True`, exact up to `cap`.
Felzenszwalb--Huttenlocher's decomposition: an exact per-column distance first, then the exact
combination along each row. The row pass is restricted to offsets within `cap`, which costs
nothing in correctness for the answers we use -- if the true distance is at most `cap` then the
nearest pixel is within `cap` columns -- and turns an O(w) inner loop into a fixed one.
"""
h, w = fg.shape
inf = np.float32(cap + 1.0)
g = np.where(fg, np.float32(0.0), inf)
for y in range(1, h):
np.minimum(g[y], g[y - 1] + 1.0, out=g[y])
for y in range(h - 2, -1, -1):
np.minimum(g[y], g[y + 1] + 1.0, out=g[y])
np.minimum(g, inf, out=g)
best = g * g
tmp = np.empty_like(best)
for dx in range(1, int(np.ceil(cap)) + 1):
d2 = np.float32(dx * dx)
for src, dst, pad in (
(np.s_[:, dx:], np.s_[:, : w - dx], np.s_[:, w - dx :]),
(np.s_[:, : w - dx], np.s_[:, dx:], np.s_[:, :dx]),
):
tmp[dst] = g[src]
tmp[pad] = inf
np.multiply(tmp, tmp, out=tmp)
np.add(tmp, d2, out=tmp)
np.minimum(best, tmp, out=best)
return np.sqrt(best)
def border_reach(mask: np.ndarray) -> np.ndarray:
"""Which `True` pixels of `mask` are 4-connected to the buffer border -- i.e. are open sea.
Run-wise rather than pixel-wise: within one row, every pixel of a maximal run of `mask` is
reachable from every other, so a whole run is settled at once. Alternating a row pass with a
column pass converges in a handful of iterations where a pixel-at-a-time flood would take
thousands.
"""
seed = np.zeros_like(mask)
seed[0] |= mask[0]
seed[-1] |= mask[-1]
seed[:, 0] |= mask[:, 0]
seed[:, -1] |= mask[:, -1]
def rows_pass(m: np.ndarray, s: np.ndarray) -> np.ndarray:
starts = m.copy()
starts[:, 1:] &= ~m[:, :-1]
ids = np.cumsum(starts.ravel())
flat_m = m.ravel()
hit = np.bincount(ids[flat_m], weights=s.ravel()[flat_m], minlength=ids[-1] + 1) > 0
return (hit[ids].reshape(m.shape)) & m
for _ in range(64):
before = int(seed.sum())
seed = rows_pass(mask, seed)
seed = np.ascontiguousarray(
rows_pass(np.ascontiguousarray(mask.T), np.ascontiguousarray(seed.T)).T
)
if int(seed.sum()) == before:
break
return seed
def report_gaps(
world: "World",
rects: dict[int, tuple[float, float, float, float]],
land: np.ndarray,
dist_to_land: np.ndarray,
cap: float,
) -> np.ndarray:
"""Print the widest fully-enclosed gap per continent, and answer which pixels those are."""
bg = ~land
holes = bg & ~border_reach(bg)
for label, lo, hi in continent_bands(rects):
band = np.zeros_like(holes)
c0, c1 = world.cols(lo, hi)
band[:, c0:c1] = True
sel = holes & band
if not sel.any():
print(f" {label}: no enclosed gap at all")
continue
widest = float(dist_to_land[sel].max())
at_cap = " (AT THE MEASUREMENT CAP -- raise --gap-cap-yards)" if widest >= cap else ""
print(
f" {label}: {int(sel.sum())} enclosed gap px, widest "
f"{2 * widest * world.ypp:.0f} yards across (inradius {widest * world.ypp:.0f} "
f"yards / {widest:.1f} px){at_cap}"
)
return holes
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("textures", type=Path, help="OpenMapClassic/Media/Textures")
ap.add_argument("--rects", type=Path, required=True, help="--dump-rects output")
ap.add_argument("--out", type=Path, default=None, help="default: <textures>/MapZoneCoast")
ap.add_argument("--soft-dir", default="MapZoneSoft")
ap.add_argument("--land-dir", default="MapOverlayLand")
ap.add_argument("--close-yards", type=float, default=CLOSE_YARDS)
ap.add_argument("--margin-yards", type=float, default=MARGIN_YARDS)
ap.add_argument("--feather-yards", type=float, default=FEATHER_YARDS)
ap.add_argument("--width", type=int, default=BUFFER_WIDTH)
# How far out the gap measurement can see. Only the measurement: an enclosed gap wider than
# this reads as exactly this wide, and the report says so rather than quietly under-reporting.
ap.add_argument("--gap-cap-yards", type=float, default=4000.0)
ap.add_argument("--dry-run", action="store_true", help="measure and report, write nothing")
ap.add_argument("--dump-png", type=Path, default=None, help="land buffer and before/after PNGs")
args = ap.parse_args()
soft_dir = args.textures / args.soft_dir
land_dir = args.textures / args.land_dir
out_dir = args.out or (args.textures / "MapZoneCoast")
for d in (soft_dir, land_dir):
if not d.is_dir():
print(f"not a directory: {d}", file=sys.stderr)
return 2
rects = read_rects(args.rects)
names = read_names(args.textures.parent.parent / NAMES_LUA)
# -- the land buffer -------------------------------------------------------------------
world = World(rects, args.width)
print(
f"world buffer {world.w}x{world.h} over u {world.u0:.4f}..{world.u1:.4f}, "
f"v {world.v0:.4f}..{world.v1:.4f} -- {world.ypp:.2f} yards a pixel"
)
land_ids, unplaced = [], []
for path in sorted(land_dir.glob("*.tga"), key=lambda p: int(p.stem)):
mid = int(path.stem)
if mid not in rects:
unplaced.append(mid)
continue
world.paste_max(rects[mid], read_alpha(path))
land_ids.append(mid)
print(
f"composited {len(land_ids)} land mask(s); {len(unplaced)} have no rect on the sheet "
f"({', '.join(str(m) for m in unplaced) or 'none'})"
)
binary = world.buf >= LAND_LEVEL
raw_cover = float(binary.mean())
# -- what the close has to fill --------------------------------------------------------
# A gap between two zones is background the overlay art completely surrounds; open sea is
# background that reaches the buffer's border. Closing by radius R removes exactly the
# background a disk of radius R cannot sit inside, so the number that sets R is the largest
# radius of a disk that fits in an enclosed gap.
gap_cap = max(args.gap_cap_yards, args.close_yards * 1.5) / world.ypp
dist_to_land = edt(binary, gap_cap)
print(f"\nland {raw_cover * 100:.2f}% of the buffer")
holes = report_gaps(world, rects, binary, dist_to_land, gap_cap)
print(f"close radius R = {args.close_yards:.0f} yards ({args.close_yards / world.ypp:.1f} px)")
# -- close, then the coastal margin ----------------------------------------------------
r_px = args.close_yards / world.ypp
dilated = dist_to_land <= r_px
closed = edt(~dilated, r_px * 1.5) > r_px
print(
f"close raised coverage {raw_cover * 100:.2f}% -> {float(closed.mean()) * 100:.2f}% "
f"of the buffer; after it:"
)
# The same measurement again, which is the only proof the radius was big enough: a seam that
# survives the close is a seam that will be cut out of two neighbouring sheets.
report_gaps(world, rects, closed, edt(closed, gap_cap), gap_cap)
# The margin and its ramp come out of one distance field: full coverage out to `margin`, then a
# smoothstep to nothing by `margin + feather`.
out_dist = edt(closed, (args.margin_yards + args.feather_yards) / world.ypp * 1.2)
m_px = args.margin_yards / world.ypp
f_px = max(args.feather_yards / world.ypp, 1e-6)
t = np.clip(1.0 - (out_dist - m_px) / f_px, 0.0, 1.0)
keep = (t * t * (3.0 - 2.0 * t)).astype(np.float32)
print(
f"coastal margin {args.margin_yards:.0f} yards + {args.feather_yards:.0f} yard feather "
f"({args.margin_yards / YARDS_PER_U:.4f} of the sheet's width, "
f"{args.margin_yards / YARDS_PER_V:.4f} of its height) "
f"-> keep {float((keep > 0.5).mean()) * 100:.2f}% of the buffer"
)
# -- the reported symptom, at the reported spots ----------------------------------------
masked = {int(p.stem) for p in soft_dir.glob("*.tga")}
print("\nat grom's two measured points:")
for u, v in DEFECT_PROBES:
col = int((u - world.u0) / (world.u1 - world.u0) * world.w)
row = int((v - world.v0) / (world.v1 - world.v0) * world.h)
here = [
f"{m} {names.get(m, '?')}"
for m, (left, right, top, bottom) in sorted(rects.items())
if m in masked and left <= u <= right and top <= v <= bottom
]
print(
f" ({u}, {v}): {out_dist[row, col] * world.ypp:>5.0f} yards past the closed land, "
f"keep {keep[row, col]:.3f} -- in the rect of {', '.join(here) or 'no masked map'}"
)
# -- the masks -------------------------------------------------------------------------
rows = []
for path in sorted(soft_dir.glob("*.tga"), key=lambda p: int(p.stem)):
mid = int(path.stem)
if mid not in rects:
print(f" {mid}: no rect on the sheet, skipped", file=sys.stderr)
continue
before = read_alpha(path)
after = (before * world.read(rects[mid], keep, before.shape[0])).astype(np.float32)
rows.append((mid, before, after))
print(f"\n{'uiMapID':>8} {'zone':<24} {'before':>8} {'after':>8} {'removed':>8}")
worst = []
for mid, before, after in rows:
b, a = float(before.mean()), float(after.mean())
frac = 0.0 if b <= 0 else 1.0 - a / b
worst.append((frac, mid))
print(f"{mid:>8} {names.get(mid, '?'):<24} {b:>8.4f} {a:>8.4f} {frac * 100:>7.1f}%")
bad = [(f, m) for f, m in worst if m in INLAND_GUARD and f > INLAND_LOSS_LIMIT]
worst.sort(reverse=True)
print(
"\nlost most: "
+ ", ".join(f"{names.get(m, m)} {f * 100:.0f}%" for f, m in worst[:6])
)
print(
"lost least: "
+ ", ".join(f"{names.get(m, m)} {f * 100:.1f}%" for f, m in worst[-4:][::-1])
)
if args.dump_png:
args.dump_png.mkdir(parents=True, exist_ok=True)
png(args.dump_png / "land-raw.png", world.buf)
png(args.dump_png / "land-closed.png", closed.astype(np.float32))
png(args.dump_png / "land-keep.png", keep)
png(args.dump_png / "land-gaps.png", holes.astype(np.float32))
for mid, before, after in rows:
png(args.dump_png / f"{mid}-before.png", before)
png(args.dump_png / f"{mid}-after.png", after)
print(f"PNGs in {args.dump_png}")
# The guard is checked before anything is written, not after: a run that fails it has eaten
# the inter-zone gaps and its masks would cut seams back into the sheets, so it ships nothing.
if bad:
print(
"\nFAILED: inland zone(s) lost more than "
f"{INLAND_LOSS_LIMIT * 100:.0f}% -- "
+ ", ".join(f"{names.get(m, m)} {f * 100:.1f}%" for f, m in bad)
+ ". The close radius is too small and is eating the inter-zone gaps, not the ocean."
" Nothing written.",
file=sys.stderr,
)
return 1
if args.dry_run:
print(f"\n--dry-run: nothing written (would have written to {out_dir})")
return 0
out_dir.mkdir(parents=True, exist_ok=True)
for mid, _, after in rows:
rgba = np.zeros(after.shape + (4,), dtype=np.uint8)
rgba[:, :, 3] = np.round(np.clip(after, 0.0, 1.0) * 255.0).astype(np.uint8)
Image.fromarray(rgba, "RGBA").save(out_dir / f"{mid}.tga")
print(f"\nwrote {len(rows)} mask(s) to {out_dir}")
return 0
def continent_bands(rects: dict[int, tuple[float, float, float, float]]) -> list:
"""The u span each continent owns alone, for splitting a measurement between the two.
The two continent rects overlap in the middle of the sheet -- Kalimdor's reaches to u 0.640,
the Eastern Kingdoms' starts at 0.372 -- and the overlap is all open water, so the band each
owns outright is the honest place to measure its gaps.
"""
out = []
kal, ek = rects.get(1414), rects.get(1415)
if kal and ek:
out.append(("Kalimdor", kal[0], ek[0]))
out.append(("Eastern Kingdoms", kal[1], ek[1]))
return out
def png(path: Path, field: np.ndarray) -> None:
data = np.round(np.clip(field, 0.0, 1.0) * 255.0).astype(np.uint8)
Image.fromarray(data, "L").save(path)
if __name__ == "__main__":
raise SystemExit(main())
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Write OpenMapClassic's `Data/OverlayLand.lua` from the baked `MapOverlayLand` masks.
`cargo run -p benilla-app --example bake_zone_land` writes one 256x256 alpha mask per map, the
union of that map's own `WorldMapOverlay` art -- the shape of the ground it paints. Lua cannot ask
whether a texture file exists (a missing one draws as a white quad, or, as a *mask*, is silently
dropped and the whole rectangle shows), so the addon needs a table of which ids were baked. That
is all this writes.
Run it after the baker, any time the mask set changes:
python3 scripts/overlay-land-index.py
`--dir` points at another mask folder, `--out` at another destination.
"""
import argparse
import re
import sys
from pathlib import Path
ADDON = Path(__file__).resolve().parent.parent / "benilla-config/AddOns/OpenMapClassic"
HEAD = """--------------------------------------------------------------------------------
-- Which maps have a generated land mask (Media/Textures/MapOverlayLand/<uiMapID>.tga).
--
-- GENERATED -- do not edit. `cargo run -p benilla-app --example bake_zone_land` writes the masks,
-- `scripts/overlay-land-index.py` writes this list.
--
-- Each mask is the union of that map's own `WorldMapOverlay` alpha: the pieces the game reveals
-- as you explore, cut to the shape of the ground, so the union is the map's land and nothing
-- else. `Map/Stitch.lua` cuts the unexplored darkening to it, and falls back to it for the art of
-- any map the hand-made `MapZoneSoft` set has no file for -- this realm has 175 `WorldMapArea`
-- rows against that set's 40.
--
-- A file list, not a file test, because Lua has no way to ask whether a texture exists: a mask
-- whose art is missing is dropped by the engine and the whole rectangle draws, which is the
-- "massive squares" this set exists to remove.
--------------------------------------------------------------------------------
local _, ns = ...
ns.MapOverlayLand = {
"""
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--dir", default=str(ADDON / "Media/Textures/MapOverlayLand"))
ap.add_argument("--out", default=str(ADDON / "Data/OverlayLand.lua"))
args = ap.parse_args()
folder = Path(args.dir)
if not folder.is_dir():
print(f"overlay-land-index: no mask folder at {folder}", file=sys.stderr)
return 1
ids = sorted(
int(m.group(1))
for p in folder.iterdir()
if (m := re.fullmatch(r"(\d+)\.tga", p.name, re.IGNORECASE))
)
if not ids:
print(f"overlay-land-index: {folder} holds no <id>.tga masks", file=sys.stderr)
return 1
body = []
for i in range(0, len(ids), 8):
body.append("\t" + " ".join(f"[{n}] = true," for n in ids[i : i + 8]))
text = HEAD + "\n".join(body) + "\n}\n"
Path(args.out).write_text(text, encoding="utf-8")
print(f"overlay-land-index: {len(ids)} masks -> {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())