"""The region manifest: RawContent/World/Region.json, which says how a window of a cylindrical planet map becomes a tiled Unreal landscape. Pure Python (no numpy, no engine), shared by generate_region_tiles.py (writes the PNGs) and create_region_world.py (imports them), so both agree without either knowing about the other. Why a second manifest beside World.json. World.json describes one landscape built by the numpy pipeline from noise; this describes a *window cut from a finished planet map* and laid out as a grid of landscapes, because 2549 km2 at a 2 m quad is 637 M vertices and no single Landscape actor is going to take that in one import. The two share the height contract (`elevation_m` spans the 16-bit range, world Z 0 is elevation 0 m) and nothing else. **The grid is rectangular.** `tiles.columns` run along world X and `tiles.rows` along world Y, because a cylindrical planet map is 2:1 and forcing it into a square either crops it or stretches it. Set the two so that columns/rows matches the window's width/height in pixels and the ground is undistorted; `generate_region_tiles.py --scout` prints the metres-per-pixel of each axis and complains when they disagree. The window. The source is read as a *flat* image: `source.metres_per_pixel` says what one of its pixels is worth and `source.window` is a rectangle of pixels in it (the whole image, by default). A cylindrical map read flat is stretched east-west by 1/cos(latitude), which is a few per cent at middle latitudes and severe near the poles; reading it flat is deliberate, because unprojecting needs a circumference the map does not carry and every projection of a window this size distorts more than the flat reading does. The tiles. `columns` x `rows` landscapes of `tiles.vertices` a side, each its own actor at its own place in one world-partitioned level. Neighbours *share* their edge vertices: tile (tx, ty) covers global vertices [tx * quads_per_tile, tx * quads_per_tile + quads_per_tile], so the last column of one tile is the first column of the next and the seam is closed by construction rather than by blending. Pick `tiles.vertices` as 255 * N + 1 so the engine gives every tile N x N components of 255 quads; see RawContent/World/README.md for why the component count is what matters. """ import json import os from world_manifest import ENGINE_SPAN_M_AT_SCALE_100, PROJECT_ROOT, WORLD_DIR MANIFEST_PATH = os.path.join(WORLD_DIR, "Region.json") TILE_DIR = os.path.join(WORLD_DIR, "RegionTiles") # The paint layers a manifest gets when it names none, which is every manifest written before D-74: the three # Elite_RockyMeadows' landscape material blends. The names mislead and are the pack's - Base_Layer is the rock, # Layer_02 the meadow grass, Layer_03 the high rock - and they are kept because the material blends by name. LEGACY_PAINT = ( {"name": "Base_Layer", "rule": "slope"}, {"name": "Layer_03", "rule": "altitude"}, {"name": "Layer_02", "rule": "remainder"}, ) # Still exported: region_overview.py and create_region_world.py imported it before the paint list existed, and # a manifest with no `paint` block still produces exactly this. LAYER_SUFFIXES = {entry["name"]: entry["name"] for entry in LEGACY_PAINT} # Every rule a paint layer may carry. `slope`, `altitude` and `beach` are read off the tile's own height; # `class` and `climate` are read off a biome mask that Tools/MapArt wrote; `remainder` is whatever no other # layer claimed, and exactly one layer must be it or the weights do not sum to 255. PAINT_RULES = ("slope", "altitude", "beach", "class", "climate", "remainder") class PaintLayer: """One paint layer: what the landscape material blends, what drives it, and whether it is built yet. `enabled` is what lets the biome layers land before the substances do. A layer that is not enabled is written by nobody and imported by nobody, so today's three-layer world is untouched, but `mapart biomes` still renders its mask - which is what makes a biome inspectable before there is any material for it. """ def __init__(self, data): self.name = data["name"] self.rule = data.get("rule", "remainder") # The file suffix defaults to the layer's name, so L_World_x0_y0_Sand.png needs no second spelling. self.suffix = data.get("suffix", self.name) self.enabled = bool(data.get("enabled", True)) self.classes = list(data.get("classes", [])) self.koppen = list(data.get("koppen", [])) self.note = data.get("note", "") if self.rule not in PAINT_RULES: raise ValueError(f"paint layer {self.name!r}: unknown rule {self.rule!r}; expected one of {PAINT_RULES}") if self.rule == "class" and not self.classes: raise ValueError(f"paint layer {self.name!r}: rule 'class' names no classes") if self.rule == "climate" and not self.koppen: raise ValueError(f"paint layer {self.name!r}: rule 'climate' names no Koppen codes") @property def reads_mask(self): """True when this layer's weight comes from a mask file rather than from the tile's own height.""" return self.rule in ("class", "climate") def __repr__(self): return f"PaintLayer({self.name!r}, {self.rule!r}, enabled={self.enabled})" HEIGHT_SUFFIX = "Height" MARKS_SUFFIX = "Marks" # reserved for the overlay's 8-bit mark index (D-57). Nothing writes one yet class RegionManifest: def __init__(self, data, path=MANIFEST_PATH): self.path = path # The one world level. Tile files are named after its last segment, so changing `level` renames every # tile the manifest expects - rename the PNGs in RegionTiles/ to match or they are all regenerated. self.level = data.get("level", "/Game/Maps/L_World") self.quad_cm = float(data["quad_cm"]) tiles = data["tiles"] # `count` is the old square spelling; columns/rows supersede it. square = int(tiles["count"]) if "count" in tiles else None self.tiles_x = int(tiles.get("columns", square)) self.tiles_y = int(tiles.get("rows", square)) self.vertices_per_tile = int(tiles["vertices"]) self.elevation_min_m = float(data["elevation_m"]["min"]) self.elevation_max_m = float(data["elevation_m"]["max"]) self.sea_level_m = float(data.get("sea_level_m", 0.0)) self.spawn_pad_m = float(data.get("spawn_pad_m", 150.0)) self.streaming_grid_components = int(data.get("streaming_grid_components", 4)) self.source = dict(data["source"]) self.overlay = dict(data.get("overlay", {})) self.layers = dict(data.get("layers", {})) # The paint layers, in order. A manifest with no `paint` block gets the three the pack's material # blends, which is what every manifest written before D-74 means. self.paint_layers = [PaintLayer(entry) for entry in self.layers.get("paint", LEGACY_PAINT)] names = [layer.name for layer in self.paint_layers] if len(set(names)) != len(names): raise ValueError(f"{path}: two paint layers share a name: {names}") remainders = [layer.name for layer in self.paint_layers if layer.rule == "remainder"] if len(remainders) != 1: # Not a warning. With none the weights do not reach 255 and the ground shows the material's first # layer wherever nothing claimed it; with two they fight over the same leftover. raise ValueError(f"{path}: exactly one paint layer must have rule 'remainder', found {remainders}") if not any(layer.enabled for layer in self.paint_layers): raise ValueError(f"{path}: no paint layer is enabled, so the landscape would have no weightmaps") # Where the biome masks come from and where Tools/MapArt puts them. self.biomes = dict(self.layers.get("biomes", {})) if min(self.tiles_x, self.tiles_y) < 1 or self.vertices_per_tile < 2: raise ValueError(f"{path}: tiles.columns/rows must be at least 1 and tiles.vertices at least 2") if self.elevation_max_m <= self.elevation_min_m or self.quad_cm <= 0: raise ValueError(f"{path}: quad_cm must be positive and elevation_m ordered") # Geometry: one tile, then the whole window. @property def quads_per_tile(self): return self.vertices_per_tile - 1 @property def tile_side_m(self): return self.quads_per_tile * self.quad_cm / 100.0 @property def quads_x(self): return self.quads_per_tile * self.tiles_x @property def quads_y(self): return self.quads_per_tile * self.tiles_y @property def vertices_x(self): """Distinct vertices across the window in X. Tiles share their edges, so it is not columns * vertices.""" return self.quads_x + 1 @property def vertices_y(self): return self.quads_y + 1 @property def width_m(self): return self.quads_x * self.quad_cm / 100.0 @property def height_m(self): return self.quads_y * self.quad_cm / 100.0 @property def area_km2(self): return (self.width_m / 1000.0) * (self.height_m / 1000.0) @property def side_m(self): """How big this world is, for anything that only needs one number: the sky dome's radius, the sea plane's size, the fog's density. The longer axis, so those all still cover the whole world. rocky_meadows.py reads this and World.json's manifest has it too.""" return max(self.width_m, self.height_m) def centre_vertex(self): """(tx, ty, i, j) of the vertex at the centre of the window: which tile it is in and where in that tile's PNG, columns then rows. On an odd grid the centre is mid-tile rather than on a corner, which is why this is worked out rather than assumed to be tile (n/2, n/2) at [0, 0].""" gi, gj = self.quads_x // 2, self.quads_y // 2 tx, i = divmod(gi, self.quads_per_tile) ty, j = divmod(gj, self.quads_per_tile) if tx >= self.tiles_x: # the far edge belongs to the last tile's last vertex tx, i = self.tiles_x - 1, self.quads_per_tile if ty >= self.tiles_y: ty, j = self.tiles_y - 1, self.quads_per_tile return tx, ty, i, j @property def tile_count(self): return self.tiles_x * self.tiles_y def quads_along(self, axis): """Quads across the whole window along axis 0 (world X, the columns) or 1 (world Y, the rows).""" return self.quads_x if axis == 0 else self.quads_y def tiles_along(self, axis): return self.tiles_x if axis == 0 else self.tiles_y # The height contract, identical to World.json's. @property def elevation_span_m(self): return self.elevation_max_m - self.elevation_min_m @property def elevation_mid_m(self): return (self.elevation_max_m + self.elevation_min_m) / 2.0 @property def z_scale(self): """The landscape actor's Z scale, so the 16-bit range spans exactly the manifest's elevation range.""" return self.elevation_span_m / ENGINE_SPAN_M_AT_SCALE_100 * 100.0 @property def landscape_z_cm(self): """Every tile's Z: value 32768 sits at elevation_mid, so elevation 0 m lands on world Z 0.""" return self.elevation_mid_m * 100.0 @property def sea_level_z_cm(self): return self.sea_level_m * 100.0 def metres_to_value(self, metres): return (metres - self.elevation_min_m) / self.elevation_span_m * 65535.0 # The source window. @property def source_path(self): """The image the tiles are cut from. Not every manifest has one: World Orogen's Unreal export renders the tiles itself and records where they came from instead of naming a file, so `kind` is `orogen_render` and there is nothing here to re-cut from. Only generate_region_tiles.py asks for this, and it is the one thing that cannot run against such a manifest - said plainly here, because the path into it is `create_region_world.ensure_tiles` noticing a *missing tile file*, and a bare KeyError three frames down does not explain that the tiles have to come from Orogen again.""" if "path" not in self.source: raise RuntimeError( f"{self.path}: source.kind is {self.source.get('kind', 'unset')!r} and names no file, so the " f"tiles cannot be cut here - they were written by World Orogen's Unreal landscape export. " f"Re-export them from Orogen into {TILE_DIR}, or point source.path at a planet heightmap to " f"use generate_region_tiles.py instead." ) return self.resolve(self.source["path"]) @property def source_elevation(self): """What the source's 0 and 65535 mean in metres. Orogen's heightmap export is a fixed -5000..6000 ramp whatever the planet, which is why this is a manifest number rather than something read from the file.""" elevation = self.source.get("elevation_m", {"min": -5000.0, "max": 6000.0}) return float(elevation["min"]), float(elevation["max"]) @property def source_window(self): """(x, y, width, height) in source pixels. x is taken modulo the image width, so a window may cross the map's seam. `size` is the old square spelling.""" window = self.source["window"] if "size" in window: return int(window["x"]), int(window["y"]), int(window["size"]), int(window["size"]) return int(window["x"]), int(window["y"]), int(window["width"]), int(window["height"]) @property def sea_scale(self): """Everything below sea level in the source is multiplied by this. The Orogen export puts its abyss at -5000 m on a fixed ramp built for a whole planet; left alone it would either clip against `elevation_m.min` or force an elevation range so wide the land loses its precision.""" return float(self.source.get("sea_scale", 1.0)) def metres_per_pixel(self, axis): """Ground metres one source pixel is worth along axis 0 (X) or 1 (Y). Derived from the window and the world, so the two can never drift apart; the manifest's own `metres_per_pixel` is documentation.""" _, _, width, height = self.source_window return (self.width_m / width) if axis == 0 else (self.height_m / height) # Files. def tile_name(self, tx, ty): return f"{os.path.basename(self.level)}_x{tx}_y{ty}" def tile_path(self, tx, ty, suffix): return os.path.join(TILE_DIR, f"{self.tile_name(tx, ty)}_{suffix}.png") def height_path(self, tx, ty): return self.tile_path(tx, ty, HEIGHT_SUFFIX) # The paint layers. @property def enabled_layers(self): """The layers that are written, imported and blended. The others exist in the manifest and have masks, and are waiting for a substance (D-74).""" return [layer for layer in self.paint_layers if layer.enabled] def find_layer(self, layer_name): for layer in self.paint_layers: if layer.name == layer_name: return layer raise KeyError(f"{self.path}: no paint layer named {layer_name!r}") def weight_path(self, tx, ty, layer_name): return self.tile_path(tx, ty, self.find_layer(layer_name).suffix) def marks_path(self, tx, ty): return self.tile_path(tx, ty, MARKS_SUFFIX) def tile_files(self, tx, ty): """Every file a tile needs to exist before it can be imported. Only the enabled layers: a tile is not missing because a biome nobody has a substance for has no weightmap.""" return ([self.height_path(tx, ty)] + [self.weight_path(tx, ty, layer.name) for layer in self.enabled_layers]) # The biome masks, which Tools/MapArt writes and generate_region_tiles.py samples. @property def masks_dir(self): return self.resolve(self.biomes.get("masks_dir", "RawContent/World/Biomes")) def mask_path(self, layer): """The mask for a class- or climate-driven layer. Named after the layer, not after what it reads, so two layers reading the same class still get one file each.""" return os.path.join(self.masks_dir, f"mask_{layer.name.lower()}.png") @property def biome_blend_m(self): return float(self.biomes.get("blend_m", 400.0)) def tiles(self): for ty in range(self.tiles_y): for tx in range(self.tiles_x): yield tx, ty # Placement. The landscape library centres a landscape on the Location it is given, so a tile's location is # its own centre, measured from the window's centre so the whole grid straddles the origin. def tile_centre_cm(self, tx, ty): cx = (tx * self.quads_per_tile + self.quads_per_tile / 2.0) - self.quads_x / 2.0 cy = (ty * self.quads_per_tile + self.quads_per_tile / 2.0) - self.quads_y / 2.0 return cx * self.quad_cm, cy * self.quad_cm def resolve(self, relative): """A manifest path is relative to the project root unless it is absolute.""" return relative if os.path.isabs(relative) else os.path.normpath(os.path.join(PROJECT_ROOT, relative)) def describe(self): return (f"{self.tiles_x}x{self.tiles_y} tiles of {self.vertices_per_tile} vertices at {self.quad_cm:g} cm: " f"{self.tile_side_m / 1000:.3f} km a tile, {self.width_m / 1000:.2f} x {self.height_m / 1000:.2f} km, " f"{self.area_km2:.0f} km2; elevation {self.elevation_min_m:g}..{self.elevation_max_m:g} m " f"(Z scale {self.z_scale:g}, actor Z {self.landscape_z_cm:g} cm)") def load_manifest(path=MANIFEST_PATH): with open(path, "r", encoding="utf-8") as f: return RegionManifest(json.load(f), path)