Tooling
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user