switch to keystone.guru tiles + enemy data

Replaces the upreza-derived 4K dungeon textures + AtlasLoot boss-coord
overlay (which had a consistent positional offset against texture skulls)
with keystone.guru's z=4 tile pyramid stitched to 6144x4096 WebP per floor.

kg's split_floors.js gives per-dungeon enemies, packs (polygons), patrols
(polylines), and map icons calibrated to those tiles, so overlays align
pixel-perfectly. 27/29 classic dungeons now have full enemy/pack data;
ZG + Sunken Temple have maps only.

Pipeline: tools/kg_fetch.py -> tools/kg_stitch.py -> tools/kg_build_data.py.
This commit is contained in:
2026-04-25 22:11:17 +02:00
parent aa1cd9ee40
commit 18c7792935
15 changed files with 178577 additions and 10485 deletions
-219
View File
@@ -1,219 +0,0 @@
#!/usr/bin/env python3
"""
Combine AtlasLoot boss coords + stitched hi-res maps into web/assets/dungeons.json.
Pipeline assumption:
data/maps_png_hires/<MapName>[_floor<n>].png ← stitch_uprez.py
web/assets/maps/<MapName>[_floor<n>].webp ← compress_for_web.py
Resolution order for AtlasLoot dungeon-id → map file(s):
1. data/aliases.json explicit override (keyed by AtlasLoot id OR MapName)
2. exact basename match (case-sensitive)
3. case-insensitive basename match
4. nothing → dungeon listed without a map (still pickable)
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "data"
WEB_ASSETS = ROOT / "web" / "assets"
WEB_MAPS = WEB_ASSETS / "maps" # holds the .webp files (and is also the source of truth for dimensions)
ALIASES_PATH = DATA / "aliases.json"
ATLASLOOT_PATH = DATA / "atlasloot_maps.json"
OUT_PATH = WEB_ASSETS / "dungeons.json"
FLOOR_RE = re.compile(r"^(.+?)(?:_floor\d+|_overview)$")
def load_json(p: Path) -> dict:
with p.open() as f:
return json.load(f)
def join_zone_name(zn) -> str | None:
if isinstance(zn, list) and zn:
return str(zn[0])
if isinstance(zn, str):
return zn
return None
def join_location(loc) -> str | None:
if isinstance(loc, list) and loc:
return str(loc[0])
if isinstance(loc, str):
return loc
return None
def collect_bosses(dungeon: dict) -> list[dict]:
out: list[dict] = []
seen = set()
for k, v in dungeon.items():
if not k.isdigit() or not isinstance(v, list):
continue
for entry in v:
if not isinstance(entry, dict):
continue
if entry.get("SubZone"):
continue
cords = entry.get("cords")
name = entry.get("1")
if not (isinstance(cords, list) and len(cords) == 2 and name):
continue
key = (name, cords[0], cords[1])
if key in seen:
continue
seen.add(key)
out.append({
"name": name,
"x": cords[0],
"y": cords[1],
"type": entry.get("pinType", "boss"),
})
return out
def floor_sort_key(stem: str):
"""Sort _overview before any _floor<n>; floor numbers in numeric order."""
if stem.endswith("_overview"):
return (0, 0, stem)
m = re.match(r".+_floor(\d+)$", stem)
if m:
return (1, int(m.group(1)), stem)
return (2, 0, stem)
def index_map_files() -> dict[str, list[str]]:
"""Group web .webp files by base map name → { base: [stem1, stem2, ...] }."""
if not WEB_MAPS.exists():
return {}
grouped: dict[str, list[str]] = {}
for p in WEB_MAPS.glob("*.webp"):
stem = p.stem
m = FLOOR_RE.match(stem)
base = m.group(1) if m else stem
grouped.setdefault(base, []).append(stem)
for base in grouped:
grouped[base].sort(key=floor_sort_key)
return grouped
def expand_bases(bases: list[str], grouped: dict[str, list[str]]) -> list[str]:
"""An alias entry like "Karazhan" expands to all floors; a specific stem
like "Karazhan_floor1" passes through as-is."""
out: list[str] = []
for b in bases:
if b in grouped:
out.extend(grouped[b])
else:
out.append(b)
return out
def resolve_basenames(
dungeon_id: str,
map_name: str | None,
aliases: dict,
grouped: dict[str, list[str]],
) -> list[str]:
"""Return list of webp basenames to use for this dungeon entry.
Note: AtlasLoot's WotLK MapData has a known bug where every dungeon's
MapName is "DireMaul"; we therefore never resolve by MapName, only by
dungeon_id (with explicit aliases for the rest)."""
# Explicit alias override wins
if dungeon_id in aliases:
return expand_bases(aliases[dungeon_id], grouped)
if map_name and map_name in aliases:
return expand_bases(aliases[map_name], grouped)
# Auto-resolve by AtlasLoot id, exact then case-insensitive
if dungeon_id in grouped:
return list(grouped[dungeon_id])
lc_index = {k.lower(): k for k in grouped}
if dungeon_id and dungeon_id.lower() in lc_index:
return list(grouped[lc_index[dungeon_id.lower()]])
return []
def main() -> int:
atlasloot = load_json(ATLASLOOT_PATH)
aliases = load_json(ALIASES_PATH) if ALIASES_PATH.exists() else {}
aliases = {k: v for k, v in aliases.items() if not k.startswith("_")}
# Ascension M+ pool is classic-only.
atlasloot = {k: v for k, v in atlasloot.items() if k == "OriginalWoW"}
grouped = index_map_files()
if not grouped:
print(f"warning: no hi-res PNGs in {PNG_HIRES}", file=sys.stderr)
dungeons: list[dict] = []
used_bases: set[str] = set()
no_map: list[str] = []
for expansion, entries in atlasloot.items():
for did, dungeon in entries.items():
if not isinstance(dungeon, dict):
continue
map_name = dungeon.get("MapName")
zone_name = join_zone_name(dungeon.get("ZoneName")) or did
bosses = collect_bosses(dungeon)
basenames = resolve_basenames(did, map_name, aliases, grouped)
maps_out: list[dict] = []
for basename in basenames:
src = WEB_MAPS / f"{basename}.webp"
if not src.exists():
continue
with Image.open(src) as im:
w, h = im.size
used_bases.add(basename)
maps_out.append({
"image": f"maps/{basename}.webp",
"width": w, "height": h,
"label": basename,
"bosses": bosses if len(basenames) == 1 else [],
})
entry = {
"id": did,
"expansion": expansion,
"name": zone_name,
"acronym": dungeon.get("Acronym"),
"levelRange": dungeon.get("LevelRange"),
"playerLimit": dungeon.get("PlayerLimit"),
"location": join_location(dungeon.get("Location")),
"reputation": dungeon.get("Reputation"),
"maps": maps_out,
}
if not maps_out:
no_map.append(f"{expansion}/{did} (MapName={map_name})")
elif len(maps_out) > 1:
entry["unassignedBosses"] = bosses
dungeons.append(entry)
dungeons.sort(key=lambda d: (d["expansion"], d["name"]))
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
with OUT_PATH.open("w") as f:
json.dump({"dungeons": dungeons}, f, indent=2)
mapped = sum(1 for d in dungeons if d["maps"])
print(f"wrote {OUT_PATH}{len(dungeons)} dungeons, {mapped} with maps")
if no_map:
print(f"\n{len(no_map)} AtlasLoot entries had no resolvable map:", file=sys.stderr)
for s in no_map:
print(f" {s}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
"""
Render every classic dungeon's webp with AtlasLoot boss coords overlaid as
red rings, side-by-side with the bare texture. Output to /tmp/alignment/ for
human review.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
WEB_MAPS = ROOT / "web" / "assets" / "maps"
DUNGEONS_JSON = ROOT / "web" / "assets" / "dungeons.json"
OUT_DIR = Path("/tmp/alignment")
def main() -> int:
if not DUNGEONS_JSON.exists():
print("dungeons.json missing, run build_data.py first", file=sys.stderr)
return 1
OUT_DIR.mkdir(parents=True, exist_ok=True)
data = json.loads(DUNGEONS_JSON.read_text())
rendered = []
for d in data["dungeons"]:
if d.get("expansion") != "OriginalWoW":
continue
# Use bosses from the first map (single-floor) or unassignedBosses (multi)
bosses = d["maps"][0]["bosses"] if d["maps"] and d["maps"][0]["bosses"] else d.get("unassignedBosses", [])
if not d["maps"]:
continue
# Pick the first map for the alignment check
m = d["maps"][0]
src = WEB_MAPS / Path(m["image"]).name
if not src.exists():
continue
with Image.open(src) as im:
im = im.convert("RGBA")
W, H = im.size
draw = ImageDraw.Draw(im)
for b in bosses:
px = int((b["x"] / 100) * W)
py = int((b["y"] / 100) * H)
r = 80
draw.ellipse([px - r, py - r, px + r, py + r], outline="red", width=14)
draw.ellipse([px - 4, py - 4, px + 4, py + 4], fill="red")
# Scale down for review
scale = 1024 / W
im_small = im.resize((1024, int(H * scale)), Image.LANCZOS)
out = OUT_DIR / f"{d['id']}.png"
im_small.save(out)
rendered.append((d["id"], d["name"], len(bosses), out))
print(f"Rendered {len(rendered)} dungeons → {OUT_DIR}")
for did, name, n, p in rendered:
print(f" {did:30s} {n:>2d} bosses → {p.name}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""
Render every classic dungeon's first-floor webp with kg's enemy positions
overlaid as colored circles, plus pack polygon outlines, to verify alignment
visually. Output: /tmp/kg_alignment/<key>.png
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
WEB_MAPS = ROOT / "web" / "assets" / "maps"
DUNGEONS_JSON = ROOT / "web" / "assets" / "dungeons.json"
OUT_DIR = Path("/tmp/kg_alignment")
CLASS_RADIUS = {1: 14, 2: 18, 3: 28, 4: 38}
CLASS_FILL = {1: "#9aa1aa", 2: "#d6d6dc", 3: "#d63b3b", 4: "#ffd83a"}
def main() -> int:
OUT_DIR.mkdir(parents=True, exist_ok=True)
data = json.loads(DUNGEONS_JSON.read_text())
rendered = []
for d in data["dungeons"]:
if not d["maps"]:
continue
m = d["maps"][0]
src = WEB_MAPS / Path(m["image"]).name
if not src.exists():
continue
with Image.open(src) as im:
im = im.convert("RGBA")
W, H = im.size
draw = ImageDraw.Draw(im)
for p in m.get("packs", []):
pts = [(int(x), int(y)) for x, y in p["vertices"]]
if len(pts) >= 3:
draw.polygon(pts, outline=p["color"], width=4)
for e in m.get("enemies", []):
cls = e.get("classification") or 1
r = CLASS_RADIUS.get(cls, 14)
fill = CLASS_FILL.get(cls, "#9aa1aa")
x, y = int(e["pos"][0]), int(e["pos"][1])
draw.ellipse([x - r, y - r, x + r, y + r], fill=fill, outline="black", width=3)
scale = 1024 / W
small = im.resize((1024, int(H * scale)), Image.LANCZOS)
out = OUT_DIR / f"{d['id']}.png"
small.save(out)
rendered.append((d["id"], d["name"], len(m.get("enemies", [])), len(m.get("packs", []))))
print(f"rendered {len(rendered)} dungeons → {OUT_DIR}")
for did, name, n_e, n_p in rendered:
print(f" {did:32s} enemies={n_e:4d} packs={n_p:3d}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""
Build web/assets/dungeons.json from keystone.guru's mapping data.
Reads:
data/kg_dungeons.json dungeon registry
data/kg/_summary.json stitch grids per floor
data/kg/<key>/split_floors.js enemies, packs, patrols, mapIcons
data/kg/<key>/lang.js NPC names + classifications
Coordinate transform:
Each kg map is rendered by Leaflet CRS.Simple with native zoom = max_zoom.
pixel_x = lng * 2^zoom
pixel_y = -lat * 2^zoom
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
KG_DIR = ROOT / "data" / "kg"
REGISTRY = ROOT / "data" / "kg_dungeons.json"
WEB_ASSETS = ROOT / "web" / "assets"
WEB_MAPS = WEB_ASSETS / "maps"
OUT_PATH = WEB_ASSETS / "dungeons.json"
# Map kg's mapIconType names → simple labels we render in the UI.
ICON_TYPE_OVERRIDES = {
"comment": "comment",
"door": "door",
"door_down": "door",
"door_left": "door",
"door_locked": "door",
"door_right": "door",
"door_up": "door",
"graveyard": "graveyard",
"dungeon_start": "start",
"skip_walk": "skip",
"skip_flight": "skip",
"skip_teleport": "skip",
"raid_marker_skull": "boss",
}
def parse_js_var(path: Path) -> dict:
content = path.read_text()
start = content.find("{")
return json.loads(content[start:].rstrip(" ;\n"))
def vertices_from(json_str: str) -> list[dict]:
if not json_str:
return []
try:
return json.loads(json_str)
except Exception:
return []
def build_one(d: dict, summary: dict, npc_index: dict | None,
icon_index: dict[int, str]) -> dict:
"""Return the dungeons.json entry for one kg dungeon."""
tile_key = d["tile_key"]
name = d["name"]
floor_summary = next((x for x in summary["dungeons"] if x["tile_key"] == tile_key), None)
if not floor_summary or not floor_summary["floors"]:
return None
floors_per_dungeon = len(floor_summary["floors"])
zoom = floor_summary.get("max_zoom", 4)
scale = 2 ** zoom
data_path = KG_DIR / tile_key / "split_floors.js"
has_data = data_path.exists()
raw = parse_js_var(data_path) if has_data else None
# Group enemies / packs / patrols / mapIcons by floor_id, since one
# dungeon may have multiple floors and each entry says which floor.
by_floor: dict[int, dict] = {}
if has_data:
for e in raw["dungeon"].get("enemies", []):
fid = e.get("floor_id")
by_floor.setdefault(fid, {"enemies": [], "packs": [], "patrols": [], "icons": []})
by_floor[fid]["enemies"].append(e)
for p in raw["dungeon"].get("enemyPacks", []):
fid = p.get("floor_id")
by_floor.setdefault(fid, {"enemies": [], "packs": [], "patrols": [], "icons": []})
by_floor[fid]["packs"].append(p)
for pa in raw["dungeon"].get("enemyPatrols", []):
fid = pa.get("floor_id")
by_floor.setdefault(fid, {"enemies": [], "packs": [], "patrols": [], "icons": []})
by_floor[fid]["patrols"].append(pa)
for ic in raw["dungeon"].get("mapIcons", []):
fid = ic.get("floor_id")
by_floor.setdefault(fid, {"enemies": [], "packs": [], "patrols": [], "icons": []})
by_floor[fid]["icons"].append(ic)
# We need to map kg's floor_id → the index we stitched. The split_floors
# data doesn't include floor metadata directly, so use ordered alignment:
# kg's floors come back in `index` order; our stitched floors_summary uses
# the same ordering (we discovered them sequentially).
# Build a mapping kg_floor_id → stitched index. Without a definitive
# floor list in split_floors.js we lean on the ordering of floor_summary.
kg_floor_ids = sorted({fid for fid in by_floor})
floor_id_to_index = {fid: i + 1 for i, fid in enumerate(kg_floor_ids)} if kg_floor_ids else {}
maps_out = []
for floor_info in floor_summary["floors"]:
idx = floor_info["index"]
cols, rows = floor_info["cols"], floor_info["rows"]
suffix = f"_floor{idx}" if floors_per_dungeon > 1 else ""
image_basename = f"{tile_key}{suffix}"
W = cols * 384 # tileWidth = 384
H = rows * 256 # tileHeight = 256
# Find the kg floor_id for this index (best-effort)
kg_floor_id = None
for fid, ix in floor_id_to_index.items():
if ix == idx:
kg_floor_id = fid
break
f_data = by_floor.get(kg_floor_id, {"enemies": [], "packs": [], "patrols": [], "icons": []})
def to_pixel(lat, lng):
return [lng * scale, -lat * scale]
# Enemies
enemies_out = []
for e in f_data["enemies"]:
npc_id = e.get("npc_id")
npc = npc_index.get(npc_id) if npc_index else None
classification = npc.get("classification_id") if npc else None
enemies_out.append({
"id": e["id"],
"npc_id": npc_id,
"name": (npc or {}).get("name") or "Unknown",
"pos": to_pixel(e["lat"], e["lng"]),
"classification": classification, # 1=trash, 2=elite, 3=boss-ish, 4=boss?
"skippable": bool(e.get("skippable")),
"required": bool(e.get("required")),
"kill_priority": e.get("kill_priority"),
"pack_id": e.get("enemy_pack_id"),
"patrol_id": e.get("enemy_patrol_id"),
})
# Packs (polygon vertices)
packs_out = []
for p in f_data["packs"]:
verts = vertices_from(p.get("vertices_json"))
if not verts:
continue
packs_out.append({
"id": p["id"],
"color": p.get("color") or "#5993D2",
"label": p.get("label"),
"vertices": [to_pixel(v["lat"], v["lng"]) for v in verts],
"enemy_ids": [en["id"] for en in p.get("enemies", []) if "id" in en],
})
# Patrols (polylines)
patrols_out = []
for pa in f_data["patrols"]:
line = pa.get("polyline", {})
verts = vertices_from(line.get("vertices_json"))
if not verts:
continue
patrols_out.append({
"id": pa["id"],
"color": line.get("color") or "#003280",
"weight": line.get("weight") or 3,
"vertices": [to_pixel(v["lat"], v["lng"]) for v in verts],
})
# Map icons — bosses, doors, comments, etc.
icons_out = []
for ic in f_data["icons"]:
icon_type = icon_index.get(ic.get("map_icon_type_id"), "unknown")
icons_out.append({
"id": ic["id"],
"type": icon_type,
"pos": to_pixel(ic["lat"], ic["lng"]),
"comment": ic.get("comment"),
})
maps_out.append({
"image": f"maps/{image_basename}.webp",
"width": W, "height": H,
"label": image_basename,
"kg_floor_id": kg_floor_id,
"enemies": enemies_out,
"packs": packs_out,
"patrols": patrols_out,
"icons": icons_out,
})
return {
"id": tile_key,
"expansion": "OriginalWoW",
"name": name,
"acronym": d.get("acronym"),
"tile_key": tile_key,
"data_slug": d.get("data_slug"),
"mapping_id": d.get("mapping_id"),
"maps": maps_out,
}
def main() -> int:
if not REGISTRY.exists() or not (KG_DIR / "_summary.json").exists():
print("missing kg_dungeons.json or _summary.json", file=sys.stderr)
return 1
registry = json.loads(REGISTRY.read_text())
summary = json.loads((KG_DIR / "_summary.json").read_text())
# Build a global icon-type index from the static.js if present, else
# fall back to known IDs.
icon_index: dict[int, str] = {
1: "unknown", 2: "comment", 3: "door", 4: "door", 5: "door", 6: "door_locked",
7: "door", 8: "door", 9: "dot_yellow", 10: "start", 11: "gateway",
12: "graveyard", 26: "boss",
}
static_path = KG_DIR / "_static.js"
if static_path.exists():
try:
static_data = parse_js_var(static_path)["static"]
for it in static_data.get("mapIconTypes", []):
key = ICON_TYPE_OVERRIDES.get(it.get("key"), it.get("key"))
icon_index[it["id"]] = key or "unknown"
except Exception:
pass
dungeons = []
for d in registry["dungeons"]:
npc_index = None
lang_path = KG_DIR / d["tile_key"] / "lang.js"
if lang_path.exists():
try:
lang = parse_js_var(lang_path)
npc_index = {n["id"]: n for n in lang.get("dungeonNpcs", [])}
except Exception:
npc_index = {}
entry = build_one(d, summary, npc_index, icon_index)
if entry:
dungeons.append(entry)
dungeons.sort(key=lambda d: d["name"])
OUT_PATH.write_text(json.dumps({"dungeons": dungeons}, indent=2))
print(f"wrote {OUT_PATH}{len(dungeons)} dungeons")
for d in dungeons:
n_enemies = sum(len(m["enemies"]) for m in d["maps"])
n_packs = sum(len(m["packs"]) for m in d["maps"])
n_icons = sum(len(m["icons"]) for m in d["maps"])
print(f" {d['name']:30s} maps={len(d['maps'])} enemies={n_enemies} packs={n_packs} icons={n_icons}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""
Fetch keystone.guru tiles + enemy data for every classic dungeon listed in
data/kg_dungeons.json.
Outputs:
data/kg/<tile_key>/floor<n>/z4/<x>_<y>.png raw tiles
data/kg/<tile_key>/<data_slug>_split_floors.js
data/kg/<tile_key>/<data_slug>_lang.js
The compiled-data path includes a build hash; we discover it once from a
known route page and use it for every fetch in this run.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import json
import re
import sys
import time
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
KG_OUT = ROOT / "data" / "kg"
REGISTRY = ROOT / "data" / "kg_dungeons.json"
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
TILE_BASE = "https://assets.keystone.guru/tiles"
DATA_BASE = "https://assets.keystone.guru/compiled"
ROUTE_PROBE_URL = "https://aws.keystone.guru/route/razorfen-downs/2bhiRi8/ascension-m-rfd/1"
HASH_RE = re.compile(r"compiled/([0-9a-f]{40})/")
def http_get(url: str, timeout: int = 15) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read()
def http_head_ok(url: str, timeout: int = 5) -> bool:
req = urllib.request.Request(url, headers={"User-Agent": UA}, method="HEAD")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status == 200
except Exception:
return False
def discover_compiled_hash() -> str:
html = http_get(ROUTE_PROBE_URL).decode("utf-8", errors="replace")
m = HASH_RE.search(html)
if not m:
raise RuntimeError("could not find compiled-asset hash on route page")
return m.group(1)
def discover_floors(tile_key: str, expansion: str, max_zoom: int = 4) -> list[int]:
"""Probe floor numbers 1..N until the first miss. Uses zoom=1 (the
lowest-zoom layer kg ships) for the existence check."""
floors = []
for f in range(1, 20):
if http_head_ok(f"{TILE_BASE}/{expansion}/{tile_key}/{f}/1/0_0.png"):
floors.append(f)
else:
break
return floors
def discover_grid(tile_key: str, expansion: str, floor: int, z: int) -> tuple[int, int]:
"""Find max x and max y at given zoom."""
max_x = 0
while http_head_ok(f"{TILE_BASE}/{expansion}/{tile_key}/{floor}/{z}/{max_x + 1}_0.png"):
max_x += 1
max_y = 0
while http_head_ok(f"{TILE_BASE}/{expansion}/{tile_key}/{floor}/{z}/0_{max_y + 1}.png"):
max_y += 1
return max_x + 1, max_y + 1 # counts (cols, rows)
def fetch_tile(args) -> tuple[Path, bool]:
url, dest = args
if dest.exists() and dest.stat().st_size > 0:
return dest, True
dest.parent.mkdir(parents=True, exist_ok=True)
try:
data = http_get(url, timeout=30)
if not data or data[:4] != b"\x89PNG":
return dest, False
dest.write_bytes(data)
return dest, True
except Exception:
return dest, False
def fetch_dungeon_tiles(d: dict, expansion: str, max_zoom: int, workers: int) -> dict:
"""For one dungeon, discover floors + grid, parallel-download all tiles."""
tile_key = d["tile_key"]
name = d["name"]
out_root = KG_OUT / tile_key
out_root.mkdir(parents=True, exist_ok=True)
floors = discover_floors(tile_key, expansion)
info = {"tile_key": tile_key, "name": name, "expansion": expansion,
"max_zoom": max_zoom, "floors": []}
if not floors:
print(f" WARN no floors for {tile_key}", file=sys.stderr)
return info
jobs = []
for f in floors:
cols, rows = discover_grid(tile_key, expansion, f, max_zoom)
info["floors"].append({"index": f, "cols": cols, "rows": rows})
floor_dir = out_root / f"floor{f}" / f"z{max_zoom}"
for x in range(cols):
for y in range(rows):
url = f"{TILE_BASE}/{expansion}/{tile_key}/{f}/{max_zoom}/{x}_{y}.png"
dest = floor_dir / f"{x}_{y}.png"
jobs.append((url, dest))
ok = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
for path, success in pool.map(fetch_tile, jobs):
if success:
ok += 1
print(f" {tile_key}: {ok}/{len(jobs)} tiles, floors={[f['index'] for f in info['floors']]}, "
f"grid=" + ", ".join(f"f{f['index']}:{f['cols']}x{f['rows']}" for f in info["floors"]))
return info
def fetch_dungeon_data(d: dict, compiled_hash: str) -> bool:
"""Download split_floors.js + en_US.js for one dungeon."""
if not d.get("data_slug") or not d.get("mapping_id"):
return False
slug = d["data_slug"]; mid = d["mapping_id"]; tile_key = d["tile_key"]
out_root = KG_OUT / tile_key
out_root.mkdir(parents=True, exist_ok=True)
splits_url = f"{DATA_BASE}/{compiled_hash}/mapcontext/data/{slug}/{mid}/split_floors.js"
lang_url = f"{DATA_BASE}/{compiled_hash}/mapcontext/data/{slug}/en_US.js"
try:
(out_root / "split_floors.js").write_bytes(http_get(splits_url))
(out_root / "lang.js").write_bytes(http_get(lang_url))
return True
except Exception as e:
print(f" data fetch failed for {tile_key}: {e}", file=sys.stderr)
return False
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--workers", type=int, default=24)
ap.add_argument("--zoom", type=int, default=4)
ap.add_argument("--dungeon", help="only fetch this tile_key")
args = ap.parse_args()
registry = json.loads(REGISTRY.read_text())
expansion = registry.get("_expansion", "classic")
compiled_hash = discover_compiled_hash()
print(f"compiled hash: {compiled_hash}")
targets = registry["dungeons"]
if args.dungeon:
targets = [d for d in targets if d["tile_key"] == args.dungeon]
if not targets:
print(f"no dungeon with tile_key={args.dungeon}", file=sys.stderr)
return 2
summary = {"compiled_hash": compiled_hash, "dungeons": []}
for d in targets:
print(f"==> {d['name']} ({d['tile_key']})")
info = fetch_dungeon_tiles(d, expansion, args.zoom, args.workers)
info["data_fetched"] = fetch_dungeon_data(d, compiled_hash)
summary["dungeons"].append(info)
(KG_OUT / "_summary.json").write_text(json.dumps(summary, indent=2))
print(f"\nwrote {KG_OUT}/_summary.json — {len(summary['dungeons'])} dungeons")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Rebuild data/kg/_summary.json by inspecting which tiles are on disk.
Each dungeon's floor is assumed to be 16x16 tiles at z=4 (that's the
convention kg uses for classic dungeons we sampled). If any tile is missing,
report it but keep the floor in the summary so stitching can paste a
black-fill placeholder.
"""
from __future__ import annotations
import json
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parent.parent
KG_DIR = ROOT / "data" / "kg"
REGISTRY = ROOT / "data" / "kg_dungeons.json"
ZOOM = 4
COLS = ROWS = 16
def main() -> int:
registry = json.loads(REGISTRY.read_text())
dungeons = []
total_missing = 0
for d in registry["dungeons"]:
tile_key = d["tile_key"]
root = KG_DIR / tile_key
if not root.exists():
continue
floor_dirs = sorted([p for p in root.iterdir() if p.is_dir() and p.name.startswith("floor")],
key=lambda p: int(re.search(r"\d+", p.name).group()))
floors = []
for fd in floor_dirs:
zd = fd / f"z{ZOOM}"
if not zd.exists():
continue
present = {p.name for p in zd.glob("*.png")}
missing = []
for x in range(COLS):
for y in range(ROWS):
if f"{x}_{y}.png" not in present:
missing.append((x, y))
if not present:
continue
idx = int(re.search(r"\d+", fd.name).group())
floors.append({
"index": idx, "cols": COLS, "rows": ROWS,
"missing_tiles": len(missing),
})
total_missing += len(missing)
dungeons.append({
"tile_key": tile_key,
"name": d["name"],
"expansion": "classic",
"max_zoom": ZOOM,
"floors": floors,
"data_fetched": (root / "split_floors.js").exists(),
})
summary = {"compiled_hash": "(resynced from disk)", "dungeons": dungeons}
(KG_DIR / "_summary.json").write_text(json.dumps(summary, indent=2))
print(f"wrote summary: {len(dungeons)} dungeons, {total_missing} missing tiles total")
for d in dungeons:
f_str = ", ".join(f"f{f['index']}({f['missing_tiles']}miss)" if f['missing_tiles'] else f"f{f['index']}" for f in d['floors'])
flag = "" if d["data_fetched"] else " [no data]"
print(f" {d['name']:30s} {f_str}{flag}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Stitch keystone.guru tile pyramid (z=4 by default) into one WebP per
dungeon-floor. Reads `data/kg/_summary.json` (produced by kg_fetch.py) for
the grid bounds.
Output: web/assets/maps/<tile_key>[_floor<n>].webp
"""
from __future__ import annotations
import argparse
import concurrent.futures
import json
import sys
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
KG_DIR = ROOT / "data" / "kg"
WEB_MAPS = ROOT / "web" / "assets" / "maps"
TILE_W, TILE_H = 384, 256
def stitch_one(args):
tile_key, floor_info, zoom, all_floors_count, quality = args
floor = floor_info["index"]
cols, rows = floor_info["cols"], floor_info["rows"]
src_dir = KG_DIR / tile_key / f"floor{floor}" / f"z{zoom}"
canvas = Image.new("RGBA", (cols * TILE_W, rows * TILE_H), (0, 0, 0, 255))
missing = 0
for x in range(cols):
for y in range(rows):
tile_path = src_dir / f"{x}_{y}.png"
if not tile_path.exists():
missing += 1
continue
with Image.open(tile_path) as t:
canvas.paste(t.convert("RGBA"), (x * TILE_W, y * TILE_H))
suffix = f"_floor{floor}" if all_floors_count > 1 else ""
out = WEB_MAPS / f"{tile_key}{suffix}.webp"
out.parent.mkdir(parents=True, exist_ok=True)
canvas.convert("RGB").save(out, "WEBP", quality=quality, method=4)
return tile_key, floor, canvas.size, missing
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--quality", type=int, default=85)
ap.add_argument("--workers", type=int, default=4)
args = ap.parse_args()
summary_path = KG_DIR / "_summary.json"
if not summary_path.exists():
print(f"missing {summary_path} — run kg_fetch.py first", file=sys.stderr)
return 1
summary = json.loads(summary_path.read_text())
jobs = []
for d in summary["dungeons"]:
if not d.get("floors"):
continue
for floor_info in d["floors"]:
jobs.append((d["tile_key"], floor_info, d["max_zoom"],
len(d["floors"]), args.quality))
print(f"stitching {len(jobs)} dungeon-floors with {args.workers} workers...")
with concurrent.futures.ProcessPoolExecutor(max_workers=args.workers) as pool:
for tile_key, floor, size, missing in pool.map(stitch_one, jobs):
note = f" ({missing} missing tiles)" if missing else ""
print(f" {tile_key} f{floor}: {size[0]}×{size[1]}{note}")
print(f"done → {WEB_MAPS}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-145
View File
@@ -1,145 +0,0 @@
#!/usr/bin/env python3
"""
Stitch WoWMapUprez_Dungeons 4×3 BLP tile grids and emit BOTH a hi-res PNG
(lossless intermediate) and a web-ready WebP per dungeon-floor in parallel.
Inputs: data/uprez/Interface/Worldmap/<MapName>/<MapName><floor>_<idx>.blp
Outputs: data/maps_png_hires/<MapName>[_floor<n>].png (lossless, ~8 MB)
web/assets/maps/<MapName>[_floor<n>].webp (q=82, ~600 KB-1.5 MB)
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "data" / "uprez" / "Interface" / "Worldmap"
DST_PNG = ROOT / "data" / "maps_png_hires"
DST_WEBP = ROOT / "web" / "assets" / "maps"
# Two tile-naming conventions:
# <Map><floor>_<idx>.blp — multi-level dungeons (e.g. Naxxramas2_5.blp)
# <Map><idx>.blp — single-level dungeons (e.g. ZulFarrak5.blp)
# In both cases, idx is 1-12 (12 tiles per level in a 4×3 grid).
TILE_IDX_RE = re.compile(r"^(?P<prefix>.+?)(?P<idx>1[0-2]|[1-9])\.blp$", re.IGNORECASE)
FLOOR_SUFFIX_RE = re.compile(r"^(?P<map>.+?)(?P<floor>\d+)_$")
COLS, ROWS = 4, 3
TILES_PER_FLOOR = COLS * ROWS
def parse_tile(filename: str):
"""Return (mapname, floor, idx) or None. floor=0 indicates the
"single-level" form with no explicit floor in the filename."""
m = TILE_IDX_RE.match(filename)
if not m:
return None
prefix = m.group("prefix")
idx = int(m.group("idx"))
fm = FLOOR_SUFFIX_RE.match(prefix)
if fm:
return fm.group("map"), int(fm.group("floor")), idx
return prefix, 0, idx
def stitch_one(args):
out_basename, tile_paths, png_dir, webp_dir, max_width, quality, write_png = args
# Open tile #1 to learn dimensions; assume uniform.
with Image.open(tile_paths[1]) as t1:
tw, th = t1.size
canvas = Image.new("RGBA", (COLS * tw, ROWS * th))
for idx in range(1, TILES_PER_FLOOR + 1):
with Image.open(tile_paths[idx]) as tile:
col = (idx - 1) % COLS
row = (idx - 1) // COLS
canvas.paste(tile.convert("RGBA"), (col * tw, row * th))
if write_png:
canvas.save(png_dir / f"{out_basename}.png", optimize=False)
# WebP needs RGB and may want a downscale
img = canvas.convert("RGB")
if max_width and img.width > max_width:
ratio = max_width / img.width
img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
img.save(webp_dir / f"{out_basename}.webp", "WEBP", quality=quality, method=4)
return out_basename, canvas.size
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--max-width", type=int, default=4096,
help="downscale WebP to this max width (default: keep 4096)")
ap.add_argument("--quality", type=int, default=82)
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 1))
ap.add_argument("--no-png", action="store_true",
help="skip lossless PNG intermediate (save disk + time)")
args = ap.parse_args()
if not SRC.exists():
print(f"missing {SRC} — clone WoWMapUprez_Dungeons first", file=sys.stderr)
return 1
DST_PNG.mkdir(parents=True, exist_ok=True)
DST_WEBP.mkdir(parents=True, exist_ok=True)
# Group: { (mapname, floor): {idx: path} }; floor=0 is "no floor in filename"
groups: dict[tuple[str, int], dict[int, Path]] = defaultdict(dict)
for dungeon_dir in sorted(SRC.iterdir()):
if not dungeon_dir.is_dir():
continue
for blp in dungeon_dir.glob("*.blp"):
parsed = parse_tile(blp.name)
if not parsed:
continue
mapname, floor, idx = parsed
groups[(mapname, floor)][idx] = blp
floors_per_map: dict[str, set[int]] = defaultdict(set)
for (mapname, floor) in groups:
floors_per_map[mapname].add(floor)
jobs = []
skipped = []
for (mapname, floor), tiles in sorted(groups.items()):
if len(tiles) != TILES_PER_FLOOR:
skipped.append(f"{mapname} floor {floor}: {len(tiles)}/{TILES_PER_FLOOR}")
continue
floors = floors_per_map[mapname]
if len(floors) == 1:
suffix = "" # only one variant
elif floor == 0:
suffix = "_overview" # mixed: 0 + others
else:
suffix = f"_floor{floor}"
out_base = f"{mapname}{suffix}"
jobs.append((out_base, tiles, DST_PNG, DST_WEBP, args.max_width, args.quality, not args.no_png))
print(f"stitching {len(jobs)} dungeon-floors with {args.workers} workers...")
done = 0
with ProcessPoolExecutor(max_workers=args.workers) as pool:
futures = [pool.submit(stitch_one, j) for j in jobs]
for fut in as_completed(futures):
try:
base, size = fut.result()
done += 1
if done % 20 == 0 or done == len(jobs):
print(f" {done}/{len(jobs)} (latest: {base} {size[0]}×{size[1]})")
except Exception as e:
print(f" FAIL: {e}", file=sys.stderr)
print(f"done: {done}/{len(jobs)} stitched")
if skipped:
print(f"\nskipped {len(skipped)} (incomplete tile sets):", file=sys.stderr)
for s in skipped[:10]:
print(f" {s}", file=sys.stderr)
if len(skipped) > 10:
print(f" ... and {len(skipped) - 10} more", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())