Files
healer-man/scripts/runewaker-pipeline/import-maps.py
T
2026-09-26 17:51:56 -04:00

245 lines
10 KiB
Python

"""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("<I", data, 12)[0]
gltf = json.loads(data[20:20 + json_length])
binary_start = 20 + ((json_length + 3) & ~3) + 8
def accessor(index):
item = gltf["accessors"][index]
view = gltf["bufferViews"][item["bufferView"]]
kind = {5126: "<f4", 5125: "<u4", 5123: "<u2"}[item["componentType"]]
width = {"VEC3": 3, "SCALAR": 1}[item["type"]]
offset = binary_start + view.get("byteOffset", 0) + item.get("byteOffset", 0)
return np.frombuffer(data, dtype=kind, count=item["count"] * width, offset=offset).reshape(-1, width)
primitive = gltf["meshes"][0]["primitives"][0]
return (accessor(primitive["attributes"]["POSITION"]).astype(np.float64),
accessor(primitive["indices"]).reshape(-1, 3))
def placement_for(slug):
metadata = read_json(ROOT / f"src/assets/game/dungeons/{slug}/import-metadata.json")
source = metadata["source"]
return source.get("wdbReport", source.get("wdb", {}))["primaryPlacement"]
def source_points(local_vertices, placement):
points = local_vertices * np.array([10, 10, -10], dtype=np.float64)
quaternion = np.array(placement["rotation"], dtype=np.float64)
cross = np.cross(quaternion[:3], points)
points = points + 2 * (quaternion[3] * cross + np.cross(quaternion[:3], cross))
return points * np.array(placement["scale"]) + np.array(placement["translation"])
def client_tiles(name):
directory = SOURCE / name / "0"
tiles = {}
for file in directory.glob("minimap_*.dds"):
match = re.fullmatch(r"minimap_(\d+)_(\d+)\.dds", file.name)
if match:
tiles[tuple(map(int, match.groups()))] = file
if not tiles:
return None
size = ((max(x for x, _ in tiles) + 1) * 512, (max(y for _, y in tiles) + 1) * 512)
image = Image.new("RGBA", size)
for (x, y), file in sorted(tiles.items()):
image.paste(Image.open(file).convert("RGBA"), (x * 512, y * 512))
return image, sorted(tiles.values())
def artwork_mask(image, size):
reduced = image.resize(size, Image.Resampling.BOX)
rgb = np.asarray(reduced.convert("RGB")).astype(np.int16)
background = np.asarray(image.convert("RGB").getpixel((0, 0)), dtype=np.int16)
opaque = np.asarray(reduced.getchannel("A")) > 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()