"""Builds /Game/Maps/L_World: a world-partitioned level with the landscape that RawContent/World/World.json describes, imported from the PNGs in RawContent/World/Heightmaps/ and dressed as Elite_RockyMeadows dresses its demo maps: the pack's landscape material and three paint layers, its sun with the moving cloud shadows, its skybox dome, sky light, height fog and post-process settings (numbers read from the pack's maps with dump_level.py), plus a sea plane at sea level and two player starts on the spawn pad. Rebuilds from scratch every run; the level is a product of this script, the manifest and the PNGs, never hand-edited. UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script=Scripts/Authoring/create_world.py -AllowCommandletRendering -AllowCommandletRendering matters: the landscape's render heightmaps come from the edit-layer merge on the GPU, and a commandlet without it silently skips that, leaving a landscape with collision but no visible surface. Swapping the terrain: point the manifest's source at a real heightmap (or change the seed), rerun generate_heightmap.py, then rerun this. If the PNGs are missing or the wrong size this script generates them first. The landscape's component layout is the engine's choice for the resolution: 4081 vertices a side gives 16x16 components of 255 quads, each its own streaming proxy (see RawContent/World/README.md before changing it). """ 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 from world_manifest import LAYER_FILES, load_manifest # noqa: E402 PACK = "/Game/Elite_RockyMeadows" LANDSCAPE_MATERIAL = f"{PACK}/Materials/M_Landscape_Main_Inst_RockyMeadows02" LAYER_INFOS = { # paint layer name -> the pack's layer info asset; the weightmap file comes from the manifest module "Base_Layer": f"{PACK}/Materials/Material_Layers/Base_Layer_LayerInfo", "Layer_02": f"{PACK}/Materials/Material_Layers/Layer_02_LayerInfo", "Layer_03": f"{PACK}/Materials/Material_Layers/Layer_03_LayerInfo", } SKYBOX_MESH = f"{PACK}/Materials/Skybox/Skybox_Mesh" SKYBOX_MATERIAL = f"{PACK}/Materials/Skybox/M_Skybox_Inst_RockyMeadows" CLOUD_SHADOWS = f"{PACK}/Materials/Light_Material/M_Cloud_Shadows_Inst02" SEA_MESH = "/Engine/BasicShapes/Plane" SEA_MATERIAL = "/Engine/EngineMaterials/WaterMaterial" # The pack's Rocky_Meadows_01 demo map, as dump_level.py read it. Distances are scaled up where the demo's # 8 km scene would otherwise cut the effect short on a 14 km world. PACK_SUN = { "rotation": unreal.Rotator(roll=-51.273, pitch=-31.342, yaw=36.413), # keyword arguments: positional order is roll, pitch, yaw "intensity": 9.2368, "light_color": unreal.Color(r=223, g=245, b=255, a=255), "light_function_scale": unreal.Vector(1024.0, 1024.0, 1024.0), "light_function_fade_distance": 2000000.0, # the demo fades its cloud shadows out at 2 km; keep them to 20 km here "dynamic_shadow_distance_movable_light": 200000.0, "cascade_distribution_exponent": 3.0, "light_source_angle": 0.5357, "shadow_bias": 0.5, } PACK_SKY_LIGHT = {"intensity": 1.5, "lower_hemisphere_color": unreal.LinearColor(0.0, 0.0, 0.0, 1.0), "sky_distance_threshold": 150000.0} PACK_FOG = { "fog_density": 0.027143, "fog_height_falloff": 0.039076, "fog_inscattering_luminance": unreal.LinearColor(0.238715, 0.329426, 0.458333, 1.0), "directional_inscattering_luminance": unreal.LinearColor(0.25, 0.20832, 0.154948, 1.0), "directional_inscattering_exponent": 4.0, "directional_inscattering_start_distance": 10000.0, } PACK_POST_PROCESS = { # FPostProcessSettings field -> value; the override flag of each is set alongside "auto_exposure_min_brightness": 1.0, "auto_exposure_max_brightness": 1.0, "auto_exposure_bias": 0.263034, "color_saturation": unreal.Vector4(1.0, 1.0, 1.0, 1.25), } 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 def load_or_raise(asset_path): asset = unreal.load_asset(asset_path) if not asset: raise RuntimeError(f"{asset_path} not found; is the pack in Content/?") return asset def set_properties(target, values): for name, value in values.items(): target.set_editor_property(name, value) def keep_always_loaded(actor): """World partition streams actors by their bounds; the sky dome and the sea are the whole world and must not stream at all.""" try: actor.set_editor_property("is_spatially_loaded", False) except Exception as error: unreal.log_warning(f"{actor.get_actor_label()}: could not clear is_spatially_loaded ({error}); it will stream by bounds") def ensure_heightmaps(): files = [manifest.heightmap_path] + [manifest.weightmap_path(name) for name in LAYER_FILES] reason = None missing = [f for f in files if not os.path.isfile(f)] if missing: reason = f"missing {missing}" else: width, height, depth, _ = heightmap_io.read_png_header(manifest.heightmap_path) if (width, height, depth) != (manifest.vertices_per_side, manifest.vertices_per_side, 16): reason = f"{manifest.heightmap_path} is {width}x{height} at {depth} bit, the manifest wants {manifest.vertices_per_side} at 16" if reason is None: return unreal.log(f"generating heightmaps: {reason}") import generate_heightmap generate_heightmap.main([]) 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 KEEP_CLASSES = {"WorldSettings", "WorldDataLayers", "WorldPartitionMiniMap"} # what a fresh partitioned level has 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 prepare_level(): """An empty level to build into. An existing level is loaded and emptied rather than deleted and recreated: recreating it resaves its two 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 (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}") 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: folder = external_actor_dir() if os.path.isdir(folder): shutil.rmtree(folder, ignore_errors=True) level_subsystem.new_level(LEVEL_PATH, True) # world-partitioned: 200 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() # /Game/__ExternalActors__/Maps/L_World/1/YM/XXXX 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_landscape(): material = load_or_raise(LANDSCAPE_MATERIAL) weightmaps = [] for layer_name, asset_path in LAYER_INFOS.items(): entry = unreal.LandscapeAuthoringWeightmap() entry.set_editor_property("layer_info", load_or_raise(asset_path)) entry.set_editor_property("file", manifest.weightmap_path(layer_name)) weightmaps.append(entry) world = editor_subsystem.get_editor_world() unreal.log(f"landscape: {manifest.describe()}") landscape = unreal.LandscapeAuthoringLibrary.create_landscape_from_heightmap( world, manifest.heightmap_path, weightmaps, material, unreal.Vector(0.0, 0.0, manifest.landscape_z_cm), unreal.Vector(manifest.quad_cm, manifest.quad_cm, manifest.z_scale), manifest.streaming_grid_components) if not landscape: raise RuntimeError("landscape creation failed; see LogSaltyEditor") return landscape def ground_height_at(x, y, fallback): world = editor_subsystem.get_editor_world() reach = manifest.elevation_max_m * 100.0 + 100000.0 start, end = unreal.Vector(x, y, reach), unreal.Vector(x, y, -reach) hit = unreal.SystemLibrary.line_trace_single(world, start, end, unreal.TraceTypeQuery.ECC_VISIBILITY, False, [], unreal.DrawDebugTrace.NONE, True) if hit and hit.to_tuple()[0]: return hit.to_tuple()[4].z unreal.log_warning("no landscape hit under the spawn; using the fallback height") return fallback def dress_with_rocky_meadows(): """The pack's sky, sun, fog and grade, so L_World reads like its demo maps.""" sun = spawn(unreal.DirectionalLight, "World_Sun", unreal.Vector(0, 0, 50000), PACK_SUN["rotation"]) light = sun.light_component light.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE) set_properties(light, {k: v for k, v in PACK_SUN.items() if k != "rotation"}) light.set_editor_property("light_function_material", load_or_raise(CLOUD_SHADOWS)) sky_light = spawn(unreal.SkyLight, "World_SkyLight", unreal.Vector(0, 0, 50000)) sky_light.light_component.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE) set_properties(sky_light.light_component, PACK_SKY_LIGHT) # The pack's skybox is a textured dome mesh, not a sky atmosphere. Scale it so the whole world sits inside # with room to spare, and sink its centre so the dome's equator is below the sea from any shore. mesh = load_or_raise(SKYBOX_MESH) native_radius = max(mesh.get_bounds().sphere_radius, 1.0) radius = manifest.side_m * 100.0 * 1.1 skybox = spawn(unreal.StaticMeshActor, "World_Skybox", unreal.Vector(0.0, 0.0, -radius * 0.25)) skybox.static_mesh_component.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE) skybox.static_mesh_component.set_static_mesh(mesh) skybox.static_mesh_component.set_material(0, load_or_raise(SKYBOX_MATERIAL)) skybox.static_mesh_component.set_editor_property("cast_shadow", False) skybox.static_mesh_component.set_collision_enabled(unreal.CollisionEnabled.NO_COLLISION) skybox.set_actor_scale3d(unreal.Vector(radius / native_radius, radius / native_radius, radius / native_radius)) keep_always_loaded(skybox) unreal.log(f"skybox dome radius {radius / 100000:.1f} km (mesh radius {native_radius:g} cm, scale {radius / native_radius:g})") fog = spawn(unreal.ExponentialHeightFog, "World_Fog", unreal.Vector(0.0, 0.0, manifest.sea_level_z_cm)) set_properties(fog.component, PACK_FOG) post = spawn(unreal.PostProcessVolume, "World_PostProcess") post.set_editor_property("unbound", True) settings = post.get_editor_property("settings") for field, value in PACK_POST_PROCESS.items(): settings.set_editor_property(f"override_{field}", True) settings.set_editor_property(field, value) post.set_editor_property("settings", settings) def ensure_sea(): """A flat plane at sea level with the engine's water material: enough to read as sea until a water body replaces it. It keeps collision so a walk off the coast is a walk, not a fall to the sea floor.""" mesh = load_or_raise(SEA_MESH) material = unreal.load_asset(SEA_MATERIAL) or load_or_raise("/Engine/BasicShapes/BasicShapeMaterial") side = manifest.side_m * 100.0 * 1.5 / 100.0 # the plane is 100 cm; cover the world and the sea beyond its edge sea = spawn(unreal.StaticMeshActor, "World_Sea_Proto", unreal.Vector(0.0, 0.0, manifest.sea_level_z_cm)) sea.static_mesh_component.set_static_mesh(mesh) sea.static_mesh_component.set_material(0, material) sea.static_mesh_component.set_editor_property("cast_shadow", False) sea.set_actor_scale3d(unreal.Vector(side, side, 1.0)) keep_always_loaded(sea) def ensure_player_starts(): # The heightmap has a flat pad at its centre; two starts so two PIE clients spawn without a warning. fallback = manifest.sea_level_z_cm + 15000.0 z = ground_height_at(0.0, 0.0, fallback) + 120.0 for index, y in enumerate((-200.0, 200.0)): spawn(unreal.PlayerStart, f"World_PlayerStart_{index}", unreal.Vector(0.0, y, z)) def main(): ensure_heightmaps() prepare_level() landscape = create_landscape() dress_with_rocky_meadows() ensure_sea() ensure_player_starts() 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) sweep_stale_actor_packages() unreal.log(f"{LEVEL_PATH} saved with landscape {landscape.get_actor_label()}") main()