Files
benilla-octo/scripts/soften-zone-masks.py
T
gromandClaude Opus 5 ee125519f1 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
2026-11-04 17:00:14 -07:00

101 lines
4.6 KiB
Python
Executable File

#!/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())