"""Import client minimap tiles and calibrated navigation plans for RuneWaker dungeons. Requires Pillow and NumPy. RUNEWAKER_ROOT may point at another extracted client. """ import hashlib import json import os import re import struct from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFilter ROOT = Path(__file__).resolve().parents[2] SOURCE = Path(os.environ.get("RUNEWAKER_ROOT", ROOT.parent / "Runewaker")) / "Resource" / "texture" / "minimap" RECIPES = ROOT / "scripts/runewaker-pipeline/recipes" OUTPUT = ROOT / "public/assets/game/dungeons" CATALOG = ROOT / "src/game/generated/runewakerDungeonMaps.generated.json" IMAGE_SIZE = (1002, 668) SOURCE_PIXELS_PER_UNIT = 0.12 ALIASES = { "ystra-labyrinth-boss1": "tiledgn_hybora_labyrinth_01", "ystra-labyrinth-boss2": "tiledgn_hybora_labyrinth_02", "ystra-labyrinth-boss3": "tiledgn_hybora_labyrinth_03", } def read_json(path): return json.loads(path.read_text(encoding="utf-8")) def runtime_visual_checksum(slug): file = (ROOT / "src/game/dungeons/forsakenAbbey.ts" if slug == "forsaken-abbey" else ROOT / "src/game/generated/runewakerDungeonDefinitions.generated.ts") source = file.read_text(encoding="utf-8") identifier = f'id: "{slug}"' if slug == "forsaken-abbey" else f'"id": "{slug}"' start = source.find(identifier) if start < 0: raise ValueError(f"Missing runtime definition for {slug}") match = re.search(r'\bvisual\b[\s\S]*?\bchecksum["\s:]*"([0-9a-f]{64})"', source[start:]) if not match: raise ValueError(f"Missing runtime visual checksum for {slug}") return match.group(1) def navigation_mesh(slug): data = (ROOT / f"src/assets/game/dungeons/{slug}/{slug}-navigation.glb").read_bytes() json_length = struct.unpack_from(" 8 visible = (np.max(np.abs(rgb - background), axis=2) > 18) & opaque return np.asarray(Image.fromarray(visible.astype("uint8") * 255).filter(ImageFilter.MaxFilter(5))) > 8 def navmesh_mask(points, faces, size): image = Image.new("L", size) draw = ImageDraw.Draw(image) for face in faces: draw.polygon([tuple(points[index]) for index in face], fill=255) return np.asarray(image) > 0 def calibrate(image, source_vertices, faces): factor = min(1, 512 / max(image.size)) size = (round(image.width * factor), round(image.height * factor)) art = artwork_mask(image, size) oriented = source_vertices[:, [0, 2]] * np.array([1, -1]) minimum = oriented.min(axis=0) points = (oriented - minimum) * SOURCE_PIXELS_PER_UNIT * factor if points[:, 0].max() >= size[0] or points[:, 1].max() >= size[1]: return None floor = navmesh_mask(points, faces, size) if floor.sum() < 20 or art.sum() < 20: return None correlation = np.fft.irfft2( np.fft.rfft2(art.astype(np.float32)) * np.conj(np.fft.rfft2(floor.astype(np.float32))), s=art.shape, ) valid = correlation[:size[1] - int(points[:, 1].max()), :size[0] - int(points[:, 0].max())] row, column = np.unravel_index(np.argmax(valid), valid.shape) confidence = float(valid[row, column]) / float(floor.sum()) return minimum, np.array([column / factor, row / factor]), confidence def original_map(image, source_vertices, faces, calibrated): minimum, shift, confidence = calibrated points = ((source_vertices[:, [0, 2]] * np.array([1, -1]) - minimum) * SOURCE_PIXELS_PER_UNIT + shift) left = max(0, int(np.floor(points[:, 0].min() - 60))) top = max(0, int(np.floor(points[:, 1].min() - 60))) right = min(image.width, int(np.ceil(points[:, 0].max() + 60))) bottom = min(image.height, int(np.ceil(points[:, 1].max() + 60))) if right <= left or bottom <= top: raise ValueError("Calibrated minimap crop is empty") cropped = image.crop((left, top, right, bottom)) scale = min(IMAGE_SIZE[0] / cropped.width, IMAGE_SIZE[1] / cropped.height) resized = cropped.resize((round(cropped.width * scale), round(cropped.height * scale)), Image.Resampling.LANCZOS) output = Image.new("RGBA", IMAGE_SIZE, (10, 14, 17, 255)) origin = ((IMAGE_SIZE[0] - resized.width) // 2, (IMAGE_SIZE[1] - resized.height) // 2) output.alpha_composite(resized, origin) def projected(source): point = (source[[0, 2]] * np.array([1, -1]) - minimum) * SOURCE_PIXELS_PER_UNIT + shift return np.array(origin) + (point - np.array([left, top])) * scale return output, projected, confidence def navigation_plan(vertices, faces): horizontal = vertices[:, [0, 2]] * np.array([1, -1]) minimum = horizontal.min(axis=0) maximum = horizontal.max(axis=0) span = np.maximum(maximum - minimum, 1) scale = min((IMAGE_SIZE[0] - 80) / span[0], (IMAGE_SIZE[1] - 80) / span[1]) origin = np.array([(IMAGE_SIZE[0] - span[0] * scale) / 2, (IMAGE_SIZE[1] - span[1] * scale) / 2]) points = origin + (horizontal - minimum) * scale image = Image.new("RGBA", IMAGE_SIZE, (10, 14, 17, 255)) draw = ImageDraw.Draw(image) for face in faces: draw.polygon([tuple(points[index]) for index in face], fill=(43, 68, 76, 255)) def projected(runtime): return origin + (runtime[[0, 2]] * np.array([1, -1]) - minimum) * scale return image, projected def affine(projected, placement=None): def runtime_to_source(runtime): return source_points(np.array([runtime], dtype=np.float64), placement)[0] base = projected(runtime_to_source([0, 0, 0])) if placement else projected(np.array([0, 0, 0])) axes = [] for axis in range(3): unit = np.zeros(3) unit[axis] = 1 point = projected(runtime_to_source(unit)) if placement else projected(unit) axes.append(point - base) return [round(float(value), 8) for row in range(2) for value in (axes[0][row], axes[1][row], axes[2][row], base[row])] def main(): maps = {} summary = [] for recipe_file in sorted(RECIPES.glob("*.json")): if recipe_file.name.endswith("-population.json") or recipe_file.stem == "runewaker-player-avatars": continue recipe = read_json(recipe_file) slug = recipe["slug"] vertices, faces = navigation_mesh(slug) placement = placement_for(slug) source_vertices = source_points(vertices, placement) map_name = ALIASES.get(slug, Path(recipe["source"]["wdb"]).stem) tiles = client_tiles(map_name) calibrated = calibrate(tiles[0], source_vertices, faces) if tiles else None if calibrated and calibrated[2] >= 0.65: image, projected, confidence = original_map(tiles[0], source_vertices, faces, calibrated) matrix = affine(projected, placement) kind = "client" source_hash = hashlib.sha256(b"".join(file.read_bytes() for file in tiles[1])).hexdigest() else: image, projected = navigation_plan(vertices, faces) matrix = affine(projected) kind = "navigation" confidence = calibrated[2] if calibrated else 0 source_hash = hashlib.sha256((ROOT / f"src/assets/game/dungeons/{slug}/{slug}-navigation.glb").read_bytes()).hexdigest() destination = OUTPUT / slug / "maps" / "runewaker-map.png" destination.parent.mkdir(parents=True, exist_ok=True) image.save(destination, optimize=True) metadata = read_json(ROOT / f"src/assets/game/dungeons/{slug}/import-metadata.json") maps[slug] = { "mapId": read_json(next((ROOT / "scripts/runewaker-pipeline/snapshots").glob(f"{slug}-zone-*.json")))["zoneId"], "coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)", "visualChecksum": runtime_visual_checksum(slug), "kind": kind, "image": f"/assets/game/dungeons/{slug}/maps/runewaker-map.png", "checksum": hashlib.sha256(destination.read_bytes()).hexdigest(), "sourceHash": source_hash, "runtimeToMap": matrix, "width": IMAGE_SIZE[0], "height": IMAGE_SIZE[1], } summary.append((slug, kind, round(confidence, 3), metadata["status"])) CATALOG.write_text(json.dumps({"schemaVersion": 1, "dungeons": maps}, indent=2) + "\n", encoding="utf-8") for entry in summary: print(*entry) if __name__ == "__main__": main()