320 lines
16 KiB
Python
320 lines
16 KiB
Python
"""Builds the world level - /Game/Maps/L_World, as Region.json's `level` says (D-72): a world-partitioned
|
|
level holding the grid of landscapes that
|
|
RawContent/World/Region.json describes, imported from the tiles in RawContent/World/RegionTiles/ and dressed
|
|
with Elite_RockyMeadows' kit through rocky_meadows.py. The level is a product of this script, the manifest and
|
|
the PNGs, never hand-edited; `--rebuild` throws away what is there and starts again.
|
|
|
|
Scripts/Authoring/build_region.sh # the whole thing, one batch of tiles at a time
|
|
|
|
-AllowCommandletRendering matters: the landscapes' render heightmaps come from the edit-layer merge on the GPU,
|
|
and a commandlet without it silently skips that, leaving landscapes with collision but no visible surface.
|
|
|
|
**Build it in batches.** A landscape of a hundred components costs about 1.5 GB of resident memory that is
|
|
never given back while the level is open, so a single process asked for all thirty-six reached 14.7 GB by the
|
|
ninth and would have run the machine out of commit long before the last. The script is therefore incremental:
|
|
it adds the tiles it is told to, saves, and exits, and a later run adds more to the same level. Six at a time
|
|
fits comfortably.
|
|
|
|
... -script=.../create_region_world.py -AllowCommandletRendering -abslog=<abs>/region.log -- --rebuild --tiles 0,0 1,0 2,0 3,0 4,0 5,0
|
|
... the same again with --tiles 0,1 1,1 ... and no --rebuild, once per row
|
|
|
|
`--rebuild` empties the level first and sweeps its stale actor packages afterwards; it belongs on the *first*
|
|
batch only. Without it the level is loaded and added to, tiles that are already in it are skipped, and the
|
|
sweep is not run — it deletes any actor package that does not belong to a currently loaded actor, which in an
|
|
append is every landscape the earlier batches built. Scripts/Authoring/build_region.sh runs the whole sequence.
|
|
|
|
One landscape per tile, rather than one landscape of 15301 vertices, because 234 M vertices is not an import
|
|
the engine will take in one piece. The tiles share their edge vertices and the tile files are bit-identical
|
|
along those edges (generate_region_tiles.py samples every vertex from its global position), so the landscapes
|
|
meet exactly; nothing here blends or stitches. Each tile is placed by its own centre, which is what the
|
|
landscape library takes as its Location.
|
|
"""
|
|
import os
|
|
import shutil
|
|
import sys
|
|
|
|
import unreal
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
sys.path.insert(0, os.path.join(HERE, ".pylib"))
|
|
import heightmap_io # noqa: E402
|
|
import rocky_meadows # noqa: E402
|
|
from region_manifest import load_manifest # noqa: E402
|
|
|
|
manifest = load_manifest()
|
|
LEVEL_PATH = manifest.level
|
|
|
|
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
|
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
|
asset_lib = unreal.EditorAssetLibrary
|
|
|
|
KEEP_CLASSES = {"WorldSettings", "WorldDataLayers", "WorldPartitionMiniMap"} # what a fresh partitioned level has
|
|
|
|
|
|
def spawn(actor_class, label, location=unreal.Vector(0, 0, 0), rotation=unreal.Rotator(0, 0, 0)):
|
|
actor = actor_subsystem.spawn_actor_from_class(actor_class, location, rotation)
|
|
actor.set_actor_label(label)
|
|
return actor
|
|
|
|
|
|
def parse_args():
|
|
"""--tiles TX,TY ..., --rebuild and --level, read out of sys.argv because UE puts its own arguments there
|
|
too. `--level /Game/Maps/L_Something` builds the same tiles into a level of its own, which is how a
|
|
dressing or material change is looked at on four tiles rather than on all ninety-eight."""
|
|
global LEVEL_PATH
|
|
argv = sys.argv[1:]
|
|
rebuild = "--rebuild" in argv
|
|
if "--level" in argv:
|
|
LEVEL_PATH = argv[argv.index("--level") + 1]
|
|
chosen = list(manifest.tiles())
|
|
if "--tiles" in argv:
|
|
spec = []
|
|
for item in argv[argv.index("--tiles") + 1:]:
|
|
if item.startswith("-"):
|
|
break
|
|
spec.append(item)
|
|
chosen = []
|
|
for item in spec:
|
|
tx, ty = (int(part) for part in item.split(","))
|
|
if not (0 <= tx < manifest.tiles_x and 0 <= ty < manifest.tiles_y):
|
|
raise RuntimeError(f"tile {item} is outside the {manifest.tiles_x}x{manifest.tiles_y} grid")
|
|
chosen.append((tx, ty))
|
|
return chosen, rebuild
|
|
|
|
|
|
def ensure_tiles(chosen):
|
|
"""The chosen tiles' files present and the right size, or generate the missing ones. A tile whose height map
|
|
is the wrong resolution means the manifest changed since it was written."""
|
|
wanted = manifest.vertices_per_tile
|
|
missing = []
|
|
for tx, ty in chosen:
|
|
if any(not os.path.isfile(f) for f in manifest.tile_files(tx, ty)):
|
|
missing.append((tx, ty))
|
|
continue
|
|
width, height, depth, _ = heightmap_io.read_png_header(manifest.height_path(tx, ty))
|
|
if (width, height, depth) != (wanted, wanted, 16):
|
|
unreal.log(f"{manifest.tile_name(tx, ty)} is {width}x{height} at {depth} bit, the manifest wants "
|
|
f"{wanted} at 16: regenerating it")
|
|
missing.append((tx, ty))
|
|
if not missing:
|
|
return
|
|
unreal.log(f"generating {len(missing)} tiles")
|
|
import generate_region_tiles
|
|
generate_region_tiles.main(["--tiles"] + [f"{tx},{ty}" for tx, ty in missing])
|
|
|
|
|
|
def existing_labels():
|
|
"""Actor labels already in the level, including actors world partition has not loaded. The unloaded ones
|
|
are read out of the asset registry rather than the world: an append must not create a second landscape for
|
|
a tile an earlier batch already built, and in a fresh commandlet almost none of them is loaded."""
|
|
labels = {actor.get_actor_label() for actor in actor_subsystem.get_all_level_actors()}
|
|
folder = "/Game/__ExternalActors__/" + LEVEL_PATH.replace("/Game/", "", 1)
|
|
try:
|
|
packages = asset_lib.list_assets(folder, recursive=True, include_folder=False) or []
|
|
except Exception as error:
|
|
unreal.log_warning(f"could not list {folder} ({error}); only loaded actors will be seen, so an append "
|
|
f"may duplicate a landscape")
|
|
return labels
|
|
for path in packages:
|
|
try:
|
|
data = asset_lib.find_asset_data(path)
|
|
label = data.get_tag_value("ActorLabel") if data else None
|
|
except Exception:
|
|
label = None
|
|
if label:
|
|
labels.add(str(label))
|
|
return labels
|
|
|
|
|
|
def content_path():
|
|
return os.path.abspath(unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_content_dir()))
|
|
|
|
|
|
def external_actor_dir():
|
|
return os.path.join(content_path(), "__ExternalActors__", LEVEL_PATH.replace("/Game/", "", 1))
|
|
|
|
|
|
def level_file():
|
|
return os.path.join(content_path(), LEVEL_PATH.replace("/Game/", "", 1) + ".umap")
|
|
|
|
|
|
def require_level_writable():
|
|
"""Refuse to start if another process holds the .umap, because the save is the *last* thing this script
|
|
does and `--rebuild` is the first.
|
|
|
|
An editor with the level open keeps a Windows write lock on it. The save then fails with `MoveFile ...
|
|
(Error Code 32)`, the run raises, and the level has already been emptied: on 2026-09-20 a full rebuild
|
|
left twelve of ninety-eight tiles in the level and everything else gone, which reads exactly like a
|
|
corrupted world. Checked here, before anything is destroyed, an open editor costs a message instead.
|
|
|
|
`r+b` is the probe rather than a test save: it asks the operating system the same question a save asks,
|
|
without writing a byte. A level that does not exist yet is not locked, and a first build is what creates
|
|
it. Close the editor, or load another level in it, and run again."""
|
|
path = level_file()
|
|
if not os.path.exists(path):
|
|
return
|
|
try:
|
|
open(path, "r+b").close()
|
|
except OSError as error:
|
|
raise RuntimeError(
|
|
f"{path} is locked by another process ({error.__class__.__name__}): an editor with "
|
|
f"{LEVEL_PATH} open holds it, and the save at the end of this run would fail after the level "
|
|
f"had already been emptied. Close the editor or load another level in it, then run again."
|
|
) from error
|
|
|
|
|
|
def prepare_level(rebuild):
|
|
"""The level to build into: emptied when rebuilding, loaded and kept when appending.
|
|
|
|
An existing level is loaded and emptied rather than deleted and recreated:
|
|
recreating it resaves the HLOD layer assets, and an open editor that has had the level loaded keeps those
|
|
files locked, so the recreation ends in a nameless temp world whose save goes nowhere (see create_world.py,
|
|
2026-09-16). Streaming proxies the editor does not load cannot be destroyed here; sweep_stale_actor_packages
|
|
removes their files after the save."""
|
|
if asset_lib.does_asset_exist(LEVEL_PATH):
|
|
if not level_subsystem.load_level(LEVEL_PATH):
|
|
raise RuntimeError(f"could not load {LEVEL_PATH}")
|
|
if rebuild:
|
|
removed = 0
|
|
for actor in actor_subsystem.get_all_level_actors():
|
|
if actor.get_class().get_name() in KEEP_CLASSES:
|
|
continue
|
|
actor_subsystem.destroy_actor(actor)
|
|
removed += 1
|
|
unreal.log(f"{LEVEL_PATH} loaded and emptied: {removed} loaded actors removed")
|
|
else:
|
|
unreal.log(f"{LEVEL_PATH} loaded to be added to")
|
|
else:
|
|
folder = external_actor_dir()
|
|
if os.path.isdir(folder):
|
|
shutil.rmtree(folder, ignore_errors=True)
|
|
level_subsystem.new_level(LEVEL_PATH, True) # world-partitioned: 936 km2 must stream
|
|
world_path = editor_subsystem.get_editor_world().get_path_name()
|
|
if not world_path.startswith(LEVEL_PATH):
|
|
raise RuntimeError(f"the editor world is {world_path}, not {LEVEL_PATH}; a save would go nowhere")
|
|
|
|
|
|
def sweep_stale_actor_packages():
|
|
"""After the save: every file under the level's external actor folder that belongs to none of the level's
|
|
actors is a leftover of an earlier build (a proxy the editor never loaded, so it could not be destroyed).
|
|
Left there, it would come back as a second landscape the next time the level loads."""
|
|
keep = set()
|
|
for actor in actor_subsystem.get_all_level_actors():
|
|
package = actor.get_outermost().get_path_name()
|
|
keep.add(os.path.normcase(os.path.join(content_path(), package.replace("/Game/", "", 1).replace("/", os.sep) + ".uasset")))
|
|
removed, failed = 0, 0
|
|
for root, _, files in os.walk(external_actor_dir()):
|
|
for name in files:
|
|
path = os.path.join(root, name)
|
|
if os.path.normcase(path) in keep:
|
|
continue
|
|
try:
|
|
os.remove(path)
|
|
removed += 1
|
|
except OSError as error:
|
|
failed += 1
|
|
unreal.log_warning(f"stale actor package {path} could not be removed: {error}")
|
|
unreal.log(f"stale actor packages: {removed} removed, {failed} left, {len(keep)} kept")
|
|
if failed:
|
|
raise RuntimeError(f"{failed} stale actor packages remain under {external_actor_dir()}; the level would load duplicates")
|
|
|
|
|
|
def create_landscapes(chosen, already):
|
|
"""One landscape per tile, each centred on its own square of the window. A tile whose landscape is already
|
|
in the level is skipped, so a batch can be rerun."""
|
|
material = rocky_meadows.load_or_raise(rocky_meadows.LANDSCAPE_MATERIAL)
|
|
world = editor_subsystem.get_editor_world()
|
|
unreal.log(f"landscapes: {manifest.describe()}")
|
|
todo = [(tx, ty) for tx, ty in chosen if manifest.tile_name(tx, ty) not in already]
|
|
skipped = len(chosen) - len(todo)
|
|
if skipped:
|
|
unreal.log(f"{skipped} of {len(chosen)} tiles are already in the level and are left alone")
|
|
built = []
|
|
for index, (tx, ty) in enumerate(todo, 1):
|
|
weightmaps = rocky_meadows.weightmap_entries(
|
|
lambda name, tx=tx, ty=ty: manifest.weight_path(tx, ty, name),
|
|
[layer.name for layer in manifest.enabled_layers])
|
|
cx, cy = manifest.tile_centre_cm(tx, ty)
|
|
landscape = unreal.LandscapeAuthoringLibrary.create_landscape_from_heightmap(
|
|
world, manifest.height_path(tx, ty), weightmaps, material,
|
|
unreal.Vector(cx, cy, manifest.landscape_z_cm),
|
|
unreal.Vector(manifest.quad_cm, manifest.quad_cm, manifest.z_scale),
|
|
manifest.streaming_grid_components)
|
|
if not landscape:
|
|
raise RuntimeError(f"landscape {manifest.tile_name(tx, ty)} failed; see LogSaltyEditor")
|
|
landscape.set_actor_label(manifest.tile_name(tx, ty))
|
|
built.append(landscape)
|
|
unreal.log(f" [{index:2d}/{len(todo)}] {manifest.tile_name(tx, ty)} at "
|
|
f"({cx / 100000:+.2f}, {cy / 100000:+.2f}) km")
|
|
return built
|
|
|
|
|
|
def pad_height_cm():
|
|
"""World Z of the spawn pad, read out of the heightmap rather than traced for it.
|
|
|
|
A trace is the wrong instrument here and it put the starts 180 m underground. The landscape's collision is
|
|
not reliably present in a commandlet, the sea plane at Z 0 *is* - it keeps collision so a walk off the coast
|
|
is a walk - so the trace hit the sea and returned 0.0, which is not None and so sailed past the guard meant
|
|
to catch exactly this. The heightmap cannot be wrong about its own pad. Which tile the centre falls in, and
|
|
where inside it, is worked out rather than assumed: on an odd grid like 14 x 7 the centre is halfway down a
|
|
tile rather than on a corner, and a tile's PNG has world X across its columns and world Y down its rows."""
|
|
tx, ty, i, j = manifest.centre_vertex()
|
|
path = manifest.height_path(tx, ty)
|
|
value = int(heightmap_io.read_png(path)[j, i])
|
|
metres = manifest.elevation_min_m + value / 65535.0 * manifest.elevation_span_m
|
|
unreal.log(f"spawn pad at {metres:.2f} m, from {os.path.basename(path)} at [row {j}, col {i}]")
|
|
return metres * 100.0
|
|
|
|
|
|
def ensure_player_starts(already):
|
|
"""Two starts on the pad at the centre of the window, so two PIE clients spawn without a warning. Existing
|
|
ones are moved rather than left alone, so a rerun repairs a level whose starts are in the wrong place."""
|
|
del already # the starts are found by class below; a label in `already` may belong to an unloaded actor
|
|
z = pad_height_cm() + 120.0
|
|
existing = {actor.get_actor_label(): actor for actor in actor_subsystem.get_all_level_actors()
|
|
if actor.get_class().get_name() == "PlayerStart"}
|
|
for index, y in enumerate((-200.0, 200.0)):
|
|
label = f"World_PlayerStart_{index}"
|
|
location = unreal.Vector(0.0, y, z)
|
|
actor = existing.get(label)
|
|
if actor is None:
|
|
spawn(unreal.PlayerStart, label, location)
|
|
unreal.log(f" {label} placed at {z / 100:.2f} m")
|
|
elif abs(actor.get_actor_location().z - z) > 1.0:
|
|
was = actor.get_actor_location().z / 100.0
|
|
actor.set_actor_location(location, False, True)
|
|
unreal.log(f" {label} moved from {was:.2f} m to {z / 100:.2f} m")
|
|
|
|
|
|
def ensure_dressing(already):
|
|
"""The sky kit and the sea, once. Every actor the dressing spawns carries a label, so an append recognises
|
|
what is already there by label and does not spawn a second sun."""
|
|
if "World_Sun" not in already:
|
|
rocky_meadows.dress(spawn, manifest)
|
|
if "World_Sea_Proto" not in already:
|
|
rocky_meadows.ensure_sea(spawn, manifest)
|
|
|
|
|
|
def main():
|
|
chosen, rebuild = parse_args()
|
|
unreal.log(f"{len(chosen)} tile(s) requested{', rebuilding the level' if rebuild else ', appending'}")
|
|
require_level_writable() # before prepare_level, which is what empties it
|
|
ensure_tiles(chosen)
|
|
prepare_level(rebuild)
|
|
already = set() if rebuild else existing_labels()
|
|
landscapes = create_landscapes(chosen, already)
|
|
ensure_dressing(already)
|
|
ensure_player_starts(already)
|
|
if not level_subsystem.save_current_level():
|
|
raise RuntimeError(f"saving {LEVEL_PATH} failed; see the log above")
|
|
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
|
if rebuild:
|
|
sweep_stale_actor_packages() # an append would sweep away every landscape it did not load
|
|
unreal.log(f"{LEVEL_PATH} saved: {len(landscapes)} landscape(s) added this run, "
|
|
f"{manifest.tile_side_m / 1000:.2f} km each")
|
|
|
|
|
|
main()
|